SEP-2567: Sessionless MCP via Explicit State Handles#2567
Conversation
Should this be "prior request call state"? I don't think we want other requests to effect the outcome as well right? |
|
|
||
| **Clients.** Clients become simpler: they no longer track or resend session identifiers, or need to determine whether a given server is stateful. List-endpoint caching becomes safe. | ||
|
|
||
| Rollout is a clean break: sessions are removed in the next spec version, with no deprecation window. Servers that currently rely on session-scoped state stay on the current protocol version until they have migrated to explicit handles. Protocol version negotiation already handles mixed-version deployments — a client that supports both versions speaks the old protocol to an unmigrated server and the new one to everyone else. This avoids shipping a version where clients support both modes simultaneously, which would prevent the caching benefit (a client cannot cache list endpoints if any connected server might be session-scoped). |
There was a problem hiding this comment.
Rollout is a clean break: sessions are removed in the next spec version, with no deprecation window. Servers that currently rely on session-scoped state stay on the current protocol version until they have migrated to explicit handles.
Somewhere in this SEP we should provide an example of how SDKs should handle this. I think we are essentially saying that the session functionality become a no-op.
|
This looks good to me. Supportive of this! |
|
This was reviewed by Core Maintainers: 6 Accepted, 2 Accept with Changes. |
|
New commits were pushed — removed the |
d95feda to
dada908
Compare
This means that MCP Servers cannot filter tools, resources, or prompts based on authorization. Seems like a step backwards. Why list an entity the client / user can't use? Actually, I guess you could do an auth to entity permission look-up on every call. It just means greater burden on the server |
|
Continuing from #2575 (comment)
Even if all transport calls are authenticated/authorized, there is a danger that the user with lower permissions could potentially hijack the session of a super user by using their handle id. |
You can still filter based on auth. I should clarify that sentence as I can see why it looks like it implies the opposite. "Per-connection state" means relying on information sent in previous messages on the connection. The auth headers are on every request, so it is stateless. |
There was a lot of discussion around this, and unfortunately it's not really possible for a few reasons:
|
|
@pja-ant fair point on But I think there's a narrower case the current draft leaves uncovered: transport metadata that the gateway needs but the LLM has no business seeing. Concrete example we're hitting: our gateway routes by an affinity ID identifying which backend instance holds a workflow's process-local state. Today (with With explicit handles + The pattern I'd suggest is a generalization of Concretely: a sibling to Crucially this is additional to explicit handles, not a replacement. Application state still flows through tool args where the model can reason about it. This is just for the genuinely infra-only category (routing affinity, trace context propagation, etc.) where the model has nothing useful to do with the value and putting it in context costs tokens for no benefit. Scoping it tightly to transport metadata (not general "remembered headers") sidesteps the sub-agent / forking ambiguity you flagged - there's no application-level question about which conversation owns the value, because the value's scope is whatever scope the host's conversation reset clears. |
|
The spec change removing the The handshake was where servers minted per-conversation identifiers like Concrete failure: two tool calls fired in parallel at the start of a conversation (which some hosts do) each random-route to different backend instances. Each mints its own state. The conversation ends up with split-brain state across instances that can't see each other. The mandated per-request fields are all "client identity" shaped, not per-conversation. The change leaves a critical flow for stateful backends without a spec-level mechanism - either a replacement is needed, or the handshake removal needs to be reconsidered. |
|
@pja-ant any response to the last comments? |
|
Hey, apologies, got busy and forgot to come back to this. From what you are describing, it sounds like you have some form of workflow, for which all the tool calls need to hit the same sticky server, is that right? Currently these workflows are tied to conversations(*) and so the use case puts the affinity ID into the session ID so that you have a routing handle. Functionally the recommendation is that for your workflow tools to add a It seems the main objection to this is that you'd like the LLM to not see the workflow/affinity ID. What's the constraint there that's driving the requirement? Regarding the "per conversation" framing and suggestion, I do want to emphasize that:
Just to give some examples of where the idea of this breaks down:
As Luca linked to above, there are some other discussions in this area around similar ideas, e.g. #2822 - the Transports WG will be discussing these. All are welcome to join the discussion if that would be useful. It's possible we'll have something else in this area in the near future, but unlikely in time for the new spec. Perhaps something will come in as an extension. |
|
You're right that "workflow" is a more accurate framing than "conversation" for our case - the affinity is per-workflow, not per the broader chat. On LLM visibility: honestly it's a preference, not a hard requirement. The model CAN see the workflow_id and our system works. The concerns are token cost (small), increased leak surface (routing metadata in LLM context is loggable and screenshot-able where transport metadata isn't), and layer cleanliness. None are showstoppers. The harder issue is that workflow_id inherits the same problems SEP-2567 originally raised against What actually works is a client-generated per-workflow identifier present on the very first request, which is what PR 2822 proposes. Will engage there. Agreed it likely doesn't make 2026-07-28 - just flagging the impact: this affects the F&O MCP server, which is part of the Dynamics 365 platform deployed across tens of thousands of enterprise organizations, and we don't currently have a way to prevent the parallel-first-call failure mode without protocol-level support. |
This comment was marked as spam.
This comment was marked as spam.
## Motivation and Context SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release) makes MCP sessionless: every Streamable HTTP POST must be fully self-contained, with no protocol-level session state shared between requests. `StreamableHTTPTransport` already implements the SEP's transport surface via `stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405, DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak: stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and `@client_capabilities` onto the shared `Server` instance. Concurrent stateless requests could therefore observe another client's identity, and the data persisted across requests, which is exactly what SEP-2567 forbids. The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack) solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture: - Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`), so client info, logging level, and initialized state live only for the duration of that POST. Repeated `initialize` requests are naturally permitted because each POST gets a fresh, never-initialized session. - `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising. With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message` would otherwise route every call into the exception reporter; non-delivery matches how these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises, as those are genuinely unsupported without a stream. Resolves #388. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: - a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched (the leak regression) - repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header - a tool calling `server_context.report_progress` under stateless mode returns its result normally and the exception reporter is never invoked - the existing "stateless mode does not support server-sent events" test is updated to assert the new `false` return instead of the removed raise All other existing stateless-mode tests pass unchanged. ## Breaking Changes `StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`. The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation, and the boolean return matches the method's documented semantics in every other non-delivery case. Default (session-oriented) mode is unchanged.
Summary
Draft SEP proposing the removal of protocol-level sessions from MCP, replacing implicit session-scoped state with explicit, server-minted state handles. Builds on SEP-1442 (stateless-by-default) but argues the opt-in stateful path should not exist at all.
Core claims:
tools/listforcesO(subagents × servers)re-fetches on everyone, even when ~zero servers actually use it(deployment, auth)granularityWhat changes:
session/create,session/destroy, orMcp-Session-Idheadertools/list/resources/list/prompts/listMUST NOT depend on per-connection or prior-tool-call statecreate_*() -> handle+ threaded parameters (guidance, not a protocol construct)Imported from modelcontextprotocol/transports-wg#25.