Skip to content

Client implementation of Streamable HTTP transport #290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 9, 2025

Conversation

ihrpr
Copy link
Contributor

@ihrpr ihrpr commented Apr 8, 2025

Introduces support for streamable http transport on client. Server was implemented in #266

TO DO

  • Tests with examples and integration in the stacked PRs

Follow ups

  • Session management
  • Refactor auth and make it work with session management
  • Examples for client and server
  • Integration tests using examples
  • Resumability
  • Server and client to handle requests returning not only streams but also JSON
  • Cancellation for streams
  • Client to handle closing stream when all responses are done (bad server case)

@ihrpr ihrpr requested a review from jspahrsummers April 8, 2025 15:18
@ihrpr ihrpr marked this pull request as ready for review April 8, 2025 15:18
@jspahrsummers jspahrsummers linked an issue Apr 8, 2025 that may be closed by this pull request
Copy link
Member

@jspahrsummers jspahrsummers left a comment

Choose a reason for hiding this comment

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

Can you please refactor to use the eventsource package (which we already depend upon)? A lot of this code will be able to be deleted, and I expect that, e.g., resumability will become easier.

Comment on lines 124 to 128
if (response.status === 405 || response.status === 404) {
// Server doesn't support GET for SSE, which is allowed by the spec
// We'll rely on SSE responses to POST requests for communication
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

Why are we permitting 404 here? It might be OK, just curious for the rationale.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

intentional as we still need to add support for GET and Auth and the order it should work. Right now it will be rejected for clients that provide session id as it's pre-initialization. Will be addressing it in the follow up.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this was refactored, the implementation was wrong for GET, we need to have a separate method, unlike SSE implementation

Comment on lines 189 to 190
// If we have a session ID, send a DELETE request to explicitly terminate the session
if (this._sessionId) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we want to automatically do this. What if the client process is restarting, for example? We should definitely offer a way to do this, but leave it up to the user when to trigger.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh, that's right! I'll remove automatic handling and add to follow ups to implemenet a way for a user to do it

Comment on lines 200 to 201
// Server might respond with 405 if it doesn't support explicit session termination
// We don't throw an error in that case
Copy link
Member

Choose a reason for hiding this comment

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

You might also see a 404 if the session ID has already been deleted on the server side (because the server could clear it at any time).

this.onclose?.();
}

async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

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

Let's leave the batching to another PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah, it's already there, I'd just leave it, the full batch support agree, will be a separate PR

}

// Successful connection, handle the SSE stream as a standalone listener
const streamId = `initial-${Date.now()}`;
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need these stream IDs? I can't find any spot where we locate a stream by its ID.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we use it in the clean up to close all of the sessions

Comment on lines 176 to 184
// Close all active streams
for (const reader of this._activeStreams.values()) {
try {
reader.cancel();
} catch (error) {
this.onerror?.(error as Error);
}
}
this._activeStreams.clear();
Copy link
Member

Choose a reason for hiding this comment

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

It might be easier to use a single AbortController for the whole object, then pass its AbortSignal into all the fetch/SSE calls.

Comment on lines +264 to +267
// Extract IDs from request messages for tracking responses
const requestIds = messages.filter(msg => 'method' in msg && 'id' in msg)
.map(msg => 'id' in msg ? msg.id : undefined)
.filter(id => id !== undefined);
Copy link
Member

Choose a reason for hiding this comment

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

We may want to close the SSE stream after receiving responses to all these request IDs.

Also, we may want to figure out how to automatically close the stream if all the requests are cancelled (by the client).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we are doing closing on the server side and as we are reading the stream response it's done when the stream reader is done:

 const { done, value: event } = await reader.read();
          if (done) {
            this._activeStreams.delete(streamId);
            break;
          }

Also, we may want to figure out how to automatically close the stream if all the requests are cancelled (by the client).

Adding to the follow ups

Copy link
Member

@jspahrsummers jspahrsummers Apr 9, 2025

Choose a reason for hiding this comment

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

This assumes a well-behaving server, which might not always be the case (e.g., the server might not be using an official SDK, or one of the SDKs may behave differently).

Basically, the client should not assume that the server will close the connection on its own.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good point! adding to follow ups

}
}

private _handleSseStream(stream: ReadableStream<Uint8Array> | null, streamId: string): void {
Copy link
Contributor

@beaulac beaulac Apr 8, 2025

Choose a reason for hiding this comment

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

NB:
import { EventSourceParserStream } from 'eventsource-parser/stream';
would take care of a lot of this parsing logic and eventsource-parser is already a transitive dependency of existing eventsource dependency

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@beaulac this is great, thank you!

@beaulac
Copy link
Contributor

beaulac commented Apr 8, 2025

@jspahrsummers

refactor to use the eventsource package

I tried to use eventsource in my own attempts at this, but EventSource can only be initialized with GET
Since we are potentially starting a SSE stream from an existing Response stream (since at time of request it's unknown whether it is or isn't an SSE stream) we can't use EventSource everywhere directly

@ihrpr ihrpr requested a review from jspahrsummers April 9, 2025 09:38
Copy link
Member

@jspahrsummers jspahrsummers left a comment

Choose a reason for hiding this comment

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

cool-beans

@@ -121,32 +119,76 @@ export class StreamableHTTPClientTransport implements Transport {
signal: this._abortController?.signal,
});

if (response.status === 405 || response.status === 404) {
Copy link
Member

Choose a reason for hiding this comment

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

We still need to handle 405s here, no?

@ihrpr ihrpr merged commit 0445095 into main Apr 9, 2025
4 checks passed
@ihrpr ihrpr deleted the ihrpr/streamable-http-client branch April 9, 2025 11:57
@ALSoryu
Copy link

ALSoryu commented Apr 10, 2025

How to implement session management without broker like redis in a distributed scenario

Pizzaface pushed a commit to RewstApp/mcp-inspector that referenced this pull request May 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Client implementation of Streamable HTTP transport
4 participants