diff --git a/README.md b/README.md index 128b0fd..d3e87c5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ through clawdforge. Spec: `Sulkta-Coop/openclaw-workspace/memory/spec-crafting-table.md` (LAN-only). -## Status — v0.1 step 6 of 10 +## Status — v0.1 step 7 of 10 - [x] Step 1: Dockerfile + per-language smoke - [x] Step 2: SQLite ledger + project registry @@ -23,7 +23,7 @@ Spec: `Sulkta-Coop/openclaw-workspace/memory/spec-crafting-table.md` (LAN-only). - [x] Step 4: Job runner core (asyncio worker pool, git worktree, subprocess) - [x] Step 5: Per-language parsers (Rust / Python / Go / TS first) - [x] Step 6: Findings extraction + storage -- [ ] Step 7: MCP server (stdio JSON-RPC, 8 tools) +- [x] Step 7: MCP server (stdio JSON-RPC, 8 tools) — see [mcp/README.md](mcp/README.md) - [x] Step 8: Email digest scheduler - [ ] Step 9: Autonomous patch loop (clawdforge integration) - [ ] Step 10: Production recipes — clawdforge, cauldron, tradecraft @@ -209,6 +209,7 @@ without lock contention. The runner is the only mutator of `jobs`/ │ ├── models.py # Pydantic schemas │ └── config.py # env-driven config ├── tests/ # pytest suite (~60 tests) +├── mcp/ # crafting-table-mcp — MCP stdio bridge (separate pip install) ├── pyproject.toml ├── requirements.txt └── .env.example @@ -331,6 +332,22 @@ curl -sH "Authorization: Bearer $ADMIN" \ Idempotency: `digest_runs` table holds `UNIQUE(date, project_name)`, so the 06:00 loop is safe to re-fire on the same day — only the first call sends. +## MCP bridge + +The `mcp/` subdirectory ships a self-contained `crafting-table-mcp` Python +package that exposes the HTTP API to MCP-aware clients (Claude Desktop, Claude +Code, Cursor, Zed, custom agents). See [mcp/README.md](mcp/README.md) for the +tool surface, installation, and configuration. + +Quickstart: + +```bash +pip install -e mcp +export CRAFTING_TABLE_BASE_URL=http://192.168.0.5:8810 +export CRAFTING_TABLE_TOKEN=ct_... +crafting-table-mcp # stdio JSON-RPC server +``` + ## License MIT diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..a60f86c --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,294 @@ +# crafting-table-mcp + +Model Context Protocol (MCP) server that bridges to the +[crafting-table](http://192.168.0.5:3001/Sulkta-Coop/crafting-table) LAN HTTP +service (port 8810). + +Drops the crafting-table tool surface into any MCP-aware client — Claude +Desktop, Claude Code, Cursor, Zed, custom agents — so the model can register +projects, kick off audit/build/test jobs, and read structured findings as +native tools. Same auth lives in one place on the LAN. + +## What it exposes + +| Tool | Backed by | Use it for | +| ----------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `crafting_table_list_projects` | `GET /projects` | Discover registered project names visible to this token. | +| `crafting_table_register_project` | `POST /projects` | Register a new project. Argument is `project_json` — a JSON-encoded string of the Project shape. | +| `crafting_table_run_audit` | `POST /projects/{name}/jobs` | Kick off an `audit` recipe job. Returns `{job_id, status}`. | +| `crafting_table_run_build` | `POST /projects/{name}/jobs` | Kick off a `build` recipe job. | +| `crafting_table_run_test` | `POST /projects/{name}/jobs` | Kick off a `test` recipe job. | +| `crafting_table_get_job` | `GET /jobs/{id}` | Poll a job for state + log tail. Two content blocks: prose summary + full JSON. | +| `crafting_table_get_findings` | `GET /jobs/{id}/findings` | Read structured findings (CVEs, lints, test failures) once a job has finished. | +| `crafting_table_draft_patch` | (wave 3 / step 9) | STUB — returns "not yet implemented". The surface is stable across waves so callers won't regress. | + +The admin endpoints (`/admin/tokens`) are deliberately NOT exposed — token +minting is a human-gated operation. + +## Install + +From a checkout of the crafting-table repo: + +```sh +pip install -e mcp +# or with test deps +pip install -e 'mcp[test]' +``` + +Once published: + +```sh +pip install crafting-table-mcp +``` + +This installs: + +- the `crafting_table_mcp` Python package +- a `crafting-table-mcp` console script (alias for `python -m crafting_table_mcp`) + +## Configure + +The server reads configuration from environment variables — your MCP client +sets these via its `env` block when it spawns the subprocess. + +| Variable | Default | Notes | +| ------------------------------ | ------------------------ | ----------------------------------------------------------- | +| `CRAFTING_TABLE_BASE_URL` | `http://localhost:8810` | Override to your crafting-table host (e.g. `http://192.168.0.5:8810`). | +| `CRAFTING_TABLE_TOKEN` | (required) | App bearer token (`ct_...`). Mint with `POST /admin/tokens` against the live service. | +| `CRAFTING_TABLE_MCP_LOG` | `WARNING` | Optional. Set `INFO` or `DEBUG` for stderr logs. | + +### Quickstart + +```bash +pip install crafting-table-mcp +export CRAFTING_TABLE_BASE_URL=http://192.168.0.5:8810 +export CRAFTING_TABLE_TOKEN=ct_... +crafting-table-mcp # stdio JSON-RPC server +``` + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json` +(macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "crafting-table": { + "command": "crafting-table-mcp", + "env": { + "CRAFTING_TABLE_BASE_URL": "http://192.168.0.5:8810", + "CRAFTING_TABLE_TOKEN": "ct_REPLACE_ME" + } + } + } +} +``` + +Or if you'd rather not rely on `crafting-table-mcp` being on `$PATH`: + +```json +{ + "mcpServers": { + "crafting-table": { + "command": "/usr/bin/python3", + "args": ["-m", "crafting_table_mcp"], + "env": { + "CRAFTING_TABLE_BASE_URL": "http://192.168.0.5:8810", + "CRAFTING_TABLE_TOKEN": "ct_REPLACE_ME" + } + } + } +} +``` + +A ready-to-paste version lives at `examples/claude-desktop.json`. + +### Claude Code + +Pass the config via `--mcp-config`: + +```sh +claude --mcp-config examples/claude-code.json +``` + +`examples/claude-code.json` follows the same `mcpServers` schema as Claude +Desktop. + +### Cursor / Zed / others + +Any client that follows the MCP server-spawn convention works the same way — +point it at the `crafting-table-mcp` command and pass `CRAFTING_TABLE_BASE_URL` +and `CRAFTING_TABLE_TOKEN` in the env block. + +## Tool reference + +### `crafting_table_list_projects` + +```jsonc +// args: none +// returns: [ +// {name, git_url, default_branch, languages, subprojects, schedule, notify, ...}, +// ... +// ] +``` + +Returns all projects visible to the bearer token. Use this before calling +any of the `run_*` tools to discover what `project_name` values are valid. + +### `crafting_table_register_project` + +```jsonc +// args: +{ + "project_json": "{\"name\": \"alpha\", \"git_url\": \"http://...\", \"subprojects\": [...]}" +} +// returns: the registered project dict. +``` + +`project_json` is a **JSON-encoded string** of the Project shape (not an +object). Encoding as a string keeps the MCP schema simple — the server-side +shape is rich enough that the LLM is much more reliable producing it as a +single JSON literal than as a deeply-nested schema-validated object. + +Project shape: + +```jsonc +{ + "name": "alpha", // slug, required + "git_url": "http://192.168.0.5:3001/.../alpha.git", // required + "default_branch": "main", // optional, default "main" + "languages": ["python", "rust"], // optional + "subprojects": [ + { + "path": "clients/python", + "language": "python", + "build": "pip install -e .[test]", + "test": "pytest tests/", + "lint": "ruff check . && mypy --strict src/", + "audit": "pip-audit", + "timeout_secs": 1800 + } + ], + "schedule": {"audit": "0 2 * * *"}, // optional cron strings + "notify": { // optional + "email": ["cobb@sulkta.com"], + "on": ["audit_fail", "cve_found"] + } +} +``` + +Returns 409 if a project with that name already exists for this token, 404 +if the name is taken under a different token (existence-leak guard). + +### `crafting_table_run_audit` / `run_build` / `run_test` + +```jsonc +// args: +{ + "project_name": "alpha", // required, slug from list_projects + "subproject": "clients/python", // optional, path inside repo + "branch": "main" // optional, default = project.default_branch +} +// returns: {"job_id": "", "status": "queued"} +``` + +Kicks off the named recipe. Returns immediately with the queued job id — +the actual pipeline runs in the runner pool. Poll `crafting_table_get_job` +to watch state, or call `crafting_table_get_findings` once status is +terminal (`succeeded`, `failed`, `timed_out`, `cancelled`). + +If `subproject` is omitted, the server picks the first subproject that +defines a non-empty command for the requested recipe kind. + +### `crafting_table_get_job` + +```jsonc +// args: +{ + "job_id": "" +} +// returns: two content blocks: +// [0] plain text — short summary (status, exit_code, last 30 log lines) +// [1] JSON — {"job": {...}, "log_tail": [...]} +``` + +The two-block layout means the LLM consumer sees the prose summary directly +without having to parse JSON, while tool-calling agents that want to +introspect can still get the full structured response from the second +block. + +### `crafting_table_get_findings` + +```jsonc +// args: +{ + "job_id": "" +} +// returns: two content blocks: +// [0] plain text — short summary ("3 finding(s). by severity: warn=2, high=1. by kind: lint=2, cve=1.") +// [1] JSON — {"findings": [{kind, severity, file?, line?, code?, message, suggested_fix?}, ...]} +``` + +Each finding has `{id, job_id, kind, severity, file?, line?, code?, +message, suggested_fix?, fingerprint, created_at}`. Empty list means +either the job had no issues OR there's no parser yet for the language — +check `crafting_table_get_job` for `exit_code` to disambiguate. + +### `crafting_table_draft_patch` + +```jsonc +// args: +{ + "job_id": "", // required + "finding_id": 42 // optional; if omitted, drafts patches for all open findings +} +// returns: {"ok": false, "pending": true, "message": "draft patch — not yet implemented (lands in wave 3 / step 9)"} +``` + +**Wave 2B stub.** The tool surface is callable today so MCP clients that +cache tool catalogues don't have to re-handshake when wave 3 ships. No +patch is drafted right now — the message says so explicitly. Once wave 3 +lands, the server will: + +1. Pull the finding(s) off the job +2. Send them to clawdforge with the project context +3. Get back a unified diff +4. Apply in a worktree, re-run the failing recipe +5. If it passes, push to a `crafting-table/auto/` branch and + open a Gitea PR + +## Testing + +```sh +pip install -e 'mcp[test]' +python -m pytest mcp/tests +``` + +The tests stub out the HTTP layer with `responses` — no live crafting-table +required. + +## Operational notes + +- **stdout is sacred.** The MCP transport pipes JSON-RPC frames over + stdin/stdout. Any stray `print()` in the server process corrupts the + stream. All diagnostics go to stderr via the `crafting_table_mcp` logger. +- **Errors are wrapped, not raised.** Auth failures, transport errors, + upstream 502s — all get formatted into a single short `crafting-table + error: ...` text content with `isError=True`. Callers see a clean message, + not a Python traceback. +- **Sync calls under async.** The MCP SDK is asyncio; our HTTP client is + blocking `requests`. Each tool offloads to `asyncio.to_thread` so a slow + HTTP call doesn't stall heartbeats. +- **404 hints.** When a tool surfaces a 404 (project or job not found), + the wrapper appends a hint pointing the LLM at + `crafting_table_list_projects` so it can self-recover. + +## Why this exists + +crafting-table centralizes the polyglot build/audit toolchain on one LAN +host so every Sulkta repo doesn't need each agent to ship its own swift / +rust / dotnet / php toolchain. MCP is the natural integration layer — any +MCP-aware client (Claude Desktop, Claude Code, Cursor, Zed, custom agents) +can now treat crafting-table as a native tool surface. Spec lives at +`memory/spec-crafting-table.md` in the openclaw-workspace repo (LAN-only). diff --git a/mcp/examples/claude-code.json b/mcp/examples/claude-code.json new file mode 100644 index 0000000..e863fc7 --- /dev/null +++ b/mcp/examples/claude-code.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "crafting-table": { + "command": "crafting-table-mcp", + "env": { + "CRAFTING_TABLE_BASE_URL": "http://192.168.0.5:8810", + "CRAFTING_TABLE_TOKEN": "ct_REPLACE_ME" + } + } + } +} diff --git a/mcp/examples/claude-desktop.json b/mcp/examples/claude-desktop.json new file mode 100644 index 0000000..e863fc7 --- /dev/null +++ b/mcp/examples/claude-desktop.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "crafting-table": { + "command": "crafting-table-mcp", + "env": { + "CRAFTING_TABLE_BASE_URL": "http://192.168.0.5:8810", + "CRAFTING_TABLE_TOKEN": "ct_REPLACE_ME" + } + } + } +} diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml new file mode 100644 index 0000000..9880d43 --- /dev/null +++ b/mcp/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "crafting-table-mcp" +version = "0.1.0" +description = "Model Context Protocol (MCP) server that bridges to crafting-table — exposes the build/test/audit job surface to MCP-aware clients (Claude Desktop, Claude Code, Cursor, Zed)." +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Kayos", email = "kayos@sulkta.com" }] +keywords = ["crafting-table", "mcp", "model-context-protocol", "claude", "sulkta", "audit", "build"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: POSIX :: Linux", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "mcp>=1.2.0", + "requests>=2.31.0", +] + +[project.optional-dependencies] +test = [ + "responses>=0.23", + "pytest>=7", + "pytest-asyncio>=0.21", +] + +[project.scripts] +crafting-table-mcp = "crafting_table_mcp.__main__:main" + +[project.urls] +Homepage = "http://192.168.0.5:3001/Sulkta-Coop/crafting-table" +Source = "http://192.168.0.5:3001/Sulkta-Coop/crafting-table" + +[tool.hatch.build.targets.wheel] +packages = ["src/crafting_table_mcp"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/crafting_table_mcp", + "README.md", + "pyproject.toml", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/mcp/src/crafting_table_mcp/__init__.py b/mcp/src/crafting_table_mcp/__init__.py new file mode 100644 index 0000000..7da919e --- /dev/null +++ b/mcp/src/crafting_table_mcp/__init__.py @@ -0,0 +1,14 @@ +"""crafting-table-mcp — Model Context Protocol server bridging to crafting-table. + +Exposes the crafting-table HTTP service (port 8810) as MCP tools so MCP-aware +clients (Claude Desktop, Claude Code, Cursor, Zed, custom agents) can register +projects, kick off audit/build/test jobs, and read structured findings as if +they were native model tools. + +Entry point: ``python -m crafting_table_mcp`` or the ``crafting-table-mcp`` +console script. +""" +from .server import build_server, run_stdio + +__all__ = ["build_server", "run_stdio"] +__version__ = "0.1.0" diff --git a/mcp/src/crafting_table_mcp/__main__.py b/mcp/src/crafting_table_mcp/__main__.py new file mode 100644 index 0000000..6a5d306 --- /dev/null +++ b/mcp/src/crafting_table_mcp/__main__.py @@ -0,0 +1,74 @@ +"""Entry point: ``python -m crafting_table_mcp``. + +Reads ``CRAFTING_TABLE_BASE_URL`` and ``CRAFTING_TABLE_TOKEN`` from the +environment (the MCP client is responsible for setting these via its ``env`` +block) and runs the MCP server over stdio. JSON-RPC frames flow on stdin / +stdout; everything else (including our own diagnostic prints) MUST go to +stderr or it will corrupt the protocol stream. +""" +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from .server import run_stdio + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="crafting-table-mcp", + description=( + "MCP server bridging to crafting-table. Set CRAFTING_TABLE_BASE_URL " + "and CRAFTING_TABLE_TOKEN in the environment. Speaks JSON-RPC on stdio." + ), + ) + parser.add_argument( + "--url", + default=None, + help=( + "Override CRAFTING_TABLE_BASE_URL (default: env or " + "http://localhost:8810)." + ), + ) + parser.add_argument( + "--check", + action="store_true", + help="Print resolved config to stderr and exit 0 without serving.", + ) + args = parser.parse_args(argv) + + url = ( + args.url + or os.environ.get("CRAFTING_TABLE_BASE_URL") + or "http://localhost:8810" + ) + token = os.environ.get("CRAFTING_TABLE_TOKEN", "") + + if args.check: + print( + f"crafting-table-mcp: url={url} token={'set' if token else 'MISSING'}", + file=sys.stderr, + ) + return 0 + + if not token: + print( + "crafting-table-mcp: CRAFTING_TABLE_TOKEN is not set in env. " + "Configure your MCP client to pass it via the server's env block. " + "Mint a token via POST /admin/tokens on the live crafting-table " + "service.", + file=sys.stderr, + ) + return 2 + + try: + asyncio.run(run_stdio(base_url=url, token=token)) + except KeyboardInterrupt: + return 130 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mcp/src/crafting_table_mcp/client.py b/mcp/src/crafting_table_mcp/client.py new file mode 100644 index 0000000..b59b03c --- /dev/null +++ b/mcp/src/crafting_table_mcp/client.py @@ -0,0 +1,376 @@ +"""Thin sync HTTP wrapper around the crafting-table service (port 8810). + +We deliberately keep this self-contained (just ``requests``) rather than +depending on the parent ``crafting_table`` server package. Reasons: + +- ``crafting-table-mcp`` ships independently and may be ``pip install``'d on a + host that doesn't have the server package published anywhere reachable + (e.g. a developer workstation that just wants the MCP bridge). +- The MCP server only needs the project + job + findings surface — pulling + the full FastAPI server (and its FastAPI / uvicorn / pydantic deps) is + overkill on a client host. + +Errors from this layer are surfaced as :class:`CraftingTableError` (and +subclasses) which the MCP server's tool handlers catch and reformat into MCP +error content. We never let a stack trace leak back through the JSON-RPC +pipe — clients show that to the model verbatim and it pollutes the context. + +Endpoints wrapped (server is at port 8810 by default): + +- ``GET /healthz`` — liveness + DB + runner stats +- ``GET /projects`` — list visible projects +- ``GET /projects/{name}`` — project detail +- ``POST /projects`` — register +- ``PUT /projects/{name}`` — update +- ``DELETE /projects/{name}`` — remove (cascades jobs+findings) +- ``POST /projects/{name}/jobs`` — enqueue a job (build/test/lint/audit) +- ``GET /jobs`` — list jobs (with project/status filters) +- ``GET /jobs/{id}`` — job detail + log_tail (last 200 lines) +- ``GET /jobs/{id}/findings`` — structured findings list +- ``GET /jobs/{id}/log`` — full log file (text/plain stream) + +Admin endpoints (``/admin/tokens``) are intentionally NOT wrapped — token +minting is a human-gated operation; an LLM client has no business poking at +it. +""" +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +import requests + + +_HEALTHZ_TIMEOUT_SECS = 10 +_DEFAULT_TIMEOUT_SECS = 30 +# Job creation kicks off a queue insert; the actual recipe runs out-of-band. +# 30s is generous for the synchronous part — anything longer is the runner +# itself, which we observe via separate get_job polls. + + +class CraftingTableError(Exception): + """Base error for the crafting-table HTTP wrapper.""" + + +class CraftingTableTransportError(CraftingTableError): + """Connection / TCP / DNS / TLS failure — no HTTP response.""" + + +class CraftingTableAPIError(CraftingTableError): + """4xx / 5xx response. ``status_code`` and ``body`` are populated.""" + + def __init__( + self, + message: str, + *, + status_code: int, + body: dict[str, Any] | str | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + self.message = message + + +class CraftingTableAuthError(CraftingTableAPIError): + """401 / 403 — bad token or IP not allowed.""" + + +class CraftingTableNotFoundError(CraftingTableAPIError): + """404 — project, job, or finding not found (or not visible to this token). + + Surfaced as a distinct class so the MCP layer can tell the LLM something + actionable like "project not found — call crafting_table_list_projects to + see registered names" rather than a raw 404. + """ + + +class CraftingTableClient: + """Minimal sync client for the crafting-table HTTP API. + + One instance per MCP server process. Holds a ``requests.Session`` for + keep-alive across tool calls. Not thread-safe — but the MCP server is + asyncio-single-threaded, and we only ever call this from + ``asyncio.to_thread``, so a single shared instance is fine. + """ + + def __init__( + self, + base_url: str, + token: str, + timeout_secs: int = _DEFAULT_TIMEOUT_SECS, + *, + session: requests.Session | None = None, + ) -> None: + if not base_url: + raise ValueError("base_url is required") + if not token: + raise ValueError("token is required") + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_secs = timeout_secs + self._session = session or requests.Session() + self._owns_session = session is None + + def close(self) -> None: + if self._owns_session: + self._session.close() + + # -- internals --------------------------------------------------------- + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.token}"} + + def _request( + self, + method: str, + path: str, + *, + json_body: dict | None = None, + params: dict | None = None, + timeout: float | None = None, + ) -> Any: + try: + resp = self._session.request( + method, + f"{self.base_url}{path}", + headers=self._headers(), + json=json_body, + params=params, + timeout=timeout if timeout is not None else self.timeout_secs, + ) + except requests.RequestException as e: + raise CraftingTableTransportError(f"transport: {e}") from e + + try: + body = resp.json() + except ValueError: + body = resp.text or None + + if resp.status_code >= 400: + short = "" + if isinstance(body, dict): + # FastAPI's HTTPException serializes as {"detail": "..."}; + # the crafting-table server uses HTTPException everywhere, so + # `detail` is the canonical actionable field. + short = body.get("detail") or body.get("error") or "" + elif isinstance(body, str): + short = body[:200] + msg = f"{resp.status_code} {resp.reason}: {short}".rstrip(": ") + if resp.status_code == 404: + raise CraftingTableNotFoundError( + msg, status_code=resp.status_code, body=body + ) + if resp.status_code in (401, 403): + raise CraftingTableAuthError( + msg, status_code=resp.status_code, body=body + ) + raise CraftingTableAPIError( + msg, status_code=resp.status_code, body=body + ) + + return body + + def _get_text(self, path: str, *, timeout: float | None = None) -> str: + """GET that wants the response body as raw text (not JSON). + + Used by ``get_log`` — the server streams ``text/plain`` for the full + log file. We still surface auth / 404 errors via the same exception + hierarchy so callers don't have to special-case this method. + """ + try: + resp = self._session.request( + "GET", + f"{self.base_url}{path}", + headers=self._headers(), + timeout=timeout if timeout is not None else self.timeout_secs, + ) + except requests.RequestException as e: + raise CraftingTableTransportError(f"transport: {e}") from e + + if resp.status_code >= 400: + try: + body = resp.json() + short = body.get("detail") or body.get("error") or "" + except ValueError: + body = resp.text or None + short = (resp.text or "")[:200] + msg = f"{resp.status_code} {resp.reason}: {short}".rstrip(": ") + if resp.status_code == 404: + raise CraftingTableNotFoundError( + msg, status_code=resp.status_code, body=body + ) + if resp.status_code in (401, 403): + raise CraftingTableAuthError( + msg, status_code=resp.status_code, body=body + ) + raise CraftingTableAPIError( + msg, status_code=resp.status_code, body=body + ) + + return resp.text + + # -- endpoints --------------------------------------------------------- + + def healthz(self) -> dict: + payload = self._request( + "GET", "/healthz", timeout=_HEALTHZ_TIMEOUT_SECS + ) + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected /healthz response type: {type(payload).__name__}" + ) + return payload + + def list_projects(self) -> list[dict]: + payload = self._request("GET", "/projects") + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected /projects response type: {type(payload).__name__}" + ) + rows = payload.get("projects") or [] + if not isinstance(rows, list): + raise CraftingTableError("/projects: 'projects' was not a list") + return rows + + def get_project(self, name: str) -> dict: + if not name: + raise ValueError("project name must be non-empty") + slug = quote(name, safe="") + payload = self._request("GET", f"/projects/{slug}") + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected /projects/{{name}} response type: " + f"{type(payload).__name__}" + ) + proj = payload.get("project") + if not isinstance(proj, dict): + raise CraftingTableError( + "/projects/{name}: 'project' missing or not an object" + ) + return proj + + def register_project(self, project: dict) -> dict: + if not isinstance(project, dict): + raise ValueError("project must be a dict") + if not project.get("name"): + raise ValueError("project.name is required") + payload = self._request("POST", "/projects", json_body=project) + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected POST /projects response type: " + f"{type(payload).__name__}" + ) + return payload.get("project") or payload + + def update_project(self, name: str, project: dict) -> dict: + if not name: + raise ValueError("project name must be non-empty") + if not isinstance(project, dict): + raise ValueError("project must be a dict") + slug = quote(name, safe="") + payload = self._request( + "PUT", f"/projects/{slug}", json_body=project + ) + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected PUT /projects/{{name}} response type: " + f"{type(payload).__name__}" + ) + return payload.get("project") or payload + + def delete_project(self, name: str) -> dict: + if not name: + raise ValueError("project name must be non-empty") + slug = quote(name, safe="") + payload = self._request("DELETE", f"/projects/{slug}") + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected DELETE /projects/{{name}} response type: " + f"{type(payload).__name__}" + ) + return payload + + def create_job( + self, + project: str, + recipe: str, + subproject: str | None = None, + branch: str | None = None, + ) -> dict: + if not project: + raise ValueError("project must be non-empty") + if not recipe: + raise ValueError("recipe must be non-empty") + body: dict[str, Any] = {"recipe": recipe} + if subproject is not None: + body["subproject"] = subproject + if branch is not None: + body["branch"] = branch + slug = quote(project, safe="") + payload = self._request( + "POST", f"/projects/{slug}/jobs", json_body=body + ) + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected POST /projects/{{name}}/jobs response type: " + f"{type(payload).__name__}" + ) + return payload + + def get_job(self, job_id: str) -> dict: + if not job_id: + raise ValueError("job_id must be non-empty") + slug = quote(job_id, safe="") + payload = self._request("GET", f"/jobs/{slug}") + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected /jobs/{{id}} response type: " + f"{type(payload).__name__}" + ) + return payload + + def list_jobs( + self, + *, + project: str | None = None, + status: str | None = None, + limit: int = 50, + ) -> list[dict]: + params: dict[str, Any] = {"limit": int(limit)} + if project is not None: + params["project"] = project + if status is not None: + params["status"] = status + payload = self._request("GET", "/jobs", params=params) + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected /jobs response type: {type(payload).__name__}" + ) + rows = payload.get("jobs") or [] + if not isinstance(rows, list): + raise CraftingTableError("/jobs: 'jobs' was not a list") + return rows + + def get_findings(self, job_id: str) -> list[dict]: + if not job_id: + raise ValueError("job_id must be non-empty") + slug = quote(job_id, safe="") + payload = self._request("GET", f"/jobs/{slug}/findings") + if not isinstance(payload, dict): + raise CraftingTableError( + f"unexpected /jobs/{{id}}/findings response type: " + f"{type(payload).__name__}" + ) + rows = payload.get("findings") or [] + if not isinstance(rows, list): + raise CraftingTableError( + "/jobs/{id}/findings: 'findings' was not a list" + ) + return rows + + def get_log(self, job_id: str) -> str: + if not job_id: + raise ValueError("job_id must be non-empty") + slug = quote(job_id, safe="") + return self._get_text(f"/jobs/{slug}/log") diff --git a/mcp/src/crafting_table_mcp/server.py b/mcp/src/crafting_table_mcp/server.py new file mode 100644 index 0000000..37256b4 --- /dev/null +++ b/mcp/src/crafting_table_mcp/server.py @@ -0,0 +1,692 @@ +"""MCP server implementation for crafting-table. + +Eight tools are exposed (per spec ``memory/spec-crafting-table.md``): + +- ``crafting_table_list_projects`` — list registered projects. +- ``crafting_table_register_project`` — register a new project from JSON. +- ``crafting_table_run_audit`` — kick off an ``audit`` recipe job. +- ``crafting_table_run_build`` — kick off a ``build`` recipe job. +- ``crafting_table_run_test`` — kick off a ``test`` recipe job. +- ``crafting_table_get_job`` — fetch job state + log tail. +- ``crafting_table_get_findings`` — fetch structured findings. +- ``crafting_table_draft_patch`` — wave-3 stub; returns "not yet + implemented" so the tool surface is stable but no work happens. + +Admin endpoints (``/admin/tokens``) are intentionally NOT exposed. Token +minting is a human-gated operation; an LLM client has no business poking at +it. + +Design notes +------------ +- The MCP SDK is asyncio-native. Our underlying ``requests`` wrapper is + blocking, so each tool offloads HTTP work via ``asyncio.to_thread`` to + avoid stalling the event loop. +- Most tool results return a single ``mcp.types.TextContent`` block with a + JSON body. ``crafting_table_get_job`` and ``crafting_table_get_findings`` + return TWO blocks following the clawdforge ``session_turn`` pattern: + block 0 is a human-readable prose summary, block 1 is the full JSON. The + prose block is what the LLM reads first; the JSON block preserves + structured detail for tool-calling agents that want to introspect. +- 404 responses surface as MCP errors with actionable messages — e.g. the + ``crafting_table_list_projects`` hint when a registered name can't be + found. +- Errors NEVER raise raw out of the tool handler; they are wrapped into + ``isError=True`` results with a clean message. Raising would surface a + Python traceback through the JSON-RPC layer, which is both ugly and + potentially leaky (token strings, internal paths). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any + +import mcp.types as types +from mcp.server import Server +from mcp.server.stdio import stdio_server + +from .client import ( + CraftingTableAPIError, + CraftingTableAuthError, + CraftingTableClient, + CraftingTableError, + CraftingTableNotFoundError, + CraftingTableTransportError, +) + + +logger = logging.getLogger("crafting_table_mcp") + + +# --------------------------------------------------------------------------- +# Tool name constants +# --------------------------------------------------------------------------- + +TOOL_LIST_PROJECTS = "crafting_table_list_projects" +TOOL_REGISTER_PROJECT = "crafting_table_register_project" +TOOL_RUN_AUDIT = "crafting_table_run_audit" +TOOL_RUN_BUILD = "crafting_table_run_build" +TOOL_RUN_TEST = "crafting_table_run_test" +TOOL_GET_JOB = "crafting_table_get_job" +TOOL_GET_FINDINGS = "crafting_table_get_findings" +TOOL_DRAFT_PATCH = "crafting_table_draft_patch" + + +_RUN_TOOLS = (TOOL_RUN_AUDIT, TOOL_RUN_BUILD, TOOL_RUN_TEST) +_RECIPE_BY_TOOL = { + TOOL_RUN_AUDIT: "audit", + TOOL_RUN_BUILD: "build", + TOOL_RUN_TEST: "test", +} + + +# --------------------------------------------------------------------------- +# Tool definitions +# --------------------------------------------------------------------------- + + +def _run_recipe_input_schema(recipe_label: str) -> dict[str, Any]: + """Shared input schema for run_audit / run_build / run_test. + + Same shape across the three: required project_name, optional subproject + (path within the repo), optional branch override. + """ + return { + "type": "object", + "properties": { + "project_name": { + "type": "string", + "minLength": 1, + "description": ( + f"Name of a registered project (slug) to run the " + f"{recipe_label} recipe against. Use " + f"crafting_table_list_projects to discover registered " + f"names." + ), + }, + "subproject": { + "type": "string", + "description": ( + "Optional subproject path inside the repo (e.g. " + "'clients/python', 'clients/rust'). If omitted, the " + f"server picks the first subproject that defines a " + f"'{recipe_label}' command." + ), + }, + "branch": { + "type": "string", + "description": ( + "Optional git branch override. Defaults to the " + "project's default_branch." + ), + }, + }, + "required": ["project_name"], + "additionalProperties": False, + } + + +def _tool_definitions() -> list[types.Tool]: + """Return the static MCP Tool definitions list. + + Descriptions are written for an LLM consumer — they should make it + obvious WHEN to use the tool, not just WHAT it does. + """ + return [ + types.Tool( + name=TOOL_LIST_PROJECTS, + description=( + "List projects registered on the crafting-table service " + "visible to this token. Use this to discover what project " + "names exist before calling crafting_table_run_audit / " + "run_build / run_test, or before registering a duplicate. " + "Returns an array of project objects (name, git_url, " + "default_branch, languages, subprojects, schedule, notify). " + "No arguments." + ), + inputSchema={ + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + ), + types.Tool( + name=TOOL_REGISTER_PROJECT, + description=( + "Register a new project on the crafting-table service. The " + "argument is project_json — a JSON STRING (not an object) " + "matching the Project schema: {name, git_url, " + "default_branch?, languages?, subprojects[], schedule?, " + "notify?}. Each subproject entry is {path, language, build?, " + "test?, lint?, audit?, timeout_secs?}. The server validates " + "the shape; 409 if a project with that name already exists " + "for this token, 404 if the name is taken under another " + "token (existence-leak guard). Returns the registered " + "project." + ), + inputSchema={ + "type": "object", + "properties": { + "project_json": { + "type": "string", + "minLength": 2, + "description": ( + "A JSON-encoded string of the Project shape. " + "Must parse to an object with at minimum 'name' " + "and 'git_url'." + ), + }, + }, + "required": ["project_json"], + "additionalProperties": False, + }, + ), + types.Tool( + name=TOOL_RUN_AUDIT, + description=( + "Run the audit recipe on a registered project. Returns " + "{job_id, status}; poll crafting_table_get_job for " + "completion. Use crafting_table_get_findings(job_id) once " + "status is 'succeeded' or 'failed' to read the structured " + "findings (CVEs, lints, test failures). 404 if " + "project_name doesn't exist or isn't visible to this token." + ), + inputSchema=_run_recipe_input_schema("audit"), + ), + types.Tool( + name=TOOL_RUN_BUILD, + description=( + "Run the build recipe on a registered project (compile / " + "package step). Returns {job_id, status}; poll " + "crafting_table_get_job for completion. The log_tail in the " + "get_job response surfaces compiler errors directly. 404 if " + "the project doesn't exist or has no 'build' command " + "defined on any subproject." + ), + inputSchema=_run_recipe_input_schema("build"), + ), + types.Tool( + name=TOOL_RUN_TEST, + description=( + "Run the test recipe on a registered project. Returns " + "{job_id, status}; poll crafting_table_get_job for " + "completion. Test failures appear both in log_tail " + "(human-readable) and in crafting_table_get_findings " + "(structured) once a parser exists for that language. 404 " + "if the project doesn't exist or has no 'test' command." + ), + inputSchema=_run_recipe_input_schema("test"), + ), + types.Tool( + name=TOOL_GET_JOB, + description=( + "Fetch job state plus the last 200 log lines. Returned in " + "two content blocks: block 0 is a human-readable summary " + "(status, exit code, recent log tail), block 1 is the full " + "JSON {job, log_tail} for tool-calling traceability. Status " + "values: queued, running, succeeded, failed, timed_out, " + "cancelled. Once status is terminal ('succeeded'/'failed'/" + "'timed_out'/'cancelled') call crafting_table_get_findings " + "for the structured output. 404 if the job doesn't exist " + "or isn't visible." + ), + inputSchema={ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "minLength": 1, + "description": ( + "Job UUID returned from a previous " + "crafting_table_run_* call." + ), + }, + }, + "required": ["job_id"], + "additionalProperties": False, + }, + ), + types.Tool( + name=TOOL_GET_FINDINGS, + description=( + "Fetch the structured findings list for a completed job. " + "Returned in two content blocks: block 0 is a human-readable " + "summary (count by severity), block 1 is the full JSON " + "list. Each finding has {kind, severity, file?, line?, " + "code?, message, suggested_fix?}. Empty list means either " + "no issues OR no parser exists yet for that language — " + "check crafting_table_get_job for exit_code in that case. " + "404 if the job doesn't exist." + ), + inputSchema={ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "minLength": 1, + "description": ( + "Job UUID. Findings are most useful once the " + "job has reached a terminal status." + ), + }, + }, + "required": ["job_id"], + "additionalProperties": False, + }, + ), + types.Tool( + name=TOOL_DRAFT_PATCH, + description=( + "Draft a patch (unified diff) addressing one or more " + "findings on a job. WAVE 2B STUB — full implementation " + "lands in wave 3 / step 9 of the v0.1 plan. Today this tool " + "is callable but only returns a 'not yet implemented' " + "message; the surface exists so tool catalogues stay stable " + "across waves. Once shipped, the patch will be drafted via " + "clawdforge and applied to a worktree, with a Gitea PR " + "opened on the configured branch." + ), + inputSchema={ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "minLength": 1, + "description": "Job UUID whose findings to patch.", + }, + "finding_id": { + "type": "integer", + "minimum": 1, + "description": ( + "Optional specific finding id. If omitted, " + "drafts patches for all open findings on the " + "job." + ), + }, + }, + "required": ["job_id"], + "additionalProperties": False, + }, + ), + ] + + +# --------------------------------------------------------------------------- +# Tool result helpers +# --------------------------------------------------------------------------- + + +def _ok_content(payload: Any) -> list[types.TextContent]: + """Wrap a successful single-block tool result as MCP content. + + JSON encoding so structured results (dicts, lists) survive intact. The + MCP client typically passes the text into the LLM verbatim, and JSON is + the lowest-friction shape to re-parse on the model side. + """ + if isinstance(payload, str): + text = payload + else: + try: + text = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except (TypeError, ValueError): + text = str(payload) + return [types.TextContent(type="text", text=text)] + + +def _err_content(message: str) -> list[types.TextContent]: + """Wrap a failure as a single text block. + + The CallToolResult around this gets marked ``isError=True`` upstream + (see :func:`_call_tool`). We keep the message tight — no traceback, + no token strings. + """ + return [ + types.TextContent( + type="text", text=f"crafting-table error: {message}" + ) + ] + + +def _two_block_content(prose: str, payload: Any) -> list[types.TextContent]: + """Two-block return: human-readable prose + full JSON.""" + try: + body = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except (TypeError, ValueError): + body = str(payload) + return [ + types.TextContent(type="text", text=prose), + types.TextContent(type="text", text=body), + ] + + +def _format_create_job_response(payload: dict) -> dict[str, Any]: + """Whitelist the fields surfaced from a /jobs POST response. + + Server returns ``{ok, job_id, status, job}``. We expose the three + caller-actionable keys; ``job`` (the full row) is omitted because the + LLM gets enough from ``{job_id, status}`` to start polling. + """ + return { + "job_id": payload.get("job_id"), + "status": payload.get("status"), + } + + +def _format_job_summary(payload: dict) -> str: + """Short prose summary for crafting_table_get_job's first content block.""" + job = payload.get("job") or {} + log_tail = payload.get("log_tail") or [] + status = job.get("status", "?") + exit_code = job.get("exit_code") + project = job.get("project_name", "?") + recipe = job.get("recipe", "?") + subproject = job.get("subproject_path", ".") + parts = [ + f"job {job.get('id', '?')} | {project}::{subproject} | " + f"recipe={recipe} | status={status}" + ] + if exit_code is not None: + parts.append(f"exit_code={exit_code}") + if log_tail: + # Cap at last ~30 lines for the prose — the full tail is in block 1. + tail_preview = "\n".join(log_tail[-30:]) + parts.append("--- log tail (last 30 lines) ---") + parts.append(tail_preview) + return "\n".join(parts) + + +def _format_findings_summary(findings: list[dict]) -> str: + """Short prose summary for crafting_table_get_findings's first block.""" + if not findings: + return ( + "no findings reported (job may have passed cleanly, or the " + "language parser may not have produced structured output yet — " + "check crafting_table_get_job for exit_code)" + ) + by_sev: dict[str, int] = {} + by_kind: dict[str, int] = {} + for f in findings: + if not isinstance(f, dict): + continue + sev = str(f.get("severity") or "?") + by_sev[sev] = by_sev.get(sev, 0) + 1 + kind = str(f.get("kind") or "?") + by_kind[kind] = by_kind.get(kind, 0) + 1 + sev_str = ", ".join(f"{k}={v}" for k, v in sorted(by_sev.items())) + kind_str = ", ".join(f"{k}={v}" for k, v in sorted(by_kind.items())) + return ( + f"{len(findings)} finding(s). " + f"by severity: {sev_str}. " + f"by kind: {kind_str}." + ) + + +def _format_error(e: CraftingTableError) -> str: + """Render a CraftingTableError as a single short string for an LLM.""" + if isinstance(e, CraftingTableAuthError): + return ( + f"auth failed ({e.status_code}). Check CRAFTING_TABLE_TOKEN " + f"and IP allowlist." + ) + if isinstance(e, CraftingTableNotFoundError): + # Concrete actionable hint on 404 — most useful failure mode for an + # LLM to recover from. + return ( + f"not found ({e.status_code}): {e.message}. " + f"Try crafting_table_list_projects to discover registered " + f"names, or check the job_id from a recent run_* call." + ) + if isinstance(e, CraftingTableAPIError): + if isinstance(e.body, dict) and e.body.get("detail"): + return f"api {e.status_code}: {e.body['detail']}" + return f"api {e.status_code}: {e.message}" + if isinstance(e, CraftingTableTransportError): + return f"transport: {e}" + return f"crafting-table: {e}" + + +# --------------------------------------------------------------------------- +# Tool dispatch +# --------------------------------------------------------------------------- + + +async def _dispatch( + ct: CraftingTableClient, + name: str, + arguments: dict[str, Any] | None, +) -> tuple[list[types.TextContent], bool]: + """Run a tool. Returns (content, is_error).""" + args = arguments or {} + + try: + if name == TOOL_LIST_PROJECTS: + payload = await asyncio.to_thread(ct.list_projects) + return _ok_content(payload), False + + if name == TOOL_REGISTER_PROJECT: + project_json = args.get("project_json") + if not isinstance(project_json, str) or not project_json.strip(): + return ( + _err_content( + "missing or empty 'project_json' argument (must be " + "a JSON string of the Project shape)" + ), + True, + ) + try: + project_obj = json.loads(project_json) + except json.JSONDecodeError as je: + return ( + _err_content( + f"project_json is not valid JSON: {je.msg} at " + f"line {je.lineno} col {je.colno}" + ), + True, + ) + if not isinstance(project_obj, dict): + return ( + _err_content( + "project_json must decode to a JSON object, got " + f"{type(project_obj).__name__}" + ), + True, + ) + try: + payload = await asyncio.to_thread( + ct.register_project, project_obj + ) + except ValueError as ve: + return _err_content(str(ve)), True + return _ok_content(payload), False + + if name in _RUN_TOOLS: + recipe = _RECIPE_BY_TOOL[name] + project_name = args.get("project_name") + if not isinstance(project_name, str) or not project_name: + return ( + _err_content( + "missing or empty 'project_name' argument" + ), + True, + ) + subproject = args.get("subproject") + if subproject is not None and not isinstance(subproject, str): + return _err_content("'subproject' must be a string"), True + branch = args.get("branch") + if branch is not None and not isinstance(branch, str): + return _err_content("'branch' must be a string"), True + try: + payload = await asyncio.to_thread( + ct.create_job, + project=project_name, + recipe=recipe, + subproject=subproject if subproject else None, + branch=branch if branch else None, + ) + except ValueError as ve: + return _err_content(str(ve)), True + return _ok_content(_format_create_job_response(payload)), False + + if name == TOOL_GET_JOB: + job_id = args.get("job_id") + if not isinstance(job_id, str) or not job_id: + return _err_content("missing or empty 'job_id' argument"), True + try: + payload = await asyncio.to_thread(ct.get_job, job_id) + except ValueError as ve: + return _err_content(str(ve)), True + prose = _format_job_summary(payload) + return _two_block_content(prose, payload), False + + if name == TOOL_GET_FINDINGS: + job_id = args.get("job_id") + if not isinstance(job_id, str) or not job_id: + return _err_content("missing or empty 'job_id' argument"), True + try: + findings = await asyncio.to_thread(ct.get_findings, job_id) + except ValueError as ve: + return _err_content(str(ve)), True + prose = _format_findings_summary(findings) + return ( + _two_block_content(prose, {"findings": findings}), + False, + ) + + if name == TOOL_DRAFT_PATCH: + # Wave-2B stub: validate args lightly, return a stable message. + # Once wave-3 lands this whole branch becomes a real call to a + # /jobs/{id}/patch endpoint that drafts via clawdforge. + job_id = args.get("job_id") + if not isinstance(job_id, str) or not job_id: + return _err_content("missing or empty 'job_id' argument"), True + finding_id = args.get("finding_id") + if finding_id is not None and not ( + isinstance(finding_id, int) + and not isinstance(finding_id, bool) + ): + return _err_content("'finding_id' must be an integer"), True + return ( + _ok_content( + { + "ok": False, + "pending": True, + "message": ( + "draft patch — not yet implemented (lands in " + "wave 3 / step 9). The tool surface is stable; " + "callers can keep referencing it. Today no " + "patch is drafted." + ), + "job_id": job_id, + "finding_id": finding_id, + } + ), + False, + ) + + return _err_content(f"unknown tool: {name}"), True + + except CraftingTableError as ce: + logger.warning("crafting-table error in tool %s: %s", name, ce) + return _err_content(_format_error(ce)), True + except Exception as e: # pragma: no cover - defensive + # Full detail (including stack + str(e)) goes to stderr via + # logger.exception. The message we hand back to the LLM intentionally + # omits ``str(e)`` because Python builtin exceptions like + # FileNotFoundError / PermissionError stringify to host paths. + logger.exception("unexpected error in tool %s", name) + return ( + _err_content( + f"unexpected internal error ({type(e).__name__})" + ), + True, + ) + + +# --------------------------------------------------------------------------- +# Server wiring +# --------------------------------------------------------------------------- + + +def build_server(ct: CraftingTableClient) -> Server: + """Build an MCP :class:`Server` bound to the given crafting-table client. + + Split out so tests can construct a server with a mock client without + touching stdio. + """ + server: Server = Server("crafting-table-mcp") + + @server.list_tools() + async def _list_tools() -> list[types.Tool]: + return _tool_definitions() + + @server.call_tool() + async def _call_tool( + name: str, arguments: dict[str, Any] | None + ) -> list[types.TextContent]: + content, is_error = await _dispatch(ct, name, arguments) + if is_error: + # Raising lets the SDK marshal isError=True into the response. + # We use a plain RuntimeError with the already-formatted message + # rather than letting an arbitrary traceback through. + raise RuntimeError(content[0].text) + return content + + return server + + +async def run_stdio(*, base_url: str, token: str) -> None: + """Run the MCP server forever on stdio. Returns when stdin closes.""" + ct = CraftingTableClient(base_url=base_url, token=token) + server = build_server(ct) + init_options = server.create_initialization_options() + try: + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, init_options) + finally: + ct.close() + + +def main(argv: list[str] | None = None) -> int: + """Console-script entry point. + + Lives here so the ``crafting-table-mcp`` script declared in + ``pyproject.toml`` can target ``crafting_table_mcp.server:main``. The + actual logic is in :mod:`crafting_table_mcp.__main__` to keep import + side-effects minimal. + """ + from .__main__ import main as _main + + return _main(argv) + + +__all__ = [ + "build_server", + "run_stdio", + "main", + "TOOL_LIST_PROJECTS", + "TOOL_REGISTER_PROJECT", + "TOOL_RUN_AUDIT", + "TOOL_RUN_BUILD", + "TOOL_RUN_TEST", + "TOOL_GET_JOB", + "TOOL_GET_FINDINGS", + "TOOL_DRAFT_PATCH", +] + + +# Configure logging to stderr (stdout is reserved for JSON-RPC framing). +if not logger.handlers: + _h = logging.StreamHandler() + _h.setFormatter( + logging.Formatter("crafting-table-mcp [%(levelname)s] %(message)s") + ) + logger.addHandler(_h) + logger.setLevel( + os.environ.get("CRAFTING_TABLE_MCP_LOG", "WARNING").upper() + ) + # Defense-in-depth against an embedding application reconfiguring the + # root logger to a stdout StreamHandler — that would corrupt our + # JSON-RPC framing. Our own handler is stderr-bound regardless. + logger.propagate = False diff --git a/mcp/tests/__init__.py b/mcp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp/tests/conftest.py b/mcp/tests/conftest.py new file mode 100644 index 0000000..3ba957a --- /dev/null +++ b/mcp/tests/conftest.py @@ -0,0 +1,39 @@ +"""Shared pytest fixtures for the crafting-table-mcp test suite. + +Keeps each test module slim — every test wants a fresh ``CraftingTableClient`` +pointed at the in-process ``responses`` mock and a tight timeout so a hung +test fails fast rather than hanging CI. +""" +from __future__ import annotations + +import pytest + +from crafting_table_mcp.client import CraftingTableClient + + +BASE_URL = "http://192.168.0.5:8810" +TOKEN = "ct_test_token_xxxxxxxx" + + +@pytest.fixture +def base_url() -> str: + return BASE_URL + + +@pytest.fixture +def token() -> str: + return TOKEN + + +@pytest.fixture +def client(): + """Yield a CraftingTableClient configured for the mock URL. + + Closes the underlying ``requests.Session`` on test exit so a leaked + connection in one test doesn't contaminate the next one's mocks. + """ + c = CraftingTableClient(base_url=BASE_URL, token=TOKEN, timeout_secs=10) + try: + yield c + finally: + c.close() diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py new file mode 100644 index 0000000..e76cf09 --- /dev/null +++ b/mcp/tests/test_client.py @@ -0,0 +1,641 @@ +"""HTTP-level tests for ``crafting_table_mcp.client.CraftingTableClient``. + +We stub the wire layer with ``responses`` and assert each method: + +- hits the correct endpoint with the correct shape (verb, path, body, params) +- raises the right exception class for each well-known status code +- handles malformed JSON / unexpected response types without crashing +""" +from __future__ import annotations + +import json +import unittest + +import responses + +from crafting_table_mcp.client import ( + CraftingTableAPIError, + CraftingTableAuthError, + CraftingTableClient, + CraftingTableError, + CraftingTableNotFoundError, + CraftingTableTransportError, +) + + +BASE_URL = "http://192.168.0.5:8810" +TOKEN = "ct_test_token_xxxxxxxx" + + +def _client() -> CraftingTableClient: + return CraftingTableClient(base_url=BASE_URL, token=TOKEN, timeout_secs=10) + + +class TestConstructorValidation(unittest.TestCase): + def test_requires_base_url(self) -> None: + with self.assertRaises(ValueError): + CraftingTableClient(base_url="", token=TOKEN) + + def test_requires_token(self) -> None: + with self.assertRaises(ValueError): + CraftingTableClient(base_url=BASE_URL, token="") + + def test_strips_trailing_slash(self) -> None: + c = CraftingTableClient(base_url=BASE_URL + "/", token=TOKEN) + self.assertEqual(c.base_url, BASE_URL) + + +class TestHealthz(unittest.TestCase): + @responses.activate + def test_healthz_ok(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/healthz", + json={"ok": True, "db": "ok", "version": "0.1.0"}, + status=200, + ) + c = _client() + try: + r = c.healthz() + finally: + c.close() + self.assertEqual(r["ok"], True) + self.assertEqual(r["version"], "0.1.0") + + @responses.activate + def test_healthz_sends_bearer(self) -> None: + captured: dict = {} + + def cb(request): + captured["auth"] = request.headers.get("Authorization") + return (200, {}, json.dumps({"ok": True})) + + responses.add_callback(responses.GET, f"{BASE_URL}/healthz", callback=cb) + c = _client() + try: + c.healthz() + finally: + c.close() + self.assertEqual(captured["auth"], f"Bearer {TOKEN}") + + +class TestProjects(unittest.TestCase): + @responses.activate + def test_list_projects_returns_array(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={ + "ok": True, + "projects": [ + {"name": "alpha", "git_url": "..."}, + {"name": "bravo", "git_url": "..."}, + ], + }, + status=200, + ) + c = _client() + try: + rows = c.list_projects() + finally: + c.close() + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["name"], "alpha") + + @responses.activate + def test_list_projects_handles_empty(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"ok": True, "projects": []}, + status=200, + ) + c = _client() + try: + rows = c.list_projects() + finally: + c.close() + self.assertEqual(rows, []) + + @responses.activate + def test_get_project_path_and_unwrap(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects/alpha", + json={"ok": True, "project": {"name": "alpha", "git_url": "..."}}, + status=200, + ) + c = _client() + try: + p = c.get_project("alpha") + finally: + c.close() + self.assertEqual(p["name"], "alpha") + + def test_get_project_rejects_empty_name(self) -> None: + c = _client() + try: + with self.assertRaises(ValueError): + c.get_project("") + finally: + c.close() + + @responses.activate + def test_get_project_url_quoted(self) -> None: + # Slash in a project name shouldn't break the URL — the client + # quotes it. Server-side validation is a separate concern; the + # client must not produce malformed paths. + captured: dict = {} + + def cb(request): + captured["url"] = request.url + return (200, {}, json.dumps({"ok": True, "project": {"name": "x"}})) + + responses.add_callback( + responses.GET, + f"{BASE_URL}/projects/weird%2Fname", + callback=cb, + ) + c = _client() + try: + c.get_project("weird/name") + finally: + c.close() + self.assertIn("weird%2Fname", captured["url"]) + + @responses.activate + def test_register_project_posts_body(self) -> None: + captured: dict = {} + + def cb(request): + captured["body"] = json.loads(request.body) + return ( + 200, + {}, + json.dumps( + {"ok": True, "project": {"name": "alpha", "git_url": "x"}} + ), + ) + + responses.add_callback(responses.POST, f"{BASE_URL}/projects", callback=cb) + c = _client() + try: + r = c.register_project( + {"name": "alpha", "git_url": "http://example/x.git"} + ) + finally: + c.close() + self.assertEqual(captured["body"]["name"], "alpha") + self.assertEqual(r["name"], "alpha") + + def test_register_project_requires_name(self) -> None: + c = _client() + try: + with self.assertRaises(ValueError): + c.register_project({"git_url": "..."}) + finally: + c.close() + + def test_register_project_requires_dict(self) -> None: + c = _client() + try: + with self.assertRaises(ValueError): + c.register_project("not a dict") # type: ignore[arg-type] + finally: + c.close() + + @responses.activate + def test_update_project_uses_put(self) -> None: + responses.add( + responses.PUT, + f"{BASE_URL}/projects/alpha", + json={"ok": True, "project": {"name": "alpha", "git_url": "y"}}, + status=200, + ) + c = _client() + try: + r = c.update_project("alpha", {"name": "alpha", "git_url": "y"}) + finally: + c.close() + self.assertEqual(r["git_url"], "y") + + @responses.activate + def test_delete_project(self) -> None: + responses.add( + responses.DELETE, + f"{BASE_URL}/projects/alpha", + json={"ok": True}, + status=200, + ) + c = _client() + try: + r = c.delete_project("alpha") + finally: + c.close() + self.assertEqual(r, {"ok": True}) + + +class TestJobs(unittest.TestCase): + @responses.activate + def test_create_job_posts_recipe(self) -> None: + captured: dict = {} + + def cb(request): + captured["body"] = json.loads(request.body) + return ( + 200, + {}, + json.dumps( + { + "ok": True, + "job_id": "j-1", + "status": "queued", + "job": {"id": "j-1"}, + } + ), + ) + + responses.add_callback( + responses.POST, + f"{BASE_URL}/projects/alpha/jobs", + callback=cb, + ) + c = _client() + try: + r = c.create_job( + project="alpha", + recipe="audit", + subproject="clients/python", + branch="dev", + ) + finally: + c.close() + self.assertEqual(captured["body"]["recipe"], "audit") + self.assertEqual(captured["body"]["subproject"], "clients/python") + self.assertEqual(captured["body"]["branch"], "dev") + self.assertEqual(r["job_id"], "j-1") + self.assertEqual(r["status"], "queued") + + @responses.activate + def test_create_job_omits_optional_fields(self) -> None: + """When subproject/branch are None, they MUST NOT appear in the body. + + FastAPI accepts the missing-key form as the default, but a literal + ``null`` would be rejected by Pydantic. + """ + captured: dict = {} + + def cb(request): + captured["body"] = json.loads(request.body) + return ( + 200, + {}, + json.dumps( + { + "ok": True, + "job_id": "j-2", + "status": "queued", + "job": {"id": "j-2"}, + } + ), + ) + + responses.add_callback( + responses.POST, + f"{BASE_URL}/projects/alpha/jobs", + callback=cb, + ) + c = _client() + try: + c.create_job(project="alpha", recipe="build") + finally: + c.close() + self.assertNotIn("subproject", captured["body"]) + self.assertNotIn("branch", captured["body"]) + + def test_create_job_rejects_empty_project(self) -> None: + c = _client() + try: + with self.assertRaises(ValueError): + c.create_job(project="", recipe="audit") + finally: + c.close() + + def test_create_job_rejects_empty_recipe(self) -> None: + c = _client() + try: + with self.assertRaises(ValueError): + c.create_job(project="alpha", recipe="") + finally: + c.close() + + @responses.activate + def test_get_job(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-1", + json={ + "ok": True, + "job": {"id": "j-1", "status": "succeeded", "exit_code": 0}, + "log_tail": ["line a", "line b"], + }, + status=200, + ) + c = _client() + try: + r = c.get_job("j-1") + finally: + c.close() + self.assertEqual(r["job"]["status"], "succeeded") + self.assertEqual(r["log_tail"], ["line a", "line b"]) + + @responses.activate + def test_list_jobs_with_filters(self) -> None: + captured: dict = {} + + def cb(request): + captured["params"] = dict(request.params) if hasattr(request, "params") else {} + # responses attaches the parsed querystring on request.url. + # Easier: just store the URL and parse later. + captured["url"] = request.url + return ( + 200, + {}, + json.dumps({"ok": True, "jobs": [{"id": "j-1"}, {"id": "j-2"}]}), + ) + + responses.add_callback(responses.GET, f"{BASE_URL}/jobs", callback=cb) + c = _client() + try: + rows = c.list_jobs(project="alpha", status="succeeded", limit=10) + finally: + c.close() + self.assertEqual(len(rows), 2) + # Verify the params went out on the URL. + self.assertIn("project=alpha", captured["url"]) + self.assertIn("status=succeeded", captured["url"]) + self.assertIn("limit=10", captured["url"]) + + @responses.activate + def test_list_jobs_no_filters(self) -> None: + captured: dict = {} + + def cb(request): + captured["url"] = request.url + return (200, {}, json.dumps({"ok": True, "jobs": []})) + + responses.add_callback(responses.GET, f"{BASE_URL}/jobs", callback=cb) + c = _client() + try: + c.list_jobs() + finally: + c.close() + # No project / status; only limit (default 50) goes out. + self.assertNotIn("project=", captured["url"]) + self.assertNotIn("status=", captured["url"]) + self.assertIn("limit=50", captured["url"]) + + @responses.activate + def test_get_findings(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-1/findings", + json={ + "ok": True, + "findings": [ + { + "id": 1, + "kind": "lint", + "severity": "warn", + "message": "x", + } + ], + }, + status=200, + ) + c = _client() + try: + rows = c.get_findings("j-1") + finally: + c.close() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["kind"], "lint") + + @responses.activate + def test_get_findings_empty(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-1/findings", + json={"ok": True, "findings": []}, + status=200, + ) + c = _client() + try: + rows = c.get_findings("j-1") + finally: + c.close() + self.assertEqual(rows, []) + + @responses.activate + def test_get_log_returns_text(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-1/log", + body="line 1\nline 2\nline 3\n", + content_type="text/plain", + status=200, + ) + c = _client() + try: + text = c.get_log("j-1") + finally: + c.close() + self.assertEqual(text, "line 1\nline 2\nline 3\n") + + +class TestExceptionMapping(unittest.TestCase): + """Each well-known status code should raise its dedicated subclass.""" + + @responses.activate + def test_404_raises_not_found(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects/missing", + json={"detail": "project not found"}, + status=404, + ) + c = _client() + try: + with self.assertRaises(CraftingTableNotFoundError) as ctx: + c.get_project("missing") + finally: + c.close() + self.assertEqual(ctx.exception.status_code, 404) + + @responses.activate + def test_401_raises_auth(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"detail": "missing bearer"}, + status=401, + ) + c = _client() + try: + with self.assertRaises(CraftingTableAuthError) as ctx: + c.list_projects() + finally: + c.close() + self.assertEqual(ctx.exception.status_code, 401) + + @responses.activate + def test_403_raises_auth(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"detail": "bad token"}, + status=403, + ) + c = _client() + try: + with self.assertRaises(CraftingTableAuthError): + c.list_projects() + finally: + c.close() + + @responses.activate + def test_500_raises_api_error(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"detail": "boom"}, + status=500, + ) + c = _client() + try: + with self.assertRaises(CraftingTableAPIError) as ctx: + c.list_projects() + finally: + c.close() + self.assertEqual(ctx.exception.status_code, 500) + # The base error class should NOT be a NotFound or Auth. + self.assertNotIsInstance(ctx.exception, CraftingTableNotFoundError) + self.assertNotIsInstance(ctx.exception, CraftingTableAuthError) + + @responses.activate + def test_409_raises_api_error(self) -> None: + responses.add( + responses.POST, + f"{BASE_URL}/projects", + json={"detail": "project already exists; use PUT to update"}, + status=409, + ) + c = _client() + try: + with self.assertRaises(CraftingTableAPIError) as ctx: + c.register_project({"name": "dup", "git_url": "x"}) + finally: + c.close() + self.assertEqual(ctx.exception.status_code, 409) + self.assertIn("already exists", ctx.exception.message) + + @responses.activate + def test_get_log_404_raises_not_found(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/missing/log", + json={"detail": "log file not present"}, + status=404, + ) + c = _client() + try: + with self.assertRaises(CraftingTableNotFoundError): + c.get_log("missing") + finally: + c.close() + + @responses.activate + def test_get_log_401_raises_auth(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/x/log", + body="bad token", + status=401, + ) + c = _client() + try: + with self.assertRaises(CraftingTableAuthError): + c.get_log("x") + finally: + c.close() + + +class TestTransportErrors(unittest.TestCase): + """Network errors (no HTTP response) raise CraftingTableTransportError.""" + + def test_unreachable_host(self) -> None: + # Use a port that's almost certainly closed to force a TCP refusal. + # Tests don't require the real port-8810 service to be up. + c = CraftingTableClient( + base_url="http://127.0.0.1:1", + token=TOKEN, + timeout_secs=2, + ) + try: + with self.assertRaises(CraftingTableTransportError): + c.healthz() + finally: + c.close() + + +class TestUnexpectedResponseShapes(unittest.TestCase): + """Bad JSON / wrong types should raise CraftingTableError, not crash.""" + + @responses.activate + def test_healthz_non_dict(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/healthz", + json=["unexpected", "list"], + status=200, + ) + c = _client() + try: + with self.assertRaises(CraftingTableError): + c.healthz() + finally: + c.close() + + @responses.activate + def test_list_projects_missing_key(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"ok": True}, # no 'projects' key + status=200, + ) + c = _client() + try: + rows = c.list_projects() + finally: + c.close() + self.assertEqual(rows, []) # missing-key path returns [] + + @responses.activate + def test_list_projects_wrong_type(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"ok": True, "projects": "not a list"}, + status=200, + ) + c = _client() + try: + with self.assertRaises(CraftingTableError): + c.list_projects() + finally: + c.close() + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py new file mode 100644 index 0000000..ad6fdb9 --- /dev/null +++ b/mcp/tests/test_tools.py @@ -0,0 +1,665 @@ +"""Tool-dispatch tests for crafting_table_mcp.server._dispatch. + +We exercise the full MCP-side path with the HTTP layer mocked via +``responses``. Each tool gets a happy-path test plus a 404/401 surfacing test. +""" +from __future__ import annotations + +import asyncio +import json +import unittest + +import responses + +from crafting_table_mcp.client import CraftingTableClient +from crafting_table_mcp.server import ( + TOOL_DRAFT_PATCH, + TOOL_GET_FINDINGS, + TOOL_GET_JOB, + TOOL_LIST_PROJECTS, + TOOL_REGISTER_PROJECT, + TOOL_RUN_AUDIT, + TOOL_RUN_BUILD, + TOOL_RUN_TEST, + _dispatch, + _tool_definitions, + build_server, +) + + +BASE_URL = "http://192.168.0.5:8810" +TOKEN = "ct_test_token_xxxxxxxx" + + +def _client() -> CraftingTableClient: + return CraftingTableClient(base_url=BASE_URL, token=TOKEN, timeout_secs=10) + + +def _run(coro): + return asyncio.run(coro) + + +class TestToolDiscovery(unittest.TestCase): + """The MCP client calls list_tools first to discover capabilities.""" + + def test_eight_tools_with_valid_schemas(self) -> None: + tools = _tool_definitions() + names = [t.name for t in tools] + self.assertEqual( + sorted(names), + sorted( + [ + TOOL_LIST_PROJECTS, + TOOL_REGISTER_PROJECT, + TOOL_RUN_AUDIT, + TOOL_RUN_BUILD, + TOOL_RUN_TEST, + TOOL_GET_JOB, + TOOL_GET_FINDINGS, + TOOL_DRAFT_PATCH, + ] + ), + ) + for t in tools: + # Every tool must have a non-empty description (the LLM uses + # this to decide when to call it). + self.assertTrue(t.description and len(t.description) > 30, t.name) + self.assertEqual(t.inputSchema.get("type"), "object", t.name) + # Top-level should explicitly forbid extra args so the LLM + # doesn't get encouraged to invent keys. + self.assertFalse( + t.inputSchema.get("additionalProperties", True), + f"{t.name} should set additionalProperties=False", + ) + + def test_run_recipe_tools_share_schema(self) -> None: + """run_audit / run_build / run_test must all require project_name.""" + tools = {t.name: t for t in _tool_definitions()} + for name in (TOOL_RUN_AUDIT, TOOL_RUN_BUILD, TOOL_RUN_TEST): + schema = tools[name].inputSchema + self.assertEqual(schema["required"], ["project_name"]) + self.assertEqual( + sorted(schema["properties"].keys()), + sorted(["project_name", "subproject", "branch"]), + ) + + def test_get_job_and_findings_require_job_id(self) -> None: + tools = {t.name: t for t in _tool_definitions()} + self.assertEqual( + tools[TOOL_GET_JOB].inputSchema["required"], ["job_id"] + ) + self.assertEqual( + tools[TOOL_GET_FINDINGS].inputSchema["required"], ["job_id"] + ) + + +class TestListProjects(unittest.TestCase): + @responses.activate + def test_happy_path(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={ + "ok": True, + "projects": [ + {"name": "alpha", "git_url": "x"}, + {"name": "bravo", "git_url": "y"}, + ], + }, + status=200, + ) + c = _client() + try: + content, is_error = _run(_dispatch(c, TOOL_LIST_PROJECTS, {})) + finally: + c.close() + self.assertFalse(is_error) + self.assertEqual(len(content), 1) + body = json.loads(content[0].text) + self.assertEqual(len(body), 2) + self.assertEqual(body[0]["name"], "alpha") + + @responses.activate + def test_401_surfaces_as_mcp_error(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/projects", + json={"detail": "missing bearer"}, + status=401, + ) + c = _client() + try: + content, is_error = _run(_dispatch(c, TOOL_LIST_PROJECTS, {})) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("auth failed", content[0].text) + self.assertIn("CRAFTING_TABLE_TOKEN", content[0].text) + + +class TestRegisterProject(unittest.TestCase): + @responses.activate + def test_happy_path(self) -> None: + captured: dict = {} + + def cb(request): + captured["body"] = json.loads(request.body) + return ( + 200, + {}, + json.dumps( + { + "ok": True, + "project": {"name": "alpha", "git_url": "x"}, + } + ), + ) + + responses.add_callback(responses.POST, f"{BASE_URL}/projects", callback=cb) + c = _client() + project = { + "name": "alpha", + "git_url": "http://192.168.0.5:3001/Sulkta-Coop/alpha.git", + "default_branch": "main", + "languages": ["python"], + "subprojects": [ + { + "path": ".", + "language": "python", + "test": "pytest", + } + ], + } + try: + content, is_error = _run( + _dispatch( + c, + TOOL_REGISTER_PROJECT, + {"project_json": json.dumps(project)}, + ) + ) + finally: + c.close() + self.assertFalse(is_error) + self.assertEqual(captured["body"]["name"], "alpha") + body = json.loads(content[0].text) + self.assertEqual(body["name"], "alpha") + + def test_rejects_missing_arg(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_REGISTER_PROJECT, {}) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("project_json", content[0].text) + + def test_rejects_invalid_json(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_REGISTER_PROJECT, + {"project_json": "not valid json {"}, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("not valid JSON", content[0].text) + + def test_rejects_non_object_json(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_REGISTER_PROJECT, + {"project_json": "[1, 2, 3]"}, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("must decode to a JSON object", content[0].text) + + def test_rejects_empty_string(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_REGISTER_PROJECT, + {"project_json": " "}, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("project_json", content[0].text) + + @responses.activate + def test_409_surfaces_as_mcp_error(self) -> None: + responses.add( + responses.POST, + f"{BASE_URL}/projects", + json={"detail": "project already exists; use PUT to update"}, + status=409, + ) + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_REGISTER_PROJECT, + { + "project_json": json.dumps( + {"name": "dup", "git_url": "x"} + ) + }, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("already exists", content[0].text) + + +class TestRunRecipe(unittest.TestCase): + """Shared coverage for run_audit / run_build / run_test.""" + + def _add_jobs_post(self, status_code: int, body: dict) -> dict: + captured: dict = {} + + def cb(request): + captured["body"] = json.loads(request.body) + captured["url"] = request.url + return (status_code, {}, json.dumps(body)) + + responses.add_callback( + responses.POST, + f"{BASE_URL}/projects/alpha/jobs", + callback=cb, + ) + return captured + + @responses.activate + def test_run_audit_happy_path(self) -> None: + captured = self._add_jobs_post( + 200, + { + "ok": True, + "job_id": "j-1", + "status": "queued", + "job": {"id": "j-1"}, + }, + ) + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_RUN_AUDIT, + {"project_name": "alpha"}, + ) + ) + finally: + c.close() + self.assertFalse(is_error) + self.assertEqual(captured["body"]["recipe"], "audit") + body = json.loads(content[0].text) + self.assertEqual(body, {"job_id": "j-1", "status": "queued"}) + + @responses.activate + def test_run_build_passes_subproject_and_branch(self) -> None: + captured = self._add_jobs_post( + 200, + { + "ok": True, + "job_id": "j-2", + "status": "queued", + "job": {"id": "j-2"}, + }, + ) + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_RUN_BUILD, + { + "project_name": "alpha", + "subproject": "clients/rust", + "branch": "feature/x", + }, + ) + ) + finally: + c.close() + self.assertFalse(is_error) + self.assertEqual(captured["body"]["recipe"], "build") + self.assertEqual(captured["body"]["subproject"], "clients/rust") + self.assertEqual(captured["body"]["branch"], "feature/x") + + @responses.activate + def test_run_test_recipe_label(self) -> None: + captured = self._add_jobs_post( + 200, + { + "ok": True, + "job_id": "j-3", + "status": "queued", + "job": {"id": "j-3"}, + }, + ) + c = _client() + try: + _run( + _dispatch( + c, + TOOL_RUN_TEST, + {"project_name": "alpha"}, + ) + ) + finally: + c.close() + self.assertEqual(captured["body"]["recipe"], "test") + + def test_rejects_missing_project_name(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_RUN_AUDIT, {}) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("project_name", content[0].text) + + def test_rejects_non_string_subproject(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_RUN_AUDIT, + {"project_name": "alpha", "subproject": 42}, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("subproject", content[0].text) + + @responses.activate + def test_404_surfaces_with_actionable_hint(self) -> None: + responses.add( + responses.POST, + f"{BASE_URL}/projects/missing/jobs", + json={"detail": "project not found"}, + status=404, + ) + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_RUN_AUDIT, + {"project_name": "missing"}, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + # Auth/404 wrapper should give the LLM a hint about list_projects. + self.assertIn("not found", content[0].text) + self.assertIn("crafting_table_list_projects", content[0].text) + + +class TestGetJob(unittest.TestCase): + @responses.activate + def test_happy_path_two_blocks(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-1", + json={ + "ok": True, + "job": { + "id": "j-1", + "project_name": "alpha", + "subproject_path": ".", + "recipe": "audit", + "status": "succeeded", + "exit_code": 0, + }, + "log_tail": ["pip install ...", "everything is fine"], + }, + status=200, + ) + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_GET_JOB, {"job_id": "j-1"}) + ) + finally: + c.close() + self.assertFalse(is_error) + # Two-block response: prose + JSON. + self.assertEqual(len(content), 2) + self.assertIn("succeeded", content[0].text) + self.assertIn("alpha::.", content[0].text) + self.assertIn("everything is fine", content[0].text) + body = json.loads(content[1].text) + self.assertEqual(body["job"]["status"], "succeeded") + self.assertEqual(body["log_tail"], ["pip install ...", "everything is fine"]) + + @responses.activate + def test_404_surfaces_as_mcp_error(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/missing", + json={"detail": "job not found"}, + status=404, + ) + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_GET_JOB, {"job_id": "missing"}) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("not found", content[0].text) + self.assertIn("crafting_table_list_projects", content[0].text) + + def test_rejects_missing_job_id(self) -> None: + c = _client() + try: + content, is_error = _run(_dispatch(c, TOOL_GET_JOB, {})) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("job_id", content[0].text) + + +class TestGetFindings(unittest.TestCase): + @responses.activate + def test_happy_path_two_blocks(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-1/findings", + json={ + "ok": True, + "findings": [ + { + "id": 1, + "kind": "lint", + "severity": "warn", + "file": "src/x.py", + "line": 10, + "code": "ruff::E501", + "message": "line too long", + }, + { + "id": 2, + "kind": "cve", + "severity": "high", + "message": "openssl bump", + }, + ], + }, + status=200, + ) + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_GET_FINDINGS, {"job_id": "j-1"}) + ) + finally: + c.close() + self.assertFalse(is_error) + self.assertEqual(len(content), 2) + # Block 0: prose summary should mention both severities + kinds. + prose = content[0].text + self.assertIn("2 finding", prose) + self.assertIn("warn", prose) + self.assertIn("high", prose) + self.assertIn("lint", prose) + self.assertIn("cve", prose) + # Block 1: full JSON with the findings list. + body = json.loads(content[1].text) + self.assertEqual(len(body["findings"]), 2) + + @responses.activate + def test_empty_findings_prose_explains(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/j-2/findings", + json={"ok": True, "findings": []}, + status=200, + ) + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_GET_FINDINGS, {"job_id": "j-2"}) + ) + finally: + c.close() + self.assertFalse(is_error) + # Prose block should still help the LLM understand "empty doesn't + # necessarily mean clean — could be no parser yet". + self.assertIn("no findings", content[0].text.lower()) + + @responses.activate + def test_404_surfaces_as_mcp_error(self) -> None: + responses.add( + responses.GET, + f"{BASE_URL}/jobs/missing/findings", + json={"detail": "job not found"}, + status=404, + ) + c = _client() + try: + content, is_error = _run( + _dispatch(c, TOOL_GET_FINDINGS, {"job_id": "missing"}) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("not found", content[0].text) + + +class TestDraftPatchStub(unittest.TestCase): + """Wave 2B stub: tool surface present, but returns a 'pending' message.""" + + def test_returns_pending_message(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_DRAFT_PATCH, + {"job_id": "j-1"}, + ) + ) + finally: + c.close() + self.assertFalse(is_error) + body = json.loads(content[0].text) + self.assertFalse(body["ok"]) + self.assertTrue(body["pending"]) + self.assertIn("not yet implemented", body["message"]) + self.assertIn("wave 3", body["message"]) + + def test_with_finding_id(self) -> None: + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_DRAFT_PATCH, + {"job_id": "j-1", "finding_id": 42}, + ) + ) + finally: + c.close() + self.assertFalse(is_error) + body = json.loads(content[0].text) + self.assertEqual(body["finding_id"], 42) + + def test_rejects_bool_finding_id(self) -> None: + # bool is a subclass of int — defense-in-depth. + c = _client() + try: + content, is_error = _run( + _dispatch( + c, + TOOL_DRAFT_PATCH, + {"job_id": "j-1", "finding_id": True}, + ) + ) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("finding_id", content[0].text) + + def test_rejects_missing_job_id(self) -> None: + c = _client() + try: + content, is_error = _run(_dispatch(c, TOOL_DRAFT_PATCH, {})) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("job_id", content[0].text) + + +class TestUnknownTool(unittest.TestCase): + def test_unknown_tool_returns_error(self) -> None: + c = _client() + try: + content, is_error = _run(_dispatch(c, "not_a_tool", {})) + finally: + c.close() + self.assertTrue(is_error) + self.assertIn("unknown tool", content[0].text) + + +class TestServerFactory(unittest.TestCase): + """Smoke test that the SDK accepts our wiring.""" + + def test_build_server_returns_named_server(self) -> None: + c = _client() + try: + server = build_server(c) + self.assertEqual(server.name, "crafting-table-mcp") + init = server.create_initialization_options() + self.assertEqual(init.server_name, "crafting-table-mcp") + finally: + c.close() + + +if __name__ == "__main__": # pragma: no cover + unittest.main()