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).
|
Spec: `Sulkta-Coop/openclaw-workspace/memory/spec-crafting-table.md` (LAN-only).
|
||||||
|
|
||||||
## Status — v0.1 step 6 of 10
|
## Status — v0.1 step 7 of 10
|
||||||
|
|
||||||
- [x] Step 1: Dockerfile + per-language smoke
|
- [x] Step 1: Dockerfile + per-language smoke
|
||||||
- [x] Step 2: SQLite ledger + project registry
|
- [x] Step 2: SQLite ledger + project registry
|
||||||
|
|
@ -23,7 +23,7 @@ Spec: `Sulkta-Coop/openclaw-workspace/memory/spec-crafting-table.md` (LAN-only).
|
||||||
- [x] Step 4: Job runner core (asyncio worker pool, git worktree, subprocess)
|
- [x] Step 4: Job runner core (asyncio worker pool, git worktree, subprocess)
|
||||||
- [x] Step 5: Per-language parsers (Rust / Python / Go / TS first)
|
- [x] Step 5: Per-language parsers (Rust / Python / Go / TS first)
|
||||||
- [x] Step 6: Findings extraction + storage
|
- [x] Step 6: Findings extraction + storage
|
||||||
- [ ] Step 7: MCP server (stdio JSON-RPC, 8 tools)
|
- [x] Step 7: MCP server (stdio JSON-RPC, 8 tools) — see [mcp/README.md](mcp/README.md)
|
||||||
- [x] Step 8: Email digest scheduler
|
- [x] Step 8: Email digest scheduler
|
||||||
- [ ] Step 9: Autonomous patch loop (clawdforge integration)
|
- [ ] Step 9: Autonomous patch loop (clawdforge integration)
|
||||||
- [ ] Step 10: Production recipes — clawdforge, cauldron, tradecraft
|
- [ ] Step 10: Production recipes — clawdforge, cauldron, tradecraft
|
||||||
|
|
@ -209,6 +209,7 @@ without lock contention. The runner is the only mutator of `jobs`/
|
||||||
│ ├── models.py # Pydantic schemas
|
│ ├── models.py # Pydantic schemas
|
||||||
│ └── config.py # env-driven config
|
│ └── config.py # env-driven config
|
||||||
├── tests/ # pytest suite (~60 tests)
|
├── tests/ # pytest suite (~60 tests)
|
||||||
|
├── mcp/ # crafting-table-mcp — MCP stdio bridge (separate pip install)
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
└── .env.example
|
└── .env.example
|
||||||
|
|
@ -331,6 +332,22 @@ curl -sH "Authorization: Bearer $ADMIN" \
|
||||||
Idempotency: `digest_runs` table holds `UNIQUE(date, project_name)`, so the
|
Idempotency: `digest_runs` table holds `UNIQUE(date, project_name)`, so the
|
||||||
06:00 loop is safe to re-fire on the same day — only the first call sends.
|
06:00 loop is safe to re-fire on the same day — only the first call sends.
|
||||||
|
|
||||||
|
## MCP bridge
|
||||||
|
|
||||||
|
The `mcp/` subdirectory ships a self-contained `crafting-table-mcp` Python
|
||||||
|
package that exposes the HTTP API to MCP-aware clients (Claude Desktop, Claude
|
||||||
|
Code, Cursor, Zed, custom agents). See [mcp/README.md](mcp/README.md) for the
|
||||||
|
tool surface, installation, and configuration.
|
||||||
|
|
||||||
|
Quickstart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e mcp
|
||||||
|
export CRAFTING_TABLE_BASE_URL=http://192.168.0.5:8810
|
||||||
|
export CRAFTING_TABLE_TOKEN=ct_...
|
||||||
|
crafting-table-mcp # stdio JSON-RPC server
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
294
mcp/README.md
Normal file
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