Skip to content

Transport for web standard fetch APIs: FetchSSEServerTransport#260

@kentcdodds

Description

@kentcdodds

Is your feature request related to a problem? Please describe.

I don't want to work with Node's http request/response APIs.

Describe the solution you'd like

I would like to work with web standard fetch request/response APIs. In my case, I'm integrating with React Router (framework mode), but I imagine this would be helpful to anyone using Hono.js, other modern frameworks, Cloudflare, Bun, Deno, etc.

Describe alternatives you've considered

I've built a transport myself that seems to work:

import{typeTransport}from'@modelcontextprotocol/sdk/shared/transport.js'import{typeJSONRPCMessage,JSONRPCMessageSchema,}from'@modelcontextprotocol/sdk/types.js'/** * Server transport for SSE using Standard Fetch: this will send messages over * an SSE connection and receive messages from HTTP POST requests. */exportclassFetchSSEServerTransportimplementsTransport{ #stream?: ReadableStreamDefaultController<string> #sessionId: string #endpoint: stringonclose?: ()=>voidonerror?: (error: Error)=>voidonmessage?: (message: JSONRPCMessage)=>void/** * Creates a new SSE server transport, which will direct the client to POST * messages to the relative or absolute URL identified by `endpoint`. */constructor(endpoint: string,sessionId?: string|null){this.#endpoint =endpointthis.#sessionId =sessionId??crypto.randomUUID()}/** * Starts processing messages on the transport. * This is called by the Server class and should not be called directly. */asyncstart(): Promise<void>{if(this.#stream){thrownewError('FetchSSEServerTransport already started! If using Server class, note that connect() calls start() automatically.',)}}/** * Handles the initial SSE connection request. * This should be called from your Remix loader to establish the SSE stream. */asynchandleSSERequest(request: Request): Promise<Response>{conststream=newReadableStream<string>({start: (controller)=>{this.#stream =controller// Send headerscontroller.enqueue(': ping\n\n')// Keep connection alive// Send the endpoint eventcontroller.enqueue(`event: endpoint\ndata: ${encodeURI(this.#endpoint,)}?sessionId=${this.#sessionId}\n\n`,)// Handle cleanup when the connection closesrequest.signal.addEventListener('abort',()=>{controller.close()this.#stream =undefinedthis.onclose?.()})},cancel: ()=>{this.#stream =undefinedthis.onclose?.()},})returnnewResponse(stream,{headers: {'Content-Type': 'text/event-stream','Cache-Control': 'no-cache',Connection: 'keep-alive','Mcp-Session-Id': this.#sessionId,},})}/** * Handles incoming POST messages. * This should be called from your Remix action to handle incoming messages. */asynchandlePostMessage(request: Request): Promise<Response>{if(!this.#stream){constmessage='SSE connection not established'returnnewResponse(message,{status: 500})}letbody: unknowntry{constcontentType=request.headers.get('content-type')if(contentType!=='application/json'){thrownewError(`Unsupported content-type: ${contentType}`)}body=awaitrequest.json()}catch(error){this.onerror?.(errorasError)returnnewResponse(String(error),{status: 400})}try{awaitthis.handleMessage(body)}catch(error){console.error(error)returnnewResponse(`Invalid message: ${body}`,{status: 400})}returnnewResponse('Accepted',{status: 202})}/** * Handle a client message, regardless of how it arrived. */asynchandleMessage(message: unknown): Promise<void>{letparsedMessage: JSONRPCMessagetry{parsedMessage=JSONRPCMessageSchema.parse(message)}catch(error){this.onerror?.(errorasError)throwerror}this.onmessage?.(parsedMessage)}asyncclose(): Promise<void>{this.#stream?.close()this.#stream =undefinedthis.onclose?.()}asyncsend(message: JSONRPCMessage): Promise<void>{if(!this.#stream){thrownewError('Not connected')}// Send the message through the event streamthis.#stream.enqueue(`event: message\ndata: ${JSON.stringify(message)}\n\n`)}/** * Returns the session ID for this transport. * This can be used to route incoming POST requests. */getsessionId(): string{returnthis.#sessionId }}

Feel free to use this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supportedv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions