Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ request. Servers implementing `2026-07-28` MUST implement it.
- **Client**: `Client.Connect` calls `server/discover` first and
uses the result to negotiate a mutually supported version. If discovery
fails or the server does not support the latest version, the client falls back to the
legacy `initialize` handshake.
legacy `initialize` handshake. On stdio transports, where an older server may
silently ignore requests before `initialize`, the client also falls back if
discovery goes unanswered for a few seconds. Set
[`ClientSessionOptions.DiscoverTimeout`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSessionOptions)
to change that bound or apply it to other transports.

A server advertises and negotiates every protocol version the SDK supports;
set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions)
Expand Down
6 changes: 5 additions & 1 deletion internal/docs/protocol.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ request. Servers implementing `2026-07-28` MUST implement it.
- **Client**: `Client.Connect` calls `server/discover` first and
uses the result to negotiate a mutually supported version. If discovery
fails or the server does not support the latest version, the client falls back to the
legacy `initialize` handshake.
legacy `initialize` handshake. On stdio transports, where an older server may
silently ignore requests before `initialize`, the client also falls back if
discovery goes unanswered for a few seconds. Set
[`ClientSessionOptions.DiscoverTimeout`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSessionOptions)
to change that bound or apply it to other transports.

A server advertises and negotiates every protocol version the SDK supports;
set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions)
Expand Down
48 changes: 47 additions & 1 deletion mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ type ClientSessionOptions struct {
// discover) request. If empty, the latest supported version is used.
// The server may negotiate a different mutually supported version.
ProtocolVersion string
// DiscoverTimeout bounds how long Connect waits for a server/discover
// response before falling back to the initialize handshake, since an
// older stdio server may silently ignore discover.
//
// If zero, a 3s bound applies on stdio, IO and in-memory transports, and
// discover is not bounded on other transports. A positive value applies
// on all transports, and a negative value disables the bound. The bound
// never exceeds half of the remaining Connect deadline.
DiscoverTimeout time.Duration
}

func (c *Client) capabilities(protocolVersion string) *ClientCapabilities {
Expand Down Expand Up @@ -316,6 +325,10 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
if opts != nil && opts.ProtocolVersion != "" {
protocolVersion = opts.ProtocolVersion
}
var discoverTimeout time.Duration
if opts != nil {
discoverTimeout = opts.DiscoverTimeout
}

if protocolVersion >= protocolVersion20260728 {
// Per SEP-2575, try the stateless server/discover RPC first. If the server
Expand All @@ -326,7 +339,9 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
// requested version but specifies which versions it supports, we negotiate
// a mutually supported version and try again.
for range 2 {
discRes, err := c.discover(discoverCtx, cs)
probeCtx, cancelProbe := discoverProbeContext(discoverCtx, cs.mcpConn, discoverTimeout)
discRes, err := c.discover(probeCtx, cs)
cancelProbe()
if err == nil {
cs.state.InitializeResult = discRes
if hc, ok := cs.mcpConn.(clientConnection); ok {
Expand Down Expand Up @@ -414,6 +429,37 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp
return cs, nil
}

const defaultDiscoverTimeout = 3 * time.Second

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this default value seems too short, maybe 10 sec would be a better default


// discoverProbeContext bounds the server/discover probe as described by
// [ClientSessionOptions.DiscoverTimeout]. The default applies to
// newline-delimited stream connections (including custom transports that
// return one), where a handshake-era server may silently ignore requests it
// doesn't recognize before initialize. Half of any remaining deadline is kept
// for the initialize fallback.
func discoverProbeContext(ctx context.Context, conn Connection, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout == 0 && isStreamConn(conn) {
timeout = defaultDiscoverTimeout
}
if timeout <= 0 {
return ctx, func() {}
}
Comment on lines +441 to +446

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would explicitly impose the timeout only for stdio connection

Suggested change
if timeout == 0 && isStreamConn(conn) {
timeout = defaultDiscoverTimeout
}
if timeout <= 0 {
return ctx, func() {}
}
if timeout == 0 {
timeout = defaultDiscoverTimeout
}
if timeout <= 0 || !isStreamConn(conn) {
return ctx, func() {}
}

if deadline, ok := ctx.Deadline(); ok {
timeout = min(timeout, time.Until(deadline)/2)
}
return context.WithTimeout(ctx, timeout)
}

func isStreamConn(conn Connection) bool {
switch conn := conn.(type) {
case *ioConn:
return true
case *loggingConn:
return isStreamConn(conn.delegate)
}
return false
}

// discover sends a SEP-2575 server/discover request to probe the server for
// stateless protocol support.
func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, error) {
Expand Down
156 changes: 156 additions & 0 deletions mcp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
package mcp

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"sync/atomic"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
Expand Down Expand Up @@ -1094,6 +1097,159 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
}
}

// serveSilentStdio emulates a handshake-era stdio server that answers only
// initialize and tools/list, silently dropping every other message.
func serveSilentStdio(r io.Reader, w io.Writer) {
sc := bufio.NewScanner(r)
for sc.Scan() {
var msg struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(sc.Bytes(), &msg) != nil {
continue
}
var result any
switch msg.Method {
case methodInitialize:
result = map[string]any{
"protocolVersion": protocolVersion20251125,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "silent", "version": "v1"},
}
case methodListTools:
result = map[string]any{"tools": []any{}}
default:
continue
}
data, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": msg.ID, "result": result})
if _, err := w.Write(append(data, '\n')); err != nil {
return
}
}
}

// delegatingTransport is a user-defined Transport wrapping another one, as an
// instrumentation or filtering decorator would.
type delegatingTransport struct {
Transport
}

// connWrappingTransport is a user-defined Transport that also wraps the
// Connection, which hides the underlying stream connection from Connect.
type connWrappingTransport struct {
Transport
}

func (t connWrappingTransport) Connect(ctx context.Context) (Connection, error) {
conn, err := t.Transport.Connect(ctx)
if err != nil {
return nil, err
}
return struct{ Connection }{conn}, nil
}

func silentStdioTransport() *IOTransport {
cr, sw := io.Pipe()
sr, cw := io.Pipe()
go serveSilentStdio(sr, sw)
return &IOTransport{Reader: cr, Writer: cw}
}

func connectWithFallback(t *testing.T, ctx context.Context, tr Transport, opts *ClientSessionOptions) {
t.Helper()
client := NewClient(&Implementation{Name: "client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, tr, opts)
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()

if got, want := cs.InitializeResult().ProtocolVersion, protocolVersion20251125; got != want {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after fallback initialize: %v", err)
}
}

// TestClientConnectDiscover_SilentStdioServer verifies that Connect falls back
// to initialize when a stdio server never answers server/discover (#1332).
func TestClientConnectDiscover_SilentStdioServer(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
connectWithFallback(t, ctx, silentStdioTransport(), &ClientSessionOptions{DiscoverTimeout: 50 * time.Millisecond})
}

// TestClientConnectDiscover_SilentStdioServerShortDeadline verifies that the
// default discover probe leaves part of a short caller deadline for
// initialize, including when the stdio transport is wrapped.
func TestClientConnectDiscover_SilentStdioServerShortDeadline(t *testing.T) {
transports := map[string]func() Transport{
"plain": func() Transport { return silentStdioTransport() },
"logging": func() Transport { return &LoggingTransport{Transport: silentStdioTransport(), Writer: io.Discard} },
"custom": func() Transport { return delegatingTransport{silentStdioTransport()} },
}
for name, newTransport := range transports {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
connectWithFallback(t, ctx, newTransport(), nil)
})
}
}

// TestClientConnectDiscover_ExplicitTimeout verifies that an explicit
// DiscoverTimeout also applies to connections the default doesn't recognize.
func TestClientConnectDiscover_ExplicitTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tr := connWrappingTransport{silentStdioTransport()}
connectWithFallback(t, ctx, tr, &ClientSessionOptions{DiscoverTimeout: 50 * time.Millisecond})
}

// TestClientConnectDiscover_NegativeTimeout verifies that a negative
// DiscoverTimeout disables the bound, so a silent server blocks Connect until
// the caller's deadline.
func TestClientConnectDiscover_NegativeTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
client := NewClient(&Implementation{Name: "client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, silentStdioTransport(), &ClientSessionOptions{DiscoverTimeout: -1})
if err == nil {
cs.Close()
t.Fatal("client.Connect succeeded, want an error from the caller's deadline")
}
}

// TestClientConnectDiscover_SlowStdioServer verifies that a modern stdio
// server answering server/discover after the probe timeout still connects
// through the initialize fallback, and its late reply is ignored.
func TestClientConnectDiscover_SlowStdioServer(t *testing.T) {
ctx := context.Background()

server := NewServer(&Implementation{Name: "slow-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
time.Sleep(200 * time.Millisecond)
}
return next(ctx, method, req)
}
})

cr, sw := io.Pipe()
sr, cw := io.Pipe()
ss, err := server.Connect(ctx, &IOTransport{Reader: sr, Writer: sw}, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()

tr := &IOTransport{Reader: cr, Writer: cw}
connectWithFallback(t, ctx, tr, &ClientSessionOptions{DiscoverTimeout: 50 * time.Millisecond})
}

// TestClientRejectsMissingRequiredParams checks that server-to-client messages
// whose params are required by the schema are rejected when params is omitted
// or null, instead of reaching handlers that dereference them.
Expand Down
Loading