DEV Community

Yigit Konur
Yigit Konur

Posted on

Understanding SSE Protocol (will be deprecated) of MCP Server & Client (+vs Streamable HTTP)

Today, we're going on a code archaeology expedition. We're going to dig deep into the internals of a real-world TypeScript SDK to understand how it implements a classic real-time communication pattern: Server-Sent Events (SSE).

While the SDK has a shiny, modern transport layer (which we'll touch on), understanding its legacy HTTP+SSE transport is a masterclass in protocol design, asynchronous communication, and managing state over stateless protocols like HTTP. We'll be looking at the Model Context Protocol (MCP) TypeScript SDK, dissecting the client and server code to see exactly how the sausage is made.

This is going to be a long one, so grab your favorite beverage, get comfortable, and let's peel back the layers.

What We'll Cover:

  • Part 1: The Core Architecture: The brilliant separation of "Protocol" from "Transport."
  • Part 2: The SSE Client Deep Dive: How the client juggles a one-way stream and separate POST requests.
  • Part 3: The SSE Server Deep Dive: How the server keeps track of it all without losing its mind.
  • Part 4: The Authentication Flow: Making it all secure with OAuth.
  • Part 5: The Bridge to the Future: How the SDK ensures backward compatibility.

Ready? Let's dive in.


Part 1: The Core Architecture: The Brains vs. The Brawn

Before we can even talk about SSE, we need to get our heads around the most fundamental design principle in this SDK: the strict separation of the Protocol Engine (the brains) from the Transport Layer (the brawn).

  • The Protocol knows what is being said. It understands JSON-RPC 2.0, manages requests, responses, notifications, and handles things like timeouts.
  • The Transport knows how to say it. It's responsible for the raw communication—opening connections, sending bytes, and receiving bytes over HTTP, WebSockets, or any other method.

This separation is beautiful because it means we can swap out the entire communication method without changing a single line of the protocol logic.

1.1. The Transport Abstraction (Transport Interface)

Everything starts with the Transport interface. Think of it as a universal adapter, a contract that any communication mechanism must fulfill to be used by the protocol engine.

// Sourced from: @modelcontextprotocol/typescript-sdk/src/shared/transport.ts
export interface Transport {
  // --- Methods for managing the connection ---
  start(): Promise<void>;
  send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>;
  close(): Promise<void>;

  // --- Callbacks for the Protocol to listen to ---
  onclose?: () => void;
  onerror?: (error: Error) => void;
  onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;

  // --- State management ---
  sessionId?: string;
}
Enter fullscreen mode Exit fullscreen mode

Reference Source: @modelcontextprotocol/typescript-sdk/src/shared/transport.ts

  • start(): This is the "Go!" button. It kicks off the connection, whatever that may mean for the specific transport (for SSE, it's making a GET request).
  • send(): This method's job is simple: take a fully formed JSONRPCMessage and get it to the other side. It doesn't care what's inside.
  • onmessage?: This is the most important callback. When the transport receives a complete message, it calls onmessage to hand it off to the protocol engine for processing.
  • sessionId?: A crucial piece of the puzzle for stateful communication. As we'll see, this is the magic key for the SSE transport.

1.2. The Protocol Engine (Protocol Class)

If the Transport is the brawn, the Protocol class is the brains of the operation. It sits on top of a Transport instance and handles all the complex logic.

Reference Source: @modelcontextprotocol/typescript-sdk/src/shared/protocol.ts

Its main jobs are:

  • Playing Matchmaker: When you make a request, it generates a unique ID, sends the request via the transport, and then stores a Promise resolver in a map (_responseHandlers). When a response with that same ID comes back, it finds the right resolver and completes your original request.
  • Managing Timeouts & Cancellation: It handles all the logic for request timeouts and for sending/receiving cancellation notifications.

The magic happens in the connect method, where the Protocol takes complete ownership of the Transport by setting its callbacks. This is a classic example of Inversion of Control.

// Sourced from: @modelcontextprotocol/typescript-sdk/src/shared/protocol.ts
async connect(transport: Transport): Promise<void> {
  this._transport = transport;

  // The Protocol is now listening to the Transport
  this._transport.onmessage = (message, extra) => {
    // ...
    if (isJSONRPCResponse(message) || isJSONRPCError(message)) {
      // This is a response to a request we sent. Let's handle it.
      this._onresponse(message);
    } else if (isJSONRPCRequest(message)) {
      // This is a new request from the other side. Let's process it.
      this._onrequest(message, extra);
    } // ... and so on for notifications
  };

  await this._transport.start();
}
Enter fullscreen mode Exit fullscreen mode

With this foundation, the SSEClientTransport can focus only on the weirdness of SSE, trusting the Protocol class to handle the rest.


Part 2: The SSE Client-Side Deep Dive (SSEClientTransport)

Alright, let's get our hands dirty. The SSEClientTransport is asymmetric. Think of it like having two separate phone lines: one for listening only (a persistent HTTP GET) and one for speaking only (separate HTTP POST calls).

2.1. Instantiation and Configuration

You configure the client transport with SSEClientTransportOptions. The key players are:

  • authProvider: The hook for your entire OAuth authentication flow.
  • eventSourceInit: A powerful, low-level hook to customize the browser's EventSource object itself. This is how auth headers get injected into the listening GET request.
  • requestInit: A way to add custom headers (or other options) to the separate POST requests.

2.2. The Asymmetric Handshake: The Core SSE Transport Logic

This is the coolest and most critical part of the entire client implementation. When the connection starts, the client can listen, but it has no idea where to talk back to. The handshake solves this.

Step 1: The Client makes a GET Request
In the _startOrAuth() method, the client creates new EventSource(this._url.href, ...). This is it. This is the persistent GET request that opens the listening channel to the server.

Step 2: The Client Listens for the "endpoint" Event
The client is now waiting for a special, custom SSE event named "endpoint". The connection isn't considered "ready" until this event arrives.

// Sourced from: @modelcontextprotocol/typescript-sdk/src/client/sse.ts

// The client sets up a listener for a custom event named 'endpoint'.
this._eventSource.addEventListener("endpoint", (event: Event) => {
  const messageEvent = event as MessageEvent;
  try {
    // The server has sent us the URL to use for our POST requests!
    this._endpoint = new URL(messageEvent.data, this._url);

    // ... validation logic ...
  } catch (error) {
    // ... handle error ...
    return;
  }

  // A-ha! The connection is now fully established.
  // The 'start()' promise resolves HERE.
  resolve(); 
});
Enter fullscreen mode Exit fullscreen mode

Step 3: The Server Sends the "endpoint" Event
Now let's peek at the server code. The very first thing the SSEServerTransport.start() method does is craft and send this exact event.

// Sourced from: @modelcontextprotocol/typescript-sdk/src/server/sse.ts

// The server creates the session ID.
const sessionId = this._sessionId;

// It builds the URL for the client to POST to, embedding the session ID.
const endpointUrl = new URL(this._endpoint, 'http://localhost');
endpointUrl.searchParams.set('sessionId', sessionId);
const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search;

// And sends it as the data for the 'endpoint' event.
this.res.write(
  `event: endpoint\ndata: ${relativeUrlWithSession}\n\n`,
);
Enter fullscreen mode Exit fullscreen mode

This is the lynchpin of the entire transport. The server literally tells the client where to send its messages, completing the asymmetric connection.

2.3. Receiving Data from the Server (onmessage)

This part is simple. The client's _eventSource.onmessage handler fires for any standard message event. It just parses the JSON data, validates it with a Zod schema (JSONRPCMessageSchema), and passes the valid message up to the Protocol layer.

2.4. Sending Data to the Server (send)

When the Protocol engine wants to send a message, it calls the transport's send() method. This method performs a completely separate HTTP POST request to the _endpoint URL it learned during the handshake. Every single message the client sends results in a new, distinct POST request.

2.5. Connection State and Teardown (close)

The client's state is held in a few key properties: _eventSource (the GET connection), _endpoint (the POST URL), and _abortController (to cancel POSTs).

The close() method is a kill switch for everything:

  1. It calls this._eventSource?.close() to terminate the listening GET stream.
  2. It calls this._abortController?.abort() to cancel any POST requests that might still be in-flight.

Part 3: The SSE Server-Side Deep Dive (SSEServerTransport)

Now, let's flip the script. The server has the tough job of managing potentially thousands of these asymmetric connections.

3.1. Server Instantiation and Session Management

The SSEServerTransport is designed to be one instance per connected client. When it's created, its first job is to generate a unique session ID: this._sessionId = randomUUID().

But here's the crucial insight: the transport itself doesn't know how to link a POST request to the correct client stream. This logic lives in the application layer. The simpleSseServer.ts example shows this perfectly. It uses a simple in-memory object as a map:

// Sourced from: @modelcontextprotocol/typescript-sdk/src/examples/server/simpleSseServer.ts

// This global object is the glue holding everything together.
const transports: Record<string, SSEServerTransport> = {};

// When a client connects with GET, we create a transport and store it.
app.get('/mcp', async (req: Request, res: Response) => {
  const transport = new SSEServerTransport('/messages', res);
  transports[transport.sessionId] = transport; // <-- Storing the instance by its new session ID
  // ...
});

// When a POST comes in, we use the sessionId from the URL to find the right transport.
app.post('/messages', async (req: Request, res: Response) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports[sessionId]; // <-- Retrieving the instance
  // ...
});
Enter fullscreen mode Exit fullscreen mode

3.2. Handling the Initial GET Connection (start)

When the application calls transport.start(), it does two things:

  1. Writes the Content-Type: text/event-stream header, officially turning the HTTP response into a long-lived SSE stream.
  2. Sends the event: endpoint message back to the client, providing the sessionId that will be used to tie the two channels together.

3.3. Handling POST Messages (handlePostMessage)

When a POST comes into the /messages endpoint, the application finds the right transport instance and calls handlePostMessage. This method parses the JSON body, passes the message up to the Protocol engine, and then... does something interesting.

It immediately responds to the POST request with res.writeHead(202).end("Accepted").

Think about that. The client sent a request, but the HTTP response it gets back is not the answer. It's just an acknowledgment. It's like the server saying, "Got your letter, I'll mail you a reply later." The "reply" will be sent down the open GET stream.

3.4. Pushing Events to the Client (send)

When the server's Protocol engine has a response or notification to send, it calls the transport's send() method. This is where the magic happens. The send method simply formats the message as SSE data: and writes it directly to the stored ServerResponse object—the very same response object from the initial GET request that's been held open this whole time.

3.5. Security: DNS Rebinding Protection

The SDK includes a neat security feature. The validateRequestHeaders method can check the Host and Origin of incoming requests against an allowlist. This helps prevent a malicious website from using a victim's browser as a proxy to make requests to an internal or non-public MCP server.


Part 4: Authentication Flow in Detail

Real-world apps need auth. In this system, authentication is reactive. The client tries to connect, and if it gets a 401 Unauthorized, it kicks off the auth flow.

Scenario 1: 401 on the Initial GET Connection
If the EventSource connection fails with a 401, the onerror handler catches it and triggers the full _authThenStart() method, which will handle the OAuth redirect or token refresh and then try to connect again.

Scenario 2: 401 on a POST Request
If a send() operation results in a 401, the catch block in the send method will trigger the auth() flow and then—this is key—it recursively calls this.send(message) to retry the original message after a successful re-authentication.

Scenario 3: Smooth Token Refreshes
The tests reveal an even better user experience. The auth() utility doesn't immediately jump to a full-page redirect. It first tries to use a stored refresh_token to get a new access_token silently in the background. The disruptive redirect only happens if that refresh fails.


Part 5: Evolution & The Compatibility Mandate

So, why did we just spend all this time dissecting a "legacy" protocol? Because understanding the past is the best way to appreciate the present and future.

5.1. Contrasting SSEClientTransport with StreamableHTTPClientTransport

The new StreamableHTTPClientTransport is a huge improvement.

  • Connection Model: It uses a unified model. A single POST request can both send a message and receive a text/event-stream in its response body. No more juggling two separate connections!
  • Resumability: It has first-class support for resumptionToken, allowing a client to disconnect and reconnect, replaying any messages it missed. This is a game-changer for unreliable network conditions and is completely absent in the old SSE transport.

5.2. The Backward-Compatible Server

The sseAndStreamableHttpCompatibleServer.ts example shows how you can build a bridge to the future. It uses simple Express routing to direct traffic:

  • Requests to /mcp are handled by the new StreamableHTTPServerTransport.
  • Requests to the old /sse and /messages endpoints are handled by the legacy SSEServerTransport.

This allows a single server to support both old and new clients during a migration period.

5.3. The Fallback Client

Finally, the streamableHttpWithSseFallbackClient.ts example shows the client-side strategy. The connectWithBackwardsCompatibility function is a simple try...catch block. It tries to connect with the modern transport first. If that fails, it catches the error and falls back to trying the legacy SSE transport.

SSE vs. Streamable HTTP: An Evolutionary Leap

While the legacy SSE transport is a clever piece of engineering, the modern StreamableHTTPClientTransport represents a significant evolution in protocol design, addressing the inherent architectural complexities of its predecessor. The difference isn't just cosmetic; it's a fundamental shift in how communication is modeled, leading to a more robust, efficient, and reliable system.

Here’s a breakdown of the key differences:

1. Connection Model: Asymmetric vs. Unified

  • Legacy SSE: The most defining characteristic is its asymmetric, two-channel model. It requires a persistent GET request for the server to push messages to the client and separate, short-lived POST requests for every message the client sends to the server. This necessitates a special handshake where the server, on the GET stream, tells the client which URL to use for its POST messages.

  • Streamable HTTP: This introduces a unified, hybrid channel. A single POST request from the client can now receive a streaming response from the server. This means a client can send a request and get a series of progress updates and the final result back on the very same HTTP transaction. The separate, long-lived GET stream becomes optional, reserved for unsolicited, server-initiated notifications rather than being a mandatory part of every two-way conversation.

2. Session Management: Query Parameters vs. HTTP Headers

  • Legacy SSE: Session state is managed by embedding a sessionId directly into the URL path as a query parameter. The server sends this URL to the client during the initial handshake, and the client is responsible for appending it to all subsequent POST requests. This requires the server application to maintain a manual mapping of session IDs to active client connections.

  • Streamable HTTP: State is managed more cleanly using a standard mcp-session-id HTTP header. The server provides the session ID in a response header, and the client simply includes this header in all future requests. This aligns better with standard HTTP practices for carrying metadata and simplifies the server-side logic, as the transport layer can handle session association more directly.

3. Reliability and Resumability: Non-Existent vs. First-Class Support

  • Legacy SSE: The protocol has no built-in mechanism for connection resumption. If the client's network connection drops, the GET stream is broken, and any in-flight operations are effectively orphaned. The entire connection must be re-established from scratch, losing all context of the previous session.

  • Streamable HTTP: This is arguably the most significant advantage. It has first-class support for resumability through a token-based system.

    • The server includes a unique id (a resumption token) with each event it sends in a stream.
    • The client saves the id of the last event it successfully received.
    • If the connection drops, the client can reconnect and send the last-seen id in a special last-event-id header.
    • A stateful server can then use this token to replay any messages the client missed, creating a seamless experience even over unreliable networks.

4. Architectural Simplicity and Flexibility

  • Legacy SSE: The two-channel design, while functional, introduces complexity for both client and server developers. The server, in particular, must have application-level logic to glue the GET and POST channels together for each user.

  • Streamable HTTP: The unified model is inherently simpler and more flexible. A server can choose how to respond to a POST based on the request's nature: for a quick query, it can return a single application/json response, and for a long-running task, it can stream back a text/event-stream response. This adaptability makes it a much more versatile and powerful protocol for modern, interactive applications.

In essence, the transition from the legacy SSE transport to Streamable HTTP is a move from a clever workaround to a purpose-built, robust, and elegant solution that fully embraces the capabilities of modern HTTP.

Conclusion

Whew, that was a lot! We've journeyed from the high-level Protocol abstraction down to the nitty-gritty of the asymmetric GET/POST handshake. We've seen how the server uses session IDs to manage state and how the client handles authentication and fallback logic.

This deep dive into the legacy SSE transport doesn't just teach us about an old pattern; it gives us a profound appreciation for the elegance and efficiency of the modern StreamableHTTP transport that replaced it. By understanding the challenges this implementation so cleverly solves, we're better equipped to build robust, real-time applications with any technology.

Happy coding!

Top comments (0)