StreamableHTTPServerTransport Notifications: A Deep Dive

by Felix Dubois 57 views

Hey guys! Ever wondered how notifications behave when using StreamableHTTPServerTransport with enableJsonResponse set to true? Let's dive into this intriguing topic, especially when these notifications are directly linked to a request, like progress updates. In this comprehensive guide, we'll explore the expected behavior, observed outcomes, and the underlying code that dictates this functionality.

What's the Expected Behavior?

When dealing with real-time applications, it's crucial to understand how notifications should be handled. Notifications are essential for providing feedback to the user, especially in long-running processes. So, what's the expectation here?

My initial thought, and probably yours too, is that these notifications would piggyback on the request response. Imagine a scenario where a server is processing a request and wants to keep the client updated on its progress. The natural way to do this is to bundle these progress notifications with the final response in a single, neat package. This batch of messages would include the response to the original request along with all the associated notifications, ensuring a seamless and informative experience for the client. This approach seems intuitive and aligns with how many real-time systems handle updates. Ensuring timely notifications are delivered is key to building responsive and engaging applications.

However, as we'll see, reality sometimes has a different plan.

The Observed Reality

Now, let's talk about what actually happens. After some digging, I noticed something peculiar. The notifications, which I expected to be part of the request response, were nowhere to be found! It's like they vanished into thin air.

To illustrate this, consider the example provided in the [jsonResponseStreamableHttp.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/examples/server/jsonResponseStreamableHttp.ts) file. This example, which should showcase the intended behavior, actually demonstrates the absence of notifications in the response. This observation raised a red flag and prompted a deeper investigation into the code. It's one thing to expect something, but it's another to see it not working as anticipated. Understanding the discrepancies between expectations and reality is a crucial step in debugging and problem-solving.

Digging into the Code: The Culprit in streamableHttp.ts

So, where do these notifications go? To answer this, I rolled up my sleeves and delved into the code. My quest led me to the [streamableHttp.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/4d0977bc9169965233120e823c8024e210132ad9/src/server/streamableHttp.ts#L695) file, specifically the send() function. Here, I stumbled upon a rather interesting piece of logic. It appears that when enableJsonResponse is set to true, the implementation intentionally drops associated notifications – those with a related request ID – like they're hot potatoes!

This discovery was a bit of a head-scratcher. Why would the code explicitly discard these notifications when they are such an integral part of the communication process? This behavior seemed counterintuitive, especially given the expectation that notifications should be included in the response batch. The send() function, which is responsible for transmitting messages, seems to have a conditional branch that diverts the notifications away from the response when JSON response mode is enabled. Analyzing the code's logic is paramount to understanding the underlying reasons for this behavior.

A Test Case to Expose the Issue

To further validate this observation and gain a clearer understanding, I decided to add a test case to [streamableHttp.test.ts](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/server/streamableHttp.test.ts). This test was designed to reproduce the issue and provide a tangible way to debug the problem. Here's the snippet of the test I added:

// Test JSON Response Mode
describe("StreamableHTTPServerTransport with JSON Response Mode", () => { 
...
beforeEach(async () => {
 ...
 mcpServer = result.mcpServer;
 ...

 /// Additional test ///
 it("should return JSON response for a single request including related notifications", async () => {

 mcpServer.tool(
 "multi-greet",
 "A greeting tool that issues multiple greetings",
 { name: z.string().describe("Name to greet") },
 async ({ name }, { sendNotification, _meta }): Promise<CallToolResult> => {
 const progressToken = _meta?.progressToken || "default-progress-token";

 await sendNotification({
 method: "notifications/progress",
 params: { message: `Starting multi-greet for ${name}`, progress: 1, total: 3, progressToken }
 });

 await sendNotification({
 method: "notifications/progress",
 params: { message: `Sending first greeting to ${name}`, progress: 2, total: 3, progressToken }
 });

 await sendNotification({
 method: "notifications/progress",
 params: { message: `Sending second greeting to ${name}`, progress: 3, total: 3, progressToken }
 });

 return { content: [{ type: "text", text: `Hello, ${name}!` }] };
 }
 );

 const toolsCallMessage: JSONRPCMessage = {
 jsonrpc: "2.0",
 method: "tools/call",
 params: {
 name: "multi-greet",
 arguments: {
 name: "JSON"
 },
 _meta: {
 progressToken: "progress-1",
 }
 },
 id: "json-req-1"
 }

 const response = await sendPostRequest(baseUrl, toolsCallMessage, sessionId);

 expect(response.status).toBe(200);
 expect(response.headers.get("content-type")).toBe("application/json");

 const results = await response.json();
 expect(Array.isArray(results)).toBe(true);
 expect(results).toHaveLength(4);

 // Batch responses can come in any order
 const progressNotifications = results.filter((r: { method?: string, params?: { progressToken?: string } }) => r.method === "notifications/progress" && r.params?.progressToken === "progress-1");
 const callResponse = results.find((r: { id?: string }) => r.id === "json-req-1");

 expect(progressNotifications).toHaveLength(3);

 expect(callResponse).toEqual(expect.objectContaining({
 jsonrpc: "2.0",
 id: "json-req-1",
 result: expect.objectContaining({
 content: expect.arrayContaining([
 expect.objectContaining({ type: "text", text: "Hello, JSON!" })
 ])
 })
 }));
 });

This test sets up a tool that sends multiple progress notifications and then makes a call to this tool. The expectation is that the response should include both the call result and the progress notifications. However, the test failed on this crucial assertion:

 expect(Array.isArray(results)).toBe(true);

This failure confirms that the notifications are indeed missing from the response when enableJsonResponse is true. Writing tests like this is an invaluable way to uncover unexpected behaviors and ensure the system works as intended.

Diving Deeper into the Test Case

Let's break down the test case a bit more. The test defines a tool named `