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.
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.
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.
chio new pricing-api --example pricing-api && cd pricing-api
python3 mcp.py2. Issue quotes through the MCP client
At the pricing> prompt, enter:
tools
list
quote sku-118 10
quote sku-204 25
quote sku-301 80
withdraw 1
listlist 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:
{
"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.
{
"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.
{
"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
}
}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.
{
"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
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
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "larkfield-pricing-client",
"version": "1.0.0"
}
},
"id": 1
}{
"protocolVersion": "2025-11-25",
"serverInfo": {
"name": "Larkfield pricing, governed",
"version": "0.1.0"
},
"capabilities": {
"tools": {
"listChanged": false
}
}
}{
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
}Discover the granted tools
{
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 3
}[
"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
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "quote",
"arguments": {
"body": {
"sku": "sku-118",
"percent": 10
}
}
},
"id": 5
}{
"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
| Call | Effect | Record |
|---|---|---|
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:
chio --receipt-db sessions/latest/receipts.db receipt list --admin-all \
| jq -c '{tool_name, verdict: .decision.verdict, id: .id[0:16]}'{"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:
uv run verify_session.py sessions/latest \
--trusted-key "trusted-kernels/$(basename "$(readlink sessions/latest)").txt" \
| jq -c '.verified, [.calls[] | {tool, outcome}]'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.