MCP · 11. The MCP surface
The Crux daemon speaks MCP Streamable HTTP on http://127.0.0.1:14801/mcp, advertises protocol version 2024-11-05, implements exactly four JSON-RPC methods, and serves a catalogue of 118 tools (119 with one feature flag on). Everything else in the MCP specification, resources, prompts, completion, logging, roots, ping, is not implemented and returns JSON-RPC -32601 inside an HTTP 200.
This chapter is reference. It is the map. The complete per-tool contract for all 118 tools is in chapter 12 and chapter 13. The free/hosted boundary is chapter 14. Where our own documentation and our code disagree is chapter 15.
Extension tools (ext.*) are covered in the daemon developer guide, chapter 10, MCP surface; this chapter states only how they interact with the catalogue and does not repeat that material.
In plain English. MCP, the Model Context Protocol, is a standard way for an AI assistant to discover and call tools that live outside itself. The assistant asks a server "what can you do", gets back a list of tools with their parameters, and can then call them by name. The value of a standard here is that you do not write an adapter per assistant: anything that speaks MCP can use anything that serves MCP. The daemon serves MCP on its own port, separately from its HTTP API, and what it offers over that port is a catalogue of 118 tools for storing facts, recalling them, managing sessions and the rest.
The distinction that matters most on arrival is that this is a second listener, not a route on the HTTP API. If you are looking for /mcp on port 14800 you will not find it. The two surfaces overlap in what they can do and differ in who they are for: HTTP is for code you write, MCP is for agents you run.
You will use this chapter when you are wiring an agent to the daemon for the first time and need to know what the connection looks like, and when a tool call behaved unexpectedly and you need to know whether the problem is the protocol, the catalogue or the tool. The complete per-tool contract lives in chapters 12 and 13; this one is the map.
The thing people get wrong is expecting the whole MCP specification to be there. It is not, and the way it is absent is unusual enough to trip clients: exactly four JSON-RPC methods are implemented, and everything else, resources, prompts, completion, logging, roots and ping, returns a JSON-RPC error inside an HTTP 200. A client that checks the HTTP status and assumes success will read that error as a valid response. Check the JSON-RPC body, not the status line. The other common surprise is retrieval-shaped rather than protocol-shaped: omitting token_budget on a query returns unbounded results, which is the fastest way to fill an agent's context window without meaning to.
11.0 Four things that surprise integrators
- Omitting
token_budgetonquery,query_scanorquery_factsdoes not apply a default. It applies no bound at all (query.rs:120). See 11.11. resources/listand friends return-32601inside an HTTP 200. A client that treats a non-200 as "unsupported" will hang on the body instead. See 11.4.- An anonymous caller sees the whole catalogue, not a reduced one. Anonymity does not shrink
tools/list; an absent capability token means the authz filter is skipped entirely. See 11.8. - A tool hidden from
tools/listby surface shaping is still callable by name. Shaping is advertisement, not authorisation (surface.rs:11).
11.1 The listener
MCP runs as a separate axum server on its own port. It is not mounted under the main HTTP router on 14800. It is not stdio, a stdio bridge exists, as a separate subcommand (11.3).
| Setting | Env var | YAML key | Default | Source |
|---|---|---|---|---|
| MCP host | CORECRUXD_MCP_HOST | daemon.listen_addr | 127.0.0.1 | config.rs:817 |
| MCP port | CORECRUXD_MCP_PORT | daemon.mcp_port | 14801 | config.rs:822 |
| MCP enabled | CORECRUXD_MCP_ENABLED | daemon.mcp_enabled | true | config.rs:827 |
Precedence is env, then file config, then default, for all three. The context is built once and only when mcp_enabled (main.rs:1116), and the router is mounted at main.rs:1176.
The canonical endpoint is http://127.0.0.1:14801/mcp. For contrast, HTTP is 14800 and gRPC is 4007, see the daemon guide, chapter 1, Architecture.
Bind-posture guard
MCP may not bind a non-loopback address without agent tokens. validate_mcp_bind_posture (main.rs:2050, called at main.rs:342) aborts startup unless CRUX_AGENT_TOKEN or CRUX_AGENT_TOKENS is set, or CORECRUXD_ALLOW_INSECURE_DEV_AUTH_BIND=1 is set explicitly.
Routes on the MCP port
Router builder, server.rs:48.
| Route | Method | Purpose | Source |
|---|---|---|---|
/mcp | POST | JSON-RPC 2.0 request and response | server.rs:51, handler :94 |
/mcp | GET | SSE stream when Accept: text/event-stream; otherwise a static discovery blob of protocolVersion and serverInfo | server.rs:52, handler :227 |
/.well-known/oauth-protected-resource | GET | RFC 9728 metadata. 404 unless CRUX_MCP_RESOURCE_URL is set | server.rs:53, handler :65 |
/.well-known/agent-card | GET | A2A discovery card, filtered to the 16-tool core floor | server.rs:57, filter agent_card.rs:106 |
The SSE stream is server-to-client push only and carries exactly one notification: notifications/tools/list_changed (sse.rs:27, pushed by notify_list_changed at sse.rs:209). It fires when an intent-bearing cuecrux_session call arrives on a session that has a registered stream (server.rs:208).
Session header
Mcp-Session-Id, maximum 128 characters, charset [A-Za-z0-9._:-] (server.rs:28, validated at :405). Minted as a simple UUID on initialize (server.rs:201) or on SSE open (:252).
SSE and auth environment variables
| Env var | Default | Source |
|---|---|---|
CRUX_MCP_SSE_MAX_SESSIONS | 1024; 0 means unlimited | sse.rs:29 |
CRUX_MCP_SSE_MAX_SESSIONS_PER_OWNER | 64; 0 means unlimited | sse.rs:30 |
CRUX_AGENT_TOKEN and CRUX_AGENT_TOKENS | unset, so anonymous access is allowed | server.rs:389 |
CRUX_AGENT_CARD | on; =0 disables the agent card | server.rs:87 |
CRUX_MCP_RESOURCE_URL | unset, so the well-known route 404s | oauth.rs:51 |
CRUX_MCP_AUTH_SERVER | unset | oauth.rs:55 |
CRUX_MCP_INTROSPECT_URL, _CLIENT_ID, _CLIENT_SECRET | unset | oauth.rs:118 |
Exceeding the SSE session limit returns 429 with {"error":"sse_session_limit","scope","limit"} (server.rs:450). Reusing another owner's session id returns 403 sse_session_owner_mismatch (server.rs:439). The owner key is agent:<name>, ip:<addr> or anonymous (server.rs:425).
11.2 The handshake
PROTOCOL_VERSION = "2024-11-05" (dispatch.rs:385), SERVER_NAME = "crux" (:388), server version is the crate version (:394).
{
"protocolVersion": "2024-11-05",
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": { "name": "crux", "version": "<crate version>" },
"_welcome": {
"hint": "…",
"quickstart": ["…"],
"docs": "https://github.com/CueCrux/Crux/blob/main/docs/agent-guide.md"
}
}
Only the tools capability is advertised (dispatch.rs:404). There is no resources capability, no prompts, no logging. A spec-compliant client reading this response will not call them, which is the intended contract, see 11.4 for what happens if a client calls them anyway.
11.3 The four JSON-RPC methods
There is a single dispatch site, dispatch() at dispatch.rs:397, matching on the method string. Four methods are accepted. That is the complete set.
| Method | Behaviour | Source |
|---|---|---|
initialize | Handshake, as in 11.2 | dispatch.rs:400 |
notifications/initialized | result: null. Defensive only, the HTTP layer short-circuits every id-less request to 202 before dispatch is reached | dispatch.rs:434 |
tools/list | list_tools_json_for_context(ctx, now); optionally emits an agent.tools_offered.v1 ledger event under CORECRUXD_FEATURE_TOOL_LEDGER | dispatch.rs:437 |
tools/call | dispatch_tool_call(id, ¶ms, ctx) | dispatch.rs:457 |
| anything else | warn! plus -32601 "method not found: {other}" | dispatch.rs:460 |
Notifications, requests where id is absent, receive a bare 202 Accepted with no body (server.rs:158). The comment at server.rs:149 records why: Codex's rmcp transport rejects the older {"id":null,"result":null} shape.
The stdio bridge
corecruxd mcp-stdio (mcp_stdio.rs) reads line-delimited JSON-RPC on stdin and relays it to POST $CRUX_MCP_URL, defaulting to http://127.0.0.1:14801/mcp (mcp_stdio.rs:36). Auth is forwarded from CRUX_AGENT_TOKEN (:22). Upstream failures surface as JSON-RPC -32000 (:39), a code that exists nowhere else in the surface.
11.4 What is not implemented, and how it fails
There is no resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, completion/complete, logging/setLevel, roots/* or ping handler anywhere in the crate. Every one of them reaches the dispatch fallback at dispatch.rs:460.
What an integrator must know about that path:
- The JSON-RPC error code is
-32601(protocol.rs:19) and the message is"method not found: resources/list"or equivalent. - The HTTP status is
200 OK. The error rides in the body (server.rs:471). Client code that branches on HTTP status will conclude the call succeeded. - There is no
datafield on the error object (protocol.rs:66); nothing to introspect beyond the message string. - The behaviour is pinned by
unknown_method_returns_error(dispatch.rs:836).
One internal inconsistency, disclosed because it will mislead you. The OAuth read-only method allowlist OAUTH_READ_METHODS (oauth.rs:352) permits ping, resources/list, resources/read and resources/templates/list. Those four pass the read-only gate at server.rs:173 and then hit the dispatch fallback anyway. The allowlist is aspirational. The methods do not exist.
11.5 Error codes
| Code | Constant | Source |
|---|---|---|
-32700 | PARSE_ERROR | protocol.rs:13 |
-32600 | INVALID_REQUEST | protocol.rs:16 |
-32601 | METHOD_NOT_FOUND | protocol.rs:19 |
-32602 | INVALID_PARAMS | protocol.rs:22 |
-32603 | INTERNAL_ERROR | protocol.rs:25 |
-32030 | CAPABILITY_DENIED, Crux-specific | dispatch.rs:27 |
-32000 | UPSTREAM_ERROR, stdio bridge only | mcp_stdio.rs:39 |
Pre-dispatch HTTP gates
These happen before dispatch() is reached at all.
| Gate | Result | Source |
|---|---|---|
| Auth failure | 401 plus WWW-Authenticate (RFC 9728) when configured | server.rs:95 |
Bad Mcp-Session-Id | 400 invalid_mcp_session_id | server.rs:101 |
| Unparseable body | 200 plus -32700; the error text is scrubbed through crux_observe::redact | server.rs:136 |
id absent | 202 Accepted, empty body | server.rs:158 |
| OAuth read-only violation | 200 plus -32601, message '<x>' is not available to read-only OAuth callers (mcp:read scope) | server.rs:173 |
11.6 The tools/call pipeline
dispatch_tool_call, dispatch.rs:468. Every call runs this sequence.
| Step | Behaviour | Source |
|---|---|---|
Missing name | -32602, tools/call requires a "name" parameter | dispatch.rs:473 |
| RCX capability gate | enforce_rcx_tool_capability; denial is -32030 carrying reason_code, mode, token_id, token_hash, stamp, refusal_receipt, upgrade_hint | dispatch.rs:480, impl :641 |
| Revocation gate | A revoked passport is refused every tool outside a small read-only allowlist | mod.rs:2876 |
| OTel span | record_tool_span_start | dispatch.rs:491 |
| Execute | tools::call_tool(name, &args, ctx) | dispatch.rs:494 |
| Token accounting | Per-passport in and out estimates plus the declared token_budget | dispatch.rs:510 |
| Metrics and ledger | An unknown tool label collapses to "unknown"; agent.tool_invocation.v1 is emitted when the ledger flag is on | dispatch.rs:528 |
| Trace ring | record_dispatch_metered with the canonical signature and predicted effects | dispatch.rs:552 |
| Success | Optional audit-envelope wrap, then normalize_result_shape which guarantees a top-level result.content | dispatch.rs:564 |
| Tool error | JsonRpcResponse::error(id, code, message); an unknown tool name is -32601 "unknown tool: {name}" | mod.rs:3021 |
Every response is wrapped in the standard MCP {"content": [{"type":"text", …}]} envelope. The per-tool output shapes in chapters 12 and 13 describe what is inside that envelope.
11.7 The verified counts
| Quantity | Value | Evidence |
|---|---|---|
Tool modules under crates/crux-mcp/src/tools/ | 52 .rs files, 51 tool modules plus mod.rs | directory listing at commit 93b41a7 |
| Tools in the base catalogue, both passport flags off | 118 | const TOOL_COUNT: usize = 118, mod.rs:3108, asserted against list_tools() at mod.rs:3156 |
Tools with CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS=1 | 119, adds request_passport_mint | filter mod.rs:2440; assertion mod.rs:3366 |
ToolDefinition literals in list_tools_with_flags | 119, one of which is conditionally filtered | mod.rs:163 onward |
Extension (ext.*) tools | unbounded, and not counted in TOOL_COUNT | injected post-catalogue at mod.rs:2497 |
Distinct handler modules reached by call_tool | 45 | mod.rs:2881 onward |
list_tools() (mod.rs:131) is the flags-off convenience wrapper over list_tools_with_flags(false, false) (mod.rs:156).
Exactly two flags change the catalogue itself:
CORECRUXD_AGENT_PASSPORTS, default off (config.rs:960). Changesissue_passport's surface marker from[hosted]to[local], not its membership (mod.rs:2446).CORECRUXD_FEATURE_PASSPORT_MINT_REQUESTS, default off (config.rs:961). Changes membership:request_passport_mintis filtered out entirely while off (mod.rs:2440), and dispatch fails closed with-32601(mint_request.rs:22).
A third flag, CORECRUXD_TOOL_SURFACE, changes what is advertised without changing what exists, see 11.10.
Four tests hold the count line: list_tools_returns_expected_count (mod.rs:3156), tool_names_unique (:3195), list_tools_json_has_tools_array (:3204) and tool_output_docs_covers_all_tools (:3270).
11.8 How tools/list filtering works
list_tools_json_for_context (mod.rs:2474) reads the surface mode from the environment and delegates to list_tools_json_for_context_with_mode (mod.rs:2480).
| Step | What happens | Source |
|---|---|---|
| 1 | auth is derived from the RCX router's token, or is None when there is no token | mod.rs:2485 |
| 2 | Base catalogue = list_tools_with_flags(agent_passports_enabled, passport_mint_requests_enabled) | mod.rs:2489 |
| 3 | Authz filter. No router means unfiltered. A router means filter_tools_for_rcx_router | mod.rs:2490 |
| 4 | Extension tools are appended | mod.rs:2497 |
| 5 | Extension tools are separately RCX-filtered under capability crux-extension.<tool> | mod.rs:2498 |
| 6 | tools.extend(extension_tools); there is no dedup | mod.rs:2509 |
| 7 | Surface shaping runs last, after authz and after the extension merge, so it can only narrow and never widen | mod.rs:2516, rationale :2510 |
| 8 | tools_to_json(tools, auth) | mod.rs:2536 |
Capability naming: the grant-to-tool mapping
rcx_capability_for_tool (mod.rs:2713):
| Tool pattern | Capability string |
|---|---|
ext.* | crux-extension.<tool_name> |
query, query_scan, query_expand | corecrux.query.local, all three share one capability |
| everything else | crux-mcp.<tool_name> |
rcx_mcp_tool_capability (mod.rs:2661) additionally sets the backend. Hosted-gated tools get backend_id = "hosted.vaultcrux.com" (tool_surface.rs:21); everything else gets backend "local" with egress [None].
The operator's rule: a passport's token must contain a backend whose id matches (local or hosted.vaultcrux.com) and which lists the exact capability string. A crux-mcp.sync_pull grant attached to the local backend surfaces nothing.
The denial gates behind the filter
filter_tools_for_rcx_router (mod.rs:2648) runs a full decide() per tool (crux-router/src/lib.rs:291) with estimated_credit_cost: 0 and backend_reachable: true. In order (crux-router/src/lib.rs:197 onward):
| Gate | Reason code | Source |
|---|---|---|
| Token requires contextual verification | TokenInvalid | lib.rs:198 |
| Issuer signature invalid | TokenInvalid | lib.rs:201 |
validate_basic fails for a non-expiry reason | TokenInvalid | lib.rs:204 |
No backend both matches preferred_backend and permits the capability | CapabilityNotPermitted | lib.rs:212 |
| Capability absent from that backend's permitted list | CapabilityNotPermitted | lib.rs:217 |
| Requested egress class not permitted | EgressNotPermitted | lib.rs:225 |
| Required attestation absent | AttestationMissing | lib.rs:233 |
| Token expired, so fallback | TokenExpired | lib.rs:241 |
| Replay receipt class on a debitable call | ReceiptClassSideEffectDenied | lib.rs:260 |
| Insufficient credit | InsufficientCredit | lib.rs:263 |
What gets attached to each tool in the response
tools_to_json (mod.rs:2558):
| Field | When | Source |
|---|---|---|
name, description, inputSchema | always | mod.rs:2606 |
inputSchema["x-crux-token-ref"] = {token_id, token_hash} | auth present | mod.rs:2564 |
inputSchema["x-crux-receipt-class"] and ["x-crux-tier"] | auth present | mod.rs:2573 |
_meta.crux.consequence_metadata: {schema, domain, reversibility, materiality, idempotency_class, blast_radius, compensating_tool, pro_enricher_available} | always, including for unauthenticated callers | mod.rs:2577; source action_enrichment.rs:183 |
_meta.crux.filtered_by = "rcx-capability-token" | auth present | mod.rs:2581 |
_meta.crux.token_ref, receipt_class, tier | auth present | mod.rs:2582 |
_meta.crux.upgrade = {platform_available, requires, docs} | description starts [hosted] | mod.rs:2594 |
x-crux-output-schema = {$ref, contract:"crc-v1", kind, when} | tool has a CRC-v1 kind | mod.rs:2604; builder crc_v1.rs:381 |
ToolAuthMetadata (mod.rs:2539) is a four-string provenance stamp, token_id, token_hash, receipt_class, tier. It performs no filtering. It exists to record which token shaped the response.
The unauthenticated caller
Two layers must not be conflated.
Transport. authenticate_agent (server.rs:336): no Authorization header plus an empty agent registry gives Anonymous; an empty header against a non-empty registry gives 401. A valid hosted OAuth bearer carrying mcp:read is read-only, enforced pre-dispatch (server.rs:173).
Catalogue. Anonymity does not shrink tools/list. An anonymous caller normally has no RCX router, so:
- No
_meta.crux.filtered_by, notoken_ref, nox-crux-*schema keys, no top-level_meta. - The base catalogue is returned unfiltered (mod.rs:2494), all 118 tools, including the three marked
[hosted]. - Zero extension tools, because the calling passport fingerprint is
None(extensions.rs:40). - Under
dynamicsurface mode, all anonymous callers share one global intent slot keyed__anon__(mod.rs:2521).
11.9 Extension tools are appended after the catalogue
ext.* tools are injected by list_extension_tools(ctx) (extensions.rs:39) at step 4 of the pipeline above, after list_tools_with_flags has produced the static catalogue. Three consequences follow directly.
- They are not counted in
TOOL_COUNT. The 118 figure is the static catalogue only. A daemon with extensions installed advertises more than 118 tools and no assertion covers the difference. - They are per-caller. Resolution requires a passport fingerprint, so a caller without one gets an empty list (extensions.rs:40). Two agents on the same daemon see different lists.
- The merge does not dedup (mod.rs:2509). An extension declaring a name that collides with a built-in appears twice in
tools/list, and every call routes to the built-in handler, because the built-in match arms precede the extension arm (mod.rs:2882 onward, extension guard at :3020). Nothing rejects this at install time.
The ext. prefix is load-bearing and unvalidated: is_extension_tool_name (extensions.rs:179) is a plain starts_with("ext."), while IntegrationManifest::validate (crux-integrations/src/lib.rs:481) checks only that a tool's name and description are non-empty. A manifest tool named without the prefix is advertised, gets the wrong capability string, and is not dispatchable. The full mechanism, including trust tiers and the loopback dispatch path, is in the daemon guide, chapter 10.
11.10 Intent-based surface narrowing
The full surface serialises to roughly 27.8k tokens, re-sent every turn (surface.rs:10). CORECRUXD_TOOL_SURFACE (surface.rs:96) trades catalogue size against discoverability.
| Value | Mode | Result |
|---|---|---|
| unset, or any unrecognised value | Full (the default) | Identity. Byte-for-byte the unshaped catalogue |
minimal | Minimal | The 16-tool core floor only (surface.rs:131) |
dynamic | Dynamic | Floor plus the top 12 scored tools (surface.rs:295) |
Parsing is case-insensitive and trimmed, and any unrecognised value falls back to Full (surface.rs:106), a typo can never silently shrink a production surface. DYNAMIC_TOP_N = 12 (surface.rs:42); INTENT_TTL_SECONDS = 3600 (surface.rs:46).
The 16-tool core floor (surface.rs:56), with cuecrux_session pinned first:
| Group | Tools |
|---|---|
| discovery | cuecrux_session |
| retrieve | query, query_scan, query_expand |
| remember | store_fact, query_facts, get_bootstrap, memory_view |
| session continuity | save_session, get_session |
| self-identify | get_agent_identity, get_passport |
| verify | receipt_verify |
| ops posture | sync_status |
| coordination | create_handoff, accept_handoff |
Intent capture. cuecrux_session(intent=…) records a per-passport intent (cuecrux_session.rs:87) into a process-global map (surface.rs:146). A blank intent clears it; expired records are evicted on read. Because stateless HTTP cannot push, the intent is read on the next tools/list, unless the client holds an open SSE stream, in which case the list_changed notification fires immediately (server.rs:208).
There are exactly five valid intents (crux-session/src/intent.rs:45). Anything else scores zero bias and yields the floor only.
| Intent | Affinity biases | Beyond-floor tools advertised |
|---|---|---|
audit_review | audit 30, proof 20, retrieval 10 | list_observations, get_observation, verify_observation, record_decision, declare_constraint, get_constraints, check_constraints, audit_config, check_config_audit, audit_export_bundle, tool_trace_recent, learn |
compliance_export | audit 30, proof 25, economy 10 | The identical 12, audit at 30 saturates the slots |
document_ingest | memory 30, journal 20, retrieval 10 | delete_fact, list_entities, fact_history, memory_acknowledge_use, memory_forget, memory_forget_dry_run, memory_edit, memory_pin, memory_history, memory_freshness, memory_sweep_candidates, memory_set_horizon |
session_review | session 30, memory 20, journal 10 | list_sessions, delete_session, archive_session, unarchive_session, get_workspace_storyline, register_repo, list_repos, then delete_fact, list_entities, fact_history, memory_acknowledge_use, memory_forget |
knowledge_query | retrieval 30, memory 20, session 5 | get_gaps, then delete_fact, list_entities, fact_history, memory_acknowledge_use, memory_forget, memory_forget_dry_run, memory_edit, memory_pin, memory_history, memory_freshness, memory_sweep_candidates |
| none, or unrecognised | - | Floor only, identical to minimal |
Scoring is intent_bias(tool_affinity(tool)) + trace_boost(tool), sorted descending and stable on catalogue index, keeping only positive scores (surface.rs:295). It never pads with irrelevant tools (surface.rs:258). Trace boosts are capped at 12 (surface.rs:269), deliberately below the maximum intent bias of 30, so a declared intent dominates recent habit.
Three limits of the affinity table, stated because they change what you will see:
- Roughly 60 real tools have no affinity at all and can never be surfaced by intent alone, the whole coordination, orchestrator, punchcard, substrate, features, GitHub, approvals and passport-lifecycle planes. Under
dynamicthey appear only via trace boosts. - No
ext.*name has an affinity, so extension tools are invisible underminimaland, absent trace boosts, underdynamic. proof_verifyappears intool_affinity(surface.rs:248) but is not a tool; thejournalandeconomyaffinities are referenced by the intent table but no tool maps to either. Unlike the core floor, which is guarded bycore_floor_names_exist_in_full_surface(surface.rs:349),tool_affinityhas no existence test, so these dead entries are invisible to CI.
Shaping is advertisement, not authorisation. A shaped-out tool remains callable by name; dispatch is a match on the name gated only by the RCX capability check (dispatch.rs:457).
11.11 token_budget: what is actually mandatory
The house convention says token_budget is mandatory on every retrieval call. That is a house convention, not a code contract. Of the 25 tools that accept token_budget, 7 reject a call that omits it. There is no server-side maximum or clamp anywhere in the MCP surface, and no environment variable that sets a default.
The hazard, stated plainly
Omitting token_budget on query, query_scan or query_facts does not fall back to a default. It applies no bound. The load-bearing line is query.rs:120:
None => result.hits,
The result is bounded only by limit, which defaults to 10 for query, 20 for query_scan and top_k 10 for query_facts. That is survivable for a small corpus and a real cost problem at scale, especially with a raised limit. Pass token_budget explicitly on every retrieval call. Nothing in the daemon will do it for you.
Group A: hard-required; the call fails with -32602 if omitted
| Tool | In schema required[] | Enforcement | Minimum |
|---|---|---|---|
session_checkpoint | mod.rs:900 | sessions.rs:79 plus an explicit greater-than-zero check | greater than 0 |
execplan_gate | mod.rs:986 | same require_u64 path | greater than 0 |
audit_export_bundle | mod.rs:1446 | audit_export.rs:118 | at least 1 |
passport_split | mod.rs:1573 | identity.rs:84 | 1 |
passport_merge | mod.rs:1621 | identity.rs:84 | 1 |
passport_link_device | mod.rs:1661 | identity.rs:84 | 1 |
approval_request | mod.rs:2242 | approvals.rs:279, a presence check only, so token_budget: 0 passes. It runs before the feature flag check at :317, so a call against a disabled feature still errors on the missing budget first | none |
Group B: optional, with a silent hardcoded default
No environment variable overrides any of these.
| Tool | Default | Applied at | Declared in the schema? |
|---|---|---|---|
memory_view | 2000 | memory.rs:170 | Description only (mod.rs:553); there is no "default" key |
memory_freshness | 500 | freshness.rs:105 | Yes, mod.rs:638 |
memory_sweep_candidates | 500 | freshness.rs:190 | Yes, mod.rs:661 |
memory_contradictions | 500 | consolidation.rs:96 | Yes, mod.rs:684 |
tool_trace_recent | 2000 | tools/traces.rs:78 | Yes, tools/traces.rs:55 |
activity_recent | 500 | tools/activity.rs:116, an explicit 0 also falls back to 500 | Description says "Defaults to 500" (mod.rs:2206); not in required[], despite the prose at mod.rs:2189 claiming "Required: session_id, token_budget" |
Group C: accepted, and it does not bound the result
| Tool | Behaviour when omitted | Source |
|---|---|---|
query | Returns all of limit, default 10. No budget applied. | query.rs:120 |
query_scan | No trim; all of limit, default 20. The parameter is read by the handler but is not in the schema | query.rs:202 |
query_facts | The whole top_k, default 10, returned untrimmed | facts.rs:787 |
memory_forget | The resolver touches everything in scope | forget.rs:448 |
memory_forget_dry_run | Same | forget.rs:337 |
artefact_put, artefact_get, artefact_list | Descriptions say "Mandatory output-token cap (QC.2)". It is never in required[] and never validated | mod.rs:778, :798, :819 |
output_attest | When present it is an admission gate, not a trimmer, oversize content is rejected with -32602 | output_attest.rs:285 |
autonomy_contract | Full matrix, no trim | autonomy.rs:218 |
context_custody_audit | Advisory only, never read by the handler; the scorecard is fixed-size | context_custody_audit.rs:63 |
session_token_usage | Accepted "for QC.2 conformance" and discarded, the handler signature ignores its arguments | token_usage.rs:32 |
get_bootstrap declares no token_budget at all (mod.rs:378); its handler hardcodes top_k: 100, token_budget: None (facts.rs:844). Passing one is not an error; it is ignored.
No clamp, and non-uniform reporting
An agent may pass token_budget: 4_000_000_000 and it is honoured verbatim. The only clamp in the repository is on a non-MCP HTTP route, body.token_budget.clamp(128, 128_000) (workbench.rs:304).
Budget-usage reporting is not uniform, and nothing anywhere emits budget_remaining.
| Tool | Reported fields |
|---|---|
query | total_candidates; under CRC-v1 also cost_estimate. No tokens_used, no truncation flag |
query_scan | tokens_returned, budget_truncated |
query_facts | Under CRC-v1, cost_estimate plus total_candidates. Legacy shape reports nothing |
memory_view | total_tokens, returned |
activity_recent | token_budget echoed, returned, truncated |
autonomy_contract | summary.truncated_by_token_budget: a count, not a boolean |
session_token_usage | used, limit, pct, tokens_in, tokens_out, calls |
session_checkpoint, get_session, get_bootstrap, get_gaps | total_tokens |
Truncation is uniformly instrumented, but as a Prometheus counter rather than a response field: corecrux_tool_response_truncated_total{tool, reason} (ledger.rs:265). Every dispatch also records the declared budget into the ledger event as token_budget_in (dispatch.rs:521).
CORECRUXD_SESSION_TOKEN_BUDGET is report-only. It is read solely by session_token_usage (token_usage.rs:47). Exceeding it neither fails nor truncates anything; it only makes pct exceed 100.
11.12 The bootstrap surface
get_bootstrap is the daemon's runtime knowledge surface, and it is a thin filter over the fact store, not a separate content system. It queries facts whose entity begins __bootstrap__:: (facts.rs:822).
topic is normalised by normalize_bootstrap_topic (facts.rs:882) and used as an entity prefix.
| Caller passes | Normalises to | Prefix queried |
|---|---|---|
doc, docs | doc | __bootstrap__::doc: |
pattern, patterns | pattern | __bootstrap__::pattern: |
error, errors, resolution, resolutions | resolution | __bootstrap__::resolution: |
tool, tool-output, tool-outputs | tool-output | __bootstrap__::tool-output: plus synthesized entries |
| anything else | passes through verbatim | __bootstrap__::<verbatim>: |
| omitted | - | __bootstrap__::; everything |
The taxonomy is open. An unrecognised topic is not an error; it is a prefix that probably matches nothing, and the tool returns no bootstrap knowledge for topic '<t>' (facts.rs:869).
Content arrives three ways: seeded unconditionally and idempotently at daemon startup (bootstrap.rs:65, called at main.rs:992, with newly embedded facts backfilled into already-seeded stores at bootstrap.rs:107); written by operators or agents as any store_fact under a __bootstrap__:: entity; and synthesized on demand for topic="tool-output" (crc_v1.rs:395).
The reserved __bootstrap__:: prefix is filtered out of memory_view, memory_freshness, memory_sweep_candidates, the audit envelope, traces and the activity log, bootstrap content never leaks into consumer memory surfaces.
The output-contract document. The MCP specification has no outputSchema field, so tool_output_docs() (mod.rs:2728) substitutes for it: a canonical per-tool output contract covering all 119 tools, reachable at runtime via get_bootstrap(topic="tool-output") and CI-guarded by tool_output_docs_covers_all_tools (mod.rs:3270). The "Output" rows in chapters 12 and 13 reproduce it.
Sources
- crates/crux-mcp/src/server.rs:48, router builder
- crates/crux-mcp/src/dispatch.rs:397,
dispatch, the four methods - crates/crux-mcp/src/dispatch.rs:468,
dispatch_tool_call - crates/crux-mcp/src/protocol.rs:13, error-code constants
- crates/crux-mcp/src/tools/mod.rs:3108,
TOOL_COUNT - crates/crux-mcp/src/tools/mod.rs:2480,
list_tools_json_for_context_with_mode - crates/crux-mcp/src/tools/mod.rs:2713,
rcx_capability_for_tool - crates/crux-mcp/src/tools/surface.rs:96,
CORECRUXD_TOOL_SURFACE - crates/crux-mcp/src/tools/query.rs:120, the unbounded retrieval path
- crates/crux-mcp/src/tools/extensions.rs:39,
list_extension_tools - crates/crux-router/src/lib.rs:197, router denial gates
- crates/corecruxd/src/config.rs:817, MCP host, port, enabled
All line references were verified at commit 93b41a7.

