Native Tool Server
Expose your own Rust functions to an agent, grant it some of them, and read the signed receipt for every call.
On this page
A native service puts your application’s Rust functions in front of an agent. The service’s signed manifest says what can be called, a capability from your kernel says what this agent may call, and the kernel checks each request sent through it against that capability before your handler runs.
Starts from chio new pricing-tools --example pricing-tools: Larkfield's quote and refund as native tools on the pricing server. The host registers both, grants the agent two quotes, and saves the signed receipt for each decision. No model API key is needed.
Two quotes pass every check
The capability names quote on server pricing and allows two invocations. The kernel checks it, dispatches to the handler, and signs a receipt for each call before the price returns.
call-1, call-2"price":"21.60"
quote on server pricing limited to two invocations. Guards, the receipt store, and manifest verification at registration are left out.1. Run the governed pricing tools
Complete Installation, then pull the project, or download it. It carries its lockfile and Rust toolchain, and Cargo fetches the pinned Chio crates.
chio new pricing-tools --example pricing-tools && cd pricing-tools
cargo run --release --lockedcall-1: ALLOW {"currency":"USD","percent":10.0,"price":"21.60","sku":"sku-118"}
call-2: ALLOW {"currency":"USD","percent":25.0,"price":"43.50","sku":"sku-204"}
outside-grant: DENY requested tool refund on server pricing is not in capability scope
exhausted-limit: DENY invocation budget exhausted for capability cap-01a12eff-a57c-76f4-941e-55509841be30
evidence: runs/91c940d4-9111-411e-9d85-cdefe526aa69You should see the four calls in the figure: two quotes, a refusal for refund, and a refusal when a third quote meets the two-call limit. The run directory printed after evidence: holds a complete signed receipt for each decision. The first build compiles Chio's dependencies, and later runs reuse them.
2. Change what the tool does
src/service.rs owns the operation. The handler reads and checks its arguments, then returns the price:
.tool(
NativeTool::new(
"quote",
"Quote a SKU at a discount, rounded half up to the cent",
json!({
"type": "object",
"properties": {"sku": {"type": "string"}, "percent": {"type": "number"}},
"required": ["sku", "percent"]
}),
)
.read_only(),
|arguments| {
let sku = text(&arguments, "sku")?;
let percent = arguments
.get("percent")
.and_then(Value::as_f64)
.filter(|percent| (0.0..=100.0).contains(percent))
.ok_or_else(|| incomplete("percent must be between 0 and 100"))?;
let cents = PRICES
.iter()
.find(|(id, _)| *id == sku)
.map(|(_, cents)| *cents)
.ok_or_else(|| incomplete("unknown sku"))?;
// Basis points keep the arithmetic exact; the last step rounds half up.
let basis = (percent * 100.0).round() as i64;
let price = (cents * (10_000 - basis) + 5_000) / 10_000;
Ok(json!({"sku": sku, "percent": percent, "price": usd(price), "currency": "USD"}))
},
)The handler checks its arguments itself, because a declared JSON Schema describes the input to callers. .read_only() sets has_side_effects = false in the manifest, and the kernel keeps its own side-effecting classification for the call.
3. Change what the agent may call
src/host.rs owns permission. Its initial grant allows quote twice. Change these two constants to allow one refund instead, then rerun the same command:
pub const ALLOWED_TOOL: &str = "refund";
pub const CALL_LIMIT: u32 = 1;call-1: ALLOW {"currency":"USD","order":"order-77","refunded":"58.00"}
outside-grant: DENY requested tool quote on server pricing is not in capability scope
exhausted-limit: DENY invocation budget exhausted for capability cap-01a12eff-9387-71a9-8112-1804a5d685fc
evidence: runs/744d6779-509d-4566-a007-d0518cbc158fYou should see the refund of order-77, a refusal for quote, and a refusal for a second refund. The host issues the capability from these constants and runs its limit demonstration from them, so the grant changes without touching the handler or the manifest.
4. Verify a refusal with your kernel’s key
The host verifies the signed manifest before it registers the service, installs SQLite before issuance, and verifies each returned receipt signature before saving it. The project also writes kernel-public-key.txt and includes a single-record verifier. Run it on the scope refusal inside your run directory:
cargo run --release --locked --bin verify-receipt -- \
outside-grant-receipt.json kernel-public-key.txt{
"authorized": false,
"crypto_error": null,
"crypto_floor": "allow_classical",
"decision": {
"guard": "kernel",
"reason": "requested tool refund on server pricing is not in capability scope",
"verdict": "deny"
},
"ok": true,
"parameter_hash_valid": true,
"receipt_id": "0c8dc3e4b6ebe95b7cca1d994c77dbd62e293e9cb1f091a6ac3a5a82eb00b020",
"receipt_id_valid": true,
"signature_valid": true,
"signer_trusted": true
}You should see ok: true with authorized: false: an authentic record of a refusal, signed by your kernel. The kernel mediates the tool calls sent through evaluate_tool_call; host code that invokes a handler directly takes the route the figure draws around the kernel, with no check and no receipt.
Open manifest.json, capability.json, and the receipt files beside it to read every record in full. The example keeps keys, revocation state, and call counters in memory for one run, while SQLite and the JSON files retain its evidence. Read the complete request path before you adapt the host to a long-running application.
Read more in Audit the pricing tools, which finds the two denials in this same store, and Verify Receipts Offline.
Native authoring reference
Try NativeTool, read_only, hybrid_price, sign_manifest, or ManifestError.
Native service or MCP wrapping
A tool server reaches the kernel in one of two ways: wrap an existing MCP server with chio mcp serve, or implement a native service with the Rust SDK. Wrap when you already run a working MCP server and want governance without code changes. Build native when you want an in-process call path, a price on each tool, or one self-contained Rust binary.
| Consideration | MCP wrapping | Native server |
|---|---|---|
Existing server | Proxied with zero modifications | Rewritten as handlers |
Typed schemas | Inherited from the MCP server | Declared per tool with JSON Schema |
Pricing metadata | Added at the proxy layer | Declared inline on each tool |
Call path | Extra process and JSON-RPC hop | In-process function call |
Resources and prompts | Proxied from the MCP server | Registered on the builder |
Manifest signing | The proxy generates and signs | The server signs its own |
The Cargo project
The download is self-contained: Cargo fetches immutable revisions of the public Chio crates, and an empty workspace declaration keeps the project independent of any enclosing workspace. The manifest below is extracted from the same project. Authoring APIs also work without a kernel, but direct service calls produce no governed receipts.
[package]
name = "pricing-tools"
version = "0.1.0"
edition = "2021"
default-run = "pricing-tools"
[workspace]
[dependencies]
chio-core = { package = "chio-core-types", git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-kernel = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-manifest = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-mcp-adapter = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-store-sqlite = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }The native module of chio-mcp-adapter provides NativeChioServiceBuilder, NativeTool, NativeResource, and NativePrompt, the authoring helpers that implement the kernel traits for you. chio-core provides cryptographic types, including chio_core::crypto::Keypair.
NativeTool
NativeTool::new takes a name, a description, and a JSON Schema for the input:
use chio_mcp_adapter::native::NativeTool;
let quote_tool = NativeTool::new(
"quote",
"Quote a SKU at a discount, rounded half up to the cent",
serde_json::json!({
"type": "object",
"properties": {
"sku": { "type": "string", "description": "A Larkfield SKU, such as sku-118" },
"percent": { "type": "number", "minimum": 0, "maximum": 100 }
},
"required": ["sku", "percent"]
}),
);.output_schema(json)declares the JSON Schema of the return value..read_only()marks the tool as having no side effects in its manifest (has_side_effects = false)..latency_hint(LatencyHint::Instant)hints at the expected response time.per_invocation_price(),flat_price(),per_unit_price(), andhybrid_price()set the price (see Tool pricing).
let quote_tool = NativeTool::new(
"quote",
"Quote a SKU at a discount, rounded half up to the cent",
serde_json::json!({
"type": "object",
"properties": {
"sku": { "type": "string" },
"percent": { "type": "number", "minimum": 0, "maximum": 100 }
},
"required": ["sku", "percent"]
}),
)
.output_schema(serde_json::json!({
"type": "object",
"properties": {
"sku": { "type": "string" },
"price": { "type": "string" },
"currency": { "type": "string" }
}
}))
.read_only()
.per_invocation_price(1, "USD")
.latency_hint(chio_manifest::LatencyHint::Instant);NativeChioServiceBuilder
The builder wires tools, resources, and prompts into a NativeChioService that implements the kernel’s ToolServerConnection, ResourceProvider, and PromptProvider traits. The constructor takes a server ID and a hex-encoded Ed25519 public key:
use chio_mcp_adapter::native::NativeChioServiceBuilder;
NativeChioServiceBuilder::new("pricing", public_key_hex)
.server_name("Larkfield pricing")
.server_version("1.0.0")
.server_description("Quotes and refunds on Larkfield's price table")Tool handlers
Chain .tool(definition, handler) for each tool. The handler receives the arguments as a serde_json::Value and returns Result<Value, KernelError>. The Err arm is where a tool refuses, so a field the input schema declares required is read as required here too:
.tool(
quote_tool,
|arguments| {
let sku = arguments
.get("sku")
.and_then(|value| value.as_str())
.ok_or_else(|| {
KernelError::RequestIncomplete("quote requires string field sku".to_string())
})?;
let percent = arguments
.get("percent")
.and_then(|value| value.as_f64())
.filter(|percent| (0.0..=100.0).contains(percent))
.ok_or_else(|| {
KernelError::RequestIncomplete("percent must be between 0 and 100".to_string())
})?;
Ok(serde_json::json!({ "sku": sku, "percent": percent, "price": price(sku, percent)? }))
},
).tool_with_nested_flow(definition, handler) also hands the handler an optional NestedFlowBridge, for a tool that drives further governed work of its own.
Resources
Resources expose static or dynamic data that agents read. NativeResource defines the URI, name, description, and MIME type, and .static_resource() registers fixed content:
use chio_core::ResourceContent;
use chio_mcp_adapter::native::NativeResource;
.static_resource(
NativeResource::new("memory://pricing/price-list", "Larkfield price list")
.description("List prices in USD, by SKU")
.mime_type("text/csv"),
vec![ResourceContent {
uri: "memory://pricing/price-list".to_string(),
mime_type: Some("text/csv".to_string()),
text: Some("sku,list\nsku-118,24.00\nsku-204,58.00\nsku-301,140.00\n".to_string()),
blob: None,
annotations: None,
}],
)For dynamic content, .resource(definition, handler) takes a handler that receives the URI and returns Result<Option<Vec<ResourceContent>>, KernelError>.
Prompts
Prompts are pre-built message sequences. NativePrompt with .static_prompt() registers a fixed one:
use chio_core::{PromptMessage, PromptResult};
use chio_mcp_adapter::native::NativePrompt;
.static_prompt(
NativePrompt::new("draft_quote_email")
.description("Asks for a short email that presents a quote to a customer"),
PromptResult {
description: Some("Quote email prompt".to_string()),
messages: vec![PromptMessage {
role: "user".to_string(),
content: serde_json::json!({
"type": "text",
"text": "Write a two-sentence email presenting this quote to Priya Raman at Harbor Goods."
}),
}],
},
)Resource and prompt registrations define provider behavior, separate from a tool capability. A host that mediates them wires their provider and authorization paths as well; the sample project registers tools.
build()
.build() validates the tool manifest and returns Result<NativeChioService, ManifestError>, so validation runs during service construction, before you register or invoke the service.
let service = builder.build().expect("valid manifest");The complete pricing service
The project's src/service.rs puts the pieces together: two tools with their schemas, one read-only, each handler checking its own arguments, and .build() at the end. An invalid manifest is a ManifestError the caller handles before the first invocation.
use chio_kernel::KernelError;
use chio_mcp_adapter::native::{NativeChioService, NativeChioServiceBuilder, NativeTool};
use serde_json::{json, Value};
/// Larkfield list prices in cents, as main holds them after the Quickstart.
const PRICES: [(&str, i64); 3] = [("sku-118", 2400), ("sku-204", 5800), ("sku-301", 14000)];
/// Larkfield's one open order: its SKU and the amount paid, in cents.
const ORDERS: [(&str, &str, i64); 1] = [("order-77", "sku-204", 5800)];
pub fn build(public_key: String) -> anyhow::Result<NativeChioService> {
Ok(NativeChioServiceBuilder::new("pricing", public_key)
.server_name("Larkfield pricing")
.server_version("1.0.0")
.tool(
NativeTool::new(
"quote",
"Quote a SKU at a discount, rounded half up to the cent",
json!({
"type": "object",
"properties": {"sku": {"type": "string"}, "percent": {"type": "number"}},
"required": ["sku", "percent"]
}),
)
.read_only(),
|arguments| {
let sku = text(&arguments, "sku")?;
let percent = arguments
.get("percent")
.and_then(Value::as_f64)
.filter(|percent| (0.0..=100.0).contains(percent))
.ok_or_else(|| incomplete("percent must be between 0 and 100"))?;
let cents = PRICES
.iter()
.find(|(id, _)| *id == sku)
.map(|(_, cents)| *cents)
.ok_or_else(|| incomplete("unknown sku"))?;
// Basis points keep the arithmetic exact; the last step rounds half up.
let basis = (percent * 100.0).round() as i64;
let price = (cents * (10_000 - basis) + 5_000) / 10_000;
Ok(json!({"sku": sku, "percent": percent, "price": usd(price), "currency": "USD"}))
},
)
.tool(
NativeTool::new(
"refund",
"Refund an order in full",
json!({
"type": "object",
"properties": {"order": {"type": "string"}},
"required": ["order"]
}),
),
|arguments| {
let order = text(&arguments, "order")?;
let (id, _, paid) = ORDERS
.iter()
.find(|(id, _, _)| *id == order)
.ok_or_else(|| incomplete("unknown order"))?;
Ok(json!({"order": id, "refunded": usd(*paid), "currency": "USD"}))
},
)
.build()?)
}
fn text<'a>(arguments: &'a Value, field: &str) -> Result<&'a str, KernelError> {
arguments
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| incomplete(&format!("{field} must be a string")))
}
fn incomplete(message: &str) -> KernelError {
KernelError::RequestIncomplete(message.into())
}
fn usd(cents: i64) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}A missing argument fails closed
A quote without percent returns KernelError::RequestIncomplete from the handler, and the kernel signs the outcome. A handler that filled in a default for a field the schema declares required would move a pricing decision out of the manifest and into code nobody reviews; keep required fields required.
Authoring failures
.build()returns aManifestError. Validation runs insidebuild, and the error names what is wrong with the declaration. Fix the declaration, then build again.- Signing fails with the right keypair.
sign_manifestvalidates the manifest again and checks that the key embedded in it matches the signer. PassNativeChioServiceBuilder::newthe public key the signing keypair produces. - An invocation returns a handler error. Handlers return
Result<Value, KernelError>, and the variant names the refusal:RequestIncompletemeans the arguments did not satisfy the schema the manifest advertises. - A resource read returns
Ok(None). No registered resource owns that exact URI. The service returnsOk(None)so a caller can try another provider. - A provider-native tool is refused although the server offers it.
server_toolsadmits a tool only when the manifest lists its stable name. These trust-boundary tools default to deny, and the builder leaves the list empty.
Tool pricing
The pricing helpers write ToolPricing into the manifest, where callers read the model and currency beside the schema. The capability’s monetary limits decide what a call may spend. A tool reports its realized cost through invoke_with_cost; the native connection uses the default implementation, which reports no measured cost, and the kernel charges the authorized per-invocation ceiling when one is configured. Implement cost reporting and grant a budget for a metered tool; the sample project grants none. See Budgets & Metering.
Amounts use the currency’s minor unit: for USD, 100 units equals one dollar.
Flat price
One fixed price regardless of usage. base_price is set, with no unit price or billing unit.
NativeTool::new("lookup", "Look up a record", schema)
.flat_price(500, "USD")Per-invocation price
A price per call. The billing unit is set to "invocation".
NativeTool::new("quote", "Quote a SKU at a discount", schema)
.per_invocation_price(1, "USD") // USD 0.01 per quotePer-unit price
A price per custom billing unit: tokens processed, documents returned, or bytes transferred.
NativeTool::new("tokenize", "Tokenize text", schema)
.per_unit_price(2, "USD", "1k_tokens")Hybrid price
A base price plus a per-unit price, for a tool with fixed overhead and variable cost.
NativeTool::new("search", "Search documents", schema)
.hybrid_price(25, 10, "USD", "document")
// USD minor units: 25 cents base + 10 cents per documentManifest signing
The tool manifest declares everything the server offers: its ID, name, version, tools with their schemas and prices, and the server’s public key. Signing it with the server’s private key authenticates those manifest bytes to that key. A receiver decides whether to trust the signer, and the handler’s behavior lies outside what the signature covers.
use chio_core::crypto::Keypair;
let server_kp = Keypair::generate();
let service = crate::service::build(server_kp.public_key().to_hex())?;
// sign_manifest returns a SignedManifest bundling the manifest, its Ed25519
// signature, and the signer's public key. There is no separate hash field.
let signed = chio_manifest::sign_manifest(service.manifest(), &server_kp)?;
// signed.manifest is the ToolManifest; signed.signature is the Ed25519
// signature; signed.signer_key is the public key it was signed with.
println!("signed by: {}", signed.signer_key.to_hex());A signed manifest can be published to a registry, exchanged during capability negotiation, or verified offline. Any change to a price, a tool, or a schema invalidates the signature. Store the server keypair like any signing key: whoever holds it can sign a manifest with altered prices or tool definitions.
Manifest structure
A successful .build() leaves the NativeChioService holding a validated ToolManifest:
pub struct ToolManifest {
/// Schema version. Must equal [`TOOL_MANIFEST_SCHEMA`].
pub schema: String,
/// The server's unique identifier.
pub server_id: chio_core::ServerId,
/// Human-readable server name.
pub name: String,
/// Server description.
pub description: Option<String>,
/// Semantic version of this tool server.
pub version: String,
/// The tools this server provides.
pub tools: Vec<ToolDefinition>,
/// Provider-native server tools this manifest explicitly allows.
///
/// Anthropic server tools are larger trust-boundary surfaces than regular
/// client-hosted tools. They default to deny unless the manifest lists the
/// stable logical tool name here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub server_tools: Vec<ServerTool>,
/// Permissions this server requires from the host environment
/// (filesystem paths, network access, environment variables, etc.).
pub required_permissions: Option<RequiredPermissions>,
/// Hex-encoded Ed25519 public key of this tool server.
pub public_key: String,
}The builder sets schema to chio.manifest.v1, copies each registered tool’s definition into tools, leaves server_tools empty, and leaves required_permissions unset. Each ToolDefinition carries name, description, input_schema, and optional output_schema, pricing, has_side_effects, and latency_hint. ToolPricing holds a pricing_model and optional base_price, unit_price, and billing_unit, which is why per_invocation_price(1, "USD") appears as a unit price of 1 USD minor unit per invocation with no base price.
public_key is bare lowercase hex, the form Keypair::public_key().to_hex() produces and the string the builder received. Signing checks the two against each other, so a manifest advertises the key that signed it.