Hello MCP
A minimal MCP edge built on chio-mcp-edge. The binary speaks JSON-RPC over stdio, mediates tools/list and tools/call through an embedded Chio kernel, and prints a signed receipt id for every governed call.
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 same ready-state contract used by the hosted HTTP edge. Only the outer framing (stdio vs
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.shSuccessful output ends with hello-mcp smoke passed and prints the bridge receipt id. The smoke writes JSON artifacts (initialize-response.json, tools-list-response.json, tool-call-response.json, bridge-call.json) under .artifacts/<timestamp>/.
Walkthrough
A trivial tool server
HelloServer implements ToolServerConnection. It advertises one tool (hello_tool) and returns a deterministic JSON payload when invoked.
struct HelloServer;
impl ToolServerConnection for HelloServer {
fn server_id(&self) -> &str { "hello-mcp-srv" }
fn tool_names(&self) -> Vec<String> {
vec!["hello_tool".to_string()]
}
fn invoke(
&self,
_tool_name: &str,
arguments: Value,
_nested_flow_bridge: Option<&mut dyn chio_kernel::NestedFlowBridge>,
) -> Result<Value, KernelError> {
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.
fn build_demo_state() -> (
ChioKernel,
chio_core::capability::CapabilityToken,
String,
ToolManifest,
) {
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: "hello-mcp-srv".to_string(),
tool_name: "hello_tool".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,
)
.expect("issue capability");
(kernel, capability, agent.public_key().to_hex(), demo_manifest())
}The stdio serve loop
The edge 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.
fn serve() -> Result<(), Box<dyn Error>> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let mut edge = make_edge();
for line in stdin.lock().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 stdout, &response)?;
writeln!(&mut stdout)?;
stdout.flush()?;
}
}
Ok(())
}The bridge-call mode
The smoke runs ./run-edge.sh bridge-call as a second invocation. This mode 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.
fn bridge_call() -> Result<(), Box<dyn Error>> {
let (kernel, capability, agent_id, _manifest) = build_demo_state();
let response = kernel.evaluate_tool_call_blocking_with_metadata(
&ToolCallRequest {
request_id: "hello-mcp-bridge".to_string(),
capability,
tool_name: "hello_tool".to_string(),
server_id: "hello-mcp-srv".to_string(),
agent_id,
arguments: json!({"name": "world"}),
dpop_proof: None,
governed_intent: None,
approval_token: None,
model_metadata: None,
federated_origin_kernel_id: None,
},
None,
)?;
serde_json::to_writer_pretty(
io::stdout(),
&json!({
"receipt_id": response.receipt.id,
"decision": response.receipt.decision,
"output": /* ... */,
}),
)?;
Ok(())
}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"], bridgeOn pass, stdout ends with three lines:
hello-mcp smoke passed
artifacts: /abs/path/to/examples/hello-mcp/.artifacts/<timestamp>
bridge receipt: rcpt_01J...Inspect after
Smoke artifacts land 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
cat "$ART/bridge-call.json" | jq .receipt_id
# expected: "rcpt_01J..." (a non-empty receipt id string)
# 2. Bridge-call decision
cat "$ART/bridge-call.json" | jq .decision
# expected: "Allow" (the kernel's verdict for this call)
# 3. tools/list response advertises hello_tool
cat "$ART/tools-list-response.json" | jq '.result.tools[0].name'
# expected: "hello_tool"
# 4. tools/call structured content matches the assertion
cat "$ART/tool-call-response.json" | jq '.result.structuredContent.message'
# expected: "hello from mcp, world"
# 5. Edge log shows kernel-side receipt emission (last 5 lines)
tail -n 5 "$ART/logs/edge.log"
# expected: lines logged while the smoke ran, no "ERROR" entriesCritical-path JSON
Three JSON-RPC pairs the smoke exchanges with the edge.
initialize
// → request
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}
// ← response (excerpt)
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "Hello MCP Server", "version": "0.1.0" }
}
}tools/list
// → request
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }
// ← response (excerpt)
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "hello_tool",
"description": "Return a greeting payload",
"inputSchema": {
"type": "object",
"properties": { "name": { "type": "string" } }
}
}
]
}
}tools/call
// → request
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "hello_tool",
"arguments": { "name": "world" }
}
}
// ← response (excerpt)
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"isError": false,
"content": [{ "type": "text", "text": "hello from mcp, world" }],
"structuredContent": {
"message": "hello from mcp, world",
"arguments": { "name": "world" }
}
}
}bridge-call
The bridge-call mode exposes the receipt id directly. The smoke writes this exact payload to .artifacts/<ts>/bridge-call.json.
{
"receipt_id": "rcpt_01JABC...",
"decision": "Allow",
"output": {
"message": "hello from mcp, world",
"arguments": { "name": "world" }
}
}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 only 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