Close-up of a dark monitor showing colourful financial line charts trending upward — the kind of data bezant makes typed and programmable
Open-source

bezant — typed IBKR access from Rust, HTTP, CLI, MCP, and TypeScript

10 min read Isaac Rowntree

Zack Design has published bezant, a typed, asynchronous Rust client for the Interactive Brokers (IBKR) Client Portal Web API, the HTTP interface to a brokerage account. The API is 155 paths, 167 methods and 1030 types. IBKR publishes an OpenAPI document for it, a machine-readable description of every endpoint, and that document will not go through oas3-gen or Progenitor, two tools that turn such a description into a Rust client, in the form IBKR ships it: around 50 operations have no operationId (the name a generator uses for each method), at least one operationId is used twice, the security sections list a null where a permission scope should be, and fields declared as integers have decimal example values. bezant keeps a copy of the spec in the repository, repairs it in a 13-step normaliser, and from one ./scripts/codegen.sh generates a Rust client, a small HTTP server other languages can call, a command-line tool, a Model Context Protocol server (the interface AI coding tools use to call outside tools) and a TypeScript package. It is the first Rust project we have open-sourced.

This post walks through what the normaliser actually does to the spec, the snapshot test that wrote step 13 for us, and the two places where the running Gateway (the local proxy process IBKR requires between your code and its servers) diverges from its own documentation: an authentication endpoint that answers with a redirect (HTTP 302) where the spec promises an “unauthorised” (401), and a content delivery network in front of the API that rejects every POST with an empty body unless the request states its length explicitly, which the generated client never does.

One script generates five clients

scripts/codegen.sh is the whole pipeline: normalize-spec.py rewrites the copied JSON in place, upgrade-to-3.1.py upgrades it from OpenAPI version 3.0 to 3.1, and oas3-gen generate ... client-mod writes the generated Rust into crates/bezant-api/src/generated/. Everything else in the workspace is either hand-written on top of that crate or generated from the same normalised document.

Crate or package Purpose
bezant-core The hand-written Rust layer you call: Client, session keepalive, health check, WebSocket streaming, pagination, symbol cache, typed errors
bezant-api Generated Rust client covering every endpoint of the API
bezant-server HTTP server (built on axum) that exposes the API as plain JSON over HTTP for any language
bezant-cli Command-line tool: bezant health, bezant positions DU123456, bezant conid AAPL
bezant-mcp Model Context Protocol server over standard input and output, for Claude Code, Cursor and Continue
bezant-client TypeScript client for Node, Deno and the browser, generated from the same spec by codegen-ts.sh

The hand-written layer is what you write against. The generated crate has 167 methods named after IBKR’s operationIds (or the ones we synthesised), and nobody wants to remember those:

use std::time::Duration;

#[tokio::main]
async fn main() -> bezant::Result<()> {
    let client = bezant::Client::new("https://localhost:5000/v1/api")?;
    let _keepalive = client.spawn_keepalive(Duration::from_secs(60));
    client.health().await?;

    let positions = client.all_positions("DU123456").await?;
    let aapl = bezant::SymbolCache::new(client).conid_for("AAPL").await?;
    println!("{} positions; AAPL = conid {aapl}", positions.len());
    Ok(())
}

spawn_keepalive exists because a Gateway session dies after about five minutes idle. The background task pings it every 60 seconds and returns a handle.

The thirteen things wrong with the spec

The spec is not wrong so much as never validated. Each of the 13 transforms in scripts/normalize-spec.py is a separate function with a counter, so the script’s last line tells you exactly what it touched:

normalized: stripped {nulls} null scopes, added {ids} operationIds, renamed {dedup} duplicates,
desugared {enums} ambiguous enums, rewrote {cts} exotic content types, ...
widened {widened} integer fields, collapsed {multi} multi-content responses

The steps, in the order they run:

  1. Remove the null entries from the security scopes; OpenAPI 3.0 allows an empty list or a named scope there, not null.
  2. Give the roughly 50 operations that have no operationId one built from their HTTP method and path.
  3. Rename duplicates: getTradingSchedule appears on two paths, so later occurrences get a suffix derived from the path.
  4. Turn enums into plain strings when their values (>=, <=, >, <, ==) would all become the same Rust identifier once the symbols are stripped.
  5. Rewrite responses declared as application/jwt (a signed token) to plain text with a string schema.
  6. Make enum values match the declared type, for example a field declared as a number whose enum is the strings "0", "1" and "2".
  7. Move parameters declared as part of the path, but whose placeholder never appears in the URL, into the query string.
  8. Drop format: "jwt", which is not a string format any generator knows.
  9. Rewrite cookie parameters as header parameters, because Progenitor does not support cookies.
  10. When a successful response is offered as both JSON and PDF, keep JSON.
  11. Drop operations whose only response is 101 Switching Protocols; those are WebSocket upgrades, not REST calls.
  12. Declare query parameters that are lists of integers as strings instead, because the generator’s comma-separated list type only handles strings.
  13. Change integer fields to floating-point numbers when the spec’s own example payloads carry decimals in them.

Each transform is written to be deleted the day IBKR fixes the corresponding thing upstream. The full list with rationale lives at Spec normalisation.

The snapshot test that wrote step 13

Step 13 was not designed; it was found. scripts/extract-examples.py pulls every example payload out of the spec into crates/bezant-core/tests/fixtures/examples.json, grouped by operationId, and tests/examples.rs tries to parse each one into the generated Rust type:

for case in cases {
    let result: Result<T, _> = serde_json::from_value(case.value.clone());
    if let Err(e) = &result {
        panic!("deserialise failed for {op_id}/{}: {e}\n  payload: {}",
               case.example_name, case.value);
    }
}

The first run failed on SMA: 368538.0. The spec declares SMA, balance, accruedInterest and their siblings as integers; IBKR’s own example ships them with decimals; the generator had faithfully made them 32-bit integers. The wrong fix is a type cast in the hand-written layer. The fix that survives the next spec revision is a normaliser step that walks every example, finds every integer-typed field holding a decimal, and widens the schema. It widens 37 fields per run today, and if IBKR adds a 38th the snapshot test fails before a user does.

That is the pattern for the whole normaliser: a failing test becomes a transform, never a hand patch to generated code.

The spec says 401. The Gateway sends 302.

/iserver/auth/status is documented to return 401 when there is no session. The real Client Portal Gateway responds with a 302 redirect to its login page. The generated client files any status code the spec did not mention under a catch-all Unknown error, so the “please log in” signal surfaced as a server error (500) with the message “unknown auth_status response”.

Client::auth_status in bezant-core now skips the generated parser, sends the request itself and reads the status code directly: any redirect or a 401 becomes Error::NotAuthenticated, a success is parsed as a typed AuthStatus, and anything else surfaces the real status code. This is a small function and it is the most important one in the crate, because every other call’s failure mode depends on it.

The CDN in front of IBKR rejects empty POSTs

The second divergence is not in IBKR’s code at all. api.ibkr.com sits behind Akamai, a content delivery network, and Akamai rejects a POST that arrives with neither a Content-Length header nor chunked transfer encoding, answering 411 Length Required. The API has several POST endpoints that take no body (/iserver/auth/ssodh/init, /iserver/reauthenticate, /tickle), and for an operation with no request body the generator emits self.client.post(url).send(), which makes reqwest, the Rust HTTP library, omit the Content-Length header. The typed client folds the 411 into Unknown, and /health reports “unknown auth_status response” with nothing in the logs to say why.

Two panels: a POST with no body and no Content-Length header is stopped at the Akamai content delivery network with 411 Length Required and never reaches IBKR; the same POST with an explicit Content-Length of zero passes through Akamai to IBKR's API.

I got this wrong twice before getting it right. The first theory was HTTP/2: reqwest negotiates HTTP/2 with the server during the TLS handshake, and an HTTP/2 POST with no body carries no Content-Length header. Pinning the client to HTTP/1.1 was the first change, and the 411s continued. The second was to give the request an explicit empty body so that hyper, the HTTP engine under reqwest, would write Content-Length: 0; .body(Vec::<u8>::new()) alone still tripped the 411 in some combinations, which looks like hyper choosing chunked encoding for a body whose length it did not know was zero. What works is setting the header yourself. The generated crate gets the empty-body form injected by a find-and-replace step at the end of codegen.sh:

patched = src.replace(
    "self.client.post(url).send().await?",
    "self.client.post(url).body(Vec::<u8>::new()).send().await?",
)

and the hand-written layer’s own raw POSTs set Content-Length: 0 explicitly. The HTTP server does the same on every proxied POST, PUT and DELETE, with the exact byte length of the body it forwards. None of this is in the spec, because the spec describes IBKR’s service and the 411 comes from the CDN in front of it.

What is tested

34 tests across the workspace at v0.1, all in CI: unit tests for the hand-written layer and the command-line tool, the example-payload snapshot suite above, crates/bezant-mcp/tests/tools.rs, and crates/bezant-server/tests/routes.rs, which starts a fake Gateway with wiremock, a mock HTTP server library, and checks things like health_maps_unauthorized_to_401_with_code, accounts_passthrough_forwards_body_verbatim and account_positions_defaults_to_page_zero.

The Docker quickstart is docker compose up, one browser login to the Gateway at https://localhost:5000, and http://localhost:8080/health reporting an authenticated session.

Six MCP tools, written by hand

bezant-mcp speaks the Model Context Protocol over standard input and output using the rmcp library, takes the Gateway URL from IBKR_GATEWAY_URL, and runs its own 60-second keepalive. The tools are not generated from the spec; there are six, defined with #[tool] in crates/bezant-mcp/src/lib.rs on top of bezant-core: health, list_accounts, account_summary, positions, conid_for (cached through SymbolCache) and tickle. That is deliberate. 167 generated tools would give a model 167 ways to be wrong about operationIds; six tools with descriptions that say “call health first if anything returns not-authenticated” is a set a model can actually drive.

Status and licensing

  • Alpha, v0.1. Works end-to-end against IBKR paper accounts; the public API will move until v1.0.
  • Dual-licensed under MIT or Apache-2.0, the Rust convention.
  • Not affiliated with Interactive Brokers. The vendored spec is IBKR’s; it is included for interoperability.
  • Docs: isaacrowntree.github.io/bezant
  • Source: github.com/isaacrowntree/bezant

Two things carried over from this project. A third-party spec is input data, not a contract, and the right place to fix it is a normaliser with a counter per fix and a test that fails when the fix stops being needed. And the interesting failures were never in the spec or the generator; they were in the layers the spec does not describe, the Gateway’s redirect and the CDN’s rules about how a request states its length, and the only way to find them was to run typed calls against the real thing and read the status codes. Contributions are welcome, particularly new normaliser steps and new client languages.