This commit is contained in:
Danilo Reyes
2026-01-30 23:17:02 -06:00
parent 527fad8da0
commit 97053901c0
17 changed files with 646 additions and 26 deletions

View File

@@ -0,0 +1,39 @@
[project]
name = "mcp-server"
version = "0.1.0"
description = "Local-only MCP server for repository maintenance tasks"
requires-python = ">=3.12"
readme = "README.md"
authors = [{ name = "Repo Automation" }]
dependencies = ["click>=8.1.7"]
[project.optional-dependencies]
dev = ["ruff>=0.6.5", "black>=24.10.0", "pytest>=8.3.3", "mypy>=1.11.2"]
[tool.black]
line-length = 100
target-version = ["py312"]
[tool.ruff]
line-length = 100
target-version = "py312"
exclude = ["build", "dist", ".venv", "venv"]
[tool.ruff.lint]
select = ["E", "F", "D", "UP", "I", "N", "B", "PL", "C4", "RET", "TRY"]
ignore = ["D203", "D212"]
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
warn_unused_ignores = true
warn_redundant_casts = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
[tool.pytest.ini_options]
addopts = "-q --maxfail=1 --disable-warnings --durations=10"
testpaths = ["tests"]

20
scripts/mcp-server/run-tests.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash
#!nix-shell -p python3 python3Packages.click python3Packages.ruff python3Packages.black python3Packages.mypy python3Packages.pytest
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$here"
ruff check .
black --check .
mypy src
start=$(date +%s)
pytest
elapsed=$(( $(date +%s) - start ))
echo "Test suite duration: ${elapsed}s"
if [ $elapsed -gt 60 ]; then
echo "Test suite exceeded 60s budget." >&2
exit 1
fi

View File

@@ -0,0 +1 @@
"""MCP server package for local repository maintenance tooling."""

View File

@@ -0,0 +1,56 @@
"""Documentation synchronization checks for MCP tool catalog."""
from __future__ import annotations
from pathlib import Path
from mcp_server.tools import DocsPath, tool_catalog
def check_catalog_parity() -> dict[str, object]:
"""Compare documented anchors with the live tool catalog and report drift."""
missing_in_docs: list[str] = []
missing_in_catalog: list[str] = []
mismatches: list[dict[str, str]] = []
docs_tools = _doc_anchors()
catalog = tool_catalog()
for tool in catalog:
anchor_key = _anchor_key(tool.docs_anchor.path, tool.docs_anchor.anchor)
if anchor_key not in docs_tools:
missing_in_docs.append(tool.name)
for anchor_key, tool_name in docs_tools.items():
if tool_name not in {t.name for t in catalog}:
missing_in_catalog.append(anchor_key)
return {
"status": (
"ok"
if not missing_in_docs and not missing_in_catalog and not mismatches
else "drift_detected"
),
"missingInDocs": missing_in_docs,
"missingInCatalog": missing_in_catalog,
"mismatches": mismatches,
}
def _doc_anchors() -> dict[str, str]:
"""Derive anchors from docs files to detect missing catalog entries."""
anchors: dict[str, str] = {}
files = list(DocsPath.rglob("*.md"))
for path in files:
tool_name = _derive_tool_name(path)
anchor_id = path.stem
anchors[_anchor_key(path, anchor_id)] = tool_name
return anchors
def _derive_tool_name(path: Path) -> str:
"""Create a best-effort tool name from a documentation path."""
parts = path.parts[-3:]
return "-".join(filter(None, parts)).replace(".md", "")
def _anchor_key(path: Path, anchor: str) -> str:
"""Build a stable key for an anchor path pair."""
return f"{path}#{anchor}"

View File

@@ -0,0 +1,93 @@
"""Local-only MCP server over stdio."""
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
def _is_local_only() -> bool:
return os.environ.get("MCP_ALLOW_REMOTE", "").lower() not in {"1", "true", "yes"}
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.exit(1)
def _write_response(payload: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(payload) + "\n")
sys.stdout.flush()
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."
),
}
}
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)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,214 @@
"""Tool registry and invocation helpers."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
RepoPath = Path(__file__).resolve().parents[3]
DocsPath = RepoPath / "docs"
@dataclass(frozen=True)
class DocsAnchor:
"""Documentation pointer for a tool."""
path: Path
anchor: str
summary: str
def as_dict(self) -> dict[str, str]:
"""Serialize the anchor for transport."""
return {"path": str(self.path), "anchor": self.anchor, "summary": self.summary}
@dataclass(frozen=True)
class InputParam:
"""Input parameter definition."""
name: str
type: str
required: bool
description: str
def as_dict(self) -> dict[str, str | bool]:
"""Serialize the input parameter for transport."""
return {
"name": self.name,
"type": self.type,
"required": self.required,
"description": self.description,
}
@dataclass(frozen=True)
class Tool:
"""Tool metadata and handler binding."""
name: str
description: str
inputs: tuple[InputParam, ...]
docs_anchor: DocsAnchor
handler: Callable[[Mapping[str, str]], tuple[str, str, list[str]]]
def as_dict(self) -> dict[str, object]:
"""Serialize tool metadata for transport."""
return {
"name": self.name,
"description": self.description,
"inputs": list(map(InputParam.as_dict, self.inputs)),
"docsAnchor": self.docs_anchor.as_dict(),
}
def _read_text(path: Path) -> str:
if not path.exists():
return ""
return path.read_text()
def _list_playbooks() -> str:
playbooks = sorted((DocsPath / "playbooks").glob("*.md"))
if not playbooks:
return "No playbooks found."
return "\n".join(p.name for p in playbooks)
def _list_reference_topics() -> str:
reference = DocsPath / "reference" / "index.md"
return _read_text(reference) or "Reference index is empty."
def _search_docs(term: str) -> str:
files = sorted(DocsPath.rglob("*.md"))
matches = []
for path in files:
content = path.read_text()
if term.lower() in content.lower():
matches.append(f"{path}: {term}")
if not matches:
return "No matches found."
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 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 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.", [])
return {
"show-constitution": show_constitution,
"list-playbooks": list_playbooks,
"show-reference": show_reference,
"search-docs": search_docs,
"list-mcp-tasks": list_tasks,
}
def tool_catalog() -> tuple[Tool, ...]:
"""Return the available MCP tools and their metadata."""
handlers = _tool_handlers()
anchor_constitution = DocsAnchor(
path=DocsPath / "constitution.md",
anchor="ai-constitution-for-the-nixos-repository",
summary="Authoritative rules and workflows",
)
anchor_playbooks = DocsAnchor(
path=DocsPath / "playbooks" / "template.md",
anchor="playbook-template",
summary="Playbook index and template reference",
)
anchor_reference = DocsAnchor(
path=DocsPath / "reference" / "index.md",
anchor="reference-index",
summary="Navigation map for repository docs",
)
anchor_search = DocsAnchor(
path=DocsPath,
anchor="docs-search",
summary="Search across docs for maintenance topics",
)
anchor_tasks = DocsAnchor(
path=RepoPath / "specs" / "001-mcp-server" / "tasks.md",
anchor="tasks",
summary="Implementation tasks for MCP feature",
)
return (
Tool(
name="show-constitution",
description="Display repository AI constitution for rule lookup.",
inputs=(),
docs_anchor=anchor_constitution,
handler=handlers["show-constitution"],
),
Tool(
name="list-playbooks",
description="List available playbooks under docs/playbooks.",
inputs=(),
docs_anchor=anchor_playbooks,
handler=handlers["list-playbooks"],
),
Tool(
name="show-reference",
description="Show docs/reference/index.md for navigation guidance.",
inputs=(),
docs_anchor=anchor_reference,
handler=handlers["show-reference"],
),
Tool(
name="search-docs",
description="Search docs for a query string.",
inputs=(InputParam("query", "string", True, "Term to search for"),),
docs_anchor=anchor_search,
handler=handlers["search-docs"],
),
Tool(
name="list-mcp-tasks",
description="Show MCP feature task list from specs.",
inputs=(),
docs_anchor=anchor_tasks,
handler=handlers["list-mcp-tasks"],
),
)
def list_tools_payload() -> dict[str, object]:
"""Render tool catalog payload for listTools."""
return {"tools": [tool.as_dict() for tool in tool_catalog()]}
def invoke_tool(name: str, args: Mapping[str, str]) -> dict[str, object]:
"""Invoke a tool and return standardized result payload."""
registry: dict[str, Tool] = {tool.name: tool for tool in tool_catalog()}
tool = registry.get(name)
if not tool:
return {
"status": "unsupported",
"output": f"Tool '{name}' is not available.",
"actions": ["call listTools to see supported tools"],
"docsAnchor": {},
}
status, output, actions = tool.handler(args)
return {
"status": status,
"output": output,
"actions": actions,
"docsAnchor": tool.docs_anchor.as_dict(),
}

View File

@@ -0,0 +1,12 @@
"""Test configuration for MCP server tests."""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC = PROJECT_ROOT / "src"
for path in (PROJECT_ROOT, SRC):
if str(path) not in sys.path:
sys.path.insert(0, str(path))

View File

@@ -0,0 +1,13 @@
"""Docs sync tests."""
from __future__ import annotations
from mcp_server.docs_sync import check_catalog_parity
def test_docs_sync_runs() -> None:
"""Docs sync returns structured result."""
result = check_catalog_parity()
assert "status" in result
assert "missingInDocs" in result
assert isinstance(result["missingInDocs"], list)

View File

@@ -0,0 +1,25 @@
"""Performance tests for MCP server handlers."""
from __future__ import annotations
import time
from mcp_server.server import handle_request
MAX_LATENCY_SECONDS = 2
def test_list_tools_is_fast() -> None:
"""ListTools responds under the latency target."""
start = time.perf_counter()
handle_request({"method": "listTools", "params": {}})
duration = time.perf_counter() - start
assert duration < MAX_LATENCY_SECONDS
def test_invoke_tool_is_fast() -> None:
"""InvokeTool responds under the latency target."""
start = time.perf_counter()
handle_request({"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}})
duration = time.perf_counter() - start
assert duration < MAX_LATENCY_SECONDS

View File

@@ -0,0 +1,56 @@
"""Server dispatch tests."""
from __future__ import annotations
from mcp_server import server as server_module
from mcp_server.server import handle_request
METHOD_NOT_FOUND = -32601
def test_list_tools_round_trip() -> None:
"""ListTools returns catalog entries."""
response = handle_request({"method": "listTools", "params": {}})
tools = response["result"]["tools"]
assert isinstance(tools, list)
assert any(entry["name"] == "show-constitution" for entry in tools)
def test_invoke_tool_round_trip() -> None:
"""InvokeTool returns standard shape."""
response = handle_request(
{"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}}
)
result = response["result"]
assert result["status"] in {"ok", "unsupported", "invalid_input"}
assert "output" in result
def test_sync_docs_response_shape() -> None:
"""SyncDocs returns expected fields."""
response = handle_request({"method": "syncDocs", "params": {}})
result = response["result"]
assert "status" in result
assert "missingInDocs" in result
def test_invalid_method() -> None:
"""Unknown method yields error."""
response = handle_request({"method": "unknown", "params": {}})
assert "error" in response
assert response["error"]["code"] == METHOD_NOT_FOUND
def test_unavailable_service_returns_actions(monkeypatch) -> None:
"""Invoke tool failure returns guidance."""
def boom(*_: object, **__: object) -> dict:
raise RuntimeError("boom")
monkeypatch.setattr(server_module, "invoke_tool", boom)
response = handle_request(
{"method": "invokeTool", "params": {"name": "list-mcp-tasks", "args": {}}}
)
assert "result" in response
assert response["result"]["status"] == "failed"
assert "actions" in response["result"]

View File

@@ -0,0 +1,31 @@
"""Tool registry tests."""
from __future__ import annotations
from mcp_server import tools
MIN_TOOLS = 5
def test_tool_catalog_has_minimum_tools() -> None:
"""Catalog includes baseline tools."""
catalog = tools.tool_catalog()
assert len(catalog) >= MIN_TOOLS
names = {tool.name for tool in catalog}
assert "show-constitution" in names
assert "list-playbooks" in names
assert "show-reference" in names
def test_invoke_tool_handles_unknown() -> None:
"""Unknown tool returns unsupported guidance."""
result = tools.invoke_tool("missing-tool", {})
assert result["status"] == "unsupported"
assert "listTools" in result["actions"][0]
def test_list_tools_payload_shape() -> None:
"""Payload includes tools key."""
payload = tools.list_tools_payload()
assert "tools" in payload
assert all("name" in entry for entry in payload["tools"])