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;
}
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 aGET
request). -
send()
: This method's job is simple: take a fully formedJSONRPCMessage
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 callsonmessage
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();
}
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'sEventSource
object itself. This is how auth headers get injected into the listeningGET
request. -
requestInit
: A way to add custom headers (or other options) to the separatePOST
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();
});
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`,
);
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 POST
s).
The close()
method is a kill switch for everything:
- It calls
this._eventSource?.close()
to terminate the listeningGET
stream. - It calls
this._abortController?.abort()
to cancel anyPOST
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
// ...
});
3.2. Handling the Initial GET
Connection (start
)
When the application calls transport.start()
, it does two things:
- Writes the
Content-Type: text/event-stream
header, officially turning the HTTP response into a long-lived SSE stream. - Sends the
event: endpoint
message back to the client, providing thesessionId
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 atext/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 newStreamableHTTPServerTransport
. - Requests to the old
/sse
and/messages
endpoints are handled by the legacySSEServerTransport
.
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-livedPOST
requests for every message the client sends to the server. This necessitates a special handshake where the server, on theGET
stream, tells the client which URL to use for itsPOST
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-livedGET
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 subsequentPOST
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 speciallast-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.
- The server includes a unique
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
andPOST
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 singleapplication/json
response, and for a long-running task, it can stream back atext/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)