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
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 MCP | Protect an API | |
|---|---|---|
| Caller protocol | MCP (tool calls from an agent) | HTTP (existing REST clients) |
| Transformation | Protocol translation: REST to MCP tools | Reverse proxy: policy in front of the same REST API |
| Client change required | None on the HTTP side; agents speak MCP | None on either side; proxy is transparent |
| Best for | Exposing a REST API for agent consumption | Governing 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.
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. 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.
# 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"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
ToolServerConnectionto aChioKernel, as in the quickstart above. The kernel dispatches, evaluates, and signs; no MCP wire is involved. - Host over MCP with
chio-mcp-edge.ChioMcpEdgeimplements the MCP JSON-RPC interface (initialize,tools/list,tools/call) and dispatches every call through the kernel.bridge.mcp_tools_list()projects the manifest intochio-mcp-edge::McpToolInfoentries for thetools/listresponse.
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 and query parameters become top-level properties of one JSON Schema object, and a request body becomes a single property named
bodyholding the body schema whole. Required parameters and a required body stay in the schema'srequiredlist. Header and cookie parameters are skipped (crates/protocol/chio-openapi/src/generator.rs:181-186), so an operation that takes an API version or a tenant in a header gives an agent no way to set it. Move anything an agent must control into the path, the query or the body. - Output schema. The generator takes the
200response schema, then the201, then the first other 2xx that declares one (crates/protocol/chio-openapi/src/generator.rs:233-256). It becomes the tool'soutputSchema. Agents that honor output schemas can validate responses; others ignore it. - Description. The tool description is the operation's
summaryif present, otherwise itsdescription, otherwise a synthesized"{METHOD} {path}"string. The two fields are not concatenated: a presentsummarywins outright. Good spec docs become good tool docs. - Route binding. The bridge keeps a
BTreeMap<String, RouteDispatch>keyed on tool name, each entry holding aRouteBindingof method and path template plus the query parameters and whether a body is required, so at invoke time it can reconstruct the exact method and URL to dispatch. Path parameters are percent-encoded and 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 method | Classification | Default guard |
|---|---|---|
GET, HEAD, OPTIONS | Safe read | Audit receipt only, no capability required |
POST, PUT, PATCH, DELETE | Side effect | Valid capability token required, signed receipt on allow or deny |
Semantics over syntax
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.
| Extension | Effect |
|---|---|
x-chio-publish: false | Omit the operation from the tool manifest entirely (useful for admin or internal routes) |
x-chio-sensitivity: restricted | Data classification (public, internal, sensitive, restricted); feeds guard logging and audit granularity |
x-chio-side-effects: false | Explicit boolean override of the method-based side-effect classification |
x-chio-approval-required: true | Force deny-by-default; takes precedence over method and x-chio-side-effects |
x-chio-budget-limit: 5000 | Per-invocation cost cap in minor currency units |
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 toolReceipts
A bridged call is an ordinary governed tool call, so the kernel signs an ordinary chio.receipt.v1. Nothing about it is bridge-shaped. Its tool_server is the server_id from your BridgeConfig, its tool_name is the operation id, and its action.parameters are the tool arguments the agent sent. Every hash on it is bare lowercase hex, 64 characters, with no ed25519: or 0x prefix anywhere, and the id is content-addressed over the canonical body. The Receipt format reference carries the field list, including the five classification fields (receipt_kind, boundary_class, tool_origin, redaction_mode, trust_level) that a receipt is never without.
The HTTP method, path template and status do not live in the signed receipt body. They ride in the MCP tool result's structuredContent, alongside the parsed upstream body:
pub(crate) fn bridged_tool_response(binding: &RouteBinding, response: BridgedResponse) -> Value {
let text = serde_json::to_string(&response.body).unwrap_or_else(|_| "{}".to_string());
json!({
"content": [{
"type": "text",
"text": text,
}],
"isError": response.is_error,
"structuredContent": {
"httpStatus": response.status,
"method": binding.method,
"path": binding.path,
"body": response.body,
}
})
}Note that path here is binding.path, the OpenAPI path template rather than the expanded URL, so GET /pets/{petId} reports /pets/{petId} whatever the pet was. The expanded URL is what the dispatcher received; the receipt binds the arguments that produced it.
Denials produce the same receipt structure with "decision": { "verdict": "deny" } and the failing guard recorded in evidence[].guard_name. Read the guard there, not out of decision.reason: the kernel formats that string from the name of the guard it has registered (crates/kernel/chio-kernel/src/kernel/dispatch.rs:401-407), which for a composed pipeline is the literal guard-pipeline (crates/guards/chio-guards/src/pipeline.rs:59-61) rather than the guard inside it that said no. The upstream is never contacted on a deny, which is the whole point of placing the bridge behind the kernel.
A Dispatcher Is Mandatory
There is no simulation or mock mode. The whole of invoke_tool is four gates and an else branch:
/// Invoke a bridged tool. A dispatcher is required so the kernel cannot
/// sign successful receipts for simulated side effects.
pub fn invoke_tool(&self, tool_name: &str, arguments: Value) -> Result<Value, BridgeError> {
let dispatch = self
.route_dispatches
.get(tool_name)
.ok_or_else(|| BridgeError::ToolNotFound(tool_name.to_string()))?;
if let Some(dispatcher) = &self.dispatcher {
let binding = dispatch.binding();
let url = dispatch_url(&self.config.base_url, dispatch, &arguments)?;
// HttpEgressContract: gate the dispatcher invocation on the typed
// egress contract. The bridge stays transport-agnostic, so we
// validate URL and DNS pre-flight, then enforce the response-byte
// ceiling post-dispatch. Missing contract state fails closed.
let contract = enforce_dispatch_contract(self.config.egress_contract.as_ref(), &url)?;
let response = dispatcher(&binding.method, &url, &arguments)?;
enforce_no_redirect_response(&response)?;
enforce_bridged_response_body(contract, &response)?;
Ok(bridged_tool_response(binding, response))
} else {
Err(BridgeError::Kernel(
"OpenAPI bridge requires a dispatcher for live tool invocation".to_string(),
))
}
}With no dispatcher set, every invocation returns BridgeError::Kernel carrying 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. The three enforcement calls above the dispatch are just as unconditional. Omit the egress contract and enforce_dispatch_contract returns OpenAPI bridge dispatcher requires an HttpEgressContract; return a response without observed_body_bytes and enforce_bridged_response_body refuses the same way.
Test policy with a stub dispatcher
BridgedResponse values instead of calling the network. The bridge and the kernel still run: createPet denies without a token, listPets allows with an audit receipt. Set observed_body_bytes on the canned response and keep the egress contract in place, or the stub fails at the same two gates a live dispatcher would. What you cannot do is skip the dispatcher.Verify the Result
The bridge is a library, so there is no command to run and no output to read back. What you check instead is the manifest it built, before you put it on a kernel. Four assertions cover the mapping this page describes, and all four are cheap enough to keep in a test:
| Check | What it proves |
|---|---|
bridge.tool_names() | Every operation you expected became a tool, and nothing you marked x-chio-publish: false did. A missing name here is a spec problem, not a runtime one. |
bridge.route_binding(name) | The method and path template the bridge will dispatch. Compare it against the spec path, not against a URL you have in mind. |
manifest().tools[i].has_side_effects | The classification the kernel will gate on. A search endpoint that is a POST reads true here until you annotate it. |
manifest().tools[i].input_schema | Whether an agent can reach every argument. Header and cookie parameters are absent by construction, so this is where you notice one. |
from_spec also validates eagerly. A spec whose publishable operations all resolve to nothing returns ManifestError::EmptyManifest, and a path template whose parameters do not match its declared parameters fails there rather than at the first call. Running OpenApiMcpBridge::from_spec in CI against your spec is the whole check.
Failures and Recovery
Every refusal below is a literal string in chio-openapi-mcp-bridge, so you can match on it. Each one fires before the network, not after.
| Message | Cause and fix |
|---|---|
OpenAPI bridge requires a dispatcher for live tool invocation | No set_dispatcher call. Supply one, or a stub that returns canned responses. |
OpenAPI bridge dispatcher requires an HttpEgressContract | egress_contract: None. Populate it; there is no permissive default. |
HttpEgressContract rejects bridge URL: ... | The resolved URL failed scheme, authority or DNS pre-flight. Widen allowed_authority_set deliberately, or fix the base_url. |
OpenAPI bridge dispatcher must provide observed_body_bytes for response-size enforcement | Your dispatcher returned a BridgedResponse with that field unset. Set it from the raw body length, before JSON parsing. |
OpenAPI bridge dispatchers must not follow redirects; returned HTTP redirect status ... | A 3xx reached the bridge, or your client followed one and this is the second hop. Disable redirect following in the client. |
OpenAPI bridge missing required request body `body` | The operation declares a required request body and the tool arguments had no body key, or it was null. |
OpenAPI bridge path parameter `x` must not be a dot segment | An argument resolved to . or ... The empty-string case has its own message. Both refuse before the URL is built. |
OpenAPI bridge route path `/x/{y}` contains undeclared path parameter `y` | Construction-time, not runtime. The template names a parameter the operation does not declare. The mirror case, a declared parameter absent from the template, has its own message. |
The dispatcher refusal is at crates/protocol/chio-openapi-mcp-bridge/src/lib.rs:280-282 and the four egress and transport checks at crates/protocol/chio-openapi-mcp-bridge/src/dispatch.rs:171,175,186,198. The body check is at dispatch.rs:252, the two path-parameter checks at dispatch.rs:393,398, and the two construction-time template checks at dispatch.rs:121,128. All of them arrive as BridgeError, which the kernel wraps as KernelError::ToolServerError when the bridge is registered as a tool server.
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
swagger2openapihandles this cleanly. - operationId collisions. Two operations with the same id across paths fail at manifest construction with
ManifestError::DuplicateToolName, not at runtime (crates/platform/chio-manifest/src/validation.rs:50-56, reached fromchio-openapi-mcp-bridge/src/lib.rs:199). RunOpenApiMcpBridge::from_specin 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
Authorizationheaders 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