# MCP Server for local-context7 Docs API with Git Sources Support """ MCP server providing Context7-style tools for interacting with the local docs API. This server exposes 6 tools: - resolve-library-id: Find libraries matching a name (with /local/ prefix) - get-library-docs: Retrieve documentation from a library - list-libraries: List all discovered libraries - search-docs: Semantic search across documents - refresh-library: Re-ingest documents for a library or all libraries - sync-sources: Sync git repositories from configuration file """ import asyncio import os from typing import Optional, List, Dict, Any try: import httpx except ImportError: httpx = None try: from fastmcp import FastMCP except ImportError: class _Tool: def __init__(self, name: str): self.name = name class FastMCP: """Import-time fallback used by tests when fastmcp is not installed.""" def __init__(self, *args, **kwargs): self.tools = [] def tool(self): def decorator(func): self.tools.append(_Tool(func.__name__)) return func return decorator def run(self, *args, **kwargs): raise RuntimeError("fastmcp is not installed") # Environment configuration DOCS_API_URL = os.getenv("DOCS_API_URL", "http://docs-api:${HOST_PORT:-8787}") MCP_API_KEY = os.getenv("MCP_API_KEY", "") def strip_local_prefix(lib_id: str) -> str: """Strip /local/ prefix from library ID for API calls.""" if lib_id.startswith("/local/"): return lib_id[7:] # Remove "/local/" prefix return lib_id # Create FastMCP instance with tools mcp = FastMCP("context7-docs", root_path="/app") @mcp.tool() async def resolve_library_id(library_name: str) -> List[Dict[str, Any]]: """ Resolve a library name to Context7-style candidates. Searches the docs API for libraries matching the given name (partial match). Args: libraryName: The library name to search for (e.g., "foundryvtt") Returns: List of candidate libraries with /local/ prefix in ID: [ { "id": "/local/foundryvtt", "name": "Foundry VTT", "description": "Fantasy tabletop virtual table...", "source": "local" }, ... ] """ try: if httpx is None: raise RuntimeError("httpx is not installed") async with httpx.AsyncClient(base_url=DOCS_API_URL, timeout=60.0) as client: response = await client.get("/libraries/search", params={"q": library_name}) if response.status_code == 200: data = response.json() return data.get("matches", []) else: raise Exception(f"API error: {response.status_code} - {response.text}") except Exception as e: print(f"Error resolving library '{library_name}': {e}") return [] @mcp.tool() async def get_library_docs(context7_compatible_library_id: str, topic: Optional[str] = None, tokens: int = 8000) -> str: """ Retrieve documentation content from a library. Args: context7_compatible_library_id: The Context7-style library ID (with /local/ prefix) topic: Optional topic to search within the library (default: None - returns most relevant content) tokens: Maximum tokens to include in response (default: 8000) Returns: Markdown string containing the documentation content Example: get_library_docs("/local/foundryvtt", topic="hooks", tokens=8000) """ try: if httpx is None: raise RuntimeError("httpx is not installed") # Strip /local/ prefix for API call library_id = strip_local_prefix(context7_compatible_library_id) async with httpx.AsyncClient(base_url=DOCS_API_URL, timeout=60.0) as client: params = {"tokens": tokens} if topic: params["topic"] = topic response = await client.get(f"/libraries/{library_id}/docs", params=params) if response.status_code == 200: data = response.json() return data.get("content", "") else: raise Exception(f"API error: {response.status_code} - {response.text}") except Exception as e: print(f"Error getting library docs for '{context7_compatible_library_id}': {e}") return f"Error retrieving documentation: {str(e)}" @mcp.tool() async def list_libraries() -> List[Dict[str, Any]]: """ List all discovered libraries in the system. Returns: List of library objects with metadata: [ { "id": "/local/foundryvtt", "name": "Foundry VTT", "description": "...", "source": "local" }, ... ] """ try: if httpx is None: raise RuntimeError("httpx is not installed") async with httpx.AsyncClient(base_url=DOCS_API_URL, timeout=60.0) as client: response = await client.get("/libraries") if response.status_code == 200: data = response.json() return data.get("libraries", []) else: raise Exception(f"API error: {response.status_code} - {response.text}") except Exception as e: print(f"Error listing libraries: {e}") return [] @mcp.tool() async def search_docs(query: str, library_id: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]: """ Perform semantic search across documents. Args: query: The search query string library_id: Optional library ID filter (with /local/ prefix). If None, searches all libraries. limit: Maximum number of results to return (default: 10) Returns: List of search results with content snippets: [ { "id": "...", "score": 0.123, "library_id": "...", "path": "...", "title": "...", "chunk_index": 0 }, ... ] """ try: if httpx is None: raise RuntimeError("httpx is not installed") async with httpx.AsyncClient(base_url=DOCS_API_URL, timeout=60.0) as client: payload = {"query": query, "limit": limit} if library_id: payload["library_id"] = strip_local_prefix(library_id) response = await client.post("/search", json=payload) if response.status_code == 200: data = response.json() return data.get("results", []) else: raise Exception(f"API error: {response.status_code} - {response.text}") except Exception as e: print(f"Error searching for query '{query}': {e}") return [] @mcp.tool() async def refresh_library(library_id: Optional[str] = None) -> Dict[str, Any]: """ Re-ingest documents for a library or all libraries. Args: library_id: If provided, re-ingests only this library (with /local/ prefix). If None, ingests all libraries. Returns: Ingestion result summary: { "total_libraries": 2, "successful": 2, "failed": 0, "total_chunks": 150 } """ try: if httpx is None: raise RuntimeError("httpx is not installed") async with httpx.AsyncClient(base_url=DOCS_API_URL, timeout=60.0) as client: response = await client.post("/ingest/all") if response.status_code == 200: data = response.json() return { "success": True, "total_libraries": data.get("total_libraries", 0), "successful": data.get("successful", 0), "failed": data.get("failed", 0), "total_chunks": data.get("total_chunks", 0) } else: raise Exception(f"API error: {response.status_code} - {response.text}") except Exception as e: print(f"Error refreshing library '{library_id or 'all'}': {e}") return {"success": False, "error": str(e)} @mcp.tool() async def sync_sources(override: bool = False) -> Dict[str, Any]: """ Sync all git repositories defined in the sources configuration file. Clones/updates each configured repository and ingests matching files into the vector store. Existing repos are updated to latest state unless override is true (clears existing repo before cloning). Args: override: If true, clears existing repo before cloning. Default: false Returns: Sync result summary: { "success": true, "total_sources": 2, "successful": 1, "failed": 1, "results": [ { "library_id": "foundryvtt", "success": true, "message": "...", "files_discovered": 450, "chunks_created": 2340, "vectors_added": 2340 }, ... ] } """ try: if httpx is None: raise RuntimeError("httpx is not installed") async with httpx.AsyncClient(base_url=DOCS_API_URL, timeout=60.0) as client: payload = {"override": override} if override else {} response = await client.post("/sources/sync", json=payload) if response.status_code == 200: data = response.json() return { "success": True, "total_sources": data.get("total_sources", 0), "successful": data.get("successful", 0), "failed": data.get("failed", 0), "results": data.get("results", []) } else: raise Exception(f"API error: {response.status_code} - {response.text}") except Exception as e: print(f"Error syncing git sources: {e}") return {"success": False, "error": str(e)} if __name__ == "__main__": # Run MCP server using streamable HTTP transport host = os.getenv("MCP_HOST", "0.0.0.0") port = int(os.getenv("MCP_PORT", 8788)) print(f"Starting MCP server on http://{host}:{port}") print("Tools available:") print(" - resolve-library-id(libraryName)") print(" - get-library-docs(context7_compatible_library_id, topic=None, tokens=8000)") print(" - list-libraries()") print(" - search_docs(query, library_id=None, limit=10)") print(" - refresh_library(library_id=None)") print(" - sync_sources(override=false)") if hasattr(mcp, "run"): mcp.run(transport="http", host=host, port=port) else: import uvicorn uvicorn.run(mcp, host=host, port=port)