001-mcp-server #3
24
.gitea/workflows/mcp-tests.yml
Normal file
24
.gitea/workflows/mcp-tests.yml
Normal file
@@ -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
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -14,3 +14,9 @@ Thumbs.db
|
||||
.idea/
|
||||
*.swp
|
||||
*.tmp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
28
docs/reference/mcp-server.md
Normal file
28
docs/reference/mcp-server.md
Normal file
@@ -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.
|
||||
39
scripts/mcp-server/pyproject.toml
Normal file
39
scripts/mcp-server/pyproject.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[project]
|
||||
name = "mcp-server"
|
||||
version = "0.1.0"
|
||||
description = "Local-only MCP server for repository maintenance tasks"
|
||||
requires-python = ">=3.12"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Repo Automation" }]
|
||||
dependencies = ["click>=8.1.7"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["ruff>=0.6.5", "black>=24.10.0", "pytest>=8.3.3", "mypy>=1.11.2"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py312"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
exclude = ["build", "dist", ".venv", "venv"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "D", "UP", "I", "N", "B", "PL", "C4", "RET", "TRY"]
|
||||
ignore = ["D203", "D212"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
strict_equality = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q --maxfail=1 --disable-warnings --durations=10"
|
||||
testpaths = ["tests"]
|
||||
20
scripts/mcp-server/run-tests.sh
Executable file
20
scripts/mcp-server/run-tests.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
#!nix-shell -i bash
|
||||
#!nix-shell -p python3 python3Packages.click python3Packages.ruff python3Packages.black python3Packages.mypy python3Packages.pytest
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$here"
|
||||
|
||||
ruff check .
|
||||
black --check .
|
||||
mypy src
|
||||
|
||||
start=$(date +%s)
|
||||
pytest
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
echo "Test suite duration: ${elapsed}s"
|
||||
if [ $elapsed -gt 60 ]; then
|
||||
echo "Test suite exceeded 60s budget." >&2
|
||||
exit 1
|
||||
fi
|
||||
1
scripts/mcp-server/src/mcp_server/__init__.py
Normal file
1
scripts/mcp-server/src/mcp_server/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""MCP server package for local repository maintenance tooling."""
|
||||
56
scripts/mcp-server/src/mcp_server/docs_sync.py
Normal file
56
scripts/mcp-server/src/mcp_server/docs_sync.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Documentation synchronization checks for MCP tool catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_server.tools import DocsPath, tool_catalog
|
||||
|
||||
|
||||
def check_catalog_parity() -> dict[str, object]:
|
||||
"""Compare documented anchors with the live tool catalog and report drift."""
|
||||
missing_in_docs: list[str] = []
|
||||
missing_in_catalog: list[str] = []
|
||||
mismatches: list[dict[str, str]] = []
|
||||
|
||||
docs_tools = _doc_anchors()
|
||||
catalog = tool_catalog()
|
||||
for tool in catalog:
|
||||
anchor_key = _anchor_key(tool.docs_anchor.path, tool.docs_anchor.anchor)
|
||||
if anchor_key not in docs_tools:
|
||||
missing_in_docs.append(tool.name)
|
||||
for anchor_key, tool_name in docs_tools.items():
|
||||
if tool_name not in {t.name for t in catalog}:
|
||||
missing_in_catalog.append(anchor_key)
|
||||
return {
|
||||
"status": (
|
||||
"ok"
|
||||
if not missing_in_docs and not missing_in_catalog and not mismatches
|
||||
else "drift_detected"
|
||||
),
|
||||
"missingInDocs": missing_in_docs,
|
||||
"missingInCatalog": missing_in_catalog,
|
||||
"mismatches": mismatches,
|
||||
}
|
||||
|
||||
|
||||
def _doc_anchors() -> dict[str, str]:
|
||||
"""Derive anchors from docs files to detect missing catalog entries."""
|
||||
anchors: dict[str, str] = {}
|
||||
files = list(DocsPath.rglob("*.md"))
|
||||
for path in files:
|
||||
tool_name = _derive_tool_name(path)
|
||||
anchor_id = path.stem
|
||||
anchors[_anchor_key(path, anchor_id)] = tool_name
|
||||
return anchors
|
||||
|
||||
|
||||
def _derive_tool_name(path: Path) -> str:
|
||||
"""Create a best-effort tool name from a documentation path."""
|
||||
parts = path.parts[-3:]
|
||||
return "-".join(filter(None, parts)).replace(".md", "")
|
||||
|
||||
|
||||
def _anchor_key(path: Path, anchor: str) -> str:
|
||||
"""Build a stable key for an anchor path pair."""
|
||||
return f"{path}#{anchor}"
|
||||
93
scripts/mcp-server/src/mcp_server/server.py
Normal file
93
scripts/mcp-server/src/mcp_server/server.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Local-only MCP server over stdio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from mcp_server.docs_sync import check_catalog_parity
|
||||
from mcp_server.tools import invoke_tool, list_tools_payload
|
||||
|
||||
|
||||
def _is_local_only() -> bool:
|
||||
return os.environ.get("MCP_ALLOW_REMOTE", "").lower() not in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _guard_local() -> None:
|
||||
if not _is_local_only():
|
||||
return
|
||||
if os.environ.get("SSH_CONNECTION"):
|
||||
_write_response({"error": {"code": -32099, "message": "Remote access denied"}})
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _write_response(payload: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps(payload) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def handle_request(request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Dispatch a JSON-RPC request to the appropriate MCP handler."""
|
||||
method = request.get("method")
|
||||
params = request.get("params") or {}
|
||||
if method == "listTools":
|
||||
return {"result": list_tools_payload()}
|
||||
if method == "invokeTool":
|
||||
name = params.get("name") or ""
|
||||
args = params.get("args") or {}
|
||||
try:
|
||||
return {"result": invoke_tool(name, args)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"result": {
|
||||
"status": "failed",
|
||||
"output": f"Service unavailable: {exc}",
|
||||
"actions": ["retry locally", "call listTools to verify availability"],
|
||||
"docsAnchor": {},
|
||||
}
|
||||
}
|
||||
if method == "syncDocs":
|
||||
return {"result": check_catalog_parity()}
|
||||
return {
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": (
|
||||
f"Method '{method}' not found. Call listTools to discover supported methods."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the MCP server in stdio mode."""
|
||||
_guard_local()
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
_write_response({"error": {"code": -32700, "message": "Parse error"}})
|
||||
continue
|
||||
try:
|
||||
response = handle_request(request)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_write_response(
|
||||
{
|
||||
"result": {
|
||||
"status": "failed",
|
||||
"output": f"Service unavailable: {exc}",
|
||||
"actions": ["retry locally", "call listTools to verify availability"],
|
||||
"docsAnchor": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
continue
|
||||
_write_response(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
214
scripts/mcp-server/src/mcp_server/tools.py
Normal file
214
scripts/mcp-server/src/mcp_server/tools.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Tool registry and invocation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
RepoPath = Path(__file__).resolve().parents[3]
|
||||
DocsPath = RepoPath / "docs"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocsAnchor:
|
||||
"""Documentation pointer for a tool."""
|
||||
|
||||
path: Path
|
||||
anchor: str
|
||||
summary: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
"""Serialize the anchor for transport."""
|
||||
return {"path": str(self.path), "anchor": self.anchor, "summary": self.summary}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputParam:
|
||||
"""Input parameter definition."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
required: bool
|
||||
description: str
|
||||
|
||||
def as_dict(self) -> dict[str, str | bool]:
|
||||
"""Serialize the input parameter for transport."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"required": self.required,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tool:
|
||||
"""Tool metadata and handler binding."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
inputs: tuple[InputParam, ...]
|
||||
docs_anchor: DocsAnchor
|
||||
handler: Callable[[Mapping[str, str]], tuple[str, str, list[str]]]
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
"""Serialize tool metadata for transport."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"inputs": list(map(InputParam.as_dict, self.inputs)),
|
||||
"docsAnchor": self.docs_anchor.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def _list_playbooks() -> str:
|
||||
playbooks = sorted((DocsPath / "playbooks").glob("*.md"))
|
||||
if not playbooks:
|
||||
return "No playbooks found."
|
||||
return "\n".join(p.name for p in playbooks)
|
||||
|
||||
|
||||
def _list_reference_topics() -> str:
|
||||
reference = DocsPath / "reference" / "index.md"
|
||||
return _read_text(reference) or "Reference index is empty."
|
||||
|
||||
|
||||
def _search_docs(term: str) -> str:
|
||||
files = sorted(DocsPath.rglob("*.md"))
|
||||
matches = []
|
||||
for path in files:
|
||||
content = path.read_text()
|
||||
if term.lower() in content.lower():
|
||||
matches.append(f"{path}: {term}")
|
||||
if not matches:
|
||||
return "No matches found."
|
||||
return "\n".join(matches[:20])
|
||||
|
||||
|
||||
def _tool_handlers() -> dict[str, Callable[[Mapping[str, str]], tuple[str, str, list[str]]]]:
|
||||
def show_constitution(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
text = _read_text(DocsPath / "constitution.md")
|
||||
return ("ok", text or "Constitution not found.", [])
|
||||
|
||||
def list_playbooks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
return ("ok", _list_playbooks(), [])
|
||||
|
||||
def show_reference(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
return ("ok", _list_reference_topics(), [])
|
||||
|
||||
def search_docs(params: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
term = params.get("query", "")
|
||||
if not term:
|
||||
return ("invalid_input", "Missing query", [])
|
||||
return ("ok", _search_docs(term), [])
|
||||
|
||||
def list_tasks(_: Mapping[str, str]) -> tuple[str, str, list[str]]:
|
||||
tasks_file = RepoPath / "specs" / "001-mcp-server" / "tasks.md"
|
||||
return ("ok", _read_text(tasks_file) or "Tasks not found.", [])
|
||||
|
||||
return {
|
||||
"show-constitution": show_constitution,
|
||||
"list-playbooks": list_playbooks,
|
||||
"show-reference": show_reference,
|
||||
"search-docs": search_docs,
|
||||
"list-mcp-tasks": list_tasks,
|
||||
}
|
||||
|
||||
|
||||
def tool_catalog() -> tuple[Tool, ...]:
|
||||
"""Return the available MCP tools and their metadata."""
|
||||
handlers = _tool_handlers()
|
||||
anchor_constitution = DocsAnchor(
|
||||
path=DocsPath / "constitution.md",
|
||||
anchor="ai-constitution-for-the-nixos-repository",
|
||||
summary="Authoritative rules and workflows",
|
||||
)
|
||||
anchor_playbooks = DocsAnchor(
|
||||
path=DocsPath / "playbooks" / "template.md",
|
||||
anchor="playbook-template",
|
||||
summary="Playbook index and template reference",
|
||||
)
|
||||
anchor_reference = DocsAnchor(
|
||||
path=DocsPath / "reference" / "index.md",
|
||||
anchor="reference-index",
|
||||
summary="Navigation map for repository docs",
|
||||
)
|
||||
anchor_search = DocsAnchor(
|
||||
path=DocsPath,
|
||||
anchor="docs-search",
|
||||
summary="Search across docs for maintenance topics",
|
||||
)
|
||||
anchor_tasks = DocsAnchor(
|
||||
path=RepoPath / "specs" / "001-mcp-server" / "tasks.md",
|
||||
anchor="tasks",
|
||||
summary="Implementation tasks for MCP feature",
|
||||
)
|
||||
return (
|
||||
Tool(
|
||||
name="show-constitution",
|
||||
description="Display repository AI constitution for rule lookup.",
|
||||
inputs=(),
|
||||
docs_anchor=anchor_constitution,
|
||||
handler=handlers["show-constitution"],
|
||||
),
|
||||
Tool(
|
||||
name="list-playbooks",
|
||||
description="List available playbooks under docs/playbooks.",
|
||||
inputs=(),
|
||||
docs_anchor=anchor_playbooks,
|
||||
handler=handlers["list-playbooks"],
|
||||
),
|
||||
Tool(
|
||||
name="show-reference",
|
||||
description="Show docs/reference/index.md for navigation guidance.",
|
||||
inputs=(),
|
||||
docs_anchor=anchor_reference,
|
||||
handler=handlers["show-reference"],
|
||||
),
|
||||
Tool(
|
||||
name="search-docs",
|
||||
description="Search docs for a query string.",
|
||||
inputs=(InputParam("query", "string", True, "Term to search for"),),
|
||||
docs_anchor=anchor_search,
|
||||
handler=handlers["search-docs"],
|
||||
),
|
||||
Tool(
|
||||
name="list-mcp-tasks",
|
||||
description="Show MCP feature task list from specs.",
|
||||
inputs=(),
|
||||
docs_anchor=anchor_tasks,
|
||||
handler=handlers["list-mcp-tasks"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def list_tools_payload() -> dict[str, object]:
|
||||
"""Render tool catalog payload for listTools."""
|
||||
return {"tools": [tool.as_dict() for tool in tool_catalog()]}
|
||||
|
||||
|
||||
def invoke_tool(name: str, args: Mapping[str, str]) -> dict[str, object]:
|
||||
"""Invoke a tool and return standardized result payload."""
|
||||
registry: dict[str, Tool] = {tool.name: tool for tool in tool_catalog()}
|
||||
tool = registry.get(name)
|
||||
if not tool:
|
||||
return {
|
||||
"status": "unsupported",
|
||||
"output": f"Tool '{name}' is not available.",
|
||||
"actions": ["call listTools to see supported tools"],
|
||||
"docsAnchor": {},
|
||||
}
|
||||
status, output, actions = tool.handler(args)
|
||||
return {
|
||||
"status": status,
|
||||
"output": output,
|
||||
"actions": actions,
|
||||
"docsAnchor": tool.docs_anchor.as_dict(),
|
||||
}
|
||||
12
scripts/mcp-server/tests/conftest.py
Normal file
12
scripts/mcp-server/tests/conftest.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Test configuration for MCP server tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = PROJECT_ROOT / "src"
|
||||
for path in (PROJECT_ROOT, SRC):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
13
scripts/mcp-server/tests/test_docs_sync.py
Normal file
13
scripts/mcp-server/tests/test_docs_sync.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Docs sync tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_server.docs_sync import check_catalog_parity
|
||||
|
||||
|
||||
def test_docs_sync_runs() -> None:
|
||||
"""Docs sync returns structured result."""
|
||||
result = check_catalog_parity()
|
||||
assert "status" in result
|
||||
assert "missingInDocs" in result
|
||||
assert isinstance(result["missingInDocs"], list)
|
||||
25
scripts/mcp-server/tests/test_performance.py
Normal file
25
scripts/mcp-server/tests/test_performance.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Performance tests for MCP server handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from mcp_server.server import handle_request
|
||||
|
||||
MAX_LATENCY_SECONDS = 2
|
||||
|
||||
|
||||
def test_list_tools_is_fast() -> None:
|
||||
"""ListTools responds under the latency target."""
|
||||
start = time.perf_counter()
|
||||
handle_request({"method": "listTools", "params": {}})
|
||||
duration = time.perf_counter() - start
|
||||
assert duration < MAX_LATENCY_SECONDS
|
||||
|
||||
|
||||
def test_invoke_tool_is_fast() -> None:
|
||||
"""InvokeTool responds under the latency target."""
|
||||
start = time.perf_counter()
|
||||
handle_request({"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}})
|
||||
duration = time.perf_counter() - start
|
||||
assert duration < MAX_LATENCY_SECONDS
|
||||
56
scripts/mcp-server/tests/test_server.py
Normal file
56
scripts/mcp-server/tests/test_server.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Server dispatch tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_server import server as server_module
|
||||
from mcp_server.server import handle_request
|
||||
|
||||
METHOD_NOT_FOUND = -32601
|
||||
|
||||
|
||||
def test_list_tools_round_trip() -> None:
|
||||
"""ListTools returns catalog entries."""
|
||||
response = handle_request({"method": "listTools", "params": {}})
|
||||
tools = response["result"]["tools"]
|
||||
assert isinstance(tools, list)
|
||||
assert any(entry["name"] == "show-constitution" for entry in tools)
|
||||
|
||||
|
||||
def test_invoke_tool_round_trip() -> None:
|
||||
"""InvokeTool returns standard shape."""
|
||||
response = handle_request(
|
||||
{"method": "invokeTool", "params": {"name": "show-constitution", "args": {}}}
|
||||
)
|
||||
result = response["result"]
|
||||
assert result["status"] in {"ok", "unsupported", "invalid_input"}
|
||||
assert "output" in result
|
||||
|
||||
|
||||
def test_sync_docs_response_shape() -> None:
|
||||
"""SyncDocs returns expected fields."""
|
||||
response = handle_request({"method": "syncDocs", "params": {}})
|
||||
result = response["result"]
|
||||
assert "status" in result
|
||||
assert "missingInDocs" in result
|
||||
|
||||
|
||||
def test_invalid_method() -> None:
|
||||
"""Unknown method yields error."""
|
||||
response = handle_request({"method": "unknown", "params": {}})
|
||||
assert "error" in response
|
||||
assert response["error"]["code"] == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
def test_unavailable_service_returns_actions(monkeypatch) -> None:
|
||||
"""Invoke tool failure returns guidance."""
|
||||
|
||||
def boom(*_: object, **__: object) -> dict:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(server_module, "invoke_tool", boom)
|
||||
response = handle_request(
|
||||
{"method": "invokeTool", "params": {"name": "list-mcp-tasks", "args": {}}}
|
||||
)
|
||||
assert "result" in response
|
||||
assert response["result"]["status"] == "failed"
|
||||
assert "actions" in response["result"]
|
||||
31
scripts/mcp-server/tests/test_tools.py
Normal file
31
scripts/mcp-server/tests/test_tools.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Tool registry tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp_server import tools
|
||||
|
||||
MIN_TOOLS = 5
|
||||
|
||||
|
||||
def test_tool_catalog_has_minimum_tools() -> None:
|
||||
"""Catalog includes baseline tools."""
|
||||
catalog = tools.tool_catalog()
|
||||
assert len(catalog) >= MIN_TOOLS
|
||||
names = {tool.name for tool in catalog}
|
||||
assert "show-constitution" in names
|
||||
assert "list-playbooks" in names
|
||||
assert "show-reference" in names
|
||||
|
||||
|
||||
def test_invoke_tool_handles_unknown() -> None:
|
||||
"""Unknown tool returns unsupported guidance."""
|
||||
result = tools.invoke_tool("missing-tool", {})
|
||||
assert result["status"] == "unsupported"
|
||||
assert "listTools" in result["actions"][0]
|
||||
|
||||
|
||||
def test_list_tools_payload_shape() -> None:
|
||||
"""Payload includes tools key."""
|
||||
payload = tools.list_tools_payload()
|
||||
assert "tools" in payload
|
||||
assert all("name" in entry for entry in payload["tools"])
|
||||
@@ -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/
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user