Repository navigation
SEP-1287: WebSocket transport for MCP - #1287
lmaccherone wants to merge 0 commit into
Conversation
|
Whoops, I thought my branch merge was from different branches in my own PR. I did not intend to try to merge this into the main for this project. Apologies. |
|
@Kludex, we are down to a few design decisions that interplay: a) Envelope or not? b) Cookies or not? c) Server or client generates the mcpSessionId. Here are the options I can see: Option 1: Kill the envelope, smuggle the auth token in on a sub-protocol, smuggle the mcpSessionId back on a subprotocol response, do NOT support cookie for auth. Note, this technically breaks the WebSocket standard because servers accepting a WS connection are supposed to choose from the list of subprotocol strings provided by the client, but we'll have to give a new subprotocol string which includes the mcpSessionId. I think this risk is low because I have read that implementations generally don't enforce that part of the standard. Option 2: Keep the envelope, still allow the auth token in on subprotocol, but send the mcpSessionId back in the envelope as the response to the Option 3: Similar to Option 1, but have the client generate the mcpSessionId and send it in on a different subprotocol from the auth token. That avoids the need to break the WebSocket standard by sending back a chosen subprotocol that wasn't in the original list provided by the client. This option allows us to keep the cookie method as well... or not. The biggest problem with this option is that it's the opposite of how HTTP does it. We already have a semantic difference--only one active connection per mcpSessionId, but I still would rather not have another. I recommend that we leave the PR as is for now, and I will spend the spare time I have in the next week working on reference implementation(s). I have never tried subprotocol smuggling, so that will be my focus. My current implementation is TypeScript running on Cloudflare Workers with Durable Object hibernating WebSockets on the server. For the client, it's also TypeScript but in the browser. It should be easy to make a client and server in Node.js, using a lot of the same code. Thoughts? |
|
I have some time today. I'm going to move forward with creating a reference implementation based upon Option 1. |
|
Some learning, but not good news. We cannot smuggle the mcpSessionId back on the subprotocol response. It works in some browsers but not in Node.js or Chrome. This leaves us three alternatives: 1) having the client provide the mcpSessionId in the URL when it connects, which is the opposite of how current network transports do it; 2) keeping the envelope; and 3) upgrading the core specification to allow for the passing of the mcpSessionId in the JSON-RPC package. I don't want to do 3 but I would be fine with the other two. Thoughts? |
A group of folks in the Transports WG are working on a proposal (related to sessions, not WebSockets) that implements 3. The plan is to submit it within the next week or two for discussion and feedback. |
|
OK. That's super helpful. I'm wondering if you would be open to a phone call to discuss MCP sessions with me wrt to WebSockets. I would prefer to have a one-to-one relationship between the mcpSessionId and a WebSocket connection. If I am reading the Streamable HTTP transport correctly, different streams can use the same Mcp-Session-Id. I don't know how you'd know what update subscriptions went with what stream in that case, so I suspect I'm missing something fundamental. |
Sure. Are you in the Discord server? If so, feel free to ping me (Jonathan Hefner) there. You can also hop in the
By "different streams" you mean different SSE streams (i.e. different request-responses), yes? If so, that is correct, different requests can use the same I haven't been following this thread very closely so I'm not sure what you mean by "update subscriptions". Is that referring to messages like resource update notifications? If so, those kinds of notifications are all multiplexed onto the same GET endpoint. |
| An MCP "session" consists of logically related interactions between a client and a | ||
| server, beginning with the [initialization phase](/specification/draft/basic/lifecycle). | ||
|
|
||
| A key motivation for this WebSocket transport is to enable bi-directional usage including server-initiated notifications, which is unworkable without session state that survives connection disruptions. Therefore, servers and clients using this WebSocket transport **MUST** provide MCP session support. |
There was a problem hiding this comment.
I don't think it's critical that a session survives connection disruptions in all cases. It's nice to offer optional support for it, but I don't think we should mandate the server supports it. I imagine many MCP servers are not going to want to deal with managing and cleaning up idle sessions in the background.
One big advantage a session-id-less WebSocket connection has over Streamable HTTP is that you can maintain a long-running session spanning multiple JSON-RPC requests but keep the management of the implicit session super simple by tying the session lifetime directly to the websocket lifetime. You also don't have to worry about dealing with possible duplicate or dropped messages.
We could add something similar to Streamable HTTP's Last-Event-Id support, but that adds a lot of complexity and potential server-side storage requirements. It's also not easy for the server to safefly free its sent message buffers because the client isn't expected to send any ACKs. I think this complexity is the main reason why Last-Event-Id support isn't widely implemented by real MCP servers yet unless their focused on extra long running operations.
A session-id-less WebSocket connection also means don't have to worry about things like session-affinity behind a load balancer since your server simply wouldn't support reconnections. Of course you could simulate this by just returning a -32602 "Invalid mcpSessionId" response to any reconnect attempt, but I think it would be cleaner to have the server just omit the mcpSessionId in this case and make it clear up front that reconnecting is not supported.
There was a problem hiding this comment.
keep the management of the implicit session super simple by tying the session lifetime directly to the websocket lifetime
My first MCP server worked this way, but the connection dropped often enough that it was unworkable for any stateful use cases. When you reconnected, you had to re-establish all state, which happened much more often than I expected. The most popular WebSockets client libraries have auto-reconnection, which hides the ephemeral nature of the connection.
So, I resist tying any server state to the connection. Robust reconnection semantics tied to mcpSessionId are a better approach.
As you say, this supports the argument for adding support for Last-Event-Id to this SEP. I just haven't worked through all the trade-offs of various ways to implement Last-Event-Id. I can't simply use the header as HTTP does, since headers are not available anywhere except in the initial WebSocket upgrade request. As you say, this would be easier if notifications were acknowledged. I can work on adding Last-Event-Id to this proposal if you think that would be good enough. Just let me know?
Separately, I could make sessions optional for the WebSocket transport as long as it was clear that you shouldn't tie any state to the connection. In that case, you'd think of the WebSocket transport as a more efficient transport for self-contained request-response usage. However, I suspect that wouldn't help you get on board.
There was a problem hiding this comment.
@connor4312, @halter73, and @Kludex, how about this for Last-Event-Id?
- We add another optional field to notifications, say
mcpEventId. I would use a monotonic ULID, but we should only constrain it in the same way an SSE event ID is constrained. - The client saves the last of these
- When a client needs to reconnect and wants all notifications that it missed, it includes an optional
mcpEventIdfield in aninitializecall. This means that if the client wants to catch up, it must call initialize. However, as currently written, calling initialize on reconnect is optional, which is helpful because... - In the
initializecall response, the server somehow acknowledges that it will immediately follow with catchup notifications. - The server then starts sending the missed notifications with their
mcpEventIds - Only after the client receives the
mcpEventIdthat matches their last, does the client send back thenotifications/initializednotification to end the initialization phase.
SEP-1364 alt 2 wanted to make this last notification optional, which I think we can still do, but only if the client doesn't want catchup notifications.
Thoughts?
There was a problem hiding this comment.
I agree with @halter73 's initial message in this thread.
I think the key motivation is because it's that WebSocket bidirectional protocol, and it makes sense to have MCP as subprotocol.
There was a problem hiding this comment.
Let's schedule a call to discuss this.
I'm okay with making sessions optional. However, if you do use them, I feel strongly that the state should be tied to the session, NOT the connection.
| ```http | ||
| GET /mcp HTTP/1.1 | ||
| Host: mcp.example.com | ||
| Authorization: Bearer eyJhbGciOiJIUzI1NiIs... | ||
| ``` | ||
|
|
||
| **WebSocket Transport:** | ||
|
|
||
| Due to browser WebSocket API limitations that prevent setting headers during the WebSocket upgrade request, auth tokens are smuggled in using the WebSocket subprotocol list. |
There was a problem hiding this comment.
I think we should add a reference to this issue: whatwg/html#2177.
Also, I think we should mention that it's more practical to have a single way to send the token.
We could also have a fallback mechanism here and use the proper Authorization header.
There was a problem hiding this comment.
OK. I'll put the Authorization header back in. I never should have removed that. I might not get that done before 11am, but we can talk about it as there are two ways.
There was a problem hiding this comment.
Please don't! It's all good. I said "could", we can discuss it in the presentation.
There was a problem hiding this comment.
I think we should add a reference to this issue: whatwg/html#2177.
Can you draft an edit for this? I'm not sure where to put it and what point to make? Is the point that the Streamable HTTP transport for browser usage suffers from the same problem when using SSE with EventSource? It's not clear to me that is how people consume the Streamable HTTP transport in browsers. The other option, I can think of would be to establish an HTTP/2 bidirectional stream and parse the SSE package contents (essentially an envelope) without using EventSource. Note, I have no idea if anyone does that. Just expressing my lack of context.
There was a problem hiding this comment.
out of curiosity, the current implementations for WS tend to either ship them in query or in cookie, cookie doesn't seem like a valid option here but was query_string considered? In practice there have been some instances where the protocol is logged / monitored, or just blocked.
There was a problem hiding this comment.
Yep, I had all four in the original draft: cookies (must be server-set for browser but works), query param, Authorization header, and WebSocket subprotocol smuggling. The feeling was that since the spec defines the minimum that everyone must support meaning every server would have to implement this, we should choose the one way that works for everyone.
I don't feel strongly so would happily change or add to how we can do auth.
I think the bigger worry is that with WebSockets, all of those mechanisms are only available when the connection is made. This makes rotating tokens doable by having the server to close the connection when the token expires but some folks don't like that.
A way to fix that, which was also in the original draft, is to add an envelope that essentially mimics http headers and allows for the transmission of other sideband metadata on every message from the client.
There was a problem hiding this comment.
The envelope was objected to because that would force load balancers to parse the JSON.
| For details, see [the Protocol Version Header section in Transports](/specification/draft/basic/transports#protocol-version-header). | ||
| server. For details, see [the Protocol Version Header section in Transports](/specification/draft/basic/transports#protocol-version-header). | ||
|
|
||
| Similarly, if using WebSocket transport, the client **MUST** include an `mcp.version.${protocolVersion}` value in the WebSocket subprotocols list via the `Sec-WebSocket-Protocol` header in the WebSocket upgrade request. |
There was a problem hiding this comment.
Again, we are doing this because of browser limitations. I think it's important to mention this, and show reference.
|
|
||
| This section defines bidirectional transport of MCP over a WebSocket connection which provides better support for maintaining long-lived connections particularly in cloud environments and through intermediaries. Long-lived connections are necessary for server-initiated notifications like `notifications/resources/updated` and `notifications/*/list_changed` which enable real-time use cases. | ||
|
|
||
| The server **MUST** provide an HTTP endpoint path that supports WebSocket upgrade HTTP request. This **SHOULD** be the same endpoint as the Streamable HTTP transport. |
There was a problem hiding this comment.
This SHOULD be the same endpoint as the Streamable HTTP transport.
This is likely to be annoying for the Python SDK, and I think for others as well.
Is there a .well-known we are supposed to add those endpoints, or a SEP for it?
In any case, I think it should't be the same.
There was a problem hiding this comment.
Fair enough. Should I make it MAY or should I explicitly state that it should be on a different endpoint?
I did a quick search of the spec for that string and the only place ".well-known" shows up in the spec is for authorization so I think it's currently unspecified for transports. We'll need a discussion to decide if/where we want to add it but at first thought I think it actually makes sense to put it near the top of the transport.mdx.
There was a problem hiding this comment.
I think it's common to see /ws everywhere, so I would add a SHOULD for this one.
|
|
||
| ### Message Handling | ||
|
|
||
| All messages **MUST** be sent as UTF-8 encoded text frames. Binary frames are not supported and **MUST** result in connection termination with close code `1003` (Unsupported Data). |
There was a problem hiding this comment.
Is the purpose of this limitation to simplify the implementation? I'm not very strong about this, but at least in the Python SDK this wouldn't be an issue - we can handle the validation of both frame formats in ~4 lines of code.
There was a problem hiding this comment.
The UTF-8 requirement is copied from the Streamable HTTP transport but I'm guessing you mean the rest of it.
Yes, I favor "ONE GOOD WAY". The less a server must support, the more likely it is to get implemented.
| An MCP "session" consists of logically related interactions between a client and a | ||
| server, beginning with the [initialization phase](/specification/draft/basic/lifecycle). | ||
|
|
||
| A key motivation for this WebSocket transport is to enable bi-directional usage including server-initiated notifications, which is unworkable without session state that survives connection disruptions. Therefore, servers and clients using this WebSocket transport **MUST** provide MCP session support. |
There was a problem hiding this comment.
I agree with @halter73 's initial message in this thread.
I think the key motivation is because it's that WebSocket bidirectional protocol, and it makes sense to have MCP as subprotocol.
| - Client **MUST** include `mcp.auth.${token}` in the WebSocket subprotocols list | ||
| - Server **MUST** validate auth before proceeding. Auth is associated with the connection, not a particular session | ||
| - Client **MUST** include the `mcpSessionId` from the previous session in any message after reconnection | ||
| - Client **MAY** send an `initialize` message including a `params.mcpSessionId` after reconnecting over a different WebSocket connection to an existing session |
There was a problem hiding this comment.
This part I didn't get it. 🤔
Why would that differ?
There was a problem hiding this comment.
What part of "This part"? Those four lines cover a lot of ground.
|
|
||
| Servers **MUST** return appropriate HTTP status codes during WebSocket upgrade failures: | ||
|
|
||
| - `404 Not Found`: WebSocket endpoint or message handler not found |
There was a problem hiding this comment.
Good catch. That's an uncleaned up artifact of an earlier version where I had multiple things that could be done on the endpoint. I'll remove it but maybe not before 11am.
Adds to transports.mdx file a specification for WebSocket transport
See the associated issue: #1288
Types of changes
Checklist