Chio/Docs
LOGIN · JOIN

BuildConnect

Native Tool Server

Build a Rust tool server for the Chio kernel protocol with typed schemas, inline pricing, and a signed manifest.

When to Use Native vs. MCP Wrapping

chio supports two modes for tool servers: wrapping an existing MCP server with chio mcp serve, or implementing a native tool server using the Rust SDK.

ConsiderationMCP WrappingNative Server
Existing serverUse the proxy with zero modificationsRewrite required
Typed schemasInherited from MCP serverDeclared per-tool with JSON Schema
Pricing metadataAdded at the proxy layerDeclared inline on each tool definition
PerformanceExtra process boundary + JSON-RPC hopIn-process function call
Resources and promptsProxied from MCP serverRegistered directly on the builder
Manifest signingProxy generates and signsServer signs its own manifest

Choose MCP wrapping when you already have a working MCP server and want governance without code changes. Choose native when you need an in-process call path, pricing declared on each tool, or a self-contained Rust binary.


Prerequisites

  • A Rust toolchain and a checkout of the Chio source beside your project. The crates are path dependencies, not registry versions.
  • Nothing else. Building a manifest, signing it, and invoking a tool in-process needs no kernel, no receipt store, and no policy file. The governance layer arrives when the server is put behind a kernel, which the guides linked at the end cover.
  • To reproduce the captures on this page, the examples/hello-tool crate in that checkout, which is the example the page walks.

Project Setup

Create a new Rust project and add the required Chio crates:

bash
$ cargo new my-tool-server
$ cd my-tool-server
cargo new stdoutbash
    Creating binary (application) `my-tool-server` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Cargo writes three files: Cargo.toml, src/main.rs, and .gitignore. Replace the manifest with the one below. Every crate manifest under crates/ in the Chio source sets publish = false except the fuzz harness under chio-wasm-guards, so these are path dependencies against a clone of that repository rather than registry versions.

Cargo.tomltoml
[package]
name = "my-tool-server"
version = "0.1.0"
edition = "2021"

[dependencies]
chio-core = { path = "../chio/crates/core/chio-core" }
chio-kernel = { path = "../chio/crates/kernel/chio-kernel" }
chio-manifest = { path = "../chio/crates/platform/chio-manifest" }
chio-mcp-adapter = { path = "../chio/crates/protocol/chio-mcp-adapter" }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Crate overview

chio-mcp-adapter's native module provides NativeChioServiceBuilder, NativeTool, NativeResource, and NativePrompt: the high-level authoring helpers that save you from implementing kernel traits by hand. chio-core provides cryptographic primitives including chio_core::crypto::Keypair.

Defining Tools with NativeTool

Each tool is created with NativeTool::new, which takes a name, description, and a JSON Schema for the input:

rust
use chio_mcp_adapter::native::NativeTool;

let greet_tool = NativeTool::new(
    "greet",
    "Returns a personalized greeting",
    serde_json::json!({
        "type": "object",
        "properties": {
            "name": {
                "type": "string",
                "description": "The name to greet"
            }
        },
        "required": ["name"]
    }),
);

The builder pattern exposes additional metadata:

  • .output_schema(json): declare the JSON Schema for the tool's return value
  • .read_only(): mark the tool as having no side effects (sets has_side_effects = false)
  • .latency_hint(LatencyHint::Instant): hint at expected response time
  • Pricing helpers (per_invocation_price(), flat_price(), per_unit_price(), hybrid_price(), see Tool Pricing below)
rust
let greet_tool = NativeTool::new(
    "greet",
    "Returns a personalized greeting",
    serde_json::json!({
        "type": "object",
        "properties": {
            "name": { "type": "string", "description": "The name to greet" }
        },
        "required": ["name"]
    }),
)
.output_schema(serde_json::json!({
    "type": "object",
    "properties": {
        "greeting": { "type": "string" }
    }
}))
.read_only()
.per_invocation_price(25, "USD")
.latency_hint(chio_manifest::LatencyHint::Instant);

Building the Service

NativeChioServiceBuilder wires tools, resources, and prompts into a NativeChioService that implements the kernel's ToolServerConnection, ResourceProvider, and PromptProvider traits.

rendering
A native tool server is built, validated, signed, and then dispatched in process. Manifest validation runs inside build, so a manifest that does not validate never becomes a service.
sourcecrates/protocol/chio-mcp-adapter/src/native.rs:236-330at fe56570

The constructor takes a server ID and a hex-encoded Ed25519 public key:

rust
use chio_mcp_adapter::native::NativeChioServiceBuilder;

NativeChioServiceBuilder::new("srv-hello", public_key_hex)
    .server_name("Hello Tool Server")
    .server_version("0.1.0")
    .server_description("A native Chio service that exposes a greeting tool")

Registering Tool Handlers

Chain .tool(definition, handler) to register each tool. The handler is a closure that 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:

rust
.tool(
    greet_tool,
    |arguments| {
        let name = arguments
            .get("name")
            .and_then(|value| value.as_str())
            .ok_or_else(|| {
                KernelError::RequestIncomplete("greet requires string field name".to_string())
            })?;
        Ok(serde_json::json!({
            "greeting": format!("Hello, {name}!")
        }))
    },
)

There is also .tool_with_nested_flow(definition, handler), whose handler additionally receives an optional NestedFlowBridge for a tool that drives further governed work of its own.

Adding Resources

Resources expose static or dynamic data that agents can read. Use NativeResource to define URI, name, description, and MIME type. For static content, use .static_resource():

rust
use chio_core::ResourceContent;
use chio_mcp_adapter::native::NativeResource;

.static_resource(
    NativeResource::new("memory://hello/template", "Greeting Template")
        .description("A static greeting template")
        .mime_type("text/plain"),
    vec![ResourceContent {
        uri: "memory://hello/template".to_string(),
        mime_type: Some("text/plain".to_string()),
        text: Some("Hello, {name}!".to_string()),
        blob: None,
        annotations: None,
    }],
)

For dynamic resources, use .resource(definition, handler) where the handler receives the URI and returns Result<Option<Vec<ResourceContent>>, KernelError>.

Adding Prompts

Prompts are pre-built message sequences. Use NativePrompt and .static_prompt() for fixed prompts:

rust
use chio_core::{PromptMessage, PromptResult};
use chio_mcp_adapter::native::NativePrompt;

.static_prompt(
    NativePrompt::new("compose_greeting")
        .description("Creates a user prompt that asks for a polite greeting"),
    PromptResult {
        description: Some("Greeting composition prompt".to_string()),
        messages: vec![PromptMessage {
            role: "user".to_string(),
            content: serde_json::json!({
                "type": "text",
                "text": "Compose a short, polite greeting for Ada."
            }),
        }],
    },
)

Calling build()

.build() validates the tool manifest and returns the finished NativeChioService. It returns Result<NativeChioService, ManifestError>: validation failures are caught at build time, not at runtime.

rust
let service = builder.build().expect("valid manifest");

Complete Example

examples/hello-tool/ in the Chio source is a native server with one tool, one resource, and one prompt. The rest of this section is that crate's own code and its own output, so what the page shows and what the example does cannot drift apart.

The handler's argument reader comes first, because it sets the posture. A missing name is an error, not a default: the tool refuses rather than inventing a stranger to greet.

examples/hello-tool/src/lib.rs20-27rust
fn greeting_name(arguments: &serde_json::Value) -> Result<&str, KernelError> {
    arguments
        .get("name")
        .and_then(|value| value.as_str())
        .ok_or_else(|| {
            KernelError::RequestIncomplete("greet requires string field name".to_string())
        })
}

The builder chain declares the tool with its schemas, its read-only flag, its price, and its latency hint, then a static resource and a static prompt, and ends in .build(). The return type is Result<NativeChioService, ManifestError>, so an invalid manifest is a value the caller has to handle rather than a surprise at the first invocation:

examples/hello-tool/src/lib.rs29-94rust
pub fn build_service(
    public_key_hex: String,
) -> Result<NativeChioService, chio_manifest::ManifestError> {
    NativeChioServiceBuilder::new("srv-hello", public_key_hex)
        .server_name("Hello Tool Server")
        .server_version("0.1.0")
        .server_description("A tiny native Chio service that exposes a tool, resource, prompt, and priced manifest")
        .tool(
            NativeTool::new(
                "greet",
                "Returns a personalized greeting",
                serde_json::json!({
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "The name to greet"
                        }
                    },
                    "required": ["name"]
                }),
            )
            .output_schema(serde_json::json!({
                "type": "object",
                "properties": {
                    "greeting": { "type": "string" }
                }
            }))
            .read_only()
            .per_invocation_price(25, "USD")
            .latency_hint(chio_manifest::LatencyHint::Instant),
            |arguments| {
                let name = greeting_name(&arguments)?;
                Ok(serde_json::json!({
                    "greeting": format!("Hello, {name}! This greeting was served by a native Chio service.")
                }))
            },
        )
        .static_resource(
            NativeResource::new("memory://hello/template", "Greeting Template")
                .description("A static greeting template used by the hello example")
                .mime_type("text/plain"),
            vec![ResourceContent {
                uri: "memory://hello/template".to_string(),
                mime_type: Some("text/plain".to_string()),
                text: Some("Hello, {name}! This greeting was served by a native Chio service.".to_string()),
                blob: None,
                annotations: None,
            }],
        )
        .static_prompt(
            NativePrompt::new("compose_greeting")
                .description("Creates a user prompt that asks for a polite greeting"),
            chio_core::PromptResult {
                description: Some("Greeting composition prompt".to_string()),
                messages: vec![PromptMessage {
                    role: "user".to_string(),
                    content: serde_json::json!({
                        "type": "text",
                        "text": "Compose a short, polite greeting for Ada."
                    }),
                }],
            },
        )
        .build()
}

The example then exercises every surface the service implements: it prints the manifest and its pricing, signs the manifest with the same keypair whose public key the builder embedded, invokes the tool, reads the resource, gets the prompt, and drains the event queue. invoke and drain_events are async; read_resource and get_prompt are not:

examples/hello-tool/src/lib.rs96-175rust
pub async fn run() -> HelloToolResult<()> {
    println!("=== Chio hello-tool example ===\n");

    let server_kp = Keypair::generate();
    let service = build_service(server_kp.public_key().to_hex())?;

    println!("Native manifest:");
    println!(
        "  Server: {} ({})",
        service.manifest().name,
        service.server_id()
    );
    for tool in &service.manifest().tools {
        println!("  Tool: {} - {}", tool.name, tool.description);
        if let Some(pricing) = &tool.pricing {
            let quoted = pricing
                .unit_price
                .as_ref()
                .map(|amount| format!("{} {}", amount.units, amount.currency))
                .or_else(|| {
                    pricing
                        .base_price
                        .as_ref()
                        .map(|amount| format!("{} {}", amount.units, amount.currency))
                })
                .unwrap_or_else(|| "n/a".to_string());
            println!(
                "    Pricing: {:?} ({quoted}{})",
                pricing.pricing_model,
                pricing
                    .billing_unit
                    .as_deref()
                    .map(|unit| format!(" per {unit}"))
                    .unwrap_or_default()
            );
        }
    }

    chio_manifest::sign_manifest(service.manifest(), &server_kp)?;
    println!("\nManifest signed successfully.");

    let greeting = service
        .invoke("greet", serde_json::json!({ "name": "World" }), None)
        .await?;
    println!("\nTool invocation:");
    println!("  Input:  {{\"name\":\"World\"}}");
    println!("  Output: {greeting}");

    let resource = service
        .read_resource("memory://hello/template")?
        .ok_or_else(|| {
            KernelError::ToolServerError("hello template resource missing".to_string())
        })?;
    println!("\nResource read:");
    if let Some(first_resource) = resource.first() {
        println!("  URI: {}", first_resource.uri);
        println!("  Text: {}", first_resource.text.as_deref().unwrap_or(""));
    }

    let prompt = service
        .get_prompt("compose_greeting", serde_json::json!({}))?
        .ok_or_else(|| {
            KernelError::ToolServerError("compose_greeting prompt missing".to_string())
        })?;
    println!("\nPrompt:");
    let first_message_text = prompt
        .messages
        .first()
        .and_then(|message| message.content["text"].as_str())
        .unwrap_or("");
    println!("  First message: {first_message_text}");

    service.emit_event(ToolServerEvent::ResourcesListChanged);
    let events = service.drain_events().await?;
    println!("\nLate events:");
    println!("  Count: {}", events.len());

    println!("\n=== done ===");
    Ok(())
}

The binary is a wrapper that turns an error into a non-zero exit:

examples/hello-tool/src/main.rsrust
use std::process::ExitCode;

#[tokio::main]
async fn main() -> ExitCode {
    if let Err(error) = hello_tool::run().await {
        eprintln!("{error}");
        return ExitCode::FAILURE;
    }

    ExitCode::SUCCESS
}

Verify the result

Run it. A fresh keypair is generated each time, and since the example prints no key material the output is identical run to run:

hello-tool · runtranscript
$ cargo run --release --quiet -p hello-tool
=== Chio hello-tool example ===

Native manifest:
  Server: Hello Tool Server (srv-hello)
  Tool: greet - Returns a personalized greeting
    Pricing: PerInvocation (25 USD per invocation)

Manifest signed successfully.

Tool invocation:
  Input:  {"name":"World"}
  Output: {"greeting":"Hello, World! This greeting was served by a native Chio service."}

Resource read:
  URI: memory://hello/template
  Text: Hello, {name}! This greeting was served by a native Chio service.

Prompt:
  First message: Compose a short, polite greeting for Ada.

Late events:
  Count: 1

=== done ===
exit 0in ../../../home/connor/backbay/arc

Four things in that output are worth reading as assertions rather than as decoration. PerInvocation (25 USD per invocation) is the pricing round-tripping through the manifest, so a tool priced in code is a tool priced in the artifact a buyer verifies. Manifest signed successfully. means signing found the embedded public key and the keypair to agree; they must, or signing refuses. The greeting is the handler's own output, dispatched in process with no JSON-RPC hop. And the resource text still has its {name} placeholder, because a static resource is bytes the server hands back, not a template it renders.

The refusal is the other half. Invoking greet with no name returns KernelError::RequestIncomplete, and the example pins that with a test:

examples/hello-tool/src/lib.rs258-273rust
async fn greet_rejects_missing_required_name() -> HelloToolResult<()> {
    let service = build_service(TEST_PUBLIC_KEY.to_string())?;

    let error = match service.invoke("greet", serde_json::json!({}), None).await {
        Ok(value) => {
            return Err(KernelError::ToolServerError(format!(
                "missing name should fail closed, got {value}"
            ))
            .into());
        }
        Err(error) => error,
    };

    assert!(matches!(error, KernelError::RequestIncomplete(reason) if reason.contains("name")));
    Ok(())
}
hello-tool · fail-closedtranscript
$ cargo test --release -p hello-tool --lib \
    -- --exact tests::greet_rejects_missing_required_name

running 1 test
test tests::greet_rejects_missing_required_name ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
exit 0in ../../../home/connor/backbay/arc

A tool that fills in a default for a missing required field has quietly moved the policy decision out of the manifest and into the handler. Declaring "required": ["name"] in the input schema and then greeting a stranger anyway is the failure this test exists to catch.


Failures and recovery

SymptomCause and recovery
.build() returns a ManifestError.Manifest validation runs inside build, so the error names what is wrong with the declaration. Fix the declaration; there is no way to serve an unvalidated manifest.
Signing fails even though the keypair is right.sign_manifest validates the manifest again and then checks that the key embedded in it matches the signer. Pass the same public key to NativeChioServiceBuilder::new that the signing keypair produces.
A tool invocation returns a handler error instead of a result.Handlers return Result<Value, KernelError>, and that is the right place to refuse. Read the variant: RequestIncomplete means the arguments did not satisfy the schema the manifest advertises.
A resource read returns Ok(None).No registered resource owns that URI. The service matches on the exact URI a resource was registered with and returns Ok(None) when none matches, so a caller can try another provider rather than treat it as an error.
A provider-native tool is denied even though the server offers it.server_tools is empty unless the manifest lists the stable tool name. Those larger trust-boundary surfaces default to deny, and the builder leaves the list empty.

Tool Pricing

Every native tool can declare pricing metadata in its manifest. The kernel uses this information for economic checks: if a capability token's budget cannot cover the declared price, the call is denied before execution.

NativeTool exposes four pricing helpers:

Flat Price

A single fixed price regardless of usage. The base_price is set; there is no unit price or billing unit.

rust
NativeTool::new("lookup", "Look up a record", schema)
    .flat_price(500, "USD")

Per-Invocation Price

Charged once per call. The billing unit is automatically set to "invocation".

rust
NativeTool::new("greet", "Return a greeting", schema)
    .per_invocation_price(25, "USD")

Per-Unit Price

Scaled by a custom billing unit: tokens processed, documents returned, bytes transferred, etc.

rust
NativeTool::new("tokenize", "Tokenize text", schema)
    .per_unit_price(2, "USD", "1k_tokens")

Hybrid Price

Combines a base price with a per-unit price. Useful when a tool has fixed overhead plus variable cost.

rust
NativeTool::new("search", "Search documents", schema)
    .hybrid_price(25, 10, "USD", "document")
    // base_price: 25 USD + unit_price: 10 USD per document

Pricing is declarative

Pricing metadata is embedded in the tool manifest. The kernel reads it during economic checks, but the actual billing settlement is handled by the receipt and settlement layer. Declaring a price does not automatically debit an account; it enables the kernel to enforce budget constraints.

Manifest Signing

The tool manifest is a structured declaration of everything the server offers: its ID, name, version, tools (with schemas and pricing), and the server's public key. Signing the manifest with the server's private key proves that the declared tools and pricing are authentic.

rust
use chio_core::crypto::Keypair;

let server_kp = Keypair::generate();
let service = build_service(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)
    .expect("sign manifest");

// 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());

The signed manifest can be published to a registry, exchanged during capability negotiation, or verified offline. Any tampering (changing a price, adding a tool, modifying a schema) invalidates the signature.

Protect your server keypair

The server keypair is the root of trust for the manifest. If the private key is compromised, an attacker can produce a valid-looking manifest with altered pricing or tool definitions. Store it with the same care as any signing key.

Manifest Structure

When .build() succeeds, the resulting NativeChioService holds a validated ToolManifest:

crates/platform/chio-manifest/src/lib.rs30-63rust
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 fills schema with chio.manifest.v1, copies each registered tool's definition into tools, and leaves server_tools empty and required_permissions unset. Each entry in tools is a ToolDefinition carrying name, description, input_schema, and optional output_schema, pricing, has_side_effects, and latency_hint. Pricing is a ToolPricing with a pricing_model and optional base_price, unit_price, and billing_unit, which is why per_invocation_price(25, "USD") shows up as a unit price of 25 USD per invocation and no base price.

public_key is bare lowercase hex, the same form Keypair::public_key().to_hex() produces and the same string the builder was handed. Signing checks the two against each other, so a manifest cannot advertise one key and be signed by another.


Next Steps

  • Custom Guards · implement your own guard to enforce domain-specific policies
  • Economics · understand how pricing flows into budgets, metering, and settlement
  • Receipts · every tool invocation produces a signed receipt, including cost data