Migrate from MCP to Chio
Move a working MCP connection behind the kernel, then prove one permitted call, one refusal, and an export that verifies offline.
On this page
Moving an MCP connection behind the kernel changes one entry in your client's configuration. The server binary, its arguments, and your client's MCP library stay as they are, and from the first call through chio mcp serve the kernel admits each tools/call and signs a receipt for it.
Save the connection's current executable, arguments, environment, and working directory before you edit the launcher. Then rerun the same representative work through Chio, including one request your policy must refuse.
StartThe client launches Chio, and Chio launches your server
Before the migration the client spawns the server and every call runs. After it, the client spawns chio, Chio spawns the same command as a child process over stdio, and the kernel decides each call first.
κcapabilityσscopegguardsBbudget
The guards check refuses before dispatch
A guard denies what the grant covers, and the first denial stops evaluation. The kernel signs a deny receipt and the client receives an error result. The tool server is never called. Guards.
Tool names, input and output schemas, descriptions, and annotations are forwarded verbatim from tools/list, so the agent sees what the server exposes. What the migration adds:
- Signed receipts. Each kernel decision, allow or deny, produces a
chio.receipt.v1signed with the kernel's Ed25519 key, which you verify offline and feed to audit pipelines. - Policy refusals. A call your policy refuses, such as a read of
.env, returns a structured error the agent can read and re-plan from, and its receipt names the refusing guard. - Optional metering. With tool pricing configured, each call carries a price and the agent can surface budget state. Pricing is opt-in and off by default.
Keep any permissions the server enforces itself. The kernel governs the calls routed through this connection, so close other routes to the server and its backend.
1. Read the tool names your server exposes
Grants and presets match server ids and tool names exactly, so read the live names before you write a rule. Put your own server command after --.
chio mcp wrap --server-id workspace --print-scopes \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace[[capability]] tool = "read_text_file" scope = "read" urn = "urn:chio:scope:tool:read"
You should see one [[capability]] block per tool. The filesystem package's read tool is read_text_file, while the code-agent preset grants read_file; write the policy for the names you see. The policy and captured checks below use read_file as their sample name. Wrap an MCP Server walks through the full scaffold.
Before you begin
Install the chio binary from Installation. Copy the server command your client launches, verbatim: Chio spawns it unchanged, so whatever your client config puts after the executable name goes after --. Choose two database paths. --session-db holds admission state and --receipt-db holds receipts; both are global flags that go before the subcommand, and chio check and chio mcp serve each exit without them. Either path may name a file that does not exist yet, because the store is created on first use.
2. Start from a deny-by-default policy
Allow one tool and refuse sensitive paths. Every other tool is denied, which gives you a baseline to widen one tool at a time.
hushspec: "0.1.0"
name: migration-baseline
rules:
tool_access:
enabled: true
default: block
allow:
- read_file
forbidden_paths:
enabled: true
patterns:
- "**/.env"
- "**/.env.*"
- "**/.ssh/**"
- "**/*.pem"
- "**/*.key"
- "**/credentials*"
exceptions: []
secret_patterns:
enabled: true
velocity:
enabled: true
max_invocations_per_window: 100
window_secs: 60The agent can now call read_file on anything outside the forbidden patterns. Expect the first run to refuse tools that plain MCP allowed; add each one to the allow list with the guards it needs. Write a Policy covers the full rule set.
Start from the code-agent preset instead
The code-agent preset is a deny-by-default baseline for file, shell, and git work, and it needs no policy file. It grants named tools on the server ids workspace, shell, and git. No command writes the preset out for editing; when you outgrow it, write a HushSpec policy and swap --preset code-agent for --policy ./policy.yaml. chio init <path> is a different tool: it scaffolds a standalone demo project with its own tool server and smoke runner.
# No policy file required: the preset is built in.
chio --session-db ./session.db --receipt-db ./receipts.db \
mcp serve --preset code-agent --server-id workspace \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace3. Check the policy before the agent meets it
Run chio check against the policy in full mode. This policy enables secret_patterns, which reads the tool's output, so --output-fixture takes a path to a JSON file standing in for the result; inline JSON is rejected.
$ chio --session-db ./admission-1.db --receipt-db ./receipts.db \
check --policy ./policy.yaml --tool read_file \
--params '{"path": "./workspace/README.md"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: ALLOW tool: read_file server: * receipt_id: 8cf7f0cb9468defc881ba90137f5978636c7dc7e0d9911f5ed6f15061e2a9b84 policy: 60df7b477be3ee42dfbe33fec4bac9b95628ac5fcb8ae526e439b69cd322acd4 source: b91346824d582bfba3f3fad1eb0627a2ede05f7c0fc778a93d224d055449c21d mode: full fixture: true
You should see ALLOW. Then check the two refusals the baseline exists to produce. The first is a scope refusal: write_file is outside the allow list, so the request never reaches a guard.
$ chio --session-db ./admission-2.db --receipt-db ./receipts.db \
check --policy ./policy.yaml --tool write_file \
--params '{"path": "./workspace/output.txt", "content": "hi"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: write_file server: * reason: requested tool write_file on server * is not in capability scope receipt_id: 41cfc48636745c6113a5ad03f80c89a82d3cf0e1884566391ae7ddc31f9ad465 policy: 60df7b477be3ee42dfbe33fec4bac9b95628ac5fcb8ae526e439b69cd322acd4 source: b91346824d582bfba3f3fad1eb0627a2ede05f7c0fc778a93d224d055449c21d mode: full fixture: true
The second is a guard denial. Its reason: line names the registered top-level guard, the compiled pipeline; the rule block that fired is recorded in the receipt, in step 6.
$ chio --session-db ./admission-3.db --receipt-db ./receipts.db \
check --policy ./policy.yaml --tool read_file \
--params '{"path": "./workspace/.env"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: read_file server: * reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: b1c490817e95b7b8a3340f1fc0a8d79a4a83f9a4cbc3773241f35b387345d58b policy: 60df7b477be3ee42dfbe33fec4bac9b95628ac5fcb8ae526e439b69cd322acd4 source: b91346824d582bfba3f3fad1eb0627a2ede05f7c0fc778a93d224d055449c21d mode: full fixture: true
Without --mode full, the check stops with CHIO-CLI-PREFLIGHT-NEEDS-OUTPUT: preflight never runs the tool, so it cannot evaluate post-output guards.
4. Change the client's launcher
Point the client's server entry at chio. Everything after -- is the literal command Chio runs as the child process, so copy your original command verbatim.
{
"mcpServers": {
"workspace": {
"command": "chio",
"args": [
"--session-db", "/abs/path/session.db",
"--receipt-db", "/abs/path/receipts.db",
"mcp", "serve",
"--policy", "/abs/path/policy.yaml",
"--server-id", "workspace",
"--",
"npx", "-y",
"@modelcontextprotocol/server-filesystem", "./workspace"
]
}
}
}Use absolute paths for the policy and both databases: the client chooses the working directory, so a path that resolves in your shell may not resolve under the client. Restart the client. If you run several MCP servers, migrate them one at a time; each wrapped server takes its own --server-id, and the others keep working unwrapped.
A launcher missing either database flag exits before it spawns anything, and the preset does not exempt it:
$ chio mcp serve --preset code-agent --server-id fs \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceerror[CHIO-CLI-ADMISSION-STORE-REQUIRED]: durable admission needs a session database so operations and tool outcomes survive a restart help: pass --session-db <path> code: urn:chio:error:cli:admission-store-required docs: https://chio.computer/docs/reference/errors#error-cli-admission-store-required
5. Confirm one permitted call and one refusal
Each check is a command with an answer.
- The edge starts. Run your
chio mcp serveline from a shell in the client's working directory. It stays running; an immediate exit prints the missing flag. - The client sees the same tools. Compare the tool list before and after: names, descriptions, and schemas match.
- An ordinary call passes. Ask the agent for its most routine task, then run
chio --receipt-db <path> receipt list --admin-alland find a receipt whosedecision.verdictisallow. - A forbidden call fails. Ask it to read a
.env. The agent gets an error, and the store holds adenywhoseevidence[].guard_nameisforbidden-path. A migration with no captured deny is untested.
6. Export the receipts and verify them offline
Read the receipts back with the path the edge wrote. A local read names its boundary with exactly one of --tenant <id> or --admin-all, and output is JSON Lines whatever format flags you pass. The three checks from step 3, projected down to the decision:
$ chio --receipt-db ./receipts.db receipt list --admin-all \
| jq -c '{tool: .tool_name, verdict: .decision.verdict,
$ reason: .decision.reason, guard: .decision.guard,
$ evidence: [.evidence[]?.guard_name]}'{"tool":"read_file","verdict":"allow","reason":null,"guard":null,"evidence":[]}
{"tool":"write_file","verdict":"deny","reason":"requested tool write_file on server * is not in capability scope","guard":"kernel","evidence":[]}
{"tool":"read_file","verdict":"deny","reason":"guard denied the request: guard \"guard-pipeline\" denied the request","guard":"kernel","evidence":["forbidden-path"]}You should see guard: "kernel" on both denials and forbidden-path in evidence[].guard_name on the guard denial. Filter and alert on that field; the reason string names only the pipeline. Pipe the same stream to jq for decision counts, denied tools, and cost histograms. Then export a package with the same explicit boundary, and verify it without contacting the kernel.
$ chio --receipt-db ./receipts.db evidence export --output ./pkg --admin-all$ chio evidence verify --input ./pkgevidence package verified tool_receipts: 3 child_receipts: 0 checkpoints: 0 checkpoint_publications: 0 checkpoint_witnesses: 0 checkpoint_consistency_proofs: 0 checkpoint_equivocations: 0 capability_lineage: 3 inclusion_proofs: 0 uncheckpointed_receipts: 3 authorized_receipts: 1 trace_observations: 0 advisory_evaluations: 0 verified_files: 12 child_receipt_scope: FullQueryWindow transparency_preview_logs: 0 publication_state: transparency_preview
You should see evidence package verified with three tool receipts and their capability lineage. The package bundles receipts, capability lineage, checkpoints, and inclusion proofs, and a gap or a mutation fails verification with a non-zero exit. The audit trail starts at the first call through chio mcp serve; calls made before the migration have no receipts.
One receipt in full
The metadata object is dropped here because it carries per-run nonces and admission bookkeeping; the rest is the whole receipt.
$ chio --receipt-db ./receipts.db receipt list --admin-all \
| jq 'select(.action.parameters.path == "./workspace/.env") | del(.metadata)'{
"id": "b1c490817e95b7b8a3340f1fc0a8d79a4a83f9a4cbc3773241f35b387345d58b",
"timestamp": 1788597783,
"capability_id": "cap-01a070bc-4b26-7422-bf79-e5a64fe86edd",
"tool_server": "*",
"tool_name": "read_file",
"action": {
"parameters": {
"path": "./workspace/.env"
},
"parameter_hash": "01a3da31bac3d250f150339143ffea8d946d0b78c2373e0470878a1ab14b802e"
},
"decision": {
"verdict": "deny",
"reason": "guard denied the request: guard \"guard-pipeline\" denied the request",
"guard": "kernel"
},
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"tool_origin": "caller_executed",
"redaction_mode": "none",
"content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"policy_hash": "60df7b477be3ee42dfbe33fec4bac9b95628ac5fcb8ae526e439b69cd322acd4",
"evidence": [
{
"guard_name": "forbidden-path",
"verdict": false,
"details": "action=deny; reason=guard denied request"
}
],
"trust_level": "mediated",
"kernel_key": "50e420c0095f75b0dfa7d5b0d22763207a5afcd04ab08b035f5bd997018bc30d",
"signature": "35ebb666768125063f446bb07065d9f3192f1af733b3aba2975ed6c2fd3c8c59c06b4eff9e08e1095bcd9d398e37042ee8646e8c17a13cbb57228102c18c4306"
}Read more in Verify Receipts Offline.
MCP migration reference
Try resources, sampling, --admin-all, protocolVersion, or WSL.
MCP feature coverage
The adapter is a thin translation layer over chio-mcp-adapter; most MCP features pass through unchanged.
| MCP feature | Handling | Governed by |
|---|---|---|
tools/list | Forwarded verbatim | Governed manifest built once |
tools/call | Admitted, then dispatched | Kernel checks and receipt |
stdio transport | Supported | Client spawns chio |
Streamable HTTP | Supported | chio mcp serve-http |
Server notifications | Forwarded in order | No guard |
resources/list, resources/read | Passthrough | Opt-in scoping |
resources/ | Passthrough | No guard |
prompts/list, prompts/get | Passthrough | Opt-in scoping |
completion/ | Forwarded unchanged | No guard |
sampling/ | Passthrough | allow_ |
Elicitation, including URL mode | Supported | Structured operations |
OAuth2 and OIDC | HTTP edge only | serve-http auth flags |
- Tools. The adapter queries the upstream once and builds a governed manifest, preserving schemas and annotations. Calls admitted by the edge reach the kernel's checks before dispatch; scope-refusal errors from tool selection come first and carry no kernel receipt.
- Streamable HTTP. The session contract follows the MCP specification:
initializeonPOST /mcp, the session id in the response, andGET /mcpfor notifications and replay. - Resources and prompts. Both are allow-through by default. For per-URI or per-prompt control, grant them under
capabilities.default.resourcesandcapabilities.default.promptsinchio.yaml, each entry auriorpromptpattern withoperationsand attl. These are keys of the deployment config; a HushSpec document has eight top-level keys andcapabilitiesis not one of them. The kernel validates the selected capability and scope before calling the provider. Successful reads and prompt gets return provider data without a tool-call receipt, and the filesystem resource-root refusal returns a signed denial, so retain operational access logs for complete fetch history. Templates are surfaced to the client with no guard. - Completion, sampling, elicitation. Completion requests for prompts and resource URIs forward to the upstream, which answers unchanged. Nested sampling is proxied unchanged; enable
allow_sampling_tool_usein the policy's kernel block to govern tool use inside it. URL-mode elicitations are parsed into structured operations, so the agent can prompt the user to open an auth page. - OAuth2 and OIDC. The auth flags live on
chio mcp serve-http, which takes--policyand has no--preset. - Platforms. macOS and Linux run the stdio edge natively; on Windows, run it under WSL.
CHIO-CLI-ADMISSION-STORE-REQUIRED
The edge printed durable admission needs a session database so operations and tool outcomes survive a restart: one or both of --session-db and --receipt-db is missing. Supply both, before the subcommand. With --session-db present, --revocation-db and --budget-db are rejected, because the durable admission authority owns that state.
durable receipt persistence unavailable on every verdict
Only --session-db was supplied, and every call denies until a receipt store exists. Add --receipt-db.
CHIO-CLI-PREFLIGHT-NEEDS-OUTPUT
The policy enables an output-sensitive rule block, secret_patterns or patch_integrity. Rerun with --mode full --output-fixture <path>, where the value is a path to a JSON file.
$ chio --session-db ./admission-0.db --receipt-db ./receipts.db \
check --policy ./policy.yaml --tool read_file \
--params '{"path": "./workspace/README.md"}'error[CHIO-CLI-PREFLIGHT-NEEDS-OUTPUT]: preflight cannot evaluate post-output guards, because it never runs the tool help: run the check with --mode full --output-fixture <JSON> so output-sensitive guards see explicit fixture output code: urn:chio:error:cli:preflight-needs-output docs: https://chio.computer/docs/reference/errors#error-cli-preflight-needs-output
CHIO-KERNEL-RECEIPT-READ-CONTEXT-MISSING
A local receipt read needs a read boundary; pass exactly one of --tenant <id> and --admin-all. Over --control-url the boundary comes from the token, and passing --tenant there is an error. The export path applies the same rule:
$ chio --receipt-db ./receipts.db evidence export --output ./pkgerror[CHIO-KERNEL-RECEIPT-READ-CONTEXT-MISSING]: evidence export needs a read boundary help: pass --tenant <id> to export one tenant's receipts, or --admin-all to export every tenant as the operator code: urn:chio:error:kernel:receipt-read-context-missing docs: https://chio.computer/docs/reference/errors#error-kernel-receipt-read-context-missing
unsupported protocolVersion from the edge
The client offered a protocol version this edge does not implement. The error body carries supportedProtocolVersions, and the client picks from that list.
The agent stalls with no error and no receipt
The upstream can still be running after your MCP client stops waiting. Inspect the original request, its retained outcome, and the upstream state before you retry. A recorded policy refusal and a missing terminal record establish different things, and an empty receipt query leaves open whether execution began.
Behaviors a first migration meets
- Large results are scanned on the way back.
secret_patternscompiles a guard on the write path, which can deny a file write carrying a secret, and a post-invocation hook on the read path, which redacts matches from the tool's result. On a stdio-wrapped server that scan can dominate latency for multi-megabyte responses; slice them server-side. - A preset grant is a tool name and a server id, matched exactly. A server wrapped under another
--server-id, or one whose tools are spelled differently, falls outside the preset's scope. Those calls return an MCP result withisError: truereadingtool is not authorized by the active capability set, and leave nothing in the receipt store. - Unusual tool names need quotes. Names with colons, dots, or whitespace are legal in MCP; quote them in the allow list, as in
- "workspace:write". If you control the server, prefer plain snake_case.
Next: widen the policy from what the receipts show
Capability tokens, delegation, tool pricing, HTTP edges, and custom guards build on this baseline. When your application owns the request and execution path, use the Python SDK in place of a launcher change.