Bridge OpenAPI to MCP
Expose an existing HTTP API to agents as MCP tools, each admitted by the kernel under a named grant before dispatch.
On this page
An OpenAPI document is enough to hand an agent your HTTP API as MCP tools. Each operation becomes a named tool, and the kernel admits each tool call under its grant before the bridge sends a single HTTP request.
Your host owns the MCP connection, the upstream credentials, and the decision to issue a grant. Starts from chio new pricing-api --example pricing-api, whose host serves Larkfield's pricing API this way. As Basalt's buying agent you issue two quotes, watch a third stop on the two-quote grant, and read the JSON-RPC exchange and the kernel's receipts. No model account or API key is involved.
The kernel admits each tool before the bridge sends HTTP
At startup the bridge reads the spec and produces a chio.manifest.v1 with one MCP tool per publishable operation, plus a route binding that maps each tool back to its method and path template. The MCP edge hands each tools/call to the kernel.
κcapabilityσscopegguardsBbudget
The kernel admits the tool before the bridge sends HTTP
At startup the bridge compiles each OpenAPI operation into a named tool and a route binding. The kernel admits tools/call for createNote under the grant; the bridge then resolves the route, enforces the egress contract, and calls the HTTP dispatcher. The MCP result carries httpStatus, method, path, and body. Bridge OpenAPI to MCP.
An admitted call reaches the bridge, which resolves the route, checks the egress contract, and calls the HTTP dispatcher your host supplies. The MCP result carries httpStatus, method, path, and body, and the kernel signs a chio.receipt.v1 for the decision. On a refusal the bridge never builds the HTTP request, so the upstream API is never contacted.
1. Open an MCP session for the pricing API
Pull the project, or download it. It holds the API, its OpenAPI document, the Rust host, a small MCP client, locked dependencies, and a Rust toolchain file; Cargo fetches the Chio crates the manifest pins.
chio new pricing-api --example pricing-api && cd pricing-api
python3 mcp.pyYou should see the launcher build the host, start the pricing API, and open the client at a pricing> prompt; quit stops both. Issued quotes stay in .pricing/, and each session keeps its own authority and receipts.
Before you begin, or run the API and client separately
You need Python 3.11 or newer and Rust. To run the parts yourself, start the API in one terminal; if it is already running from Protect an API, keep that process and its quotes.
python3 pricing_api.pyIn another terminal, build the host and start an MCP session. The host calls the API directly on port 8087, with no HTTP sidecar in between.
cargo build --release --locked
python3 client.py --server target/release/pricing-mcp2. Issue two quotes and watch the third stop
At the pricing> prompt, enter these commands. The client turns them into initialize, tools/list, and tools/call messages over the host's standard input and output.
tools
list
quote sku-118 10
quote sku-204 25
quote sku-301 80
withdraw 1
list
quitlist HTTP 200 0 quotes quote sku-118 10 HTTP 201 USD 21.60, quote 1 quote sku-204 25 HTTP 201 USD 43.50, quote 2 quote sku-301 80 refused invocation budget exhausted for capability cap-01a12ef7-d610-732e-a4a2-919d1dab4820 withdraw 1 refused requested tool withdrawQuote on server pricing is not in capability scope list HTTP 200 2 quotes
You should see two quotes admitted with HTTP 201, the third refused with invocation budget exhausted, and the withdrawal refused because withdrawQuote is outside the grant. The final list shows the two issued quotes and nothing more. The kernel signs a deny receipt for both refusals, and neither request reaches the API.
3. Read the grant and the request
The client prints its session directory. It holds the capability, the signed manifest, the kernel public key, the receipt and admission databases, and the mcp.ndjson wire transcript. The capability names three tools.
{
"grants": [
{
"server_id": "pricing",
"tool_name": "listQuotes",
"operations": [
"invoke"
]
},
{
"server_id": "pricing",
"tool_name": "readQuote",
"operations": [
"invoke"
]
},
{
"server_id": "pricing",
"tool_name": "quote",
"operations": [
"invoke"
],
"max_invocations": 2
}
]
}The grant permits listQuotes, readQuote, and two quote calls for this 30-minute session. withdrawQuote is registered and has no grant. Each tool request is one small JSON-RPC object.
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "quote",
"arguments": {
"body": {
"sku": "sku-118",
"percent": 10
}
}
},
"id": 5
}Each new client session issues new authority, so the allowance is per session. Restarting the client opens a fresh session and leaves no lifetime quota.
4. Change what the host grants
Edit authority.json to set Basalt's read permission and quote limit, then start a new session. python3 mcp.py --role corbel selects the read-only role an auditor at Corbel Assurance would get.
{
"basalt": {
"allow_read": true,
"allow_write": true,
"max_writes": 2
},
"corbel": {
"allow_read": true,
"allow_write": false,
"max_writes": 0
}
}You should see the new limit in the next session's capability.json. Keep the previous session's receipt and admission databases as evidence; the host refuses to reuse a session directory.
Before it issues the session grant, the host installs SQLite receipts and durable admission. It generates the manifest, signs and verifies it with the service key, registers OwnedBridgeToolServer, then serves MCP with ChioMcpEdge. The bridge library provides neither the wire listener nor the capabilities; the host supplies both.
Complete Rust host and dependency manifest
mod dispatcher;
use chio_core::{
capability::scope::{ChioScope, Operation, ToolGrant},
crypto::Keypair,
};
use chio_egress_contract::HttpEgressContract;
use chio_kernel::{
ChioKernel, KernelConfig, DEFAULT_CHECKPOINT_BATCH_SIZE, DEFAULT_MAX_STREAM_DURATION_SECS,
DEFAULT_MAX_STREAM_TOTAL_BYTES,
};
use chio_mcp_edge::{ChioMcpEdge, McpEdgeConfig};
use chio_openapi_mcp_bridge::{BridgeConfig, OpenApiMcpBridge, OwnedBridgeToolServer};
use std::{collections::BTreeSet, io::BufReader, path::PathBuf};
fn grant(tool: &str, max_invocations: Option<u32>) -> ToolGrant {
ToolGrant {
server_id: "pricing".into(),
tool_name: tool.into(),
operations: vec![Operation::Invoke],
constraints: Vec::new(),
max_invocations,
max_cost_per_invocation: None,
max_total_cost: None,
dpop_required: None,
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RolePolicy {
allow_read: bool,
allow_write: bool,
max_writes: u32,
}
fn main() -> anyhow::Result<()> {
let directory = PathBuf::from(std::env::args().nth(1).ok_or_else(|| {
anyhow::anyhow!("Usage: pricing-mcp NEW_SESSION_DIRECTORY [basalt|corbel]")
})?);
let role = std::env::args().nth(2).unwrap_or_else(|| "basalt".into());
let policy_path = std::env::var_os("CHIO_PRICING_AUTHORITY_FILE")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "authority.json".into());
let policy_bytes = std::fs::read(policy_path)?;
let policies: std::collections::BTreeMap<String, RolePolicy> =
serde_json::from_slice(&policy_bytes)?;
let policy = policies
.get(&role)
.ok_or_else(|| anyhow::anyhow!("Unknown role {role}; choose a role in authority.json"))?;
anyhow::ensure!(
policy.max_writes <= 50,
"max_writes must be between 0 and 50"
);
let mut grants = Vec::new();
if policy.allow_read {
grants.extend([grant("listQuotes", None), grant("readQuote", None)]);
}
if policy.allow_write && policy.max_writes > 0 {
grants.push(grant("quote", Some(policy.max_writes)));
}
// Refuse reuse: each explicitly started client session receives a new grant.
std::fs::create_dir(&directory)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?;
}
std::fs::write(directory.join("authority.json"), &policy_bytes)?;
std::fs::write(directory.join("role.txt"), &role)?;
let spec = std::fs::read_to_string("openapi.yaml")?;
let policy_hash = chio_core::sha256_hex(&chio_core::canonical_json_bytes(
&serde_json::json!({
"openapi": spec, "authority": serde_json::from_slice::<serde_json::Value>(&policy_bytes)?, "role": role
}),
)?);
let authority =
chio_control_plane::DurableAdmissionRuntime::open(&directory.join("admission.db"))?;
let keypair = authority.kernel_keypair();
std::fs::write(
directory.join("kernel-public-key.txt"),
keypair.public_key().to_hex(),
)?;
let mut kernel = ChioKernel::new(KernelConfig {
keypair,
ca_public_keys: Vec::new(),
max_delegation_depth: 3,
policy_hash,
allow_sampling: false,
allow_sampling_tool_use: false,
allow_elicitation: false,
max_stream_duration_secs: DEFAULT_MAX_STREAM_DURATION_SECS,
max_stream_total_bytes: DEFAULT_MAX_STREAM_TOTAL_BYTES,
require_web3_evidence: false,
// Install the receipt store and durable admission authority below.
// Revocations are process-local in this single-session host.
allow_ephemeral_receipt_log: true,
allow_ephemeral_revocation_store: true,
checkpoint_batch_size: DEFAULT_CHECKPOINT_BATCH_SIZE,
retention_config: None,
memory_budget: chio_kernel::MemoryBudgetConfig::defaults(),
deadlines: chio_kernel::HotPathDeadlineConfig::default(),
});
kernel.set_receipt_store(Box::new(chio_store_sqlite::SqliteReceiptStore::open(
directory.join("receipts.db"),
)?))?;
authority.attach(&mut kernel)?;
let service_key = Keypair::generate();
let mut bridge = OpenApiMcpBridge::from_spec(
&spec,
BridgeConfig {
server_id: "pricing".into(),
server_name: "Larkfield pricing".into(),
server_version: "1.0.0".into(),
public_key: service_key.public_key().to_hex(),
base_url: "http://127.0.0.1:8087".into(),
egress_contract: Some(HttpEgressContract {
tenant_egress_namespace: "larkfield-pricing-local".into(),
allowed_schemes: BTreeSet::from(["http".into()]),
allowed_authority_set: BTreeSet::from(["127.0.0.1:8087".into()]),
deny_loopback: false,
deny_link_local: true,
deny_ipv6_ula: true,
max_redirect_chain: 0,
max_response_bytes: dispatcher::RESPONSE_LIMIT,
}),
},
)?;
bridge.set_dispatcher(dispatcher::build()?);
let manifest = bridge.manifest_clone();
let signed = chio_manifest::sign_manifest(&manifest, &service_key)?;
chio_manifest::verify_manifest(&signed, &service_key.public_key())?;
std::fs::write(
directory.join("manifest.json"),
serde_json::to_vec_pretty(&signed)?,
)?;
kernel.register_tool_server(Box::new(OwnedBridgeToolServer::from_bridge(bridge)));
let agent = Keypair::generate();
let capability = kernel.issue_capability(
&agent.public_key(),
ChioScope {
grants,
..ChioScope::default()
},
1800,
)?;
std::fs::write(
directory.join("capability.json"),
serde_json::to_vec_pretty(&capability)?,
)?;
let mut edge = ChioMcpEdge::new(
McpEdgeConfig {
server_name: "Larkfield pricing, governed".into(),
..McpEdgeConfig::default()
},
kernel,
agent.public_key().to_hex(),
vec![capability],
vec![manifest],
)?;
eprintln!(
"Pricing MCP: role {role}, quote limit {}; evidence: {}",
if policy.allow_write {
policy.max_writes
} else {
0
},
directory.display()
);
edge.serve_stdio(BufReader::new(std::io::stdin()), std::io::stdout())?;
Ok(())
}[package]
name = "pricing-mcp"
version = "0.1.0"
edition = "2021"
[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-edge = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-openapi-mcp-bridge = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-egress-contract = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-store-sqlite = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
chio-control-plane = { git = "https://github.com/backbay-labs/chio", rev = "1346840e1192b5d6baf581c447cd37550e5c0144" }
anyhow = "1"
serde_json = "1"
reqwest = { version = "0.13", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }This stdio host gives its one caller an operator-issued grant for 30 minutes, keeps revocations in its process, and retains admission state per session. A shared deployment authenticates each caller and runs issuance and revocation as an operator service.
5. Read the kernel's receipts
The client points sessions/latest at the session it just closed. The projection shows each tool and decision without the whole signed record.
chio --receipt-db sessions/latest/receipts.db receipt list --admin-all \
| jq -c '{tool_name, verdict: .decision.verdict}'{
"id": "d569b5adb03fca2c89a178daace04890453c00561f8294714f3f28bcfcb113e1",
"tool_server": "pricing",
"tool_name": "quote",
"action": {
"parameters": {
"body": {
"percent": 80,
"sku": "sku-301"
}
},
"parameter_hash": "248cb70df6f2a615b68d0a6bea91f1a38ab0a7ca720d56213357fc293ace397f"
},
"decision": {
"verdict": "deny",
"reason": "invocation budget exhausted for capability cap-01a12ef7-d610-732e-a4a2-919d1dab4820",
"guard": "kernel"
}
}You should see six kernel receipts: two reads, two quotes, the exhausted quote, and the withdrawal outside the grant. Download the complete signed denial or the first quote's receipt. uv run verify_session.py checks every receipt against the kernel key the client pinned and matches each to its call; the captured session holds the exchange and that verification.
A bridged call is an ordinary governed tool call, so its receipt is an ordinary chio.receipt.v1. Its tool_server is the server_id from your BridgeConfig, its tool_name is the operation id, and its action.parameters are the arguments the agent sent. Receipt format lists every field.
The bridge serves agents; the sidecar serves HTTP clients
Chio governs a REST API in two ways, and one deployment can run both against the same upstream, each with its own authority configuration.
| Decision | Bridge OpenAPI to MCP | HTTP sidecar |
|---|---|---|
| Caller protocol | MCP tool calls from an agent | HTTP from existing REST clients |
| Transformation | REST operations become MCP tools | Reverse proxy in front of the same API |
| Client change | Agents speak MCP; the HTTP side is unchanged | Point clients at the sidecar with a capability |
| Grant names | One tool per operation | The synthetic HTTP operation |
Use the bridge to expose an API for agent consumption, and chio api protect to govern it for clients that keep speaking HTTP. Protect an API runs the sidecar on this pricing API.
OpenAPI bridge reference
Try operationId, x-chio-publish, dispatcher, or redirect.
Operation to tool mapping
Each publishable OpenAPI operation becomes one MCP tool, deterministically.
- Name. The tool name is the operation's
operationId. Without one, the bridge falls back to"{METHOD} {path}", for exampleGET /v1/quotes. Fallback names are stable within one spec version and can shift between versions, so give operations explicit ids. - 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 the dispatcher supplies required headers. Keep upstream credentials and operator-selected tenant identity in the host, and expose only parameters the agent is authorized to choose. - Output schema. The generator takes the
200response schema, then201, then the first other 2xx that declares one (generator.rs:233-256), as the tool'soutputSchema. Agents that honor output schemas validate responses with it. - Description. The operation's
summaryif present, otherwise itsdescription, otherwise a synthesized"{METHOD} {path}". A presentsummarywins outright; the two are never concatenated. - Route binding. The bridge keeps a
BTreeMap<String, RouteDispatch>keyed on tool name. Each entry holds aRouteBindingof method and path template, the query parameters, and whether a body is required, so at invoke time the bridge rebuilds the exact method and URL. Path parameters are percent-encoded and substituted into the template from the tool arguments.
Side-effect classification
The bridge classifies each tool by HTTP method into the has_side_effects flag the kernel uses for side-effect admission. A read classification grants nothing: this host issues named grants for its read operations too.
| HTTP method | Classification | Admission requirement |
|---|---|---|
GET, HEAD, OPTIONS | Safe read | Named read grant; exempt from durable side-effect admission |
POST, PUT, PATCH, DELETE | Side effect | Named grant plus durable side-effect admission |
A read served over POST, common in search-style RPC-over-REST designs, is classified as a side effect by default. Mark it with x-chio-side-effects: false.
OpenAPI extensions
chio-openapi reads these x-chio-* fields on an operation.
x-chio-publish: false. Omits the operation from the tool manifest entirely, for admin or internal routes.x-chio-side-effects. An explicit boolean that overrides the method-based classification.x-chio-sensitivity. A data classification, one ofpublic,internal,sensitive, orrestricted, defaultinternal. The guard pipeline uses it for logging level and audit granularity; it leaves policy unchanged.x-chio-approval-required: true. Setsannotations.requires_approvalon the generated tool and makes the operation deny by default, ahead of every other policy input. The approval comes from a person:chio approvals pending, thenchio approve <id>.x-chio-budget-limit. A per-invocation cost cap in minor currency units, for example5000, which the budget guard applies against the caller's remaining budget.
paths:
/v1/quote:
post:
operationId: quote
x-chio-side-effects: true
x-chio-approval-required: true # deep discounts wait for a person
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/QuoteRequest'
responses:
'201':
description: Quote issued
/admin/reprice:
post:
operationId: reprice
x-chio-publish: false # never exposed as a toolWhat the receipt and the tool result carry
Every hash on a bridged receipt is bare lowercase hex, 64 characters, with no ed25519: or 0x prefix, and the id is content-addressed over the canonical body. The classification fields receipt_kind, boundary_class, tool_origin, redaction_mode, and trust_level are always present. The HTTP method, path template, and status live in the MCP tool result's structuredContent, beside 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,
}
})
}path is binding.path, the OpenAPI template, so GET /v1/quotes/{quoteId} reports /v1/quotes/{quoteId} for every quote. The dispatcher received the expanded URL; the receipt binds the arguments that produced it.
A denial has the same structure with "decision": { "verdict": "deny" } and the failing guard in evidence[].guard_name. Read the guard there: the kernel formats decision.reason from the name of its registered guard (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). A pre-dispatch denial prevents the HTTP call; a failure found after dispatch leaves open whether the upstream was contacted.
The dispatcher contract
The bridge opens no socket itself. A registered bridge performs its HTTP operation through the dispatcher your host supplies, which keeps the crate transport-agnostic and testable, and it enforces the caller-supplied HttpEgressContract around every dispatch: URL and DNS pre-flight before the call, a response-byte ceiling after. The dispatcher owns transport; the contract owns egress policy.
/// 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, so the kernel never signs a successful receipt for a side effect that never happened. A missing egress contract makes enforce_dispatch_contract return OpenAPI bridge dispatcher requires an HttpEgressContract, and a response without observed_body_bytes makes enforce_bridged_response_body refuse the same way.
use chio_openapi_mcp_bridge::{BridgeError, BridgedResponse, HttpDispatcher};
use std::io::Read;
pub const RESPONSE_LIMIT: u64 = 128 * 1024;
// The bridge expands declared path/query parameters. This dispatcher sends
// only arguments.body as JSON, and never follows an ungoverned redirect.
pub fn build() -> anyhow::Result<Box<HttpDispatcher>> {
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.build()?;
Ok(Box::new(move |method, url, arguments| {
let execute = || -> anyhow::Result<BridgedResponse> {
let method = reqwest::Method::from_bytes(method.as_bytes())?;
let mut request = client.request(method, url);
if let Some(body) = arguments.get("body") {
request = request.json(body);
}
let response = request.send()?;
let status = response.status().as_u16();
let mut bytes = Vec::new();
response.take(RESPONSE_LIMIT + 1).read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() as u64 <= RESPONSE_LIMIT,
"response exceeds 128 KiB"
);
let body = if bytes.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bytes)?
};
Ok(BridgedResponse {
status,
body,
observed_body_bytes: Some(bytes.len() as u64),
is_error: status >= 400,
})
};
execute().map_err(|error| BridgeError::UpstreamError(error.to_string()))
}))
}This dispatcher disables redirects, uses the exact local upstream with no proxy, applies a 10-second timeout, and stops reading above 128 KiB. It sends arguments.body as the JSON request body; the bridge has already expanded declared path and query parameters. The bridge rejects a returned 3xx, so the dispatcher returns redirects as they arrive. A test may install a dispatcher that returns a canned BridgedResponse with observed_body_bytes and a valid egress contract; it exercises mapping and response checks, and the pricing walkthrough covers HTTP delivery and recorded quotes.
Checks on the generated tools
bridge.tool_names(). Every operation you expected became a tool, and none markedx-chio-publish: falsedid. A missing name is a spec problem.bridge.route_binding(name). The method and path template the bridge dispatches. Compare it with the spec path.manifest().tools[i].has_side_effects. The classification the kernel admits on. A search endpoint served overPOSTreadstrueuntil you annotate it.manifest().tools[i].input_schema. Whether an agent can reach every argument. Header and cookie parameters are absent by construction.
from_spec validates eagerly. A spec whose publishable operations all resolve to nothing returns ManifestError::EmptyManifest, and a path template whose parameters disagree with the declared parameters fails at construction. Run OpenApiMcpBridge::from_spec against your spec in CI to catch construction errors, and test authorization, dispatch, and application state separately.
Bridge errors
Missing contracts and malformed arguments fail before HTTP dispatch. Redirect and response-size failures occur after the upstream was contacted, and a failed result cannot roll back a write the upstream already performed.
OpenAPI bridge requires a dispatcher for live tool invocation. Noset_dispatchercall. Install the HTTP dispatcher before serving client calls.OpenAPI bridge dispatcher requires an HttpEgressContract.egress_contract: None. Populate it; no permissive default exists.HttpEgressContract rejects bridge URL: .... The resolved URL failed scheme, authority, or DNS pre-flight. Widenallowed_authority_setdeliberately, or fix thebase_url.OpenAPI bridge dispatcher must provide observed_body_bytes for response-size enforcement. The dispatcher returned aBridgedResponsewith that field unset. Set it from the raw body length, before JSON parsing.OpenAPI bridge dispatchers must not follow redirects; returned HTTP redirect status .... The dispatcher returned a 3xx. Disable redirect following in the client; a client that hides intermediate redirects breaks the contract and can evade this check.OpenAPI bridge missing required request body `body`. The operation declares a required body and the arguments had nobodykey, 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`. A construction-time error: 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, the four egress and transport checks at dispatch.rs:171,175,186,198, the body check 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 arrive as BridgeError, which the kernel wraps as KernelError::ToolServerError when the bridge is registered as a tool server.
Spec, response, and credential rules
- Responses. The bridge models each response as one JSON body, and this host buffers it within its bound. Server-sent events and long-lived streams sit outside it, and the HTTP proxy buffers responses the same way.
- Spec versions. The bridge reads OpenAPI 3.0 and 3.1. Convert Swagger 2.0 first, for example with
swagger2openapi. operationIdcollisions. Two operations with one id fail at manifest construction withManifestError::DuplicateToolName(crates/platform/chio-manifest/src/validation.rs:50-56, reached fromchio-openapi-mcp-bridge/src/lib.rs:199).- Path parameters. A parameter that resolves to an empty string or a dot segment is rejected at invoke time, before dispatch.
- Redirects. A 3xx from the dispatcher is always an error; return it as-is and let the bridge reject it.
- Upstream credentials. Your dispatcher attaches
Authorizationheaders or signs requests. The operator holds those credentials and decides how a client receives authority to use them.