From 8946ade5e82bc10712c446d8b3e53d87c115897b Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 1 Feb 2026 10:36:54 -0600 Subject: [PATCH] mcp server done --- docs/reference/index.md | 2 +- docs/reference/mcp-server.md | 16 +-- modules/dev/mcp.nix | 25 ++--- parts/devshells.nix | 6 +- parts/packages.nix | 29 +++++- scripts/mcp-server/pyproject.toml | 9 +- scripts/mcp-server/run-tests.sh | 15 ++- scripts/mcp-server/src/mcp_server/server.py | 102 ++++++++----------- scripts/mcp-server/src/mcp_server/tools.py | 83 +++++++++++---- scripts/mcp-server/tests/test_performance.py | 6 +- scripts/mcp-server/tests/test_server.py | 47 ++------- scripts/mcp-server/tests/test_tools.py | 2 +- specs/001-mcp-server/contracts/mcp-tools.md | 36 +++---- specs/001-mcp-server/quickstart.md | 12 +-- specs/001-mcp-server/tasks.md | 14 +-- 15 files changed, 214 insertions(+), 190 deletions(-) diff --git a/docs/reference/index.md b/docs/reference/index.md index ed14cf2..e90bdae 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -55,7 +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) +- MCP server reference: `docs/reference/mcp-server.md` (tool catalog, `nixos-mcp` wrapper, invocation, sync-docs) ## 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 index 46c16fc..b3a2e19 100644 --- a/docs/reference/mcp-server.md +++ b/docs/reference/mcp-server.md @@ -2,8 +2,8 @@ ## 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`. +- Transport: MCP protocol over stdio via the official Python SDK; no network listeners; enforced local-only guard. +- Source: `scripts/mcp-server/`; connect via the `nixos-mcp` wrapper package. ## Tool Catalog - `show-constitution`: Display `docs/constitution.md` to confirm authoritative rules. @@ -11,12 +11,14 @@ - `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`. +- `sync-docs`: Compare tool catalog against documented anchors for drift reporting. ## 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. +- Start server: `nixos-mcp` (stdio mode). +- Dev shell: `nix develop .#mcp` provides `nixos-mcp` and Codex CLI. +- Codex CLI: configure MCP endpoint as local stdio and allowlist `nixos-mcp` in `.codex/requirements.toml`. +- Invoke: call the MCP tool by name with arguments as defined above. +- Drift check: invoke `sync-docs` 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. @@ -24,5 +26,5 @@ ## 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`. +- Keep docs aligned by updating this reference and running `sync-docs`. - CI: `.gitea/workflows/mcp-tests.yml` runs lint/format/mypy/pytest with a 60s budget on `scripts/**` and `docs/**` changes. diff --git a/modules/dev/mcp.nix b/modules/dev/mcp.nix index 3606da4..3ee6c5f 100644 --- a/modules/dev/mcp.nix +++ b/modules/dev/mcp.nix @@ -6,21 +6,10 @@ ... }: let - python = pkgs.python3.withPackages ( - ps: - builtins.attrValues { - inherit (ps) - click - pytest - black - ruff - ; - } - ); - packages = builtins.attrValues { - inherit python; - inherit (pkgs) codex; # codex-cli from openai - }; + packages = [ + pkgs.codex + inputs.self.packages.${pkgs.system}.nixos-mcp + ]; in { options = { @@ -34,17 +23,15 @@ in }; devShells.mcp = lib.mkOption { type = lib.types.package; + description = "MCP dev shell for this repo"; default = pkgs.mkShell { inherit packages; name = "mcp-dev-shell"; shellHook = '' export CODEX_HOME=$PWD/.codex - export PYTHONPATH=$PWD/scripts/mcp-server/src - alias mcp-run="python -m mcp_server.server" - echo "MCP shell ready: codex + python + PYTHONPATH set" + echo "MCP shell ready: codex + nixos-mcp" ''; }; - description = "MCP + Codex shell for this repo"; }; }; config = lib.mkIf config.my.dev.mcp.enable { diff --git a/parts/devshells.nix b/parts/devshells.nix index f9595e0..ab62d43 100644 --- a/parts/devshells.nix +++ b/parts/devshells.nix @@ -2,10 +2,14 @@ { perSystem = _: { devShells = + let + hostShells = inputs.self.nixosConfigurations.emacs.config.devShells; + in inputs.self.lib.langList + |> builtins.filter (name: hostShells ? ${name}) |> map (name: { inherit name; - value = inputs.self.nixosConfigurations.emacs.config.devShells.${name}; + value = hostShells.${name}; }) |> builtins.listToAttrs; }; diff --git a/parts/packages.nix b/parts/packages.nix index c774353..9a2eab4 100644 --- a/parts/packages.nix +++ b/parts/packages.nix @@ -1,7 +1,32 @@ { inputs, ... }: { perSystem = - { system, ... }: + { system, pkgs, ... }: + let + mcpServerPkg = pkgs.python3Packages.buildPythonPackage { + pname = "nixos-mcp-server"; + version = "0.1.0"; + src = inputs.self + "/scripts/mcp-server"; + pyproject = true; + build-system = with pkgs.python3Packages; [ + setuptools + wheel + ]; + propagatedBuildInputs = with pkgs.python3Packages; [ + click + mcp + ]; + doCheck = false; + }; + mcpPython = pkgs.python3.withPackages (_: [ mcpServerPkg ]); + nixosMcp = pkgs.writeShellApplication { + name = "nixos-mcp"; + runtimeInputs = [ mcpPython ]; + text = '' + exec ${mcpPython}/bin/python -m mcp_server.server + ''; + }; + in { packages = (inputs.jawz-scripts.packages.${system} or { }) // { emacs-vm = inputs.nixos-generators.nixosGenerate { @@ -13,6 +38,8 @@ outputs = inputs.self; }; }; + nixos-mcp = nixosMcp; + nixos-mcp-server = mcpServerPkg; }; }; } diff --git a/scripts/mcp-server/pyproject.toml b/scripts/mcp-server/pyproject.toml index f297dc3..25a7934 100644 --- a/scripts/mcp-server/pyproject.toml +++ b/scripts/mcp-server/pyproject.toml @@ -5,7 +5,7 @@ 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"] +dependencies = ["click>=8.1.7", "mcp>=1.2.0"] [project.optional-dependencies] dev = ["ruff>=0.6.5", "black>=24.10.0", "pytest>=8.3.3", "mypy>=1.11.2"] @@ -23,6 +23,9 @@ exclude = ["build", "dist", ".venv", "venv"] select = ["E", "F", "D", "UP", "I", "N", "B", "PL", "C4", "RET", "TRY"] ignore = ["D203", "D212"] +[tool.ruff.lint.per-file-ignores] +"src/mcp_server/tools.py" = ["PLC0415"] + [tool.mypy] python_version = "3.12" warn_unused_configs = true @@ -34,6 +37,10 @@ check_untyped_defs = true no_implicit_optional = true strict_equality = true +[[tool.mypy.overrides]] +module = ["mcp", "mcp.*"] +ignore_missing_imports = 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 index 613ddc8..aa544b0 100755 --- a/scripts/mcp-server/run-tests.sh +++ b/scripts/mcp-server/run-tests.sh @@ -6,8 +6,19 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$here" -ruff check . -black --check . +fix=false +for arg in "$@"; do + if [ "$arg" = "--fix" ]; then + fix=true + fi +done +if $fix; then + ruff check --fix . + black . +else + ruff check . + black --check . +fi mypy src start=$(date +%s) diff --git a/scripts/mcp-server/src/mcp_server/server.py b/scripts/mcp-server/src/mcp_server/server.py index 1680c51..d6392e3 100644 --- a/scripts/mcp-server/src/mcp_server/server.py +++ b/scripts/mcp-server/src/mcp_server/server.py @@ -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__": diff --git a/scripts/mcp-server/src/mcp_server/tools.py b/scripts/mcp-server/src/mcp_server/tools.py index 6ab8188..3f8480c 100644 --- a/scripts/mcp-server/src/mcp_server/tools.py +++ b/scripts/mcp-server/src/mcp_server/tools.py @@ -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) diff --git a/scripts/mcp-server/tests/test_performance.py b/scripts/mcp-server/tests/test_performance.py index 0e38893..b5abafa 100644 --- a/scripts/mcp-server/tests/test_performance.py +++ b/scripts/mcp-server/tests/test_performance.py @@ -4,7 +4,7 @@ from __future__ import annotations import time -from mcp_server.server import handle_request +from mcp_server import tools MAX_LATENCY_SECONDS = 2 @@ -12,7 +12,7 @@ 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": {}}) + tools.list_tools_payload() duration = time.perf_counter() - start assert duration < MAX_LATENCY_SECONDS @@ -20,6 +20,6 @@ def test_list_tools_is_fast() -> None: 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": {}}}) + tools.invoke_tool("show-constitution", {}) 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 index ac9d5f1..84dd08e 100644 --- a/scripts/mcp-server/tests/test_server.py +++ b/scripts/mcp-server/tests/test_server.py @@ -1,56 +1,27 @@ -"""Server dispatch tests.""" +"""Server tool wiring tests.""" from __future__ import annotations -from mcp_server import server as server_module -from mcp_server.server import handle_request - -METHOD_NOT_FOUND = -32601 +from mcp_server import tools 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) + payload = tools.list_tools_payload() + tool_list = payload["tools"] + assert isinstance(tool_list, list) + assert any(entry["name"] == "show-constitution" for entry in tool_list) def test_invoke_tool_round_trip() -> None: """InvokeTool returns standard shape.""" - response = handle_request( - {"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}} - ) - result = response["result"] + result = tools.invoke_tool("show-constitution", {}) 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"] + result = tools.invoke_tool("sync-docs", {}) 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"] + assert "missingInDocs" in result["output"] diff --git a/scripts/mcp-server/tests/test_tools.py b/scripts/mcp-server/tests/test_tools.py index b113351..f98cb01 100644 --- a/scripts/mcp-server/tests/test_tools.py +++ b/scripts/mcp-server/tests/test_tools.py @@ -21,7 +21,7 @@ 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] + assert "tools/list" in result["actions"][0] def test_list_tools_payload_shape() -> None: diff --git a/specs/001-mcp-server/contracts/mcp-tools.md b/specs/001-mcp-server/contracts/mcp-tools.md index a118150..06dc296 100644 --- a/specs/001-mcp-server/contracts/mcp-tools.md +++ b/specs/001-mcp-server/contracts/mcp-tools.md @@ -1,35 +1,29 @@ -# MCP Tooling Contracts (JSON-RPC over stdio) +# MCP Tooling Contracts (MCP over stdio via `nixos-mcp`) -## listTools -- **Method**: `listTools` +## tools/list +- **Method**: `tools/list` - **Params**: none - **Result**: - `tools`: array of Tool objects - `name`: string (unique) - `description`: string - - `inputs`: array of InputParam - - `name`: string - - `type`: string (constrained to allowed primitives) - - `required`: boolean - - `description`: string - - `docsAnchor`: object - - `path`: string (under `docs/`) - - `anchor`: string (heading id) - - `summary`: string + - `inputSchema`: object (JSON schema derived from tool signature) -## invokeTool -- **Method**: `invokeTool` +## tools/call +- **Method**: `tools/call` - **Params**: - `name`: string (must match Tool.name) - - `args`: object (key/value per Tool.inputs) + - `arguments`: object (key/value per Tool inputs) - **Result**: - `status`: enum (`ok`, `invalid_input`, `failed`, `unsupported`) - - `output`: string (human-readable result or guidance) + - `output`: string or object (human-readable result or structured payload) - `actions`: array of suggested follow-ups (optional) - - `docsAnchor`: object (same shape as listTools.docsAnchor) for quick navigation + - `docsAnchor`: object for quick navigation + - `path`: string (under `docs/` or `specs/`) + - `anchor`: string (heading id) + - `summary`: string -## syncDocs -- **Method**: `syncDocs` +## sync-docs (tool) - **Purpose**: Validate that documented tools match the live catalog. - **Params**: none - **Result**: @@ -42,6 +36,6 @@ - `actual`: string ## Error Handling -- **Transport errors**: standard JSON-RPC error object with code/message. +- **Transport errors**: standard MCP error object with code/message. - **Validation errors**: return `invalid_input` with details in `output`. -- **Unknown methods**: return `unsupported` status with guidance to run `listTools`. +- **Unknown tools**: return `unsupported` status with guidance to run `tools/list`. diff --git a/specs/001-mcp-server/quickstart.md b/specs/001-mcp-server/quickstart.md index 959d4ae..4cb7706 100644 --- a/specs/001-mcp-server/quickstart.md +++ b/specs/001-mcp-server/quickstart.md @@ -1,9 +1,9 @@ # Quickstart: MCP Server for Repo Maintenance -1) **Prereqs**: Python 3.12, `uv` or `pip`, and Codex CLI installed locally. -2) **Install**: From repo root, `cd scripts/mcp-server` and run `uv pip install -e .` (or `pip install -e .` if uv unavailable). -3) **Run tests**: `pytest --maxfail=1 --disable-warnings -q` (adds lint/format checks via ruff/black in CI). -4) **Launch MCP server**: `python -m mcp_server.server` (stdio mode). -5) **Connect Codex CLI**: Configure Codex to use the local MCP endpoint (stdin transport) and run `listTools` to verify catalog. -6) **Docs alignment**: If adding/updating tools, run `syncDocs` to confirm docs match; update `docs/` MCP section accordingly. +1) **Prereqs**: Nix with flakes enabled and Codex CLI installed locally. +2) **Install**: `nix profile install .#nixos-mcp` (or `nix develop .#mcp` for a dev shell). +3) **Run tests**: `./scripts/mcp-server/run-tests.sh`. +4) **Launch MCP server**: `nixos-mcp` (stdio mode). +5) **Connect Codex CLI**: Ensure `.codex/config.toml` points to `nixos-mcp` and `.codex/requirements.toml` allowlists it, then list tools via the MCP client to verify catalog. +6) **Docs alignment**: If adding/updating tools, run `sync-docs` to confirm docs match; update `docs/` MCP section accordingly. 7) **CI behavior**: Gitea runs lint/format/tests when `scripts/**` or `docs/**` change; fix failures before merging. diff --git a/specs/001-mcp-server/tasks.md b/specs/001-mcp-server/tasks.md index 5f4ee28..20bbd62 100644 --- a/specs/001-mcp-server/tasks.md +++ b/specs/001-mcp-server/tasks.md @@ -31,21 +31,21 @@ **Goal**: Codex CLI lists and runs documented maintenance tools via MCP server -**Independent Test**: Connect Codex CLI to the local MCP server, call listTools, and successfully invoke a documented maintenance tool end-to-end without manual repo edits. +**Independent Test**: Connect Codex CLI to the local MCP server, list tools via MCP, and successfully invoke a documented maintenance tool end-to-end without manual repo edits. ### Tests for User Story 1 -- [X] T009 [P] [US1] Add contract tests for listTools/invokeTool/syncDocs responses in scripts/mcp-server/tests/test_server.py +- [X] T009 [P] [US1] Add contract tests for MCP tools/list, tools/call, and sync-docs 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 +- [X] T012 [P] [US1] Add performance regression tests for tools/list and tools/call latency (<2s) in scripts/mcp-server/tests/test_performance.py ### Implementation for User Story 1 - [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] T014 [US1] Implement MCP tools/list and tool registration with input metadata in scripts/mcp-server/src/mcp_server/server.py +- [X] T015 [US1] Implement MCP tools/call dispatch with guard clauses and standardized result payloads in scripts/mcp-server/src/mcp_server/server.py +- [X] T016 [US1] Implement sync-docs 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 @@ -79,7 +79,7 @@ - [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 +- [X] T024 [P] [US3] Document invocation examples and sync-docs usage aligned to tool anchors in docs/reference/mcp-server.md **Checkpoint**: User Story 3 functional and independently testable