Chio/Docs
LOGIN · JOIN

Pricing over MCP

Serve Larkfield's pricing API to an agent as MCP tools, issue two quotes under a two-quote grant, and read the signed decision for every call.

Guide chio@5f63e6e

On this page

An agent that speaks MCP can ask Larkfield for quotes without an HTTP client or a model provider in the loop. The host derives the tools from the pricing API's OpenAPI document, embeds the kernel, and gives each session a grant that names exactly which tools it may call and how often.

Starts from chio new pricing-api --example pricing-api. You act as Basalt's buying agent: two quotes are admitted, the third is refused when the allowance is spent, and a withdrawal outside the grant is refused too. If you already run an MCP server of your own, start with Wrap an MCP Server.

Start

An application behind an MCP edge

The client discovers tools and calls them over JSON-RPC. The host decides which operations the agent may invoke: discovery lists only granted tools, and every tools/call reaches the kernel before the API sees an HTTP request.

1. Build the host and open a session

You need Rust and Python 3.11 or newer. The launcher builds the host, starts the pricing API on port 8087, and opens the client; quit stops both. You can also download the project.

Terminalbash
chio new pricing-api --example pricing-api && cd pricing-api
python3 mcp.py

2. Issue quotes through the MCP client

At the pricing> prompt, enter:

At the pricing prompttext
tools
list
quote sku-118 10
quote sku-204 25
quote sku-301 80
withdraw 1
list
Each call's result
list               HTTP 200  0 quotes
quote sku-118 10   HTTP 201  USD 21.60, quote 1
quote sku-204 25   HTTP 201  USD 43.50, quote 2
quote sku-301 80   refused  invocation budget exhausted for capability cap-01a12ef7-d610-732e-a4a2-919d1dab4820
withdraw 1         refused  requested tool withdrawQuote on server pricing is not in capability scope
list               HTTP 200  2 quotes

You should see two quotes issued, USD 21.60 and USD 43.50, the third refused, the withdrawal refused, and a final list of exactly two quotes:

The final listjson
{
  "quotes": [
    {
      "currency": "USD",
      "id": 1,
      "percent": 10,
      "price": "21.60",
      "sku": "sku-118"
    },
    {
      "currency": "USD",
      "id": 2,
      "percent": 25,
      "price": "43.50",
      "sku": "sku-204"
    }
  ]
}

Each session gets a new 30-minute capability and a new evidence directory. A new session grants a new allowance; the quotes persist in .pricing/.

Tell the two refusals apart

The third quote reaches the kernel because quote is granted; the kernel refuses it because the first two spent the allowance, and signs the denial.

The third quote · resultjson
{
  "id": 7,
  "jsonrpc": "2.0",
  "result": {
    "_meta": {
      "chioReceipt": {
        "receiptId": "d569b5adb03fca2c89a178daace04890453c00561f8294714f3f28bcfcb113e1",
        "requestId": "mcp-edge-req-b014ef56b48d41488762b2adae74cdf4"
      }
    },
    "content": [
      {
        "text": "invocation budget exhausted for capability cap-01a12ef7-d610-732e-a4a2-919d1dab4820",
        "type": "text"
      }
    ],
    "isError": true
  }
}

withdrawQuote is missing from this session's discovery list. When the client asks for it anyway, the kernel checks it against the existing grant, refuses it, and signs a scope denial. The request adds no authority to the session.

withdraw 1 · resultjson
{
  "id": 8,
  "jsonrpc": "2.0",
  "result": {
    "_meta": {
      "chioReceipt": {
        "receiptId": "cd98d10e096082478a3ef9adf29632f56c1d92944dba107ee023ababf614b6e7",
        "requestId": "mcp-edge-req-b3ad381140c64bc4a7fa9f6748a86bb0"
      }
    },
    "content": [
      {
        "text": "requested tool withdrawQuote on server pricing is not in capability scope",
        "type": "text"
      }
    ],
    "isError": true
  }
}
One authority boundary for permitted and refused calls
One authority boundary for permitted and refused callsThe MCP client sends a known tool request through the edge to the Chio kernel. The kernel checks scope and the shared write allowance. Two createNote calls reach the notes API and save data. An exhausted createNote and an ungranted deleteNote produce signed denials without reaching the API. Each result carries its own receipt and request identifiers.APPLICATION-OWNED MCP HOSTMCP clienttools/callJSON-RPC request IDMCP edgeActive sessionDiscover granted toolsChio kernelScope + allowanceDurable admissioncreateNote · listNotes · deleteNoteNo authority or quota leftSigned refusalNo API dispatchSaved notes unchangedWrites 1–2Notes APIPOST /notesPersist in SQLiteEvery tool result carries its own request ID and retained receipt ID.One authority boundary for permitted and refused callsThe MCP client sends a known tool request through the edge to the Chio kernel. The kernel checks scope and the shared write allowance. Two createNote calls reach the notes API and save data. An exhausted createNote and an ungranted deleteNote produce signed denials without reaching the API. Each result carries its own receipt and request identifiers.MCP clienttools/callAPPLICATION MCP HOSTMCP edgeActive sessionDiscover granted toolsKnown tool requestChio kernelScope + allowanceDurable admissionScope or quota refusalSigned refusalNo API dispatchSaved notes unchangedNotes APIWrites 1–2 permittedPersist in SQLiteThe result names its ownrequest and signed receipt.
The exhausted quote and the ungranted withdrawal both end in signed kernel denials. Neither reaches the pricing API.
Source

The host supplies the integration

The API stays an ordinary application

pricing_api.py prices quotes and keeps them in SQLite; openapi.yaml names its operations. The host derives the tools from that document and supplies a bounded HTTP dispatcher restricted to the local API. An admitted quote becomes POST /v1/quote.

Change the grant

The operator sets authority per role in authority.json. Raise max_writes to 3 and the next Basalt session may issue three quotes; python3 mcp.py --role corbel opens a read-only session, the access an auditor at Corbel Assurance would get. No rebuild is needed.

authority.json · completejson
{
  "basalt": {
    "allow_read": true,
    "allow_write": true,
    "max_writes": 2
  },
  "corbel": {
    "allow_read": true,
    "allow_write": false,
    "max_writes": 0
  }
}
Read src/main.rs · complete 172 lines · complete file
src/main.rs · completerust
mod dispatcher;

use chio_core::{
    capability::scope::{ChioScope, Operation, ToolGrant},
    crypto::Keypair,
};
use chio_egress_contract::HttpEgressContract;
use chio_kernel::{
    ChioKernel, KernelConfig, DEFAULT_CHECKPOINT_BATCH_SIZE, DEFAULT_MAX_STREAM_DURATION_SECS,
    DEFAULT_MAX_STREAM_TOTAL_BYTES,
};
use chio_mcp_edge::{ChioMcpEdge, McpEdgeConfig};
use chio_openapi_mcp_bridge::{BridgeConfig, OpenApiMcpBridge, OwnedBridgeToolServer};
use std::{collections::BTreeSet, io::BufReader, path::PathBuf};

fn grant(tool: &str, max_invocations: Option<u32>) -> ToolGrant {
    ToolGrant {
        server_id: "pricing".into(),
        tool_name: tool.into(),
        operations: vec![Operation::Invoke],
        constraints: Vec::new(),
        max_invocations,
        max_cost_per_invocation: None,
        max_total_cost: None,
        dpop_required: None,
    }
}

#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RolePolicy {
    allow_read: bool,
    allow_write: bool,
    max_writes: u32,
}

fn main() -> anyhow::Result<()> {
    let directory = PathBuf::from(std::env::args().nth(1).ok_or_else(|| {
        anyhow::anyhow!("Usage: pricing-mcp NEW_SESSION_DIRECTORY [basalt|corbel]")
    })?);
    let role = std::env::args().nth(2).unwrap_or_else(|| "basalt".into());
    let policy_path = std::env::var_os("CHIO_PRICING_AUTHORITY_FILE")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| "authority.json".into());
    let policy_bytes = std::fs::read(policy_path)?;
    let policies: std::collections::BTreeMap<String, RolePolicy> =
        serde_json::from_slice(&policy_bytes)?;
    let policy = policies
        .get(&role)
        .ok_or_else(|| anyhow::anyhow!("Unknown role {role}; choose a role in authority.json"))?;
    anyhow::ensure!(
        policy.max_writes <= 50,
        "max_writes must be between 0 and 50"
    );
    let mut grants = Vec::new();
    if policy.allow_read {
        grants.extend([grant("listQuotes", None), grant("readQuote", None)]);
    }
    if policy.allow_write && policy.max_writes > 0 {
        grants.push(grant("quote", Some(policy.max_writes)));
    }
    // Refuse reuse: each explicitly started client session receives a new grant.
    std::fs::create_dir(&directory)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?;
    }
    std::fs::write(directory.join("authority.json"), &policy_bytes)?;
    std::fs::write(directory.join("role.txt"), &role)?;
    let spec = std::fs::read_to_string("openapi.yaml")?;
    let policy_hash = chio_core::sha256_hex(&chio_core::canonical_json_bytes(
        &serde_json::json!({
            "openapi": spec, "authority": serde_json::from_slice::<serde_json::Value>(&policy_bytes)?, "role": role
        }),
    )?);
    let authority =
        chio_control_plane::DurableAdmissionRuntime::open(&directory.join("admission.db"))?;
    let keypair = authority.kernel_keypair();
    std::fs::write(
        directory.join("kernel-public-key.txt"),
        keypair.public_key().to_hex(),
    )?;
    let mut kernel = ChioKernel::new(KernelConfig {
        keypair,
        ca_public_keys: Vec::new(),
        max_delegation_depth: 3,
        policy_hash,
        allow_sampling: false,
        allow_sampling_tool_use: false,
        allow_elicitation: false,
        max_stream_duration_secs: DEFAULT_MAX_STREAM_DURATION_SECS,
        max_stream_total_bytes: DEFAULT_MAX_STREAM_TOTAL_BYTES,
        require_web3_evidence: false,
        // Install the receipt store and durable admission authority below.
        // Revocations are process-local in this single-session host.
        allow_ephemeral_receipt_log: true,
        allow_ephemeral_revocation_store: true,
        checkpoint_batch_size: DEFAULT_CHECKPOINT_BATCH_SIZE,
        retention_config: None,
        memory_budget: chio_kernel::MemoryBudgetConfig::defaults(),
        deadlines: chio_kernel::HotPathDeadlineConfig::default(),
    });
    kernel.set_receipt_store(Box::new(chio_store_sqlite::SqliteReceiptStore::open(
        directory.join("receipts.db"),
    )?))?;
    authority.attach(&mut kernel)?;
    let service_key = Keypair::generate();
    let mut bridge = OpenApiMcpBridge::from_spec(
        &spec,
        BridgeConfig {
            server_id: "pricing".into(),
            server_name: "Larkfield pricing".into(),
            server_version: "1.0.0".into(),
            public_key: service_key.public_key().to_hex(),
            base_url: "http://127.0.0.1:8087".into(),
            egress_contract: Some(HttpEgressContract {
                tenant_egress_namespace: "larkfield-pricing-local".into(),
                allowed_schemes: BTreeSet::from(["http".into()]),
                allowed_authority_set: BTreeSet::from(["127.0.0.1:8087".into()]),
                deny_loopback: false,
                deny_link_local: true,
                deny_ipv6_ula: true,
                max_redirect_chain: 0,
                max_response_bytes: dispatcher::RESPONSE_LIMIT,
            }),
        },
    )?;
    bridge.set_dispatcher(dispatcher::build()?);
    let manifest = bridge.manifest_clone();
    let signed = chio_manifest::sign_manifest(&manifest, &service_key)?;
    chio_manifest::verify_manifest(&signed, &service_key.public_key())?;
    std::fs::write(
        directory.join("manifest.json"),
        serde_json::to_vec_pretty(&signed)?,
    )?;
    kernel.register_tool_server(Box::new(OwnedBridgeToolServer::from_bridge(bridge)));
    let agent = Keypair::generate();
    let capability = kernel.issue_capability(
        &agent.public_key(),
        ChioScope {
            grants,
            ..ChioScope::default()
        },
        1800,
    )?;
    std::fs::write(
        directory.join("capability.json"),
        serde_json::to_vec_pretty(&capability)?,
    )?;
    let mut edge = ChioMcpEdge::new(
        McpEdgeConfig {
            server_name: "Larkfield pricing, governed".into(),
            ..McpEdgeConfig::default()
        },
        kernel,
        agent.public_key().to_hex(),
        vec![capability],
        vec![manifest],
    )?;
    eprintln!(
        "Pricing MCP: role {role}, quote limit {}; evidence: {}",
        if policy.allow_write {
            policy.max_writes
        } else {
            0
        },
        directory.display()
    );
    edge.serve_stdio(BufReader::new(std::io::stdin()), std::io::stdout())?;
    Ok(())
}

The host owns the keys, the tool registration, durable admission state, and the receipt store, and it refuses to reuse a session directory. Revocations are process-local in this host. The API also answers directly on port 8087, so a deployment closes that port to untrusted callers.

One JSON-RPC message per line

The host's serve_stdio call connects standard input and output to ChioMcpEdge; diagnostics go to standard error. The client sends a request, records the exchange, and waits for the matching response ID.

Use this host from another MCP client

Any stdio MCP client can launch target/debug/pricing-mcp with one new session directory as its argument and the project directory as its working directory. It sends initialize, then notifications/initialized, then tool calls. Bridge OpenAPI to MCP explains this host's routing and egress configuration.

Read the protocol exchange

Initialize and announce readiness

initialize · requestjson
{
  "jsonrpc": "2.0",
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {},
    "clientInfo": {
      "name": "larkfield-pricing-client",
      "version": "1.0.0"
    }
  },
  "id": 1
}
initialize · response, selected fieldsjson
{
  "protocolVersion": "2025-11-25",
  "serverInfo": {
    "name": "Larkfield pricing, governed",
    "version": "0.1.0"
  },
  "capabilities": {
    "tools": {
      "listChanged": false
    }
  }
}
The ready notificationjson
{
  "jsonrpc": "2.0",
  "method": "notifications/initialized",
  "params": {}
}

Discover the granted tools

tools/list · requestjson
{
  "jsonrpc": "2.0",
  "method": "tools/list",
  "params": {},
  "id": 3
}
tools/list · tool namesjson
[
  "quote",
  "listQuotes",
  "readQuote"
]

Discovery reflects the active grant: withdrawQuote is registered and not listed. A listed tool can still be refused later when its allowance runs out.

Call a tool and read its result

The first quote · requestjson
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "quote",
    "arguments": {
      "body": {
        "sku": "sku-118",
        "percent": 10
      }
    }
  },
  "id": 5
}
The first quote · responsejson
{
  "id": 5,
  "jsonrpc": "2.0",
  "result": {
    "_meta": {
      "chioReceipt": {
        "receiptId": "6543009a87b968e427f404c4d61334c1b0daeeff6d3cb7645771e34ef796bab9",
        "requestId": "mcp-edge-req-e90adbbf1e2d47e4b09d5cc5706180cb"
      }
    },
    "content": [
      {
        "text": "{\"currency\":\"USD\",\"id\":1,\"percent\":10.0,\"price\":\"21.60\",\"sku\":\"sku-118\"}",
        "type": "text"
      }
    ],
    "isError": false,
    "structuredContent": {
      "body": {
        "currency": "USD",
        "id": 1,
        "percent": 10,
        "price": "21.60",
        "sku": "sku-118"
      },
      "httpStatus": 201,
      "method": "POST",
      "path": "/v1/quote"
    }
  }
}

structuredContent carries the API's response and HTTP status. _meta.chioReceipt names the request and receipt for this exact call; use those IDs to find the signed record, because an adjacent record can belong to another call.

Check the result in the service

CallEffectRecord
Two granted quotes
Two quotes recorded, each HTTP 201
Two signed allow receipts
Third quote
No quote recorded
Signed denial: budget exhausted
Ungranted withdrawal
No quote withdrawn
Signed denial: outside the grant
First and last list
Reads the recorded quotes
Two signed allow receipts

Read and verify the signed decisions

The client points sessions/latest at the session it just closed. List its kernel receipts:

Terminal · pricing-apibash
chio --receipt-db sessions/latest/receipts.db receipt list --admin-all \
  | jq -c '{tool_name, verdict: .decision.verdict, id: .id[0:16]}'
Output
{"tool_name":"listQuotes","verdict":"allow","id":"0ef3be5b02f1d4bc"}
{"tool_name":"quote","verdict":"allow","id":"6543009a87b968e4"}
{"tool_name":"quote","verdict":"allow","id":"46b01ea066b73a81"}
{"tool_name":"quote","verdict":"deny","id":"d569b5adb03fca2c"}
{"tool_name":"withdrawQuote","verdict":"deny","id":"cd98d10e09608247"}
{"tool_name":"listQuotes","verdict":"allow","id":"f30afb87ba499aee"}

You should see six receipts in call order. The complete exhausted-quote receipt names the tool, capability, parameters, and reason. Then verify the session against the kernel key the client pinned before its first call:

Terminal · pricing-apibash
uv run verify_session.py sessions/latest \
  --trusted-key "trusted-kernels/$(basename "$(readlink sessions/latest)").txt" \
  | jq -c '.verified, [.calls[] | {tool, outcome}]'
Output
true
[{"tool":"listQuotes","outcome":"completed"},{"tool":"quote","outcome":"completed"},{"tool":"quote","outcome":"completed"},{"tool":"quote","outcome":"denied"},{"tool":"withdrawQuote","outcome":"denied"},{"tool":"listQuotes","outcome":"completed"}]

Verification checks each signature against the pinned key, matches each receipt to its request and arguments, and checks each permitted result against its signed output hash; a call with no terminal receipt fails. A valid signature on a denial establishes a recorded refusal, not permission to issue the quote. Verify Receipts Offline covers the checks in detail.

Follow the call to the handler

Initialization and discovery are answered by the edge. An admitted tools/call reaches the bridge's dispatcher, which sends arguments.body to the API, expands declared path and query parameters, disables redirects and proxies, and bounds the response to 128 KiB. The API still validates the SKU and percent and owns the SQLite write.

Next: change what the host serves