MCP server
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 name | io.github.marcomq/mq-bridge-app | |
|---|---|---|
| Transports | stdio, streamable HTTP | |
| Tools | 7 | see below |
| Connectors | 15+ | see below |
| Install | mq-bridge-app mcp install | Docker / 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
eventsRedis stream into theeventstable 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
| Flag | Default | Meaning |
|---|---|---|
--transport | stdio | stdio for local clients, http for streamable HTTP (served over hyper) |
--bind | 127.0.0.1:9092 | Listen address; --transport http only |
--report-to-ui | off | Also 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
| Tool | Key arguments | Purpose |
|---|---|---|
publish | publisher, message | messages, name | Send one message or a batch to any endpoint. Independent of routes. |
start_route | route (input/output), name, batch_size, concurrency, capture_last | Run a route moving messages from source to sink. Returns the route name. |
list_routes | — | Every route started by this server, with live connection health and rates. |
route_status | name (optional) | Health, totals and rates for one route, or all of them. |
route_messages | name | The most recent messages captured on a route. Requires capture_last; reads drain the buffer. |
stop_route | name | Stop a route; returns total messages and the rate it achieved. |
server_info | — | Crate 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 key | Connector | Details |
|---|---|---|
sqlx | PostgreSQL / MySQL / MariaDB / SQLite | connectors · parameters |
postgres_cdc | PostgreSQL logical replication (CDC) | connectors · parameters |
clickhouse | ClickHouse | connectors · parameters |
mongodb | MongoDB | connectors · parameters |
kafka | Apache Kafka | connectors · parameters |
amqp | RabbitMQ (AMQP) | connectors · parameters |
nats | NATS / JetStream | connectors · parameters |
mqtt | MQTT | connectors · parameters |
redis_streams | Redis Streams (alias redis) | connectors · parameters |
aws | AWS SQS / SNS | connectors · parameters |
ibmmq | IBM MQ (optional feature) | connectors · parameters |
zeromq | ZeroMQ | connectors · parameters |
http | HTTP client & server | connectors · parameters |
websocket | WebSocket | connectors · parameters |
grpc | gRPC | connectors · parameters |
file | Files — CSV / JSON / JSONL / raw | connectors · parameters |
object_store | Cloud object storage (alias s3) | parameters |
sled | Embedded local store | endpoints |
memory | In-process topic | endpoints |
null | Discards everything — {"null": null} as an output drains a source | endpoints |
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:
| Field | Meaning |
|---|---|
messages | Total messages the route has moved. |
messages_per_second | Instantaneous rate, smoothed over ~0.5 s. Decays to ~0 within a second of a route going idle. |
elapsed_s | The span over which those messages moved: route start → last message seen. Stops growing once the route goes idle. |
average_messages_per_second | messages / 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.
| Measurement | Result |
|---|---|
| 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_second | 768,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 dataset | 1,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
| Command | Purpose |
|---|---|
mcp install | Register this binary. --client, --local, --report-to-ui, --print-config. |
mcp uninstall | Remove the registration. --client, --local. |
mcp status | Show where it is registered and whether the path is still current. |
| Client | Global config | Project config (--local) |
|---|---|---|
claude (Claude Code) | claude mcp add --scope user, else ~/.claude.json | --scope project, else ./.mcp.json |
claude-desktop | claude_desktop_config.json | not 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_emptystays inlist_routesafter it has drained and exited; callstop_routeto 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 arefalse/null; once its task ends,"finished": trueand"outcome"iscompleted(drained cleanly) orfailed(permanent error — the cause is instatus.error).stop_routeremoves the entry fromlist_routesas 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 install | MCP Registry | |
|---|---|---|
| For | Someone who already has the binary | Someone who has not heard of it yet |
| Does | Writes the client config directly | Publishes discovery metadata only |
Needs server.json | No | Yes |
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:
registryType | Identifier | Requires |
|---|---|---|
oci | ghcr.io/marcomq/mq-bridge-app:<version> | Docker; no Rust toolchain |
cargo | mq-bridge-app | cargo 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.namein theDockerfilefinal 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.