mcp server done
This commit is contained in:
@@ -2,14 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from mcp_server.docs_sync import check_catalog_parity
|
||||
from mcp_server.tools import invoke_tool, list_tools_payload
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_server import tools
|
||||
|
||||
|
||||
def _is_local_only() -> bool:
|
||||
@@ -20,73 +18,53 @@ def _guard_local() -> None:
|
||||
if not _is_local_only():
|
||||
return
|
||||
if os.environ.get("SSH_CONNECTION"):
|
||||
_write_response({"error": {"code": -32099, "message": "Remote access denied"}})
|
||||
sys.stderr.write("Remote access denied. Unset SSH_CONNECTION or set MCP_ALLOW_REMOTE.\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _write_response(payload: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps(payload) + "\n")
|
||||
sys.stdout.flush()
|
||||
mcp = FastMCP("NixOS Repo MCP", json_response=True)
|
||||
|
||||
|
||||
def handle_request(request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Dispatch a JSON-RPC request to the appropriate MCP handler."""
|
||||
method = request.get("method")
|
||||
params = request.get("params") or {}
|
||||
if method == "listTools":
|
||||
return {"result": list_tools_payload()}
|
||||
if method == "invokeTool":
|
||||
name = params.get("name") or ""
|
||||
args = params.get("args") or {}
|
||||
try:
|
||||
return {"result": invoke_tool(name, args)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"result": {
|
||||
"status": "failed",
|
||||
"output": f"Service unavailable: {exc}",
|
||||
"actions": ["retry locally", "call listTools to verify availability"],
|
||||
"docsAnchor": {},
|
||||
}
|
||||
}
|
||||
if method == "syncDocs":
|
||||
return {"result": check_catalog_parity()}
|
||||
return {
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": (
|
||||
f"Method '{method}' not found. Call listTools to discover supported methods."
|
||||
),
|
||||
}
|
||||
}
|
||||
@mcp.tool(name="show-constitution")
|
||||
def show_constitution() -> dict[str, object]:
|
||||
"""Display repository AI constitution for rule lookup."""
|
||||
return tools.invoke_tool("show-constitution", {})
|
||||
|
||||
|
||||
@mcp.tool(name="list-playbooks")
|
||||
def list_playbooks() -> dict[str, object]:
|
||||
"""List available playbooks under docs/playbooks."""
|
||||
return tools.invoke_tool("list-playbooks", {})
|
||||
|
||||
|
||||
@mcp.tool(name="show-reference")
|
||||
def show_reference() -> dict[str, object]:
|
||||
"""Show docs/reference/index.md for navigation guidance."""
|
||||
return tools.invoke_tool("show-reference", {})
|
||||
|
||||
|
||||
@mcp.tool(name="search-docs")
|
||||
def search_docs(query: str) -> dict[str, object]:
|
||||
"""Search docs for a query string."""
|
||||
return tools.invoke_tool("search-docs", {"query": query})
|
||||
|
||||
|
||||
@mcp.tool(name="list-mcp-tasks")
|
||||
def list_mcp_tasks() -> dict[str, object]:
|
||||
"""Show MCP feature task list from specs."""
|
||||
return tools.invoke_tool("list-mcp-tasks", {})
|
||||
|
||||
|
||||
@mcp.tool(name="sync-docs")
|
||||
def sync_docs() -> dict[str, object]:
|
||||
"""Compare tool catalog against documented anchors."""
|
||||
return tools.invoke_tool("sync-docs", {})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the MCP server in stdio mode."""
|
||||
_guard_local()
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
_write_response({"error": {"code": -32700, "message": "Parse error"}})
|
||||
continue
|
||||
try:
|
||||
response = handle_request(request)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_write_response(
|
||||
{
|
||||
"result": {
|
||||
"status": "failed",
|
||||
"output": f"Service unavailable: {exc}",
|
||||
"actions": ["retry locally", "call listTools to verify availability"],
|
||||
"docsAnchor": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
continue
|
||||
_write_response(response)
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,7 +6,20 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
RepoPath = Path(__file__).resolve().parents[3]
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
# Prefer cwd so local dev runs from the repo root without packaging.
|
||||
candidates = (Path.cwd(), Path(__file__).resolve())
|
||||
for base in candidates:
|
||||
for parent in (base, *base.parents):
|
||||
if (parent / "docs" / "constitution.md").exists():
|
||||
return parent
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
RepoPath = _find_repo_root()
|
||||
DocsPath = RepoPath / "docs"
|
||||
|
||||
|
||||
@@ -50,7 +63,7 @@ class Tool:
|
||||
description: str
|
||||
inputs: tuple[InputParam, ...]
|
||||
docs_anchor: DocsAnchor
|
||||
handler: Callable[[Mapping[str, str]], tuple[str, str, list[str]]]
|
||||
handler: Callable[[Mapping[str, str]], tuple[str, object, list[str]]]
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
"""Serialize tool metadata for transport."""
|
||||
@@ -92,33 +105,51 @@ def _search_docs(term: str) -> str:
|
||||
return "\n".join(matches[:20])
|
||||
|
||||
|
||||
def _tool_handlers() -> dict[str, Callable[[Mapping[str, str]], tuple[str, str, list[str]]]]:
|
||||
def show_constitution(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
text = _read_text(DocsPath / "constitution.md")
|
||||
return ("ok", text or "Constitution not found.", [])
|
||||
def show_constitution(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
"""Return the AI constitution contents."""
|
||||
text = _read_text(DocsPath / "constitution.md")
|
||||
return ("ok", text or "Constitution not found.", [])
|
||||
|
||||
def list_playbooks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
return ("ok", _list_playbooks(), [])
|
||||
|
||||
def show_reference(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
return ("ok", _list_reference_topics(), [])
|
||||
def list_playbooks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
"""Return the list of available playbooks."""
|
||||
return ("ok", _list_playbooks(), [])
|
||||
|
||||
def search_docs(params: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
term = params.get("query", "")
|
||||
if not term:
|
||||
return ("invalid_input", "Missing query", [])
|
||||
return ("ok", _search_docs(term), [])
|
||||
|
||||
def list_tasks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
tasks_file = RepoPath / "specs" / "001-mcp-server" / "tasks.md"
|
||||
return ("ok", _read_text(tasks_file) or "Tasks not found.", [])
|
||||
def show_reference(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
"""Return the reference index contents."""
|
||||
return ("ok", _list_reference_topics(), [])
|
||||
|
||||
|
||||
def search_docs(params: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
"""Search docs for a query string and return matches."""
|
||||
term = params.get("query", "")
|
||||
if not term:
|
||||
return ("invalid_input", "Missing query", [])
|
||||
return ("ok", _search_docs(term), [])
|
||||
|
||||
|
||||
def list_tasks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
"""Return MCP task list contents."""
|
||||
tasks_file = RepoPath / "specs" / "001-mcp-server" / "tasks.md"
|
||||
return ("ok", _read_text(tasks_file) or "Tasks not found.", [])
|
||||
|
||||
|
||||
def sync_docs(_: Mapping[str, str]) -> tuple[str, object, list[str]]:
|
||||
"""Return catalog vs docs drift report."""
|
||||
from mcp_server.docs_sync import check_catalog_parity
|
||||
|
||||
return ("ok", check_catalog_parity(), [])
|
||||
|
||||
|
||||
def _tool_handlers() -> dict[str, Callable[[Mapping[str, str]], tuple[str, object, list[str]]]]:
|
||||
return {
|
||||
"show-constitution": show_constitution,
|
||||
"list-playbooks": list_playbooks,
|
||||
"show-reference": show_reference,
|
||||
"search-docs": search_docs,
|
||||
"list-mcp-tasks": list_tasks,
|
||||
"sync-docs": sync_docs,
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +181,11 @@ def tool_catalog() -> tuple[Tool, ...]:
|
||||
anchor="tasks",
|
||||
summary="Implementation tasks for MCP feature",
|
||||
)
|
||||
anchor_sync = DocsAnchor(
|
||||
path=DocsPath / "reference" / "mcp-server.md",
|
||||
anchor="sync-docs",
|
||||
summary="Compare tool catalog against documented anchors",
|
||||
)
|
||||
return (
|
||||
Tool(
|
||||
name="show-constitution",
|
||||
@@ -186,11 +222,18 @@ def tool_catalog() -> tuple[Tool, ...]:
|
||||
docs_anchor=anchor_tasks,
|
||||
handler=handlers["list-mcp-tasks"],
|
||||
),
|
||||
Tool(
|
||||
name="sync-docs",
|
||||
description="Compare tool catalog against docs reference anchors.",
|
||||
inputs=(),
|
||||
docs_anchor=anchor_sync,
|
||||
handler=handlers["sync-docs"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def list_tools_payload() -> dict[str, object]:
|
||||
"""Render tool catalog payload for listTools."""
|
||||
"""Render tool catalog payload for tool discovery."""
|
||||
return {"tools": [tool.as_dict() for tool in tool_catalog()]}
|
||||
|
||||
|
||||
@@ -202,7 +245,7 @@ def invoke_tool(name: str, args: Mapping[str, str]) -> dict[str, object]:
|
||||
return {
|
||||
"status": "unsupported",
|
||||
"output": f"Tool '{name}' is not available.",
|
||||
"actions": ["call listTools to see supported tools"],
|
||||
"actions": ["call tools/list to see supported tools"],
|
||||
"docsAnchor": {},
|
||||
}
|
||||
status, output, actions = tool.handler(args)
|
||||
|
||||
Reference in New Issue
Block a user