- Condition
- Several agents share one deployment and issue interleaved requests against the same process.
- Blast radius
- If caller identity were cached against a session or a connection, a request from one agent could be served under another agent's identity, reaching that agent's rooms and outbox.
- Mitigation
- The stateless transport keeps no session to attach identity to.
BearerAuthMiddlewarere-authenticates every request, and the request context ownsagent_id. Handlers never take identity from tool arguments, so a caller cannot assert an identity it was not issued. - Verification
- Checked against head
7ce1dffand confirmed. Repository gates are green, including the protocol and tool test split, with 276 tests passing. The final Oracle review raised no blocker here.
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
MCPServerfrom the MCP Python SDK v2, constructed withstateless_http=True.- Identity
- Bearer credential verified on every request.
agent_idis 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 fromLast-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.
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
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
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
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.
- Condition
- An agent asks for an event, or acknowledges a sequence, in a room it is not authorized for.
- Blast radius
- A leaky denial has two costs at once. It can expose another agent's message payload in the error path, and it can advance a cursor past events the caller never legitimately received, silently dropping them for the agent that owns them.
- Mitigation
- The denial is returned as a typed error result with no payload attached, and the durable cursor is not mutated on a denied path. The acknowledgement path authorizes every covered room inside the same transaction that advances the cursor.
- Verification
- Checked against head
7ce1dffand confirmed on both the read and the acknowledge path. Covered by the events and tools test split inside the 276 passing tests. No blocker raised in the final review.
- Condition
- Room creation accepts invite lists, and a caller names the connector's own ghost identity in one of them.
- Blast radius
- A connector ghost pulled into a room it should not join widens the connector's own read surface, and does so through an ordinary tool call rather than an administrative action.
- Mitigation
- The bypass is blocked on both paths that accept invites,
invite_agent_idsandinvite_mxids. Blocking only one of them would have left the other as an equivalent route, which is why the check is applied to both rather than to the field that looked more obvious. - Verification
- Both fields checked against head
7ce1dffand confirmed blocked. Repository gates green at 276 passing tests. No blocker raised in the final review.
- Condition
- A local HTTP surface is reachable from a browser, and a hostile page rebinds a hostname it controls to the connector's address.
- Blast radius
- Without host checking, a foreign origin could drive requests against a locally reachable MCP surface, turning the browser into a confused deputy for tool calls.
- Mitigation
MCPServerenforces Host and Origin allowlists on the Streamable HTTP surface, in line with the transport guidance for MCP revision 2026-07-28. A request whose Host or Origin is not on the allowlist is rejected before it reaches a handler.- Verification
- Confirmed present and enforced on the new transport at head
7ce1dff, alongside the protocol test split. No blocker raised in the final review.
- Condition
- The
niodependency does not ship type stubs for everything the connector imports from it. - Blast radius
- Static coverage is reduced at that one dependency boundary. Nothing in this diff is affected at error level, and the gap is unchanged by the migration.
- Mitigation
- None applied here, deliberately. Adding local stubs or suppressions inside a transport migration would mix two unrelated changes and make the diff harder to review. The item is recorded rather than silently absorbed.
- Verification
basedpyrightreports 0 errors and 5 warnings at head7ce1dff. All 5 are the pre-existing missingniostub warnings, and the count did not change across this pull request.
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
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
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
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
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
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.