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

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.