Chio/Docs

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 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 ready-state contract used by the hosted HTTP edge. The 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: a kernel inside the edge process evaluates each 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. The impl is async (behind #[async_trait::async_trait]) and fails closed: any tool name other than hello_tool returns KernelError::ToolNotRegistered.

examples/hello-mcp/src/lib.rs
struct HelloServer;

#[async_trait::async_trait]
impl ToolServerConnection for HelloServer {
    fn server_id(&self) -> &str { "hello-mcp-srv" }

    fn tool_names(&self) -> Vec<String> {
        vec!["hello_tool".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 != "hello_tool" {
            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.

examples/hello-mcp/src/lib.rs
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: "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,
        )
        .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.

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

examples/hello-mcp/src/lib.rs
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: "hello_tool".to_string(),
            server_id: "hello-mcp-srv".to_string(),
            agent_id,
            arguments: json!({"name": "world"}),
            dpop_proof: None,
            execution_nonce: None,
            governed_intent: None,
            approval_token: None,
            model_metadata: None,
            federated_origin_kernel_id: None,
        },
        None,
    )?;

    Ok(json!({
        "receipt_id": response.receipt.id,
        "decision": response.receipt.decision,
        "output": /* ToolCallOutput mapped to JSON */,
    }))
}

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 output files are written 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 (a serde-tagged object, not a bare string)
cat "$ART/bridge-call.json" | jq .decision.verdict
# 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-11-25",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "Chio MCP Edge", "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": { "verdict": "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 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