Chio/Docs
LOGIN · JOIN

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.

Guide chio@5f63e6e

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.

Start

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.

MCP edgeChoose an outcome.

κ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.

One MCP tools/call through the edge to the kernel and the wrapped stdio server, with the kernel’s checks in evaluation order. Output checks after dispatch and the child process’s host confinement are left out.

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 --.

Terminalbash
curl -fsSL https://chio.computer/install.sh | sh
chio mcp wrap --print-scopes \
    -- npx -y @modelcontextprotocol/server-filesystem ./workspace
Output, two entries
# 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.

Terminalbash
mkdir -p workspace/src
printf 'export const x = 1;\n' > workspace/src/main.ts

Then write a HushSpec policy that names the server's read tools, confines paths to the workspace, and refuses sensitive files, shell commands, and egress.

fs-readonly-policy.yamlyaml
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: 120

Start 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.

output-fixture.jsonjson
{"content":[{"type":"text","text":"export const x = 1;\n"}]}
A tool outside tool_access.allowtranscript
$ 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.json
verdict:    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
exit 2deny

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:

Preflight against an output-sensitive policytranscript
$ 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
exit 1

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.

A permitted read, denied for want of rootstranscript
$ 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.json
verdict:    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
exit 2deny

The reason: line names only the pipeline. The receipt store records which guard fired, in evidence[].guard_name:

The two dry-run receiptstranscript
$ 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":[]}
exit 0deny

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.

Terminalbash
chio --session-db ./admission.sqlite --receipt-db ./receipts.sqlite \
    mcp serve --policy ./fs-readonly-policy.yaml --server-id workspace \
    -- npx -y @modelcontextprotocol/server-filesystem ./workspace

Chio 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.

Terminal · your project directorybash
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.

mcp-client-config.jsonjson
{
  "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:

Recorded live session · permitted read, forbidden read, ungranted writecaptured output
{"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"}]}
exit 0

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.

Receipts for the sessiontranscript
$ 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"]}
exit 0allow

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.

CommandDecides each call withKernel on each callTransport
chio mcp wrapA promoted TOML allow-setNonce preflight with --strict-execution-noncestdio
chio mcp serveHushSpec policy or --presetGuards and a signed receiptstdio
chio mcp serve-httpHushSpec policyGuards and a signed receiptStreamable 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

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-scopes prints the inferred scaffold and exits.
  • --emit-config <ide> prints a paste-ready client config for cursor, claude-desktop, continue, or zed and exits.
  • --manifest <path> runs the gated proxy loop against a promoted scaffold. With no manifest the allow-set is empty and every tools/call is denied.
  • --strict-execution-nonce runs each allowed tools/call through 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.
Terminalbash
# 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
chio mcp wrap --emit-config cursor stdout
{
  "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.

Terminalbash
# Capture the inferred scaffold to a file you can edit:
chio mcp wrap --print-scopes \
    -- npx -y @modelcontextprotocol/server-filesystem ./workspace \
    > fs-scaffold.toml

Flip 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.

fs-scaffold.toml (after editing)toml
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
Terminalbash
# 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 ./workspace

Example servers

The same steps apply to any stdio MCP server; the tool names change with the server.

ServerCommandTools exposed
Filesystem
npx -y @modelcontextprotocol/server-filesystem ./workspace
read_text_file, write_file, list_directory, and more
npx -y @modelcontextprotocol/server-postgres $DATABASE_URL
query; table schemas are resources
uvx mcp-server-fetch
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.

db-readonly-policy.yamlyaml
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: 60

Streamable 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:

mcp-wrap · serve-http-no-authtranscript
$ chio mcp serve-http --policy ./fs-readonly-policy.yaml \
  --server-id srv-files --listen 127.0.0.1:8931 \
  -- npx -y @modelcontextprotocol/server-filesystem ./workspace
error[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
exit 1

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:

mcp-wrap · serve-http-no-dbtranscript
$ 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 ./workspace
error[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
exit 1

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.

mcp-wrap · serve-httptranscript
$ 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 ./workspace
remote MCP edge listening on http://127.0.0.1:8931/mcp
exit 0

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).

discover-tools.tsdiscover_tools.py
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();
}

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.

multi-server-policy.yamlyaml
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
Terminalbash
# 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_URL

Each 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.

Next: shape the policy to your workflow