internal/mcp: implement HTTP SSE transport

Add support for SSE transport, as defined by version 2024-11-05 and
2025-03-26 of the MCP spec.

In order to achieve this, the jsonrpc2 stream must be abstracted so that
we can implement it across multiple HTTP requests. This required some
refactoring of the jsonrpc2_v2 package, so that we can avoid the
Dialer/Binder interfaces and construct the Connection directly from its
constituent primitives.

Change-Id: Id627b5b5a474d92f2c613cfad904d14611e7c768
Reviewed-on: https://go-review.googlesource.com/c/tools/+/667475
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
This commit is contained in:
Rob Findley 2025-04-22 13:56:51 +00:00 • committed by Robert Findley
commit ef446f82d7
11 changed files with 771 additions and 223 deletions

View file

@ -6,16 +6,15 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"net/http"
"os" "os"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
type Optional[T any] struct { var httpAddr = flag.String("http", "", "if set, use SSE HTTP at this address, instead of stdin/stdout")
present bool
value T
}
type SayHiParams struct { type SayHiParams struct {
Name string `json:"name" mcp:"the name to say hi to"` Name string `json:"name" mcp:"the name to say hi to"`
@ -28,11 +27,20 @@ func SayHi(ctx context.Context, params *SayHiParams) ([]mcp.Content, error) {
} }
func main() { func main() {
flag.Parse()
server := mcp.NewServer("greeter", "v0.0.1", nil) server := mcp.NewServer("greeter", "v0.0.1", nil)
server.AddTools(mcp.MakeTool("greet", "say hi", SayHi)) server.AddTools(mcp.MakeTool("greet", "say hi", SayHi))
opts := &mcp.ConnectionOptions{Logger: os.Stderr} if *httpAddr != "" {
if err := server.Run(context.Background(), mcp.NewStdIOTransport(), opts); err != nil { handler := mcp.NewSSEHandler(func() *mcp.Server {
fmt.Fprintf(os.Stderr, "Server failed: %v", err) return server
})
http.ListenAndServe(*httpAddr, handler)
} else {
opts := &mcp.ConnectionOptions{Logger: os.Stderr}
if err := server.Run(context.Background(), mcp.NewStdIOTransport(), opts); err != nil {
fmt.Fprintf(os.Stderr, "Server failed: %v", err)
}
} }
} }

View file

@ -201,14 +201,43 @@ func (o ConnectionOptions) Bind(context.Context, *Connection) ConnectionOptions
return o return o
} }
// newConnection creates a new connection and runs it. // A ConnectionConfig configures a bidirectional jsonrpc2 connection.
type ConnectionConfig struct {
Reader Reader // required
Writer Writer // required
Closer io.Closer // required
Preempter Preempter // optional
Bind func(*Connection) Handler // required
OnDone func() // optional
OnInternalError func(error) // optional
}
// NewConnection creates a new [Connection] object and starts processing
// incoming messages.
func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection {
ctx = notDone{ctx}
c := &Connection{
state: inFlightState{closer: cfg.Closer},
done: make(chan struct{}),
writer: make(chan Writer, 1),
onDone: cfg.OnDone,
onInternalError: cfg.OnInternalError,
}
c.handler = cfg.Bind(c)
c.writer <- cfg.Writer
c.start(ctx, cfg.Reader, cfg.Preempter)
return c
}
// bindConnection creates a new connection and runs it.
// //
// This is used by the Dial and Serve functions to build the actual connection. // This is used by the Dial and Serve functions to build the actual connection.
// //
// The connection is closed automatically (and its resources cleaned up) when // The connection is closed automatically (and its resources cleaned up) when
// the last request has completed after the underlying ReadWriteCloser breaks, // the last request has completed after the underlying ReadWriteCloser breaks,
// but it may be stopped earlier by calling Close (for a clean shutdown). // but it may be stopped earlier by calling Close (for a clean shutdown).
func newConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binder, onDone func()) *Connection { func bindConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binder, onDone func()) *Connection {
// TODO: Should we create a new event span here? // TODO: Should we create a new event span here?
// This will propagate cancellation from ctx; should it? // This will propagate cancellation from ctx; should it?
ctx := notDone{bindCtx} ctx := notDone{bindCtx}
@ -238,7 +267,11 @@ func newConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binde
c.writer <- framer.Writer(rwc) c.writer <- framer.Writer(rwc)
reader := framer.Reader(rwc) reader := framer.Reader(rwc)
c.start(ctx, reader, options.Preempter)
return c
}
func (c *Connection) start(ctx context.Context, reader Reader, preempter Preempter) {
c.updateInFlight(func(s *inFlightState) { c.updateInFlight(func(s *inFlightState) {
select { select {
case <-c.done: case <-c.done:
@ -252,9 +285,8 @@ func newConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binde
// (If the Binder closed the Connection already, this should error out and // (If the Binder closed the Connection already, this should error out and
// return almost immediately.) // return almost immediately.)
s.reading = true s.reading = true
go c.readIncoming(ctx, reader, options.Preempter) go c.readIncoming(ctx, reader, preempter)
}) })
return c
} }
// Notify invokes the target method but does not wait for a response. // Notify invokes the target method but does not wait for a response.

View file

@ -62,7 +62,7 @@ func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Co
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newConnection(ctx, rwc, binder, onDone), nil return bindConnection(ctx, rwc, binder, onDone), nil
} }
// NewServer starts a new server listening for incoming connections and returns // NewServer starts a new server listening for incoming connections and returns
@ -117,7 +117,7 @@ func (s *Server) run(ctx context.Context) {
// A new inbound connection. // A new inbound connection.
activeConns.Add(1) activeConns.Add(1)
_ = newConnection(ctx, rwc, s.binder, activeConns.Done) // unregisters itself when done _ = bindConnection(ctx, rwc, s.binder, activeConns.Done) // unregisters itself when done
} }
activeConns.Wait() activeConns.Wait()
} }

View file

@ -89,7 +89,7 @@ func (c *Client) disconnect(sc *ServerConnection) {
// when it is no longer needed. However, if the connection is closed by the // when it is no longer needed. However, if the connection is closed by the
// server, calls or notifications will return an error wrapping // server, calls or notifications will return an error wrapping
// [ErrConnectionClosed]. // [ErrConnectionClosed].
func (c *Client) Connect(ctx context.Context, t *Transport, opts *ConnectionOptions) (sc *ServerConnection, err error) { func (c *Client) Connect(ctx context.Context, t Transport, opts *ConnectionOptions) (sc *ServerConnection, err error) {
defer func() { defer func() {
if sc != nil && err != nil { if sc != nil && err != nil {
_ = sc.Close() _ = sc.Close()

View file

@ -2,7 +2,7 @@
// Use of this source code is governed by an MIT-style // Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package mcp_test package mcp
import ( import (
"context" "context"
@ -11,9 +11,9 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/internal/jsonschema" "github.com/modelcontextprotocol/go-sdk/internal/jsonschema"
"github.com/modelcontextprotocol/go-sdk/internal/protocol" "github.com/modelcontextprotocol/go-sdk/internal/protocol"
) )
@ -22,22 +22,22 @@ type hiParams struct {
Name string Name string
} }
func sayHi(_ context.Context, v hiParams) ([]mcp.Content, error) { func sayHi(_ context.Context, v hiParams) ([]Content, error) {
return []mcp.Content{mcp.TextContent{Text: "hi " + v.Name}}, nil return []Content{TextContent{Text: "hi " + v.Name}}, nil
} }
func TestEndToEnd(t *testing.T) { func TestEndToEnd(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ct, st := mcp.NewLocalTransport() ct, st := NewLocalTransport()
s := mcp.NewServer("testServer", "v1.0.0", nil) s := NewServer("testServer", "v1.0.0", nil)
// The 'greet' tool says hi. // The 'greet' tool says hi.
s.AddTools(mcp.MakeTool("greet", "say hi", sayHi)) s.AddTools(MakeTool("greet", "say hi", sayHi))
// The 'fail' tool returns this error. // The 'fail' tool returns this error.
failure := errors.New("mcp failure") failure := errors.New("mcp failure")
s.AddTools(mcp.MakeTool("fail", "just fail", func(context.Context, struct{}) ([]mcp.Content, error) { s.AddTools(MakeTool("fail", "just fail", func(context.Context, struct{}) ([]Content, error) {
return nil, failure return nil, failure
})) }))
@ -60,7 +60,7 @@ func TestEndToEnd(t *testing.T) {
clientWG.Done() clientWG.Done()
}() }()
c := mcp.NewClient("testClient", "v1.0.0", nil) c := NewClient("testClient", "v1.0.0", nil)
// Connect the client. // Connect the client.
sc, err := c.Connect(ctx, ct, nil) sc, err := c.Connect(ctx, ct, nil)
@ -101,7 +101,7 @@ func TestEndToEnd(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
wantHi := []mcp.Content{mcp.TextContent{Text: "hi user"}} wantHi := []Content{TextContent{Text: "hi user"}}
if diff := cmp.Diff(wantHi, gotHi); diff != "" { if diff := cmp.Diff(wantHi, gotHi); diff != "" {
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
} }
@ -126,18 +126,18 @@ func TestEndToEnd(t *testing.T) {
func TestServerClosing(t *testing.T) { func TestServerClosing(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ct, st := mcp.NewLocalTransport() ct, st := NewLocalTransport()
s := mcp.NewServer("testServer", "v1.0.0", nil) s := NewServer("testServer", "v1.0.0", nil)
// The 'greet' tool says hi. // The 'greet' tool says hi.
s.AddTools(mcp.MakeTool("greet", "say hi", sayHi)) s.AddTools(MakeTool("greet", "say hi", sayHi))
cc, err := s.Connect(ctx, st, nil) cc, err := s.Connect(ctx, st, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
c := mcp.NewClient("testClient", "v1.0.0", nil) c := NewClient("testClient", "v1.0.0", nil)
sc, err := c.Connect(ctx, ct, nil) sc, err := c.Connect(ctx, ct, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -156,36 +156,47 @@ func TestServerClosing(t *testing.T) {
} }
cc.Close() cc.Close()
wg.Wait() wg.Wait()
if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); !errors.Is(err, mcp.ErrConnectionClosed) { if _, err := sc.CallTool(ctx, "greet", hiParams{"user"}); !errors.Is(err, ErrConnectionClosed) {
t.Errorf("after disconnection, got error %v, want EOF", err) t.Errorf("after disconnection, got error %v, want EOF", err)
} }
} }
func TestBatching(t *testing.T) { func TestBatching(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ct, st := mcp.NewLocalTransport() ct, st := NewLocalTransport()
s := mcp.NewServer("testServer", "v1.0.0", nil) s := NewServer("testServer", "v1.0.0", nil)
_, err := s.Connect(ctx, st, nil) _, err := s.Connect(ctx, st, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
c := mcp.NewClient("testClient", "v1.0.0", nil) c := NewClient("testClient", "v1.0.0", nil)
opts := new(mcp.ConnectionOptions) opts := new(ConnectionOptions)
mcp.BatchSize(opts, 2) // TODO: this test is broken, because increasing the batch size here causes
// 'initialize' to block. Therefore, we can only test with a size of 1.
const batchSize = 1
BatchSize(ct, batchSize)
sc, err := c.Connect(ctx, ct, opts) sc, err := c.Connect(ctx, ct, opts)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer sc.Close() defer sc.Close()
errs := make(chan error, 2) errs := make(chan error, batchSize)
for range 2 { for i := range batchSize {
go func() { go func() {
_, err := sc.ListTools(ctx) _, err := sc.ListTools(ctx)
errs <- err errs <- err
}() }()
time.Sleep(2 * time.Millisecond)
if i < batchSize-1 {
select {
case <-errs:
t.Errorf("ListTools: unexpected result for incomplete batch: %v", err)
default:
}
}
} }
} }

View file

@ -56,10 +56,10 @@ func NewServer(name, version string, opts *ServerOptions) *Server {
// AddTools adds the given tools to the server. // AddTools adds the given tools to the server.
// //
// TODO(rfindley): notify connected clients of any changes. // TODO(rfindley): notify connected clients of any changes.
func (c *Server) AddTools(tools ...*Tool) { func (s *Server) AddTools(tools ...*Tool) {
c.mu.Lock() s.mu.Lock()
defer c.mu.Unlock() defer s.mu.Unlock()
c.tools = append(c.tools, tools...) s.tools = append(s.tools, tools...)
} }
// Clients returns an iterator that yields the current set of client // Clients returns an iterator that yields the current set of client
@ -71,26 +71,26 @@ func (s *Server) Clients() iter.Seq[*ClientConnection] {
return slices.Values(clients) return slices.Values(clients)
} }
func (c *Server) listTools(_ context.Context, params *protocol.ListToolsParams) (*protocol.ListToolsResult, error) { func (s *Server) listTools(_ context.Context, _ *ClientConnection, params *protocol.ListToolsParams) (*protocol.ListToolsResult, error) {
c.mu.Lock() s.mu.Lock()
defer c.mu.Unlock() defer s.mu.Unlock()
res := new(protocol.ListToolsResult) res := new(protocol.ListToolsResult)
for _, t := range c.tools { for _, t := range s.tools {
res.Tools = append(res.Tools, t.Definition) res.Tools = append(res.Tools, t.Definition)
} }
return res, nil return res, nil
} }
func (c *Server) callTool(ctx context.Context, params *protocol.CallToolParams) (*protocol.CallToolResult, error) { func (s *Server) callTool(ctx context.Context, _ *ClientConnection, params *protocol.CallToolParams) (*protocol.CallToolResult, error) {
c.mu.Lock() s.mu.Lock()
var tool *Tool var tool *Tool
if i := slices.IndexFunc(c.tools, func(t *Tool) bool { if i := slices.IndexFunc(s.tools, func(t *Tool) bool {
return t.Definition.Name == params.Name return t.Definition.Name == params.Name
}); i >= 0 { }); i >= 0 {
tool = c.tools[i] tool = s.tools[i]
} }
c.mu.Unlock() s.mu.Unlock()
if tool == nil { if tool == nil {
return nil, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, params.Name) return nil, fmt.Errorf("%s: unknown tool %q", jsonrpc2.ErrInvalidParams, params.Name)
@ -98,33 +98,33 @@ func (c *Server) callTool(ctx context.Context, params *protocol.CallToolParams)
return tool.Handler(ctx, params.Arguments) return tool.Handler(ctx, params.Arguments)
} }
// Run runs the server over the given transport. // Run runs the server over the given transport, which must be persistent.
// //
// Run blocks until the client terminates the connection. // Run blocks until the client terminates the connection.
func (c *Server) Run(ctx context.Context, t *Transport, opts *ConnectionOptions) error { func (s *Server) Run(ctx context.Context, t Transport, opts *ConnectionOptions) error {
conn, err := c.Connect(ctx, t, opts) cc, err := s.Connect(ctx, t, opts)
if err != nil { if err != nil {
return err return err
} }
return conn.Wait() return cc.Wait()
} }
// bind implements the binder[*ClientConnection] interface, so that Servers can // bind implements the binder[*ClientConnection] interface, so that Servers can
// be connected using [connect]. // be connected using [connect].
func (c *Server) bind(conn *jsonrpc2.Connection) *ClientConnection { func (s *Server) bind(conn *jsonrpc2.Connection) *ClientConnection {
cc := &ClientConnection{conn: conn, server: c} cc := &ClientConnection{conn: conn, server: s}
c.mu.Lock() s.mu.Lock()
c.clients = append(c.clients, cc) s.clients = append(s.clients, cc)
c.mu.Unlock() s.mu.Unlock()
return cc return cc
} }
// disconnect implements the binder[*ClientConnection] interface, so that // disconnect implements the binder[*ClientConnection] interface, so that
// Servers can be connected using [connect]. // Servers can be connected using [connect].
func (c *Server) disconnect(cc *ClientConnection) { func (s *Server) disconnect(cc *ClientConnection) {
c.mu.Lock() s.mu.Lock()
defer c.mu.Unlock() defer s.mu.Unlock()
c.clients = slices.DeleteFunc(c.clients, func(cc2 *ClientConnection) bool { s.clients = slices.DeleteFunc(s.clients, func(cc2 *ClientConnection) bool {
return cc2 == cc return cc2 == cc
}) })
} }
@ -135,8 +135,8 @@ func (c *Server) disconnect(cc *ClientConnection) {
// It returns a connection object that may be used to terminate the connection // It returns a connection object that may be used to terminate the connection
// (with [Connection.Close]), or await client termination (with // (with [Connection.Close]), or await client termination (with
// [Connection.Wait]). // [Connection.Wait]).
func (c *Server) Connect(ctx context.Context, t *Transport, opts *ConnectionOptions) (*ClientConnection, error) { func (s *Server) Connect(ctx context.Context, t Transport, opts *ConnectionOptions) (*ClientConnection, error) {
return connect(ctx, t, opts, c) return connect(ctx, t, opts, s)
} }
// A ClientConnection is a connection with an MCP client. // A ClientConnection is a connection with an MCP client.
@ -144,32 +144,34 @@ func (c *Server) Connect(ctx context.Context, t *Transport, opts *ConnectionOpti
// It handles messages from the client, and can be used to send messages to the // It handles messages from the client, and can be used to send messages to the
// client. Create a connection by calling [Server.Connect]. // client. Create a connection by calling [Server.Connect].
type ClientConnection struct { type ClientConnection struct {
conn *jsonrpc2.Connection
server *Server server *Server
conn *jsonrpc2.Connection
mu sync.Mutex mu sync.Mutex
initializeParams *protocol.InitializeParams // set once initialize has been received initializeParams *protocol.InitializeParams // set once initialize has been received
} }
func (cc *ClientConnection) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { func (cc *ClientConnection) handle(ctx context.Context, req *jsonrpc2.Request) (any, error) {
// TODO: embed the incoming request ID in the ClientContext (or, more likely,
// a wrapper around it), so that we can correlate responses and notifications
// to the handler; this is required for the new session-based transport.
switch req.Method { switch req.Method {
case "initialize": case "initialize":
return dispatch(ctx, req, cc.initialize) return dispatch(ctx, cc, req, cc.initialize)
// TODO: handle initialized
case "tools/list": case "tools/list":
return dispatch(ctx, req, cc.server.listTools) return dispatch(ctx, cc, req, cc.server.listTools)
case "tools/call": case "tools/call":
return dispatch(ctx, req, cc.server.callTool) return dispatch(ctx, cc, req, cc.server.callTool)
case "notifications/initialized": case "notifications/initialized":
} }
return nil, jsonrpc2.ErrNotHandled return nil, jsonrpc2.ErrNotHandled
} }
func (cc *ClientConnection) initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { func (cc *ClientConnection) initialize(ctx context.Context, _ *ClientConnection, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {
cc.mu.Lock() cc.mu.Lock()
cc.initializeParams = params cc.initializeParams = params
cc.mu.Unlock() cc.mu.Unlock()
@ -202,11 +204,11 @@ func (cc *ClientConnection) Wait() error {
return cc.conn.Wait() return cc.conn.Wait()
} }
func dispatch[TParams, TResult any](ctx context.Context, req *jsonrpc2.Request, f func(context.Context, TParams) (TResult, error)) (TResult, error) { func dispatch[TParams, TResult any](ctx context.Context, conn *ClientConnection, req *jsonrpc2.Request, f func(context.Context, *ClientConnection, TParams) (TResult, error)) (TResult, error) {
var params TParams var params TParams
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
var zero TResult var zero TResult
return zero, err return zero, err
} }
return f(ctx, params) return f(ctx, conn, params)
} }

430
mcp/sse.go Normal file
View file

@ -0,0 +1,430 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package mcp
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
)
// This file implements support for SSE transport server and client.
//
// TODO:
// - avoid the use of channels as listenable queues.
// - support resuming broken streamable sessions
// - support GET channels for unrelated notifications in streamable sessions
// - add client support (and use it to test)
// - properly correlate notifications/requests to an incoming request (using
// requestCtx)
// An event is a server-sent event.
type event struct {
name string
data []byte
}
// writeEvent writes the event to w, and flushes.
func writeEvent(w io.Writer, evt event) (int, error) {
var b bytes.Buffer
if evt.name != "" {
fmt.Fprintf(&b, "event: %s\n", evt.name)
}
fmt.Fprintf(&b, "data: %s\n\n", string(evt.data))
n, err := w.Write(b.Bytes())
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return n, err
}
// SSEHandler is an http.Handler that serves streamable MCP sessions as
// defined by version 2024-11-05 of the MCP spec:
// https://modelcontextprotocol.io/specification/2024-11-05/basic/transports
type SSEHandler struct {
getServer func() *Server
onClient func(*ClientConnection) // for testing; must not block
mu sync.Mutex
sessions map[string]*sseSession
}
// NewSSEHandler returns a new [SSEHandler] that is ready to serve HTTP.
//
// The getServer function is used to bind create servers for new sessions. It
// is OK for getServer to return the same server multiple times.
func NewSSEHandler(getServer func() *Server) *SSEHandler {
return &SSEHandler{
getServer: getServer,
sessions: make(map[string]*sseSession),
}
}
// A sseSession abstracts a session initiated through the sse endpoint.
//
// It implements the Transport interface.
type sseSession struct {
incoming chan jsonrpc2.Message
mu sync.Mutex
w io.Writer // the hanging response body
isDone bool // set when the stream is closed
done chan struct{} // closed when the stream is closed
}
// connect returns the receiver, as an sseSession is a logical stream.
func (s *sseSession) connect(context.Context) (stream, error) {
return s, nil
}
func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
sessionID := req.URL.Query().Get("sessionid")
// TODO: consider checking Content-Type here. For now, we are lax.
// For POST requests, the message body is a message to send to a session.
if req.Method == http.MethodPost {
// Look up the session.
if sessionID == "" {
http.Error(w, "sessionid must be provided", http.StatusBadRequest)
return
}
h.mu.Lock()
session := h.sessions[sessionID]
h.mu.Unlock()
if session == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// Read and parse the message.
data, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
// Optionally, we could just push the data onto a channel, and let the
// message fail to parse when it is read. This failure seems a bit more
// useful
msg, err := jsonrpc2.DecodeMessage(data)
if err != nil {
http.Error(w, "failed to parse body", http.StatusBadRequest)
return
}
session.incoming <- msg
w.WriteHeader(http.StatusAccepted)
return
}
if req.Method != http.MethodGet {
http.Error(w, "invalid method", http.StatusMethodNotAllowed)
return
}
// GET requests create a new session, and serve messages over SSE.
// TODO: it's not entirely documented whether we should check Accept here.
// Let's again be lax and assume the client will accept SSE.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
sessionID = randText()
h.mu.Lock()
session := &sseSession{
w: w,
incoming: make(chan jsonrpc2.Message, 1000),
done: make(chan struct{}),
}
h.sessions[sessionID] = session
h.mu.Unlock()
// The session is terminated when the request exits.
defer func() {
h.mu.Lock()
delete(h.sessions, sessionID)
h.mu.Unlock()
}()
server := h.getServer()
cc, err := server.Connect(req.Context(), session, nil)
if err != nil {
http.Error(w, "connection failed", http.StatusInternalServerError)
return
}
if h.onClient != nil {
h.onClient(cc)
}
defer cc.Close()
endpoint, err := req.URL.Parse("?sessionid=" + sessionID)
if err != nil {
http.Error(w, "internal error: failed to create endpoint", http.StatusInternalServerError)
return
}
session.mu.Lock()
_, err = writeEvent(w, event{
name: "endpoint",
data: []byte(endpoint.RequestURI()),
})
session.mu.Unlock()
if err != nil {
return // too late to write the status header
}
select {
case <-req.Context().Done():
case <-session.done:
}
}
// Read implements jsonrpc2.Reader.
func (s *sseSession) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
select {
case msg := <-s.incoming:
if msg == nil {
return nil, 0, io.EOF
}
return msg, 0, nil
case <-s.done:
return nil, 0, io.EOF
}
}
// Write implements jsonrpc2.Writer.
func (s *sseSession) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.isDone {
return 0, io.EOF
}
n, err := writeEvent(s.w, event{name: "message", data: data})
return int64(n), err
}
// Close implements io.Closer.
func (s *sseSession) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.isDone {
s.isDone = true
close(s.done)
}
return nil
}
// An SSEClientTransport is a [Transport] that can communicate with an MCP
// endpoint serving the SSE transport defined by the 2024-11-05 version of the
// spec.
type SSEClientTransport struct {
sseEndpoint *url.URL
}
// NewSSEClientTransport returns a new client transport that connects to the
// SSE server at the provided URL.
func NewSSEClientTransport(rawURL string) (*SSEClientTransport, error) {
url, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
return &SSEClientTransport{
sseEndpoint: url,
}, nil
}
// connect connects to the client endpoint.
func (c *SSEClientTransport) connect(ctx context.Context) (stream, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.sseEndpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(resp.Body)
// TODO: investigate proper behavior when events are out of order, or have
// non-standard names.
var (
eventKey = []byte("event")
dataKey = []byte("data")
)
// nextEvent reads one sse event from the wire.
// https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#examples
//
// - `key: value` line records.
// - Consecutive `data: ...` fields are joined with newlines.
// - Unrecognized fields are ignored. Since we only care about 'event' and
// 'data', these are the only two we consider.
// - Lines starting with ":" are ignored.
// - Records are terminated with two consecutive newlines.
nextEvent := func() (event, error) {
var (
evt event
lastWasData bool // if set, preceding data field was also data
)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 && (evt.name != "" || len(evt.data) > 0) {
return evt, nil
}
before, after, found := bytes.Cut(line, []byte{':'})
if !found {
return evt, fmt.Errorf("malformed line in SSE stream: %q", string(line))
}
switch {
case bytes.Equal(before, eventKey):
evt.name = strings.TrimSpace(string(after))
case bytes.Equal(before, dataKey):
data := bytes.TrimSpace(after)
if lastWasData {
evt.data = slices.Concat(evt.data, []byte{'\n'}, data)
} else {
evt.data = data
}
lastWasData = true
}
}
return evt, io.EOF
}
msgEndpoint, err := func() (*url.URL, error) {
evt, err := nextEvent()
if err != nil {
return nil, err
}
if evt.name != "endpoint" {
return nil, fmt.Errorf("first event is %q, want %q", evt.name, "endpoint")
}
raw := string(evt.data)
return c.sseEndpoint.Parse(raw)
}()
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("missing endpoint: %v", err)
}
// From here on, the stream takes ownership of resp.Body.
s := &sseClientStream{
sseEndpoint: c.sseEndpoint,
msgEndpoint: msgEndpoint,
incoming: make(chan []byte, 100),
body: resp.Body,
done: make(chan struct{}),
}
go func() {
for {
evt, err := nextEvent()
if err != nil {
close(s.incoming)
return
}
if evt.name == "message" {
select {
case s.incoming <- evt.data:
case <-s.done:
close(s.incoming)
return
}
}
}
}()
return s, nil
}
type sseClientStream struct {
sseEndpoint *url.URL
msgEndpoint *url.URL
incoming chan []byte
mu sync.Mutex
body io.ReadCloser
isDone bool
done chan struct{}
closeErr error
}
func (c *sseClientStream) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case data := <-c.incoming:
if data == nil {
return nil, 0, io.EOF
}
msg, err := jsonrpc2.DecodeMessage(data)
if err != nil {
return nil, 0, err
}
return msg, int64(len(data)), nil
case <-c.done:
if c.closeErr != nil {
return nil, 0, c.closeErr
}
return nil, 0, io.EOF
}
}
func (c *sseClientStream) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
return 0, err
}
c.mu.Lock()
done := c.isDone
c.mu.Unlock()
if done {
return 0, io.EOF
}
req, err := http.NewRequestWithContext(ctx, "POST", c.msgEndpoint.String(), bytes.NewReader(data))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return 0, fmt.Errorf("failed to write: %s", resp.Status)
}
return int64(len(data)), nil
}
func (c *sseClientStream) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.isDone {
c.isDone = true
c.closeErr = c.body.Close()
close(c.done)
}
return c.closeErr
}

65
mcp/sse_test.go Normal file
View file

@ -0,0 +1,65 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package mcp
import (
"context"
"fmt"
"net/http/httptest"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestSSEServer(t *testing.T) {
for _, closeServerFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("closeServerFirst=%t", closeServerFirst), func(t *testing.T) {
ctx := context.Background()
server := NewServer("testServer", "v1.0.0", nil)
server.AddTools(MakeTool("greet", "say hi", sayHi))
sseHandler := NewSSEHandler(func() *Server { return server })
clients := make(chan *ClientConnection, 1)
sseHandler.onClient = func(cc *ClientConnection) {
select {
case clients <- cc:
default:
}
}
httpServer := httptest.NewServer(sseHandler)
clientTransport, err := NewSSEClientTransport(httpServer.URL)
if err != nil {
t.Fatal(err)
}
client := NewClient("testClient", "v1.0.0", nil)
sc, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatal(err)
}
cc := <-clients
gotHi, err := sc.CallTool(ctx, "greet", hiParams{"user"})
if err != nil {
t.Fatal(err)
}
wantHi := []Content{TextContent{Text: "hi user"}}
if diff := cmp.Diff(wantHi, gotHi); diff != "" {
t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff)
}
// Test that closing either end of the connection terminates the other
// end.
if closeServerFirst {
sc.Close()
cc.Wait()
} else {
cc.Close()
sc.Wait()
}
})
}
}

View file

@ -10,7 +10,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"net" "net"
"os" "os"
"sync" "sync"
@ -27,44 +26,52 @@ var ErrConnectionClosed = errors.New("connection closed")
// A Transport is used to create a bidirectional connection between MCP client // A Transport is used to create a bidirectional connection between MCP client
// and server. // and server.
type Transport struct { //
dialer jsonrpc2.Dialer // Transports should be used for at most one call to [Server.Connect] or
// [Client.Connect].
type Transport interface {
// connect returns the logical stream.
//
// It is called exactly once by [connect].
connect(ctx context.Context) (stream, error)
}
// A stream is an abstract bidirectional jsonrpc2 stream.
// It is used by [connect] to establish a [jsonrpc2.Connection].
type stream interface {
jsonrpc2.Reader
jsonrpc2.Writer
io.Closer
} }
// ConnectionOptions configures the behavior of an individual client<->server // ConnectionOptions configures the behavior of an individual client<->server
// connection. // connection.
type ConnectionOptions struct { type ConnectionOptions struct {
Logger io.Writer // if set, write RPC logs SessionID string // if set, the session ID
Logger io.Writer // if set, write RPC logs
}
batchSize int // outgoing batch size for requests/notifications, for testing // An IOTransport is a [Transport] that communicates using newline-delimited
// JSON over an io.ReadWriteCloser.
type IOTransport struct {
rwc io.ReadWriteCloser
}
func (t *IOTransport) connect(context.Context) (stream, error) {
return newIOStream(t.rwc), nil
} }
// NewStdIOTransport constructs a transport that communicates over // NewStdIOTransport constructs a transport that communicates over
// stdin/stdout. // stdin/stdout.
func NewStdIOTransport() *Transport { func NewStdIOTransport() *IOTransport {
dialer := dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) { return &IOTransport{rwc{os.Stdin, os.Stdout}}
return rwc{os.Stdin, os.Stdout}, nil
})
return &Transport{
dialer: dialer,
}
} }
// NewLocalTransport returns two in-memory transports that connect to // NewLocalTransport returns two in-memory transports that connect to
// each other, for testing purposes. // each other, for testing purposes.
func NewLocalTransport() (*Transport, *Transport) { func NewLocalTransport() (*IOTransport, *IOTransport) {
c1, c2 := net.Pipe() c1, c2 := net.Pipe()
t1 := &Transport{ return &IOTransport{c1}, &IOTransport{c2}
dialer: dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) {
return c1, nil
}),
}
t2 := &Transport{
dialer: dialerFunc(func(ctx context.Context) (io.ReadWriteCloser, error) {
return c2, nil
}),
}
return t1, t2
} }
// handler is an unexported version of jsonrpc2.Handler, to be implemented by // handler is an unexported version of jsonrpc2.Handler, to be implemented by
@ -79,43 +86,37 @@ type binder[T handler] interface {
disconnect(T) disconnect(T)
} }
func connect[H handler](ctx context.Context, t *Transport, opts *ConnectionOptions, b binder[H]) (H, error) { func connect[H handler](ctx context.Context, t Transport, opts *ConnectionOptions, b binder[H]) (H, error) {
if opts == nil { if opts == nil {
opts = new(ConnectionOptions) opts = new(ConnectionOptions)
} }
// Frame messages using newline delimited JSON.
//
// If logging is configured, write message logs.
var framer jsonrpc2.Framer = &ndjsonFramer{}
if opts.Logger != nil {
framer = &loggingFramer{opts.Logger, framer}
}
var h H
// Bind the server connection.
binder := jsonrpc2.BinderFunc(func(_ context.Context, conn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions {
h = b.bind(conn)
return jsonrpc2.ConnectionOptions{
Framer: framer,
Handler: jsonrpc2.HandlerFunc(h.handle),
OnInternalError: func(err error) {
log.Printf("Internal error: %v", err)
},
}
})
// Clean up the connection when done.
onDone := func() {
b.disconnect(h)
}
var zero H var zero H
_, err := jsonrpc2.Dial(ctx, t.dialer, binder, onDone) stream, err := t.connect(ctx)
if err != nil { if err != nil {
return zero, err return zero, err
} }
// If logging is configured, write message logs.
reader, writer := jsonrpc2.Reader(stream), jsonrpc2.Writer(stream)
if opts.Logger != nil {
reader = loggingReader(opts.Logger, reader)
writer = loggingWriter(opts.Logger, writer)
}
var h H
bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler {
h = b.bind(conn)
return jsonrpc2.HandlerFunc(h.handle)
}
_ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{
Reader: reader,
Writer: writer,
Closer: stream,
Bind: bind,
OnDone: func() {
b.disconnect(h)
},
})
assert(h != zero, "unbound connection") assert(h != zero, "unbound connection")
return h, nil return h, nil
} }
@ -135,13 +136,6 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params,
// The helpers below are used to bind transports to jsonrpc2. // The helpers below are used to bind transports to jsonrpc2.
// A dialerFunc implements jsonrpc2.Dialer.Dial.
type dialerFunc func(context.Context) (io.ReadWriteCloser, error)
func (f dialerFunc) Dial(ctx context.Context) (io.ReadWriteCloser, error) {
return f(ctx)
}
// A readerFunc implements jsonrpc2.Reader.Read. // A readerFunc implements jsonrpc2.Reader.Read.
type readerFunc func(context.Context) (jsonrpc2.Message, int64, error) type readerFunc func(context.Context) (jsonrpc2.Message, int64, error)
@ -156,41 +150,35 @@ func (f writerFunc) Write(ctx context.Context, msg jsonrpc2.Message) (int64, err
return f(ctx, msg) return f(ctx, msg)
} }
// A loggingFramer logs jsonrpc2 messages to its enclosed writer. // loggingReader is a stream middleware that logs incoming messages.
type loggingFramer struct { func loggingReader(w io.Writer, delegate jsonrpc2.Reader) jsonrpc2.Reader {
w io.Writer
delegate jsonrpc2.Framer
}
func (f *loggingFramer) Reader(rw io.Reader) jsonrpc2.Reader {
delegate := f.delegate.Reader(rw)
return readerFunc(func(ctx context.Context) (jsonrpc2.Message, int64, error) { return readerFunc(func(ctx context.Context) (jsonrpc2.Message, int64, error) {
msg, n, err := delegate.Read(ctx) msg, n, err := delegate.Read(ctx)
if err != nil { if err != nil {
fmt.Fprintf(f.w, "read error: %v", err) fmt.Fprintf(w, "read error: %v", err)
} else { } else {
data, err := jsonrpc2.EncodeMessage(msg) data, err := jsonrpc2.EncodeMessage(msg)
if err != nil { if err != nil {
fmt.Fprintf(f.w, "LoggingFramer: failed to marshal: %v", err) fmt.Fprintf(w, "LoggingFramer: failed to marshal: %v", err)
} }
fmt.Fprintf(f.w, "read: %s", string(data)) fmt.Fprintf(w, "read: %s", string(data))
} }
return msg, n, err return msg, n, err
}) })
} }
func (f *loggingFramer) Writer(w io.Writer) jsonrpc2.Writer { // loggingWriter is a stream middleware that logs outgoing messages.
delegate := f.delegate.Writer(w) func loggingWriter(w io.Writer, delegate jsonrpc2.Writer) jsonrpc2.Writer {
return writerFunc(func(ctx context.Context, msg jsonrpc2.Message) (int64, error) { return writerFunc(func(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
n, err := delegate.Write(ctx, msg) n, err := delegate.Write(ctx, msg)
if err != nil { if err != nil {
fmt.Fprintf(f.w, "write error: %v", err) fmt.Fprintf(w, "write error: %v", err)
} else { } else {
data, err := jsonrpc2.EncodeMessage(msg) data, err := jsonrpc2.EncodeMessage(msg)
if err != nil { if err != nil {
fmt.Fprintf(f.w, "LoggingFramer: failed to marshal: %v", err) fmt.Fprintf(w, "LoggingFramer: failed to marshal: %v", err)
} }
fmt.Fprintf(f.w, "write: %s", string(data)) fmt.Fprintf(w, "write: %s", string(data))
} }
return n, err return n, err
}) })
@ -217,42 +205,60 @@ func (r rwc) Close() error {
return errors.Join(r.rc.Close(), r.wc.Close()) return errors.Join(r.rc.Close(), r.wc.Close())
} }
// A ndjsonFramer is a jsonrpc2.Framer that delimits messages with newlines. // An ioStream is a transport that delimits messages with newlines across
// It also supports jsonrpc2 batching. // a bidirectional stream, and supports JSONRPC2 message batching.
// //
// See https://github.com/ndjson/ndjson-spec for discussion of newline // See https://github.com/ndjson/ndjson-spec for discussion of newline
// delimited JSON. // delimited JSON.
// //
// See [msgBatch] for more discussion of message batching. // See [msgBatch] for more discussion of message batching.
type ndjsonFramer struct { type ioStream struct {
// batchSize allows customizing batching behavior for testing. rwc io.ReadWriteCloser // the underlying stream
// in *json.Decoder // a decoder bound to rwc
// If set to a positive number, requests and notifications will be buffered
// into groups of this size before being sent as a batch. // If outgoiBatch has a positive capacity, it will be used to batch requests
batchSize int // and notifications before sending.
outgoingBatch []jsonrpc2.Message
// Unread messages in the last batch. Since reads are serialized, there is no
// need to guard here.
queue []jsonrpc2.Message
// batches correlate incoming requests to the batch in which they arrived. // batches correlate incoming requests to the batch in which they arrived.
// Since writes may be concurrent to reads, we need to guard this with a mutex.
batchMu sync.Mutex batchMu sync.Mutex
batches map[jsonrpc2.ID]*msgBatch // lazily allocated batches map[jsonrpc2.ID]*msgBatch // lazily allocated
} }
func newIOStream(rwc io.ReadWriteCloser) *ioStream {
return &ioStream{
rwc: rwc,
in: json.NewDecoder(rwc),
}
}
// connect returns the receiver, as a streamTransport is a logical stream.
func (t *ioStream) connect(ctx context.Context) (stream, error) {
return t, nil
}
// addBatch records a msgBatch for an incoming batch payload. // addBatch records a msgBatch for an incoming batch payload.
// It returns an error if batch is malformed, containing previously seen IDs. // It returns an error if batch is malformed, containing previously seen IDs.
// //
// See [msgBatch] for more. // See [msgBatch] for more.
func (f *ndjsonFramer) addBatch(batch *msgBatch) error { func (t *ioStream) addBatch(batch *msgBatch) error {
f.batchMu.Lock() t.batchMu.Lock()
defer f.batchMu.Unlock() defer t.batchMu.Unlock()
for id := range batch.unresolved { for id := range batch.unresolved {
if _, ok := f.batches[id]; ok { if _, ok := t.batches[id]; ok {
return fmt.Errorf("%w: batch contains previously seen request %v", jsonrpc2.ErrInvalidRequest, id.Raw()) return fmt.Errorf("%w: batch contains previously seen request %v", jsonrpc2.ErrInvalidRequest, id.Raw())
} }
} }
for id := range batch.unresolved { for id := range batch.unresolved {
if f.batches == nil { if t.batches == nil {
f.batches = make(map[jsonrpc2.ID]*msgBatch) t.batches = make(map[jsonrpc2.ID]*msgBatch)
} }
f.batches[id] = batch t.batches[id] = batch
} }
return nil return nil
} }
@ -263,18 +269,18 @@ func (f *ndjsonFramer) addBatch(batch *msgBatch) error {
// The second result reports whether resp was part of a batch. If this is true, // The second result reports whether resp was part of a batch. If this is true,
// the first result is nil if the batch is still incomplete, or the full set of // the first result is nil if the batch is still incomplete, or the full set of
// batch responses if resp completed the batch. // batch responses if resp completed the batch.
func (f *ndjsonFramer) updateBatch(resp *jsonrpc2.Response) ([]*jsonrpc2.Response, bool) { func (t *ioStream) updateBatch(resp *jsonrpc2.Response) ([]*jsonrpc2.Response, bool) {
f.batchMu.Lock() t.batchMu.Lock()
defer f.batchMu.Unlock() defer t.batchMu.Unlock()
if batch, ok := f.batches[resp.ID]; ok { if batch, ok := t.batches[resp.ID]; ok {
idx, ok := batch.unresolved[resp.ID] idx, ok := batch.unresolved[resp.ID]
if !ok { if !ok {
panic("internal error: inconsistent batches") panic("internal error: inconsistent batches")
} }
batch.responses[idx] = resp batch.responses[idx] = resp
delete(batch.unresolved, resp.ID) delete(batch.unresolved, resp.ID)
delete(f.batches, resp.ID) delete(t.batches, resp.ID)
if len(batch.unresolved) == 0 { if len(batch.unresolved) == 0 {
return batch.responses, true return batch.responses, true
} }
@ -301,55 +307,28 @@ type msgBatch struct {
responses []*jsonrpc2.Response responses []*jsonrpc2.Response
} }
// An ndjsonReader reads newline-delimited messages or message batches. func (t *ioStream) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
type ndjsonReader struct { return t.read(ctx, t.in)
queue []jsonrpc2.Message
framer *ndjsonFramer
in *json.Decoder
} }
// A ndjsonWriter writes newline-delimited messages to the wrapped io.Writer. func (t *ioStream) read(ctx context.Context, in *json.Decoder) (jsonrpc2.Message, int64, error) {
//
// If batch is set, messages are wrapped in a JSONRPC2 batch.
type ndjsonWriter struct {
// Testing support: if outgoingBatch has capacity, it is used to buffer
// outgoing messages before sending a JSONRPC2 message batch.
outgoingBatch []jsonrpc2.Message
framer *ndjsonFramer // to track batch responses
out io.Writer // to write to the wire
}
func (f *ndjsonFramer) Reader(r io.Reader) jsonrpc2.Reader {
return &ndjsonReader{framer: f, in: json.NewDecoder(r)}
}
func (f *ndjsonFramer) Writer(w io.Writer) jsonrpc2.Writer {
writer := &ndjsonWriter{framer: f, out: w}
if f.batchSize > 0 {
writer.outgoingBatch = make([]jsonrpc2.Message, 0, f.batchSize)
}
return writer
}
func (r *ndjsonReader) Read(ctx context.Context) (jsonrpc2.Message, int64, error) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, 0, ctx.Err() return nil, 0, ctx.Err()
default: default:
} }
if len(r.queue) > 0 { if len(t.queue) > 0 {
next := r.queue[0] next := t.queue[0]
r.queue = r.queue[1:] t.queue = t.queue[1:]
return next, 0, nil return next, 0, nil
} }
var raw json.RawMessage var raw json.RawMessage
if err := r.in.Decode(&raw); err != nil { if err := in.Decode(&raw); err != nil {
return nil, 0, err return nil, 0, err
} }
var rawBatch []json.RawMessage var rawBatch []json.RawMessage
if err := json.Unmarshal(raw, &rawBatch); err == nil { if err := json.Unmarshal(raw, &rawBatch); err == nil {
msg, err := r.readBatch(rawBatch) msg, err := t.readBatch(rawBatch)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -361,7 +340,7 @@ func (r *ndjsonReader) Read(ctx context.Context) (jsonrpc2.Message, int64, error
// readBatch reads a batch of jsonrpc2 messages, and records the batch // readBatch reads a batch of jsonrpc2 messages, and records the batch
// in the framer so that responses can be collected and send back together. // in the framer so that responses can be collected and send back together.
func (r *ndjsonReader) readBatch(rawBatch []json.RawMessage) (jsonrpc2.Message, error) { func (t *ioStream) readBatch(rawBatch []json.RawMessage) (jsonrpc2.Message, error) {
if len(rawBatch) == 0 { if len(rawBatch) == 0 {
return nil, fmt.Errorf("empty batch") return nil, fmt.Errorf("empty batch")
} }
@ -403,16 +382,16 @@ func (r *ndjsonReader) readBatch(rawBatch []json.RawMessage) (jsonrpc2.Message,
} }
if respBatch != nil { if respBatch != nil {
// The batch contains one or more incoming requests to track. // The batch contains one or more incoming requests to track.
if err := r.framer.addBatch(respBatch); err != nil { if err := t.addBatch(respBatch); err != nil {
return nil, err return nil, err
} }
} }
r.queue = append(r.queue, queue...) t.queue = append(t.queue, queue...)
return first, nil return first, nil
} }
func (w *ndjsonWriter) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) { func (t *ioStream) Write(ctx context.Context, msg jsonrpc2.Message) (int64, error) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return 0, ctx.Err() return 0, ctx.Err()
@ -424,28 +403,28 @@ func (w *ndjsonWriter) Write(ctx context.Context, msg jsonrpc2.Message) (int64,
// want to collect it into a batch before sending, if we're configured to use // want to collect it into a batch before sending, if we're configured to use
// outgoing batches. // outgoing batches.
if resp, ok := msg.(*jsonrpc2.Response); ok { if resp, ok := msg.(*jsonrpc2.Response); ok {
if batch, ok := w.framer.updateBatch(resp); ok { if batch, ok := t.updateBatch(resp); ok {
if len(batch) > 0 { if len(batch) > 0 {
data, err := marshalMessages(batch) data, err := marshalMessages(batch)
if err != nil { if err != nil {
return 0, err return 0, err
} }
data = append(data, '\n') data = append(data, '\n')
n, err := w.out.Write(data) n, err := t.rwc.Write(data)
return int64(n), err return int64(n), err
} }
return 0, nil return 0, nil
} }
} else if len(w.outgoingBatch) < cap(w.outgoingBatch) { } else if len(t.outgoingBatch) < cap(t.outgoingBatch) {
w.outgoingBatch = append(w.outgoingBatch, msg) t.outgoingBatch = append(t.outgoingBatch, msg)
if len(w.outgoingBatch) == cap(w.outgoingBatch) { if len(t.outgoingBatch) == cap(t.outgoingBatch) {
data, err := marshalMessages(w.outgoingBatch) data, err := marshalMessages(t.outgoingBatch)
w.outgoingBatch = w.outgoingBatch[:0] t.outgoingBatch = t.outgoingBatch[:0]
if err != nil { if err != nil {
return 0, err return 0, err
} }
data = append(data, '\n') data = append(data, '\n')
n, err := w.out.Write(data) n, err := t.rwc.Write(data)
return int64(n), err return int64(n), err
} }
return 0, nil return 0, nil
@ -455,10 +434,14 @@ func (w *ndjsonWriter) Write(ctx context.Context, msg jsonrpc2.Message) (int64,
return 0, fmt.Errorf("marshaling message: %v", err) return 0, fmt.Errorf("marshaling message: %v", err)
} }
data = append(data, '\n') // newline delimited data = append(data, '\n') // newline delimited
n, err := w.out.Write(data) n, err := t.rwc.Write(data)
return int64(n), err return int64(n), err
} }
func (t *ioStream) Close() error {
return t.rwc.Close()
}
func marshalMessages[T jsonrpc2.Message](msgs []T) ([]byte, error) { func marshalMessages[T jsonrpc2.Message](msgs []T) ([]byte, error) {
var rawMsgs []json.RawMessage var rawMsgs []json.RawMessage
for _, msg := range msgs { for _, msg := range msgs {

View file

@ -12,12 +12,14 @@ import (
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
) )
// BatchSize causes a connection to collect n requests or notifications before // BatchSize causes a transport to collect n requests or notifications before
// sending a batch on the wire (responses are always sent in isolation). // sending a batch on the wire (responses are always sent in isolation).
// //
// Exported for testing in the mcp_test package. // Exported for testing in the mcp_test package.
func BatchSize(opts *ConnectionOptions, n int) { func BatchSize(t Transport, n int) {
opts.batchSize = n if st, ok := t.(*ioStream); ok {
st.outgoingBatch = make([]jsonrpc2.Message, 0, n)
}
} }
func TestBatchFraming(t *testing.T) { func TestBatchFraming(t *testing.T) {
@ -29,21 +31,20 @@ func TestBatchFraming(t *testing.T) {
ctx := context.Background() ctx := context.Background()
r, w := io.Pipe() r, w := io.Pipe()
framer := ndjsonFramer{batchSize: 2} tport := newIOStream(rwc{r, w})
reader := framer.Reader(r) tport.outgoingBatch = make([]jsonrpc2.Message, 0, 2)
writer := framer.Writer(w)
// Read the two messages into a channel, for easy testing later. // Read the two messages into a channel, for easy testing later.
read := make(chan jsonrpc2.Message) read := make(chan jsonrpc2.Message)
go func() { go func() {
for range 2 { for range 2 {
msg, _, _ := reader.Read(ctx) msg, _, _ := tport.Read(ctx)
read <- msg read <- msg
} }
}() }()
// The first write should not yet be observed by the reader. // The first write should not yet be observed by the reader.
writer.Write(ctx, &jsonrpc2.Request{ID: jsonrpc2.Int64ID(1), Method: "test"}) tport.Write(ctx, &jsonrpc2.Request{ID: jsonrpc2.Int64ID(1), Method: "test"})
select { select {
case got := <-read: case got := <-read:
t.Fatalf("after one write, got message %v", got) t.Fatalf("after one write, got message %v", got)
@ -51,7 +52,7 @@ func TestBatchFraming(t *testing.T) {
} }
// ...but the second write causes both messages to be observed. // ...but the second write causes both messages to be observed.
writer.Write(ctx, &jsonrpc2.Request{ID: jsonrpc2.Int64ID(2), Method: "test"}) tport.Write(ctx, &jsonrpc2.Request{ID: jsonrpc2.Int64ID(2), Method: "test"})
for _, want := range []int64{1, 2} { for _, want := range []int64{1, 2} {
got := <-read got := <-read
if got := got.(*jsonrpc2.Request).ID.Raw(); got != want { if got := got.(*jsonrpc2.Request).ID.Raw(); got != want {

View file

@ -4,6 +4,8 @@
package mcp package mcp
import "crypto/rand"
func assert(cond bool, msg string) { func assert(cond bool, msg string) {
if !cond { if !cond {
panic(msg) panic(msg)
@ -14,3 +16,17 @@ func is[T any](v any) bool {
_, ok := v.(T) _, ok := v.(T)
return ok return ok
} }
// Copied from crypto/rand.
// TODO: once 1.24 is assured, just use crypto/rand.
const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
func randText() string {
// ⌈log₃₂ 2¹²⁸⌉ = 26 chars
src := make([]byte, 26)
rand.Read(src)
for i := range src {
src[i] = base32alphabet[src[i]%32]
}
return string(src)
}