v0.1 wave 2B (step 7): MCP server — stdio JSON-RPC, 8 tools

- mcp/ subpackage: crafting-table-mcp (separate pip install)
- Self-contained requests-based HTTP client (mirrors clawdforge_mcp pattern)
- 8 tools: list_projects / register_project / run_audit / run_build / run_test / get_job / get_findings / draft_patch (stub)
- draft_patch is stubbed — full impl lands in wave 3 / step 9
- tests/: client + tool coverage, 401/404 surfacing
- Tools designed for LLM consumption; descriptions tuned for "when to use" guidance

Spec: memory/spec-crafting-table.md
This commit is contained in:
Kayos 2026-04-29 08:33:05 -07:00
parent d467b2f5be
commit ecb9d76e6d
13 changed files with 2889 additions and 2 deletions

View file

@ -15,7 +15,7 @@ through clawdforge.
Spec: `Sulkta-Coop/openclaw-workspace/memory/spec-crafting-table.md` (LAN-only). 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 1: Dockerfile + per-language smoke
- [x] Step 2: SQLite ledger + project registry - [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 4: Job runner core (asyncio worker pool, git worktree, subprocess)
- [x] Step 5: Per-language parsers (Rust / Python / Go / TS first) - [x] Step 5: Per-language parsers (Rust / Python / Go / TS first)
- [x] Step 6: Findings extraction + storage - [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 - [x] Step 8: Email digest scheduler
- [ ] Step 9: Autonomous patch loop (clawdforge integration) - [ ] Step 9: Autonomous patch loop (clawdforge integration)
- [ ] Step 10: Production recipes — clawdforge, cauldron, tradecraft - [ ] 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 │ ├── models.py # Pydantic schemas
│ └── config.py # env-driven config │ └── config.py # env-driven config
├── tests/ # pytest suite (~60 tests) ├── tests/ # pytest suite (~60 tests)
├── mcp/ # crafting-table-mcp — MCP stdio bridge (separate pip install)
├── pyproject.toml ├── pyproject.toml
├── requirements.txt ├── requirements.txt
└── .env.example └── .env.example
@ -331,6 +332,22 @@ curl -sH "Authorization: Bearer $ADMIN" \
Idempotency: `digest_runs` table holds `UNIQUE(date, project_name)`, so the 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. 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 ## License
MIT MIT

294
mcp/README.md Normal file
View file

@ -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": "<uuid>", "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": "<uuid>"
}
// 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": "<uuid>"
}
// 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": "<uuid>", // 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/<finding-id>` 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).

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

53
mcp/pyproject.toml Normal file
View file

@ -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"]

View file

@ -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"

View file

@ -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())

View file

@ -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")

View file

@ -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

0
mcp/tests/__init__.py Normal file
View file

39
mcp/tests/conftest.py Normal file
View file

@ -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()

641
mcp/tests/test_client.py Normal file
View file

@ -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()

665
mcp/tests/test_tools.py Normal file
View file

@ -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()