BuildConnect
Wrap an MCP Server
Wrap an MCP server with Chio to evaluate tool calls against policy and emit signed receipts.
Prerequisites
How the MCP Adapter Works
The MCP adapter is a transparent proxy that wraps an existing MCP server. It does three things:
- Discovers tools: reads the MCP server's tool list via the
tools/listmethod (chio mediates this transparently) and generates a tool manifest - Intercepts calls: translates incoming requests into MCP
tools/callmessages, evaluating each one against your policy before forwarding - Records decisions: produces a signed receipt for each allow or deny decision
The MCP server itself runs as a sandboxed subprocess. It communicates with Chio over stdio. Your agent connects to Chio the same way it would connect to any MCP server, with no client-side changes required.
chio mcp wrap
The walkthrough below uses chio mcp serve, the policy-driven path. Before you author a policy, there is a lower-ceremony command for a first look: chio mcp wrap. It spawns the wrapped stdio server, pulls tools/list once, and infers a capability-scope manifest scaffold from the live tool list instead of requiring a hand-authored policy. By default it is a manifest-gated pass-through: a tool passes once you promote it to allow in the scaffold, and anything else is denied with a urn:chio:error:capability:scope-exceeded JSON-RPC error.
--print-scopesprints the inferred scaffold and exits, so you can see what the server exposes before promoting anything.--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-nonceupgrades the path: allowedtools/callrequests run through a kernel preflight that mints a single-use execution nonce and re-presents it before the wrapped server is invoked, closing the TOCTOU window between the check and the call.
# 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--emit-config cursor writes the client entry to stdout and exits. It re-spells the same argv you just typed, with chio mcp wrap in front of it, so the editor launches the wrapper rather than the bare server:
{
"mcpServers": {
"mcp": {
"args": [
"mcp",
"wrap",
"--server-id",
"mcp",
"--",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"./workspace"
],
"command": "chio",
"metadata": {
"chioSchema": "cursor.mcp/2024-12",
"displayName": "mcp"
}
}
}
}Promote tools in two steps. --print-scopes writes a TOML scaffold to stdout: a server_id line plus one [[capability]] block per discovered tool, every block defaulting to allow = false. Capture it to a file, flip the tools you trust to allow = true, then start the loop against that file with --manifest.
# Capture the inferred scaffold to a file you can edit:
$ chio mcp wrap --print-scopes \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace \
> fs-scaffold.tomlAgainst @modelcontextprotocol/server-filesystem the inference produces one block per discovered tool, each carrying the scope it was classified into, the scope URN, and the tool's own description verbatim from tools/list. Two of the fourteen, abridged:
# 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 promoteThe classifier is what makes the scaffold worth reading before you promote anything: write_file lands in destructive, not the filesystem bucket its name suggests, and edit_file lands there too. Flip only what you mean to grant:
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 actually runs:
$ chio mcp wrap --manifest ./fs-scaffold.toml \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceA tool denied by the scaffold comes back as a JSON-RPC error with the urn:chio:error:capability:scope-exceeded reason code, the same as any tool you never promoted.
wrap and serve have different guarantees
chio mcp wrap and chio mcp serve are different code paths with different guarantees. The default wrap path is a manifest-gated pass-through: no HushSpec compilation, and no execution-nonce enforcement unless you add --strict-execution-nonce. serve compiles a HushSpec policy and runs its guards on each call. Use serve (or serve-http) when you need HushSpec policy evaluation and signed receipts.1. Choose Your MCP Server
Chio is server-agnostic. Any MCP server that communicates over stdio works. Common choices:
| Server | Command | Tools Exposed |
|---|---|---|
| Filesystem | npx -y @modelcontextprotocol/server-filesystem ./workspace | read_text_file, write_file, list_directory, and eleven more |
| PostgreSQL | npx -y @modelcontextprotocol/server-postgres $DATABASE_URL | query, list_tables, describe_table |
| GitHub | npx -y @modelcontextprotocol/server-github | search_repositories, create_issue, get_file_contents |
| Fetch (HTTP) | npx -y @modelcontextprotocol/server-fetch | fetch |
This guide uses the filesystem server. The same steps apply to any MCP server, but the tool names do not carry over, and a policy that names a tool the server does not expose is silently inert. Run chio mcp wrap --print-scopes against your own server and write the policy from its output. The filesystem server exposes fourteen tools, and its read_file carries the description DEPRECATED: Use read_text_file instead., so a read-only policy should allow read_text_file.
2. Write a Policy Tailored to Your Server
The policy defines which tools the agent can invoke, which paths it can access, and what other constraints apply. Write a HushSpec YAML file tailored to the tools your MCP server exposes.
Here is a policy for a filesystem server that allows reading and listing but blocks writing, restricts access to a workspace directory, and forbids sensitive files:
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 restrictive, open selectively
default: block on tool access and an empty write list. Add permissions only when your workflow requires them. This follows the principle of least privilege.For a database server, the policy shape changes. Here is one for a PostgreSQL MCP server that allows read queries but blocks mutations:
hushspec: "0.1.0"
name: db-readonly
rules:
tool_access:
enabled: true
default: block
allow:
- query
- list_tables
- describe_table
# Block destructive SQL patterns
shell_commands:
enabled: true
forbidden_patterns:
- "(?i)\b(DROP|DELETE|TRUNCATE|ALTER|INSERT|UPDATE)\b"
# Allow egress only to the database host
egress:
enabled: true
allow:
- "db.internal:5432"
# Scan for leaked credentials in query results
secret_patterns:
enabled: true
velocity:
enabled: true
max_invocations_per_window: 50
window_secs: 603. Dry-run the Policy with chio check
chio check evaluates one tool call against a policy without starting a server. It answers some questions about this policy and cannot answer others, so it is worth being precise about which.
The command as written on most pages does not run against this policy. secret_patterns and patch_integrity are output-sensitive, so the default preflight mode has nothing to evaluate them against and refuses:
$ chio --session-db ./admission-0.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 [urn:chio:error:cli:other]: chio check preflight cannot evaluate post-output guards; use --mode full --output-fixture <JSON> so output-sensitive policy is evaluated against explicit fixture output
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.Add --mode full, an --output-fixture file holding the JSON the tool would have returned, and the two required database flags, and the call is evaluated. It is still a deny:
$ chio --session-db ./admission-1.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: 50c9d02dce2d678c3a6b97edcfb8e6dee357a3e837541b4cf7555a21ad8a1478 policy: 08e0e4550624c92d7154575cb2f2b8210019f483bd9f9f49b1c033c7b6db7720 source: cad65775097103eb33ac614535fcc47b74eddd34af34e2979757b4567c4298a7 mode: full fixture: true
That is not a policy bug. 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 (crates/kernel/chio-kernel/src/kernel/dispatch.rs:751-753 with crates/guards/chio-guards/src/path_allowlist.rs:198-203). Any policy enabling path_allowlist therefore has no allow case in the dry run.
What the dry run does prove is the tool boundary. A tool outside tool_access.allow is refused at capability selection, before any guard, and the reason names the tool and the server:
$ chio --session-db ./admission-2.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: a9314084d4f0fed6f25e21ebf92418fc47b648b77b2934cdfbfcc617f9643f7d policy: 08e0e4550624c92d7154575cb2f2b8210019f483bd9f9f49b1c033c7b6db7720 source: cad65775097103eb33ac614535fcc47b74eddd34af34e2979757b4567c4298a7 mode: full fixture: true
The two verdicts read very differently in the receipt store, which is where the guard that fired is recorded. The reason: line only ever names the pipeline (crates/guards/chio-guards/src/pipeline.rs:58-61); evidence[].guard_name names the guard:
$ 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":[]}What to check before you serve
forbidden_paths, for egress and for shell_commands. Give each check its own --session-db: the command issues every request under the fixed id check-001 (crates/products/chio-cli/src/cli/runtime.rs:703), so a second check against the same database is denied by admission rather than by policy. For path_allowlist, use the live session in step 5.4. Run chio mcp serve
With the policy tested, start the governed MCP server. --policy and --preset are mutually exclusive: pass one or the other, not both.
# Using a policy file you wrote:
$ chio mcp serve --policy <file> --server-id <id> \
-- <command>
# Or using a bundled preset (today: code-agent):
$ chio mcp serve --preset code-agent --server-id <id> \
-- <command>The --preset code-agent preset bundles the deny-by-default guards appropriate for code-agent workflows (safe file reads, denies .env / .git/** / .ssh/** writes, denies git push --force). Against the filesystem server with the custom policy from Step 2:
$ chio --session-db ./admission.sqlite --receipt-db ./receipts.sqlite \
mcp serve --policy ./fs-readonly-policy.yaml --server-id srv-files \
-- npx -y @modelcontextprotocol/server-filesystem ./workspaceBoth database flags are required, on this command as on chio check. Without them the edge refuses to start with durable admission mode requires a database, and --preset code-agent does not exempt it. Two edges may share one --receipt-db, which is what puts both servers in one audit trail.
Chio starts, spawns the MCP server as a subprocess, discovers its tools, and begins proxying tool calls. The proxy is transparent to the agent. Even though the MCP server exposes write_file, the policy blocks it, and the call is refused before it reaches the subprocess.
Two kinds of refusal come out of this edge and only one of them writes a receipt. A tool outside the active capability set is refused during tool selection, before the kernel evaluates anything, and returns a JSON-RPC result with isError set and a notifications/message log entry (crates/protocol/chio-mcp-edge/src/runtime/tool_calls.rs:339-361). A call that reaches the guards produces a receipt whichever way it goes. Step 5 shows both.
Server ID must be unique
--server-id identifies this tool server in capability tokens and receipts. Use a descriptive, stable ID like srv-files or srv-postgres-prod. Changing the ID invalidates existing capability tokens scoped to it.5. Connect Your Agent to the Chio Proxy
Your agent connects to Chio exactly as it would connect to any MCP server. If you are using an MCP client configuration file, point the command at Chio instead of the raw server:
{
"mcpServers": {
"filesystem": {
"command": "chio",
"args": [
"--session-db", "./admission.sqlite",
"--receipt-db", "./receipts.sqlite",
"mcp", "serve",
"--policy", "./fs-readonly-policy.yaml",
"--server-id", "srv-files",
"--",
"npx", "-y",
"@modelcontextprotocol/server-filesystem", "./workspace"
]
}
}
}The agent sees the same tools, request and response format, and transport. What changes is what happens between the call and the subprocess.
Filesystem tools take absolute paths, and path_allowlist is enforced against the roots the client declares over roots/list, so a client that declares no roots has every path-bearing call denied. The three calls below come from a client that declares ./workspace as a root: one read the policy permits, one read forbidden_paths blocks, and one write that is not in the capability set at all.
$ python3 ./mcp_client.py \
chio --session-db ./stdio-session.db --receipt-db ./stdio-receipts.db \
mcp serve --policy ./fs-readonly-policy.yaml --server-id srv-files \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace{"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"}]}The first is the allow this policy exists to permit, and it is the case chio check could not produce. The second is a guard deny, carrying the pipeline's message. The third is the capability refusal, which never reaches the guards and reads differently for that reason.
6. Monitor Receipts
While the server is running, every tool call, allowed or denied, produces a signed receipt. Inspect the log to verify policy enforcement:
$ 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"]}Two receipts for three calls. The write_file refusal produced none, because tool selection rejected it before the kernel was asked (crates/protocol/chio-mcp-edge/src/runtime/tool_calls.rs:339-361). Everything the kernel evaluates is recorded; a call it never sees is visible in the edge's notifications/message log instead.
decision.reason on the guard deny names guard-pipeline and decision.guard reads kernel, on this path as on every other. The rule that fired is in evidence[].guard_name.
A local --receipt-db read fails closed unless you pass exactly one of --tenant <id> or --admin-all, so the operator names the read boundary explicitly rather than defaulting to a cross-tenant read. Output is JSON Lines (one receipt per line). Pipe to jq for summaries or a pretty printer, and filter by capability, tool, outcome, or cost using the flags on chio receipt list --help.
Each receipt is cryptographically signed with the kernel's Ed25519 key. Receipts are non-repudiable evidence of what was requested, what decision was made, and which guards were evaluated. See the Receipts guide for the full receipt format and verification.
7. Advanced: HTTP Edge Mode
chio mcp serve-http exposes the same governed server over Streamable HTTP instead of stdio. It requires --policy and --server-id, and takes --listen <addr>, which defaults to 127.0.0.1:8931.
Two more things are required and neither is optional. 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 [urn:chio:error:cli:other]: remote MCP edge requires either --auth-token, --auth-jwt-public-key, --auth-jwt-discovery-url, --auth-introspection-url, or --auth-server-seed-file
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.Any one of those five satisfies it. --auth-token is the simplest, and reads CHIO_AUTH_TOKEN from the environment so the bearer does not appear in 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 [urn:chio:error:cli:other]: durable admission mode requires a database so operations and tool outcomes survive restart
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.With auth and both databases, it starts. Startup writes one line, on stderr, naming the endpoint:
$ 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
Agents connect to that endpoint instead of spawning a local process. The guard pipeline, the receipt writing and the policy enforcement are the same as in stdio mode.
Connect from Python or TypeScript
An agent written in code, rather than configured through a client config file, opens a session against the edge and calls tools through it. Two clients ship for this, one per ecosystem, and they are the same object with the same three moves: construct with a bearer token, initialize to get a session, then list and call tools on that session. Neither has a default endpoint: the base URL is a required first argument in both (sdks/python/chio-py/src/chio/client.py:43-50, sdks/typescript/chio-ts/src/client/client.ts:41-50), so it must match whatever --listen was set to.
$ pip install chio-sdk
$ npm install @chio-protocol/sdkThe Python distribution is chio-sdk and the import package it installs is chio. The same block also queries the receipts those calls produced, so one script drives the server and reads its own audit trail:
from chio import ChioClient, ReceiptQueryClient
client = ChioClient.with_static_bearer("http://127.0.0.1:8931", "demo-token")
session = client.initialize()
try:
tools = session.list_tools()
print(tools)
receipts = ReceiptQueryClient("http://127.0.0.1:8940", "demo-token").query(
{"toolServer": "wrapped-http-mock", "limit": 5}
)
print(receipts["totalCount"])
finally:
session.close()import { ChioClient, ReceiptQueryClient } from "@chio-protocol/sdk";
const client = ChioClient.withStaticBearer("http://127.0.0.1:8931", "demo-token");
const session = await client.initialize();
try {
const tools = await session.listTools();
console.log(tools);
const receipts = await new ReceiptQueryClient(
"http://127.0.0.1:8940",
"demo-token",
).query({ toolServer: "wrapped-http-mock", limit: 5 });
console.log(receipts.totalCount);
} finally {
await session.close();
}Set --server-id on the edge to whatever you pass as toolServer in the query, and the receipts from these calls come back under that name. 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.
Secure the HTTP edge
8. Advanced: Multiple MCP Servers Under One Policy
A single Chio instance can govern multiple MCP servers. Each server gets its own server ID, but they share a policy. This is useful when an agent needs access to both a filesystem and a database, for example.
Write a combined policy that addresses tools from all servers:
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
- list_tables
- describe_table
path_allowlist:
enabled: true
read:
- "**/workspace/**"
write: []
patch: []
forbidden_paths:
enabled: true
patterns:
- "**/.env"
- "**/.ssh/**"
egress:
enabled: true
allow:
- "db.internal:5432"
shell_commands:
enabled: true
forbidden_patterns:
- "(?i)\b(DROP|DELETE|TRUNCATE|ALTER|INSERT|UPDATE)\b"
secret_patterns:
enabled: true
patch_integrity:
enabled: true
velocity:
enabled: true
max_invocations_per_window: 300
window_secs: 120Then start each server with its own server ID, pointing at the shared policy:
# Terminal 1: Filesystem server
$ chio --session-db ./admission-files.sqlite --receipt-db ./receipts.sqlite \
mcp serve --policy ./multi-server-policy.yaml --server-id srv-files \
-- npx -y @modelcontextprotocol/server-filesystem ./workspace
# Terminal 2: Database server. 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 srv-postgres \
-- npx -y @modelcontextprotocol/server-postgres $DATABASE_URLThe two edges share a receipt database, which is what makes one audit trail out of two servers, and each carries its own session database. Both flags are required on every edge.
Your agent connects to both chio proxies. The mcp-tool guard applies uniformly: the agent can call read_text_file on srv-files and query on srv-postgres, but write_file is blocked on both. Receipts from all servers are collected in the same log, tagged by server ID.
Summary
Wrapping an MCP server with Chio gives you:
- Policy enforcement: every tool call evaluated against your HushSpec policy
- Seven stateless guards + velocity: forbidden-path, path-allowlist, shell-command, egress-allowlist, mcp-tool, secret-leak, patch-integrity, and velocity
- Signed receipts: cryptographic proof of every decision
- Zero server modifications: the MCP server runs unmodified as a subprocess
- Transparent proxy: agents connect to Chio the same way they connect to any MCP server
Next Steps
- Write a Policy · HushSpec policy authoring and the available guard blocks
- Native Tool Server · build a chio-native tool server for tighter integration
- Receipts · deep dive into the receipt format and verification