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 — 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.
# 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, 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
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<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 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
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:
{
"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:
{
"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.
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
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
swagger2openapihandles this cleanly. - operationId collisions. Two operations with the same id across paths will fail at manifest construction, not at runtime. Run
OpenApiMcpBridge::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