|
cMCP 0.4.1
Model Context Protocol library in pure C11
|
Generated 2026-05-30 as the council-D1 deliverable (see ~/.claude/plans/eager-leaping-pike.md Tier 7 + the council verdict captured in chat). The premise: drive tools/crag-mcp/ end-to-end through the public client API the way butlerbot will, and write down every moment the API felt wrong from the host's seat.
The harness lives at tools/dogfood-crag-host/main.c. It is built opt-in (make dogfood-crag-host) and not part of make all — its output is this doc, not a binary anybody links.
The harness exercises (in order): handshake, tools/list, resources/read of crag://stats, 3 sync crag_search calls, 3 async parallel crag_search calls with reverse-order waits, two schema-bound error paths, an unknown-tool path, teardown. 25 wire frames captured to the JSONL above.
Each finding is one of:
A host talking to one server has no typed shortcut for the three operations every host needs:
| Op | What exists | What I had to write |
|---|---|---|
| list tools | only cmcp_session_tools_list (multi-server) | cmcp_client_request("tools/list", NULL, &resp) then walk result.tools[] via cmcp_json_object_get |
| read a resource | only cmcp_session_resource_read | cmcp_client_request("resources/read", {uri}, &resp) then walk result.contents[0].text |
| call a tool | only cmcp_session_tool_call | cmcp_client_request("tools/call", {name, arguments}, &resp) then walk result.content[0] + check result.isError (and response.error, see F2) |
Even our own cmcp-inspect drops to raw JSON walking for this — so butlerbot will too. The session-layer types are correct; mirror them onto cmcp_client_t:
Effort: ~½ day. They're light wrappers over the existing async core.
A tools/call can fail two ways the host must distinguish:
response.error.code/message/data):-32601 Method not found-32601 Unknown tool ← server returns this for unknown tool name-32603 Internal error-32602 Invalid params ← in some pathsresponse.result.isError == true plus response.result.content[].text carrying a human-prose reason):minLength, maximum, etc.)There is no client helper that flattens these into a single (success | tool_error | protocol_error) channel. I wrote that flattener three times in the harness as ad-hoc code per call site.
Worse: the two channels carry different shapes of structured data. JSON-RPC -32602 errors come with error.data = {path, keyword, message} (structured, host-machine-readable). Tool-level errors come with result.content[0].text = "Invalid arguments for tool ... (path:
/query, keyword: minLength)" — the same fields, but inline-stringified. A host that wants to react programmatically to a "minLength violation
on /query" must either parse a sentence, or look up which error channel the server chose.
This is a real product gap, not a style preference. Recommended fix:
Plus optionally, on the server side, surfacing the structured {path, keyword, message} block from the schema validator into result.structuredContent (or _meta.errorData) so tool-level errors have machine-readable data too — that's a server-side change, not part of the client API ask.
crag-mcp's schema rejection returns tool-level isError:true (per MCP 2025-11-25 convention for tools/call). But:
-32602 Invalid params JSON-RPC error code still exists and is what other validators (e.g. raw cmcp_server schema-rejection paths) historically returned.conformance/playbooks/crag-mcp.md, task T5) explicitly says the empty-query rejection returns "-32602
schema error" — that documentation is stale.-32601 Unknown tool (JSON-RPC level), which is the right level for "you named a thing that does
not exist."isError:true (tool level), which means "your call cannot proceed because the args are wrong."Both are "the call you asked for cannot proceed." The line between them is fine. The spec (2025-11-25) does sort this out — schema rejections are explicitly tool-level — but the post-6.1.4 fixture sweep should have also updated the playbook. Doc drift item.
While writing the harness I three times reached for text->str.n, text->str.s, is_err->boolean — all of which are wrong field names. The actual fields are .str.len, .str.s, .b. The compiler caught me, but only because the field names happened to be different. If they had collided, this would be a silent ABI-fragility hazard across versions.
include/cmcp_json.h exposes the full struct so the parser can construct values cheaply. But it also exposes typed accessors (cmcp_json_string(), cmcp_json_string_len(), cmcp_json_bool(), cmcp_json_array_len(), cmcp_json_array_at()) that I should have used from the start.
The header doesn't signal "prefer accessors." Add either:
@warning block in the struct's Doxygen block explicitly directing host code to the accessors and reserving the union for parser/library use, orcmcp_json_impl_t and make the accessors mandatory. (More work; better long-term.)Recommend (1) for v0.6.x, defer (2).
Constructors are verb-first:
Setters and accessors are subject-first:
So you write:
It is small, but every host author will pause once. Pick one rule for v0.7.0 (the next MAJOR-eligible release; this is an ABI break). Recommend subject-first throughout (matches the accessors, which are the more-frequently-called API):
Defer until v0.7.0 — paper-cut-grade, not blocking butlerbot.
Three sync calls cost 18.6 ms / 15.2 ms / 14.7 ms = ~48 ms total. Three async calls fired in parallel completed in 28.2 ms total. The parallelism is real but small — and the win shrinks if I fire more, because crag-mcp funnels every search through a single Ollama embed call that itself serializes.
The wire layer is correctly parallel (multiple in-flight ids, reader thread demuxes, any-order completion). The bottleneck is in the server-side handler. A host author looking at the protocol has no way to know whether firing 10 calls in parallel will give 10× throughput, 2× throughput, or 1× throughput.
This is an MCP-spec gap as much as a cMCP gap — there is no capability flag like "server": { "maxConcurrentToolCalls": N }. But cMCP could introduce a vendor-prefixed extension that surfaces "this server's `tools/call` is effectively serial / pooled-N /
unbounded" so a host budgets accordingly. Park as a discussion item; not v0.6 work.
The first sync crag_search call cost 612 ms on the first run of this session (when Ollama hadn't loaded the embed model). Subsequent calls were 14-16 ms. The wire-level handshake itself cost 4-5 ms.
Not a finding — this is Ollama, not cMCP. Captured because a host author will reflexively blame the transport, and they shouldn't.
There is no real bug in cMCP. The schema validator works (the post-6.7 conformance corpus proved it; the playbook captured the correct tool-level rejection shape; the harness initially misread the result because I looked in the JSON-RPC error channel instead of the tool-level channel). The two surprises in step 6 of the first harness run ("" and k=999 "ACCEPTED") were both harness bugs, not server bugs — the harness was looking in the wrong error channel.
That harness mistake is the finding (F2): if even the dogfooder who wrote the library mis-reads the error model on first try, butlerbot will too.
The council's D2 task (write the v0.6.0 acceptance criterion) should take F1 + F2 as the primary load-bearing input. They are:
F3 (schema-error-channel doc drift) is a doc-only fix and can land alongside F1+F2. F4 (struct layout warning) is a one-line doc comment. F5 (naming) defers to v0.7.0. F6 (server concurrency hint) parks as a Tier 8 discussion.
Recommended v0.6.0 scope (subject to D2 confirmation):
cmcp_client_tool_call flattener with the three-way outcome enum.That's a small, honest, evidence-driven v0.6. Cuts in ~1 week.
tools/dogfood-crag-host/main.c — the harness.conformance/fixtures/crag-mcp/dogfood/session-2026-05-30.jsonl — the captured wire transcript (25 frames; usable as a fresh replay fixture once F1/F2 land).