338 lines
11 KiB
Python
338 lines
11 KiB
Python
# 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)
|