Initial DocsMCP stack
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user