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:
parent
d467b2f5be
commit
ecb9d76e6d
13 changed files with 2889 additions and 2 deletions
21
README.md
21
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
|
||||
|
|
|
|||
294
mcp/README.md
Normal file
294
mcp/README.md
Normal 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).
|
||||
11
mcp/examples/claude-code.json
Normal file
11
mcp/examples/claude-code.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
mcp/examples/claude-desktop.json
Normal file
11
mcp/examples/claude-desktop.json
Normal 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
53
mcp/pyproject.toml
Normal 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"]
|
||||
14
mcp/src/crafting_table_mcp/__init__.py
Normal file
14
mcp/src/crafting_table_mcp/__init__.py
Normal 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"
|
||||
74
mcp/src/crafting_table_mcp/__main__.py
Normal file
74
mcp/src/crafting_table_mcp/__main__.py
Normal 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())
|
||||
376
mcp/src/crafting_table_mcp/client.py
Normal file
376
mcp/src/crafting_table_mcp/client.py
Normal 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")
|
||||
692
mcp/src/crafting_table_mcp/server.py
Normal file
692
mcp/src/crafting_table_mcp/server.py
Normal 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
0
mcp/tests/__init__.py
Normal file
39
mcp/tests/conftest.py
Normal file
39
mcp/tests/conftest.py
Normal 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
641
mcp/tests/test_client.py
Normal 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
665
mcp/tests/test_tools.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue