Initial DocsMCP stack

This commit is contained in:
george
2026-06-05 23:02:55 +01:00
commit 421b6f973a
51 changed files with 7414 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
# 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="streamable-http", host=host, port=port)
else:
import uvicorn
uvicorn.run(mcp, host=host, port=port)