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. The 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. The impl is async (behind #[async_trait::async_trait]) and fails closed: any tool name other than hello_tool returns KernelError::ToolNotRegistered.
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.
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.
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: "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:
# 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 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
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" 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-11-25",
"capabilities": { "tools": {} },
"serverInfo": { "name": "Chio MCP Edge", "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": { "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
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