Chio/Docs

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 required notifications/initialized, then tools/list.
  • Authoritative tools/call execution through ChioMcpEdge::handle_jsonrpc.
  • A companion bridge-call mode 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

This example builds an authoritative edge: the kernel and tool server live in the same process. If you want to wrap an existing third-party MCP server with Chio, use chio mcp serve instead. See Wrap an MCP Server.

Use this when

You want to publish an MCP edge that's authoritative under Chio policy: kernel inside the edge process, every 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.
  • python3 on PATH (the smoke driver spawns the edge and exchanges JSON-RPC messages from Python).

Run it

Start the stdio edge:

bash
cd examples/hello-mcp
./run-edge.sh serve

Run the smoke flow (drives the edge through initialize, tools/list, tools/call, then runs bridge-call):

bash
./smoke.sh

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

examples/hello-mcp/src/main.rs
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.

examples/hello-mcp/src/main.rs
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.

examples/hello-mcp/src/main.rs
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.

examples/hello-mcp/src/main.rs
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:

examples/hello-mcp/smoke.sh
# 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"], bridge

On pass, stdout ends with three lines:

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

bash
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" entries

Critical-path JSON

Three JSON-RPC pairs the smoke exchanges with the edge.

initialize

json
// → 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

json
// → 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

json
// → 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.

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 initialize and tools/list, the edge answers from its own state (kernel + registered manifests). The underlying tool server is not called.
  • For tools/call, the edge builds a ToolCallRequest, 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-call payload exposes that receipt_id directly.

Next