Skip to content

Commit e4eca70

Browse files
committed
Add an MQTT 5 transport example
1 parent bc21a0e commit e4eca70

12 files changed

Lines changed: 513 additions & 1 deletion

File tree

.github/workflows/shared.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ jobs:
121121
uv run --frozen --no-sync --package mcp-transport-examples --group dev
122122
pytest -c examples/transports/pyproject.toml examples/transports/tests --record-mode=none
123123
--junitxml=transport-results.xml
124+
- name: Start isolated broker fixtures
125+
run: docker compose -p mcp-sdk-transport-check -f examples/transports/compose.yaml up --wait
126+
- name: Check live MQTT routing and peer loss
127+
run: |
128+
for script in demo_mqtt.py demo_mqtt_disconnect.py; do
129+
uv run --frozen --no-sync --package mcp-transport-examples --group dev python "examples/transports/$script"
130+
done
131+
- name: Stop broker fixtures
132+
if: always()
133+
run: docker compose -p mcp-sdk-transport-check -f examples/transports/compose.yaml down --volumes
124134
- name: Retain adapter test results
125135
if: always()
126136
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

examples/transports/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,45 @@ The gRPC cassette tests record real calls with `cassetter` and replay with `--re
6363
The lifecycle, capacity, and malformed-frame regression tests own a gRPC server inside the test process. That server is the software under test, not an external service; replaying its outputs would bypass the behavior being checked. Cassette tests separately compare recorded native results with the current in-process MCP handler. `cassetter` lacks parts of the streaming-call cancellation interface, so it is not used to stand in for live lifecycle checks.
6464

6565
Binary protobuf payloads are not pattern-scrubbed. Inspect new cassettes before committing them; the checked-in recordings contain only public test data.
66+
67+
## MQTT 5
68+
69+
```bash
70+
docker compose -p mcp-sdk-transport-check -f examples/transports/compose.yaml up --wait
71+
UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv sync --frozen --package mcp-transport-examples --group dev
72+
UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples python examples/transports/demo_mqtt.py
73+
UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples python examples/transports/demo_mqtt_disconnect.py
74+
docker compose -p mcp-sdk-transport-check -f examples/transports/compose.yaml down --volumes
75+
```
76+
77+
The programs check concurrent calls for two peers, both server APIs, and `legacy`, `auto`, and pinned `2026-07-28` clients. The disconnect check uses a broker-forced session takeover and requires a pending call to receive `CONNECTION_CLOSED` without relying on its request timeout. CI runs these live checks separately from cassette replay.
78+
79+
`demo_mqtt.py` contains complete connection setup. You own and enter the MQTT client before entering `mqtt_transport()`. The adapter unsubscribes on exit but does not close the borrowed client. Each logical peer gets a dedicated client and messages iterator; session identifiers are agreed out of band. Discovery and multiplexing are not implemented.
80+
81+
### MQTT wire binding
82+
83+
| Property | Value |
84+
| --- | --- |
85+
| Requests | `mcp/<principal>/<session>/requests` |
86+
| Replies | `mcp/<principal>/<session>/responses` |
87+
| Framing | One JSON-RPC message per publish |
88+
| Delivery | QoS 2 only |
89+
| Close | Empty payload |
90+
| Retention | Never retain commands; reject retained deliveries |
91+
| Expiry | MQTT message expiry, default 60 seconds |
92+
| Message limit | 4 MiB by default |
93+
| Reconnect | Fail old calls and establish fresh topics; never replay requests |
94+
95+
Configure a QoS-2, non-retained Last Will with an empty payload on the outgoing topic before CONNECT. The broker publishes it on unexpected disconnection; the example uses a 15-second keepalive to bound detection of a silent network loss. An already-connected client cannot acquire a Last Will through this adapter. Use a client request timeout for startup failures before the peer subscribes: a non-retained will is not replayed to a later subscriber.
96+
97+
QoS 2 handles protocol retransmissions within a session, not exactly-once tool execution. Republishing a request can repeat its side effects. Malformed messages become recoverable stream exceptions; connection loss ends the read stream.
98+
99+
### MQTT authorization and limits
100+
101+
The local fixture uses per-user topic ACLs and `use_username_as_clientid true`, so another authenticated user cannot evict a peer by claiming its client ID. It supports one connection per credential; server routes use separate `server-alice` and `server-bob` users. Deployments needing multiple sessions per credential need broker authorization of client-ID namespaces instead.
102+
103+
The fixture uses public test credentials, binds only to localhost, and disables persistence. Do not deploy it. Use TLS and your broker's authorization policy in production, and bind request state to a verified, authority-qualified principal through `RequestStateSecurity.bind_principal`. A successful SUBACK is not proof that Mosquitto's ACL permits message delivery.
104+
105+
The example bounds aiomqtt's incoming queue at 256 messages, but aiomqtt can drop messages when it fills. Its public publish callback also discards negative broker reason codes. These are unresolved reliability blockers, not successful execution acknowledgments. Request timeouts and monitoring are required; neither the queue bound nor MQTT QoS bounds concurrently executing tool handlers.
106+
107+
`cassetter` has no MQTT interceptor. Full broker branch coverage, saturation and failure validation, and production TLS authorization remain open gates. The provider uses asyncio and requires a selector event loop on Windows; Trio and Windows support have not been validated. Change the local port with `MQTT_TEST_PORT` (default 13883).
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
user server-alice
2+
topic read mcp/alice/+/requests
3+
topic write mcp/alice/+/responses
4+
5+
user server-bob
6+
topic read mcp/bob/+/requests
7+
topic write mcp/bob/+/responses
8+
9+
user health
10+
topic write health
11+
12+
user alice
13+
topic write mcp/alice/+/requests
14+
topic read mcp/alice/+/responses
15+
16+
user bob
17+
topic write mcp/bob/+/requests
18+
topic read mcp/bob/+/responses
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
listener 1883
2+
allow_anonymous false
3+
use_username_as_clientid true
4+
password_file /tmp/passwords
5+
acl_file /mosquitto/config/mosquitto.acl
6+
persistence false
7+
log_dest stdout
8+
log_type all
9+
max_packet_size 4195328
10+
max_inflight_messages 32
11+
max_queued_messages 256

examples/transports/compose.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
services:
2+
mqtt:
3+
image: eclipse-mosquitto:2.0.22@sha256:212f89e1eaeb2c322d6441b64396e3346026674db8fa9c27beac293405c32b3c
4+
ports:
5+
- "127.0.0.1:${MQTT_TEST_PORT:-13883}:1883"
6+
volumes:
7+
- ./brokers/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
8+
- ./brokers/mosquitto.acl:/mosquitto/config/mosquitto.acl:ro
9+
entrypoint: ["/bin/sh", "-ec"]
10+
command:
11+
- |
12+
mosquitto_passwd -b -c /tmp/passwords server-alice test-server-alice-password
13+
mosquitto_passwd -b /tmp/passwords server-bob test-server-bob-password
14+
mosquitto_passwd -b /tmp/passwords health test-health-password
15+
mosquitto_passwd -b /tmp/passwords alice test-alice-password
16+
mosquitto_passwd -b /tmp/passwords bob test-bob-password
17+
chmod 644 /tmp/passwords
18+
exec mosquitto -c /mosquitto/config/mosquitto.conf
19+
healthcheck:
20+
test:
21+
[
22+
"CMD",
23+
"mosquitto_pub",
24+
"-h",
25+
"127.0.0.1",
26+
"-u",
27+
"health",
28+
"-P",
29+
"test-health-password",
30+
"-t",
31+
"health",
32+
"-m",
33+
"",
34+
"-q",
35+
"2",
36+
]
37+
interval: 1s
38+
timeout: 3s
39+
retries: 20

examples/transports/demo_common.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Shared live-broker checks for the reference adapters."""
2+
3+
from collections.abc import AsyncIterator, Awaitable, Callable
4+
from contextlib import AsyncExitStack, asynccontextmanager
5+
from dataclasses import dataclass
6+
from functools import partial
7+
from typing import Any, TypeAlias
8+
from uuid import uuid4
9+
10+
import anyio
11+
from mcp import Client
12+
from mcp.server import Server, ServerRequestContext
13+
from mcp.server.mcpserver import Context, MCPServer
14+
from mcp.shared.transport import MessageMetadata, Transport, TransportContext
15+
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent, Tool
16+
17+
TransportFactory: TypeAlias = Callable[[AsyncExitStack, str, str, bool], Awaitable[Transport]]
18+
19+
20+
@dataclass(kw_only=True, frozen=True)
21+
class BrokerContext(TransportContext):
22+
principal: str
23+
24+
25+
def peer_context(metadata: MessageMetadata, *, principal: str, kind: str) -> BrokerContext:
26+
return BrokerContext(kind=kind, can_send_request=True, principal=principal)
27+
28+
29+
async def verify(factory: TransportFactory, *, kind: str, highlevel: bool, mode: str) -> None:
30+
"""Check real concurrent calls, peer metadata, both server APIs, and shared lifespan."""
31+
entered = {"alice": anyio.Event(), "bob": anyio.Event()}
32+
sessions = {principal: uuid4().hex for principal in entered}
33+
lifecycle: list[str] = []
34+
35+
async def identity(transport: TransportContext | None) -> str:
36+
assert isinstance(transport, BrokerContext)
37+
principal = transport.principal
38+
entered[principal].set()
39+
await entered["bob" if principal == "alice" else "alice"].wait()
40+
return principal
41+
42+
@asynccontextmanager
43+
async def lifespan(server: Server[Any] | MCPServer[Any]) -> AsyncIterator[None]:
44+
lifecycle.append("start")
45+
try:
46+
yield None
47+
finally:
48+
lifecycle.append("stop")
49+
50+
if highlevel:
51+
server = MCPServer("Broker", lifespan=lifespan)
52+
53+
@server.tool()
54+
async def identify(ctx: Context) -> str:
55+
return await identity(ctx.transport)
56+
57+
else:
58+
59+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
60+
return ListToolsResult(tools=[Tool(name="identify", input_schema={"type": "object"})])
61+
62+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
63+
assert params.name == "identify"
64+
return CallToolResult(content=[TextContent(text=await identity(ctx.transport))])
65+
66+
server = Server("Broker", lifespan=lifespan, on_list_tools=list_tools, on_call_tool=call_tool)
67+
68+
results: dict[str, str] = {}
69+
70+
async def call(client: Client, principal: str) -> None:
71+
result = await client.call_tool("identify")
72+
content = result.content[0]
73+
assert isinstance(content, TextContent)
74+
results[principal] = content.text
75+
76+
async with AsyncExitStack() as stack:
77+
transports = {
78+
principal: await factory(stack, principal, session, True) for principal, session in sessions.items()
79+
}
80+
runtime = await stack.enter_async_context(server.serve())
81+
clients: dict[str, Client] = {}
82+
for principal, transport in transports.items():
83+
await runtime.connect(
84+
transport,
85+
session_id=sessions[principal],
86+
transport_builder=partial(peer_context, principal=principal, kind=kind),
87+
)
88+
client_transport = await factory(stack, principal, sessions[principal], False)
89+
clients[principal] = await stack.enter_async_context(
90+
Client(client_transport, mode=mode, read_timeout_seconds=5)
91+
)
92+
async with anyio.create_task_group() as tg:
93+
for principal, client in clients.items():
94+
tg.start_soon(call, client, principal)
95+
assert results == {"alice": "alice", "bob": "bob"}
96+
assert lifecycle == ["start"]
97+
assert lifecycle == ["start", "stop"]

examples/transports/demo_mqtt.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Exercise the MQTT 5 adapter against the local Mosquitto broker."""
2+
3+
import os
4+
from contextlib import AsyncExitStack
5+
6+
import aiomqtt
7+
import anyio
8+
from mcp.shared.transport import Transport
9+
10+
from demo_common import verify
11+
from mcp_transport_examples.mqtt import mqtt_transport
12+
13+
14+
async def open_transport(stack: AsyncExitStack, principal: str, session: str, server_side: bool) -> Transport:
15+
user = f"server-{principal}" if server_side else principal
16+
topic = f"mcp/{principal}/{session}"
17+
incoming, outgoing = ("requests", "responses") if server_side else ("responses", "requests")
18+
client = await stack.enter_async_context(
19+
aiomqtt.Client(
20+
"127.0.0.1",
21+
int(os.environ.get("MQTT_TEST_PORT", "13883")),
22+
username=user,
23+
password=f"test-{user}-password",
24+
identifier=f"mcp-{principal}-{session}-{'server' if server_side else 'client'}",
25+
protocol=aiomqtt.ProtocolVersion.V5,
26+
keepalive=15,
27+
will=aiomqtt.Will(f"{topic}/{outgoing}", payload=b"", qos=2, retain=False),
28+
max_queued_incoming_messages=256,
29+
)
30+
)
31+
return mqtt_transport(client, incoming_topic=f"{topic}/{incoming}", outgoing_topic=f"{topic}/{outgoing}")
32+
33+
34+
async def main() -> None:
35+
for highlevel in (False, True):
36+
for mode in ("legacy", "auto", "2026-07-28"):
37+
with anyio.fail_after(5):
38+
await verify(open_transport, kind="mqtt", highlevel=highlevel, mode=mode)
39+
40+
41+
if __name__ == "__main__":
42+
anyio.run(main)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Check remote-peer loss using Mosquitto's session-takeover behavior."""
2+
3+
import os
4+
from contextlib import AsyncExitStack
5+
from uuid import uuid4
6+
7+
import aiomqtt
8+
import anyio
9+
from mcp import Client, MCPError
10+
from mcp.server.mcpserver import MCPServer
11+
from mcp.types import CONNECTION_CLOSED
12+
13+
from demo_mqtt import open_transport
14+
15+
16+
async def main() -> None:
17+
"""A broker-forced disconnect publishes the configured Last Will and settles a waiting MCP call."""
18+
entered = anyio.Event()
19+
finished = anyio.Event()
20+
server = MCPServer("peer loss")
21+
session = uuid4().hex
22+
23+
@server.tool()
24+
async def hold() -> str:
25+
entered.set()
26+
await anyio.sleep_forever()
27+
raise NotImplementedError
28+
29+
with anyio.fail_after(5):
30+
async with AsyncExitStack() as stack:
31+
server_transport = await open_transport(stack, "alice", session, True)
32+
client_transport = await open_transport(stack, "alice", session, False)
33+
runtime = await stack.enter_async_context(server.serve())
34+
await runtime.connect(server_transport)
35+
client = await stack.enter_async_context(Client(client_transport, read_timeout_seconds=None))
36+
37+
async def call() -> None:
38+
try:
39+
await client.call_tool("hold")
40+
except MCPError as exc:
41+
assert exc.code == CONNECTION_CLOSED
42+
else:
43+
raise AssertionError("Remote peer loss must fail the pending call")
44+
finished.set()
45+
46+
async with anyio.create_task_group() as tg:
47+
tg.start_soon(call)
48+
await entered.wait()
49+
async with aiomqtt.Client(
50+
"127.0.0.1",
51+
int(os.environ.get("MQTT_TEST_PORT", "13883")),
52+
username="bob",
53+
password="test-bob-password",
54+
identifier="server-alice",
55+
protocol=aiomqtt.ProtocolVersion.V5,
56+
):
57+
assert [tool.name for tool in (await client.list_tools()).tools] == ["hold"]
58+
assert not finished.is_set()
59+
async with aiomqtt.Client(
60+
"127.0.0.1",
61+
int(os.environ.get("MQTT_TEST_PORT", "13883")),
62+
username="server-alice",
63+
password="test-server-alice-password",
64+
identifier="server-alice",
65+
protocol=aiomqtt.ProtocolVersion.V5,
66+
):
67+
await finished.wait()
68+
69+
70+
if __name__ == "__main__":
71+
anyio.run(main)

0 commit comments

Comments
 (0)