Chio/Docs

BuildConnect

Bridge OpenAPI to MCP

Build a Chio-governed MCP tool server from an OpenAPI 3 specification and retain signed receipts for each decision.

Prerequisites

This guide assumes you have a Rust toolchain and an OpenAPI 3.0 or 3.1 spec (JSON or YAML) for the upstream API. The bridge is a library crate today, not a CLI; you embed it in a small Rust binary that owns the HTTP dispatcher, the egress contract, and the kernel registration.

Bridge vs. Protect

Chio exposes two different patterns for governing a REST API, and picking the right one is the first decision.

Bridge OpenAPI to MCPProtect an API
Caller protocolMCP (tool calls from an agent)HTTP (existing REST clients)
TransformationProtocol translation: REST to MCP toolsReverse proxy: policy in front of the same REST API
Client change requiredNone on the HTTP side; agents speak MCPNone on either side; proxy is transparent
Best forExposing a REST API for agent consumptionGoverning an API without changing its protocol

The two are complementary. A production deployment often runs both: the bridge fronts the API for agent traffic, and chio api protect fronts the same upstream for legacy HTTP clients, with a shared policy.


How the Bridge Builds Tools

Given a spec, the bridge produces a chio.manifest.v1 with one MCP tool per publishable operation and a route binding that maps each tool back to its HTTP method and path template. Tool invocations land on the kernel for guard evaluation; allowed calls are dispatched to the upstream through a pluggable HTTP dispatcher; every decision is returned as a signed chio.receipt.v1.

rendering…
The kernel evaluates each invocation before the bridge calls the HTTP dispatcher. It returns a signed receipt for both allows and denials.

The bridge never opens a socket itself. All HTTP mechanics live in the dispatcher you supply, which keeps the crate transport-agnostic and testable. The bridge still enforces a caller-supplied HttpEgressContract around every dispatch — URL and DNS pre-flight before the call, a response-byte ceiling after — so the dispatcher owns transport, not egress policy.


Quickstart

Start from the Pet Store example specification. A small Rust binary turns it into a kernel-registered tool server.

toml
# Cargo.toml
[dependencies]
chio-openapi-mcp-bridge = "0.1"
chio-egress-contract = "0.1"
chio-kernel = "0.1"
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde_json = "1"
anyhow = "1"
rust
use chio_egress_contract::HttpEgressContract;
use chio_kernel::{ChioKernel, KernelConfig};
use chio_openapi_mcp_bridge::{
    BridgeConfig, BridgeError, BridgedResponse, OpenApiMcpBridge, OwnedBridgeToolServer,
};
use serde_json::json;

fn main() -> anyhow::Result<()> {
    let spec = std::fs::read_to_string("petstore.yaml")?;

    // The egress contract is the SSRF and response-size policy the bridge
    // enforces on every dispatch. It is not optional: with no contract, live
    // invocation fails closed before any HTTP call is made.
    let egress = HttpEgressContract {
        tenant_egress_namespace: "petstore".into(),
        allowed_schemes: ["https".to_string()].into_iter().collect(),
        allowed_authority_set: ["petstore.example.com".to_string()].into_iter().collect(),
        deny_loopback: true,
        deny_link_local: true,
        deny_ipv6_ula: true,
        max_redirect_chain: 0,
        max_response_bytes: 1 << 20,
    };

    let mut bridge = OpenApiMcpBridge::from_spec(
        &spec,
        BridgeConfig {
            server_id: "petstore".into(),
            server_name: "Pet Store".into(),
            server_version: "1.0.0".into(),
            public_key: std::env::var("CHIO_SERVER_PUBLIC_KEY")?,
            base_url: "https://petstore.example.com".into(),
            egress_contract: Some(egress),
        },
    )?;

    // The dispatcher is where the bridge meets the network. It must be a
    // single hop: return 3xx responses instead of following them, and report
    // observed_body_bytes so the egress contract is checked against upstream
    // bytes. You own timeouts, retries, pooling, and upstream auth here.
    let http = reqwest::blocking::Client::new();
    bridge.set_dispatcher(Box::new(move |method, url, args| {
        let method = reqwest::Method::from_bytes(method.as_bytes())
            .map_err(|e| BridgeError::UpstreamError(e.to_string()))?;
        let resp = http
            .request(method, url)
            .json(args)
            .send()
            .map_err(|e| BridgeError::UpstreamError(e.to_string()))?;
        let status = resp.status().as_u16();
        let bytes = resp
            .bytes()
            .map_err(|e| BridgeError::UpstreamError(e.to_string()))?;
        Ok(BridgedResponse {
            status,
            observed_body_bytes: Some(bytes.len() as u64),
            body: serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})),
            is_error: status >= 400,
        })
    }));

    // Register the bridge as a governed tool server. register_tool_server is
    // synchronous and takes ownership of a boxed ToolServerConnection.
    // KernelConfig carries the signing keypair, policy hash, and runtime
    // limits; see the hello-mcp example for a complete KernelConfig.
    let mut kernel = ChioKernel::new(kernel_config());
    kernel.register_tool_server(Box::new(OwnedBridgeToolServer::from_bridge(bridge)));

    Ok(())
}

The bridge is now a governed tool server on the kernel, exposing listPets, createPet, and getPetById. The kernel decides which calls are allowed; the bridge dispatches the allowed ones; every decision is a signed receipt.


Hosting Over MCP

chio-openapi-mcp-bridge does not implement MCP wire transport itself. It produces a governed tool server; putting that server on an actual MCP connection is a separate step. Two paths:

  • Register with a kernel. Hand the bridge's ToolServerConnection to a ChioKernel, as in the quickstart above. The kernel dispatches, evaluates, and signs; no MCP wire is involved.
  • Host over MCP with chio-mcp-edge. ChioMcpEdge implements the MCP JSON-RPC interface (initialize, tools/list, tools/call) and dispatches every call through the kernel. bridge.mcp_tools_list() projects the manifest into chio-mcp-edge::McpToolInfo entries for the tools/list response.

The bridge binds each operation to an HTTP route and enforces egress. The kernel evaluates and signs; chio-mcp-edge carries the tools/list and tools/call traffic on the wire.


Operation to Tool Mapping

Each publishable OpenAPI operation becomes one MCP tool. The mapping is deterministic:

  • Name. The tool name is the operation's operationId. If the spec omits one, the bridge falls back to "{METHOD} {path}" (for example, GET /pets). Prefer explicit operation ids; fallback names are stable per spec version but can shift between versions.
  • Input schema. Path, query, header, and request-body parameters are merged into a single JSON Schema object that becomes the tool's inputSchema. Required fields in the specification remain required fields on the tool.
  • Output schema. If the primary success response declares a body schema, it becomes the tool's outputSchema. Agents that honor output schemas can validate responses; others ignore it.
  • Description. The tool description is the operation's summary if present, otherwise its description, otherwise a synthesized "{METHOD} {path}" string. The two fields are not concatenated — a present summary wins outright. Good spec docs become good tool docs.
  • Route binding. The bridge keeps a BTreeMap<tool_name, RouteBinding> so that at invoke time it can reconstruct the exact method and URL to dispatch — path parameters are substituted back into the template from the tool arguments.

Side-Effect Classification

The bridge classifies every tool by HTTP method. This feeds the has_side_effects flag on each tool, which the kernel uses to decide whether a capability token is required.

HTTP methodClassificationDefault guard
GET, HEAD, OPTIONSSafe readAudit receipt only, no capability required
POST, PUT, PATCH, DELETESide effectValid capability token required, signed receipt on allow or deny

Semantics over syntax

If your API returns a read over POST (common in search-style RPC-over-REST designs), the bridge will default to treating it as a side effect. Mark those operations explicitly with x-chio-side-effects: false instead of relying on method inference.

OpenAPI Extensions

chio-openapi parses these x-chio-* extension fields you place on an operation. They control what becomes a tool, its data sensitivity, its side-effect classification, and its approval and budget requirements.

ExtensionEffect
x-chio-publish: falseOmit the operation from the tool manifest entirely (useful for admin or internal routes)
x-chio-sensitivity: restrictedData classification (public, internal, sensitive, restricted); feeds guard logging and audit granularity
x-chio-side-effects: falseExplicit boolean override of the method-based side-effect classification
x-chio-approval-required: trueForce deny-by-default; takes precedence over method and x-chio-side-effects
x-chio-budget-limit: 5000Per-invocation cost cap in minor currency units
yaml
paths:
  /pets:
    post:
      operationId: createPet
      x-chio-side-effects: true
      x-chio-budget-limit: 5000
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewPet'
      responses:
        '201':
          description: Pet created
  /admin/reindex:
    post:
      operationId: reindex
      x-chio-publish: false   # never exposed as a tool

Receipts

Every bridged call produces a chio.receipt.v1, identical in shape to a native chio tool invocation. The signed receipt records the tool name, capability, decision, and content and policy hashes:

json
{
  "id": "01HXYZ...7K4",
  "timestamp": 1745020800,
  "capability_id": "cap-pets-writer",
  "tool_server": "petstore",
  "tool_name": "createPet",
  "decision": { "verdict": "allow" },
  "content_hash": "8b1c...7d33",
  "policy_hash": "c7e9...02af",
  "kernel_key": "ed25519:f03b...91c2",
  "signature": "..."
}

The resolved HTTP method, path, and status do not live in the signed receipt body. They ride in the MCP tool result's structuredContent, alongside the parsed upstream body:

json
{
  "content": [{ "type": "text", "text": "{...}" }],
  "isError": false,
  "structuredContent": {
    "httpStatus": 201,
    "method": "POST",
    "path": "/pets",
    "body": { "id": "pet-99", "name": "Rex" }
  }
}

Denials produce the same receipt structure with "decision": { "verdict": "deny" } and the failing guard recorded in the receipt evidence. The upstream is never contacted on a deny, which is the whole point of placing the bridge in front of the kernel.


A Dispatcher Is Mandatory

There is no simulation or mock mode. invoke_tool requires a dispatcher: with none set, every invocation returns BridgeError ("OpenAPI bridge requires a dispatcher for live tool invocation") and fails closed. This is deliberate — the kernel must not sign a successful receipt for a side effect that never happened.

rust
let bridge = OpenApiMcpBridge::from_spec(&spec, config)?;
// No set_dispatcher call.

// Fails closed: BridgeError, and no receipt for a phantom side effect.
let outcome = bridge.invoke_tool("createPet", json!({ "name": "Rex" }));
assert!(outcome.is_err());

Test policy with a stub dispatcher

To author policy before the upstream exists, supply a dispatcher that returns canned BridgedResponse values instead of calling the network. The bridge and kernel still runs: createPet denies without a token, listPets allows with an audit receipt. What you cannot do is skip the dispatcher — an unset dispatcher returns an error instead of faking a result.

Limitations and Gotchas

  • No streaming responses. The current bridge models responses as a single JSON body. Long- poll endpoints, SSE, and chunked transfer are out of scope and should go through Protect an API instead.
  • OpenAPI 3.0 and 3.1 only. Swagger 2.0 specs must be converted first; tooling like swagger2openapi handles this cleanly.
  • operationId collisions. Two operations with the same id across paths will fail at manifest construction, not at runtime. Run OpenApiMcpBridge::from_spec in CI against your spec to catch this before deploy.
  • Path parameters are validated. A path parameter that resolves to an empty string or a dot-segment (. or ..) is rejected outright at invoke time; the call fails before dispatch instead of producing a surprising upstream URL.
  • Redirects are never followed. A 3xx response from the dispatcher is always treated as an error. A dispatcher that follows redirects internally violates the egress contract — return the 3xx response as-is and let the bridge reject it.
  • Authentication to the upstream. The bridge does not manage upstream credentials. Your dispatcher is responsible for attaching Authorization headers or signed requests. This is a feature: the kernel already authenticates the caller, so upstream credentials should be a separate concern held by the operator, not the agent.

Next Steps

  • Write a Policy · author the rules that decide which bridged operations an agent can call
  • Protect an API · the companion pattern for governing HTTP clients instead of MCP agents
  • Receipt format · the exact structure of the receipts emitted on every bridged call
  • Capabilities · how scope tokens gate side-effect operations at the bridge boundary