This commit is contained in:
@@ -55,7 +55,7 @@
|
|||||||
- Playbook template: `docs/playbooks/template.md`
|
- 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`
|
- 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.
|
- 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
|
## Quick Audit Checklist
|
||||||
- Module coverage: All categories (apps, dev, scripts, servers, services, shell, network, users, nix, patches) have corresponding entries and auto-import rules.
|
- Module coverage: All categories (apps, dev, scripts, servers, services, shell, network, users, nix, patches) have corresponding entries and auto-import rules.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
- Purpose: local-only MCP server that exposes repository maintenance helpers to Codex CLI.
|
- 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.
|
- Transport: MCP protocol over stdio via the official Python SDK; no network listeners; enforced local-only guard.
|
||||||
- Source: `scripts/mcp-server/`; connect via `python -m mcp_server.server`.
|
- Source: `scripts/mcp-server/`; connect via the `nixos-mcp` wrapper package.
|
||||||
|
|
||||||
## Tool Catalog
|
## Tool Catalog
|
||||||
- `show-constitution`: Display `docs/constitution.md` to confirm authoritative rules.
|
- `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.
|
- `show-reference`: Show `docs/reference/index.md` to navigate repo guidance.
|
||||||
- `search-docs`: Search the docs set for a query (param: `query`).
|
- `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`.
|
- `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
|
## Invocation
|
||||||
- Start server: `python -m mcp_server.server` (from repo root, stdio mode).
|
- Start server: `nixos-mcp` (stdio mode).
|
||||||
- Codex CLI: configure MCP endpoint as local stdio, then call `listTools` to verify catalog.
|
- Dev shell: `nix develop .#mcp` provides `nixos-mcp` and Codex CLI.
|
||||||
- Invoke: `invokeTool` with `name` and `args` as defined above.
|
- Codex CLI: configure MCP endpoint as local stdio and allowlist `nixos-mcp` in `.codex/requirements.toml`.
|
||||||
- Drift check: call `syncDocs` to report mismatches between tool catalog and documented anchors.
|
- 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
|
## Local-Only Expectations
|
||||||
- Remote access is blocked by guard clauses; unset `SSH_CONNECTION` applies local-only behavior.
|
- Remote access is blocked by guard clauses; unset `SSH_CONNECTION` applies local-only behavior.
|
||||||
@@ -24,5 +26,5 @@
|
|||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
- Update tool definitions in `scripts/mcp-server/src/mcp_server/tools.py` with doc anchors.
|
- 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.
|
- CI: `.gitea/workflows/mcp-tests.yml` runs lint/format/mypy/pytest with a 60s budget on `scripts/**` and `docs/**` changes.
|
||||||
|
|||||||
@@ -6,21 +6,10 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
python = pkgs.python3.withPackages (
|
packages = [
|
||||||
ps:
|
pkgs.codex
|
||||||
builtins.attrValues {
|
inputs.self.packages.${pkgs.system}.nixos-mcp
|
||||||
inherit (ps)
|
];
|
||||||
click
|
|
||||||
pytest
|
|
||||||
black
|
|
||||||
ruff
|
|
||||||
;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
packages = builtins.attrValues {
|
|
||||||
inherit python;
|
|
||||||
inherit (pkgs) codex; # codex-cli from openai
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options = {
|
options = {
|
||||||
@@ -34,17 +23,15 @@ in
|
|||||||
};
|
};
|
||||||
devShells.mcp = lib.mkOption {
|
devShells.mcp = lib.mkOption {
|
||||||
type = lib.types.package;
|
type = lib.types.package;
|
||||||
|
description = "MCP dev shell for this repo";
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
inherit packages;
|
inherit packages;
|
||||||
name = "mcp-dev-shell";
|
name = "mcp-dev-shell";
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export CODEX_HOME=$PWD/.codex
|
export CODEX_HOME=$PWD/.codex
|
||||||
export PYTHONPATH=$PWD/scripts/mcp-server/src
|
echo "MCP shell ready: codex + nixos-mcp"
|
||||||
alias mcp-run="python -m mcp_server.server"
|
|
||||||
echo "MCP shell ready: codex + python + PYTHONPATH set"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
description = "MCP + Codex shell for this repo";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
config = lib.mkIf config.my.dev.mcp.enable {
|
config = lib.mkIf config.my.dev.mcp.enable {
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
{
|
{
|
||||||
perSystem = _: {
|
perSystem = _: {
|
||||||
devShells =
|
devShells =
|
||||||
|
let
|
||||||
|
hostShells = inputs.self.nixosConfigurations.emacs.config.devShells;
|
||||||
|
in
|
||||||
inputs.self.lib.langList
|
inputs.self.lib.langList
|
||||||
|
|> builtins.filter (name: hostShells ? ${name})
|
||||||
|> map (name: {
|
|> map (name: {
|
||||||
inherit name;
|
inherit name;
|
||||||
value = inputs.self.nixosConfigurations.emacs.config.devShells.${name};
|
value = hostShells.${name};
|
||||||
})
|
})
|
||||||
|> builtins.listToAttrs;
|
|> builtins.listToAttrs;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
{ inputs, ... }:
|
{ inputs, ... }:
|
||||||
{
|
{
|
||||||
perSystem =
|
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 { }) // {
|
packages = (inputs.jawz-scripts.packages.${system} or { }) // {
|
||||||
emacs-vm = inputs.nixos-generators.nixosGenerate {
|
emacs-vm = inputs.nixos-generators.nixosGenerate {
|
||||||
@@ -13,6 +38,8 @@
|
|||||||
outputs = inputs.self;
|
outputs = inputs.self;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
nixos-mcp = nixosMcp;
|
||||||
|
nixos-mcp-server = mcpServerPkg;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description = "Local-only MCP server for repository maintenance tasks"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Repo Automation" }]
|
authors = [{ name = "Repo Automation" }]
|
||||||
dependencies = ["click>=8.1.7"]
|
dependencies = ["click>=8.1.7", "mcp>=1.2.0"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["ruff>=0.6.5", "black>=24.10.0", "pytest>=8.3.3", "mypy>=1.11.2"]
|
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"]
|
select = ["E", "F", "D", "UP", "I", "N", "B", "PL", "C4", "RET", "TRY"]
|
||||||
ignore = ["D203", "D212"]
|
ignore = ["D203", "D212"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"src/mcp_server/tools.py" = ["PLC0415"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
@@ -34,6 +37,10 @@ check_untyped_defs = true
|
|||||||
no_implicit_optional = true
|
no_implicit_optional = true
|
||||||
strict_equality = true
|
strict_equality = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["mcp", "mcp.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-q --maxfail=1 --disable-warnings --durations=10"
|
addopts = "-q --maxfail=1 --disable-warnings --durations=10"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -6,8 +6,19 @@ set -euo pipefail
|
|||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$here"
|
cd "$here"
|
||||||
|
|
||||||
ruff check .
|
fix=false
|
||||||
black --check .
|
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
|
mypy src
|
||||||
|
|
||||||
start=$(date +%s)
|
start=$(date +%s)
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from mcp_server.docs_sync import check_catalog_parity
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp_server.tools import invoke_tool, list_tools_payload
|
|
||||||
|
from mcp_server import tools
|
||||||
|
|
||||||
|
|
||||||
def _is_local_only() -> bool:
|
def _is_local_only() -> bool:
|
||||||
@@ -20,73 +18,53 @@ def _guard_local() -> None:
|
|||||||
if not _is_local_only():
|
if not _is_local_only():
|
||||||
return
|
return
|
||||||
if os.environ.get("SSH_CONNECTION"):
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _write_response(payload: dict[str, Any]) -> None:
|
mcp = FastMCP("NixOS Repo MCP", json_response=True)
|
||||||
sys.stdout.write(json.dumps(payload) + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_request(request: Mapping[str, Any]) -> dict[str, Any]:
|
@mcp.tool(name="show-constitution")
|
||||||
"""Dispatch a JSON-RPC request to the appropriate MCP handler."""
|
def show_constitution() -> dict[str, object]:
|
||||||
method = request.get("method")
|
"""Display repository AI constitution for rule lookup."""
|
||||||
params = request.get("params") or {}
|
return tools.invoke_tool("show-constitution", {})
|
||||||
if method == "listTools":
|
|
||||||
return {"result": list_tools_payload()}
|
|
||||||
if method == "invokeTool":
|
@mcp.tool(name="list-playbooks")
|
||||||
name = params.get("name") or ""
|
def list_playbooks() -> dict[str, object]:
|
||||||
args = params.get("args") or {}
|
"""List available playbooks under docs/playbooks."""
|
||||||
try:
|
return tools.invoke_tool("list-playbooks", {})
|
||||||
return {"result": invoke_tool(name, args)}
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
return {
|
@mcp.tool(name="show-reference")
|
||||||
"result": {
|
def show_reference() -> dict[str, object]:
|
||||||
"status": "failed",
|
"""Show docs/reference/index.md for navigation guidance."""
|
||||||
"output": f"Service unavailable: {exc}",
|
return tools.invoke_tool("show-reference", {})
|
||||||
"actions": ["retry locally", "call listTools to verify availability"],
|
|
||||||
"docsAnchor": {},
|
|
||||||
}
|
@mcp.tool(name="search-docs")
|
||||||
}
|
def search_docs(query: str) -> dict[str, object]:
|
||||||
if method == "syncDocs":
|
"""Search docs for a query string."""
|
||||||
return {"result": check_catalog_parity()}
|
return tools.invoke_tool("search-docs", {"query": query})
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": -32601,
|
@mcp.tool(name="list-mcp-tasks")
|
||||||
"message": (
|
def list_mcp_tasks() -> dict[str, object]:
|
||||||
f"Method '{method}' not found. Call listTools to discover supported methods."
|
"""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:
|
def main() -> None:
|
||||||
"""Run the MCP server in stdio mode."""
|
"""Run the MCP server in stdio mode."""
|
||||||
_guard_local()
|
_guard_local()
|
||||||
for line in sys.stdin:
|
mcp.run(transport="stdio")
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,7 +6,20 @@ from collections.abc import Callable, Mapping
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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"
|
DocsPath = RepoPath / "docs"
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +63,7 @@ class Tool:
|
|||||||
description: str
|
description: str
|
||||||
inputs: tuple[InputParam, ...]
|
inputs: tuple[InputParam, ...]
|
||||||
docs_anchor: DocsAnchor
|
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]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
"""Serialize tool metadata for transport."""
|
"""Serialize tool metadata for transport."""
|
||||||
@@ -92,33 +105,51 @@ def _search_docs(term: str) -> str:
|
|||||||
return "\n".join(matches[:20])
|
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]]:
|
||||||
def show_constitution(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
"""Return the AI constitution contents."""
|
||||||
text = _read_text(DocsPath / "constitution.md")
|
text = _read_text(DocsPath / "constitution.md")
|
||||||
return ("ok", text or "Constitution not found.", [])
|
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]]:
|
def list_playbooks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||||
return ("ok", _list_reference_topics(), [])
|
"""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]]:
|
def show_reference(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||||
tasks_file = RepoPath / "specs" / "001-mcp-server" / "tasks.md"
|
"""Return the reference index contents."""
|
||||||
return ("ok", _read_text(tasks_file) or "Tasks not found.", [])
|
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 {
|
return {
|
||||||
"show-constitution": show_constitution,
|
"show-constitution": show_constitution,
|
||||||
"list-playbooks": list_playbooks,
|
"list-playbooks": list_playbooks,
|
||||||
"show-reference": show_reference,
|
"show-reference": show_reference,
|
||||||
"search-docs": search_docs,
|
"search-docs": search_docs,
|
||||||
"list-mcp-tasks": list_tasks,
|
"list-mcp-tasks": list_tasks,
|
||||||
|
"sync-docs": sync_docs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -150,6 +181,11 @@ def tool_catalog() -> tuple[Tool, ...]:
|
|||||||
anchor="tasks",
|
anchor="tasks",
|
||||||
summary="Implementation tasks for MCP feature",
|
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 (
|
return (
|
||||||
Tool(
|
Tool(
|
||||||
name="show-constitution",
|
name="show-constitution",
|
||||||
@@ -186,11 +222,18 @@ def tool_catalog() -> tuple[Tool, ...]:
|
|||||||
docs_anchor=anchor_tasks,
|
docs_anchor=anchor_tasks,
|
||||||
handler=handlers["list-mcp-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]:
|
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()]}
|
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 {
|
return {
|
||||||
"status": "unsupported",
|
"status": "unsupported",
|
||||||
"output": f"Tool '{name}' is not available.",
|
"output": f"Tool '{name}' is not available.",
|
||||||
"actions": ["call listTools to see supported tools"],
|
"actions": ["call tools/list to see supported tools"],
|
||||||
"docsAnchor": {},
|
"docsAnchor": {},
|
||||||
}
|
}
|
||||||
status, output, actions = tool.handler(args)
|
status, output, actions = tool.handler(args)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from mcp_server.server import handle_request
|
from mcp_server import tools
|
||||||
|
|
||||||
MAX_LATENCY_SECONDS = 2
|
MAX_LATENCY_SECONDS = 2
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ MAX_LATENCY_SECONDS = 2
|
|||||||
def test_list_tools_is_fast() -> None:
|
def test_list_tools_is_fast() -> None:
|
||||||
"""ListTools responds under the latency target."""
|
"""ListTools responds under the latency target."""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
handle_request({"method": "listTools", "params": {}})
|
tools.list_tools_payload()
|
||||||
duration = time.perf_counter() - start
|
duration = time.perf_counter() - start
|
||||||
assert duration < MAX_LATENCY_SECONDS
|
assert duration < MAX_LATENCY_SECONDS
|
||||||
|
|
||||||
@@ -20,6 +20,6 @@ def test_list_tools_is_fast() -> None:
|
|||||||
def test_invoke_tool_is_fast() -> None:
|
def test_invoke_tool_is_fast() -> None:
|
||||||
"""InvokeTool responds under the latency target."""
|
"""InvokeTool responds under the latency target."""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
handle_request({"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}})
|
tools.invoke_tool("show-constitution", {})
|
||||||
duration = time.perf_counter() - start
|
duration = time.perf_counter() - start
|
||||||
assert duration < MAX_LATENCY_SECONDS
|
assert duration < MAX_LATENCY_SECONDS
|
||||||
|
|||||||
@@ -1,56 +1,27 @@
|
|||||||
"""Server dispatch tests."""
|
"""Server tool wiring tests."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from mcp_server import server as server_module
|
from mcp_server import tools
|
||||||
from mcp_server.server import handle_request
|
|
||||||
|
|
||||||
METHOD_NOT_FOUND = -32601
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_tools_round_trip() -> None:
|
def test_list_tools_round_trip() -> None:
|
||||||
"""ListTools returns catalog entries."""
|
"""ListTools returns catalog entries."""
|
||||||
response = handle_request({"method": "listTools", "params": {}})
|
payload = tools.list_tools_payload()
|
||||||
tools = response["result"]["tools"]
|
tool_list = payload["tools"]
|
||||||
assert isinstance(tools, list)
|
assert isinstance(tool_list, list)
|
||||||
assert any(entry["name"] == "show-constitution" for entry in tools)
|
assert any(entry["name"] == "show-constitution" for entry in tool_list)
|
||||||
|
|
||||||
|
|
||||||
def test_invoke_tool_round_trip() -> None:
|
def test_invoke_tool_round_trip() -> None:
|
||||||
"""InvokeTool returns standard shape."""
|
"""InvokeTool returns standard shape."""
|
||||||
response = handle_request(
|
result = tools.invoke_tool("show-constitution", {})
|
||||||
{"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}}
|
|
||||||
)
|
|
||||||
result = response["result"]
|
|
||||||
assert result["status"] in {"ok", "unsupported", "invalid_input"}
|
assert result["status"] in {"ok", "unsupported", "invalid_input"}
|
||||||
assert "output" in result
|
assert "output" in result
|
||||||
|
|
||||||
|
|
||||||
def test_sync_docs_response_shape() -> None:
|
def test_sync_docs_response_shape() -> None:
|
||||||
"""SyncDocs returns expected fields."""
|
"""SyncDocs returns expected fields."""
|
||||||
response = handle_request({"method": "syncDocs", "params": {}})
|
result = tools.invoke_tool("sync-docs", {})
|
||||||
result = response["result"]
|
|
||||||
assert "status" in result
|
assert "status" in result
|
||||||
assert "missingInDocs" in result
|
assert "missingInDocs" in result["output"]
|
||||||
|
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def test_invoke_tool_handles_unknown() -> None:
|
|||||||
"""Unknown tool returns unsupported guidance."""
|
"""Unknown tool returns unsupported guidance."""
|
||||||
result = tools.invoke_tool("missing-tool", {})
|
result = tools.invoke_tool("missing-tool", {})
|
||||||
assert result["status"] == "unsupported"
|
assert result["status"] == "unsupported"
|
||||||
assert "listTools" in result["actions"][0]
|
assert "tools/list" in result["actions"][0]
|
||||||
|
|
||||||
|
|
||||||
def test_list_tools_payload_shape() -> None:
|
def test_list_tools_payload_shape() -> None:
|
||||||
|
|||||||
@@ -1,35 +1,29 @@
|
|||||||
# MCP Tooling Contracts (JSON-RPC over stdio)
|
# MCP Tooling Contracts (MCP over stdio via `nixos-mcp`)
|
||||||
|
|
||||||
## listTools
|
## tools/list
|
||||||
- **Method**: `listTools`
|
- **Method**: `tools/list`
|
||||||
- **Params**: none
|
- **Params**: none
|
||||||
- **Result**:
|
- **Result**:
|
||||||
- `tools`: array of Tool objects
|
- `tools`: array of Tool objects
|
||||||
- `name`: string (unique)
|
- `name`: string (unique)
|
||||||
- `description`: string
|
- `description`: string
|
||||||
- `inputs`: array of InputParam
|
- `inputSchema`: object (JSON schema derived from tool signature)
|
||||||
- `name`: string
|
|
||||||
- `type`: string (constrained to allowed primitives)
|
|
||||||
- `required`: boolean
|
|
||||||
- `description`: string
|
|
||||||
- `docsAnchor`: object
|
|
||||||
- `path`: string (under `docs/`)
|
|
||||||
- `anchor`: string (heading id)
|
|
||||||
- `summary`: string
|
|
||||||
|
|
||||||
## invokeTool
|
## tools/call
|
||||||
- **Method**: `invokeTool`
|
- **Method**: `tools/call`
|
||||||
- **Params**:
|
- **Params**:
|
||||||
- `name`: string (must match Tool.name)
|
- `name`: string (must match Tool.name)
|
||||||
- `args`: object (key/value per Tool.inputs)
|
- `arguments`: object (key/value per Tool inputs)
|
||||||
- **Result**:
|
- **Result**:
|
||||||
- `status`: enum (`ok`, `invalid_input`, `failed`, `unsupported`)
|
- `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)
|
- `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
|
## sync-docs (tool)
|
||||||
- **Method**: `syncDocs`
|
|
||||||
- **Purpose**: Validate that documented tools match the live catalog.
|
- **Purpose**: Validate that documented tools match the live catalog.
|
||||||
- **Params**: none
|
- **Params**: none
|
||||||
- **Result**:
|
- **Result**:
|
||||||
@@ -42,6 +36,6 @@
|
|||||||
- `actual`: string
|
- `actual`: string
|
||||||
|
|
||||||
## Error Handling
|
## 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`.
|
- **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`.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Quickstart: MCP Server for Repo Maintenance
|
# Quickstart: MCP Server for Repo Maintenance
|
||||||
|
|
||||||
1) **Prereqs**: Python 3.12, `uv` or `pip`, and Codex CLI installed locally.
|
1) **Prereqs**: Nix with flakes enabled 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).
|
2) **Install**: `nix profile install .#nixos-mcp` (or `nix develop .#mcp` for a dev shell).
|
||||||
3) **Run tests**: `pytest --maxfail=1 --disable-warnings -q` (adds lint/format checks via ruff/black in CI).
|
3) **Run tests**: `./scripts/mcp-server/run-tests.sh`.
|
||||||
4) **Launch MCP server**: `python -m mcp_server.server` (stdio mode).
|
4) **Launch MCP server**: `nixos-mcp` (stdio mode).
|
||||||
5) **Connect Codex CLI**: Configure Codex to use the local MCP endpoint (stdin transport) and run `listTools` to verify catalog.
|
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 `syncDocs` to confirm docs match; update `docs/` MCP section accordingly.
|
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.
|
7) **CI behavior**: Gitea runs lint/format/tests when `scripts/**` or `docs/**` change; fix failures before merging.
|
||||||
|
|||||||
@@ -31,21 +31,21 @@
|
|||||||
|
|
||||||
**Goal**: Codex CLI lists and runs documented maintenance tools via MCP server
|
**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
|
### 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] 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] 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
|
### 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] 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] 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 invokeTool dispatch with guard clauses and standardized result payloads 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 syncDocs comparison logic to flag drift between registry and docs in scripts/mcp-server/src/mcp_server/docs_sync.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] 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
|
- [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] 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] 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
|
**Checkpoint**: User Story 3 functional and independently testable
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user