From 97053901c0b98c9eb049c5b60b4b957fb88c122b Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Fri, 30 Jan 2026 23:17:02 -0600 Subject: [PATCH] mcp --- .gitea/workflows/mcp-tests.yml | 24 ++ .gitignore | 6 + docs/constitution.md | 1 + docs/reference/index.md | 1 + docs/reference/mcp-server.md | 28 +++ scripts/mcp-server/pyproject.toml | 39 ++++ scripts/mcp-server/run-tests.sh | 20 ++ scripts/mcp-server/src/mcp_server/__init__.py | 1 + .../mcp-server/src/mcp_server/docs_sync.py | 56 +++++ scripts/mcp-server/src/mcp_server/server.py | 93 ++++++++ scripts/mcp-server/src/mcp_server/tools.py | 214 ++++++++++++++++++ scripts/mcp-server/tests/conftest.py | 12 + scripts/mcp-server/tests/test_docs_sync.py | 13 ++ scripts/mcp-server/tests/test_performance.py | 25 ++ scripts/mcp-server/tests/test_server.py | 56 +++++ scripts/mcp-server/tests/test_tools.py | 31 +++ specs/001-mcp-server/tasks.md | 52 ++--- 17 files changed, 646 insertions(+), 26 deletions(-) create mode 100644 .gitea/workflows/mcp-tests.yml create mode 100644 docs/reference/mcp-server.md create mode 100644 scripts/mcp-server/pyproject.toml create mode 100755 scripts/mcp-server/run-tests.sh create mode 100644 scripts/mcp-server/src/mcp_server/__init__.py create mode 100644 scripts/mcp-server/src/mcp_server/docs_sync.py create mode 100644 scripts/mcp-server/src/mcp_server/server.py create mode 100644 scripts/mcp-server/src/mcp_server/tools.py create mode 100644 scripts/mcp-server/tests/conftest.py create mode 100644 scripts/mcp-server/tests/test_docs_sync.py create mode 100644 scripts/mcp-server/tests/test_performance.py create mode 100644 scripts/mcp-server/tests/test_server.py create mode 100644 scripts/mcp-server/tests/test_tools.py diff --git a/.gitea/workflows/mcp-tests.yml b/.gitea/workflows/mcp-tests.yml new file mode 100644 index 0000000..17dda9e --- /dev/null +++ b/.gitea/workflows/mcp-tests.yml @@ -0,0 +1,24 @@ +name: MCP Tests + +on: + push: + branches: [ main ] + paths: + - 'scripts/**' + - 'docs/**' + - '.gitea/workflows/mcp-tests.yml' + pull_request: + paths: + - 'scripts/**' + - 'docs/**' + - '.gitea/workflows/mcp-tests.yml' + +jobs: + mcp-tests: + runs-on: nixos + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run MCP lint/format/tests via nix-shell + run: ./scripts/mcp-server/run-tests.sh diff --git a/.gitignore b/.gitignore index 7210bc6..769d16a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ Thumbs.db .idea/ *.swp *.tmp +__pycache__/ +*.pyc +.venv/ +venv/ +dist/ +*.egg-info/ diff --git a/docs/constitution.md b/docs/constitution.md index 5a74d34..3e66b3a 100644 --- a/docs/constitution.md +++ b/docs/constitution.md @@ -59,5 +59,6 @@ config.services = { ## Quick Reference and Navigation - Constitution: `docs/constitution.md` (this file) - Reference map: `docs/reference/index.md` (paths, hosts, secrets, proxies, stylix) +- MCP server reference: `docs/reference/mcp-server.md` (tools, invocation, sync checks) - Playbooks: `docs/playbooks/*.md` (add module/server/script/host toggle/secret, plus template) - Planning artifacts: `specs/001-ai-docs/` (plan, research, data-model, quickstart, contracts) diff --git a/docs/reference/index.md b/docs/reference/index.md index 3a92285..ed14cf2 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -55,6 +55,7 @@ - Playbook template: `docs/playbooks/template.md` - Workflows: `docs/playbooks/add-module.md`, `add-server.md`, `add-script.md`, `add-host-toggle.md`, `add-secret.md` - Constitution link-back: `docs/constitution.md` sections on terminology, proxies, secrets, and maintenance. +- MCP server reference: `docs/reference/mcp-server.md` (tool catalog, invocation, syncDocs) ## Quick Audit Checklist - Module coverage: All categories (apps, dev, scripts, servers, services, shell, network, users, nix, patches) have corresponding entries and auto-import rules. diff --git a/docs/reference/mcp-server.md b/docs/reference/mcp-server.md new file mode 100644 index 0000000..46c16fc --- /dev/null +++ b/docs/reference/mcp-server.md @@ -0,0 +1,28 @@ +# MCP Server Reference + +## Overview +- Purpose: local-only MCP server that exposes repository maintenance helpers to Codex CLI. +- Transport: JSON-RPC over stdio; no network listeners; enforced local-only guard. +- Source: `scripts/mcp-server/`; connect via `python -m mcp_server.server`. + +## Tool Catalog +- `show-constitution`: Display `docs/constitution.md` to confirm authoritative rules. +- `list-playbooks`: List available playbooks under `docs/playbooks/` for common tasks. +- `show-reference`: Show `docs/reference/index.md` to navigate repo guidance. +- `search-docs`: Search the docs set for a query (param: `query`). +- `list-mcp-tasks`: Show MCP feature task list from `specs/001-mcp-server/tasks.md`. + +## Invocation +- Start server: `python -m mcp_server.server` (from repo root, stdio mode). +- Codex CLI: configure MCP endpoint as local stdio, then call `listTools` to verify catalog. +- Invoke: `invokeTool` with `name` and `args` as defined above. +- Drift check: call `syncDocs` to report mismatches between tool catalog and documented anchors. + +## Local-Only Expectations +- Remote access is blocked by guard clauses; unset `SSH_CONNECTION` applies local-only behavior. +- If `MCP_ALLOW_REMOTE` is set to `true/1/yes`, guard is relaxed (not recommended). + +## Maintenance +- Update tool definitions in `scripts/mcp-server/src/mcp_server/tools.py` with doc anchors. +- Keep docs aligned by updating this reference and running `syncDocs`. +- CI: `.gitea/workflows/mcp-tests.yml` runs lint/format/mypy/pytest with a 60s budget on `scripts/**` and `docs/**` changes. diff --git a/scripts/mcp-server/pyproject.toml b/scripts/mcp-server/pyproject.toml new file mode 100644 index 0000000..f297dc3 --- /dev/null +++ b/scripts/mcp-server/pyproject.toml @@ -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"] diff --git a/scripts/mcp-server/run-tests.sh b/scripts/mcp-server/run-tests.sh new file mode 100755 index 0000000..613ddc8 --- /dev/null +++ b/scripts/mcp-server/run-tests.sh @@ -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 diff --git a/scripts/mcp-server/src/mcp_server/__init__.py b/scripts/mcp-server/src/mcp_server/__init__.py new file mode 100644 index 0000000..93affaf --- /dev/null +++ b/scripts/mcp-server/src/mcp_server/__init__.py @@ -0,0 +1 @@ +"""MCP server package for local repository maintenance tooling.""" diff --git a/scripts/mcp-server/src/mcp_server/docs_sync.py b/scripts/mcp-server/src/mcp_server/docs_sync.py new file mode 100644 index 0000000..2010584 --- /dev/null +++ b/scripts/mcp-server/src/mcp_server/docs_sync.py @@ -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}" diff --git a/scripts/mcp-server/src/mcp_server/server.py b/scripts/mcp-server/src/mcp_server/server.py new file mode 100644 index 0000000..1680c51 --- /dev/null +++ b/scripts/mcp-server/src/mcp_server/server.py @@ -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() diff --git a/scripts/mcp-server/src/mcp_server/tools.py b/scripts/mcp-server/src/mcp_server/tools.py new file mode 100644 index 0000000..6ab8188 --- /dev/null +++ b/scripts/mcp-server/src/mcp_server/tools.py @@ -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(), + } diff --git a/scripts/mcp-server/tests/conftest.py b/scripts/mcp-server/tests/conftest.py new file mode 100644 index 0000000..686784b --- /dev/null +++ b/scripts/mcp-server/tests/conftest.py @@ -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)) diff --git a/scripts/mcp-server/tests/test_docs_sync.py b/scripts/mcp-server/tests/test_docs_sync.py new file mode 100644 index 0000000..ab7a31a --- /dev/null +++ b/scripts/mcp-server/tests/test_docs_sync.py @@ -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) diff --git a/scripts/mcp-server/tests/test_performance.py b/scripts/mcp-server/tests/test_performance.py new file mode 100644 index 0000000..0e38893 --- /dev/null +++ b/scripts/mcp-server/tests/test_performance.py @@ -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 diff --git a/scripts/mcp-server/tests/test_server.py b/scripts/mcp-server/tests/test_server.py new file mode 100644 index 0000000..ac9d5f1 --- /dev/null +++ b/scripts/mcp-server/tests/test_server.py @@ -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"] diff --git a/scripts/mcp-server/tests/test_tools.py b/scripts/mcp-server/tests/test_tools.py new file mode 100644 index 0000000..b113351 --- /dev/null +++ b/scripts/mcp-server/tests/test_tools.py @@ -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"]) diff --git a/specs/001-mcp-server/tasks.md b/specs/001-mcp-server/tasks.md index 872dc44..5f4ee28 100644 --- a/specs/001-mcp-server/tasks.md +++ b/specs/001-mcp-server/tasks.md @@ -7,10 +7,10 @@ **Purpose**: Project initialization and base tooling -- [ ] T001 Create Python project skeleton under scripts/mcp-server with src/tests layout and __init__.py -- [ ] T002 Initialize scripts/mcp-server/pyproject.toml with runtime deps (MCP stdio/JSON-RPC, click) and dev deps (pytest, ruff, black) -- [ ] T003 [P] Configure lint/format/typing settings in scripts/mcp-server/pyproject.toml (ruff, black, mypy if used) -- [ ] T004 [P] Add pytest config and coverage thresholds in scripts/mcp-server/pyproject.toml +- [X] T001 Create Python project skeleton under scripts/mcp-server with src/tests layout and __init__.py +- [X] T002 Initialize scripts/mcp-server/pyproject.toml with runtime deps (MCP stdio/JSON-RPC, click) and dev deps (pytest, ruff, black) +- [X] T003 [P] Configure lint/format/typing settings in scripts/mcp-server/pyproject.toml (ruff, black, mypy if used) +- [X] T004 [P] Add pytest config and coverage thresholds in scripts/mcp-server/pyproject.toml --- @@ -18,10 +18,10 @@ **Purpose**: Core scaffolding required before user stories -- [ ] T005 Implement stdio JSON-RPC server bootstrap with local-only guard in scripts/mcp-server/src/mcp_server/server.py -- [ ] T006 [P] Define tool catalog schema and registry stubs with type hints in scripts/mcp-server/src/mcp_server/tools.py -- [ ] T007 [P] Add documentation sync scaffolding and anchor loader in scripts/mcp-server/src/mcp_server/docs_sync.py -- [ ] T008 [P] Add ruff/mypy configurations enforcing docstrings, guard clauses, and functional style rules in scripts/mcp-server/pyproject.toml +- [X] T005 Implement stdio JSON-RPC server bootstrap with local-only guard in scripts/mcp-server/src/mcp_server/server.py +- [X] T006 [P] Define tool catalog schema and registry stubs with type hints in scripts/mcp-server/src/mcp_server/tools.py +- [X] T007 [P] Add documentation sync scaffolding and anchor loader in scripts/mcp-server/src/mcp_server/docs_sync.py +- [X] T008 [P] Add ruff/mypy configurations enforcing docstrings, guard clauses, and functional style rules in scripts/mcp-server/pyproject.toml **Checkpoint**: Foundation ready for user story work @@ -35,19 +35,19 @@ ### Tests for User Story 1 -- [ ] T009 [P] [US1] Add contract tests for listTools/invokeTool/syncDocs responses in scripts/mcp-server/tests/test_server.py -- [ ] T010 [P] [US1] Add unit tests for tool registry schema and local-only guard behavior in scripts/mcp-server/tests/test_tools.py -- [ ] T011 [P] [US1] Add docs/catalog parity tests in scripts/mcp-server/tests/test_docs_sync.py -- [ ] T012 [P] [US1] Add performance regression tests for listTools/invokeTool latency (<2s) in scripts/mcp-server/tests/test_performance.py +- [X] T009 [P] [US1] Add contract tests for listTools/invokeTool/syncDocs responses in scripts/mcp-server/tests/test_server.py +- [X] T010 [P] [US1] Add unit tests for tool registry schema and local-only guard behavior in scripts/mcp-server/tests/test_tools.py +- [X] T011 [P] [US1] Add docs/catalog parity tests in scripts/mcp-server/tests/test_docs_sync.py +- [X] T012 [P] [US1] Add performance regression tests for listTools/invokeTool latency (<2s) in scripts/mcp-server/tests/test_performance.py ### Implementation for User Story 1 -- [ ] T013 [US1] Populate tool registry with documented maintenance tasks and doc anchors in scripts/mcp-server/src/mcp_server/tools.py -- [ ] T014 [US1] Implement listTools handler with input metadata in scripts/mcp-server/src/mcp_server/server.py -- [ ] T015 [US1] Implement invokeTool dispatch with guard clauses and standardized result payloads in scripts/mcp-server/src/mcp_server/server.py -- [ ] T016 [US1] Implement syncDocs comparison logic to flag drift between registry and docs in scripts/mcp-server/src/mcp_server/docs_sync.py -- [ ] T017 [US1] Add CLI/stdio entrypoint for MCP server (`python -m mcp_server.server`) enforcing local-only access in scripts/mcp-server/src/mcp_server/server.py -- [ ] T018 [US1] Implement unavailable-service handling with actionable guidance in scripts/mcp-server/src/mcp_server/server.py and cover in tests +- [X] T013 [US1] Populate tool registry with documented maintenance tasks and doc anchors in scripts/mcp-server/src/mcp_server/tools.py +- [X] T014 [US1] Implement listTools handler with input metadata in scripts/mcp-server/src/mcp_server/server.py +- [X] T015 [US1] Implement invokeTool dispatch with guard clauses and standardized result payloads in scripts/mcp-server/src/mcp_server/server.py +- [X] T016 [US1] Implement syncDocs comparison logic to flag drift between registry and docs in scripts/mcp-server/src/mcp_server/docs_sync.py +- [X] T017 [US1] Add CLI/stdio entrypoint for MCP server (`python -m mcp_server.server`) enforcing local-only access in scripts/mcp-server/src/mcp_server/server.py +- [X] T018 [US1] Implement unavailable-service handling with actionable guidance in scripts/mcp-server/src/mcp_server/server.py and cover in tests **Checkpoint**: User Story 1 fully functional and independently testable @@ -61,9 +61,9 @@ ### Implementation for User Story 2 -- [ ] T019 [US2] Add Gitea workflow .gitea/workflows/mcp-tests.yml with path filters for scripts/** and docs/** running ruff, black check, and pytest (including performance tests) -- [ ] T020 [P] [US2] Add local helper script scripts/mcp-server/run-tests.sh mirroring CI commands for developer use -- [ ] T021 [US2] Add CI time-budget check in .gitea/workflows/mcp-tests.yml to fail when MCP test suite exceeds 60s +- [X] T019 [US2] Add Gitea workflow .gitea/workflows/mcp-tests.yml with path filters for scripts/** and docs/** running ruff, black check, and pytest (including performance tests) +- [X] T020 [P] [US2] Add local helper script scripts/mcp-server/run-tests.sh mirroring CI commands for developer use +- [X] T021 [US2] Add CI time-budget check in .gitea/workflows/mcp-tests.yml to fail when MCP test suite exceeds 60s **Checkpoint**: User Story 2 functional and independently testable @@ -77,9 +77,9 @@ ### Implementation for User Story 3 -- [ ] T022 [US3] Add MCP overview and tool catalog mapping with anchors in docs/reference/mcp-server.md -- [ ] T023 [P] [US3] Link MCP reference into docs/reference/index.md and docs/constitution.md to satisfy two-click discoverability -- [ ] T024 [P] [US3] Document invocation examples and syncDocs usage aligned to tool anchors in docs/reference/mcp-server.md +- [X] T022 [US3] Add MCP overview and tool catalog mapping with anchors in docs/reference/mcp-server.md +- [X] T023 [P] [US3] Link MCP reference into docs/reference/index.md and docs/constitution.md to satisfy two-click discoverability +- [X] T024 [P] [US3] Document invocation examples and syncDocs usage aligned to tool anchors in docs/reference/mcp-server.md **Checkpoint**: User Story 3 functional and independently testable @@ -89,8 +89,8 @@ **Purpose**: Final quality, consistency, and validation -- [ ] T025 [P] Run ruff, black check, and pytest per quickstart to validate MCP package -- [ ] T026 [P] Verify tool catalog and documentation anchors remain in sync after changes in scripts/ and docs/ +- [X] T025 [P] Run ruff, black check, and pytest per quickstart to validate MCP package +- [X] T026 [P] Verify tool catalog and documentation anchors remain in sync after changes in scripts/ and docs/ ---