"""Resource management for MkDocs documentation."""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin, urlparse
import aiofiles
import frontmatter
from mcp.types import Resource, TextContent
logger = logging.getLogger(__name__)
class DocumentationResourceManager:
"""Manages MkDocs documentation resources for MCP server."""
def __init__(self, docs_path: Path) -> None:
"""Initialize the resource manager.
Args:
docs_path: Path to the documentation directory
"""
self.docs_path = docs_path
self._resource_cache: Dict[str, Resource] = {}
async def list_resources(self) -> List[Resource]:
"""List all available documentation resources.
Returns:
List of Resource objects representing documentation files
"""
logger.debug(f"Scanning docs directory: {self.docs_path}")
resources = []
if not self.docs_path.exists():
logger.warning(f"Docs directory does not exist: {self.docs_path}")
return resources
# Find all markdown files recursively
for md_file in self.docs_path.rglob("*.md"):
try:
resource = await self._create_resource(md_file)
resources.append(resource)
# Cache the resource
self._resource_cache[resource.uri] = resource
except Exception as e:
logger.error(f"Error processing {md_file}: {e}")
continue
logger.info(f"Found {len(resources)} documentation resources")
return sorted(resources, key=lambda r: r.uri)
async def read_resource(self, uri: str) -> TextContent:
"""Read the content of a documentation resource.
Args:
uri: Resource URI to read
Returns:
TextContent with the resource content
Raises:
FileNotFoundError: If the resource doesn't exist
ValueError: If the URI format is invalid
"""
logger.debug(f"Reading resource: {uri}")
# Parse the URI to get the file path
file_path = self._uri_to_path(uri)
if not file_path.exists():
raise FileNotFoundError(f"Resource not found: {uri}")
if not file_path.is_file():
raise ValueError(f"Resource is not a file: {uri}")
# Read the file content
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
content = await f.read()
# Parse frontmatter if present
try:
post = frontmatter.loads(content)
metadata = post.metadata
body = post.content
# Create enhanced content with metadata
if metadata:
metadata_text = "---\n"
for key, value in metadata.items():
metadata_text += f"{key}: {value}\n"
metadata_text += "---\n\n"
full_content = metadata_text + body
else:
full_content = body
except Exception as e:
logger.debug(f"Error parsing frontmatter for {uri}: {e}")
full_content = content
return TextContent(
type="text",
text=full_content,
)
async def _create_resource(self, file_path: Path) -> Resource:
"""Create a Resource object from a file path.
Args:
file_path: Path to the markdown file
Returns:
Resource object
"""
# Create URI from relative path
relative_path = file_path.relative_to(self.docs_path)
uri = f"docs://{relative_path.as_posix()}"
# Read file to get title and description
try:
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
content = await f.read()
# Parse frontmatter for metadata
post = frontmatter.loads(content)
metadata = post.metadata
body = post.content
# Extract title
title = metadata.get('title')
if not title:
# Try to extract from first heading
lines = body.split('\n')
for line in lines:
line = line.strip()
if line.startswith('# '):
title = line[2:].strip()
break
if not title:
# Use filename as fallback
title = file_path.stem.replace('-', ' ').replace('_', ' ').title()
# Extract description
description = metadata.get('description')
if not description:
# Try to extract from second line or first paragraph
lines = [line.strip() for line in body.split('\n') if line.strip()]
if len(lines) > 1:
# Skip the title line if it exists
start_idx = 1 if lines[0].startswith('#') else 0
if start_idx < len(lines):
potential_desc = lines[start_idx]
if not potential_desc.startswith('#') and len(potential_desc) > 10:
description = potential_desc[:200] + "..." if len(potential_desc) > 200 else potential_desc
if not description:
description = f"Documentation page: {title}"
except Exception as e:
logger.debug(f"Error reading metadata from {file_path}: {e}")
title = file_path.stem.replace('-', ' ').replace('_', ' ').title()
description = f"Documentation file: {relative_path}"
return Resource(
uri=uri,
name=title,
description=description,
mimeType="text/markdown",
)
def _uri_to_path(self, uri: str) -> Path:
"""Convert a resource URI to a file path.
Args:
uri: Resource URI (e.g., "docs://getting-started/installation.md")
Returns:
Path object pointing to the file
Raises:
ValueError: If the URI format is invalid
"""
parsed = urlparse(uri)
if parsed.scheme != "docs":
raise ValueError(f"Invalid URI scheme: {parsed.scheme}")
if not parsed.path or parsed.path.startswith('/'):
# Remove leading slash if present
path_part = parsed.path[1:] if parsed.path.startswith('/') else parsed.path
else:
path_part = parsed.path
if not path_part:
raise ValueError(f"Empty path in URI: {uri}")
return self.docs_path / path_part
def _path_to_uri(self, file_path: Path) -> str:
"""Convert a file path to a resource URI.
Args:
file_path: Path to the file
Returns:
Resource URI
"""
relative_path = file_path.relative_to(self.docs_path)
return f"docs://{relative_path.as_posix()}"