Skip to content

SEP-1287: WebSocket transport for MCP - #1287

Closed
lmaccherone wants to merge 0 commit into
modelcontextprotocol:mainfrom
lumenize:main
Closed

lmaccherone wants to merge 0 commit into
modelcontextprotocol:mainfrom
lumenize:main

Conversation

@lmaccherone

@lmaccherone lmaccherone commented Aug 2, 2025 •

Copy link
Copy Markdown

Adds to transports.mdx file a specification for WebSocket transport

See the associated issue: #1288

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • N/A My code follows the repository's style guidelines
  • N/A New and existing tests pass locally
  • N/A I have added appropriate error handling
  • I have added or updated documentation as needed

@lmaccherone
lmaccherone requested a review from a team August 2, 2025 15:10
@lmaccherone
lmaccherone requested a review from a team August 3, 2025 23:26

@Kludex Kludex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll review more later.

@dsp-ant dsp-ant changed the title SEP PR for WebSocket transport for MCP SEP-1287:for WebSocket transport for MCP Aug 4, 2025
@dsp-ant dsp-ant changed the title SEP-1287:for WebSocket transport for MCP SEP-1287: WebSocket transport for MCP Aug 4, 2025
@lmaccherone

Copy link
Copy Markdown
Author

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 Kludex added the in-review SEP proposal ready for review. label Aug 5, 2025
@lmaccherone

lmaccherone commented Aug 5, 2025 •

Copy link
Copy Markdown
Author

@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 initialize call. The decision of whether or not we support cookies is independent of this, if we keep the envelope.

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?

@lmaccherone

Copy link
Copy Markdown
Author

I have some time today. I'm going to move forward with creating a reference implementation based upon Option 1.

@lmaccherone

lmaccherone commented Aug 7, 2025 •

Copy link
Copy Markdown
Author

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?

@jonathanhefner

Copy link
Copy Markdown
Member
  1. 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.

@lmaccherone

lmaccherone commented Aug 7, 2025 •

Copy link
Copy Markdown
Author

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.

@jonathanhefner

Copy link
Copy Markdown
Member

I'm wondering if you would be open to a phone call to discuss MCP sessions with me wrt to WebSockets.

Sure. Are you in the Discord server? If so, feel free to ping me (Jonathan Hefner) there. You can also hop in the #transports channel a start up a thread if you'd like. Anyone is welcome. 😄

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.

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 Mcp-Session-Id.

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.

@halter73 halter73 Aug 27, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@connor4312, @halter73, and @Kludex, how about this for Last-Event-Id?

  1. 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.
  2. The client saves the last of these
  3. When a client needs to reconnect and wants all notifications that it missed, it includes an optional mcpEventId field in an initialize call. 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...
  4. In the initialize call response, the server somehow acknowledges that it will immediately follow with catchup notifications.
  5. The server then starts sending the missed notifications with their mcpEventIds
  6. Only after the client receives the mcpEventId that matches their last, does the client send back the notifications/initialized notification 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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lmaccherone lmaccherone Aug 28, 2025 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't! It's all good. I said "could", we can discuss it in the presentation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ciamshrek ciamshrek Aug 28, 2025 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lmaccherone lmaccherone Aug 28, 2025 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part I didn't get it. 🤔

Why would that differ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Message handler not found?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch was previously deployed

1 inactive deployment
github-pages — 35fa160c Deployed Dec 2, 2025 by dsp-ant via deploy #327
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in-review SEP proposal ready for review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants