Wrap an MCP Server
Put Chio between an MCP client and the stdio server it launches, so the kernel admits each tool call and signs its receipt.
On this page
Every tools/call your MCP client sends through Chio is admitted by the kernel before your server runs it. Chio launches the server as a child process, keeps its tools and wire format, and signs a receipt for each decision the kernel makes.
The server needs no change. Your client launches chio in place of the server, and a HushSpec policy, or the bundled code-agent preset, decides what the kernel admits.
The kernel sits between the client's call and the server
The client speaks MCP to Chio over stdio, and Chio speaks MCP to your server over the child's stdio. The MCP edge resolves each tools/call against the active grant and hands it to the kernel, which runs its checks, invokes the server, and signs the receipt before the result returns.
κcapabilityσscopegguardsBbudget
Every check passes, so the kernel invokes the server
The client launches Chio, and Chio launches the server as a child process over stdio. The edge resolves tools/call against the active grant and hands it to the kernel, which invokes the server and signs the receipt before the result returns. The Mediated Call.
Two refusals come out of this edge. A call the kernel evaluates gets a signed receipt whichever way it goes: on a deny, the client receives an error result and the server is never called. A tool outside the active capability set is refused first, at tool selection, as a result with isError set and a notifications/message log entry, and leaves no receipt.
A listed tool carries no authority of its own; each call is admitted on its own. The child process runs with whatever filesystem and network access the host gives it, so confine it there and close any direct route to the server, as the mediation boundary describes.
1. Install Chio and list your server's tools
Install the CLI, then ask the wrapper to read your server's live tool list. The steps wrap the filesystem server; put your own server's launch command after --.
curl -fsSL https://chio.computer/install.sh | sh
chio mcp wrap --print-scopes \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace# chio mcp wrap -- inferred capability manifest scaffold # Review each tool below; flip `allow = true` to promote. # Default is deny. server_id = "mcp" # TODO: review the inferred scopes below before promoting. [[capability]] tool = "read_text_file" scope = "read" urn = "urn:chio:scope:tool:read" description = "Read the complete contents of a file from the file system as text. ..." allow = false # TODO: review and promote [[capability]] tool = "write_file" scope = "destructive" urn = "urn:chio:scope:tool:destructive" description = "Create a new file or completely overwrite an existing file with new content. ..." allow = false # TODO: review and promote
You should see one [[capability]] block per tool, each with the scope the classifier chose and the tool's own description. Read the scopes before you write a policy: write_file and edit_file land in destructive, and read_file carries the description DEPRECATED: Use read_text_file instead., so a read-only policy allows read_text_file.
Before you begin
You need macOS or Linux with curl, Node.js, and npx. Skip the installer if chio is already on your PATH; Installation lists the other install options. Tool names differ between servers, and a policy entry that names a tool the server does not expose matches nothing, so write each policy from this output.
2. Write a policy for those tools
Create a file in the directory the server exposes, so the read in step 5 has something to return.
mkdir -p workspace/src
printf 'export const x = 1;\n' > workspace/src/main.tsThen write a HushSpec policy that names the server's read tools, confines paths to the workspace, and refuses sensitive files, shell commands, and egress.
hushspec: "0.1.0"
name: fs-readonly
rules:
# Only allow read-oriented tools
tool_access:
enabled: true
default: block
allow:
- read_text_file
- read_file
- list_directory
- search_files
# Restrict filesystem access to the workspace.
# Globs are matched against the normalized path, which drops "." segments,
# so a "./workspace/**" pattern would never match anything.
path_allowlist:
enabled: true
read:
- "**/workspace/**"
write: []
patch: []
# Block sensitive file patterns
forbidden_paths:
enabled: true
patterns:
- "**/.env"
- "**/.env.*"
- "**/*.pem"
- "**/*.key"
- "**/.ssh/**"
- "**/credentials*"
exceptions: []
# No shell commands through this server
shell_commands:
enabled: true
forbidden_patterns:
- ".*"
# No network egress
egress:
enabled: true
allow: []
block: []
# Scan for secrets in tool arguments
secret_patterns:
enabled: true
# Validate patches
patch_integrity:
enabled: true
# Rate limit: 200 calls per 2 minutes
velocity:
enabled: true
max_invocations_per_window: 200
window_secs: 120Start from default: block and an empty write list, and add a tool only when your workflow calls it. Path globs match the normalized path, which drops . segments, so **/workspace/** matches and ./workspace/** never does. A database server takes a different policy, shown in the reference below.
3. Dry-run the policy with chio check
chio check evaluates one call against the policy without starting the server. This policy enables secret_patterns and patch_integrity, which read the tool's output, so save a stand-in tool response and run in full mode.
{"content":[{"type":"text","text":"export const x = 1;\n"}]}$ chio --session-db ./admission.db --receipt-db ./receipts.db \
check --policy ./fs-readonly-policy.yaml \
--tool write_file --server srv-files \
--params '{"path": "./workspace/out.txt", "content": "hello"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: write_file server: srv-files reason: requested tool write_file on server srv-files is not in capability scope receipt_id: 03a90fa8a9f55d4e04be3f4a58f9a5060aa4cec8101431868b113927ac2ab538 policy: 08e0e4550624c92d7154575cb2f2b8210019f483bd9f9f49b1c033c7b6db7720 source: cad65775097103eb33ac614535fcc47b74eddd34af34e2979757b4567c4298a7 mode: full fixture: true
You should see DENY with a reason that names the tool and the server: a tool outside tool_access.allow is refused at capability selection, before any guard runs. Without --mode full and --output-fixture, the preflight stops before a verdict:
$ chio --session-db ./admission.db --receipt-db ./receipts.db \
check --policy ./fs-readonly-policy.yaml \
--tool read_text_file --server srv-files \
--params '{"path": "./workspace/src/main.ts"}'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
Use the dry run for the tool boundary, forbidden_paths, egress, and shell_commands. Reuse the same session and receipt databases across checks; each invocation gets its own request identity. Test path_allowlist through the live session in step 5.
Why a path-bearing call denies in the dry run
The fixture supplies output to the evaluator; the check does not invoke the filesystem server. chio check opens a session that declares no filesystem roots, and PathAllowlistGuard treats an empty root set as matching nothing, so it denies every path-bearing call before it reads its own allowlist. A policy that enables path_allowlist therefore has no allow case in the dry run.
$ chio --session-db ./admission.db --receipt-db ./receipts.db \
check --policy ./fs-readonly-policy.yaml \
--tool read_text_file --server srv-files \
--params '{"path": "./workspace/src/main.ts"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: read_text_file server: srv-files reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: 7ea4d4171866d51af64f2acda7c5ebcc3ba357dc690f0fe859aa64e11421391a policy: 08e0e4550624c92d7154575cb2f2b8210019f483bd9f9f49b1c033c7b6db7720 source: cad65775097103eb33ac614535fcc47b74eddd34af34e2979757b4567c4298a7 mode: full fixture: true
The reason: line names only the pipeline. The receipt store records which guard fired, in evidence[].guard_name:
$ chio --receipt-db ./receipts.db receipt list --admin-all \
| jq -c '{tool: .tool_name, verdict: .decision.verdict,
$ reason: .decision.reason,
$ evidence: [.evidence[]?.guard_name]}'{"tool":"read_text_file","verdict":"deny","reason":"guard denied the request: guard \"guard-pipeline\" denied the request","evidence":["path-allowlist"]}
{"tool":"write_file","verdict":"deny","reason":"requested tool write_file on server srv-files is not in capability scope","evidence":[]}4. Serve the wrapped server
Start the governed server with the policy, a server id, and the two databases. --session-db holds admission state and --receipt-db holds receipts; both are global flags and go before mcp.
chio --session-db ./admission.sqlite --receipt-db ./receipts.sqlite \
mcp serve --policy ./fs-readonly-policy.yaml --server-id workspace \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceChio spawns the server, discovers its tools, and proxies each call; the server's write_file is refused before it reaches the subprocess. Without both database flags the edge exits with CHIO-CLI-ADMISSION-STORE-REQUIRED. Two edges may share one --receipt-db, which puts both servers in one audit trail.
--server-id names this server in capability tokens and receipts. Choose a stable, descriptive id such as workspace for the project's files or orders for its order database; changing it invalidates capability tokens scoped to the old id.
Serve with the code-agent preset instead
--preset code-agent replaces --policy; pass one or the other. The preset bundles deny-by-default guards for code-agent work: safe file reads, refused writes to .env, .git/**, and .ssh/**, and a refused git push --force. It still requires both databases. Run this from your project directory; Chio creates both databases, launches the filesystem server, and waits for MCP client messages until Ctrl+C.
chio --receipt-db ./chio-receipts.db --session-db ./chio-session.db \
mcp serve --preset code-agent --server-id workspace -- \
npx -y @modelcontextprotocol/server-filesystem@2026.8.31 .Use the command as your client's server command, with its working directory set to the project. Your client discovers read_file, list_directory, and the other filesystem tools; the official Python MCP SDK 1.26.0 discovers them and reads a file through this command. The kernel records each receipt in chio-receipts.db.
5. Point your client at Chio
Change the client's server entry so it launches chio with the same arguments as step 4. Use absolute paths in a client configuration, because the client chooses the working directory.
{
"mcpServers": {
"workspace": {
"command": "chio",
"args": [
"--session-db", "./admission.sqlite",
"--receipt-db", "./receipts.sqlite",
"mcp", "serve",
"--policy", "./fs-readonly-policy.yaml",
"--server-id", "workspace",
"--",
"npx", "-y",
"@modelcontextprotocol/server-filesystem", "./workspace"
]
}
}
}The agent sees the same tools, request and response format, and transport. Filesystem tools take absolute paths, and path_allowlist is checked against the roots the client declares over roots/list; a client that declares no roots has every path-bearing call denied. This session declares ./workspace and makes three calls:
{"tool": "read_text_file", "isError": false, "content": [{"text": "export const x = 1;\n", "type": "text"}]}
{"tool": "read_text_file", "isError": true, "content": [{"text": "guard denied the request: guard \"guard-pipeline\" denied the request", "type": "text"}]}
{"tool": "write_file", "isError": true, "content": [{"text": "tool is not authorized by the active capability set", "type": "text"}]}You should see the permitted read return the file, the case the dry run could not produce. The second read is a guard deny carrying the pipeline's message. The write_file call is the capability refusal, which never reaches the guards and reads differently for that reason.
6. Read the receipts
List the receipts from the same database the edge wrote. A local read names its boundary with exactly one of --tenant <id> or --admin-all, and fails closed without one.
$ chio --receipt-db ./stdio-receipts.db receipt list --admin-all \
| jq -c '{tool: .tool_name, server: .tool_server,
$ verdict: .decision.verdict,
$ evidence: [.evidence[]?.guard_name]}'{"tool":"read_text_file","server":"srv-files","verdict":"allow","evidence":[]}
{"tool":"read_text_file","server":"srv-files","verdict":"deny","evidence":["forbidden-path"]}You should see two receipts for three calls. The write_file refusal appears in the edge's notifications/message log, because tool selection turned it away before the kernel evaluated it. On the guard deny, decision.reason names guard-pipeline, decision.guard reads kernel, and the rule that fired is in evidence[].guard_name.
Output is JSON Lines, one receipt per line; pipe it to jq, and filter by capability, tool, outcome, or cost with the flags in chio receipt list --help. Verify each receipt against the kernel key your application trusts: the signature covers the recorded request, decision, and guard evidence.
Read more in Receipts.
Three commands put the kernel in front of an MCP server
The steps above use chio mcp serve. The other two commands share its launch shape and differ in what decides each call.
| Command | Decides each call with | Kernel on each call | Transport |
|---|---|---|---|
chio mcp wrap | A promoted TOML allow-set | Nonce preflight with --strict-execution-nonce | stdio |
chio mcp serve | HushSpec policy or --preset | Guards and a signed receipt | stdio |
chio mcp serve-http | HushSpec policy | Guards and a signed receipt | Streamable HTTP |
chio mcp wrap is a manifest-gated pass-through for a first look at a server: it compiles no HushSpec policy. With --strict-execution-nonce, each allowed call runs through a kernel preflight that mints a single-use execution nonce and re-presents it before the server is invoked. Use serve or serve-http when you need policy evaluation and guards on every call.
MCP wrapper reference
Try --emit-config, serve-http, CHIO_AUTH_TOKEN, or velocity.
chio mcp wrap
chio mcp wrap spawns the wrapped stdio server, pulls tools/list once, and infers a capability-scope manifest scaffold from the live list. A tool passes once you promote it to allow; any other call is denied with a urn:chio:error:capability:scope-exceeded JSON-RPC error.
--print-scopesprints the inferred scaffold and exits.--emit-config <ide>prints a paste-ready client config forcursor,claude-desktop,continue, orzedand exits.--manifest <path>runs the gated proxy loop against a promoted scaffold. With no manifest the allow-set is empty and everytools/callis denied.--strict-execution-nonceruns each allowedtools/callthrough a kernel preflight that mints a single-use execution nonce and re-presents it before the wrapped server is invoked, binding that invocation to the checked execution authorization.
# Infer and print the capability scaffold, no config changes:
chio mcp wrap --print-scopes \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace
# Emit a paste-ready Cursor config for the wrapped server:
chio mcp wrap --emit-config cursor \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace{
"mcpServers": {
"mcp": {
"args": [
"mcp",
"wrap",
"--server-id",
"mcp",
"--",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"./workspace"
],
"command": "chio",
"metadata": {
"chioSchema": "cursor.mcp/2024-12",
"displayName": "mcp"
}
}
}
}--emit-config cursor re-spells the argv you typed with chio mcp wrap in front of it, so the editor launches the wrapper. To promote tools, capture the scaffold to a file. Every block defaults to allow = false.
# Capture the inferred scaffold to a file you can edit:
chio mcp wrap --print-scopes \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace \
> fs-scaffold.tomlFlip only the tools you mean to grant, then start the loop against that file. A tool the scaffold denies returns the same urn:chio:error:capability:scope-exceeded reason code as a tool you never promoted.
server_id = "mcp"
[[capability]]
tool = "read_text_file"
scope = "read"
urn = "urn:chio:scope:tool:read"
allow = true # promoted
[[capability]]
tool = "write_file"
scope = "destructive"
urn = "urn:chio:scope:tool:destructive"
allow = false # stays denied# Start the gated proxy against the promoted allow-set. Drop
# --print-scopes / --emit-config so the loop runs:
chio mcp wrap --manifest ./fs-scaffold.toml \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceExample servers
The same steps apply to any stdio MCP server; the tool names change with the server.
| Server | Command | Tools exposed |
|---|---|---|
Filesystem | npx -y @modelcontextprotocol/ | read_text_file, write_file, list_directory, and more |
npx -y @modelcontextprotocol/ | query; table schemas are resources | |
uvx mcp- | fetch |
Database server policy
Grant a database server only the tools it exposes. The archived PostgreSQL reference runs its queries in read-only transactions; database privileges and the server's transaction rules set that boundary, and shell-command patterns do not inspect SQL.
hushspec: "0.1.0"
name: db-readonly
rules:
tool_access:
enabled: true
default: block
allow:
- query
# SQL access is constrained by the database role and server.
# Allow egress only to the database host
egress:
enabled: true
allow:
- "orders.larkfield.example:5432"
# Scan for leaked credentials in query results
secret_patterns:
enabled: true
velocity:
enabled: true
max_invocations_per_window: 50
window_secs: 60Streamable HTTP edge
chio mcp serve-http exposes the same governed server over Streamable HTTP. It requires --policy and --server-id, and --listen <addr> defaults to 127.0.0.1:8931. The edge is network-facing, so it refuses to start without an authentication source:
$ chio mcp serve-http --policy ./fs-readonly-policy.yaml \
--server-id srv-files --listen 127.0.0.1:8931 \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceerror[CHIO-CLI-AUTH-REQUIRED]: a remote MCP edge needs an authentication mode help: pass --auth-token, --auth-jwt-public-key, --auth-jwt-discovery-url, --auth-introspection-url, or --auth-server-seed-file code: urn:chio:error:cli:auth-required docs: https://chio.computer/docs/reference/errors#error-cli-auth-required
Any one of those five satisfies it. --auth-token reads CHIO_AUTH_TOKEN from the environment, which keeps the bearer out of ps. With one supplied, the durable-admission requirement is next:
$ chio mcp serve-http --policy ./fs-readonly-policy.yaml \
--server-id srv-files --listen 127.0.0.1:8931 \
--auth-token demo-token \
-- 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
With authentication and both databases, the edge writes one line to stderr naming the endpoint. Agents connect to it in place of spawning a local process; the guard pipeline, receipts, and policy are the same as on stdio. In production, terminate TLS in front of it and choose an authentication mode from Remote MCP Edge.
$ chio --session-db ./edge-session.db --receipt-db ./edge-receipts.db \
mcp serve-http --policy ./fs-readonly-policy.yaml \
--server-id srv-files --listen 127.0.0.1:8931 --auth-token demo-token \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceremote MCP edge listening on http://127.0.0.1:8931/mcp
Clients in code
An agent written in code opens a session against the HTTP edge with ChioClient, the lower-level MCP client in @chio-protocol/sdk and chio-sdk: construct it with a bearer token, call initialize, then list and call tools on the session. The base URL is a required first argument in both languages, so match it to --listen. Install with npm install @chio-protocol/sdk or pip install chio-sdk, and export CHIO_AUTH_TOKEN with the edge's token (demo-token in the capture above).
import { ChioClient } from "@chio-protocol/sdk";
const token = process.env.CHIO_AUTH_TOKEN!;
const client = ChioClient.withStaticBearer("http://127.0.0.1:8931", token);
const session = await client.initialize();
try {
const tools = await session.listTools();
console.log(tools);
} finally {
await session.close();
}import os
from chio import ChioClient
token = os.environ["CHIO_AUTH_TOKEN"]
client = ChioClient.with_static_bearer("http://127.0.0.1:8931", token)
session = client.initialize()
try:
tools = session.list_tools()
print(tools)
finally:
session.close()Discovery creates no tool-call receipt. Run the TypeScript file with bun run discover-tools.ts. The TypeScript package is ESM, ships its own declarations, and requires Node 22 or newer; the Python package requires 3.11 or newer and pulls in httpx and pure25519. To query executed calls from an SDK, serve the same receipt store through the trust-control query service, with its own URL and read token.
Several servers under one policy
Run one edge per upstream server, each with its own server id. The edges can share a policy and a receipt database, so a filesystem server and a database server appear in one audit view.
hushspec: "0.1.0"
name: multi-server
rules:
tool_access:
enabled: true
default: block
allow:
# Filesystem tools
- read_text_file
- list_directory
# Database tools
- query
path_allowlist:
enabled: true
read:
- "**/workspace/**"
write: []
patch: []
forbidden_paths:
enabled: true
patterns:
- "**/.env"
- "**/.ssh/**"
egress:
enabled: true
allow:
- "orders.larkfield.example:5432"
# SQL access is constrained by the database role and server.
secret_patterns:
enabled: true
patch_integrity:
enabled: true
velocity:
enabled: true
max_invocations_per_window: 300
window_secs: 120# Terminal 1: the workspace file server
chio --session-db ./admission-files.sqlite --receipt-db ./receipts.sqlite \
mcp serve --policy ./multi-server-policy.yaml --server-id workspace \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace
# Terminal 2: the orders database. Same receipt database, its own session database.
chio --session-db ./admission-db.sqlite --receipt-db ./receipts.sqlite \
mcp serve --policy ./multi-server-policy.yaml --server-id orders \
-- npx -y @modelcontextprotocol/server-postgres $DATABASE_URLEach edge carries its own session database, and both flags are required on every edge. The mcp-tool guard applies to both: the agent can call read_text_file on workspace and query on orders, and write_file is blocked on both. Receipts from both servers land in one log, tagged by server id.
Guards a policy compiles
A HushSpec policy on this path compiles into seven stateless guards plus velocity: forbidden-path, path-allowlist, shell-command, egress-allowlist, mcp-tool, secret-leak, patch-integrity, and velocity. The kernel runs them on each call it evaluates, and receipts name the one that fired in evidence[].guard_name.
Source: the edge is crates/products/chio-cli/src/cli/mcp.rs; tool-selection refusals come from crates/protocol/chio-mcp-edge/src/runtime/tool_calls.rs:339-361; the empty-roots rule is crates/kernel/chio-kernel/src/kernel/dispatch.rs:751-753 with crates/guards/chio-guards/src/path_allowlist.rs:198-203; the pipeline reason string is crates/guards/chio-guards/src/pipeline.rs:58-61; the SDK base URLs are sdks/python/chio-py/src/chio/client.py:43-50 and sdks/typescript/chio-ts/src/client/client.ts:41-50.