matrix-agent-connector / pull request 9

Stateless MCP, durable Matrix delivery

feat(mcp): migrate connector to stateless MCP SDK v2. The session-oriented FastMCP surface is replaced by the SDK v2 MCPServer running stateless Streamable HTTP. Matrix delivery keeps its own durability, and GET /events stays a separate Matrix outbox SSE route rather than an MCP transport.

github.com/Ekho-Labs/matrix-agent-connector/pull/9 head 7ce1dff

Review verdict

APPROVED

0 blockers

This visualization is published alongside the pull request. The code review itself is complete and carries no blocking finding.

Files changed
36
Lines added
+2802
Lines removed
-2156
Commits
14
Tools exposed
6
Tests passing
276

What this pull request changes

The transport loses its session while Matrix delivery keeps an independent durable cursor.

The connector's MCP surface used to be session-oriented. A client established a session and the server carried state on behalf of that session for as long as it lived. This pull request replaces that with the SDK v2 MCPServer running stateless_http=True, so every request stands on its own: it is authenticated on arrival, it resolves its own caller identity, and it leaves nothing behind for the next one.

Delivery state already belongs to the per-agent SQLite outbox, not to the MCP session, and this migration preserves that boundary. Room events are read back by sequence, either through the matrix_next_event tool or through the GET /events route. Progress is committed by an explicit matrix_ack_event transaction rather than inferred from a live connection.

GET /events is not an MCP transport and was not turned into one. It remains a separate Matrix outbox SSE route, and the Last-Event-ID a client sends to resume it is application state that the client owns, not a handle on server-side session state.

Transport contract after this change

Transport
Streamable HTTP, stateless. No session establishment, no session id to carry.
Protocol
MCP revision 2026-07-28.
Server
MCPServer from the MCP Python SDK v2, constructed with stateless_http=True.
Identity
Bearer credential verified on every request. agent_id is read from the request context, never from tool arguments.
Origin defence
Host and Origin allowlists on the HTTP surface, blocking DNS rebinding.
Outbox route
GET /events, a separate Matrix outbox SSE route that resumes from Last-Event-ID.
Result shape
Typed CallToolResult, with the structured JSON mirrored as text content.

Architecture change map

The left lane is one stateless request, start to finish. The right lane is delivery that outlives it. They meet only through tool calls, and neither one holds a session.

HTTP request Durable data Denied path Structured response

Lane 1

Stateless MCP request path

One request in, one typed result out. Nothing is carried between them, so nothing from a previous caller can be reused for the next one.

Lane 2

Durable Matrix delivery

Reached through the same stateless request path, but its state outlives any single request. Progress lives in SQLite and in the client's own resume token.

Last-Event-ID is application state, not an MCP session. It is a resume token the client holds over a plain SSE route. Losing it costs a replay position, not a server-side session.

Hover or tab to any block to trace what it connects to. Press Enter or Space to pin the trace so it survives the pointer leaving, and Escape to release it.

Tool boundaries

Six tools, three jobs. Every one of them resolves agent_id from the request context, then clears the tool ACL and the room ACL before it touches any state. What follows is what each boundary owns, not which files it lives in.

2 tools

Receive and acknowledge

matrix_next_event matrix_ack_event

Reads the caller's own outbox by durable sequence, and commits progress separately. The read hands back the next event after the caller's cursor; the acknowledgement is a transaction that advances that cursor only once authorization has passed on the same path.

Because the cursor is durable rather than session-scoped, a client that disconnects and returns resumes from where it acknowledged, not from wherever a live connection happened to be. A denied read or acknowledgement returns a typed error, discloses no payload, and leaves the cursor untouched.

2 tools

Send and reply

room-scoped writes

Writes into a room the caller is authorized for. The send gate resolves the caller's room policy and rate limit before any Matrix send is attempted, and records the outcome for audit.

These are the paths that carry rate limiting, since they are the ones that put new traffic into rooms rather than draining traffic already there.

2 tools

Rooms: list and create

membership surface

Lists the rooms the caller is allowed to see, and creates new ones. Creation is the only tool that takes invite lists, which makes it the one place where the connector's own ghost identity could otherwise be pulled into a room it should not be in.

That bypass is blocked on both invite paths. Neither invite_agent_ids nor invite_mxids can be used to smuggle a connector ghost into a room.

Safety controls and review findings

The final review found no blockers. Four controls were checked against the migration and hold. One low item is residual: it pre-dates this pull request and is unchanged by it.

Severity

Showing 5 of 5 entries

What changed, by group

36 files across 14 commits, grouped by the reason they had to move rather than by directory.

Group 1

Transport and auth

mcp_app.py mcp_dependencies.py mcp_results.py

This is where the migration actually happens. The session-oriented FastMCP application is replaced by the SDK v2 MCPServer with stateless_http=True, so dependencies had to be rewritten to resolve the caller per request rather than per session, and results had to be assembled as typed CallToolResult values with the JSON mirrored as text. Bearer verification and the Host and Origin allowlists live on this seam.

Group 2

Delivery and persistence

event_stream.py outbox.py outbox_maintenance.py

The per-agent SQLite outbox and its sequence cursor already own delivery durability, and this migration keeps them independent from MCP transport state. GET /events is extracted as a separate Matrix outbox SSE route that resumes from Last-Event-ID rather than being folded into the MCP transport. Outbox maintenance is separated from the core append, read, and acknowledgement path.

Group 3

Room and send safeguards

rooms.py room_helpers.py send.py rate_limit.py

Request-scoped ACL checks and send rate limits remain on the paths that touch rooms. The connector ghost invite bypass is blocked on both invite_agent_ids and invite_mxids, while shared room parsing and rate-limit state move into focused modules without changing their policy boundary.

Group 4

Client and documentation

agent_smoke.py quickstart skill

The client-side smoke script and the written guidance had to follow the transport, since there is no longer a session to establish or a session id to carry between calls. The quickstart and the agent skill now describe a plain authenticated request plus a durable cursor, which is what an integrator actually has to implement.

Group 5

Tests

protocol tools events ops

The suite was split along the boundaries the migration created rather than left as one undifferentiated set: protocol covers the stateless transport itself, tools covers dispatch and the two ACL layers, events covers outbox delivery and cursor behaviour, and ops covers the operational paths around them. 276 tests pass at head 7ce1dff. That split is what makes the denial and isolation controls above checkable rather than merely asserted.

Verdict

Final state of the review at head 7ce1dff.

APPROVED 0 blockers
276 tests Ruff clean format clean basedpyright 0 errors uv lock clean diff clean Oracle approved