BuildFoundations
Hello MCP
Evaluate MCP tools/call through an embedded Chio kernel and return signed receipt IDs.
What it shows
- The MCP handshake:
initialize, the requirednotifications/initialized, thentools/list. - Authoritative
tools/callexecution throughChioMcpEdge::handle_jsonrpc. - A companion
bridge-callmode that calls the kernel directly and prints the receipt id and decision. - The ready-state contract used by the hosted HTTP edge. Only the outer framing (stdio versus
POST /mcp) differs.
Wrapping vs running an MCP edge
chio mcp serve instead. See Wrap an MCP Server.Use this when
tools/call mediated, signed receipts emitted. Don't use this if you want a non-MCP native tool in your own binary, see Hello Tool; and don't use this if you're wrapping an existing third-party MCP server, see Wrap an MCP Server for the chio mcp serve adapter shape.Prerequisites
- A Rust toolchain matching the workspace.
python3onPATH(the smoke driver spawns the edge and exchanges JSON-RPC messages from Python).
Run it
Start the stdio edge:
cd examples/hello-mcp
./run-edge.sh serveRun the smoke flow (drives the edge through initialize, tools/list, tools/call, then runs bridge-call):
$ ./smoke.shhello-mcp smoke passed artifacts: ~/chio/hello-mcp/.artifacts/20260905T115735Z bridge receipt: de5b2e8e8d107915663443020099441d4e2e5573e165d89b951927e876a725ce
The artifacts: line is the run directory, which is wherever the smoke was run from. The receipt id is a 64-character lowercase hex digest and differs per run. The smoke writes JSON artifacts there (initialize-response.json, tools-list-response.json, tool-call-response.json, bridge-call.json) under .artifacts/<timestamp>/.
A call the edge refuses
The smoke calls the one tool the server registers. Ask for a different one and the edge answers with a JSON-RPC error instead of a result. Drive it the way the smoke does, with initialize first and then a tools/call naming a tool that was never registered:
import json, subprocess
from pathlib import Path
example_root = Path("examples/hello-mcp").resolve()
proc = subprocess.Popen(
[str(example_root / "run-edge.sh"), "serve"],
cwd=example_root, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1,
)
def rpc(message):
proc.stdin.write(json.dumps(message) + "\n")
proc.stdin.flush()
if "id" not in message:
return None
return json.loads(proc.stdout.readline())
rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
rpc({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
denied = rpc({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "delete_everything", "arguments": {}},
})
print(json.dumps(denied, indent=2))
proc.terminate()
proc.wait(timeout=30){
"error": {
"code": -32602,
"message": "unknown tool"
},
"id": 2,
"jsonrpc": "2.0"
}The response has an error member and no result member, which is how a JSON-RPC client tells a refusal from an answer. -32602 is the JSON-RPC Invalid params code. The edge refuses at its own tool index before it selects a capability or calls the tool server (crates/protocol/chio-mcp-edge/src/runtime/tool_calls.rs), so HelloServer::invoke is never reached and the fail-closed ToolNotRegistered branch in the server is the second line of defense rather than the first.
Calling an edge from a client SDK
Framing the JSON-RPC by hand is worth doing once, to see that nothing is hidden. After that, use a client. The example's README states the relationship: this example uses the same ready-state contract as the hosted HTTP edge, and the difference is the outer framing, stdio JSON-RPC here against POST /mcp plus GET /mcp replay when hosted.
So the method names below are the ones this page has been driving over pipes. Two SDKs speak the hosted framing and hand you the same three calls:
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();
}initialize and notifications/initialized collapse into one call, and tools/call becomes session.call_tool(name, arguments) in Python or session.callTool(name, arguments) in TypeScript. Install with pip install chio-sdk (module chio) or npm install @chio-protocol/sdk.
A worked end-to-end version of each, which lists tools, calls one, and then resolves the receipt that call produced, is sdks/python/chio-py/examples/governed_hello.py and sdks/typescript/chio-ts/examples/governed_hello.ts. Both read CHIO_BASE_URL, CHIO_CONTROL_URL, and CHIO_AUTH_TOKEN. Only CHIO_CONTROL_URL has a default, and it is CHIO_BASE_URL. The other two go through require_env (governed_hello.py:35,37) and the example exits if either is unset, so point them at whichever edge you are running.
Two packages, two jobs
chio-sdk (module chio) drives a hosted Chio edge as a client, which is what this section does. chio-sdk-python (module chio_sdk) is the other direction: a service asking a local sidecar whether a call is allowed. Their names are similar and their roles are not.Walkthrough
A trivial tool server
HelloServer implements ToolServerConnection. It advertises one tool (hello_tool) and returns a deterministic JSON payload when invoked. The impl is async (behind #[async_trait::async_trait]) and fails closed: any tool name other than hello_tool returns KernelError::ToolNotRegistered.
impl ToolServerConnection for HelloServer {
fn server_id(&self) -> &str {
SERVER_ID
}
fn tool_names(&self) -> Vec<String> {
vec![TOOL_NAME.to_string()]
}
async fn invoke(
&self,
tool_name: &str,
arguments: Value,
_nested_flow_bridge: Option<&mut dyn chio_kernel::NestedFlowBridge>,
) -> Result<Value, KernelError> {
if tool_name != TOOL_NAME {
return Err(KernelError::ToolNotRegistered(tool_name.to_string()));
}
let name = arguments
.get("name")
.and_then(Value::as_str)
.unwrap_or("world");
Ok(json!({
"message": format!("hello from mcp, {name}"),
"arguments": arguments,
}))
}
}Kernel, manifest, and capability
build_demo_state() assembles the pieces an authoritative edge needs: a kernel keypair, a tool server registration, a signed manifest, and a capability scoped to hello_tool. It returns a HelloMcpResult<HelloMcpDemoState>; the named struct carries the kernel, capability, agent id, and manifest, with private into_edge_parts() / into_bridge_parts() converters for the serve and bridge paths.
pub struct HelloMcpDemoState {
kernel: ChioKernel,
capability: CapabilityToken,
agent_id: String,
manifest: ToolManifest,
}pub fn build_demo_state() -> HelloMcpResult<HelloMcpDemoState> {
let authority = Keypair::generate();
let mut kernel = ChioKernel::new(kernel_config(authority.clone()));
kernel.register_tool_server(Box::new(HelloServer));
let agent = Keypair::generate();
let capability = kernel
.issue_capability(
&agent.public_key(),
ChioScope {
grants: vec![ToolGrant {
server_id: SERVER_ID.to_string(),
tool_name: TOOL_NAME.to_string(),
operations: vec![Operation::Invoke],
constraints: vec![],
max_invocations: None,
max_cost_per_invocation: None,
max_total_cost: None,
dpop_required: None,
}],
..ChioScope::default()
},
300,
)
.map_err(|error| -> Box<dyn Error + Send + Sync> {
format!("issue capability: {error}").into()
})?;
Ok(HelloMcpDemoState {
kernel,
capability,
agent_id: agent.public_key().to_hex(),
manifest: demo_manifest(),
})
}The stdio serve loop
serve_reader reads one JSON-RPC message per line, hands it to edge.handle_jsonrpc, and writes the response (if any) back. Notifications return None; requests return a response object. serve_stdio() is the thin wrapper that binds it to stdin / stdout.
pub fn serve_reader<R, W>(reader: R, mut writer: W) -> HelloMcpResult<()>
where
R: BufRead,
W: Write,
{
let mut edge = make_edge()?;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let message: Value = serde_json::from_str(&line)?;
if let Some(response) = edge.handle_jsonrpc(message) {
serde_json::to_writer(&mut writer, &response)?;
writeln!(&mut writer)?;
writer.flush()?;
}
}
Ok(())
}The bridge-call mode
The smoke runs ./run-edge.sh bridge-call as a second invocation. bridge_call_value() bypasses the JSON-RPC framing and calls kernel.evaluate_tool_call_blocking_with_metadata directly. It exists to make the underlying receipt id visible in a plain JSON payload.
pub fn bridge_call_value() -> HelloMcpResult<Value> {
let (kernel, capability, agent_id) = build_demo_state()?.into_bridge_parts();
let response = kernel.evaluate_tool_call_blocking_with_metadata(
&ToolCallRequest {
request_id: "hello-mcp-bridge".to_string(),
capability,
tool_name: TOOL_NAME.to_string(),
server_id: SERVER_ID.to_string(),
agent_id,
arguments: json!({"name": "world"}),
dpop_proof: None,
execution_nonce: None,
governed_intent: None,
approval_token: None,
approval_tokens: Vec::new(),
threshold_approval_proposal: None,
supplemental_authorization: None,
model_metadata: None,
federated_origin_kernel_id: None,
},
None,
)?;
let output = match response.output {
Some(ToolCallOutput::Value(value)) => value,
Some(ToolCallOutput::Stream(stream)) => json!({
"chunks": stream
.chunks
.into_iter()
.map(|chunk| chunk.data)
.collect::<Vec<_>>(),
}),
None => Value::Null,
};
Ok(json!({
"receipt_id": response.receipt.id,
"decision": response.receipt.decision,
"output": output,
}))
}Success criteria
The smoke driver opens the edge as a subprocess, exchanges three JSON-RPC messages over stdio, then runs the binary again in bridge-call mode. These are the exact assertions that decide pass/fail:
# initialize: a non-empty protocol version came back
assert initialize["result"]["protocolVersion"], initialize
# tools/list: first tool is named "hello_tool"
assert listed["result"]["tools"][0]["name"] == "hello_tool", listed
# tools/call: the call did not raise an error
assert called["result"]["isError"] is False, called
# tools/call: structured content carries the deterministic message
assert called["result"]["structuredContent"]["message"] == "hello from mcp, world", called
# bridge-call: the kernel attached a non-empty receipt id
assert bridge["receipt_id"], bridgeThose five are literal from examples/hello-mcp/smoke.sh:70-73,82; the comments above each are this page's. On pass, stdout ends with the three lines shown under Run it.
Inspect after
Smoke output files are written under examples/hello-mcp/.artifacts/<timestamp>/. Replace $ART with the path printed by the smoke.
ART=$(ls -1d examples/hello-mcp/.artifacts/*/ | tail -n1)
# 1. Bridge-call receipt id and decision
jq '.receipt_id, .decision.verdict' "$ART/bridge-call.json"
# 2. tools/list response advertises hello_tool
jq -r '.result.tools[0].name' "$ART/tools-list-response.json"
# 3. tools/call structured content matches the assertion
jq -r '.result.structuredContent.message' "$ART/tool-call-response.json"The receipt id is a 64-character lowercase hex sha256 and is different on every run, so compare its shape, not its value. The decision is a serde-tagged object rather than a bare string, which is why the query reads .decision.verdict and not .decision.
The edge log is empty
logs/edge.log, and the example logs nothing there. The file exists and is zero bytes, so there is no point tailing it:$ wc -c "$ART/logs/edge.log"0 edge.log
Critical-path JSON
Three JSON-RPC pairs the smoke exchanges with the edge.
initialize
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}The response is longer than the MCP baseline, because the edge advertises what it is on top of the protocol: the pinned chioProtocol block with its exact-match compatibility rule and error registry, a chioToolStreaming flag, task support, and tools.listChanged. Read it in full rather than assuming the usual two-key shape.
$ cat "$ART/initialize-response.json"{
"id": 1,
"jsonrpc": "2.0",
"result": {
"capabilities": {
"experimental": {
"chioProtocol": {
"compatibility": "exact_match",
"downgradeBehavior": "reject",
"errorRegistry": {
"path": "spec/errors/chio-error-registry.v1.json",
"schema": "chio.error-registry.v1"
},
"requestIdentity": {
"requestBoundArtifactsRequireStableId": true,
"stableRequestIdMetaField": "chioRequestId"
},
"selectedProtocolVersion": "2025-11-25",
"supportedProtocolVersions": [
"2025-11-25"
]
},
"chioToolStreaming": {
"toolCallChunkNotifications": true
}
},
"tasks": {
"cancel": {},
"list": {},
"requests": {
"tools": {
"call": {}
}
}
},
"tools": {
"listChanged": false
}
},
"protocolVersion": "2025-11-25",
"serverInfo": {
"name": "Chio MCP Edge",
"version": "0.1.0"
}
}
}tools/list
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }The entry carries the annotations and the output schema the manifest declared, not just a name and an input schema.
$ cat "$ART/tools-list-response.json"{
"id": 2,
"jsonrpc": "2.0",
"result": {
"nextCursor": null,
"tools": [
{
"annotations": {
"destructiveHint": false,
"readOnlyHint": true
},
"description": "Return a greeting payload",
"execution": {
"taskSupport": "optional"
},
"inputSchema": {
"properties": {
"name": {
"type": "string"
}
},
"type": "object"
},
"name": "hello_tool",
"outputSchema": {
"properties": {
"arguments": {
"type": "object"
},
"message": {
"type": "string"
}
},
"type": "object"
}
}
]
}
}tools/call
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "hello_tool",
"arguments": { "name": "world" }
}
}$ cat "$ART/tool-call-response.json"{
"id": 3,
"jsonrpc": "2.0",
"result": {
"content": [
{
"text": "{\"arguments\":{\"name\":\"world\"},\"message\":\"hello from mcp, world\"}",
"type": "text"
}
],
"isError": false,
"structuredContent": {
"arguments": {
"name": "world"
},
"message": "hello from mcp, world"
}
}
}Note what content holds. It is not a prose greeting: the edge serializes the whole tool output and puts that JSON string in the text block, so content[0].text is {"arguments":{"name":"world"},"message":"hello from mcp, world"}. The parsed form is beside it in structuredContent, which is what the smoke asserts on. A client that reads content as display text will show the reader raw JSON.
bridge-call
The bridge-call mode exposes the receipt id directly. This is the file the smoke wrote to .artifacts/<ts>/bridge-call.json on the run captured here.
$ cat "$ART/bridge-call.json"{
"decision": {
"verdict": "allow"
},
"output": {
"arguments": {
"name": "world"
},
"message": "hello from mcp, world"
},
"receipt_id": "de5b2e8e8d107915663443020099441d4e2e5573e165d89b951927e876a725ce"
}What arrives at the tool server vs what the agent sends
Agents send JSON-RPC messages. The edge intercepts each one before it reaches HelloServer::invoke.
- For
initializeandtools/list, the edge answers from its own state (kernel + registered manifests). The underlying tool server is not called. - For
tools/call, the edge builds aToolCallRequest, asks the kernel to evaluate it (capability check, guards, receipt), and then dispatches to the registered server. - The receipt is signed before the response leaves the edge. The smoke's
bridge-callpayload exposes thatreceipt_iddirectly.
Next
- Wrap an MCP Server: run a third-party MCP server through
chio mcp serveinstead of building an authoritative edge. - Hello Tool: the same kernel without any protocol framing.
- Native Tool Server: the longer guide on native services.
- Examples Overview