Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

mq-bridge-app

crossing streams

mq-bridge-app is a fast, single-command ETL and data-movement tool built in Rust — and, on top of the same engine, a multi-protocol bridge and traffic workbench for messaging.

It ships in three forms that share one engine and one config format: a desktop app (visual workbench), a CLI / server (headless bridge and one-line copy), and a library (embed the engine in Rust, Python, or Node.js). Design a route once, run it anywhere — no rewrite in between.

Supported integrations include Kafka, RabbitMQ (AMQP), NATS, AWS SQS, MQTT, IBM MQ (optional), HTTP, gRPC, ZeroMQ, MongoDB, Redis Streams, ClickHouse, Postgres CDC, sqlx (MySQL, MariaDB, PostgreSQL, SQLite), cloud object storage, and filesystem endpoints.

A quick taste

At its core is a zero-config copy command that moves data between databases, queues, and files in a single line of bash — no YAML, no pipeline definition, no code:

mq-bridge-app copy \
  --from 'postgres://user:pass@localhost/db?table=src&sslmode=disable' \
  --to   'file://out.jsonl?format=raw' \
  --drain

The scheme selects the endpoint and query parameters configure it, so any source→sink pair is just one URL each. And it’s quick: in benchmarks a 1,000,000-row Postgres → JSONL job sustained 266,951 rows/s at ~20 MiB peak RSS — see Performance tuning.

Philosophy

The project has one main bias: move data reliably without forcing the rest of the application to care too much about the transport. Kafka offsets, RabbitMQ nacks, HTTP responses, MongoDB polling, WebSocket frames, and file rows are all different in real life, but route code should still be able to receive a batch, process it, publish it, and commit it.

  • Fast by default. Every endpoint is optimized around batch-shaped APIs, and the headless surfaces ship tuned for throughput: the copy CLI and the MCP server default to batch_size: 1024 and concurrency: 4. The low-level library/config primitive defaults to batch_size: 1, concurrency: 1 (opt in per route) so embedded routes stay predictable until you raise them — usually the first knob to reach for when throughput matters.
  • Reliability is built in, not bolted on. Retries, dead-letter queues, deduplication, rate limiting, and cookie/session persistence wrap any endpoint. Ack/nack behaviour and retry/DLQ handling were designed to work with batching, including commit sequencing for cumulative-ack brokers.
  • Not a framework. It is not a domain framework, an actor runtime, or a full stream processor. It cares about transport, routing, and delivery behaviour, not about prescribing your domain model.

Where to go next

  • New here? Start with Installation and the Quick start.
  • Want the end-to-end walkthroughs? See the Tutorials.
  • Looking for a specific task? The Cookbook has short recipes.
  • Need exact fields and defaults? The Reference is authoritative.
  • Running it in production? See Operations, especially the Performance tuning page.
  • Driving it from an AI agent? The same binary is an MCP server — the rows move without entering the model’s context.

App vs. engine

mq-bridge is the engine/library; mq-bridge-app is the application — desktop app + CLI/server + library distribution — built on that engine. This book is the user-facing home; the engine’s deep API reference lives on docs.rs. The library bindings let you embed the same engine in Rust, Python, or Node.js.

Install

mq-bridge-app ships in three forms that share one engine and one config format: the CLI / server, the desktop app (UI), and the library. Pick the install path for the form you need. To compile any of them yourself, see BUILD.md.


CLI / server

The CLI (mq-bridge-app) is a single headless binary.

brew install marcomq/tap/mq-bridge-app
brew upgrade mq-bridge-app     # later, to update

Prebuilt bottles cover Apple Silicon macOS and x86_64 Linux. Homebrew refreshes the tap only on brew update, so if brew upgrade doesn’t pick up a new release yet, run brew update first.

cargo binstall — prebuilt binary

Downloads the prebuilt CLI from Releases instead of compiling from source, so it installs in seconds:

cargo binstall mq-bridge-app

Prebuilt binaries are available for x86_64 Linux, Apple Silicon macOS, and x86_64 Windows. (cargo-binstall is a drop-in cargo install replacement.)

cargo install — from source

Requires a Rust toolchain and compiles all supported endpoint client libraries (except IBM MQ), so it may take a while:

cargo install mq-bridge-app

For IBM MQ, install the client library first and build with --features=ibm-mq.

Docker

The CLI is published as a multi-arch image (amd64 + arm64):

docker run --rm --name mq-bridge -p 9091:9091 ghcr.io/marcomq/mq-bridge-app:latest

To read+tail from input.log and forward its content, mount the working directory at /app and seed the config from one of the templates baked into the image at /config:

touch input.log
docker run --rm --name mq-bridge -p 9091:9091 -v "$(pwd)":/app \
  ghcr.io/marcomq/mq-bridge-app:latest --init-config=/config/file-to-http.yml

Note

The default latest image is a plain multi-arch image for amd64 and arm64. IBM MQ support is published separately as the latest-ibm-mq and ibm-mq tags on amd64 only, since there is no redistributable IBM MQ client library for arm64 yet. Start that image in emulation mode with --platform=linux/amd64, or build mq-bridge-app yourself with cargo build --release --features=ibm-mq.


Desktop app (UI)

The desktop app is a Tauri bundle of the full messaging workbench — the same UI the CLI serves in a browser, only packaged as a native app.

Homebrew cask (macOS, Apple Silicon)

brew install --cask marcomq/tap/mq-bridge

Homebrew quarantines cask apps by default and the desktop binaries are not notarized yet, so macOS blocks the app on first launch. See the note below for how to open it (via System Settings or Terminal).

Prebuilt bundles (macOS, Windows, Linux)

Bundles for every platform are attached to each release on the GitHub Releases page.

  • macOS — download the .dmg / .app bundle.
  • Windows — download the installer or standalone executable.
  • Linux — download the bundle that suits your distribution: AppImage, .deb, .rpm, or the unpacked archive.

Note

The desktop binaries are currently not notarized, so on macOS Gatekeeper blocks the app on first launch — it’s reported as “damaged” or “cannot be opened because the developer cannot be verified”. This applies to both the Homebrew cask and a downloaded bundle. There are two ways to open it:

Option 1 — System Settings (no Terminal). Try to open the app once so the block is triggered, then go to System Settings → Privacy & Security, scroll to the Security section, and click “Open Anyway” next to the mq-bridge message. Authenticate, then launch the app again.

Option 2 — Terminal. Remove the quarantine attribute directly:

# app in /Applications (where the cask and the .dmg install it)
sudo xattr -rd com.apple.quarantine /Applications/mq-bridge.app
# app in a user-owned directory (e.g. ~/Downloads) — no sudo needed
xattr -rd com.apple.quarantine ~/Downloads/mq-bridge.app

If macOS says the app is “damaged”, the “Open Anyway” button may not appear — use the Terminal method in that case.


Library

Embed the core engine in your own code — produce or consume messages with a unified API, one config format across all three bindings:

The core of the library are the MessageConsumer and MessagePublisher traits, found in mq_bridge::traits.


Build from source

Building the CLI/server, the desktop (Tauri) app, or the Docker image from source is covered in BUILD.md. For IBM MQ specifically, see the IBM MQ Setup Guide.

Quick Start

mq-bridge-app copy moves data between two endpoints described as URIs. The scheme picks the connector; ?query=params configure it.

mq-bridge-app copy \
  --from postgres://localhost/app?table=users \
  --to 'clickhouse://localhost:8123?table=users&database=analytics'

This copies every row currently in app.users (PostgreSQL) into analytics.users (ClickHouse). No config file, no UI — just a source and a destination. Add --drain to exit once the source is empty instead of running as a continuous bridge (see Continuous vs. one-shot below).

The five sections below are complete, working commands. Each links to the full connector page for that endpoint, which lists every available option; the generated URL reference is the authoritative source for every parameter’s type, default, and description.

PostgreSQL → ClickHouse

mq-bridge-app copy --drain \
  --from postgres://user:pass@localhost/app?table=orders \
  --to 'clickhouse://localhost:8123?table=orders&database=analytics'

Reads all rows from the orders table and bulk-inserts them into ClickHouse’s HTTP interface. Add &cursor_column=id&cursor_id=orders_sync on --from to make this resumable instead of a one-shot batch. See PostgreSQL and ClickHouse.

PostgreSQL CDC → PostgreSQL

mq-bridge-app copy \
  --from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
  --to 'postgres://user:pass@otherhost/replica?table=orders&auto_create_table=true'

Streams inserts/updates/deletes from a PostgreSQL logical-replication publication into another PostgreSQL table, continuously (CDC is a change stream, so this command doesn’t drain — run it as a long-lived process). See PostgreSQL CDC.

MQTT → Kafka

mq-bridge-app copy \
  --from mqtt://broker.local:1883?topic=sensors/+/temperature \
  --to kafka://kafka.local:9092?topic=sensor-readings

Subscribes to an MQTT topic (wildcards supported) and republishes every message to a Kafka topic, continuously. See MQTT and Kafka.

RabbitMQ → HTTP

mq-bridge-app copy \
  --from rabbitmq://guest:guest@localhost:5672/%2f?queue=orders \
  --to http://internal-api.local/ingest?method=POST

Consumes messages from a RabbitMQ queue and POSTs each one to an HTTP endpoint, continuously. See RabbitMQ and HTTP.

File (CSV) → MongoDB

mq-bridge-app copy --drain \
  --from file:///data/customers.csv?format=csv \
  --to 'mongodb://localhost?database=app&collection=customers'

Reads a CSV file (first row = header) and inserts one document per row into a MongoDB collection, then exits since the source is a finite file. See File and MongoDB.

Continuous vs. one-shot

Without --drain, copy runs as a continuous bridge until Ctrl-C — the right mode for message brokers (MQTT, Kafka, RabbitMQ) and CDC sources, which never “end”. With --drain, copy exits once the source yields an empty batch — the right mode for finite sources (a file, or a full-table read from a database). --concurrency and --batch_size tune throughput on both modes.

Escape hatch: driver options and full connection strings

Any query parameter that isn’t a recognised config field (e.g. sslmode, replicaSet) is left on the connection URL untouched, so driver-specific options just work — including object-typed fields like tls, which can never be set from a single scalar query param and so always stays on the URL (e.g. mongodb://host/?tls=true&database=appdb passes tls=true straight through to the MongoDB driver). If you already have a complete connection string (copied from elsewhere, or one whose own options would otherwise be mis-parsed as config), skip decomposition entirely and pass it verbatim with ?url=<url-encoded string>:

mq-bridge-app copy \
  --from 'mongodb://_/?url=mongodb%3A%2F%2Fuser%3Apass%40host%2Fdb%3Ftls%3Dtrue&collection=orders' \
  --to null:

See the generated reference for each connector’s recognised field names.

The three ways to run it

mq-bridge-app is one engine with one config format, exposed three ways. Build and test a route in the UI, export the JSON/YAML, then run that config in a config-mode service or from library code. The copy CLI takes the same settings, but expressed as endpoint URIs and flags rather than a config file.

FormWhat it isQuick install
Desktop app (UI)The visual workbench — build/test routes, run request/response traffic, inspect message historybrew install --cask marcomq/tap/mq-bridge
CLI / serverHeadless binary: a one-line copy, a drain-then-exit batch job, or a long-lived bridge (also serves the same UI in a browser)brew install marcomq/tap/mq-bridge-app
LibraryThe engine embedded in your own code — native Rust, Python, or Node.js bindingscargo add / pip / npm

See Installation for every install method and platform.

Desktop app (UI)

The desktop app is a Tauri bundle of the full messaging workbench: manage publishers/consumers/routes, run request/response traffic (like Postman for REST), inspect message history, and import Postman/OpenAPI/AsyncAPI definitions. It is the same UI the CLI serves in a browser — only the packaging differs.

The UI is generated dynamically from the Rust configuration structures: the backend uses schemars to produce a JSON Schema for the AppConfig struct (exposed at /schema.json, also mq-bridge-app --schema), and the frontend renders a complete config form from that schema. So when a new middleware or option is added to the engine, the schema updates automatically and the UI reflects it with no frontend change.

CLI / server

The CLI (mq-bridge-app) is a headless binary that runs in three modes. They share the same engine and config format but differ in how you drive them — and only config mode serves the browser UI:

ModeInvocationServes web UI?Best for
Config mode (default)mq-bridge-app [--config x.yml]Yes — the same UI as the desktop app, on the configured portLong-lived bridge from a config file; Container/Kubernetes deployments
copymq-bridge-app copy --from … --to …No (headless)Ad-hoc one-route job from two endpoint URIs, no config file; add --drain to exit once the source is empty
mcpmq-bridge-app mcpNo (headless)Expose the bridge as MCP tools so an LLM agent can publish and route from natural language
# Config mode: run a long-lived bridge from a file
mq-bridge-app --config config.yml

# Seed config.yml from a template on first run only
mq-bridge-app --config config.yml --init-config dev/config/file-to-http.yml

# Start empty, then open the UI to build your config
mq-bridge-app

In config mode the CLI also serves the browser UI (the same UI as the desktop app) on the configured port. See the CLI reference for every flag, and Configuration grammar for the config format.

The configuration-first workflow

The point of one shared config format is that you can test connections and dial in a route in the UI, export the JSON/YAML, then run that exact config unchanged — as a config-mode service or loaded from library code. A known-good route shape from the UI drops straight into production.

copy is the exception: it takes no config file, so a route you built in the UI has to be mapped by hand onto --from / --to endpoint URIs and route flags. The settings are the same, only the way you pass them differs.

Library

Beyond running standalone, the core engine is available as a library so you can produce or consume messages with a unified API — no broker-specific SDK, one config format across all three bindings:

The core of the library are the MessageConsumer and MessagePublisher traits in mq_bridge::traits. See the Language bindings API and the Embed the library tutorial.

How the UI differs from API clients

The UI overlaps with API clients like Postman, Bruno, and Insomnia, but its centre of gravity is different: it is designed around message bridging, runtime operation, and long-lived route management rather than just request composition. It adds broker pub/sub workflows, long-lived consumers/routes, bridging traffic between protocols, hex-level payload debugging, replay, local-first git-friendly config, and encrypted config — while leaving scripting and complex request workflows to the dedicated API clients. Use an API client when your main job is crafting and sharing API requests; use mq-bridge-app when you need to connect systems, move messages between protocols, inspect live traffic, and manage bridge-style runtime configuration.

The desktop / web UI

mq-bridge-app ships a visual workbench for building, testing, and running message routes. It is the same UI whether you launch the desktop app (a Tauri bundle) or let the CLI serve it in a browser — only the packaging differs. Think of it as Postman for message bridging: build and test a route, export the JSON/YAML, then run that exact config unchanged wherever you deploy.

For how the UI is built (schema-driven forms, the engine acting as its own web server), see How the app is built.

Layout

The window is split into three top-level tabs, a sidebar listing your endpoints, and a top bar:

  • Publishers (↑) — endpoints you send to (build/test requests, run request/response traffic).
  • Consumers (↓) — endpoints you receive from (live message log, payload inspection).
  • App Config (⚙) — application-wide settings, config security, and environment variables.

The top bar shows a runtime status indicator (idle / active consumer(s)), a theme switcher (light / auto / dark), and Save. The sidebar filters endpoints and offers one-click import from Postman, OpenAPI, AsyncAPI, and existing mq-bridge configs.

Publishers

Select or add a publisher on the left, then edit it on the right. The header row holds the endpoint type (HTTP, Kafka, NATS, …), method, and URL, with a Send button to fire a request. Below it are per-endpoint tabs:

  • Definition — name, endpoint type and connection settings (with Show advanced options), and a Middlewares list you can add to / remove from (metrics, retries, transforms, …).
  • Body / Headers — the outgoing payload (with AUTO / TEXT / JSON / XML / HEX views) and header rows.
  • History / Presets — previous sends and saved request presets.

After a Send, the response panel shows status, timing, request/response headers, and the response body (including a HEX view), with copy / json / curl shortcuts. Copy to Consumer, Clone, {} (view raw JSON), and Delete act on the selected endpoint.

Consumers

Consumers receive messages live. The header shows the connection state (Connected), a Capture messages toggle, a Keep last N limit, and Clear / Stop controls. Tabs:

  • Definition — connection settings, same shape as a publisher.
  • Output — the consumer’s response/output configuration.
  • Messages — a live log (time + payload preview). Click a row to inspect it below: Message Headers, Message Body, and any Response Headers / Body, each with AUTO / TEXT / JSON / XML / HEX views plus copy/export.

App Config

Application-wide settings rendered directly from the AppConfig schema:

  • AppConfig — default tab, log level, logger, metrics address, and UI address.
  • Config Security — the storage mode (unencrypted, balanced, sensitive, durable, …) controlling how secrets and cached message history are stored. See Encryption at rest and Secrets.
  • Environment Variables — key/value pairs available for interpolation in endpoint URLs and settings.

Export, Import, Reset, and {?} JSON operate on the whole configuration.

The configuration-first workflow

The point of one shared config format is that you test connections and dial in a route in the UI, export the JSON/YAML, then run that exact config unchanged — as a copy command, a config-mode service, or loaded from library code. A known-good route shape from the UI drops straight into production.

How the UI differs from API clients

The UI overlaps with API clients like Postman, Bruno, and Insomnia, but its centre of gravity is different: it is built around message bridging, runtime operation, and long-lived route management rather than one-off request composition. It adds broker pub/sub workflows, long-lived consumers/routes, protocol-to-protocol bridging, hex-level payload debugging, replay, local-first git-friendly config, and encrypted config — while leaving scripting and complex request chaining to the dedicated API clients.

Status. The UI/Tauri layer was prototyped quickly and does not mirror the mq-bridge / core/CLI standards — treat it as a working demo, not a reference implementation, and test before relying on it in production.

MCP server

mq-bridge-app

data movement as a tool call

mq-bridge-app mcp runs the bridge as a Model Context Protocol server, so an agent can move data between any two supported endpoints — 15+ connectors covering databases, queues, brokers, HTTP, and files — from natural language.

Nothing is preconfigured. publish and start_route take their endpoints inline as JSON keyed by connector type, so the model picks both ends ad hoc; there is no YAML to write first and no per-connector tool to install. list_routes, route_status, route_messages, and stop_route manage what is already running.

The rows never enter the model’s context. The agent describes the job; the engine moves the bytes. Moving a 116.3 MiB dataset costs three tool calls and ~370 tokens — the same ~370 tokens whether the job is 1,000 rows or 1,000,000.

Registry nameio.github.marcomq/mq-bridge-app
Transportsstdio, streamable HTTP
Tools7see below
Connectors15+see below
Installmq-bridge-app mcp installDocker / cargo / Homebrew / binaries — Installation

Quick start

# 1. get the binary (any install method from the Installation page)
brew install marcomq/tap/mq-bridge-app

# 2. register it with every MCP client detected on this machine
mq-bridge-app mcp install

# 3. restart the client fully — reopening a tab is not enough

Then ask the agent for the job in words:

“Drain the events Redis stream into the events table in Postgres, creating the table if needed.”

Running the server

# stdio — for local clients (Claude Code, Claude Desktop, Cursor)
mq-bridge-app mcp

# streamable HTTP — for remote/shared clients
mq-bridge-app mcp --transport http --bind 127.0.0.1:9092
FlagDefaultMeaning
--transportstdiostdio for local clients, http for streamable HTTP (served over hyper)
--bind127.0.0.1:9092Listen address; --transport http only
--report-to-uioffAlso report route/publisher activity to a running desktop or web UI

No web UI is started in this mode. Logs go to stderr, because stdio transport owns stdout for the protocol itself.

Tools

ToolKey argumentsPurpose
publishpublisher, message | messages, nameSend one message or a batch to any endpoint. Independent of routes.
start_routeroute (input/output), name, batch_size, concurrency, capture_lastRun a route moving messages from source to sink. Returns the route name.
list_routesEvery route started by this server, with live connection health and rates.
route_statusname (optional)Health, totals and rates for one route, or all of them.
route_messagesnameThe most recent messages captured on a route. Requires capture_last; reads drain the buffer.
stop_routenameStop a route; returns total messages and the rate it achieved.
server_infoCrate version, git hash, build profile and build time.

Call server_info before quoting any throughput number: a debug binary reports much slower rates, and the figure would be meaningless.

start_route and route_messages are annotated not read-only — starting a route moves real data, and reading captured messages consumes the buffer — so a client should not auto-approve them the way it may auto-approve a true reader.

Endpoints

Every endpoint is a single-key JSON object naming the connector:

{"kafka":   {"url": "localhost:9092", "topic": "orders"}}
{"nats":    {"url": "nats://localhost:4222", "stream": "ORDERS", "subject": "ORDERS.new"}}
{"sqlx":    {"url": "postgres://user:pass@localhost:5432/db", "table": "events"}}
{"file":    {"path": "/tmp/out.jsonl", "format": "json"}}
{"null":    null}

The key is the connector; the object is its configuration:

JSON keyConnectorDetails
sqlxPostgreSQL / MySQL / MariaDB / SQLiteconnectors · parameters
postgres_cdcPostgreSQL logical replication (CDC)connectors · parameters
clickhouseClickHouseconnectors · parameters
mongodbMongoDBconnectors · parameters
kafkaApache Kafkaconnectors · parameters
amqpRabbitMQ (AMQP)connectors · parameters
natsNATS / JetStreamconnectors · parameters
mqttMQTTconnectors · parameters
redis_streamsRedis Streams (alias redis)connectors · parameters
awsAWS SQS / SNSconnectors · parameters
ibmmqIBM MQ (optional feature)connectors · parameters
zeromqZeroMQconnectors · parameters
httpHTTP client & serverconnectors · parameters
websocketWebSocketconnectors · parameters
grpcgRPCconnectors · parameters
fileFiles — CSV / JSON / JSONL / rawconnectors · parameters
object_storeCloud object storage (alias s3)parameters
sledEmbedded local storeendpoints
memoryIn-process topicendpoints
nullDiscards everything — {"null": null} as an output drains a sourceendpoints

Structural endpoints (fanout, switch, stream_buffer, request, reader, response, static, ref) are accepted here too and are documented under Middleware & structural endpoints.

A file/object_store source must repeat the compression/encryption its data was written with — neither is auto-detected, and a mismatch ends the route as completed having moved few or no messages rather than failing. Check the moved-message count.

Messages

A message is a payload plus optional string metadata headers and an optional message_id (a UUIDv7 is generated when omitted). A string payload is sent verbatim; any other JSON value is serialized to JSON bytes.

{
  "payload": {"id": 1, "sku": "A-100", "qty": 3},
  "metadata": {"kind": "order", "correlation_id": "abc-123"}
}

Conventional metadata keys: kind (message type, drives type-based routing), correlation_id and reply_to (request/reply). Keys starting with mqb.src. are reserved for provenance and are stripped on input.

Route options

Alongside input and output, a route accepts batch_size, concurrency, exit_on_empty (drain the source, then exit) and capture_last. Without exit_on_empty a route polls indefinitely until stop_route.

batch_size and concurrency are top-level start_route arguments, not fields inside route — the MCP server applies the tuned app defaults (1024 and 4) rather than the library’s conservative 1/1.

capture_last: N keeps the last N messages that flow through the route for route_messages to return. It is off by default: captured payloads are held in memory and handed to the model verbatim, which is exactly what “the rows never enter the context” otherwise avoids. Use it to sample a stream, not to move it.

Route status

route_status (and list_routes) report two rates, and they answer different questions:

FieldMeaning
messagesTotal messages the route has moved.
messages_per_secondInstantaneous rate, smoothed over ~0.5 s. Decays to ~0 within a second of a route going idle.
elapsed_sThe span over which those messages moved: route start → last message seen. Stops growing once the route goes idle.
average_messages_per_secondmessages / elapsed_s — the rate the route actually achieved.

For a route that is running now, read messages_per_second. For one that has finished — anything started with exit_on_empty — read average_messages_per_second: the instantaneous rate of a completed job is ~0 by the time any status call observes it, which says nothing about how fast it was. stop_route returns the same two fields as its parting summary.

elapsed_s and average_messages_per_second are null until the route has been sampled at least once (the sampler runs every 200 ms), so a job that finishes inside one tick reports no average.

Examples

Publish a batch to NATS JetStream:

{
  "publisher": {"nats": {"url": "nats://localhost:4222", "stream": "ORDERS", "subject": "ORDERS.new"}},
  "messages": [
    {"payload": {"id": 1, "sku": "A-100"}, "metadata": {"kind": "order"}},
    {"payload": {"id": 2, "sku": "B-200"}, "metadata": {"kind": "order"}}
  ]
}

Drain a Redis stream into Postgres, then exit:

{
  "name": "redis-to-postgres",
  "route": {
    "input":  {"redis_streams": {"url": "redis://localhost:6379", "stream": "events",
                                 "group": "g1", "read_from_start": true}},
    "output": {"sqlx": {"url": "postgres://user:pass@localhost:5432/db",
                        "table": "events", "auto_create_table": true}},
    "exit_on_empty": true
  }
}

Tail a live Kafka topic and keep the last 20 messages for inspection:

{
  "name": "orders-tail",
  "route": {
    "input":  {"kafka": {"url": "localhost:9092", "topic": "orders", "group_id": "agent"}},
    "output": {"null": null}
  },
  "capture_last": 20
}

Performance

The interface costs one round-trip, not a per-row tax: a route started through start_route moves data at the rate the copy CLI does, within run-to-run variance, and what separates them is a fixed ~55 ms of startup and completion polling.

MeasurementResult
Tool-call round-trip latency (1,000 calls)p50 0.065 ms · p95 0.097 ms · p99 0.184 ms
1M-row CSV → JSONL via start_route (client wall-clock, 3 runs)735,330 rows/s (1.360 s ±0.009)
Same job, server’s own average_messages_per_second768,555 rows/s
copy CLI baseline, same dataset (§6 untyped)766,283 rows/s (session range 766,283–788,022)
Agent tool traffic to move the whole dataset1,482 bytes (~370 tokens, 3 calls)
The same 116.3 MiB through a model’s context~30.5M tokens

Agent token cost is flat in the number of rows moved — three tool calls (start_route, one route_status, stop_route) totalling ~1.5 KB of JSON-RPC move 1M rows or 1,000 alike. Passing the same 116.3 MiB through a context window instead would cost ~30.5M tokens, which no context window holds at any price.

Methodology and the client used to measure it — a real MCP client over stdio, not an in-process harness — are in benches/etl/README.md, scenario 7. Engine-level tuning is on the Performance tuning page.

Registering with a client

mcp install writes the client config for you, registering the absolute path of the binary you just ran — so a target/debug build and an installed release binary each register themselves correctly.

# every client detected on this machine
mq-bridge-app mcp install

# a single client, project-scoped rather than global
mq-bridge-app mcp install --client cursor --local

# bake --report-to-ui into the registered command
mq-bridge-app mcp install --report-to-ui
CommandPurpose
mcp installRegister this binary. --client, --local, --report-to-ui, --print-config.
mcp uninstallRemove the registration. --client, --local.
mcp statusShow where it is registered and whether the path is still current.
ClientGlobal configProject config (--local)
claude (Claude Code)claude mcp add --scope user, else ~/.claude.json--scope project, else ./.mcp.json
claude-desktopclaude_desktop_config.jsonnot supported
cursor~/.cursor/mcp.json./.cursor/mcp.json

Where the client ships its own CLI (Claude Code) that CLI is driven, since it stays correct across config-schema changes; otherwise the entry is merged into the client’s JSON, leaving every other registered server untouched. Installing twice is a no-op.

With --client omitted, every client detected on the machine is configured. For anything not listed above, mcp install --print-config prints the snippet:

{
  "mcpServers": {
    "mq-bridge": {
      "command": "mq-bridge-app",
      "args": ["mcp", "--transport", "stdio"]
    }
  }
}

Use an absolute path to the binary if it is not on PATH. With --transport http, point your client at http://127.0.0.1:9092/ instead.

Restart the client fully after installing — reopening a tab is not enough.

Under stdio the client spawns the server process, so a rebuilt binary is only picked up after the client restarts the server — an edit to mcp.rs alone will not change the behaviour of an already-running session.

Errors

Invalid endpoints, duplicate route names, and unknown route names come back as JSON-RPC -32602 (invalid params); a connection failure at publish time reports the underlying transport error:

invalid publisher endpoint: IO error: Connection refused (os error 61)

A partially failed batch returns a result flagged is_error with a {"status": "Partial", "sent": N, "failed": M, "errors": [...]} summary, so a partial send is not mistaken for success.

Known limitations

This originates in the upstream mq-bridge crate, not in the MCP layer.

  • Finished routes are not reaped. A route started with exit_on_empty stays in list_routes after it has drained and exited; call stop_route to clear it. It is no longer indistinguishable from a running one, though: since mq-bridge 0.3.6 each entry carries "finished" plus an "outcome". While the route runs these are false / null; once its task ends, "finished": true and "outcome" is completed (drained cleanly) or failed (permanent error — the cause is in status.error).

    stop_route removes the entry from list_routes as it stops the route, so the third outcome, stopped, is not observable through these tools.

MCP Registry (server.json)

mcp install and the registry solve different problems, and neither needs the other:

mq-bridge-app mcp installMCP Registry
ForSomeone who already has the binarySomeone who has not heard of it yet
DoesWrites the client config directlyPublishes discovery metadata only
Needs server.jsonNoYes

So install remains the shortest path on a machine that already has the binary — it is not superseded by publishing to the registry.

server.json at the repository root is the registry entry. It publishes the server under io.github.marcomq/mq-bridge-app and offers two package types, both of which run the same mcp --transport=stdio command:

registryTypeIdentifierRequires
ocighcr.io/marcomq/mq-bridge-app:<version>Docker; no Rust toolchain
cargomq-bridge-appcargo install, so a Rust toolchain

The registry only stores metadata — it never hosts the artifact — so it verifies that each package really belongs to this project by looking for an ownership marker inside the published artifact itself:

  • OCI — a LABEL io.modelcontextprotocol.server.name in the Dockerfile final stage.
  • Cargo — an mcp-name: line in the crate README, which crates.io renders. It must be visible markdown: crates.io strips HTML comments during rendering, so the hidden <!-- mcp-name: … --> form that works for PyPI and NuGet is silently dropped here.

Both markers must equal the name field in server.json.

Publishing happens in the publish-mcp-registry job of the release workflow. It runs after the ghcr manifest and the crates.io release exist, rewrites the versions in server.json from the release tag, authenticates with mcp-publisher login github-oidc (the io.github.* namespace is proven by the workflow’s own OIDC token, so there is no secret to rotate) and publishes. To do it by hand:

brew install mcp-publisher
mcp-publisher login github
mcp-publisher publish

Testing against local brokers

The compose files in the mq-bridge repo bring up the brokers:

cd ../mq-bridge/tests/integration/docker-compose
docker compose -f nats.yml -f postgres.yml -f redis.yml up -d

Verified end-to-end against these: batch publish to file/NATS/Redis Streams, NATS → file and Redis Streams → Postgres routes (including auto_create_table), the full route lifecycle, and the error paths above.

Architecture Overview

mq-bridge is designed as a highly extensible, protocol-agnostic message integration layer for Rust. Its architecture enables seamless bridging between diverse messaging systems, databases, and protocols, while allowing users to inject custom business logic and reliability patterns.

Core Principles

  • Protocol Abstraction: All business logic operates on a unified CanonicalMessage type, decoupling your code from specific broker or database APIs.
  • Extensibility: New endpoints and middleware can be added with minimal effort via trait-based factories.
  • Async-First: Built on Tokio, all I/O and processing is asynchronous and concurrency-aware.
  • Unopinionated: The library does not enforce a specific domain or concurrency model, focusing instead on reliable, programmable data movement.

Main Components

1. Route

A Route defines a data pipeline from one input endpoint to one output endpoint. Each route can:

  • Specify concurrency and batch size
  • Attach middleware for reliability, deduplication, metrics, etc.
  • Attach a handler for business logic (transform, filter, respond)

2. Endpoint

Endpoints are protocol adapters for sources (consumers) and sinks (publishers). Supported types include Kafka, NATS, AMQP, MQTT, MongoDB, HTTP, SQLx, ZeroMQ, Files, AWS, IBM MQ, and the memory endpoint (in-process channels and cross-process IPC). Endpoints are created via factory functions and configured via serde (json/yml).

Beyond these protocol adapters there are structural endpoints that compose other endpoints or shape routing rather than talking to a broker: ref, fanout, switch, request, response, reader, static, stream_buffer, null and custom. All of them are documented in REFERENCE.md.

3. Middleware

Middleware wraps consumers and publishers to add cross-cutting features. There is no Middleware trait: a middleware is a decorator implementing MessageConsumer and/or MessagePublisher, which is why CustomMiddlewareFactory is defined as apply_consumer / apply_publisher. Available middleware includes:

  • Retries (exponential backoff) and dead-letter queues (DLQ)
  • JSON transformation (transform): mapping, type coercion, schema validation
  • Deduplication (sled-based), weak joins, buffering, rate limiting
  • Metrics, delays, fault injection
  • Custom user middleware

The complete list, with fields, defaults and the layer-ordering rules, is in REFERENCE.md.

4. Handler

Handlers are user-defined async functions that process messages. There are two main handler types:

  • CommandHandler: 1-to-1 or 1-to-0 transformation, can return a new message for publishing or as response.
  • EventHandler: 1-to-N handler for event consumption. Compatible to CommandHandler, but should not return a response.
  • TypeHandler: Strongly-typed handler, dispatches based on the kind metadata field and deserializes payloads.

Memory Endpoint and IPC Transport

The memory endpoint is not only an in-process channel. Its topic field (serde alias: url) doubles as a transport URL, so the same endpoint type covers both in-process queues and cross-process IPC over Unix domain sockets / Windows named pipes.

Transport URL schemes

URLResolves toPlatform
my-topicmemory://my-topic (no scheme = in-process, for backward compatibility)all
memory://my-topicIn-process channel, shared by namespace within the same processall
ipc://my-queueUnix: /run/mq-bridge/my-queue.sock
Windows: \\.\pipe\mq-bridge-my-queue
Unix + Windows
ipc:///var/run/my.sockThat exact socket path (leading / = absolute path, note the three slashes)Unix
unix:///var/run/my.sockThat exact socket path; the path must be absoluteUnix only
pipe://my-pipe\\.\pipe\my-pipe — used verbatim, no mq-bridge- prefixWindows only

Anything else (http://…, an empty URL, a relative unix://path) is rejected at parse time.

Named ipc:// resolution on Unix falls back in order, using the first writable location:

  1. /run/mq-bridge/<name>.sock (systemd standard)
  2. $XDG_RUNTIME_DIR/mq-bridge/<name>.sock
  3. /tmp/mq-bridge/<name>.sock (less secure)

Because of this fallback, ipc://name can land in different places for different users or services. When both sides must agree deterministically, use an explicit path (ipc:///run/myapp/queue.sock) instead of a bare name.

Roles: consumer is the server, publisher is the client

IPC is unidirectional, publisher → consumer, and the roles are fixed:

  • The consumer binds and listens on the socket/pipe (server). It removes a stale socket file before binding, creates the parent directory with mode 0700, and sets the socket to 0600. It unlinks the socket on drop.
  • The publisher connects to it (client).

So the consumer process must be running before the publisher connects — otherwise the publisher fails with a connection error. The consumer serves one connection at a time; if the peer disconnects, it logs a warning and waits for a new connection rather than erroring out.

IPC requires async construction

MemoryConsumer::new, MemoryPublisher::new, and new_local are synchronous and only support memory://. Given an IPC URL they return an error (“requires async endpoint construction”). Use the async constructors:

use mq_bridge::endpoints::memory::{MemoryConsumer, MemoryPublisher};
use mq_bridge::models::MemoryConfig;

let config = MemoryConfig::new_with_url("ipc:///run/mq-bridge/orders.sock", Some(100));

// Server side (start first)
let mut consumer = MemoryConsumer::new_async(&config).await?;
// Client side, in another process
let publisher = MemoryPublisher::new_async(&config).await?;

Routes always build endpoints through the async factories, so an ipc:// URL works in YAML/JSON config with no extra code:

ipc_ingest:
  input:
    memory:
      url: "ipc:///run/mq-bridge/orders.sock"
      capacity: 256
  output:
    kafka:
      topic: "orders"
      url: "localhost:9092"

Wire format

Batches are serialized with MessagePack (Vec<CanonicalMessage>) and written as length-prefixed frames: a 4-byte big-endian length followed by the payload. Frames larger than 100 MB are rejected.

Framing is handled by tokio_util::codec::LengthDelimitedCodec in endpoints/memory/framed.rs, shared by both platforms. This matters for more than deduplication: the codec owns the partial-frame buffer, which makes reads cancel safe. Routes cancel receive_batch on shutdown via select!, and a hand-rolled read_exact pair would consume part of a frame on cancellation and desync the connection permanently.

Backpressure

A batch is written as one frame. Socket buffers are small — 8 KiB by default on macOS (net.local.stream.sendspace) — so a batch that outgrows the buffer only completes once the consumer drains it. send_batch blocking is therefore normal backpressure, not a fault.

Two consequences worth knowing:

  • The consumer must actually be reading, not merely connected. A consumer that has accepted but stopped draining will stall the publisher indefinitely. After 5 seconds blocked, the publisher logs a warning naming the socket; it keeps waiting rather than dropping data.
  • capacity does not create a queue here. There is no buffering between the two processes beyond the kernel socket buffer.

Acknowledgements and redelivery over IPC

enable_nack defaults to true for IPC transports (ipc://, unix://, pipe://) and false for memory://; an explicit enable_nack in config always wins.

Redelivery over IPC is consumer-local. The socket carries publisher → consumer traffic only, so a nack cannot travel back to the producer — the publisher never reads, and writing to it would strand the messages and eventually block the commit on a full socket buffer. A nacked message is therefore requeued inside the consumer and redelivered ahead of new traffic. It does not survive a consumer crash, and the publisher is never told.

If you need redelivery that survives the consumer process, use a real broker endpoint. mq-bridge deliberately does not implement a bidirectional ack protocol over IPC.

Behavioural differences vs memory://

  • subscribe_mode is not supported over IPC, on either side — the EventStore/broadcast backend is in-process only.
  • request_reply is not supported for IPC publishers.
  • Only the publisher side may send. Calling send_batch on a consumer-side IPC transport returns an error rather than writing into a peer that never reads.
  • capacity bounds the consumer-side buffer, not an internal queue — a socket has no backlog of its own. len() reports whole frames already buffered by the codec (readable without touching the socket), and endpoint status().pending adds the consumer’s own buffered and awaiting-redelivery messages.

Batching and Concurrency

Batch processing is a core concept in mq-bridge and is required for all endpoint implementations. Every consumer and publisher must implement batch receive and batch send methods (receive_batch, send_batch).

Why batch mode?

  • Batching improves throughput and efficiency, especially for high-volume or high-latency backends.
  • It enables the bridge to process messages concurrently and in parallel, reducing per-message overhead.

Concurrency

  • Each route can be configured with a concurrency parameter, which determines how many worker tasks will process batches in parallel.
  • Batch size is also configurable per route.

Helper Utilities

mq-bridge provides several helper functions to make implementing batching easier, especially if your endpoint only supports single-message operations:

  • send_batch_helper: Calls send for each message in a batch and aggregates the results. Used to implement send_batch when only single-message sending is available.
  • receive_batch_helper: Calls receive once and wraps the result as a batch. Used to implement receive_batch when only single-message receive is available.
  • into_commit_func: Converts a batch commit function (BatchCommitFunc) into a single-message commit function (CommitFunc).
  • into_batch_commit_func: Converts a single-message commit function into a batch commit function.

Sample: Using send_batch_helper

use mq_bridge::traits::send_batch_helper;
// Inside your MessagePublisher implementation:
async fn send_batch(&self, messages: Vec<CanonicalMessage>) -> Result<SentBatch, PublisherError> {
    send_batch_helper(self, messages, |pub_ref, msg| {
        Box::pin(pub_ref.send(msg))
    }).await
}

Sample: Using receive_batch_helper

// receive_batch_helper is a default method on MessageConsumer — no import needed.
// Inside your MessageConsumer implementation:
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
    self.receive_batch_helper(max_messages).await
}

Sample: Commit function conversion

use mq_bridge::traits::{into_commit_func, into_batch_commit_func};
let commit: CommitFunc = into_commit_func(batch_commit_func);
let batch_commit: BatchCommitFunc = into_batch_commit_func(commit_func);

How batch receive works internally

The receive_batch method is designed to efficiently collect a batch of messages from the underlying transport. The typical pattern is:

  1. Wait for the first message: The consumer awaits a message from the backend (e.g., Kafka, NATS, etc.).
  2. Drain additional messages if available: After the first message is received, the consumer immediately checks if more messages are already available (without waiting). It continues to drain messages up to the batch size or until no more are available.
  3. Return the batch: The batch is returned as soon as either the batch size is reached or no more messages are immediately available.

This approach minimizes latency for the first message while maximizing throughput for bursts of messages.

Pseudocode:

async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
	let mut messages = Vec::with_capacity(max_messages);
	// Wait for the first message
	let first = self.inner_receive().await?;
	messages.push(first);
	// Try to drain more messages without waiting
	while messages.len() < max_messages {
		match self.try_receive_now()? {
			Some(msg) => messages.push(msg),
			None => break,
		}
	}
	Ok(ReceivedBatch { messages, commit: ... })
}

Example: Receiving and committing a batch

let batch = consumer.receive_batch(100).await?;
// Process each message in the batch...
batch.commit(vec![MessageDisposition::Ack; batch.messages.len()]).await?;

Example: Sending a batch

let messages = vec![msg1, msg2, msg3];
publisher.send_batch(messages).await?;

See the README and tests for more advanced batching and concurrency patterns.

Getting Started

Below are minimal examples for the three main usage patterns in mq-bridge. For more, see the README and tests.

1. Typed Handler (Event-driven, Type-safe)

Use for strongly-typed, event-driven communication. Register Rust types per message kind; the bridge deserializes payloads automatically. Supports request-response where the protocol allows. Multiple types can be handled by a single route.

use mq_bridge::{msg, Handled, Route, publisher::Publisher, models::Endpoint};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, Clone)]
struct OrderPlaced {
    order_id: u64,
    amount: f64,
}

let input = Endpoint::new_memory("in", 10);
let output = Endpoint::null();
let route = Route::new(input, output)
    .add_handler("order_placed", |msg: OrderPlaced| async move {
        println!("Order #{}: ${}", msg.order_id, msg.amount);
        Ok(Handled::Ack)
    });
route.deploy("typed_handler_example").await.unwrap(); // or use route.run()

let input_publisher = Publisher::new(route.input.clone()).await.unwrap();
let event = OrderPlaced { order_id: 42, amount: 19.99 };
input_publisher.send(msg!(&event, "order_placed")).await.unwrap();
// ...

2. Compute Handler (Generic)

Use a generic handler to process, transform, or filter messages against the raw CanonicalMessage. One handler per-route. Suitable for pipelines, ETL, or side-effect processing.

use mq_bridge::{CanonicalMessage, Handled, Route, models::Endpoint};

let input = Endpoint::new_memory("in", 10);
let output = Endpoint::new_memory("out", 10);
let handler = |mut msg: CanonicalMessage| {
    msg.set_payload_str(format!("processed: {}", msg.get_payload_str()));
    async move { Ok(Handled::Publish(msg)) }
};
let route = Route::new(input, output).with_handler(handler);
route.deploy("compute_handler_example").await.unwrap();
// ...

3. Direct Endpoint Usage (Manual Control)

Use send / send_batch and receive / receive_batch directly on endpoints. Gives full manual control over batching, commit, concurrency, and sequencing. Useful for advanced scenarios or integration with external async runtimes.

use mq_bridge::endpoints::memory::{MemoryConsumer, MemoryPublisher};
use mq_bridge::{CanonicalMessage, traits::MessageDisposition};

let publisher = MemoryPublisher::new_local("my_topic", 100);
let mut consumer = MemoryConsumer::new_local("my_topic", 100);
let msg = CanonicalMessage::new(b"hello world".to_vec(), None);
publisher.send(msg).await.unwrap();
let received = consumer.receive().await.unwrap();
// Process the message...
// Acknowledge (required for most endpoints):
(received.commit)(MessageDisposition::Ack).await.unwrap();

// Batch variant:
let batch = consumer.receive_batch(10).await.unwrap();
batch.commit(vec![MessageDisposition::Ack; batch.messages.len()]).await.unwrap();

Extending mq-bridge

  • Custom Endpoints: Implement the CustomEndpointFactory trait and register your type.
  • Custom Middleware: Implement the CustomMiddlewareFactory trait.
  • Typed Handlers: Use TypeHandler to add new message types and logic.

Configuration

  • All routes, endpoints, and middleware are defined via YAML, JSON, or environment variables.
  • See CONFIGURATION.md for a full reference and examples.

Example: Route Lifecycle

  1. Define a route either as json or code
  2. Create endpoints and apply middleware
  3. Attach a handler (optional)
  4. Deploy or run the route (spawns async workers)
  5. Inject or receive messages
  6. Route processes, transforms, and delivers messages according to config and handler logic

More Information

  • See the README for usage patterns and code examples.
  • See CONFIGURATION.md for configuration details.
  • See the source code for trait definitions and extension points.

How the app is built

mq-bridge-app is a demonstration of the mq-bridge engine used on itself: the application serves its own management UI through the engine rather than through a separate web framework. If you want to understand the engine’s request-reply model in practice, this is a worked example. For the engine internals themselves, see Learn the architecture.

Backend: mq-bridge as a web server

Instead of using a traditional web framework like Actix or Axum directly for the management API, the application routes HTTP through the engine:

  1. HTTP input — an http input endpoint listens on the configured UI port and converts incoming HTTP requests into CanonicalMessages.
  2. WebUiHandler — a custom Handler processes these messages as a router: serving static files (HTML, JS) or handling API requests (e.g. /config, /schema.json).
  3. Response output — the handler returns a response message, sent to a response output endpoint, completing the HTTP request-response cycle.

This showcases the library’s ability to handle request-reply patterns and act as a lightweight web server.

Frontend: vanilla-schema-forms

The Web UI is generated dynamically from the Rust configuration structures — no hand-written form code:

  1. Schema generation — the backend uses schemars to generate a JSON Schema for the AppConfig struct at runtime, exposed via /schema.json (also available on the CLI: mq-bridge-app --schema dev/config/schema.json).
  2. Dynamic form — the frontend uses vanilla-schema-forms to render a complete configuration form from that schema alone.
  3. No UI code changes — when a new feature or config option is added to the Rust code (e.g. a new middleware), the schema updates automatically and the UI reflects it without any frontend changes.

Project status

This project is in active development. It originally served as the primary reference implementation and testbed for the mq-bridge engine.

The UI/Tauri layer was largely prototyped quickly and does not mirror the mq-bridge / mq-bridge-app core/CLI standards — treat it as a working demo, not a reference implementation, and test before relying on it in production.

Core concepts

Four terms recur throughout this book.

Route

A route is a named data pipeline that defines a flow from one input to one output. It carries optional middlewares on either side, a handler, and the tuning knobs batch_size, concurrency, and commit_concurrency_limit. In config, the top-level keys are route names:

orders_bridge:                 # <- route name
  input:  { kafka: { topic: "orders", url: "localhost:9092" } }
  output: { nats:  { subject: "orders.processed", url: "nats://localhost:4222" } }

Endpoint

An endpoint is a source or sink for messages, written as a single-key object naming the connector. Transport endpoints talk to a broker or store (kafka, nats, sqlx, mongodb, file, memory, …); structural endpoints (fanout, switch, response, ref, null, …) compose other endpoints or shape routing. The same endpoint type can act as consumer or publisher depending on which side of the route it is on. See the full catalog in Endpoints (connectors).

Middleware

Middleware intercepts and processes messages around an endpoint — retries, dead-letter queues, transformation, deduplication, rate limiting, encryption, metrics. It attaches as a middlewares: list on the input, output, or both. Ordering matters: on an output the last entry is the outermost layer, so dlq goes last. See Middleware.

Handler

A handler is a programmatic component for business logic, used when you embed the library (config-only routes forward messages without one). There are three kinds:

  • CommandHandler — a 1-to-1 or 1-to-0 transformation. It takes a message and can return a new message to publish or return as a response.
  • EventHandler — a terminal 1-to-N handler for event consumption. It reads messages without removing them for other event handlers, and should not return a response.
  • TypeHandler — a strongly-typed handler. It dispatches on the kind metadata field and deserializes payloads into concrete Rust types, so handlers don’t repeat parsing code.
let typed_handler = TypeHandler::new()
    .add("create_user", |cmd: CreateUser| async move {
        // handle create_user; Ok(()) maps to Handled::Ack
    })
    .add("delete_user", |cmd: DeleteUser| async move {
        // handle delete_user
    });
let route = Route::new(input, output).with_handler(typed_handler);

Handlers return a Handled value: Handled::Ack (consume), Handled::Publish(msg) (forward msg down the publisher chain), and so on. Message selection for TypeHandler is driven by the kind metadata field, which msg!(&value, "kind") sets. See the Embed the library tutorial and Language bindings API.

CanonicalMessage

Every handler works with a CanonicalMessage — the unified message type that decouples your code from any specific transport. It carries a payload (bytes), string metadata headers, and a message_id (a UUIDv7 when omitted). Conventional metadata keys: kind (message type / typed routing), correlation_id and reply_to (request/reply). Keys starting with mqb.src. are reserved for provenance and stripped on input.

Delivery model

Endpoints default to a consumer (queue) pattern; subscriber (pub/sub) behaviour is opt-in per backend. Delivery is at-least-once where a durable position or lease exists — so for ETL, pair it with an idempotent write at the sink for effective exactly-once. Ack/nack and retry/DLQ handling are designed to work with batching; see Learn the architecture.

Build from source

Developer build instructions for mq-bridge-app — the CLI/server, the desktop (Tauri) app, and the Docker image. For prebuilt installs (cargo binstall, release bundles, Docker Hub images) see the main README.

Prerequisites

  • Rust toolchain (latest stable version recommended)
  • Access to the message brokers you want to connect (e.g. Kafka, NATS, RabbitMQ)

CLI / server

  1. Clone the repository:

    git clone https://github.com/marcomq/mq-bridge-app
    cd mq-bridge-app
    
  2. Build and run the CLI (empty):

    cargo run --release
    
  3. Build and run with a config:

    cargo run --release -- --config dev/config/file-to-http.yml
    

Create a config.yml in the project root or set environment variables — or start without one and use the UI to define config.yml.

For IBM MQ, install the client library first and build with --features=ibm-mq. See the IBM MQ Setup Guide.

Desktop app (Tauri)

The desktop UI is a Svelte frontend driven by a Tauri backend:

npm install
npm run dev

To build the UI bundle and serve it from the Rust backend:

npm run build:ui
cargo run --release

Docker image (no local Rust required)

Requires Docker and Docker Compose:

docker-compose up --build

This builds and starts the bridge CLI application.

Postgres CDC → JSONL

Stream every insert/update/delete from a PostgreSQL table into a newline-delimited JSON file, continuously. This is change-data-capture (CDC): instead of reading a table snapshot, mq-bridge follows the write-ahead log via logical replication, so new changes keep flowing until you stop it.

Prerequisites

  • PostgreSQL with logical replication enabled (wal_level = logical).

  • A publication on the source table:

    CREATE PUBLICATION mqb_pub FOR TABLE orders;
    

    publication must already exist; slot_name is created automatically if missing (it is a permanent, resumable replication slot).

The one-liner

mq-bridge-app copy \
  --from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
  --to file:///data/orders.jsonl?format=json

--from uses the postgres-cdc:// scheme (alias pgcdc://); the URL underneath is a plain Postgres URL. --to writes each change as one JSON object per line (format=json); the path comes from the URI path itself, not a query param.

Each change arrives as a CanonicalMessage whose payload is the flat row and whose postgres.operation metadata marks the operation (insert/update/delete) — the same convention as MongoDB CDC, so typed handlers work identically across both.

Equivalent config file

The same thing as a route in mq-bridge.yaml, for the config-driven run forms:

orders_cdc:
  input:
    postgres_cdc:
      url: "postgres://user:pass@localhost:5432/app"
      publication: "mqb_pub"     # CREATE PUBLICATION mqb_pub FOR TABLE orders;
      slot_name: "mqb_slot"      # created if missing (permanent slot, resumable)
  output:
    file:
      path: "/data/orders.jsonl"
      format: "json"

Notes

  • Resumability. The named replication slot persists progress on the server, so a restart resumes from the last confirmed LSN rather than re-copying from the start.
  • This runs forever. CDC is a continuous stream; the process stays up tailing new changes. For a one-shot bounded copy of an existing table, use a plain postgres://…?table=… source instead (see Quick start).
  • Performance. For sustained CDC/ETL throughput and how batching interacts with file sinks, see Performance tuning.

See also

HTTP webhook → MongoDB

Accept incoming HTTP webhooks on a local port and persist each request body as a document in MongoDB, with automatic retries on transient write failures. This is a config-driven route rather than a one-liner, because it wires middleware into the pipeline.

The route

mq-bridge.yaml:

webhook_to_mongo:
  input:
    http:
      url: "127.0.0.1:8080"
      # Force the normal route pipeline instead of the inline HTTP response fast path.
      inline_response_fast_path: false
    middlewares:
      - retry:
          max_attempts: 3
          initial_interval_ms: 500
  output:
    mongodb:
      url: "mongodb://localhost:27017"
      database: "app_db"
      collection: "webhooks"
      format: "json"   # a bit slower, but stores readable documents

Run it in config mode:

mq-bridge-app --config mq-bridge.yaml

Then POST to it:

curl -X POST http://localhost:8080 \
  -H 'content-type: application/json' \
  -d '{"event":"signup","user":"alice"}'

The document lands in app_db.webhooks.

What each piece does

  • http input listens on 127.0.0.1:8080, i.e. localhost only. The endpoint is unauthenticated, so bind it to 0.0.0.0 only behind an authenticating proxy or a network you control. Setting inline_response_fast_path: false forces requests through the full route pipeline (input → middleware → output) instead of the fast inline-response path, so the retry middleware and the MongoDB write both run before the HTTP response is sent.
  • retry middleware re-attempts a failed write up to max_attempts times, starting at initial_interval_ms and backing off. See the Retries & backoff recipe for the full knob set and its interaction with a dead-letter queue.

Note

Delivery here is at-least-once: a write that actually succeeded but whose acknowledgement was lost gets retried and inserts a second document. For an idempotent sink, set id_field on the mongodb output to a stable business key so the value becomes the document _id: a retry of an already-written message is then rejected by the unique _id instead of appending a new document — see Upserts.

  • mongodb output with format: "json" stores each payload as a readable JSON document rather than the default full-message serialization.

Variations

  • Want a synchronous reply to the caller? Attach a handler and use a response output — see the Request / reply tutorial.
  • High request rates? The http input scales with connection concurrency; see Performance tuning.

See also

Cross-process IPC bridge

The memory endpoint is more than an in-process channel: its url (alias topic) is a transport URL, so the same endpoint can bridge separate processes on one machine over a Unix socket or Windows named pipe — no broker required.

Transport URLs

The url field selects the transport:

URLMeaning
"name"memory://name — in-process, same process only
"memory://name"in-process channel
"ipc://name"Unix: /run/mq-bridge/name.sock (falls back to $XDG_RUNTIME_DIR/mq-bridge, then /tmp/mq-bridge). Windows: \\.\pipe\mq-bridge-name
"ipc:///abs/path.sock"that exact socket path (Unix)
"unix:///abs/path.sock"Unix only, path must be absolute
"pipe://name"Windows only, \\.\pipe\name

The consumer side binds/listens and must be started before the publisher connects. IPC does not support subscribe_mode or request_reply. enable_nack defaults to true, but redelivery is consumer-local: a nacked message is retried inside the consumer and is lost if the consumer process dies.

Example: ingest process → Kafka

One process reads from a Unix socket and forwards to Kafka. Start this consumer first so the socket exists before any producer connects:

ipc_ingest:
  input:
    memory:
      url: "ipc://orders"
      capacity: 256
  output:
    kafka:
      topic: "orders"
      url: "localhost:9092"
mq-bridge-app --config ipc_ingest.yaml

The named form is used rather than an explicit path so no privileged directory has to exist up front: /run/mq-bridge/ needs root, and ipc://orders falls back to $XDG_RUNTIME_DIR/mq-bridge or /tmp/mq-bridge. Give an absolute ipc:///abs/path.sock only once you have created and permissioned that directory.

Any other process on the same host can now publish into ipc://orders — via its own mq-bridge route, or programmatically through a Publisher built on a memory endpoint (see the embedding tutorial) — and the messages flow through to Kafka.

When to use it

  • Decouple a producer and a sink into separate processes without standing up a broker — e.g. an app writes to the socket, a long-lived bridge process owns the Kafka/DB connections and batching.
  • Language boundary. A Python or Node process publishes into the socket; a Rust bridge process does the heavy I/O.

capacity bounds the in-flight channel; tune it alongside the guidance in Performance tuning.

See also

Request / reply

mq-bridge supports request-response patterns for interactive services such as web APIs. A client sends a request and waits for the matching response, while the bridge keeps the correlation details away from your handler.

The response output is the most direct option and the safest one under concurrency: the response stays in the same execution context as the request, so concurrent requests do not share a reply queue or race on correlation IDs.

How it works

  1. An input endpoint that supports request-reply (like http) receives a request.
  2. The message passes through the route’s processing chain — this is where you attach a handler to process the request and generate a response payload.
  3. The final message is sent to the output.
  4. If the output is response: {}, the bridge sends the message back to the original input source, which delivers it as the reply (e.g. the HTTP response body).

Not every backend can do request-reply. It is only supported/tested for endpoints that natively support or emulate it. SQLx, files, AWS, IBM MQ, and Sled do not support request-reply. Check the backend table in the engine README.

Example: MongoDB request/response

A sender writes a request document to MongoDB and waits for a reply. The bridge reads the document, runs the handler, and writes the result back to the reply collection:

mongo_responder:
  input:
    mongodb:
      url: "mongodb://localhost:27017"
      database: "app_db"
      collection: "requests"
  output:
    # 'response' sends the processed message back to the reply collection
    # (whatever reply_to the sender set).
    response: {}

Attaching the handler (Rust)

The response output only echoes what the pipeline produced — you supply the logic by attaching a handler to the route:

use mq_bridge::models::Handled;
use mq_bridge::CanonicalMessage;

let handler = |mut msg: CanonicalMessage| async move {
    let request_body = String::from_utf8_lossy(&msg.payload);
    let response_body = format!("Handled response for: {}", request_body);
    msg.payload = response_body.into();
    Ok(Handled::Publish(msg))
};
// load the route from YAML, then attach `handler` to its output and run it.

See Embed the library for the full load-and-run scaffolding, and Core concepts for the handler model (CommandHandler / EventHandler / TypeHandler).

See also

Embed the library (Rust / Python / Node)

The core engine is a Rust library, and the same engine ships as native bindings for Python and Node.js. The Tokio runtime, broker I/O, routing, and batching all stay in Rust; the binding is a thin layer for handlers and configuration. Behaviour and reliability match the Rust engine regardless of which language calls in.

LanguagePackageInstall
Rustmq-bridgecargo add mq-bridge
Pythonmq-bridge-pypip install mq-bridge-py
Node.jsmq-bridgenpm install mq-bridge

The config-first workflow

The natural way to embed the library is to design the route as config, then load that exact config from your code. Design and test a route in the desktop UI (or hand-write the YAML), export the JSON/YAML, and load it — the running route behaves identically to the --config CLI form.

Constructor names are kept aligned across languages (Python snake_case, Node camelCase):

PurposePythonNode.js
Load a route from a YAML/JSON fileRoute.from_fileRoute.fromFile
Load from an in-memory YAML/JSON stringRoute.from_strRoute.fromStr
Load from a parsed dict / JS objectRoute.from_configRoute.fromConfig
Build a publisher endpointthe matching Publisher.*the matching Publisher.*

The name argument is optional: pass it to select one entry from a routes:/publishers: document, or omit it to treat the config as a single bare route/endpoint body.

Rust

The Rust crate exposes the full engine. The main types:

  • Route::new(input, output) — a pipeline from one endpoint to one endpoint, with .with_batch_size(n), .with_handler(h), .add_handler(kind, f), .deploy(name) / .run().
  • Endpoint — protocol adapters (Endpoint::new_memory, Endpoint::null, …) plus the serde-configured variants.
  • Publisher::new(endpoint) — publish into a route’s input or any endpoint.
  • Handlers: CommandHandler (1-to-1 / 1-to-0), EventHandler (terminal 1-to-N), and TypeHandler (dispatches on the kind metadata field, deserializing payloads).
  • CanonicalMessage — the unified message type all handlers work with; msg!(&value, "kind") builds one with a kind.

The MessageConsumer and MessagePublisher traits (in mq_bridge::traits) are the core abstractions. See the Rust docs on docs.rs for the full API.

See also

Upserts & insert-if-absent

For ETL, at-least-once delivery plus an idempotent write at the sink gives you effective exactly-once: a replayed or retried record must not create a duplicate row. The most robust place to enforce this is the sink’s own unique constraint — it’s already shared across every writer, so no extra state store is needed.

MongoDB — id_field

Point id_field at a top-level payload field and its value becomes the document _id. Re-inserting the same business key then hits the unique _id index and is treated as an idempotent success (the duplicate is skipped, not errored):

orders_to_mongo:
  input:  { kafka: { topic: "orders", url: "localhost:9092" } }
  output:
    mongodb:
      url: "mongodb://localhost:27017"
      database: "shop"
      collection: "orders"
      format: json
      id_field: "order_id"   # payload {"order_id": "A-1", ...} → _id = "A-1"

The field’s JSON type is preserved (a number stays a BSON integer). The payload must contain the field, otherwise the message is dead-lettered rather than written with a random _id. Use id_field on sink collections only — a business-key _id is incompatible with the consumer/subscriber competing-consumer modes, which require a UUID _id.

SQL (sqlx) — ON CONFLICT / ON DUPLICATE KEY

insert_query is user-supplied, so you write the dialect’s upsert directly. This requires a pre-existing UNIQUE/PRIMARY KEY on the key column, and is incompatible with bulk_copy (COPY cannot express ON CONFLICT) — so you trade peak throughput for deduplication.

-- PostgreSQL — insert if absent (drop duplicates):
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING

-- PostgreSQL — upsert (last write wins):
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body})
  ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body

-- MySQL / MariaDB:
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body})
  ON DUPLICATE KEY UPDATE body = VALUES(body)

-- SQLite:
INSERT INTO orders (id, body) VALUES (${payload:id}, ${payload:body}) ON CONFLICT (id) DO NOTHING

A plain INSERT without a conflict clause instead fails the row as a non-retryable error (captured by a dlq, or logged and dropped without one). ${payload:field} binds a typed value from the JSON payload; ${metadata:key} binds a metadata string.

ClickHouse — ReplacingMergeTree

ClickHouse has no unique constraints; dedup is a table-engine property. Create the target as ReplacingMergeTree(version) keyed by your business key via ORDER BY, using a monotonic column as the version (an ingest timestamp, or postgres.lsn from a CDC source):

CREATE TABLE orders (id UInt64, body String, version UInt64)
ENGINE = ReplacingMergeTree(version) ORDER BY id;
-- mq-bridge just inserts rows; duplicates for the same id collapse on merge.
SELECT * FROM orders FINAL;

Read with FINAL (or argMax) to see the deduplicated result. ClickHouse also natively dedupes identical re-inserted blocks (default one-hour window) — treat that only as retry-safety, and rely on ReplacingMergeTree for logical dedup.

Branch on insert vs. duplicate (MongoDB report_outcome)

To act on whether a record was newly inserted or already existed, set report_outcome: true; the Mongo publisher tags the returned message with mongodb.outcome = inserted or existed. Wrap it in a request endpoint to forward that tagged message into a switch that routes on the outcome:

orders_upsert_branch:
  input: { kafka: { topic: "orders", url: "localhost:9092" } }
  output:
    request:
      to:
        mongodb:
          url: "mongodb://localhost:27017"
          database: "shop"
          collection: "orders"
          format: json
          id_field: "order_id"     # deterministic _id → insert-if-absent
          report_outcome: true     # → mongodb.outcome = inserted | existed
      forward_to:
        switch:
          metadata_key: "mongodb.outcome"
          cases:
            inserted: { ref: "enrich_new_order" }
            existed:  { ref: "handle_duplicate" }

report_outcome is sink-only and pairs with id_field. See also Deduplication for the middleware-based complement, and the Postgres CDC tutorial for CDC-specific idempotency (postgres.lsn as the version to drop stale replays).

Dead-letter queues

By default, a message that fails permanently is logged at error level and dropped — the route tolerates it and keeps going. A dlq middleware is the only mechanism that retains those failures for inspection or replay.

Basic DLQ

Add dlq to the output middleware list. The DLQ endpoint is a full endpoint, so failed messages can land anywhere — a file, another queue, a database:

output:
  middlewares:
    - dlq:
        endpoint:
          file: { path: "dead-letters.jsonl" }
  kafka: { topic: "orders", url: "localhost:9092" }

From the copy CLI, dlq’s endpoint is a URL-encoded endpoint URI:

--to 'kafka://broker:9092?topic=orders|dlq?endpoint=file%3A%2F%2F%2Ftmp%2Ffailed.jsonl'

Pair it with retry — and mind the order

retry alone does not retain a failed message; once its attempts are exhausted it hands the still-failing message on to be dropped (or to a following dlq). Put retry before dlq so the dlq is the outermost layer and captures what retry gives up on:

output:
  middlewares:
    - retry: { max_attempts: 3 }
    - dlq: { endpoint: { file: { path: "rejected.jsonl" } } }   # last = outermost

On an output, publisher middlewares wrap in list order, so the last entry is outermost. See the ordering rule.

What gets dead-lettered

  • NonRetryable failures (data/type errors the sink rejects, poison payloads).
  • Retryable failures whose retries are exhausted.
  • Connection errors are not dead-lettered — they propagate so the route can reconnect. (If the DLQ send itself fails with a connection error, that error propagates rather than silently dropping the message.)

Catching transform rejections

A transform failure on an output is non-retryable and flows to a following dlq, so invalid records are captured with the reason:

output:
  middlewares:
    - transform: { schema_file: "schemas/user.json" }
    - dlq: { endpoint: { file: { path: "rejected.jsonl" } } }
  kafka: { topic: "users", url: "localhost:9092" }

No DLQ? Watch the logs

Without a dlq, a systematic failure (e.g. every row hitting a column-type mismatch) drains the input while committing nothing and still ends completed. The signal is a burst of Dropping message … due to non-retryable error in the logs. See Troubleshooting.

Deduplication

Two complementary ways to keep replayed or retried records from creating duplicates: the deduplication middleware (filters before the sink), and the sink’s own unique constraint (the robust choice for multi-writer ETL, covered in Upserts & insert-if-absent).

The deduplication middleware

Drops messages whose ID was already seen within a TTL. Input only. Requires the dedup feature (pulls sled):

input:
  middlewares:
    - deduplication: { sled_path: "/var/lib/mq-bridge/dedup", ttl_seconds: 3600 }
  kafka: { topic: "orders", url: "localhost:9092" }

State is a local sled database, so deduplication is per-process, not cluster-wide. For multi-writer pipelines prefer the sink constraint (below); use the middleware as a complement, not a replacement.

Sink-side dedup (the robust path)

The most robust place to dedup is the sink’s own unique constraint — it’s already shared across every writer:

  • MongoDBid_field maps a business key to the unique _id; a duplicate is an idempotent skip.
  • SQLON CONFLICT (key) DO NOTHING / ON DUPLICATE KEY UPDATE.
  • ClickHouseReplacingMergeTree(version) collapses duplicates by sort key at merge time.

Full examples in Upserts & insert-if-absent.

Deduplicating CDC replays

A postgres_cdc change event’s message_id is a stable hash of schema.table + key + lsn, so a replayed change deduplicates through the deduplication middleware, and the sink’s own constraint (id_field / ON CONFLICT) makes the write idempotent. Use postgres.lsn as the version to drop stale replays:

INSERT INTO orders (id, body, lsn) VALUES (${payload:id}, ${payload:body}, ${metadata:postgres.lsn})
  ON CONFLICT (id) DO UPDATE SET body = EXCLUDED.body, lsn = EXCLUDED.lsn
  WHERE EXCLUDED.lsn > orders.lsn

Known edge: if the same primary key changes twice within one transaction, both events share that transaction’s commit LSN and therefore the same message_id — the middleware treats the second as a duplicate and drops it. The sink still converges to the final row, but the intermediate revision is not delivered. If you need every intra-txn revision, don’t rely on the message_id/middleware path for those rows.

See the Postgres CDC → JSONL tutorial for the full CDC idempotency picture.

Retries & backoff

The retry middleware retries failed sends with exponential backoff. Output only.

output:
  middlewares:
    - retry: { max_attempts: 5, initial_interval_ms: 200, max_interval_ms: 10000, multiplier: 2.0 }
  kafka: { topic: "orders", url: "localhost:9092" }

From the copy CLI:

Attach the chain to --to, since retry is output-only:

--to 'kafka://localhost:9092?topic=orders|retry?max_attempts=5&initial_interval_ms=200'
FieldDefault
max_attempts3
initial_interval_ms100
max_interval_ms5000
multiplier2.0

What gets retried

Only Retryable and connection errors are retried; NonRetryable failures pass straight through. Once attempts are exhausted, the error is marked so a following dlq treats it as permanent.

Always pair retry with dlq

retry does not retain a message it gives up on — it hands the still-failing message on to be dropped, or to a following dlq. Put dlq after retry so it’s the outermost layer and captures the exhausted failures:

output:
  middlewares:
    - retry: { max_attempts: 3 }
    - dlq: { endpoint: { file: { path: "rejected.jsonl" } } }

Don’t over-tune

Several endpoints already retry connection/timeout errors internally, and the retry middleware adds backoff on top — so you rarely need a large max_attempts. Cap max_interval_ms to match your latency budget so retries never stall a route indefinitely. See Performance tuning → Retry & backoff.

Transform & schema mapping

The transform middleware reshapes JSON payloads declaratively, so renaming fields and fixing types doesn’t need a custom handler. It runs two optional stages over a single parse: mapping (rename, move, nest) then schema (coerce, apply defaults, validate). Input and output.

Rename + type-fix a CSV feed

csv_to_kafka:
  input:
    file: { path: "users.csv", format: csv }
  output:
    middlewares:
      - transform:
          mapping:
            firstName: "$.first_name"
            lastName: "$.last_name"
            id: "$.user_id"
            "address.city": { path: "$.city", default: "unknown" }
          schema_file: "schemas/user.json"
      - dlq:
          endpoint: { file: { path: "rejected.jsonl" } }
    kafka: { topic: "users", url: "localhost:9092" }

With the CSV producing {"first_name":"John","last_name":"Smith","user_id":"42"}, the mapping yields {"firstName":"John","lastName":"Smith","id":"42","address":{"city":"unknown"}} ($.city is absent, so address.city falls back to its default) and the schema then coerces id to the integer 42.

  • Paths accept $.field, $.a.b, $.items[0] (the $. prefix is optional). Dots in the output key nest the result. An absent optional source field is omitted, not emitted as null.
  • Coercions are the lossless ones only: string → integer, string → number, string → boolean, number → string.

Decode an embedded JSON string

A field carrying a JSON document as a string is decoded via contentMediaType (JSON Schema 2020-12) — opt-in per field, never done by plain coercion:

- transform:
    schema:
      type: object
      properties:
        payload:
          type: string
          contentMediaType: application/json
          contentSchema:
            type: object
            properties:
              qty: { type: integer }

The string is replaced by the parsed document, and contentSchema (if given) is applied with the same coercion/defaults/validation — so an inner qty: "7" arrives as 7.

Handling failures

Failures are non-retryable and name the field, e.g. transform failed at $.items[1].qty [coercion]: cannot coerce string "oops" to integer.

  • On an output, the message is failed so a following dlq captures it.
  • On an input, it’s dropped from the batch and acknowledged, keeping invalid data out.
  • on_error: pass_through instead forwards the original payload with the reason in the mqb.transform_error metadata key — which a switch can route on.

Full option list, the supported schema subset, and the exact coercion rules are in the middleware reference → transform.

Content-based routing (switch)

The switch structural endpoint picks a destination by the value of a metadata key. Output only.

output:
  switch:
    metadata_key: "country_code"
    cases:
      US: { kafka: { topic: "us_orders", url: "kafka-us:9092" } }
      EU: { nats:  { subject: "eu_orders", url: "nats-eu:4222" } }
    default: { file: { path: "/var/data/unroutable_orders.log" } }

A message whose key is missing or unmatched goes to default; without a default it is dropped.

Route on the payload, not just metadata

switch matches on metadata, not payload fields. To route on payload content, first promote the value into metadata. Two common ways:

  • An endpoint that already emits a status key — e.g. http_status_code from an HTTP request, or mongodb.outcome from a Mongo upsert (see Upserts).
  • transform with on_error: pass_through, which sets mqb.transform_error on failed records so you can shunt them aside:
output:
  middlewares:
    - transform: { schema_file: "schemas/order.json", on_error: pass_through }
  switch:
    metadata_key: "mqb.transform_error"
    cases: {}                                  # (no exact-match cases)
    default: { kafka: { topic: "orders", url: "localhost:9092" } }

Split HTTP responses by status

Pair switch with request, which forwards a response (or, on error/timeout, the original message) tagged with a status key:

output:
  request:
    to: { http: { url: "https://api.internal/score" } }
    forward_to:
      switch:
        metadata_key: "http_status_code"
        cases:
          "200": { nats: { subject: "ok", url: "nats://localhost:4222" } }
          "404": { file: { path: "not-found.jsonl" } }
        default: { file: { path: "other.jsonl" } }

See also Fan-out to send to all destinations instead of picking one.

Fan-out

The fanout structural endpoint publishes each message to every listed endpoint. Output only. The value is a plain list — each branch may have its own middleware and may itself be structural.

output:
  fanout:
    - kafka: { topic: "audit", url: "localhost:9092" }
    - file: { path: "audit.jsonl" }
    - nats: { subject: "audit", url: "nats://localhost:4222" }

All branches receive the same message. Use it to mirror a stream to an archive while it also flows to its primary sink, to tee traffic to an audit log, or to emulate a static subscriber set (one branch per subscriber).

Fan-out vs. switch

  • fanoutevery destination gets the message.
  • switchone destination, chosen by a metadata key.

Give one branch its own reliability

Because each branch is a full endpoint, you can wrap just the fragile one with retry/dlq while the others stay plain:

output:
  fanout:
    - kafka: { topic: "orders", url: "localhost:9092" }
    - middlewares:
        - retry: { max_attempts: 5 }
        - dlq: { endpoint: { file: { path: "audit-failed.jsonl" } } }
      http: { url: "https://audit.internal/ingest", method: "POST" }

Weak join / correlation

The weak_join middleware correlates messages by a metadata key and emits them as one joined message. Input only. It has two modes.

Count mode

Wait for any N messages sharing a correlation key, then emit them as a JSON array:

input:
  middlewares:
    - weak_join: { group_by: "correlation_id", expected_count: 3, timeout_ms: 5000 }
  kafka: { topic: "fragments", url: "localhost:9092" }

Branch mode

Set branch_by to wait for named branches (e.g. one message from inventory and one from pricing), then emit a branch-keyed JSON object. required overrides expected_count:

- weak_join:
    group_by: "correlation_id"
    expected_count: 2
    timeout_ms: 5000
    branch_by: "source"
    required: ["inventory", "pricing"]
    on_timeout: discard

From the copy CLI, object/array fields take a JSON literal:

--from '...|weak-join?group_by=cid&expected_count=2&timeout_ms=1000&required=["inventory","pricing"]'

On timeout

An incomplete group is either emitted partially (on_timeout: fire, the default) or dropped (on_timeout: discard).

Durability caveat. Messages are acknowledged on receipt, so a crash before the group completes loses the buffered membersweak_join correlates in memory and is not a durable join. For correlation that must survive a restart, land the fragments in a store and join there.

Full field list in the middleware reference → weak_join.

Encryption at rest

Two ways to encrypt data, depending on whether you want it encrypted in transit between endpoints or at rest in a file / object.

The encryption middleware — payloads in transit

The encryption middleware seals each message payload into a self-describing AEAD envelope on the output side and decrypts it on the input side. Metadata and routing keys stay in the clear. Requires the encryption feature.

output:
  middlewares:
    - encryption: { key: "${env:MQB_ENC_KEY}" }
  nats: { subject: "secure.orders", url: "nats://localhost:4222" }

The key is a base64-encoded 32-byte key; ${env:VAR} reads it from the environment (see Secrets & interpolation). Cipher defaults to xchacha20poly1305 (aes256gcm also available).

Key rotation: seal with a new key_id/key while listing the old key under decrypt_keys on the consuming side. Each payload is authenticated independently — tampering, a torn frame, or a wrong/missing key is a hard consumer error, not a silent drop.

The AEAD binds only the payload (empty associated data), so a sealed payload can be replayed under different metadata. Use deduplication or a sink uniqueness constraint if that matters.

File / object encryption at rest

To store data compressed and encrypted at rest, use the file / object_store endpoints’ own compression + encryption fields — they apply compress-then-encrypt per batch, which is what you want (ciphertext does not compress):

output:
  file:
    path: "data.enc"
    format: raw
    compression: lz4          # none | gzip | lz4 | zstd  (`compression` feature)
    encryption: { key: "${env:MQB_ENC_KEY}" }

Do not stack the encryption middleware on top of a sink’s batch compression on the same route — ciphertext won’t compress. Use the endpoint fields above instead.

An encrypted file is written as length-prefixed sealed frames (one per batch) and is only readable through a matching consumer. object_store derives its object extension from these fields (e.g. .jsonl.gz / .jsonl.lz4, plus a trailing .enc when encryption is on).

Reading it back

A file source must declare the same compression/encryption the data was written with. A mismatch (wrong key, wrong codec, or a missing field) is a permanent decode failure — the route ends failed with the error in its status, rather than completing as if the file were empty. See Troubleshooting and the Compression recipe.

Secrets & interpolation

Keep credentials out of committed config, and template message bodies from request data. There are two related interpolation systems: config-value env references (resolved when the config loads) and ${namespace:selector} message templating (resolved per message, in static bodies and a few endpoint fields).

Config values from the environment

Reference environment variables anywhere in JSON/YAML/UI values with ${ENV_VARIABLE_NAME:-default_if_not_found}:

orders_out:
  output:
    kafka:
      topic: "orders"
      url: "${KAFKA_URL:-localhost:9092}"
      # a password sourced from the environment, never committed
      sasl_password: "${KAFKA_PASSWORD}"

For local development, drop a .env file in the working directory — it is loaded automatically. In containers/Kubernetes, set the vars in the environment (and override any config field with MQB__{ROUTE}__{PATH}; see Configuration grammar). This is the recommended way to keep secrets out of source — see the deploying security checklist.

Encryption keys from the environment

The encryption middleware and the file endpoints read their key with the ${env:VAR} form:

- encryption: { key: "${env:MQB_ENC_KEY}" }

Message templating with ${namespace:selector}

The static endpoint’s body is a template compiled once at startup. Tokens use the ${namespace:selector} form:

TokenResolves to
${payload:a.b.c}a field of the incoming JSON payload (dotted path; array indices allowed)
${metadata:key}a metadata value
${message:id}the message id (UUID string)
${gen:uuid}a fresh UUID v7
${gen:now} / ${gen:timestamp}current time (RFC3339 UTC / Unix epoch ms)
${gen:counter}a per-endpoint counter, starting at 0
${gen:random(1,100)}a random integer in [min, max]
${env:VAR}an environment variable, resolved once at startup
output:
  static:
    body: '{"error":"not found","id":"${message:id}","at":"${gen:now}"}'
    raw: true
    metadata: { content-type: "application/json" }
  • payload/metadata/message read the request, so they’re the useful ones on an output (e.g. an error reply echoing the request); on an input (a load-test source) only gen/env produce values.
  • When the body’s content-type is a JSON type, interpolated request values are JSON-escaped by default so external data can’t break the structure — append | raw to a token to splice it verbatim.
  • To emit a literal ${…}, write $${…}. Any ${…} with an unknown namespace is left untouched.

SQL / query token mapping

The sqlx insert_query supports the same request-binding tokens so a typed value goes into the statement: ${payload:field} binds a typed JSON value, ${metadata:key} binds a metadata string. See Upserts & insert-if-absent.

Compression

The file and object_store endpoints can compress each batch on write with the compression field (requires the compression feature):

output:
  file:
    path: "data.jsonl"
    format: json
    compression: lz4        # none | gzip | lz4 | zstd
CodecNotes
nonedefault — no compression
gzipwidest compatibility; standard .gz stream
lz4fastest; standard .lz4 stream
zstdbest ratio for the CPU cost

object_store derives its default object extension from the codec (e.g. .jsonl.gz / .jsonl.lz4).

Reading it back

A file source must declare the same compression the data was written with:

input:
  file:
    path: "data.jsonl"
    format: json
    compression: lz4

A mismatch — wrong codec, or a missing field — is a permanent decode failure: the route ends failed with the error in its status rather than silently completing as if the file were empty. Reading a compressed file with no compression set is likewise rejected up front by sniffing the leading magic bytes, so raw compressed bytes are never emitted as messages.

File compression supports only the default consume mode. csv works too: the header row is written into the first member, so the decoded stream is a normal CSV file.

Compression and encryption

Do not stack the encryption middleware on top of a sink’s batch compression on the same route — ciphertext does not compress. For compressed and encrypted data at rest, use the endpoints’ own fields, which apply compress-then-encrypt per batch:

output:
  file:
    path: "data.enc"
    format: raw
    compression: lz4
    encryption: { key: "${env:MQB_ENC_KEY}" }

An encrypted file is written as length-prefixed sealed frames (one per batch) and is only readable through a matching consumer; object_store adds a trailing .enc since the object is ciphertext, not a directly decompressible .gz.

See also

Endpoints (connectors)

An endpoint is a source (consumer) or sink (publisher) for messages. Every endpoint — whatever the underlying protocol — is driven behind the same receive_batch / send_batch shape, so any source can feed any sink. In config, an endpoint is a single-key object naming the connector:

input:  { kafka:   { url: "localhost:9092", topic: "orders" } }
output: { mongodb: { url: "mongodb://localhost:27017", database: "app", collection: "orders" } }

This page catalogs the transport endpoints and their behaviour. For per-connector query parameters used by copy (name, type, default, required, description), see the generated URL parameter reference and the hand-written connector pages. Structural endpoints (ref, fanout, switch, response, …) are documented separately in Structural endpoints.

Supported transports

Kafka, NATS, AMQP (RabbitMQ), MQTT, MongoDB, Postgres CDC (logical replication), PostgreSQL / MySQL / SQLite / MariaDB (SQLx), ClickHouse, HTTP, WebSocket, gRPC, ZeroMQ, Redis Streams, AWS SQS/SNS, cloud object storage (S3 / GCS / Azure), IBM MQ, files, and in-memory channels (memory, in-process and cross-process IPC).

Consumer vs. subscriber, and nack support

Endpoints default to a Consumer pattern (a queue: messages are distributed among workers). To get Subscriber (pub/sub) behaviour, per-backend config is required.

BackendSubscriber Config (Pub/Sub)Request-ReplyNack Support
AMQPSet subscribe_mode: trueEmulated (Property)Yes (Basic.nack)
AWSN/A (Use SNS)NoYes (Visibility Timeout)
FileSet mode: subscribeNoSimulated (In-Memory)
gRPCN/ANoNo
HTTPN/ANative (Implicit)Yes (HTTP 500)
IBM MQSet topicNoYes (Tx Rollback)
KafkaOmit group_idEmulated (Header)Eventual (Skip Offset)
Memory (in-process)Set subscribe_mode: trueEmulated (Metadata)Yes (Re-queue), by default disabled
Memory (IPC: ipc://, unix://, pipe://)Not supportedNot supportedYes (Re-queue), by default enabled, consumer-local
MongoDBSet consume: subscriberEmulated (Metadata)Yes (Unlock)
MQTTSet clean_session: trueEmulated (Property)Eventual (Skip Ack)
NATSSet subscriber_mode: trueNative (Inbox)Yes (JetStream Nak)
Postgres CDCN/A (streams committed changes)NoYes (confirmed LSN not advanced)
Redis StreamsSet subscriber_mode: trueNoEventual (PEL, un-acked)
SledSet delete_after_read: falseNoYes (Tx Rollback)
SQLxNot supportedNoEventual (Skip Delete)
WebSocketN/ANoNo
ZeroMQSet socket_type: "sub"Native (REQ/REP)No
  • Request-ReplyNative uses protocol-level correlation (HTTP connection, NATS reply subject). Emulated publishes a new message to a reply destination (the reply_to metadata field) carrying a correlation_id.
  • NackYes is explicit negative acknowledgement triggering redelivery. Eventual means redelivery depends on timeout or a connection drop. Simulated is handled in-memory. Consumer-local means the nacked message is retried inside the consumer process and is lost if that process dies — the producer is never notified.

Database sources: change capture vs. polling

Databases have no native pub/sub, so a database source is read one of two ways:

  • Change Data Capture (CDC) tails the database’s own change log — inserts and updates/deletes — and resumes from a durable log position.
    • PostgreSQL — the postgres_cdc endpoint streams a logical-replication slot (pgoutput). It emits flat JSON rows tagged with postgres.operation metadata (insert/update/delete/truncate). Acking a batch confirms the LSN back to the server; a nack or interrupted run does not advance the confirmed LSN, so replication resumes from the last acknowledged position — at-least-once. Enable with the postgres-cdc feature; requires wal_level = logical and a publication. See the Postgres CDC → JSONL tutorial.
    • MongoDB — set consume: capture_new to watch an existing collection for changes from now on, or consume: capture_all to read existing documents first and then keep capturing (no gap, at-least-once). It emits insert/update/replace/delete tagged with mongodb.operation and checkpoints under cursor_id. Change streams need a replica set.
  • Cursor polling pages an existing table by a monotonic cursor_column (WHERE col > $last ORDER BY col ASC), persisting the last read value under cursor_id. Captures appends only — updates and deletes are not observed. Available on SQLx (PostgreSQL / MySQL / MariaDB / SQLite) and ClickHouse; the poll interval backs off exponentially between polling_interval_ms and max_polling_interval_ms. SQLite and ClickHouse are polling-only.

SQLx read mode is chosen by config, not by driver. With no cursor_column, an SQLx source is a competing-consumers work queue: it atomically claims rows via a locked_until lease and deletes them on ack. This requires the table to carry the queue schema — an id, a payload, and a locked_until column — on every driver; it is not a plain full-table read. To read an arbitrary table non-destructively (the ETL path), set cursor_column to switch to cursor polling. A source table missing locked_until fails fast with a permanent error rather than reconnecting forever.

MongoDB consume modes

consumemechanismmodifies sourceends on drainuse for
consumer (default)claim → lock → re-fetch → deleteyesyeswork queues, competing readers
subscriberpolls seq > last_seq, advancing a cursornoyesephemeral fan-out
capture_newchange stream, new changes onlynonoongoing CDC
capture_all_id snapshot, then change streamnostandalone onlybulk read / ETL

These are not interchangeable. The default consumer buys exclusivity via four round trips per batch; where you only need a single-reader bulk read or ETL pass, use capture_all — it is roughly 5x faster (500k docs, standalone MongoDB: consumernull 23,667 rows/s vs. capture_all → jsonl 120,308 rows/s). Note concurrency does not speed up a MongoDB source; batches are fetched serially.

Cloud object storage (S3 / GCS / Azure)

The object_store endpoint (alias s3) reads and writes Amazon S3, Google Cloud Storage, Azure Blob, Cloudflare R2, and anything else the object_store crate speaks. Enable with the object-store feature. Credentials and backend options come from the environment (AWS_ACCESS_KEY_ID, AWS_REGION, GOOGLE_SERVICE_ACCOUNT, AZURE_STORAGE_ACCOUNT, …); the URL scheme picks the backend (s3://, gs://, az://).

  • As a sink, each flushed batch is encoded with the file formats (normal JSONL, json, text, raw) and written as one immutable object at <prefix>/[YYYY/MM/DD/]<uuidv7>.<ext>. Objects are write-once.
  • As a source, objects under the prefix are listed in key order, fetched whole, split on the delimiter, and emitted. Progress is a durable cursor holding the last fully-acked object key: set cursor_id and an external checkpoint_store (file://, s3://, postgres://, mongodb://) so a restart resumes. Objects are never deleted or rewritten.
archive_to_s3:
  input:  { memory: { topic: "events" } }
  output: { object_store: { url: "s3://my-bucket/events", format: normal } }

replay_from_s3:
  input:
    object_store:
      url: "s3://my-bucket/events"
      cursor_id: "replayer-1"
      checkpoint_store: "file:///var/lib/mqb/s3-cursor.json"
  output: { nats: { subject: "events.replay", url: "nats://localhost:4222" } }

Point checkpoint_store at a different bucket/prefix than the source reads; a cursor object under the source prefix would be listed and re-read as data.

File and object formats

Both file and object_store share the encodings: normal/json/text write the {message_id, payload, metadata} wrapper (the message id survives the round trip); raw writes payloads verbatim (bare documents, no wrapper). csv is supported as a source (and on file sinks). The file endpoints also carry their own compression and encryption fields — see the Compression and Encryption at rest recipes.

Memory endpoint and IPC

The memory endpoint is both an in-process channel and a cross-process IPC transport over Unix domain sockets / Windows named pipes. Its topic field (alias url) doubles as a transport URL. See the Cross-process IPC bridge tutorial and Learn the architecture for the URL schemes, roles (consumer is server, publisher is client), wire format and backpressure.

IBM MQ

IBM MQ is included in the full feature set via the ibm-mq feature, which loads the IBM MQ client library at runtime via dlopen — no IBM SDK is needed to build. The redistributable client only has to be present at runtime, and only if you actually use an IBM MQ endpoint (it is loaded lazily on first connect). The loader finds the client via the platform default name, MQ_INSTALLATION_PATH (e.g. /opt/mqm), or an explicit MQB_IBM_MQ_LIB path. To link statically at build time, use ibm-mq-static (requires the IBM MQ SDK). IBM MQ has its own TLS shape (IbmTlsConfig): tls.cert_file (alias key_repository) is a CMS key repository path, not a PEM file. See the IBM MQ setup guide.

Connection sharing

Publishers that target the same server reuse one underlying transport client by default, consolidating TCP connections, background threads, and batching. Sharing applies to Kafka, NATS, MongoDB, SQLx, HTTP, and gRPC; the client is keyed by connection-level settings (URL, auth, TLS, client options), never by topic/subject/collection. Set shared: false on a publisher to give it a dedicated connection — see Configuration grammar and Performance tuning.

Idempotent writes

For ETL, at-least-once delivery plus an idempotent write gives effective exactly-once. The most robust place to enforce this is the sink’s own unique constraint. See the Upserts & insert-if-absent and Deduplication recipes for the per-sink mechanisms (id_field on MongoDB, ON CONFLICT/ON DUPLICATE KEY on SQL, ReplacingMergeTree on ClickHouse, deterministic message_id + postgres.key on Postgres CDC).

Connectors

Hand-written per-connector docs: purpose, URL format, and practical examples. For the full, auto-generated list of every recognised query parameter (type, default, required, description), see the matching page under ../reference/.

Every connector recognised by mq-bridge-app copy now has a hand-written page. For the full, auto-generated parameter tables see the matching page under ../reference/.

PostgreSQL / MySQL / MariaDB / SQLite

The sqlx connector reads and writes rows in a relational database table. The same connector backs all four schemes — the URL scheme just selects the driver. Postgres additionally has a dedicated CDC connector for streaming change data instead of table reads.

URL format

postgres://[user:pass@]host[:port]/database?table=<name>

The scheme (postgres/postgresql, mysql, mariadb, sqlite) and everything up to the query string is passed straight through as the driver connection string; table (and any other recognised field below) is pulled out of the query string into config. For SQLite, host/database are replaced by a file path, e.g. sqlite:///var/data/app.db?table=orders.

Examples

Full-table read (source), one-shot:

mq-bridge-app copy --drain \
  --from postgres://user:pass@localhost/app?table=orders \
  --to null:

Write with auto-created table (destination):

mq-bridge-app copy --drain \
  --from file:///data/orders.csv?format=csv \
  --to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'

Resumable incremental read, keyed by a monotonic column, continuous:

mq-bridge-app copy \
  --from 'postgres://user:pass@localhost/app?table=orders&cursor_column=id&cursor_id=orders_export' \
  --to kafka://kafka.local:9092?topic=orders

Each restart resumes from the last id seen (persisted via cursor_id) instead of re-copying from the start.

Custom multi-column insert, MySQL:

mq-bridge-app copy --drain \
  --from postgres://user:pass@src/app?table=orders \
  --to 'mysql://user:pass@dst/app?table=orders&insert_query=INSERT+INTO+orders+%28id%2C+sku%2C+qty%29+VALUES+%28%24%7Bpayload%3Aid%7D%2C+%24%7Bpayload%3Asku%7D%2C+%24%7Bpayload%3Aqty%7D%29'

(insert_query is shown URL-encoded above — the SQL is INSERT INTO orders (id, sku, qty) VALUES (${payload:id}, ${payload:sku}, ${payload:qty}).)

Key options

OptionPurpose
tableRequired. Table to read from / write to.
cursor_column + cursor_idNon-destructive, resumable incremental reads instead of a one-shot full-table copy.
checkpoint_store(Consumer, cursor_column mode) Where to persist the resume cursor. Absent → a mqb_cursors_<table> table in the source database; a bare name reuses the source datastore with that table; a URL (file://, postgres://, mysql://, mongodb://, s3:///gs:///az:///abfs://) selects an external backend. Treated as a secret since it may embed credentials.
auto_create_tablePublisher creates the destination table if missing.
insert_queryCustom INSERT with ${payload:field} / ${metadata:key} tokens for multi-column writes.
bulk_copyPostgreSQL only — use COPY FROM STDIN for high-throughput bulk loads.
delete_after_readConsumer deletes rows after they’re processed (mutually exclusive with cursor_column).

Any other query parameter (e.g. sslmode=disable) is left on the connection URL untouched and passed to the driver as-is.

Full field list, types, and defaults: reference/postgres.md.

PostgreSQL CDC

A separate connector for streaming logical-replication changes (insert/ update/delete) instead of reading a table snapshot. Uses postgres-cdc:// (alias pgcdc://) to select the endpoint kind; the connection URL underneath it is a plain Postgres URL.

postgres-cdc://[user:pass@]host[:port]/database?publication=<name>&slot_name=<name>

Stream changes from a publication into Kafka, continuous:

mq-bridge-app copy \
  --from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
  --to kafka://kafka.local:9092?topic=app-changes

Replicate a table into another PostgreSQL instance, continuous:

mq-bridge-app copy \
  --from 'postgres-cdc://user:pass@localhost/app?publication=mqb_pub&slot_name=mqb_slot' \
  --to 'postgres://user:pass@otherhost/replica?table=orders&auto_create_table=true'

publication must already exist on the source (CREATE PUBLICATION mqb_pub FOR TABLE orders;); slot_name is created automatically if missing.

Full field list: reference/postgres-cdc.md.

ClickHouse

Reads from or bulk-inserts into a ClickHouse table over ClickHouse’s HTTP interface (port 8123 by default; 8443 for HTTPS).

URL format

clickhouse://host[:port]?table=<name>[&database=<name>]

clickhouse:// is rewritten to http:// (and clickhouses:// to https://) before being handed to the ClickHouse client — the scheme only selects the endpoint kind on the CLI.

Examples

Bulk insert from a full-table Postgres read, one-shot:

mq-bridge-app copy --drain \
  --from postgres://user:pass@localhost/app?table=orders \
  --to 'clickhouse://localhost:8123?table=orders&database=analytics'

Async insert for high-throughput streaming writes, continuous:

mq-bridge-app copy \
  --from kafka://kafka.local:9092?topic=events \
  --to 'clickhouse://user:pass@ch.local:8123?table=events&database=analytics&async_insert=true'

Resumable, non-destructive read of an existing table into Kafka:

mq-bridge-app copy \
  --from 'clickhouse://localhost:8123?table=events&database=analytics&cursor_column=id&cursor_id=events_export' \
  --to kafka://kafka.local:9092?topic=events

Each restart resumes from the last id seen instead of re-reading from the start. (Per-column mapping via columns is a map field, so it can’t be set from a query param — use a YAML route config for that.)

Key options

OptionPurpose
tableRequired. May be schema-qualified (db.table).
databaseDefaults to default.
columnsMap target columns to ${payload:field} / ${metadata:key} tokens instead of inserting the whole JSON payload as one row.
async_insertServer-side buffered inserts for higher publisher throughput.
cursor_column + cursor_idNon-destructive, resumable reads of an existing table.
checkpoint_store(Consumer, cursor_column mode) Where to persist the resume cursor. ClickHouse can’t do per-row cursor upserts, so a durable checkpoint needs an external store URL: file://, postgres:///mysql://, mongodb://, or s3:///gs:///az:///abfs://. Treated as a secret since it may embed credentials.

Full field list: reference/clickhouse.md.

MQTT

Publishes to or subscribes from an MQTT broker (v5 by default, v3 supported).

URL format

mqtt://[user:pass@]host[:port]?topic=<topic>

mqtt:// is rewritten to tcp:// (and mqtts:// to ssl://) before being handed to the MQTT client — the scheme only selects the endpoint kind on the CLI. MQTT topic wildcards (+, #) are supported on the source side.

Examples

Subscribe to a wildcard topic and forward to Kafka, continuous:

mq-bridge-app copy \
  --from mqtt://broker.local:1883?topic=sensors/+/temperature \
  --to kafka://kafka.local:9092?topic=sensor-readings

Publish a file’s lines to a topic, one-shot:

mq-bridge-app copy --drain \
  --from file:///data/events.jsonl?format=json \
  --to mqtts://user:pass@broker.local:8883?topic=events

Fixed client ID and QoS 2 for exactly-once delivery semantics:

mq-bridge-app copy \
  --from 'mqtt://broker.local:1883?topic=alerts&client_id=mqb-alerts-01&qos=2' \
  --to null:

Key options

OptionPurpose
topicMQTT topic (wildcards on the source side).
client_idFixed client ID; auto-generated if omitted.
qosQuality of Service (0, 1, or 2). Defaults to 1.
protocolV3 or V5. Defaults to V5.
delayed_ackConsumer-only: ack after processing instead of on receipt (default).

Full field list: reference/mqtt.md.

Kafka

Produces to or consumes from a Kafka topic via librdkafka.

URL format

kafka://broker[:port]?topic=<topic>

kafka:// is stripped before being handed to librdkafka’s bootstrap.servers, which expects a bare host:port list, not a URI — the scheme only selects the endpoint kind on the CLI. For a multi-broker cluster, use the ?url= escape hatch (see Quick Start): kafka://_/?url=broker1:9092,broker2:9092&topic=orders.

Examples

Forward an MQTT stream into a Kafka topic, continuous:

mq-bridge-app copy \
  --from mqtt://broker.local:1883?topic=sensors/+/temperature \
  --to kafka://kafka.local:9092?topic=sensor-readings

Consume with a durable consumer group, continuous:

mq-bridge-app copy \
  --from 'kafka://kafka.local:9092?topic=orders&group_id=mqb-orders-sync' \
  --to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'

Without group_id, the consumer runs in ephemeral subscriber mode (unique group ID, starts from the latest offset).

SASL-authenticated broker:

mq-bridge-app copy \
  --from postgres://user:pass@localhost/app?table=orders \
  --to 'kafka://kafka.local:9093?topic=orders&username=svc&password=secret'

TLS (an object-typed field, not a scalar) can’t be set via a query param; use the ?url= escape hatch to pass a librdkafka connection string with TLS options embedded.

Key options

OptionPurpose
topicTopic to produce to or consume from.
group_idConsumer group ID; omit for ephemeral subscriber mode.
username / passwordSASL authentication.
tlsTLS configuration (object; not settable via a scalar query param — use ?url= to pass driver-level TLS options).
delayed_ackPublisher-only: don’t wait for broker acknowledgement.

Full field list: reference/kafka.md.

RabbitMQ (AMQP)

Publishes to or consumes from a RabbitMQ queue over the AMQP 0-9-1 protocol.

URL format

rabbitmq://[user:pass@]host[:port]/<vhost>?queue=<name>

amqp:///amqps:// (the native AMQP scheme) and rabbitmq:/// rabbitmqs:// (an alias) are both accepted; rabbitmq(s) is rewritten to amqp(s) before being handed to the driver. The default vhost / must be percent-encoded as %2f in the URL path, per the AMQP URI spec.

Examples

Consume a queue and forward each message to an HTTP endpoint, continuous:

mq-bridge-app copy \
  --from rabbitmq://guest:guest@localhost:5672/%2f?queue=orders \
  --to http://internal-api.local/ingest?method=POST

Publish to an exchange instead of a default-exchange queue:

mq-bridge-app copy --drain \
  --from file:///data/events.jsonl?format=json \
  --to 'amqp://guest:guest@localhost:5672/%2f?exchange=events&queue=events'

Fan-out subscriber mode (ephemeral queue bound to the exchange):

mq-bridge-app copy \
  --from 'amqp://guest:guest@localhost:5672/%2f?exchange=events&subscribe_mode=true' \
  --to kafka://kafka.local:9092?topic=events

Key options

OptionPurpose
queueQueue to consume from / publish to.
exchangeExchange to publish to or bind the queue to.
subscribe_modeConsumer-only: fan-out (ephemeral queue) instead of point-to-point.
prefetch_countConsumer-only: messages to prefetch. Defaults to 100.
no_persistenceNon-durable queues / non-persistent messages.

Full field list: reference/rabbitmq.md.

NATS

Publishes to or consumes from NATS subjects. Uses JetStream by default (durable, acked); set no_jetstream=true for fire-and-forget Core NATS.

URL format

nats://host[:port]?subject=<subject>[&stream=<name>]

For a multi-server cluster, pass a comma-separated list via the ?url= escape hatch: nats://_/?url=nats://n1:4222,nats://n2:4222&subject=orders. Consumers require stream (the JetStream stream name), even with no_jetstream=true (validated but unused there). If the bridge auto-creates a stream it is scoped to {stream}.>, so prefix your subject accordingly.

Examples

Load a JSONL file into a JetStream subject, one-shot:

mq-bridge-app copy --drain \
  --from file:///data/orders.jsonl?format=json \
  --to 'nats://localhost:4222?subject=orders&stream=ORDERS'

Consume a durable JetStream stream into Postgres, continuous:

mq-bridge-app copy \
  --from 'nats://localhost:4222?subject=orders&stream=ORDERS' \
  --to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'

Core NATS request/reply (no JetStream), continuous:

mq-bridge-app copy \
  --from http://0.0.0.0:8080/rpc \
  --to 'nats://localhost:4222?subject=rpc.echo&no_jetstream=true&request_reply=true'

Key options

OptionPurpose
subjectSubject to publish to or subscribe to.
streamJetStream stream name. Required for consumers.
no_jetstreamUse Core NATS (fire-and-forget) instead of JetStream.
deliver_policyConsumer-only: all (default), last, new, last_per_subject.
subscriber_modeConsumer-only: ephemeral subscriber instead of a durable consumer.
request_reply + request_timeout_msPublisher-only request/reply pattern.
deduplicatePublisher-only (JetStream): send Nats-Msg-Id so JetStream dedupes redeliveries.
username / password / tokenAuthentication.
tlsTLS configuration (object; set with a JSON literal ?tls={...}).

Full field list: reference/nats.md.

Redis Streams

Publishes to or consumes from a Redis stream (XADD / XREADGROUP). Consumers use a consumer group by default for durable, acked delivery; subscriber_mode=true reads ephemerally via XREAD.

URL format

redis://[user:pass@]host[:port]?stream=<key>

redis:// (plain) and rediss:// (TLS) are both accepted; redis_streams:// is an explicit alias. If stream is omitted it defaults to the route name.

Examples

Load a CSV file into a stream, one-shot:

mq-bridge-app copy --drain \
  --from file:///data/events.csv?format=csv \
  --to redis://localhost:6379?stream=events

Consume with a durable group into ClickHouse, continuous:

mq-bridge-app copy \
  --from 'redis://localhost:6379?stream=events&group=analytics&consumer_name=w1' \
  --to 'clickhouse://localhost:8123?table=events&database=analytics'

Unclaimed entries pending longer than redelivery_timeout_ms (default 60s) are re-delivered via XAUTOCLAIM.

Ephemeral tail of new messages (no group, no acks), continuous:

mq-bridge-app copy \
  --from 'redis://localhost:6379?stream=events&subscriber_mode=true' \
  --to kafka://kafka.local:9092?topic=events

Key options

OptionPurpose
streamStream key to publish to / read from. Defaults to the route name.
groupConsumer-only: group name. Defaults to {APP_NAME}-{stream}.
consumer_nameConsumer-only: consumer within the group.
subscriber_modeConsumer-only: ephemeral XREAD from new messages (no group/acks).
read_from_startConsumer-only: on group creation, start from the beginning (0) not $.
redelivery_timeout_msConsumer-only: re-claim entries pending ≥ this long; 0 disables.
reader_connectionsConsumer-only: parallel XREADGROUP readers across the group.
maxlen + approx_trimPublisher-only: cap stream length with XADD MAXLEN.
username / passwordAuthentication (Redis ACL).

Full field list: reference/redis.md.

HTTP

As a publisher, sends each message as an HTTP request to a target URL. As a consumer, runs an embedded HTTP server and turns incoming requests into messages.

URL format

http://host[:port][/path]?method=<verb>

http:///https:// pass through unchanged — they’re already the native scheme the driver expects (the target path, if any, is just part of the URL, not a separate query param).

Examples

Consume a RabbitMQ queue and POST each message to an API, continuous:

mq-bridge-app copy \
  --from rabbitmq://guest:guest@localhost:5672/%2f?queue=orders \
  --to http://internal-api.local/ingest?method=POST

Run an HTTP listener as the source (webhook receiver), continuous:

mq-bridge-app copy \
  --from http://0.0.0.0:8080?method=POST \
  --to kafka://kafka.local:9092?topic=webhooks

Non-blocking publisher (don’t wait for the downstream response):

mq-bridge-app copy --drain \
  --from file:///data/events.jsonl?format=json \
  --to 'https://api.example.com/ingest?method=POST&request_timeout_ms=5000'

Key options

OptionPurpose
methodHTTP method. Publisher: request method (defaults to POST). Consumer: restrict to this method.
request_timeout_msPer-request timeout. Defaults to 30000ms.
workersConsumer-only: worker thread count. Defaults to unlimited.
fire_and_forgetConsumer-only: respond 202 immediately, don’t wait for downstream processing.
message_id_headerHeader to extract the message ID from. Defaults to message-id.

Full field list: reference/http.md.

WebSocket

Acts as a WebSocket server (consumer — listens for connections) or client (publisher — connects to a target URL). As a consumer it receives frames; as a publisher it sends them.

URL format

# Consumer (listen):   ws://<bind-address>
# Publisher (connect):  ws://host[:port]/path

ws:// (plain) and wss:// (TLS) are both accepted. For a consumer the URL is the listen address (e.g. ws://0.0.0.0:9000); for a publisher it is the target server URL.

Examples

Listen for WebSocket frames and forward them to Kafka, continuous:

mq-bridge-app copy \
  --from ws://0.0.0.0:9000 \
  --to kafka://kafka.local:9092?topic=ws-events

Only accept a specific path:

mq-bridge-app copy \
  --from ws://0.0.0.0:9000?path=/ingest \
  --to file:///data/ws.jsonl?format=json

Push a stream to a remote WebSocket server, continuous:

mq-bridge-app copy \
  --from redis://localhost:6379?stream=events \
  --to wss://feed.example.com/socket

Key options

OptionPurpose
pathConsumer-only: only upgrade requests whose URI path matches exactly are delivered.
message_id_headerConsumer-only: handshake header to read the message ID from (default message-id).
execution_modeConsumer-only: auto (default), direct_only, or routed.
backlogConsumer-only: TCP listen backlog for the accept socket (default 4096).
routed_queue_capacityConsumer-only: queue capacity for the routed adapter (default 100).

Full field list: reference/websocket.md.

gRPC

Sends or receives messages over gRPC. As a client (default) it connects to a remote server and issues Publish / PublishBatch RPCs; as a server (server_mode=true) it starts an embedded tonic server that accepts those RPCs.

URL format

grpc://host[:port]?topic=<topic>

grpc:// (plain) and grpcs:// (TLS) are both accepted. In client mode the URL is the remote server (e.g. grpc://localhost:50051); in server mode it is the bind address (e.g. grpc://0.0.0.0:50051).

Examples

Forward a Kafka topic to a remote gRPC service, continuous:

mq-bridge-app copy \
  --from kafka://kafka.local:9092?topic=orders \
  --to grpc://orders-svc.local:50051?topic=orders

Run an embedded gRPC server that ingests into Postgres, continuous:

mq-bridge-app copy \
  --from 'grpc://0.0.0.0:50051?server_mode=true&topic=orders' \
  --to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'

Key options

OptionPurpose
server_modeStart an embedded gRPC server (receive) instead of connecting as a client.
topicTopic / subject used for both subscribe and publish paths.
timeout_msClient: connection timeout and per-request deadline. Server: per-request deadline.
max_decoding_message_sizeServer-only: max decoded incoming message size (default 4 MiB).
http2_keepalive_interval_ms / http2_keepalive_timeout_msServer-only: HTTP/2 keepalive tuning.
tlsTLS configuration (object; set with a JSON literal ?tls={...}).

Full field list: reference/grpc.md.

MongoDB

Reads from or writes to a MongoDB collection. database and collection are separate query params — MongoDB URIs don’t encode the database in the path the way this connector reads them (unlike PostgreSQL).

URL format

mongodb://[user:pass@]host[:port]?database=<db>&collection=<name>

Examples

Load a CSV file into a collection, one-shot:

mq-bridge-app copy --drain \
  --from file:///data/customers.csv?format=csv \
  --to 'mongodb://localhost?database=app&collection=customers'

Non-destructive read of an existing collection (default source behavior):

mq-bridge-app copy --drain \
  --from 'mongodb://localhost?database=app&collection=customers' \
  --to 'postgres://user:pass@localhost/app?table=customers&auto_create_table=true'

By default (no consume given), a MongoDB source reads existing documents then watches for changes (capture_all) — pointing at a collection never claims/deletes its documents, unlike the library’s own consumer default.

Watch for new documents only (change stream), continuous:

mq-bridge-app copy \
  --from 'mongodb://localhost?database=app&collection=orders&consume=capture_new' \
  --to kafka://kafka.local:9092?topic=orders

Key options

OptionPurpose
databaseRequired.
collectionCollection name.
consumecapture_all (default via the CLI), capture_new (changes only), consumer (durable queue, destructive), or subscriber (ephemeral).
checkpoint_storeWhere to persist the resume cursor for capture_new/capture_all.
username / passwordTake precedence over credentials embedded in url.

Full field list: reference/mongodb.md.

AWS SQS / SNS

Consumes from an SQS queue or publishes to an SQS queue / SNS topic. Credentials come from the standard AWS provider chain (environment, profile, instance role) unless supplied explicitly as query params.

URL format

aws://?queue_url=<sqs-url>&region=<region>

aws:// and aws-sqs:// are both accepted. The host is unused — the queue is selected by queue_url (required for consumers, and for publishers unless topic_arn is set for SNS). Point endpoint_url at LocalStack for local testing.

Examples

Drain an SQS queue into a file, one-shot:

mq-bridge-app copy --drain \
  --from 'aws://?queue_url=https://sqs.us-east-1.amazonaws.com/1234/orders&region=us-east-1' \
  --to file:///data/orders.jsonl?format=json

Publish a Postgres table to an SNS topic, one-shot:

mq-bridge-app copy --drain \
  --from postgres://user:pass@localhost/app?table=orders \
  --to 'aws://?topic_arn=arn:aws:sns:us-east-1:1234:orders&region=us-east-1'

LocalStack, continuous:

mq-bridge-app copy \
  --from 'aws://?queue_url=http://localhost:4566/000000000000/orders&region=us-east-1&endpoint_url=http://localhost:4566&access_key=test&secret_key=test' \
  --to null:

Warning

access_key / secret_key in the URL are shown here only because LocalStack’s test/test are throwaway values. Never put real credentials in a connector URL — they leak into shell history and process listings. Against real AWS, omit both and let the standard provider chain (env vars, ~/.aws/credentials, IAM role) supply them; see Secrets.

Key options

OptionPurpose
queue_urlSQS queue URL. Required for consumers; optional for publishers if topic_arn is set.
topic_arnPublisher-only: SNS topic ARN to publish to.
regionAWS region (e.g. us-east-1).
endpoint_urlCustom endpoint (e.g. LocalStack).
access_key / secret_key / session_tokenExplicit credentials (otherwise the AWS provider chain is used).
max_messagesConsumer-only: batch size per receive (1–10).
wait_time_secondsConsumer-only: long-poll wait (0–20).

Full field list: reference/aws.md.

ZeroMQ

Sends or receives messages over ZeroMQ sockets (PUSH/PULL, PUB/SUB, REQ/REP). The socket_type selects the pattern and bind decides whether the endpoint binds or connects.

URL format

zeromq://<transport>?socket_type=<type>[&bind=true]

zeromq:// and zmq:// are both accepted. The URL is a ZeroMQ transport address such as tcp://127.0.0.1:5555. Choose bind=true on exactly one side of a socket pair; the other connects.

Examples

Pull from a PUSH producer and write to a file, continuous:

mq-bridge-app copy \
  --from 'zeromq://tcp://127.0.0.1:5555?socket_type=pull&bind=true' \
  --to file:///data/events.jsonl?format=json

Publish a Kafka topic to a PUB socket, continuous:

mq-bridge-app copy \
  --from kafka://kafka.local:9092?topic=events \
  --to 'zeromq://tcp://0.0.0.0:5556?socket_type=pub&bind=true'

Subscribe to a topic on a remote PUB socket, continuous:

mq-bridge-app copy \
  --from 'zeromq://tcp://feed.local:5556?socket_type=sub&topic=orders' \
  --to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'

Key options

OptionPurpose
socket_typepush, pull, pub, sub, req, or rep.
bindBind to the address instead of connecting (default false).
topicConsumer-only: topic filter for sub sockets.
formatWire format: json (default, wraps the message), raw (payload bytes), raw_framed (raw + JSON metadata frame).
backendzmq (default) or omq (PoC, PUSH/PULL + PUB/SUB only; needs the zeromq-omq feature).

Full field list: reference/zeromq.md.

IBM MQ

Puts to or gets from an IBM MQ queue (point-to-point) or topic (publish/subscribe). Requires the IBM MQ client libraries and the optional IBM MQ build — see IBM MQ setup for prerequisites.

URL format

ibmmq://host(port)?queue_manager=<QM>&channel=<CHANNEL>&queue=<QUEUE>

ibmmq:// and ibm-mq:// are both accepted. The host is given in IBM MQ’s host(port) form, and a comma-separated list provides failover (host1(1414),host2(1414)). queue_manager and channel (the SVRCONN channel) are always required; supply queue or topic.

Examples

Drain a queue into a file, one-shot:

mq-bridge-app copy --drain \
  --from 'ibmmq://mq.local(1414)?queue_manager=QM1&channel=DEV.APP.SVRCONN&queue=DEV.QUEUE.1&username=app&password=<password>' \
  --to file:///data/mq.jsonl?format=json

Credentials on the command line land in shell history and process listings. Keep them out of the URL by interpolating an environment variable instead — see Secrets.

Bridge a queue into Kafka, continuous:

mq-bridge-app copy \
  --from 'ibmmq://mq.local(1414)?queue_manager=QM1&channel=DEV.APP.SVRCONN&queue=DEV.QUEUE.1' \
  --to kafka://kafka.local:9092?topic=mq-events

Subscribe to a topic (pub/sub), continuous:

mq-bridge-app copy \
  --from 'ibmmq://mq.local(1414)?queue_manager=QM1&channel=DEV.APP.SVRCONN&topic=orders/new' \
  --to 'postgres://user:pass@localhost/app?table=orders&auto_create_table=true'

Key options

OptionPurpose
queue_managerRequired. Queue Manager name (e.g. QM1).
channelRequired. SVRCONN channel name defined on the QM.
queueQueue for point-to-point (defaults to the route name if omitted).
topicTopic string for pub/sub; enables subscriber mode on a consumer.
username / passwordAuthentication (required if the channel enforces it).
max_message_sizeMax message size in bytes (default 4 MiB).
wait_timeout_msConsumer-only: polling timeout (default 1000 ms).
tlsTLS via the MQ-native key repository (object; set with a JSON literal ?tls={...}).

Full field list: reference/ibmmq.md. Setup and prerequisites: IBM MQ setup.

File (CSV / JSON / JSONL)

Reads from or writes to a local file. Useful as a one-shot source/sink for migrating data in or out of the other connectors.

URL format

file:///absolute/path/to/file?format=<normal|json|text|raw|csv>

The path comes from the URI path itself (file:///...), not a query param. format defaults to normal (the full message serialized as JSON).

Examples

Load a CSV file into MongoDB, one-shot (first row = header):

mq-bridge-app copy --drain \
  --from file:///data/customers.csv?format=csv \
  --to 'mongodb://localhost?database=app&collection=customers'

Export a table to JSONL, one-shot:

mq-bridge-app copy --drain \
  --from postgres://user:pass@localhost/app?table=orders \
  --to file:///data/orders.jsonl?format=json

Tail a file as it grows (broadcast/subscribe mode), continuous:

mq-bridge-app copy \
  --from file:///var/log/app/events.log?mode=subscribe \
  --to kafka://kafka.local:9092?topic=app-events

Key options

OptionPurpose
formatnormal, json, text, raw, or csv.
delimiterMessage delimiter. Defaults to newline.
modeConsumer only: consume (from start), subscribe (tail from end), or persistent offset-tracked modes.
compressionCompress/decompress each batch: none (default), gzip, lz4, zstd (needs the compression build feature). A source must declare the same codec the file was written with. See Compression.

Full field list: reference/file.md.

URL Parameter Reference

Auto-generated from the same JSON Schemas mq-bridge-app copy uses to parse --from/--to query parameters (see crates/cli/src/main.rs::endpoint_from_uri). For workflow examples, see the connector pages.

PostgreSQL / MySQL / MariaDB / SQLite

Schemes: postgres://, postgresql://, mysql://, mariadb://, sqlite://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
acquire_timeout_msintegernoTimeout for acquiring a connection from the pool in milliseconds. Defaults to 30000ms.
auto_create_tablebooleannofalse(Publisher only) If true, automatically create the table and indexes if they don’t exist. Defaults to false.
bulk_copybooleannofalse(Publisher only) PostgreSQL only. Bulk-load batches via COPY FROM STDIN (much faster than multi-row INSERT). Requires a token-based insert_query; no ON CONFLICT/RETURNING.
checkpoint_storestringno(Consumer only) Where to persist the resume cursor in cursor_column mode. A URL selects the backend; a bare name (or /name) reuses the source datastore with that table name: - absent → source datastore, table mqb_cursors_<source_table> (auto-unique) - /my_cursors → source datastore, table my_cursors - file:///var/lib/mqb/cursors.json → local JSON file (read-only / write-restricted sources) - postgres://user@host/db/table or mysql://host/db/table → external SQL table (table optional) - mongodb://host/db/collection → external MongoDB collection (collection optional) - s3://bucket/prefix (also gs://, az://, abfs://) → cloud object store; creds via env When no table/collection is named, it defaults to mqb_cursors_<source_table>. May embed connection credentials, so it is treated as a secret.
create_publicationbooleannofalse(Consumer only, CDC) When publication is set, create it if missing (default false). Needs table-owner privilege: it is auto-published FOR TABLE {table}.
cursor_columnstringno(Consumer only) Read an existing table non-destructively and resumably, paging by this monotonic column (SELECT * FROM {table} WHERE {cursor_column} > $last ORDER BY {cursor_column} ASC LIMIT n) and persisting the last read value under cursor_id. Does not delete/lock source rows. Mutually exclusive with delete_after_read.
cursor_idstringno(Consumer only) Cursor id used to key the persisted resume position. Recommended when cursor_column is set: without it, progress is not persisted and every restart re-copies from the beginning.
delete_after_readbooleannofalse(Consumer only) If true, delete messages after processing.
idle_timeout_msintegernoMaximum idle time for a connection in milliseconds. Defaults to 600000ms (10 minutes).
insert_querystringno(Publisher only) Optional. A custom SQL INSERT query. Use ? as a placeholder for the payload. If not provided, a default INSERT INTO {table} (payload) VALUES (?) is used. For multi-column inserts, embed explicit source tokens directly in the query: ${metadata:<key>} binds message.metadata["<key>"], and ${payload:<field>} binds the top-level JSON field <field> of the payload (types preserved: numbers/bools stay numeric/bool). There is no fallback between the two: an absent metadata key, non-JSON payload, or missing/non-scalar field binds SQL NULL. Example: INSERT INTO orders (customer_id, sku, qty) VALUES (${metadata:customer_id}, ${payload:sku}, ${payload:qty}). A query with no ${...} tokens behaves exactly as before (whole payload bound once). auto_create_table is not supported together with a token-based query. Tokens bind as text/number/bool; Postgres won’t implicitly cast text into a numeric/timestamptz column (these arrive as JSON strings from a sql source). Add an explicit cast next to the token — it is preserved verbatim in the SQL: VALUES (${payload:amount}::numeric, ${payload:created_at}::timestamptz).
max_connectionsintegernoMaximum number of connections in the pool. Defaults to 10.
max_lifetime_msintegernoMaximum lifetime of a connection in milliseconds. Defaults to 1800000ms (30 minutes).
max_polling_interval_msintegerno(Consumer only) If set, the poll interval backs off exponentially from polling_interval_ms up to this value while drained, resetting on new rows. Unset = constant interval.
min_connectionsintegernoMinimum number of connections to keep in the pool. Defaults to 0.
passwordstringnonullOptional password. Takes precedence over any credentials embedded in the url.
polling_interval_msintegerno(Consumer only) Polling interval in milliseconds. Defaults to 100ms.
publicationstringno(Consumer only, PostgreSQL) If set, consume via logical-replication CDC instead of cursor polling: streams inserts/updates/deletes from this publication. Requires the postgres-cdc feature and a Postgres URL. For full control use the dedicated postgres_cdc endpoint.
select_querystringno(Consumer only) Optional. A custom SQL SELECT query to fetch messages. This is only supported for PostgreSQL and Microsoft SQL Server. The query must include a placeholder for the batch size ($1 for PostgreSQL, @p1 for SQL Server). The bridge will bind the route’s batch_size to this placeholder.
sharedbooleannotrueShare one connection pool per connection (default: true); false forces a dedicated pool.
slot_namestringno(Consumer only, CDC) Replication slot name; created if missing. Defaults to mq_bridge_slot.
tablestringyesThe table to interact with.
tlsobjectnosee belowTLS configuration for the database connection.
urlstringyesDatabase connection URL. If it contains userinfo, it will be treated as a secret.
usernamestringnonullOptional username. Takes precedence over any credentials embedded in the url.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

PostgreSQL CDC (logical replication)

Schemes: postgres-cdc://, pgcdc://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
checkpoint_storestringnoCheckpoint store spec (e.g. file:///path, s3://bucket/prefix); defaults to the source database.
create_publicationbooleannofalseCreate the publication if missing (default false; leave off if it pre-exists). Needs table ownership for publication_tables, or superuser when none are set (FOR ALL TABLES).
create_slotbooleannotrueCreate the replication slot if it does not exist.
cursor_idstringnoCheckpoint key for persisting the confirmed LSN across restarts (optional; the slot is authoritative).
publicationstringyesPublication name (must already exist; defines which tables are captured).
publication_tablesarray of stringnosee belowTables to include when managing the publication (create_publication); may be schema.table. Missing ones are added to an existing publication (never removed). Empty = FOR ALL TABLES (needs superuser).
slot_namestringnomq_bridge_slotReplication slot name; created if missing when create_slot is true.
status_interval_msintegerno10000Standby-status-update interval in ms; must be shorter than the server’s wal_sender_timeout.
temporary_slotbooleannofalseUse a temporary slot (dropped on disconnect). Not restart-safe; default is a permanent slot.
tlsobjectnosee belowTLS configuration for the replication connection.
urlstringyesConnection URL, e.g. postgres://user:pass@host:5432/dbname.

Struct-typed fields

publication_tables

[]

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

MongoDB

Schemes: mongodb://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
capped_size_bytesintegerno(Publisher only) If set, creates a capped collection with this size in bytes.
change_streambooleannofalse(Consumer only) Deprecated — use consume: subscriber. Kept for compatibility.
checkpoint_storestringno(Consumer only) Where to persist the resume cursor in capture_new/capture_all mode. A URL selects the backend; a bare name (or /name) reuses the source database with that name: - absent → source database, collection mqb_cursors_<source_collection> (auto-unique) - /my_cursors → source database, collection my_cursors - file:///var/lib/mqb/cursors.json → local JSON file (read-only / write-restricted sources) - mongodb://host/db/collection → external MongoDB collection (collection optional) - postgres://user@host/db/table or mysql://host/db/table → external SQL table (table optional) - s3://bucket/prefix (also gs://, az://, abfs://) → cloud object store; creds via env When no collection/table is named, it defaults to mqb_cursors_<source_collection>. May embed connection credentials, so it is treated as a secret.
collectionstringnoThe MongoDB collection name.
consumeconsumer | subscriber | capture_new | capture_allno(Consumer only) How to consume the collection: consumer (default, competing-consumers work queue — destructive and ~5x slower), subscriber (ephemeral queue), capture_new (watch an existing collection for changes), or capture_all (read existing documents first, then watch for changes — use this for single-reader bulk reads and ETL). The bridge selects the underlying mechanism automatically. If unset, the deprecated change_stream boolean is honored for backward compatibility.
cursor_idstringnoThe ID used for the cursor in sequenced mode. If not provided, consumption starts from the current sequence (ephemeral).
databasestringyesThe database name.
formatnormal | json | text | rawnonormalFormat for storing messages. Defaults to Normal.
id_fieldstringno(Publisher only) Top-level payload field whose value becomes the document _id, for idempotent inserts via the unique _id index. Sink collections only.
meta_collectionstringno(Optional) Collection to store sequence counters and cursor positions. Defaults to the message collection if not set.
passwordstringnoOptional password. Takes precedence over any credentials embedded in the url. Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production.
polling_interval_msintegerno(Consumer only) Polling interval in milliseconds for the consumer (when not using Change Streams). Defaults to 100ms.
receive_querystringno(Consumer only) Optional custom MongoDB query to filter messages. Provided as a JSON string (e.g., ‘{“type”: “notification”}’).
reply_polling_msintegerno(Publisher only) Polling interval in milliseconds for the publisher when waiting for a reply. Defaults to 50ms.
report_outcomebooleannofalse(Publisher only) Return the message with metadata mongodb.outcome = inserted/existed (dup-key) so a request+switch can branch. Sink collections only; pair with id_field.
request_replybooleannofalse(Publisher only) If true, the publisher will wait for a response in a dedicated collection. Defaults to false.
request_timeout_msintegerno(Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
sharedbooleannotrueShare one MongoDB client per connection (default: true); false forces a dedicated client.
tlsobjectnosee belowTLS configuration.
ttl_secondsintegerno(Publisher only) TTL in seconds for documents created by the publisher. If set, a TTL index is created.
urlstringyesMongoDB connection string URI. Can contain a comma-separated list of hosts for a replica set. If it contains userinfo, it will be treated as a secret. Credentials provided via the separate username and password fields take precedence over any credentials embedded in the URL.
usernamestringnoOptional username. Takes precedence over any credentials embedded in the url. Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

Kafka

Schemes: kafka://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
consumer_optionsarray of array of anynonull(Consumer only) Additional librdkafka consumer configuration options (key-value pairs).
delayed_ackbooleannofalse(Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
group_idstringno(Consumer only) Consumer group ID. If not provided, the consumer acts in Subscriber mode: it generates a unique, ephemeral group ID and starts consuming from the latest offset.
partition_keystringnonull(Publisher only) Name of a metadata field whose value is used as the Kafka record key (drives partitioning/ordering). Unset, or absent on a given message, falls back to the message id. Default unset.
partitionsintegerno6(Publisher only) Partition count used when auto-creating the topic (default: 6). Higher values raise write/consume parallelism; ordering is only guaranteed per partition key (message_id), not across the whole topic. Ignored if the topic exists.
passwordstringnoOptional password for SASL authentication.
producer_optionsarray of array of anynonull(Publisher only) Additional librdkafka producer configuration options (key-value pairs).
sharedbooleannotrue(Publisher only) Share one producer per connection (default: true); false gives a dedicated producer.
tlsobjectnosee belowTLS configuration.
topicstringnoThe Kafka topic to produce to or consume from.
urlstringyesComma-separated list of Kafka broker URLs. If it contains userinfo, it will be treated as a secret.
usernamestringnoOptional username for SASL authentication.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

NATS

Schemes: nats://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
deduplicatebooleannofalse(Publisher only, JetStream) If true, publish a Nats-Msg-Id header (from the message id) so JetStream deduplicates redeliveries within the stream’s duplicate window. Defaults to false.
delayed_ackbooleannofalse(Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
deliver_policyall | last | new | last_per_subjectno(Consumer only) The delivery policy for the consumer. Defaults to “all”.
no_jetstreambooleannofalseIf no_jetstream: true, use Core NATS (fire-and-forget) instead of JetStream. Defaults to false.
passwordstringnoOptional password for authentication.
prefetch_countintegerno(Consumer only) Number of messages to prefetch from the consumer. Defaults to 10000.
request_replybooleannofalse(Publisher only) If true, the publisher uses the request-reply pattern. It sends a request and waits for a response (using core_client.request_with_headers()). Defaults to false.
request_timeout_msintegerno(Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
sharedbooleannotrueShare one NATS client per connection (default: true); false forces a dedicated connection.
streamstringnoThe JetStream stream name. Required for Consumers, even with no_jetstream: true (unused there, but still validated).
stream_max_bytesintegerno(Publisher only) Maximum total bytes in the stream (if created by the bridge). Defaults to 1GB.
stream_max_messagesintegerno(Publisher only) Maximum number of messages in the stream (if created by the bridge). Defaults to 1,000,000.
subjectstringnoThe NATS subject to publish to or subscribe to. If a stream is auto-created, it’s scoped to {stream}.>, so prefix accordingly.
subscriber_modebooleannofalse(Consumer only) If true, use ephemeral Subscriber mode. Defaults to false (durable consumer).
tlsobjectnosee belowTLS configuration.
tokenstringnoOptional token for authentication.
urlstringyesComma-separated list of NATS server URLs (e.g., “nats://localhost:4222,nats://localhost:4223”). If it contains userinfo, it will be treated as a secret.
usernamestringnoOptional username for authentication.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

RabbitMQ (AMQP)

Schemes: amqp://, amqps://, rabbitmq://, rabbitmqs://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
delayed_ackbooleannofalse(Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
exchangestringnoThe exchange to publish to or bind the queue to.
no_declare_queuebooleannofalse(Publisher only) If true, do not attempt to declare the queue. Assumes the queue already exists. Defaults to false.
no_persistencebooleannofalseIf true, declare queues as non-durable (transient). Defaults to false. Affects both Consumer (queue durability) and Publisher (message persistence).
passwordstringnoOptional password for authentication.
prefetch_countintegerno(Consumer only) Number of messages to prefetch. Defaults to 100.
queuestringnoThe AMQP queue name.
subscribe_modebooleannofalse(Consumer only) If true, act as a Subscriber (fan-out). Defaults to false.
tlsobjectnosee belowTLS configuration.
urlstringyesAMQP connection URI. The lapin client connects to a single host specified in the URI. If it contains userinfo, it will be treated as a secret. For high availability, provide the address of a load balancer or use DNS resolution that points to multiple brokers. Example: “amqp://localhost:5672/vhost”.
usernamestringnoOptional username for authentication.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

MQTT

Schemes: mqtt://, mqtts://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
clean_sessionbooleannofalse(Consumer only) If true, start with a clean session. Defaults to false (persistent session). Setting this to true effectively enables Subscriber mode (ephemeral).
client_idstringnoOptional client ID. If not provided, one is generated or derived from route name.
delayed_ackbooleannofalse(Consumer only) If true, messages are acknowledged immediately upon receipt (auto-ack). If false (default), messages are acknowledged after processing (manual-ack). Note: For QoS 1/2 the publisher always waits for end-to-end broker confirmation (PUBACK/PUBCOMP) before reporting success, independent of this setting; QoS 0 remains fire-and-forget.
keep_alive_secondsintegernoKeep-alive interval in seconds. Defaults to 20.
max_inflightintegernoMaximum number of inflight messages.
passwordstringnoOptional password for authentication.
protocolv5 | v3nov5MQTT protocol version (V3 or V5). Defaults to V5.
qosintegernoQuality of Service level (0, 1, or 2). Defaults to 1.
queue_capacityintegernoCapacity of the internal channel for incoming messages. Defaults to 100.
session_expiry_intervalintegernoSession expiry interval in seconds (MQTT v5 only).
tlsobjectnosee belowTLS configuration.
topicstringnoThe MQTT topic.
urlstringyesMQTT broker URL (e.g., “tcp://localhost:1883”). Does not support multiple hosts. If it contains userinfo, it will be treated as a secret.
usernamestringnoOptional username for authentication.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

Redis Streams

Schemes: redis://, rediss://, redis_streams://

Query parameters recognised as config fields for this connector. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
approx_trimbooleanno(Publisher) Use approximate (~) trimming when maxlen is set. Defaults to true.
block_msintegerno(Consumer) Block timeout in milliseconds for each read. Defaults to 5000ms.
consumer_namestringno(Consumer) Consumer name within the group. Defaults to a unique per-instance id.
groupstringno(Consumer) Group name. Defaults to {APP_NAME}-{stream}; ignored in subscriber_mode.
internal_buffer_sizeintegernoInternal buffer size for the consumer channel. Defaults to 128.
maxlenintegerno(Publisher) If set, cap the stream length with XADD MAXLEN.
passwordstringnoOptional password for authentication.
read_from_startbooleannofalse(Consumer) On group creation, start from the stream beginning (“0”) not “$”. Default false.
reader_connectionsintegerno(Consumer) Parallel XREADGROUP reader connections fanned out across the group. Default 1. Ignored in subscriber_mode.
redelivery_timeout_msintegerno(Consumer) Redeliver entries pending ≥ this long via XAUTOCLAIM; 0 disables. Default 60000ms.
streamstringnoThe stream key to publish to or read from. Defaults to the route name.
subscriber_modebooleannofalse(Consumer) Read ephemerally via XREAD from new messages (no group/acks). Default false.
urlstringyesRedis URL, redis:// or rediss:// for TLS. Userinfo is treated as a secret.
usernamestringnoOptional username for authentication (Redis ACL).

ClickHouse

Schemes: clickhouse://, clickhouses://

Query parameters recognised as config fields for this connector. The object-typed columns is set with a JSON literal, e.g. ?columns={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
async_insertbooleannofalse(Publisher only) If true, set the ClickHouse async_insert=1 server setting so inserts are buffered server-side. Defaults to false.
checkpoint_storestringno(Consumer only) Where to persist the resume cursor. Because ClickHouse is unsuited to per-row cursor upserts, a durable checkpoint requires an external store URL: - file:///var/lib/mqb/cursors.json → local JSON file - postgres://user@host/db/table / mysql://host/db/table → external SQL table (table optional) - mongodb://host/db/collection → external MongoDB collection (collection optional) - s3://bucket/prefix (also gs://, az://, abfs://) → cloud object store; creds via env May embed connection credentials, so it is treated as a secret.
columnsobjectno(Publisher only) Optional per-column mapping. Each entry maps a target column name to a value token: ${payload:<field>} takes the top-level JSON field <field> of the payload (JSON type preserved), ${metadata:<key>} takes message.metadata["<key>"] (as a string), and any other value is inserted literally. When omitted, the whole payload JSON object is inserted as one row.
compressionnone | gzip | lz4 | zstdnogzipHTTP body compression for inserts and cursor reads (none, gzip, lz4, zstd). Applied as Content-Encoding on the request body and negotiated on the response via Accept-Encoding. lz4/zstd are faster than gzip; all are understood natively by ClickHouse. Defaults to gzip.
connect_timeout_msintegernoConnection (TCP + TLS handshake) timeout in milliseconds. Defaults to 10000ms.
cursor_columnstringno(Consumer only) Read an existing table non-destructively and resumably, paging by this monotonic column (SELECT … WHERE {cursor_column} > {last} ORDER BY {cursor_column} ASC LIMIT n) and persisting the last read value under cursor_id.
cursor_idstringno(Consumer only) Cursor id used to key the persisted resume position. Without it, progress is not persisted and every restart re-copies from the beginning.
databasestringnoDatabase name. Defaults to default.
max_polling_interval_msintegerno(Consumer only) If set, the poll interval backs off exponentially from polling_interval_ms up to this value while drained, resetting on new rows. Unset = constant interval.
passwordstringnonullOptional password. Takes precedence over any credentials embedded in the url.
polling_interval_msintegerno(Consumer only) Polling interval in milliseconds when the table is drained. Defaults to 100ms.
request_timeout_msintegernoRequest timeout in milliseconds for ClickHouse HTTP calls (inserts, cursor reads, status). Unset = no timeout (wait indefinitely), which suits very large batch inserts.
select_columnsstringno(Consumer only) Columns to select in cursor_column mode. Defaults to *.
tablestringyesThe table to read from / write to. May be schema-qualified (db.table).
tlsobjectnosee belowTLS configuration for https:// connections.
urlstringyesClickHouse HTTP endpoint URL, e.g. http://localhost:8123 (or https://…). If it contains userinfo, it will be treated as a secret.
usernamestringnonullOptional username. Takes precedence over any credentials embedded in the url. Defaults to default.
wait_for_async_insertbooleannonull(Publisher only) With async_insert, wait for the server to flush before acking. Defaults to true (durable). False = fire-and-forget: faster, but a crash before flush can drop the batch.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

HTTP

Schemes: http://, https://

Query parameters recognised as config fields for this connector. The object-typed custom_headers is set with a JSON literal, e.g. ?custom_headers={...}. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
basic_autharray of anynoHTTP Basic Authentication credentials (username, password). For consumers: validates incoming requests. For publishers: adds Authorization header.
batch_concurrencyintegerno(Publisher only) The number of concurrent HTTP requests to send in a batch. Defaults to 20.
compressionnone | gzip | lz4 | zstdnonone(Publisher only) Codec for the request body (none, gzip, lz4, zstd); overrides compression_enabled. lz4 is non-standard (mq-bridge peers only). Ignored on a consumer — enable response compression with compression_enabled. Defaults to none.
compression_enabledbooleannoTurns compression on. Publisher: compress the request body with gzip (unless compression sets another codec). Consumer: compress responses, negotiating the best codec the client’s Accept-Encoding accepts. Defaults to off.
compression_threshold_bytesintegernonullMinimum message size in bytes to compress. Messages smaller than this are sent uncompressed. Defaults to 1024 bytes.
concurrency_limitintegerno(Consumer only) Maximum number of concurrent requests to handle. Defaults to 100.
custom_headersobjectnoCustom headers as key-value pairs (e.g., {“X-API-Key”: “token123”}). Added to outgoing HTTP headers for both consumers and publishers.
fire_and_forgetbooleannofalse(Consumer only) If true, respond immediately with 202 Accepted without waiting for downstream processing. Defaults to false.
inline_response_fast_pathbooleannotrue(Consumer only) If true, compatible http -> response routes may bypass the normal route consumer/worker/disposition pipeline and reply inline for lower latency. Defaults to true. Set to false to force the normal route path.
internal_buffer_sizeintegerno(Consumer only) Internal buffer size for the channel. Defaults to 100.
message_id_headerstringno(Consumer only) Header key to extract the message ID from. Defaults to “message-id”.
methodstringno(Optional) HTTP method. For publishers: the method to use (defaults to POST). For consumers: restrict to this method (others return 405).
pathstringno(Consumer only) Optional request path filter. If set, only requests whose URI path matches exactly are delivered to this consumer.
pool_idle_timeout_msintegerno(Publisher only) Timeout for idle connections in the connection pool in milliseconds. Defaults to 90000ms.
receive_streamablebooleannofalse(Consumer only) If true, read request bodies as a stream and emit each received stream item as a separate message.
request_timeout_msintegernoTimeout for HTTP requests in milliseconds. For consumers, it’s the request-reply timeout. For publishers, it’s the timeout for each individual request. Defaults to 30000ms.
server_protocolauto | http1_only | http2_onlynoauto(Consumer only) Restrict which HTTP protocol versions a server listener accepts. Defaults to auto (HTTP/1.1 + HTTP/2). On cleartext listeners, http2_only means HTTP/2 prior-knowledge (h2c) only.
sharedbooleannotrue(Publisher only) Share one HTTP client per connection (default: true); false forces a dedicated client.
stream_response_toobjectno(Publisher only) Optional endpoint that receives streamed HTTP response items as correlated messages. Use a stream_buffer endpoint here when callers need to read streamed response items later through a normal mq-bridge consumer. Each streamed item is published with correlation_id, http_stream_id, http_stream_index, http_stream_format, and http_stream_end metadata. If the request message has no correlation_id, the HTTP publisher uses format!("{:032x}", request.message_id) so callers can derive the consumer correlation id before calling send.
tcp_keepalive_msintegerno(Publisher only) TCP keepalive timeout for the underlying connection pool in milliseconds. Defaults to 60000ms.
tlsobjectnosee belowTLS configuration.
urlstringyesFor consumers, the listen address (e.g., “0.0.0.0:8080”). For publishers, the target URL.
workersintegerno(Consumer only) Number of worker threads to use. Defaults to 0 for unlimited.

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

WebSocket

Schemes: ws://, wss://

Query parameters recognised as config fields for this connector. Any other ?key=value pair is passed through unchanged as a driver option on the connection URL.

NameTypeRequiredDefaultDescription
backlogintegerno(Consumer only) TCP listen backlog (pending-connection queue depth) for the accept socket. Raise this if high-concurrency handshake bursts are being dropped/reset before accept() can keep up. Defaults to 4096, which is higher than the OS/tokio default of 1024.
execution_modeauto | direct_only | routednoauto(Consumer only) Selects whether WebSocket routes run directly or through the routed pipeline.
message_id_headerstringno(Consumer only) Header key to extract the message ID from the WebSocket handshake. Defaults to “message-id”.
pathstringno(Consumer only) Optional request path filter. If set, only upgrade requests whose URI path matches exactly are delivered to this consumer.
routed_queue_capacityintegerno(Consumer only) Queue capacity for the routed adapter. Direct response routes do not use this queue. Defaults to 100.
urlstringyesFor consumers, the listen address (e.g. “0.0.0.0:9000”). For publishers, the target URL.

gRPC

Schemes: grpc://, grpcs://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
concurrency_limit_per_connectionintegernonullMaximum number of concurrent requests handled per connection. Server-mode only.
http2_keepalive_interval_msintegernonullHTTP/2 keepalive ping interval in milliseconds. Server-mode only. Default disabled
http2_keepalive_timeout_msintegernonullTimeout for a keepalive ping acknowledgement in milliseconds. Server-mode only.
initial_connection_window_sizeintegernonullHTTP/2 connection-level initial window size in bytes. Server-mode only.
initial_stream_window_sizeintegernonullHTTP/2 stream-level initial window size in bytes. Server-mode only.
max_decoding_message_sizeintegernonullMaximum size of a decoded incoming message in bytes. Server-mode only. Default 4 MiB.
server_modebooleannofalseIf true, start an embedded tonic gRPC server that accepts incoming Publish / PublishBatch RPCs. If false (the default), connect to a remote server as a client.
sharedbooleannotrue(Publisher only) Share one gRPC channel per connection (default: true); false forces a dedicated channel.
timeout_msintegernoTimeout in milliseconds. - Client mode: used as the connection timeout and per-request deadline. - Server mode: applied as the per-request deadline on the embedded server.
tlsobjectnosee belowTLS configuration.
topicstringnoTopic / subject used for both subscribe and publish paths.
urlstringyesThe gRPC server URL (e.g., “http://localhost:50051” for client or “0.0.0.0:50051” for server mode).

Struct-typed fields

tls

TLS configuration for secure connections.

Configures Transport Layer Security (TLS/SSL) for encrypted communication. Supports both client certificate (mutual TLS) and server certificate validation.

Examples

use mq_bridge::models::TlsConfig;

let tls = TlsConfig {
    required: true,
    ca_file: Some("/path/to/ca.pem".to_string()),
    cert_file: Some("/path/to/cert.pem".to_string()),
    key_file: Some("/path/to/key.pem".to_string()),
    ..Default::default()
};
NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
ca_filestringnoPath to the CA certificate file.
cert_filestringnoPath to the client certificate file (PEM).
cert_passwordstringnoPassword for the private key (if encrypted).
key_filestringnoPath to the client private key file (PEM).
requiredbooleannofalseIf true, enable TLS/SSL.

ZeroMQ

Schemes: zeromq://, zmq://

Query parameters recognised as config fields for this connector. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
backendzmq | omqnozmqBackend: zmq (default, the zeromq crate) or omq (the omq-tokio PoC — PUSH/PULL + PUB/SUB only). omq needs the zeromq-omq build feature.
bindbooleannofalseIf true, bind to the address. If false, connect.
formatjson | raw | raw_framednojsonWire format: json wraps the CanonicalMessage; raw sends payload bytes per frame; raw_framed adds a JSON metadata frame. Default json.
internal_buffer_sizeintegernonullInternal buffer size for the channel. Defaults to 128.
socket_typepush | pull | pub | sub | req | repnonullThe socket type (PUSH, PULL, PUB, SUB, REQ, REP).
topicstringno(Consumer only) The ZeroMQ topic (for SUB sockets).
urlstringyesThe ZeroMQ URL (e.g., “tcp://127.0.0.1:5555”).

IBM MQ

Schemes: ibmmq://, ibm-mq://

Query parameters recognised as config fields for this connector. The object-typed tls is set with a JSON literal, e.g. ?tls={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
channelstringyesRequired. Server Connection (SVRCONN) Channel name defined on the QM.
disable_status_inqbooleannofalseIf false, attempt to open the queue with INQUIRE permissions to fetch queue depth for status checks. Defaults to false.
internal_buffer_sizeintegernonullInternal buffer size for the channel. Defaults to 100.
max_message_sizeintegerno4194304Maximum message size in bytes (default: 4MB). Optional.
passwordstringnoPassword for authentication. Optional; required if the channel enforces authentication.
queuestringnoTarget Queue name for point-to-point messaging. Optional if topic is set; defaults to route name if omitted.
queue_managerstringyesRequired. Name of the Queue Manager to connect to (e.g., QM1).
tlsobjectnosee belowTLS configuration settings (e.g., keystore paths). Optional.
topicstringnoTarget Topic string for Publish/Subscribe. If set, enables Subscriber mode (Consumer) or publishes to a topic (Publisher). Optional if queue is set.
urlstringyesRequired. Connection URL in host(port) format. Supports comma-separated list for failover (e.g., host1(1414),host2(1414)). If it contains userinfo, it will be treated as a secret.
usernamestringnoUsername for authentication. Optional; required if the channel enforces authentication
wait_timeout_msintegerno1000(Consumer only) Polling timeout in milliseconds (default: 1000ms). Optional.

Struct-typed fields

tls

TLS configuration for the IBM MQ native client.

The IBM MQ client doesn’t consume PEM files, so this uses MQ-native field names rather than the generic [TlsConfig] used by the other endpoints.

NameTypeRequiredDefaultDescription
accept_invalid_certsbooleannofalseIf true, disable server certificate verification (insecure).
cert_filestringnoFor IBM MQ this is the CMS key repository stem (e.g. /path/to/tls for tls.kdb/tls.sth), not a PEM file. Exposed as cert_file for config parity with the generic TlsConfig; the MQ-native name key_repository is still accepted.
cert_passwordstringnoPassword unlocking the key repository. Requires an IBM MQ client/server at 9.3.0.0+. Exposed as cert_password for parity with TlsConfig; alias key_repository_password.
cipher_specstringnoTLS CipherSpec (e.g., ANY_TLS12). Required for encrypted connections. IBM MQ-specific.
key_repositorystringnoMQ-native alias for cert_file: the CMS key repository stem (e.g. /path/to/tls for tls.kdb/tls.sth).
key_repository_passwordstringnoMQ-native alias for cert_password: password unlocking the key repository. Requires an IBM MQ client/server at 9.3.0.0+.
requiredbooleannofalseIf true, enable TLS/SSL.

AWS SQS / SNS

Schemes: aws://, aws-sqs://

Query parameters recognised as config fields for this connector. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
access_keystringnoAWS Access Key ID.
binary_payload_modebooleannofalseUse binary payloads in SQS/SNS messages.
endpoint_urlstringnoCustom endpoint URL (e.g., for LocalStack).
max_messagesintegerno(Consumer only) Maximum number of messages to receive in a batch (1-10).
queue_urlstringnoThe SQS queue URL. Required for Consumer. Optional for Publisher if topic_arn is set. If it contains userinfo, it will be treated as a secret.
regionstringnoAWS Region (e.g., “us-east-1”).
secret_keystringnoAWS Secret Access Key.
session_tokenstringnoAWS Session Token.
topic_arnstringno(Publisher only) The SNS topic ARN.
wait_time_secondsintegerno(Consumer only) Wait time for long polling in seconds (0-20).

File (CSV / JSON / JSONL)

Schemes: file://

Query parameters recognised as config fields for this connector. The object-typed encryption is set with a JSON literal, e.g. ?encryption={...}. Unrecognised parameters are not forwarded as driver options, so any other ?key=value pair is rejected rather than silently ignored.

NameTypeRequiredDefaultDescription
compressionnone | gzip | lz4 | zstdnononePer-batch compression (none, gzip, lz4, zstd). Requires the compression feature; consume mode only.
deletebooleannofalseIf true, processed lines are physically removed from the file once they are successfully acknowledged.
delimiterstringnoOptional delimiter for messages. Defaults to newline (“\n”). Can be a string or a hex sequence (e.g. “0x00”). Currently only single-byte delimiters are supported.
encryptionobjectnonullAt-rest AEAD encryption applied after compression. Requires the encryption feature; consume mode only.
formatnormal | json | text | raw | csvnonormalThe format for writing messages to the file (Publisher) or interpreting them (Consumer). Defaults to normal.
group_idstringnoThe consumer group ID that is used for offset tracking. Should be unique.
modeconsume | subscribe | group_subscribeno
pathstringyesPath to the file.
read_from_tailbooleannofalseIf true, starts reading from the end of the file if no offset is stored. If false, starts reading from the beginning.

Middleware & Structural Endpoint Reference

Complete listing of every middleware and every structural endpoint mq-bridge ships.

Structural endpoints are the ones that do not talk to a broker or store: they compose other endpoints, shape routing, or terminate a request. Data endpoints (kafka, nats, mqtt, sqlx, …) are covered in README.md and CONFIGURATION.md.


Middleware

Middleware attaches to an endpoint via a middlewares: list, on the input, the output, or both:

my_route:
  input:
    middlewares:
      - deduplication: { sled_path: "/var/lib/mqb/dedup", ttl_seconds: 3600 }
    kafka: { topic: "orders", url: "localhost:9092" }
  output:
    middlewares:
      - retry: { max_attempts: 5 }
      - dlq: { endpoint: { file: { path: "failed.jsonl" } } }
    nats: { subject: "orders.processed", url: "nats://localhost:4222" }

Ordering — read this before combining middleware

Output (publisher) middlewares wrap in list order, so the last entry is the outermost layer and sees the failures of the ones before it. Put dlq last.

Input (consumer) middlewares are applied in reverse, so the first entry is outermost and runs first on an incoming message.

This is asserted by route::tests::test_dlq_and_retry_batch_integration, middleware::transform::tests::test_rejected_message_reaches_the_dlq_through_the_config_wiring, and reference_docs_test::publisher_middleware_wraps_last_entry_outermost, and is documented on apply_middlewares_to_publisher in src/middleware/mod.rs.

# Correct: transform rejects -> retry gives up -> dlq captures.
middlewares:
  - transform: { schema_file: "user.json" }
  - retry: { max_attempts: 3 }
  - dlq: { endpoint: { file: { path: "rejected.jsonl" } } }

What exists

NameInputOutputFeaturePurpose
retryExponential-backoff retry of failed sends
dlqRoute permanently-failed messages to another endpoint
transformDeclarative JSON mapping, coercion, validation
deduplicationdedupDrop repeated message IDs within a TTL
weak_joinCorrelate and join related messages
bufferCoalesce single sends into batches
limiterCap throughput to a message rate
delayFixed delay per receive/send
cookie_jarPersist HTTP cookies / session values across messages
encryptionencryptionAEAD-encrypt payloads on send, decrypt on receive
compressioncompressionCompress payloads on send, decompress on receive
metricsmetricsEmit throughput/latency/error metrics
random_panicFault injection for testing
customYour own middleware via a registered factory

Two kinds of compression. The compression middleware compresses each message payload on any transport and decompresses it on the far side. Separately, the batch compression field on the file and object_store endpoints (none / gzip / lz4 / zstd, same compression feature) compresses whole write batches so the file stays decodable with zcat / lz4 -d. Use the field for CLI-readable data at rest, the middleware for over-the-wire payloads. Don’t stack either compression with the encryption middleware on the same route — ciphertext does not compress; for compressed-and-encrypted data at rest use the endpoints’ own compression/encryption fields (compress-then-encrypt per batch).

Putting a middleware on the wrong side behaves in two different ways, so check the table above rather than assuming:

  • deduplication on an output, and dlq / retry on an input, log a warning and are skipped. The route still starts.
  • weak_join on an output is a hard startup error (Unsupported publisher middleware).

A middleware whose feature is not compiled in (deduplication without dedup, metrics without metrics) is likewise a startup error, not a silent no-op.


retry

Retries failed sends with exponential backoff. Output only.

FieldTypeDefault
max_attemptsinteger3
initial_interval_msinteger100
max_interval_msinteger5000
multiplierfloat2.0
- retry: { max_attempts: 5, initial_interval_ms: 200, max_interval_ms: 10000, multiplier: 2.0 }

Only Retryable and connection errors are retried; NonRetryable failures pass straight through. Once attempts are exhausted the error is marked so a following dlq treats it as permanent. Pair the two.

dlq

Sends permanently-failed messages to a separate endpoint instead of failing the batch. Output only.

FieldTypeRequired
endpointEndpointyes
- dlq:
    endpoint:
      file: { path: "dead-letters.jsonl" }

Captures NonRetryable failures and Retryable ones whose retries are exhausted. Connection errors are not dead-lettered — they propagate so the route can reconnect. The DLQ endpoint is a full endpoint, so it can itself have middleware. If the DLQ send fails with a connection error that error propagates rather than silently dropping the message.

Without a dlq middleware, a message that fails permanently — a data/type error the sink rejects, a poison payload a handler rejects — is logged at error level and dropped, and the route keeps processing the rest of the batch. dlq is the only retention mechanism: retry alone does not retain a permanently-failed message nor prevent it from being dropped — it only re-attempts retryable/connection errors, then hands a still-failing message on to be dropped (or to a following dlq). This tolerate-and-continue policy keeps one bad message from halting the whole stream, but it means a systematic failure (e.g. every row hitting a column-type mismatch) drains the input while committing nothing and still ends completed. Add a dlq to capture the failures for inspection/replay, or watch the route’s logs — a burst of Dropping message … due to non-retryable error is the signal. Note that transient errors are handled separately: several endpoints retry connection/timeout errors internally, and the retry middleware adds backoff on top, so only genuinely permanent errors reach this drop path.

transform

Declarative JSON reshaping: field mapping, then schema-directed coercion, defaults and validation — over a single parse. Input and output.

FieldTypeDefault
mappingmap of output field → rule{}
schemainline JSON Schema subset
schema_filepath to a schema file
coercebooltrue
apply_defaultsbooltrue
on_errorreject | pass_throughreject

schema and schema_file are mutually exclusive. A mapping rule is either a bare path string or { path, default, required }.

- transform:
    mapping:
      firstName: "$.first_name"
      id: "$.user_id"
      "address.city": { path: "$.city", default: "unknown" }
    schema_file: "schemas/user.json"

Paths accept $.field, $.a.b, and $.items[0]; the $. prefix is optional. Dots in the output key nest the result. An absent optional source field is omitted rather than emitted as null.

Schema keywords honoured: type, properties, required, default, items, nullable (also "type": ["string","null"]), enum, contentMediaType, contentSchema. Everything else is ignored, so an existing fuller schema can be used as-is. Coercions are limited to the lossless ones: string → integer, string → number, string → boolean (true/false/1/0), number → string.

Embedded JSON

A field carrying a JSON document as a string is decoded by contentMediaType, following JSON Schema 2020-12:

- transform:
    schema:
      type: object
      properties:
        payload:
          type: string
          contentMediaType: application/json
          contentSchema:
            type: object
            properties:
              qty: { type: integer }

The string is replaced by the parsed document, and contentSchema — if given — is applied to it with the same coercion, defaults and validation as anywhere else, so the inner qty: "7" arrives as 7. Without contentSchema the value is parsed but not validated. A root-level schema of this shape decodes a double-encoded message body.

This is not a coercion, and coerce: true never performs it: widening "42" to 42 is lossless, whereas evaluating a string as a document is a parse that can succeed on input never meant as JSON. It is opt-in per field, as the JSON Schema spec requires. Note that the spec treats contentSchema as annotation-only; applying it is the opt-in behaviour it carves out.

Media types ending in +json (and text/json) are decoded too; parameters like ; charset=utf-8 are ignored. A media type we cannot decode, or one paired with a contentEncoding, leaves the string untouched rather than failing. A string that does not parse fails with kind content: transform failed at $.payload [content]: contentMediaType is JSON but the string does not parse: ....

Failures are always non-retryable and name the field, e.g. transform failed at $.items[1].qty [coercion]: cannot coerce string "oops" to integer. On an output endpoint the message is failed so a following dlq captures it; on an input endpoint it is dropped from the batch and acknowledged, keeping invalid data out of the route. on_error: pass_through instead forwards the original payload with the reason in the mqb.transform_error metadata key, which a switch can route on.

Schemas and paths compile once at startup; schema_file is read a single time. A transform with neither stage configured leaves the payload untouched without parsing it.

deduplication

Drops messages whose ID was already seen within the TTL. Input only. Requires the dedup feature (pulls sled).

FieldTypeRequired
storestringone of store/sled_path
sled_pathstringone of store/sled_path
ttl_secondsintegeryes

store selects the backend by URL scheme:

  • sled:///path (or a bare path) — a local sled database; per-process, not cluster-wide.
  • mongodb://host/db[/collection] — a shared collection, so multiple instances of a route deduplicate against one another. Requires the mongodb feature. Entries expire via a MongoDB TTL index; the collection defaults to mqb_dedup_<route>. Point it at the same deployment your sink already uses to avoid running extra infrastructure.
  • postgres|mysql|mariadb|sqlite://…[/table] — a shared SQL table (dedup_key PK, expire_at), so multiple instances deduplicate against one another. Requires the sqlx feature. SQL has no native TTL, so expired rows are swept periodically; the table defaults to mqb_dedup_<route>.

sled_path is the legacy spelling of a local sled store and is equivalent to store: "sled://<path>".

- deduplication: { store: "sled:///var/lib/mq-bridge/dedup", ttl_seconds: 3600 }
- deduplication: { store: "mongodb://localhost:27017/etl", ttl_seconds: 3600 }
- deduplication: { store: "postgres://user:pass@localhost/etl", ttl_seconds: 3600 }

When MongoDB is your sink and messages carry a business key, prefer the sink’s own unique index (id_field on the mongodb output) over this middleware — the target collection then is the deduplication authority, with no second write. See the idempotency notes in README.

weak_join

Correlates messages by a metadata key and emits them as one joined message. Input only.

FieldTypeDefault
group_bystring (metadata key)required
expected_countintegerrequired
timeout_msintegerrequired
branch_bystring (metadata key)
requiredlist of branch names[]
on_timeoutfire | discardfire
# Count mode: wait for any 3 messages sharing a correlation_id, emit a JSON array.
- weak_join: { group_by: "correlation_id", expected_count: 3, timeout_ms: 5000 }

# Branch mode: wait for named branches, emit a branch-keyed JSON object.
- weak_join:
    group_by: "correlation_id"
    expected_count: 2
    timeout_ms: 5000
    branch_by: "source"
    required: ["inventory", "pricing"]
    on_timeout: discard

Setting branch_by switches to branch mode, where required overrides expected_count. On timeout an incomplete group is either emitted partially (fire) or dropped (discard). Messages are acknowledged on receipt, so a crash before the group completes loses the buffered members.

buffer

Accumulates single sends and forwards them as one batch. Input and output.

FieldTypeRequired
max_messagesintegeryes
max_delay_msintegeryes
- buffer: { max_messages: 500, max_delay_ms: 20 }

Flushes when either bound is hit. Useful in front of an endpoint whose per-call overhead dominates. Adds up to max_delay_ms of latency.

limiter

Paces throughput to a target rate. Input and output.

FieldTypeRequired
messages_per_secondfloat (> 0)yes
- limiter: { messages_per_second: 250 }

Best-effort pacing that accounts for batch size, not just call count.

delay

Sleeps a fixed duration before each receive or send. Input and output.

FieldTypeRequired
delay_msintegeryes
- delay: { delay_ms: 100 }

Mainly for testing and for crude pacing of a downstream system; prefer limiter for real rate control.

Persists HTTP cookies and arbitrary session values across messages. Input and output.

FieldTypeDefault
shared_scopestring– (per-instance store)
cookie_metadata_keystringcookie
set_cookie_metadata_keystringset-cookie
capture_metadata_keyslist of strings[]
export_metadata_prefixstring
inject_metadatamap string→string{}
- cookie_jar:
    shared_scope: "login-session"
    capture_metadata_keys: ["x-csrf-token"]
    export_metadata_prefix: "session."

Reads set-cookie from responses and injects cookie into later requests. With shared_scope, instances using the same name share one store across endpoints and routes in the process — that is how a login route and a data route reuse one session.

encryption

Encrypts each message payload into a self-describing AEAD envelope on the output side and decrypts it on the input side. Metadata and routing keys stay in the clear. Input and output. Requires the encryption feature.

FieldTypeDefault
cipherxchacha20poly1305 | aes256gcmxchacha20poly1305
key_idstringdefault
keystring — base64-encoded 32-byte key; ${env:VAR} reads it from the environmentrequired
decrypt_keysmap key_id → key{}
- encryption: { key: "${env:MQB_ENC_KEY}" }

The envelope records the cipher and key_id, so key rotation works by sealing with a new key_id/key while listing the old key under decrypt_keys on the consuming side. Each payload is authenticated independently: any bit-level tampering, a torn frame, or a missing/wrong key is a hard consumer error, not a silent drop. The AEAD binds only the payload (empty associated data): metadata and routing keys are not authenticated against the ciphertext, since they are not guaranteed to survive transport round-trips (many endpoints regenerate the message_id or drop kind). A sealed payload can therefore be replayed under different metadata; use the deduplication middleware or a sink uniqueness constraint if that matters. Note that this authenticates each payload, not the file as a whole — like any append-structured file, an at-rest file that loses whole trailing frames (truncation at a frame boundary) reads back as a shorter stream with no error, so rely on the consumer’s checkpoint/cursor for completeness rather than on the encryption layer.

Do not combine this middleware with a sink’s batch compression on the same route: ciphertext does not compress. For compressed and encrypted data at rest, use the file / object_store endpoints’ own fields instead, which apply compress-then-encrypt per batch:

output:
  file:
    path: "data.enc"
    format: raw
    compression: lz4          # none | gzip | lz4 | zstd  (`compression` feature)
    encryption: { key: "${env:MQB_ENC_KEY}" }

Both endpoints accept the same compression and encryption fields (object_store derives its default object extension from them, e.g. .jsonl.gz / .jsonl.lz4, and adds a trailing .enc when encryption is on since the object is ciphertext, not a directly decompressible .gz). An encrypted file is written as length-prefixed sealed frames (one per batch) and is only readable through a matching consumer; a compressed-only file stays a standard .gz/.lz4 stream. File compression/encryption supports only the default consume mode. csv works too: the header row is written into the first member, so the decoded stream is a normal CSV file.

A file source must declare the same compression/encryption the data was written with. A mismatch (wrong key, wrong codec, or a missing field) is a permanent decode failure: the route ends failed with the error in its status, rather than completing as if the file were empty. Reading a compressed file with no compression set is likewise rejected up front by sniffing the leading magic bytes, so raw compressed bytes are never emitted as messages.

f64 precision. Numbers move through payloads as JSON. serde_json’s default parser shifts ~1 ULP on ~19% of 17-significant-digit doubles, so a postgres → file → postgres hop of a double precision column can change the last bit. Build with the float-roundtrip feature for bit-exact float parsing across every endpoint (it trades a little parse speed for it).

compression

Compresses each message payload on the output side and decompresses it on the input side. Metadata and routing keys are untouched. Input and output. Requires the compression feature.

FieldTypeDefault
algorithmnone | gzip | lz4 | zstdzstd
max_decompressed_bytesinteger — reject a payload that decompresses larger than this (bomb guard); consumer side onlyunset (no limit)
- compression: { algorithm: zstd }

Each payload is compressed independently into a single self-contained member, so this works over any transport, not just files. algorithm: none is a passthrough. A truncated or corrupt frame is a permanent consumer error (the poison message is not re-read indefinitely), as is a payload that exceeds max_decompressed_bytes. Put the same algorithm on both the input and output side of a route.

Unlike the file / object_store batch compression field — which keeps whole write batches decodable with zcat / lz4 -d — this middleware frames per message and is only readable through a matching consumer. Do not combine it with the encryption middleware (ciphertext does not compress); for compressed-and-encrypted data at rest, use the endpoints’ own compression/encryption fields instead.

metrics

Emits throughput, latency and error metrics for the endpoint. Input and output. Requires the metrics feature. Takes no options; its presence enables collection.

- metrics: {}

Input and output are labelled separately, so attaching it to both sides is meaningful.

random_panic

Deliberate fault injection for testing recovery paths. Input and output.

FieldTypeDefault
modepanic | disconnect | timeout | json_format_error | nackpanic
trigger_on_messageinteger (1-indexed)– (every message)
enabledbooltrue
- random_panic: { mode: disconnect, trigger_on_message: 500 }

disconnect and timeout produce retryable errors; json_format_error produces a non-retryable one — useful for exercising a dlq. Keep enabled: false in committed configs rather than deleting the block.

custom (middleware)

Delegates to a factory you registered programmatically.

FieldTypeRequired
namestringyes
configany JSONyes
- custom:
    name: "my_enricher"
    config: { lookup_url: "http://enrich.internal" }

Implement CustomMiddlewareFactory (apply_consumer and/or apply_publisher, each defaulting to pass-through) and register it before starting routes. See ARCHITECTURE.md.


Structural endpoints

These appear wherever an endpoint is expected — as a route input/output, or nested inside another structural endpoint.

NameInputOutputPurpose
refReuse an endpoint defined elsewhere by name
fanoutSend every message to all listed endpoints
switchContent-based routing on a metadata key
requestCall a request/reply endpoint, forward the response onward
responseReply to the origin of the current request
readerUse an incoming message as a trigger to pull from a consumer
staticFixed, pre-rendered message
stream_bufferCorrelation-partitioned in-memory stream
nullDiscard everything
customYour own endpoint via a registered factory

They live under src/endpoints/structural/, and each of the variants above carries "format": "structural_endpoint" in the generated JSON schema (mq-bridge.schema.json), so external tooling can tell them apart from the transport endpoints.

ref

Reuses an endpoint registered under a name, instead of repeating its configuration.

The name is a registry key, not a topic name. Register it from Rust before starting the routes:

use mq_bridge::models::Endpoint;
use mq_bridge::route::register_endpoint;

register_endpoint("common_queue", Endpoint::new_memory("shared_memory_topic", 100));
enrich:
  input: { ref: "common_queue" }
  output: { nats: { subject: "enriched", url: "nats://localhost:4222" } }

A route can also publish its own output under a name with Route::register_output_endpoint(Some("name")), which is how one route’s output becomes another’s input.

The value is a bare string. Resolution looks in the endpoint registry first, then in registered publishers. Middleware on the ref itself is applied outside the referenced endpoint’s own middleware. Circular references are detected and rejected at startup, and nesting depth is bounded.

fanout

Publishes each message to every listed endpoint. Output only.

output:
  fanout:
    - kafka: { topic: "audit", url: "localhost:9092" }
    - file: { path: "audit.jsonl" }
    - nats: { subject: "audit", url: "nats://localhost:4222" }

The value is a plain list of endpoints, each of which may have its own middleware and may itself be structural. All branches receive the same message.

switch

Content-based routing: picks a destination by the value of a metadata key.

FieldTypeRequired
metadata_keystringyes
casesmap value → Endpointyes
defaultEndpointno
output:
  switch:
    metadata_key: "http_status_code"
    cases:
      "200": { nats: { subject: "ok", url: "nats://localhost:4222" } }
      "404": { file: { path: "not-found.jsonl" } }
    default: { file: { path: "other.jsonl" } }

Matching is on the metadata value, not the payload — it does not read JSON fields. To route on payload content, first promote the value into metadata (for example with transform’s on_error: pass_through, which sets mqb.transform_error, or an endpoint that emits a status key such as http_status_code). A message whose key is missing or unmatched goes to default; without a default it is dropped.

request

Sends each message to a request-capable endpoint and forwards the response somewhere else, turning a request/reply exchange into a one-way flow.

FieldTypeRequired
toEndpoint (request-capable)yes
forward_toEndpointyes
output:
  request:
    to: { http: { url: "https://api.internal/score" } }
    forward_to: { ibmmq: { queue: "RESULTS", url: "mq(1414)", queue_manager: "QM1", channel: "APP.SVRCONN" } }

to must support request/reply: http, or a nats/mongodb/memory endpoint with request_reply: true. On error or timeout the original message is forwarded instead of a response, so nothing is lost — distinguish the two downstream with a switch on a status key such as http_status_code.

response

Replies to the origin of the current request. Output only, and the recommended way to build request/reply routes.

http_echo:
  input: { http: { url: "0.0.0.0:8080" } }
  output: { response: {} }

Takes no options. Requires an input that carries a reply channel (http, websocket, grpc, or a request/reply nats/mongodb/memory). With an http or websocket input and no middleware, response (and static) enables an inline fast path that skips the normal route pipeline. See README.md.

reader

An output endpoint that ignores the incoming payload and instead reads one message from the wrapped consumer, returning it as the response. The inbound message is purely a trigger.

# HTTP GET pulls the next message off a Kafka topic.
poll_api:
  input: { http: { url: "0.0.0.0:8080", method: "GET" } }
  output:
    reader:
      kafka: { topic: "queue", url: "localhost:9092" }

The value is a single nested endpoint, which must be valid as a consumer. The message read is acknowledged immediately, before the caller has necessarily received it — so a crash in between loses it. Use it for polling APIs, not for guaranteed delivery.

static

A fixed, pre-rendered message. Usable as an output (a constant reply) or an input (a constant source).

FieldTypeDefault
bodystringrequired
rawboolfalse
metadatamap string→string{}

Accepts either a bare string or the full map form:

output: { static: "OK" }                       # shorthand, body JSON-encoded

output:
  static:
    body: '{"status":"ok"}'
    raw: true                                  # send verbatim, do not JSON-encode
    metadata: { content-type: "application/json" }

raw: true sends body byte-for-byte; the default JSON-encodes it as a string. Like response, a static output enables the HTTP inline fast path.

Placeholders

body is a template compiled once at startup; rendering a message never re-parses it. Tokens use the ${namespace:selector} form:

TokenResolves to
${payload:a.b.c}a field of the incoming JSON payload (dotted path; array indices allowed)
${metadata:key}a metadata value
${message:id}the message id (UUID string)
${gen:uuid}a fresh UUID v7
${gen:now} / ${gen:timestamp}current time (RFC3339 UTC / Unix epoch ms)
${gen:counter}a per-endpoint counter, starting at 0
${gen:random(1,100)}a random integer in [min, max]
${env:VAR}an environment variable, resolved once at startup

payload/metadata/message read the request, so they are the useful ones on an output (e.g. an error reply that echoes the request); on an input (load-test source) only gen/env produce values. When the body’s content-type metadata is a JSON type, interpolated request values are JSON-escaped by default so external data cannot break the structure — append | raw to a token to splice it verbatim. To emit a literal, un-interpolated ${…}, write $${…} (a bare $$ is left as-is); any ${…} with an unknown namespace is also left untouched.

output:
  static:
    body: '{"error":"not found","id":"${message:id}","at":"${gen:now}"}'
    raw: true
    metadata: { content-type: "application/json" }

stream_buffer

An in-memory stream partitioned by correlation ID, used to carry streaming request/response bodies between routes.

FieldTypeNotes
topicstringrequired; shared by publisher and consumers
correlation_idstringrequired on consumers, must be unset on publishers
capacityintegerdefault 100, per partition
output:
  stream_buffer: { topic: "responses" }        # publisher: no correlation_id

input:
  stream_buffer: { topic: "responses", correlation_id: "req-123" }   # consumer

A consumer without correlation_id is a startup error; a publisher with one logs a warning and ignores it. Primarily wired up via HttpConfig::stream_response_to.

null

Discards every message. Output only. This is the default output when a route omits one.

drain:
  input: { kafka: { topic: "noisy", url: "localhost:9092" } }
  output: null          # a bare YAML null

Spelling trap: it is a bare YAML null (or ~, or the explicit null: null). null: {} does not parse. Omitting output: entirely gives the same result.

Useful for consume-and-handle routes where a handler does the work and there is nothing to forward, and for benchmarking an input in isolation.

custom (endpoint)

Delegates to a factory you registered programmatically.

FieldTypeRequired
namestringyes
configany JSONyes
output:
  custom:
    name: "my_sink"
    config: { target: "internal://thing" }

Implement CustomEndpointFactory and register it before starting routes. See ARCHITECTURE.md.


See also

  • README.md — overview, data endpoints, request/response and CQRS patterns
  • CONFIGURATION.md — full YAML examples, env vars, TLS, IDE schema validation
  • ARCHITECTURE.md — internals, batching/concurrency, extension traits

Configuration Guide

mq-bridge uses a flexible configuration system supporting YAML, JSON, and environment variables.

Configuration Reference

The best way to understand the configuration structure is through a comprehensive example. mq-bridge uses a YAML map where keys are route names.

# mq-bridge.yaml

# Route 1: Kafka to NATS
kafka_to_nats:
  concurrency: 4
  input:
    kafka:
      url: "localhost:9092"
      topic: "orders"
      group_id: "bridge_group"
      # TLS Configuration (Optional)
      tls:
        required: true
        ca_file: "./certs/ca.pem"
  output:
    nats:
      url: "nats://localhost:4222"
      subject: "orders_stream.processed"
      stream: "orders_stream"

# Route 2: HTTP Webhook to MongoDB with Middleware
webhook_to_mongo:
  input:
    http:
      url: "0.0.0.0:8080"
      # Force the normal route pipeline instead of the inline HTTP response fast path.
      inline_response_fast_path: false
    middlewares:
      - retry:
          max_attempts: 3
          initial_interval_ms: 500
  output:
    mongodb:
      url: "mongodb://localhost:27017"
      database: "app_db"
      collection: "webhooks"
      format: "json" # a bit slower, but better readability

# Route 3: File to AMQP (RabbitMQ)
file_ingest:
  input:
    file:
      path: "./data/input.jsonl"
  output:
    amqp:
      url: "amqp://localhost:5672"
      exchange: "logs"
      queue: "file_logs"

# Route 4: AWS SQS to SNS
aws_sqs_to_sns:
  input:
    aws:
      # To consume from SNS, subscribe this SQS queue to the SNS topic in AWS Console/Terraform.
      queue_url: "https://sqs.us-east-1.amazonaws.com/000000000000/my-queue"
      region: "us-east-1"
      # Credentials (optional if using env vars or IAM roles)
      access_key: "test"
      secret_key: "test"
  output:
    aws:
      topic_arn: "arn:aws:sns:us-east-1:000000000000:my-topic"
      region: "us-east-1"

# Route 5: IBM MQ Example
ibm_mq_route:
  input:
    ibmmq:
      queue_manager: "QM1"
      url: "localhost(1414)"
      channel: "DEV.APP.SVRCONN"
      queue: "DEV.QUEUE.1"
      username: "app"
      password: "admin"
  output:
    memory:
      topic: "received_from_mq"

# Route 6: MQTT to Switch (Content-based Routing)
iot_router:
  input:
    mqtt:
      url: "mqtt://localhost:1883"
      topic: "sensors/+"
      qos: 1
  output:
    switch:
      metadata_key: "sensor_type"
      cases:
        temp:
          kafka:
            url: "localhost:9092"
            topic: "temperature"
      default:
        memory:
          topic: "dropped_sensors"

# Route 7: ZeroMQ PUSH/PULL
zeromq_pipeline:
  input:
    zeromq:
      url: "tcp://0.0.0.0:5555"
      socket_type: "pull"
      bind: true
  output:
    zeromq:
      url: "tcp://localhost:5556"
      socket_type: "push"
      bind: false
      # format: "raw"         # send/receive raw payload bytes per frame instead of JSON-wrapping (e.g. JPEG, Protobuf)
      # format: "raw_framed"  # like "raw" but prepends a JSON metadata frame so headers still travel. Default "json".

# Route 8: PostgreSQL via SQLx
sqlx_postgres_route:
  input:
    sqlx:
      url: "postgres://user:pass@localhost:5432/mydb"
      table: "job_queue"
      delete_after_read: true
  output:
    memory:
      topic: "processed_jobs"

# Route 9: Cross-process IPC via the memory endpoint
# The `topic` field (alias `url`) is a transport URL:
#   "name"                  -> memory://name  (in-process, same process only)
#   "memory://name"         -> in-process channel
#   "ipc://name"            -> Unix: /run/mq-bridge/name.sock (falls back to
#                              $XDG_RUNTIME_DIR/mq-bridge, then /tmp/mq-bridge)
#                              Windows: \\.\pipe\mq-bridge-name
#   "ipc:///abs/path.sock"  -> that exact socket path (Unix)
#   "unix:///abs/path.sock" -> Unix only, path must be absolute
#   "pipe://name"           -> Windows only, \\.\pipe\name
# The consumer side binds/listens and must be started before the publisher connects.
# IPC does not support `subscribe_mode` or `request_reply`.
# `enable_nack` defaults to true, but redelivery is consumer-local: a nacked message
# is retried inside the consumer and is lost if the consumer process dies.
ipc_ingest:
  input:
    memory:
      url: "ipc:///run/mq-bridge/orders.sock"
      capacity: 256
  output:
    kafka:
      topic: "orders"
      url: "localhost:9092"

Configuration Details

Environment Variables

All YAML configuration can be overridden with environment variables. The mapping follows this pattern: MQB__{ROUTE_NAME}__{PATH_TO_SETTING}

For example, to set the Kafka topic for the kafka_to_nats route:

export MQB__KAFKA_TO_NATS__INPUT__KAFKA__TOPIC="my-other-topic"

Postgres CDC example

orders_cdc:
  input:
    postgres_cdc:
      url: "postgres://user:pass@localhost:5432/app"
      publication: "orders_pub"   # CREATE PUBLICATION orders_pub FOR TABLE orders;
      slot_name: "mqb_orders"       # created if missing (permanent slot, resumable)
  output:
    nats:
      subject: "orders.changes"
      url: "nats://localhost:4222"

Each change arrives as a CanonicalMessage whose payload is the flat row and whose postgres.operation metadata marks the operation — the same convention as MongoDB CDC, so typed handlers work identically across both. The replication transport uses the published pgwire-replication crate.

NATS JetStream Notes

Two gotchas worth knowing before wiring up a nats endpoint:

  • Subject must be prefixed with the stream name. When mq-bridge auto-creates a JetStream stream (no existing stream already covers the subject), it scopes the stream to {stream}.>. So stream: "orders_stream" requires a subject like orders_stream.foo — a subject such as orders.foo will fail to publish with “no stream found for given subject”. This only applies to auto-creation; publishing to a stream that already exists with a wider subject filter works regardless of naming.
  • stream is required even in Core NATS mode. Consumer validation requires a stream value even when no_jetstream: true. It’s unused for the actual Core NATS subscribe, but validation still rejects a missing value — pass any placeholder string.

Middleware Configuration

Every available middleware, with its fields, defaults, supported side (input/output) and a working example, is listed in REFERENCE.md. Note in particular the ordering rule: on an output, the last middleware in the list is the outermost layer, so dlq goes last.

Middleware is defined as a list under an endpoint.

input:
  middlewares:
    - retry:
        max_attempts: 5
        initial_interval_ms: 200
    - dlq:
        endpoint:
          nats:
            subject: "my-dlq-subject"
            url: "nats://localhost:4222"
    - deduplication:
        sled_path: "/var/data/mq-bridge/dedup_db"
        ttl_seconds: 3600 # 1 hour
  kafka:
    # ... kafka config

TLS & Security Hardening

Most endpoints accept a tls block. The available fields are:

tls:
  required: true                   # enable TLS
  ca_file: "./certs/ca.pem"        # CA to verify the server
  cert_file: "./certs/client.pem"  # client cert (mTLS)
  key_file: "./certs/client.key"   # client private key (mTLS)
  cert_password: "secret"          # password for an encrypted key (where supported)
  accept_invalid_certs: false      # NEVER set true in production

Hardening checklist (e.g. for PCI-DSS Req 4.2.1):

  1. Enable TLS on every endpoint carrying sensitive data (required: true) and supply a ca_file. Use mTLS (cert_file + key_file) for mutual authentication where the broker supports it.
  2. Never disable certificate validation. accept_invalid_certs defaults to false; leaving it that way is required — setting it true on a sensitive path defeats TLS.
  3. Choose the crypto provider feature. Build with rustls-aws-lc (FIPS-capable, also enables post-quantum key exchange) or rustls-ring. The rustls-based endpoints — NATS, MQTT, HTTP, gRPC, WebSocket, AMQP — only ever negotiate rustls’s safe TLS 1.2/1.3 AEAD cipher suites; weak/legacy suites (RC4, 3DES, CBC-SHA1, export) cannot be offered, so “strong ciphers only” holds without any explicit cipher list.
  4. Kafka (librdkafka/OpenSSL): certificate verification is on by default (enable.ssl.certificate.verification follows accept_invalid_certs). If an auditor requires an explicit allowlist, pin it via producer_options / consumer_options:
    kafka:
      producer_options: [["ssl.cipher.suites", "ECDHE-RSA-AES256-GCM-SHA384"]]
      consumer_options: [["ssl.cipher.suites", "ECDHE-RSA-AES256-GCM-SHA384"]]
    
  5. IBM MQ (native stack): set a strong tls.cipher_spec (a TLS 1.2/1.3 CipherSpec) — it is required for encrypted connections. Note cipher_spec lives under tls, not at the top level of the ibmmq config (a breaking rename from earlier releases, where it was ibmmq.cipher_spec).
  6. Keep sensitive payloads out of logs. Message payloads are emitted at trace level; run production above trace and confirm no cardholder data (PAN) reaches logs or traces.
  7. Do not commit secrets. Source passwords and tokens from a secrets manager or env vars (MQB__...) rather than checked-in config.

Notes and boundaries:

  • TLS 1.3 alone is sufficient in 2026 (Mozilla “Modern” profile). The rustls endpoints currently negotiate TLS 1.2 and 1.3 (both are PCI-acceptable). A central “TLS 1.3-only” toggle is not yet configurable in the library; enforce a minimum protocol version on the broker/server side, which is the side that accepts the connection.
  • Kafka and IBM MQ use native TLS stacks, so a library-wide version policy cannot be applied to them — configure their minimum TLS version on the broker.

HTTP Consumer Fast Path

Compatible http -> response routes may use an inline response fast path for lower latency. This bypasses the normal route consumer/worker/disposition pipeline, but it still keeps the output publisher chain active, including output handlers and allowed output middlewares.

The fast path is only considered when:

  • the input has no middlewares
  • receive_streamable is false
  • fire_and_forget is false
  • output middlewares are limited to buffer, delay, limiter, and/or metrics

To force the normal route pipeline, set this on the HTTP consumer:

input:
  http:
    url: "0.0.0.0:8080"
    inline_response_fast_path: false

This is useful when you want stable, explicit semantics regardless of future optimizations, or when you want to avoid the inline path’s response behavior differences. In particular, the inline path does not automatically echo unchanged request metadata back as HTTP response headers.

Connection Sharing

Publishers that target the same server reuse one underlying transport client by default, instead of each opening its own. This consolidates TCP connections, background threads, and batching, and follows each driver’s own guidance (one shared producer / client / pool per application). Sharing applies to Kafka, NATS, MongoDB, SQLx, HTTP, and gRPC; the client is keyed by its connection-level settings (URL, auth, TLS, and client-level options), never by topic/subject/collection. A shared client is released once the last publisher using it is dropped.

Set shared: false on a publisher to give it a dedicated connection:

orders_out:
  output:
    kafka:
      topic: "orders"
      url: "localhost:9092"
      shared: false   # dedicated producer — keeps this latency-sensitive topic off a busy producer's queue
  • Kafka: a single producer serves every topic and is the recommended setup. Use shared: false to isolate a latency-sensitive topic from a high-throughput one so they don’t share one internal send queue (head-of-line blocking).
  • SQLx: a shared pool means its max_connections is a budget shared across every route using that database. Use shared: false if a route needs its own pool.
  • gRPC: a shared channel multiplexes over one HTTP/2 connection; at very high concurrency its max-concurrent-streams cap can bottleneck — shared: false gives a dedicated channel.

Specialized Endpoints

This section covers switch in depth. The other structural endpoints — ref, fanout, request, response, reader, static, stream_buffer, null and custom — are documented in REFERENCE.md.

Switch

The switch endpoint is a conditional publisher that routes messages to different outputs based on a metadata key.

It checks the specified metadata_key in each message. If the key’s value matches one of the cases, the message is forwarded to that endpoint. If no case matches, it’s sent to the default endpoint. If there is no default, the message is dropped.

This is useful for content-based routing.

Example: Route orders to different systems based on country_code metadata.

output:
  switch:
    metadata_key: "country_code"
    cases:
      US:
        kafka:
          topic: "us_orders"
          url: "kafka-us:9092"
      EU:
        nats:
          subject: "eu_orders"
          url: "nats-eu:4222"
    default:
      file:
        path: "/var/data/unroutable_orders.log"

IDE Support (Schema Validation)

mq-bridge includes a JSON schema for configuration validation and auto-completion.

  1. Ensure you have a YAML plugin installed (e.g., YAML for VS Code).
  2. Configure your editor to reference the schema. For VS Code, add this to .vscode/settings.json:
{ 
  "yaml.schemas": { 
    "https://raw.githubusercontent.com/marcomq/mq-bridge/main/mq-bridge.schema.json": ["mq-bridge.yaml", "config.yaml"]
  } 
} 

To regenerate the schema from this repo, run: cargo test --features schema

CLI commands

mq-bridge-app is a single headless binary with three modes: config mode (the default — run a long-lived bridge, optionally serving the browser UI), the copy subcommand (an ad-hoc one-route job), and the mcp subcommand (expose the bridge as MCP tools).

mq-bridge-app [OPTIONS]            # config mode
mq-bridge-app copy [COPY OPTIONS]  # one-route ad-hoc job
mq-bridge-app mcp  [MCP OPTIONS]   # MCP server

Config mode (default)

Run with no subcommand to load a config and run a bridge; with no config at all it starts empty and serves the UI so you can build one interactively.

mq-bridge-app --config config.yml
mq-bridge-app --config config.yml --init-config dev/config/file-to-http.yml
mq-bridge-app                      # start empty, define config.yml in the UI
OptionMeaning
-c, --config <path>Config file to load and save (the UI writes back here).
-i, --init-config <path>Initialize from a template file only if the main config doesn’t exist yet.
--init-config-str <str>Initialize from an inline config string if the main config doesn’t exist yet.
--config-str <str>Inline config that overrides the config file.
--schema <path>Write the JSON Schema for AppConfig (use - for stdout) and exit.

Config is hierarchical (files + environment variables) — see Configuration grammar. In config mode the CLI also serves the browser UI (the same UI as the desktop app) on the configured port.

copy — ad-hoc one-route job

Builds a single route from two endpoint URIs and runs it headlessly (no web UI). The scheme selects the endpoint and query parameters set its config.

# DB → DB, drain the source table then exit (exit code 0 on success)
mq-bridge-app copy \
  --from 'postgres://user:pass@localhost/db?table=src' \
  --to   'postgres://user:pass@localhost/db?table=dst' \
  --drain

# Queue → DB as a continuous bridge (runs until Ctrl-C; omit --drain)
mq-bridge-app copy \
  --from 'nats://localhost:4222?subject=orders' \
  --to   'postgres://user:pass@localhost/db?table=orders'
FlagDefaultMeaning
--from <uri>requiredSource endpoint URI, optionally followed by |-separated middlewares.
--to <uri>requiredDestination endpoint URI (same forms as --from).
--drainoffExit once the source yields an empty batch. Without it, copy runs as a continuous bridge until Ctrl-C.
--concurrency <N>4Route concurrency.
--batch-size <N>1024Batch size.

Note: copy’s defaults (--concurrency 4, --batch-size 1024) are higher than the library’s route defaults (concurrency: 1, batch_size: 1), because copy is built for bulk throughput. See Performance tuning.

URI grammar

scheme://…?param=a&next=b: the scheme selects the endpoint and query parameters set its config. Any query key that matches a field of that endpoint’s config becomes endpoint config; every other query param stays on the connection URL, so driver params pass through unchanged (e.g. postgres://…/db?table=src&sslmode=disable).

  • Schemes: postgres / postgresql / mysql / mariadb / sqlite → sqlx, nats → NATS, mongodb → MongoDB, redis → Redis streams, file → file, and the rest by name.
  • Common config params: table, insert_query (URL-encoded; supports ${metadata:<key>} / ${payload:<field>} token mapping), delete_after_read, subject, stream, collection, database, format, … — anything on the endpoint’s config struct.
  • For nats, the dominant target field can be given as the URL path (nats://localhost:4222/orders?subject=orders); the query form wins if both are given. A redis path is the connection’s database number, so a redis stream target must use ?stream=….
  • MongoDB sources are non-destructive by default: copy defaults consume to capture_all. Pass ?consume=consumer to opt into the destructive queue-drain mode (capture_* use change streams, which require a replica set).

Middlewares in the URI

Append |-separated middlewares to either URI to wrap that endpoint. They apply in the order written, and each takes its own config struct’s fields as query params:

mq-bridge-app copy \
  --from 'postgres://user:pass@localhost/db?table=src|retry?max_attempts=5&initial_interval_ms=200' \
  --to   'kafka://broker:9092?topic=orders|buffer?max_messages=500&max_delay_ms=50|metrics' \
  --drain
  • Names: retry, metrics, dlq, deduplication, delay, limiter, buffer, weak_join, cookie_jar, random_panic, custom (- is accepted for _).
  • A middleware with no params needs no ?|metrics.
  • dlq’s endpoint is itself a URL-encoded endpoint URI: |dlq?endpoint=file%3A%2F%2F%2Ftmp%2Ffailed.jsonl.
  • Object/array fields take a JSON literal: |weak-join?group_by=cid&expected_count=2&timeout_ms=1000&required=["a","b"].
  • A literal | inside the URI (e.g. in a password) must be written percent-encoded as %7C.

Escape hatch: full connection strings

Any query parameter that isn’t a recognised config field (e.g. sslmode, replicaSet) stays on the connection URL, so driver options just work — including object-typed fields like tls. If you already have a complete connection string, pass it verbatim with ?url=<url-encoded>:

mq-bridge-app copy \
  --from 'mongodb://_/?url=mongodb%3A%2F%2Fuser%3Apass%40host%2Fdb%3Ftls%3Dtrue&collection=orders' \
  --to null:

See the Quick start for complete, working copy commands.

mcp — MCP server

mq-bridge-app mcp                                    # stdio (local clients)
mq-bridge-app mcp --transport http --bind 127.0.0.1:9092   # streamable HTTP
FlagDefaultMeaning
--transport <stdio|http>stdioTransport. stdio for local clients (Claude Desktop/Code), http for streamable HTTP over hyper.
--bind <addr>127.0.0.1:9092Bind address; --transport http only.
--report-to-uioffReport running routes / publish targets to a local mq-bridge-app UI over a local IPC socket. Only names, connector types, health and counts are sent — never URLs or credentials.

mcp install / uninstall / status

Register the running binary with local MCP clients so you don’t write the config by hand:

mq-bridge-app mcp install                         # every detected client
mq-bridge-app mcp install --client cursor --local  # one client, project-scoped
mq-bridge-app mcp install --report-to-ui           # bake --report-to-ui into the entry
mq-bridge-app mcp status
mq-bridge-app mcp uninstall
SubcommandFlagsPurpose
install--client, --local, --report-to-ui, --print-configRegister this binary (its absolute path).
uninstall--client, --localRemove the registration.
status--localShow where it is registered and whether the path is still current.

--print-config prints the JSON snippet for a client not written directly. Full tool and message reference is in MCP server.

Language bindings API

The core engine is a Rust library, but the same engine ships as native bindings for Python and Node.js. The Tokio runtime, broker I/O, routing, and batching all stay in Rust; the binding is a thin layer for handlers and configuration.

LanguagePackageInstall
Rustmq-bridgecargo add mq-bridge
Pythonmq-bridge-pypip install mq-bridge-py
Node.jsmq-bridgenpm install mq-bridge

The core of the library are the MessageConsumer and MessagePublisher traits, found in mq_bridge::traits.

Config loaders

Constructor names are kept aligned across languages, so a config loader reads the same in either binding (Python uses snake_case, Node uses camelCase):

PurposePythonNode.js
Load a route from a YAML/JSON fileRoute.from_fileRoute.fromFile
Load from an in-memory YAML/JSON stringRoute.from_strRoute.fromStr
Load from a parsed dict / JS objectRoute.from_configRoute.fromConfig
Build a publisher endpointthe matching Publisher.*the matching Publisher.*

The name argument is optional in both: pass it to select one entry from a routes:/publishers: document, or omit it to treat the config as a single bare route/endpoint body.

This is the natural companion to the configuration-first workflow: design and test a route in the UI, export the JSON/YAML, then load that exact config from your code. See the Embed the library tutorial for full examples in each language.

Rust API surface

The Rust crate exposes the full engine. The main types:

  • Route::new(input, output) — a pipeline from one endpoint to one endpoint, with .with_batch_size(n), .with_handler(h), .add_handler(kind, f), .deploy(name) / .run().
  • Endpoint — protocol adapters (Endpoint::new_memory, Endpoint::null, …) plus the serde-configured variants.
  • Publisher::new(endpoint) — publish into a route’s input or any endpoint.
  • Handlers: CommandHandler (1-to-1/1-to-0), EventHandler (terminal 1-to-N), and TypeHandler (dispatches on the kind metadata field, deserializing payloads).
  • CanonicalMessage — the unified message type all handlers work with; msg!(&value, "kind") builds one with a kind.

See Core concepts and Learn the architecture for the handler model, and the Rust docs on docs.rs for the full API.

Notes

  • The Python binding holds up under load on the third-party http-arena.com requests-per-second HTTP benchmark (a live leaderboard — rankings shift over time).
  • A binding is a thin layer: routing, batching, and broker I/O stay in Rust regardless of which language calls in, so behaviour and reliability match the Rust engine.

IBM MQ Setup

How to build and install mq-bridge-app (CLI/server and the Tauri desktop app) with IBM MQ support.

IBM MQ is an optional, opt-in feature (--features ibm-mq). It is not part of the default full feature set because it links against IBM’s native MQ client library, which is not redistributable on crates.io and has no arm64 build. The UI auto-detects whether the running backend was built with IBM MQ and shows/hides the IBM MQ endpoint type accordingly (via the /features endpoint).

1. Install the IBM MQ client library

You need IBM’s native MQ C client before building.

  1. Download the IBM MQ redistributable C client for your platform from the IBM MQ downloads page:

    • Linux: IBM-MQC-Redist-LinuxX64.tar.gz
    • macOS: IBM-MQC-Redist-MacX64.tar.gz
    • Windows: IBM-MQC-Redist-Win64.zip
  2. Extract it and point MQ_INSTALLATION_PATH at it (skip the env var if you use the default /opt/mqm):

    Linux / macOS

    mkdir -p ~/ibm-mq && tar -xzf IBM-MQC-Redist-*.tar.gz -C ~/ibm-mq
    export MQ_INSTALLATION_PATH=~/ibm-mq
    

    Windows (PowerShell) — extract the zip, then:

    $env:MQ_INSTALLATION_PATH = "C:\IBM\MQ"
    

IBM MQ client libraries are x86_64 only. There is no arm64 redistributable client, so arm64 builds cannot include IBM MQ (even on Apple Silicon — build the x86_64 binary instead).

2. Install mq-bridge-app with IBM MQ

The build reads MQ_INSTALLATION_PATH (or MQ_HOME, default /opt/mqm) to locate the client library, so make sure it is exported in the shell you run cargo from.

CLI / server (web UI)

export MQ_INSTALLATION_PATH=~/ibm-mq          # where you extracted the client
cargo install mq-bridge-app --features ibm-mq
mq-bridge-app                                  # then open http://localhost:9091

The web UI is embedded directly in the binary, so a plain cargo install serves the full UI — no static/ folder or extra files to ship. (--features ibm-mq keeps the default full feature set on and adds IBM MQ; you don’t need --features ibm-mq,full.)

Desktop app (Tauri)

The desktop crate is not published to crates.io, so install it straight from git. The committed UI bundle is reused, so no npm build is required:

export MQ_INSTALLATION_PATH=~/ibm-mq
cargo install --git https://github.com/marcomq/mq-bridge-app \
  mq-bridge-app-desktop --features ibm-mq
mq-bridge-app-desktop

The desktop build also needs the usual Tauri prerequisites (WebKitGTK + build tools on Linux; Xcode command-line tools on macOS; WebView2 on Windows).

From a local checkout

git clone https://github.com/marcomq/mq-bridge-app
cd mq-bridge-app
export MQ_INSTALLATION_PATH=~/ibm-mq

# CLI / server
cargo install --path crates/cli --features ibm-mq
# or desktop
cargo install --path crates/desktop --features ibm-mq

Docker (CLI / server, amd64 only)

A prebuilt IBM MQ image is published as the ibm-mq / latest-ibm-mq tags:

docker run --rm -p 9091:9091 ghcr.io/marcomq/mq-bridge-app:latest-ibm-mq

Or build it yourself (the Dockerfile downloads the MQ client automatically):

docker build --build-arg ENABLE_IBM_MQ=true -t mq-bridge-app:ibm-mq .

3. Verify IBM MQ is enabled

curl http://localhost:9091/features
# => {"ibm_mq":true, "kafka":true, ...}

When ibm_mq is true, the IBM MQ endpoint type appears in the publisher and consumer dropdowns in the UI.

4. Configure an IBM MQ endpoint

publishers:
  - name: "IBM MQ Publisher"
    endpoint:
      ibmmq:
        connection_manager: "QM1"
        queue: "DEV.QUEUE.1"
        # ...or a topic instead of a queue:
        # topic: "topic://events"
        url: "mq-host(1414)"
        channel: "DEV.APP.SVRCONN"
        username: "app"
        password: "${MQ_PASSWORD}"

consumers:
  - name: "IBM MQ Consumer"
    endpoint:
      ibmmq:
        connection_manager: "QM1"
        queue: "DEV.QUEUE.1"
        url: "mq-host(1414)"
        channel: "DEV.APP.SVRCONN"
        username: "app"
        password: "${MQ_PASSWORD}"

Troubleshooting

Build can’t find the client library — confirm MQ_INSTALLATION_PATH (or MQ_HOME) points at the directory that contains lib64/ (64-bit). The build script links against $MQ_INSTALLATION_PATH/lib64. Make sure you installed the C client, not the Java client, and that it matches your architecture (x86_64).

Runtime: cannot open shared object file: libmqic_r.so (Linux) / libmqic_r.dylib (macOS) — the loader needs the library at runtime:

# Linux
export LD_LIBRARY_PATH=$MQ_INSTALLATION_PATH/lib64:$LD_LIBRARY_PATH
# macOS
export DYLD_LIBRARY_PATH=$MQ_INSTALLATION_PATH/lib64:$DYLD_LIBRARY_PATH

On Linux you can make it permanent:

echo "$MQ_INSTALLATION_PATH/lib64" | sudo tee /etc/ld.so.conf.d/ibm-mq.conf
sudo ldconfig

(The CLI build adds an rpath automatically for GNU/Linux targets, so this is usually only needed for the desktop app or non-default install paths.)

Connection errors — verify the queue manager is running, the channel/queue exist, the port (default 1414) is reachable, and credentials are correct. Check the MQ error logs for detail.

Platform notes

PlatformDefault pathLibrary dirNative library
Linux/opt/mqmlib64libmqic_r.so
macOS/opt/mqmlib64libmqic_r.dylib (may need a security exception for the unsigned lib)
WindowsC:\Program Files\IBM\MQbin64mqic.dll

License

The IBM MQ client library is redistributable under IBM’s own license terms. If you distribute binaries built with IBM MQ support, include IBM’s license files (from $MQ_INSTALLATION_PATH/licenses) and comply with IBM’s redistribution terms.

Further reading

Deploying

The CLI / server form is what you deploy — a single headless binary that runs a long-lived bridge in config mode. This page covers running it as a container and as a service, and the config/secrets patterns that suit each.

Docker

The CLI is published as a multi-arch image (amd64 + arm64):

docker run --rm --name mq-bridge -p 9091:9091 ghcr.io/marcomq/mq-bridge-app:latest

Mount the working directory at /app and seed the config on first run from one of the templates baked into the image at /config:

touch input.log
docker run --rm --name mq-bridge -p 9091:9091 -v "$(pwd)":/app \
  ghcr.io/marcomq/mq-bridge-app:latest --init-config=/config/file-to-http.yml
  • The default latest image is a plain multi-arch image for amd64 and arm64.
  • IBM MQ support is published separately as the latest-ibm-mq / ibm-mq tags, amd64 only (no redistributable arm64 client yet). Start it with --platform=linux/amd64, or build yourself with cargo build --release --features=ibm-mq.

To build the image from source, see BUILD.md.

Configuration in containers / Kubernetes

Configuration is hierarchical — files plus environment variables — which is exactly what container and Kubernetes deployments want:

  • Bake a base config.yml into the image or mount it as a ConfigMap.
  • Override any field per environment with MQB__{ROUTE}__{PATH} env vars (double underscores between segments), e.g. MQB__KAFKA_TO_NATS__INPUT__KAFKA__TOPIC=my-topic.
  • Reference secrets inline with ${ENV_VARIABLE_NAME:-default}; a .env file in the working directory is auto-loaded for local development.

See Configuration grammar for the full env-var mapping and Secrets & interpolation for keeping credentials out of committed config.

Choosing the run shape

You want…Run it as…
A one-shot batch move (finite source), exit 0 on successcopy … --drain (see Quick start)
A long-lived bridge with one or more routesconfig mode: mq-bridge-app --config config.yml
The bridge driven by an LLM agentmcp mode

In config mode the CLI also serves the browser UI on the configured port. If you don’t want the UI exposed in production, front it appropriately or run only the routes you need.

Security checklist for production

  • TLS on every sensitive endpoint (tls.required: true + ca_file, mTLS where supported). Never set accept_invalid_certs: true. Pick the crypto provider feature (rustls-aws-lc for FIPS-capable / post-quantum, or rustls-ring).
  • Keep payloads out of logs: run above trace level (payloads log at trace).
  • Do not commit secrets: source them from a secrets manager or env vars.
  • Consider the config security modes (plain, extracted secrets, encrypted config, encrypted history) based on the runtime target and available key storage.

Full hardening notes (including the PCI-DSS-oriented checklist) are in the TLS & security hardening section.

Observability

Wire up the metrics middleware and scrape the Prometheus endpoint; ship the JSON logs to your aggregator. See Observability & metrics.

Continuous deployment of this book

The book is published to GitHub Pages by .github/workflows/docs.yml on every push to main that touches dev/docs/**. It vendors the engine’s canonical docs at build time, so it checks out the mq-bridge repo and runs dev/docs/sync-engine-docs.sh (with ENGINE_REPO pointed at that checkout) before mdbook build dev/docs. To build it locally, run the same two commands from the repository root. See the book’s README.

Observability & metrics

mq-bridge-app is production-ready with structured JSON logging and a Prometheus metrics endpoint, plus per-route status you can query at runtime.

Metrics

Attach the metrics middleware to an endpoint to emit throughput, latency, and error metrics. It requires the metrics feature and takes no options — its presence enables collection. Input and output are labelled separately, so attaching it to both sides is meaningful:

orders_bridge:
  input:
    middlewares: [ { metrics: {} } ]
    kafka: { topic: "orders", url: "localhost:9092" }
  output:
    middlewares: [ { metrics: {} } ]
    nats: { subject: "orders.processed", stream: "orders_stream", url: "nats://localhost:4222" }

Metrics are exposed for a Prometheus scrape. Point your Prometheus (or Grafana Agent) at the running server and build dashboards on the emitted throughput/latency/error series.

⚠️ Metrics are not free. The metrics middleware records a measurement per message on whichever side it is attached, so it adds per-message overhead and can measurably reduce throughput — most noticeably on high-throughput endpoints where per-message cost dominates. Enable it where you need visibility, not blanket-on every side of every route: attach it to the one endpoint you actually want to watch, and leave it off the hot path when chasing peak throughput. Benchmark numbers should be taken without it attached (see Reading throughput honestly).

Logging

Logs are structured JSON, suited to shipping into a log aggregator. Two things to keep in mind:

  • Message payloads are emitted only at trace level. Run production above trace so no sensitive data (e.g. cardholder data) reaches logs or traces. See the TLS & security hardening notes.
  • The MCP server’s stdio transport owns stdout for the protocol, so its logs go to stderr. In HTTP transport this is not a concern.

Runtime route status

For a running bridge, query route health rather than reading logs:

  • In the UI, the runtime status view shows live connection health and message counts per publisher/consumer/route.
  • Through the MCP server, list_routes and route_status report messages, messages_per_second (instantaneous), elapsed_s, and average_messages_per_second. For a running route read the instantaneous rate; for a finished job read the average. See MCP route status.

Reading throughput honestly

Whatever the source, a rate figure is only meaningful with its methodology:

  • Measure on a release build — a debug build reports dramatically slower rates. Through MCP, call server_info first to confirm the build profile.
  • The instantaneous rate of a completed job decays to ~0 within a second — use the average for finished work.
  • Record CPU/cores/RAM and the exact batch_size / concurrency next to every number.

See Performance tuning → Measuring and the like-for-like ETL/CDC methodology in benches/etl/README.md.

Performance tuning

mq-bridge is fast by default and stays out of your way until you need more. This page is the map of the knobs that trade throughput against latency, memory, and ordering — what each one does, when raising it helps, and when it hurts.

Philosophy: fast by default, safe knobs

The engine is optimized around batch-shaped APIs on every endpoint, even when the backend only has a single-message primitive. The headless surfaces ship tuned for throughput; the low-level engine primitive stays conservative so embedded routes are predictable and easy to reason about. Tuning is opt-in: you raise a knob deliberately for a specific workload.

Two different starting points are worth knowing:

  • Library / config-mode routes default to batch_size: 1, concurrency: 1 — the engine primitive default, so an embedded route does nothing surprising until you opt in.
  • The copy CLI and the MCP server default to batch_size: 1024, concurrency: 4 (--batch-size / --concurrency on copy), because they exist for bulk moves. The benchmark numbers below use copy.

When throughput matters, batch_size is almost always the first knob to try.

batch_size — the single most important knob

batch_size is the maximum number of messages a route gathers per iteration before handing the batch to the sink. Larger batches amortize per-call overhead (network round-trips, SQL statement prep, file syscalls) across many rows.

FieldDefault (library)Default (copy)
batch_size11024

When bigger helps: row-oriented and high-latency sinks — SQL INSERT, HTTP POST, object storage, ClickHouse. These pay a fixed cost per call, so moving from 1 to 128–1024 rows per call is often a 10–100x throughput change. Databases especially: one multi-row insert beats a thousand single-row inserts.

When bigger hurts:

  • Latency. A larger batch means the first message waits longer before the batch flushes and ships. For a low-latency bridge, keep it small — or use a buffer middleware with a max_delay_ms bound so a partial batch still flushes on a timer.
  • Memory. The whole batch is held in memory (and, over IPC, written as one frame — frames over 100 MB are rejected). Very large batches of large payloads raise peak RSS.
  • Redelivery granularity. A nack redelivers at batch granularity on some transports, so a big batch means more replayed work after a failure.

Choosing batch_size

WorkloadRecommended batch_size
Bulk DB → file / file → DB ETL5121024
Broker → DB sink (row inserts)128512
Low-latency request/response or event bridge116
Large payloads (MB-scale)keep small; watch peak RSS and the 100 MB IPC frame cap

concurrency — sequential vs. worker pool

concurrency is the number of route worker tasks that process batches in parallel.

FieldDefault (library)Default (copy)
concurrency14

When it helps: high-latency handlers or sinks where a worker spends most of its time awaiting I/O — HTTP calls, remote databases. More workers keep the pipeline full while others wait.

Ordering implications. With concurrency > 1, batches are processed in parallel, so strict per-key ordering across the route is not guaranteed. Commits are still sequenced for cumulative-ack brokers (a later batch cannot ack over an earlier unresolved one), but the order of side effects at the sink can interleave. If you need ordering, keep concurrency: 1 or partition upstream so each key lands on one route.

When it does not help. Concurrency does not speed up sources that fetch serially. A MongoDB source fetches batches serially — concurrency only widens the downstream side, not the read. A single-connection cursor read is likewise source-bound. Raising concurrency against a serial source just adds idle workers.

Choosing concurrency

WorkloadRecommended concurrency
CPU-light copy, fast local sink14
Per-message HTTP call / remote sink (I/O-bound)416
Order-sensitive stream1
MongoDB / serial-read source1 (widen downstream instead)

commit_concurrency_limit (default 4096) caps in-flight commit operations, whether queued through ordered sequencing or run concurrently for independent-ack transports. Rarely needs changing.

Connection pooling / reuse

Publishers targeting the same server share one underlying transport client by default — consolidating TCP connections, background threads, and batching, following each driver’s own guidance (one shared producer / client / pool per application). Sharing applies to Kafka, NATS, MongoDB, SQLx, HTTP, and gRPC, keyed by connection-level settings (URL, auth, TLS), never by topic/subject/collection.

Set shared: false on a publisher for a dedicated connection when sharing works against you:

  • Kafka — isolate a latency-sensitive topic from a high-throughput one so they don’t share one internal send queue (head-of-line blocking).
  • SQLx — a shared pool’s max_connections is a budget spread across every route on that database; give a hot route its own pool.
  • gRPC — a shared channel multiplexes over one HTTP/2 connection; at very high concurrency its max-concurrent-streams cap can bottleneck.

See Connection sharing.

Retry & backoff

The retry middleware retries only Retryable and connection errors, with exponential backoff (initial_interval_ms, multiplier, max_interval_ms). Tuning notes:

  • Backoff caps at max_interval_ms, so retries don’t stall a route indefinitely; pick a cap that matches your latency budget.
  • Retry interacts with dlq: once attempts are exhausted the error is marked permanent so a following dlq captures it. Put dlq after retry on the output (last = outermost). Without a dlq, an exhausted message is dropped.
  • Several endpoints already retry connection/timeout errors internally, so retry adds backoff on top — you don’t need huge max_attempts.

Compression & encryption cost

  • Compression (file / object_store compression: none|gzip|lz4|zstd) trades CPU for smaller output. lz4 is cheapest; zstd compresses best; gzip is roughly 33% slower than zstd at similar ratios in practice. See the Compression recipe.
  • Encryption costs an AEAD seal/open per batch. Do not stack the encryption middleware on top of a sink’s batch compression — ciphertext does not compress; use the file endpoints’ own compress-then-encrypt fields instead. See Encryption at rest.
  • A buffer middleware in front of a compressing sink increases batch size, which improves compression ratio and amortizes the codec cost.

Measuring

Never trust a single number without its methodology. Measure with a release build (a debug build reports dramatically slower rates — if you’re driving the MCP server, call server_info first to confirm the build profile), record CPU/cores/RAM, and report the exact batch_size / concurrency next to every figure.

  • Attach the metrics middleware and scrape the Prometheus endpoint for live throughput/latency/error rates.
  • For the MCP server, read average_messages_per_second for a finished job and messages_per_second for a running one (see MCP route status).
  • The like-for-like ETL/CDC methodology and fixed parameters are in benches/etl/README.md.

Reference numbers

Measured through mq-bridge-app’s copy CLI (the zero-code path) on an Apple M1, 8 cores, 8 GB RAM. Numbers are hardware-dependent — treat them as shape, not guarantees.

ScenarioBatchConc.ThroughputPeak RSS
IPC forward (staticmemory)102411,202,926 rows/s
CSV → JSONL (strings passthrough, 1M rows ~116 MiB)10241784,313 rows/s~20 MiB
CSV → JSONL with typing transform (id→int, embedded JSON)10241540,248 rows/s~20 MiB
Postgres → JSONL (1M rows, 7 mixed-type cols)10241266,951 rows/s~20 MiB
Postgres → JSONL (same)10244303,398 rows/s

Two things the table shows:

  • Typing has a real but modest cost. Adding a transform that coerces id to an integer and decodes an embedded JSON document costs ~0.58 µs/row — CSV→JSONL drops from 784k to 540k rows/s but every output record is fully typed.
  • Peak RSS stays flat (~20 MiB) regardless of dataset size, because rows stream in batches rather than being buffered whole — the reason mq-bridge is ~30x leaner than tools that materialize the dataset.

Keyset cursor needs an index. The Postgres bulk-copy reader uses keyset pagination (WHERE id > $cursor ORDER BY id LIMIT batch). Without an index on the cursor column it does a full scan per batch (near-quadratic). CREATE INDEX ON <table>(id) first.

A tuning checklist

  1. Start from the defaults. Confirm correctness before tuning.
  2. Raise batch_size (128 → 512 → 1024) and re-measure. This is usually the biggest win.
  3. If the sink or handler is I/O-bound and order doesn’t matter, raise concurrency.
  4. Add buffer if the source emits singles but the sink prefers batches.
  5. For hot Kafka/SQLx/gRPC publishers colliding with others, set shared: false.
  6. Add retry + dlq for resilience; keep max_attempts modest.
  7. Measure on a release build, with metrics, and record the parameters.

Troubleshooting

Common failure modes and how to read them. When something “silently does nothing”, the cause is almost always one of the drop/drain behaviours below.

Messages disappear and the route still ends completed

By design, a message that fails permanently (a type/data error the sink rejects, a poison payload a handler rejects) is logged at error level and dropped — the route keeps processing the rest of the batch. So a systematic failure (e.g. every row hitting a column-type mismatch) drains the input while committing nothing and still ends completed.

  • Watch for a burst of Dropping message … due to non-retryable error in the logs — that’s the signal.
  • Add a dlq to capture the failures for inspection/replay.
  • retry alone does not retain a failed message — pair it with dlq (dlq last). See Dead-letter queues.

--drain / exit_on_empty behaviour

  • --drain (CLI) / exit_on_empty (MCP/route) exits once the source yields an empty batch — right for finite sources (a file, a full-table read). For brokers and CDC sources that never “end”, omit it and run continuously.
  • A drained Redis Streams source reports an error at the end: "healthy": false with Redis XREAD failed: timed out, even though every message moved. Cosmetic — verify the sink, not the final status. NATS does not behave this way.
  • Finished MCP routes are not reaped: a route started with exit_on_empty stays in list_routes after draining. Check "finished" / "outcome" on the entry, or call stop_route to clear it. See MCP known limitations.

IPC: publisher fails to connect or blocks

The memory endpoint’s IPC transport is unidirectional, publisher → consumer, and the consumer is the server:

  • The consumer must be running before the publisher connects — otherwise the publisher fails with a connection error. Start the consumer-side route first.
  • send_batch blocking is normal backpressure, not a fault: socket buffers are small (8 KiB on macOS), so a batch larger than the buffer only completes once the consumer drains it. After 5 s blocked the publisher logs a warning naming the socket and keeps waiting.
  • A consumer that has accepted but stopped draining stalls the publisher indefinitely — make sure the consumer side is actually reading.
  • Named ipc://name resolves to different paths for different users/services (the /run$XDG_RUNTIME_DIR/tmp fallback). When both sides must agree, use an explicit path (ipc:///run/myapp/queue.sock).
  • IPC redelivery is consumer-local and does not survive a consumer crash; use a real broker for durable redelivery. See Cross-process IPC bridge.

Request/reply timeouts and dropped responses

  • The response output requires an input that carries a reply channel (http, websocket, grpc, or a request/reply nats/mongodb/memory). If the input does not support responses (File, SQLx, …), the message sent to response is dropped.
  • Configure a generous timeout on the requester side — bridge processing adds latency.
  • Middleware that drops metadata (like correlation_id) breaks the response chain. Keep the correlation metadata intact through the route.
  • MongoDB’s reply pattern uses emulation that waits for messages; misconfigured timeouts can cause severe stalls. Test it before relying on it.

Ordering looks wrong

With concurrency > 1, batches process in parallel, so strict per-key ordering across the route is not guaranteed — side effects at the sink can interleave. Commits are still sequenced for cumulative-ack brokers. If you need ordering, set concurrency: 1 or partition upstream so each key lands on one route. See Performance tuning.

NATS JetStream: “no stream found for given subject”

When mq-bridge auto-creates a JetStream stream it scopes it to {stream}.>, so the subject must be prefixed with the stream name: stream: "orders_stream" needs a subject like orders_stream.foo. Also, stream is required even in Core NATS mode (no_jetstream: true) — pass any placeholder string. See NATS JetStream notes.

SQLx source: “reconnecting forever” or rejects the table

An SQLx source with no cursor_column is a competing-consumers work queue and requires the queue schema (id, payload, locked_until). A plain table without locked_until fails fast with a permanent error. To read an arbitrary table non-destructively, set cursor_column to switch to cursor polling. See Endpoints.

Encrypted / compressed file source ends immediately or failed

A file source must declare the same compression/encryption the data was written with. A mismatch (wrong key, wrong codec, missing field) is a permanent decode failure — the route ends failed with the error in its status. Reading a compressed file with no compression set is rejected up front by magic-byte sniffing. See Encryption at rest.

Postgres bulk copy is slow / near-quadratic

The Postgres keyset-cursor reader does WHERE id > $cursor ORDER BY id LIMIT batch. Without an index on the cursor column it does a full scan per batch. Create one: CREATE INDEX ON <table>(id). See Performance tuning.

Throughput numbers look wrong

Measure on a release build — a debug build reports dramatically slower rates. Through the MCP server, call server_info first to confirm the build profile. See Measuring.

Float values change in the last bit

Numbers move through payloads as JSON, and serde_json’s default parser shifts ~1 ULP on ~19% of 17-significant-digit doubles. For bit-exact float round-tripping across every endpoint, build with the float-roundtrip feature. See middleware encryption notes.

Custom endpoints

When a protocol isn’t built in, you can plug your own endpoint into the engine. A custom endpoint is selected from config by name and delegates to a factory you register programmatically before starting routes.

Config

output:
  custom:
    name: "my_sink"
    config: { target: "internal://thing" }
FieldTypeRequired
namestringyes — matches the registered factory
configany JSONyes — passed through to your factory

The custom endpoint works as a route input or output.

Implementing it

Implement the CustomEndpointFactory trait and register your type before starting routes. The factory receives the config JSON and builds an endpoint that speaks the engine’s MessageConsumer / MessagePublisher traits (in mq_bridge::traits), so your endpoint participates in batching, middleware, and ack/nack exactly like a built-in one.

See also

Custom middleware

Middleware wraps the message flow between an input and an output — retries, dedup, transforms, and so on. When the built-in set doesn’t cover your need, register your own and select it from config by name.

Config

Middleware attaches to a route side, so the list goes under input: or output::

output:
  mongodb:
    url: "mongodb://localhost:27017"
  middlewares:
    - custom:
        name: "my_enricher"
        config: { lookup_url: "http://enrich.internal" }
FieldTypeRequired
namestringyes — matches the registered factory
configany JSONyes — passed through to your factory

Implementing it

Implement the CustomMiddlewareFactory trait and register it before starting routes. It exposes apply_consumer and/or apply_publisher — each defaults to pass-through, so you only implement the side you need. The consumer and publisher sides are separate because middleware wraps a MessageConsumer on the way in and a MessagePublisher on the way out; wrap order matters (see the architecture doc).

See also

Contributing

Contributions to the engine (mq-bridge) — bug reports, feature requests, docs, and code — are welcome. The engine repo holds the authoritative CONTRIBUTING.md; this page is a short orientation.

Getting started

  1. Fork and clone the engine repo.
  2. Install Rust (stable, via rustup).
  3. The tests/ folder ships Docker-Compose files for each broker, so you don’t need to install Kafka/NATS/AMQP/etc. natively.
  4. Verify your environment: cargo test --features full.

Code style

  • cargo fmt --all before submitting a PR.
  • cargo clippy --all-features -- -D warnings must pass.
  • Follow idiomatic Rust and existing conventions.

Adding an endpoint or middleware

  • Add files under src/endpoints/ or src/middleware/.
  • Update the factory functions in the relevant mod.rs.
  • Add configuration models to src/models.rs.
  • Add/adjust unit tests in the module, and integration tests under tests/integration/ where applicable.
  • Keep the REFERENCE.md snippets valid — they are parsed by tests/reference_docs_test.rs.

Building this book

The documentation lives here in mq-bridge-app under dev/docs/. To build it locally, see dev/docs/README.md.

See also