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:
parent
1204b93a85
commit
ef446f82d7
11 changed files with 771 additions and 223 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, ¶ms); err != nil {
|
if err := json.Unmarshal(req.Params, ¶ms); 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
430
mcp/sse.go
Normal 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
65
mcp/sse_test.go
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
271
mcp/transport.go
271
mcp/transport.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
16
mcp/util.go
16
mcp/util.go
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue