Compare commits

..
28 changed files with 139 additions and 1459 deletions

View file

@ -44,12 +44,7 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
return nil return nil
} }
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the if baseURL.Path == "" {
// issuer identifier before the well-known suffix is inserted, so an issuer
// such as "https://auth.example.com/" has no path component at all.
issuerPath := strings.Trim(baseURL.Path, "/")
if issuerPath == "" {
// "OAuth 2.0 Authorization Server Metadata". // "OAuth 2.0 Authorization Server Metadata".
baseURL.Path = "/.well-known/oauth-authorization-server" baseURL.Path = "/.well-known/oauth-authorization-server"
urls = append(urls, baseURL.String()) urls = append(urls, baseURL.String())
@ -59,14 +54,15 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
return urls return urls
} }
originalPath := baseURL.Path
// "OAuth 2.0 Authorization Server Metadata with path insertion". // "OAuth 2.0 Authorization Server Metadata with path insertion".
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String()) urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path insertion". // "OpenID Connect Discovery 1.0 with path insertion".
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String()) urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path appending". // "OpenID Connect Discovery 1.0 with path appending".
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration" baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
urls = append(urls, baseURL.String()) urls = append(urls, baseURL.String())
return urls return urls

View file

@ -6,81 +6,11 @@ package auth
import ( import (
"net/http" "net/http"
"slices"
"testing" "testing"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
) )
func TestAuthorizationServerMetadataURLs(t *testing.T) {
tests := []struct {
issuerURL string
want []string
}{
{
issuerURL: "https://auth.example.com",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server",
"https://auth.example.com/.well-known/openid-configuration",
},
},
{
issuerURL: "https://auth.example.com/",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server",
"https://auth.example.com/.well-known/openid-configuration",
},
},
{
issuerURL: "https://auth.example.com/tenant1",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
"https://auth.example.com/.well-known/openid-configuration/tenant1",
"https://auth.example.com/tenant1/.well-known/openid-configuration",
},
},
{
issuerURL: "https://auth.example.com/tenant1/",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
"https://auth.example.com/.well-known/openid-configuration/tenant1",
"https://auth.example.com/tenant1/.well-known/openid-configuration",
},
},
}
for _, tt := range tests {
t.Run(tt.issuerURL, func(t *testing.T) {
if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) {
t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want)
}
})
}
}
// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier
// written with a terminating slash still finds metadata served at the root
// well-known path.
func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) {
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
})
s.Start(t)
got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient)
if err != nil {
t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err)
}
if got == nil {
t.Fatal("GetAuthServerMetadata() got nil, want metadata")
}
if got.Issuer != s.URL() {
t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL())
}
}
func TestGetAuthServerMetadata(t *testing.T) { func TestGetAuthServerMetadata(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -304,7 +304,7 @@ func Example_elicitation() {
`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the `ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the
client renders as a form. Two field keywords shape that form. client renders as a form. Two field keywords shape that form.
A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a
field. When the user accepts without supplying it, the SDK fills the field in field. When the user accepts without supplying it, the SDK fills the field in
from the schema before the result reaches either side's caller — the client from the schema before the result reaches either side's caller — the client
does so after its elicitation handler returns, and `ServerSession.Elicit` does does so after its elicitation handler returns, and `ServerSession.Elicit` does
@ -313,7 +313,7 @@ defaulted field `Required` defeats it: accepted content is validated against
the schema before defaults are applied, so an answer that omits the field is the schema before defaults are applied, so an answer that omits the field is
rejected rather than defaulted. rejected rather than defaulted.
An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a
field to a fixed set of values, which the client renders as a choice. Enums field to a fixed set of values, which the client renders as a choice. Enums
are supported only on `"string"` fields; declaring one on another type is are supported only on `"string"` fields; declaring one on another type is
rejected. To label the choices, set the legacy `enumNames` keyword through rejected. To label the choices, set the legacy `enumNames` keyword through

View file

@ -18,16 +18,6 @@
## `MCPGODEBUG` history ## `MCPGODEBUG` history
### 1.9.0
Options listed below were added and will be removed in the 1.11.0 version of the SDK.
- `structuredcontentfloat64` added. If set to `1`, numbers in
`CallToolResult.StructuredContent` are decoded as `float64`, restoring the
previous behavior. The default behavior decodes numbers as `json.Number` so
that large integers and other exact JSON values do not silently lose
precision.
### 1.8.0 ### 1.8.0
Options listed below were added and will be removed in the 1.9.0 version of the SDK. Options listed below were added and will be removed in the 1.9.0 version of the SDK.

View file

@ -1066,12 +1066,8 @@ server produces logs that remain server-side, for use by server maintainers.)
**Server-side**: **Server-side**:
The minimum log level is part of the server state. The minimum log level is part of the server state.
For stateful sessions, there is no default log level: no log messages will be sent For stateful sessions, there is no default log level: no log messages will be sent
until the client calls `SetLoggingLevel` (see below). until the client calls `SetLevel` (see below).
For legacy stateless sessions the level defaults to "info". For stateful sessions, the level defaults to "info".
For sessionless 2026-07-28 requests there is no server-side default: each request
carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is
logged until the client sends one.
(Note: logging is deprecated as of 2026-07-28 / SEP-2577.)
[`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients.
It sends a logging notification to the client if the level of the message It sends a logging notification to the client if the level of the message
@ -1087,7 +1083,7 @@ Servers always report the logging capability.
**Client-side**: **Client-side**:
Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages.
Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session.
```go ```go
func Example_logging() { func Example_logging() {

View file

@ -38,7 +38,7 @@ func main() {
logger.Info("MCP method started", logger.Info("MCP method started",
"method", method, "method", method,
"session_id", req.GetSession().ID(), "session_id", req.GetSession().ID(),
"has_params", mcp.HasParams(req), "has_params", req.GetParams() != nil,
) )
// Log more for tool calls. // Log more for tool calls.
if ctr, ok := req.(*mcp.CallToolRequest); ok { if ctr, ok := req.(*mcp.CallToolRequest); ok {

View file

@ -99,7 +99,7 @@ pattern.
`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the `ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the
client renders as a form. Two field keywords shape that form. client renders as a form. Two field keywords shape that form.
A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a
field. When the user accepts without supplying it, the SDK fills the field in field. When the user accepts without supplying it, the SDK fills the field in
from the schema before the result reaches either side's caller — the client from the schema before the result reaches either side's caller — the client
does so after its elicitation handler returns, and `ServerSession.Elicit` does does so after its elicitation handler returns, and `ServerSession.Elicit` does
@ -108,7 +108,7 @@ defaulted field `Required` defeats it: accepted content is validated against
the schema before defaults are applied, so an answer that omits the field is the schema before defaults are applied, so an answer that omits the field is
rejected rather than defaulted. rejected rather than defaulted.
An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a
field to a fixed set of values, which the client renders as a choice. Enums field to a fixed set of values, which the client renders as a choice. Enums
are supported only on `"string"` fields; declaring one on another type is are supported only on `"string"` fields; declaring one on another type is
rejected. To label the choices, set the legacy `enumNames` keyword through rejected. To label the choices, set the legacy `enumNames` keyword through

View file

@ -17,16 +17,6 @@
## `MCPGODEBUG` history ## `MCPGODEBUG` history
### 1.9.0
Options listed below were added and will be removed in the 1.11.0 version of the SDK.
- `structuredcontentfloat64` added. If set to `1`, numbers in
`CallToolResult.StructuredContent` are decoded as `float64`, restoring the
previous behavior. The default behavior decodes numbers as `json.Number` so
that large integers and other exact JSON values do not silently lose
precision.
### 1.8.0 ### 1.8.0
Options listed below were added and will be removed in the 1.9.0 version of the SDK. Options listed below were added and will be removed in the 1.9.0 version of the SDK.

View file

@ -437,12 +437,8 @@ server produces logs that remain server-side, for use by server maintainers.)
**Server-side**: **Server-side**:
The minimum log level is part of the server state. The minimum log level is part of the server state.
For stateful sessions, there is no default log level: no log messages will be sent For stateful sessions, there is no default log level: no log messages will be sent
until the client calls `SetLoggingLevel` (see below). until the client calls `SetLevel` (see below).
For legacy stateless sessions the level defaults to "info". For stateful sessions, the level defaults to "info".
For sessionless 2026-07-28 requests there is no server-side default: each request
carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is
logged until the client sends one.
(Note: logging is deprecated as of 2026-07-28 / SEP-2577.)
[`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients.
It sends a logging notification to the client if the level of the message It sends a logging notification to the client if the level of the message
@ -458,7 +454,7 @@ Servers always report the logging capability.
**Client-side**: **Client-side**:
Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages.
Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session.
%include ../../mcp/server_example_test.go logging - %include ../../mcp/server_example_test.go logging -

View file

@ -44,15 +44,6 @@ func Unmarshal(data []byte, v any) error {
return NewDecoder(bytes.NewReader(data)).Decode(v) return NewDecoder(bytes.NewReader(data)).Decode(v)
} }
func UnmarshalUseNumber(data []byte, v any) error {
if err := checkMaxDepth(data, defaultMaxDepth); err != nil {
return err
}
dec := NewDecoder(bytes.NewReader(data))
dec.dec.UseNumber()
return dec.Decode(v)
}
// checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the // checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the
// nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which // nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which
// tracks '{' and '[' against '}' and ']' while skipping over the contents of strings. // tracks '{' and '[' against '}' and ']' while skipping over the contents of strings.

View file

@ -453,19 +453,12 @@ func (ac *AsyncCall) Await(ctx context.Context, result any) error {
// will not cause any messages that have not arrived yet with that ID to be // will not cause any messages that have not arrived yet with that ID to be
// cancelled. // cancelled.
func (c *Connection) Cancel(id ID) { func (c *Connection) Cancel(id ID) {
c.CancelCause(id, nil)
}
// CancelCause is like [Connection.Cancel], but records cause as the reason
// the Context was cancelled, so that the Handle call can read it back through
// [context.Cause]. A nil cause reads as [context.Canceled].
func (c *Connection) CancelCause(id ID, cause error) {
var req *incomingRequest var req *incomingRequest
c.updateInFlight(func(s *inFlightState) { c.updateInFlight(func(s *inFlightState) {
req = s.incomingByID[id] req = s.incomingByID[id]
}) })
if req != nil { if req != nil {
req.cancel(cause) req.cancel(nil)
} }
} }

View file

@ -1147,9 +1147,6 @@ func (c *Client) AddSendingMiddleware(middleware ...Middleware) {
// //
// Receiving middleware is called when a request is received. It is useful for tasks // Receiving middleware is called when a request is received. It is useful for tasks
// such as authentication, request logging and metrics. // such as authentication, request logging and metrics.
//
// A received message need not carry params: use [HasParams] before inspecting
// [Request.GetParams].
func (c *Client) AddReceivingMiddleware(middleware ...Middleware) { func (c *Client) AddReceivingMiddleware(middleware ...Middleware) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()

View file

@ -710,13 +710,13 @@ func TestCancellation(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
var ( var (
start = make(chan struct{}) start = make(chan struct{})
cancelled = make(chan error, 1) // don't block the request cancelled = make(chan struct{}, 1) // don't block the request
) )
slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) {
start <- struct{}{} start <- struct{}{}
select { select {
case <-ctx.Done(): case <-ctx.Done():
cancelled <- context.Cause(ctx) cancelled <- struct{}{}
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
return nil, nil, nil return nil, nil, nil
} }
@ -732,72 +732,7 @@ func TestCancellation(t *testing.T) {
<-start <-start
cancel() cancel()
// The client sends its context's error as the reason of its cancelled <-cancelled
// notification, and the handler reads it back as the cause.
cause := <-cancelled
if !errors.Is(cause, context.Canceled) {
t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause)
}
if want := "request cancelled by the peer: " + context.Canceled.Error(); cause == nil || cause.Error() != want {
t.Errorf("context.Cause = %v, want %q", cause, want)
}
})
}
// TestCancellationReason verifies that the reason a peer gives in its
// cancelled notification reaches the handler as the cause of its context,
// which is what lets a server log it as the specification asks.
//
// The call and the notification are written to the connection directly, so
// the reason is one the SDK's own client would never send; the session is
// pinned to 2025-11-25 so that a request written that way needs no _meta.
func TestCancellationReason(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var (
start = make(chan struct{})
cancelled = make(chan error, 1) // don't block the request
)
slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) {
start <- struct{}{}
select {
case <-ctx.Done():
cancelled <- context.Cause(ctx)
case <-time.After(5 * time.Second):
cancelled <- nil
}
return nil, nil, nil
}
ctx := context.Background()
ct, st := NewInMemoryTransports()
s := NewServer(testImpl, nil)
AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool)
ss, err := s.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ss.Close() })
cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cs.Close() })
call := cs.conn.Call(ctx, methodCallTool, &CallToolParams{Name: "slow"})
<-start
if err := cs.conn.Notify(ctx, notificationCancelled, &CancelledParams{RequestID: call.ID().Raw(), Reason: "user asked"}); err != nil {
t.Fatal(err)
}
cause := <-cancelled
if cause == nil {
t.Fatal("the tool ran to completion, want it cancelled")
}
if !errors.Is(cause, context.Canceled) {
t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause)
}
if got, want := cause.Error(), "request cancelled by the peer: user asked"; got != want {
t.Errorf("context.Cause = %q, want %q", got, want)
}
}) })
} }
@ -3363,159 +3298,6 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) {
} }
} }
// newSubListenSubscribeServer is newSubListenServer with a resource and the
// handlers that make the server advertise resources.subscribe, so that a
// listen can carry a resource subscription.
func newSubListenSubscribeServer() *Server {
s := NewServer(testImpl, &ServerOptions{
SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil },
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil },
})
AddTool(s, &Tool{Name: "t1"}, sayHi)
s.AddPrompt(&Prompt{Name: "p1"}, nil)
s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil)
return s
}
func waitSubListenEvent(t *testing.T, events chan subListenEvent, kind string) subListenEvent {
t.Helper()
select {
case e := <-events:
if e.kind != kind {
t.Fatalf("got event %q, want %q", e.kind, kind)
}
return e
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for %q", kind)
return subListenEvent{}
}
}
// TestSubscriptionsListen_TeardownKeepsOtherListens verifies that unwinding
// one listen leaves the other listens on the same session registered.
// Unsubscribe tears down the listen that Subscribe opened for the resource;
// the auto-listen opened by Connect must keep delivering list-changed
// notifications.
func TestSubscriptionsListen_TeardownKeepsOtherListens(t *testing.T) {
events := make(chan subListenEvent, 16)
server := newSubListenSubscribeServer()
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cs, err := newSubListenClient(events).Connect(ctx, ct,
&ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
autoListen := waitSubListenEvent(t, events, "ack")
if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
resourceListen := waitSubListenEvent(t, events, "ack")
if resourceListen.id == autoListen.id {
t.Fatalf("Subscribe reused subscription ID %s", autoListen.id)
}
if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil {
t.Fatalf("Unsubscribe: %v", err)
}
// Wait for the server to unwind that listen, observed through a registry
// the auto-listen does not appear in.
waitUntil(t, 5*time.Second, "resource listen to unwind", func() bool {
server.mu.Lock()
defer server.mu.Unlock()
_, ok := server.resourceSubscriptions["file:///r1"]
return !ok
})
AddTool(server, &Tool{Name: "t2"}, sayHi)
if got := waitSubListenEvent(t, events, "tool"); got.id != autoListen.id {
t.Errorf("tool notification id = %s, want auto-listen %s", got.id, autoListen.id)
}
}
// TestSubscriptionsListen_TeardownRetiresOwnRegistration verifies that a
// listen still retires what it registered, so that per-listen tracking does
// not turn into a leak.
func TestSubscriptionsListen_TeardownRetiresOwnRegistration(t *testing.T) {
events := make(chan subListenEvent, 8)
server := newSubListenServer()
ct, st := NewInMemoryTransports()
ss, err := server.Connect(context.Background(), st, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer ss.Close()
// A client with no list-changed handlers does not auto-listen, so the
// listen opened below is the session's only one.
c := NewClient(testImpl, nil)
c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == notificationSubscriptionsAck {
events <- subListenEvent{"ack", ""}
}
return next(ctx, method, req)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
listenCtx, cancelListen := context.WithCancel(context.Background())
defer cancelListen()
if err := cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{
Notifications: &NotificationSubscriptions{ToolsListChanged: true, PromptsListChanged: true},
}); err != nil {
t.Fatalf("listen: %v", err)
}
waitSubListenEvent(t, events, "ack")
server.mu.Lock()
_, inTool := server.toolChangeSubscriptions[ss]
_, inPrompt := server.promptChangeSubscriptions[ss]
server.mu.Unlock()
if !inTool || !inPrompt {
t.Fatal("listen not registered")
}
cancelListen()
waitUntil(t, 5*time.Second, "registrations to be retired", func() bool {
server.mu.Lock()
defer server.mu.Unlock()
_, inTool := server.toolChangeSubscriptions[ss]
_, inPrompt := server.promptChangeSubscriptions[ss]
return !inTool && !inPrompt
})
}
// waitUntil polls cond until it reports true, failing the test after timeout.
func waitUntil(t *testing.T, timeout time.Duration, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(10 * time.Millisecond)
}
}
// TestSubscriptionsListen_RespectsServerCapabilities verifies that during // TestSubscriptionsListen_RespectsServerCapabilities verifies that during
// Connect the client only opens a SEP-2575 subscriptions/listen stream for the // Connect the client only opens a SEP-2575 subscriptions/listen stream for the
// change notifications the server advertised during capability negotiation. // change notifications the server advertised during capability negotiation.

View file

@ -36,20 +36,29 @@ type multiRoundTripResponse interface {
hasContent() bool hasContent() bool
} }
// validateMultiRoundTripResult rejects a result that carries both content and func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error {
// input requests. [annotateResultType] labels the result later, in
// [ServerSession.handle], so this function does not set resultType.
func validateMultiRoundTripResult(logger *slog.Logger, res multiRoundTripResponse) error {
if res == nil { if res == nil {
return nil return nil
} }
if res.inputRequests() != nil && res.hasContent() { hasInputRequests := res.inputRequests() != nil
if hasInputRequests && res.hasContent() {
logger.Warn("handler returned both content and inputRequests") logger.Warn("handler returned both content and inputRequests")
return &jsonrpc.Error{ return &jsonrpc.Error{
Code: jsonrpc.CodeInternalError, Code: jsonrpc.CodeInternalError,
Message: "server bug: result has both content and inputRequests", Message: "server bug: result has both content and inputRequests",
} }
} }
if clientSupportsMultiRoundTrip(ss) {
// For older clients the resultType is left unset. Input requests will be handled
// by serverMultiRoundTripMiddleware client calls and handler reinvocation.
if hasInputRequests {
res.setResultType(resultTypeInputRequired)
} else {
res.setResultType(resultTypeComplete)
}
}
return nil return nil
} }

View file

@ -19,7 +19,7 @@ type resultType string
const ( const (
// resultTypeComplete indicates the result is final. // resultTypeComplete indicates the result is final.
// This is the default when resultType is empty. // This is the default when ResultType is empty.
resultTypeComplete resultType = "complete" resultTypeComplete resultType = "complete"
// resultTypeInputRequired indicates the server needs additional client // resultTypeInputRequired indicates the server needs additional client
@ -40,20 +40,9 @@ type completeResultResponse interface {
isCompleteResult() isCompleteResult()
} }
// annotateResultType sets the resultType that protocol 2026-07-28 requires on func setCompleteResultType(res Result) {
// every result. if r, ok := res.(completeResultResponse); ok {
func annotateResultType(res Result) {
switch r := res.(type) {
case completeResultResponse:
r.setResultType(resultTypeComplete) r.setResultType(resultTypeComplete)
case multiRoundTripResponse:
// These results are complete or input_required, so label them by
// whether the handler asked for more client input.
if r.inputRequests() != nil {
r.setResultType(resultTypeInputRequired)
} else {
r.setResultType(resultTypeComplete)
}
} }
} }
@ -237,8 +226,9 @@ type CallToolParams struct {
// marshaled to JSON. // marshaled to JSON.
Arguments any `json:"arguments,omitempty"` Arguments any `json:"arguments,omitempty"`
// InputResponses maps input request IDs to responses. Set it when the // InputResponses maps input request IDs to responses, provided when
// client retries a call that asked for input. See [CallToolResult.NeedsInput]. // retrying a call after receiving a result with ResultType
// ResultTypeInputRequired.
InputResponses InputResponseMap `json:"inputResponses,omitempty"` InputResponses InputResponseMap `json:"inputResponses,omitempty"`
// RequestState is the opaque state from the previous input-required result. // RequestState is the opaque state from the previous input-required result.
// The client must echo this back when retrying. // The client must echo this back when retrying.
@ -259,8 +249,9 @@ type CallToolParamsRaw struct {
// Arguments (see [AddTool]). // Arguments (see [AddTool]).
Arguments json.RawMessage `json:"arguments,omitempty"` Arguments json.RawMessage `json:"arguments,omitempty"`
// InputResponses maps input request IDs to responses. Set it when the // InputResponses maps input request IDs to responses, provided when
// client retries a call that asked for input. See [CallToolResult.NeedsInput]. // retrying a call after receiving a result with ResultType
// ResultTypeInputRequired.
InputResponses InputResponseMap `json:"inputResponses,omitempty"` InputResponses InputResponseMap `json:"inputResponses,omitempty"`
// RequestState is the opaque state from the previous input-required result. // RequestState is the opaque state from the previous input-required result.
// The client must echo this back when retrying. // The client must echo this back when retrying.
@ -289,8 +280,6 @@ type CallToolResult struct {
// result of the tool call. Per SEP-2106, it may marshal to any valid JSON // result of the tool call. Per SEP-2106, it may marshal to any valid JSON
// value (object, array, or primitive) conforming to the tool's // value (object, array, or primitive) conforming to the tool's
// [Tool.OutputSchema]. // [Tool.OutputSchema].
// Numbers received from the wire are represented as [json.Number] to preserve
// their exact values.
// //
// When using a [ToolHandlerFor] with structured output, you should not // When using a [ToolHandlerFor] with structured output, you should not
// populate this field. It will be automatically populated with the typed Out // populate this field. It will be automatically populated with the typed Out
@ -316,9 +305,9 @@ type CallToolResult struct {
IsError bool `json:"isError,omitempty"` IsError bool `json:"isError,omitempty"`
// InputRequests is a map of server-assigned IDs to input requests. // InputRequests is a map of server-assigned IDs to input requests.
// It is set only when the result needs more client input. // Populated only when ResultType is ResultTypeInputRequired.
// The client must fulfill these and echo the IDs back in InputResponses // The client must fulfill these and echo the IDs back in InputResponses
// when it retries the call. // when retrying the call.
InputRequests InputRequestMap `json:"inputRequests,omitempty"` InputRequests InputRequestMap `json:"inputRequests,omitempty"`
// RequestState is an opaque string the client must echo back when // RequestState is an opaque string the client must echo back when
@ -328,10 +317,10 @@ type CallToolResult struct {
// Unauthenticated servers must encrypt, sign and verify this value. // Unauthenticated servers must encrypt, sign and verify this value.
RequestState string `json:"requestState,omitempty"` RequestState string `json:"requestState,omitempty"`
// resultType records whether the call finished or needs more client input. // ResultType indicates whether this result is complete or requires further
// Empty or resultTypeComplete means it finished. resultTypeInputRequired // client input. Empty or ResultTypeComplete means the call succeeded
// means the client must fulfill InputRequests and retry. // normally. ResultTypeInputRequired means the client should fulfill the
// See [CallToolResult.NeedsInput]. // InputRequests and retry the call.
resultType resultType resultType resultType
// The error passed to setError, if any. // The error passed to setError, if any.
// It is not marshaled, and therefore it is only visible on the server. // It is not marshaled, and therefore it is only visible on the server.
@ -359,8 +348,7 @@ func (r *CallToolResult) GetError() error {
return r.err return r.err
} }
func (*CallToolResult) isResult() {} func (*CallToolResult) isResult() {}
func (x *CallToolResult) isNil() bool { return x == nil }
func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt } func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt }
func (r *CallToolResult) requestState() string { return r.RequestState } func (r *CallToolResult) requestState() string { return r.RequestState }
@ -375,18 +363,12 @@ func (r *CallToolResult) hasContent() bool {
} }
// NeedsInput reports whether this result requires further client input. // NeedsInput reports whether this result requires further client input.
// This is true when the server returned a resultType of "input_required". // This is true when the server returned ResultType "input_required".
// When NeedsInput returns true, check InputRequests for the set of // When NeedsInput returns true, check InputRequests for the set of
// requests the server needs fulfilled before retrying the call. // requests the server needs fulfilled before retrying the call.
// An empty InputRequests with NeedsInput true indicates load-shedding. // An empty InputRequests with NeedsInput true indicates load-shedding.
func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired }
// structuredcontentfloat64 is a compatibility parameter that restores the
// previous behavior of decoding numbers in [CallToolResult.StructuredContent]
// as float64 values. By default, numbers are decoded as [json.Number] to avoid
// losing precision. The option will be removed in the 1.11.0 version of the SDK.
var structuredcontentfloat64 = mcpgodebug.Value("structuredcontentfloat64")
func (x *CallToolResult) MarshalJSON() ([]byte, error) { func (x *CallToolResult) MarshalJSON() ([]byte, error) {
type res CallToolResult // avoid recursion type res CallToolResult // avoid recursion
type wire struct { type wire struct {
@ -409,22 +391,12 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error {
type res CallToolResult // avoid recursion type res CallToolResult // avoid recursion
var wire struct { var wire struct {
res res
Content []*wireContent `json:"content"` Content []*wireContent `json:"content"`
StructuredContent json.RawMessage `json:"structuredContent"` ResultType resultType `json:"resultType"`
ResultType resultType `json:"resultType"`
} }
if err := internaljson.Unmarshal(data, &wire); err != nil { if err := internaljson.Unmarshal(data, &wire); err != nil {
return err return err
} }
if len(wire.StructuredContent) > 0 {
unmarshal := internaljson.UnmarshalUseNumber
if structuredcontentfloat64 == "1" {
unmarshal = internaljson.Unmarshal
}
if err := unmarshal(wire.StructuredContent, &wire.res.StructuredContent); err != nil {
return err
}
}
var err error var err error
if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil {
return err return err
@ -683,8 +655,7 @@ type CompleteResult struct {
Completion CompletionResultDetails `json:"completion"` Completion CompletionResultDetails `json:"completion"`
} }
func (*CompleteResult) isResult() {} func (*CompleteResult) isResult() {}
func (x *CompleteResult) isNil() bool { return x == nil }
// CreateMessageParams holds parameters for a sampling/createMessage request. // CreateMessageParams holds parameters for a sampling/createMessage request.
// //
@ -868,7 +839,6 @@ type CreateMessageResult struct {
} }
func (*CreateMessageResult) isResult() {} func (*CreateMessageResult) isResult() {}
func (x *CreateMessageResult) isNil() bool { return x == nil }
func (*CreateMessageResult) isInputResponse() {} func (*CreateMessageResult) isInputResponse() {}
func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { func (r *CreateMessageResult) UnmarshalJSON(data []byte) error {
type result CreateMessageResult // avoid recursion type result CreateMessageResult // avoid recursion
@ -920,7 +890,6 @@ var createMessageWithToolsResultAllow = map[string]bool{
} }
func (*CreateMessageWithToolsResult) isResult() {} func (*CreateMessageWithToolsResult) isResult() {}
func (x *CreateMessageWithToolsResult) isNil() bool { return x == nil }
func (*CreateMessageWithToolsResult) isInputResponse() {} func (*CreateMessageWithToolsResult) isInputResponse() {}
// MarshalJSON marshals the result. When Content has a single element, it is // MarshalJSON marshals the result. When Content has a single element, it is
@ -981,8 +950,9 @@ type GetPromptParams struct {
// The name of the prompt or prompt template. // The name of the prompt or prompt template.
Name string `json:"name"` Name string `json:"name"`
// InputResponses maps input request IDs to responses. Set it when the // InputResponses maps input request IDs to responses, provided when
// client retries a call that asked for input. See [CallToolResult.NeedsInput]. // retrying a call after receiving a result with ResultType
// ResultTypeInputRequired.
InputResponses InputResponseMap `json:"inputResponses,omitempty"` InputResponses InputResponseMap `json:"inputResponses,omitempty"`
// RequestState is the opaque state from the previous input-required result. // RequestState is the opaque state from the previous input-required result.
RequestState string `json:"requestState,omitempty"` RequestState string `json:"requestState,omitempty"`
@ -1002,20 +972,19 @@ type GetPromptResult struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Messages []*PromptMessage `json:"messages"` Messages []*PromptMessage `json:"messages"`
// InputRequests is set when the result needs more client input. // InputRequests is populated when ResultType is ResultTypeInputRequired.
// See [CallToolResult.InputRequests]. // See [CallToolResult.InputRequests].
InputRequests InputRequestMap `json:"inputRequests,omitempty"` InputRequests InputRequestMap `json:"inputRequests,omitempty"`
// RequestState is the opaque state for multi-round-trip retries. // RequestState is the opaque state for multi-round-trip retries.
// See [CallToolResult.RequestState]. // See [CallToolResult.RequestState].
RequestState string `json:"requestState,omitempty"` RequestState string `json:"requestState,omitempty"`
// resultType records whether the call finished or needs more client input. // ResultType indicates whether this result is complete or requires further
// See [CallToolResult.NeedsInput]. // client input. See [CallToolResult.ResultType] for details.
resultType resultType resultType resultType
} }
func (*GetPromptResult) isResult() {} func (*GetPromptResult) isResult() {}
func (x *GetPromptResult) isNil() bool { return x == nil }
func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt } func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt }
func (r *GetPromptResult) requestState() string { return r.RequestState } func (r *GetPromptResult) requestState() string { return r.RequestState }
@ -1125,8 +1094,7 @@ type InitializeResult struct {
ServerInfo *Implementation `json:"serverInfo"` ServerInfo *Implementation `json:"serverInfo"`
} }
func (*InitializeResult) isResult() {} func (*InitializeResult) isResult() {}
func (x *InitializeResult) isNil() bool { return x == nil }
type InitializedParams struct { type InitializedParams struct {
// Meta is reserved by the protocol to allow clients and servers to attach // Meta is reserved by the protocol to allow clients and servers to attach
@ -1169,8 +1137,7 @@ type DiscoverResult struct {
Instructions string `json:"instructions,omitempty"` Instructions string `json:"instructions,omitempty"`
} }
func (*DiscoverResult) isResult() {} func (*DiscoverResult) isResult() {}
func (x *DiscoverResult) isNil() bool { return x == nil }
func (x *ListPromptsParams) isParams() {} func (x *ListPromptsParams) isParams() {}
func (x *ListPromptsParams) isNil() bool { return x == nil } func (x *ListPromptsParams) isNil() bool { return x == nil }
@ -1240,7 +1207,6 @@ type ListPromptsResult struct {
} }
func (x *ListPromptsResult) isResult() {} func (x *ListPromptsResult) isResult() {}
func (x *ListPromptsResult) isNil() bool { return x == nil }
func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor } func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor }
type ListResourceTemplatesParams struct { type ListResourceTemplatesParams struct {
@ -1272,7 +1238,6 @@ type ListResourceTemplatesResult struct {
} }
func (x *ListResourceTemplatesResult) isResult() {} func (x *ListResourceTemplatesResult) isResult() {}
func (x *ListResourceTemplatesResult) isNil() bool { return x == nil }
func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor } func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor }
type ListResourcesParams struct { type ListResourcesParams struct {
@ -1304,7 +1269,6 @@ type ListResourcesResult struct {
} }
func (x *ListResourcesResult) isResult() {} func (x *ListResourcesResult) isResult() {}
func (x *ListResourcesResult) isNil() bool { return x == nil }
func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor } func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor }
// ListRootsParams holds parameters for a roots/list request. // ListRootsParams holds parameters for a roots/list request.
@ -1341,7 +1305,6 @@ type ListRootsResult struct {
} }
func (*ListRootsResult) isResult() {} func (*ListRootsResult) isResult() {}
func (x *ListRootsResult) isNil() bool { return x == nil }
func (*ListRootsResult) isInputResponse() {} func (*ListRootsResult) isInputResponse() {}
type ListToolsParams struct { type ListToolsParams struct {
@ -1373,7 +1336,6 @@ type ListToolsResult struct {
} }
func (x *ListToolsResult) isResult() {} func (x *ListToolsResult) isResult() {}
func (x *ListToolsResult) isNil() bool { return x == nil }
func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor } func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor }
// The severity of a log message. // The severity of a log message.
@ -1613,8 +1575,9 @@ type ReadResourceParams struct {
// the server how to interpret it. // the server how to interpret it.
URI string `json:"uri"` URI string `json:"uri"`
// InputResponses maps input request IDs to responses. Set it when the // InputResponses maps input request IDs to responses, provided when
// client retries a call that asked for input. See [CallToolResult.NeedsInput]. // retrying a call after receiving a result with ResultType
// ResultTypeInputRequired.
InputResponses InputResponseMap `json:"inputResponses,omitempty"` InputResponses InputResponseMap `json:"inputResponses,omitempty"`
// RequestState is the opaque state from the previous input-required result. // RequestState is the opaque state from the previous input-required result.
RequestState string `json:"requestState,omitempty"` RequestState string `json:"requestState,omitempty"`
@ -1633,20 +1596,19 @@ type ReadResourceResult struct {
Cacheable Cacheable
Contents []*ResourceContents `json:"contents"` Contents []*ResourceContents `json:"contents"`
// InputRequests is set when the result needs more client input. // InputRequests is populated when ResultType is ResultTypeInputRequired.
// See [CallToolResult.InputRequests]. // See [CallToolResult.InputRequests].
InputRequests InputRequestMap `json:"inputRequests,omitempty"` InputRequests InputRequestMap `json:"inputRequests,omitempty"`
// RequestState is the opaque state for multi-round-trip retries. // RequestState is the opaque state for multi-round-trip retries.
// See [CallToolResult.RequestState]. // See [CallToolResult.RequestState].
RequestState string `json:"requestState,omitempty"` RequestState string `json:"requestState,omitempty"`
// resultType records whether the call finished or needs more client input. // ResultType indicates whether this result is complete or requires further
// See [CallToolResult.NeedsInput]. // client input. See [CallToolResult.ResultType] for details.
resultType resultType resultType resultType
} }
func (*ReadResourceResult) isResult() {} func (*ReadResourceResult) isResult() {}
func (x *ReadResourceResult) isNil() bool { return x == nil }
func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt } func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt }
func (r *ReadResourceResult) requestState() string { return r.RequestState } func (r *ReadResourceResult) requestState() string { return r.RequestState }
@ -2152,8 +2114,7 @@ type SubscriptionsListenResult struct {
Meta `json:"_meta"` Meta `json:"_meta"`
} }
func (*SubscriptionsListenResult) isResult() {} func (*SubscriptionsListenResult) isResult() {}
func (x *SubscriptionsListenResult) isNil() bool { return x == nil }
// TODO(jba): add CompleteRequest and related types. // TODO(jba): add CompleteRequest and related types.
@ -2230,7 +2191,6 @@ type ElicitResult struct {
} }
func (*ElicitResult) isResult() {} func (*ElicitResult) isResult() {}
func (x *ElicitResult) isNil() bool { return x == nil }
func (*ElicitResult) isInputResponse() {} func (*ElicitResult) isInputResponse() {}
// ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. // ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed.

View file

@ -1285,7 +1285,7 @@ func TestContentUnmarshal(t *testing.T) {
Meta: Meta{"m": true}, Meta: Meta{"m": true},
Content: content, Content: content,
IsError: true, IsError: true,
StructuredContent: json.Number("3"), StructuredContent: 3.0,
} }
var gotf CallToolResult var gotf CallToolResult
roundtrip(ctrf, &gotf) roundtrip(ctrf, &gotf)
@ -1298,24 +1298,6 @@ func TestContentUnmarshal(t *testing.T) {
roundtrip(pm, &gotpm) roundtrip(pm, &gotpm)
} }
func TestCallToolResultStructuredContentFloat64Compatibility(t *testing.T) {
prev := structuredcontentfloat64
structuredcontentfloat64 = "1"
t.Cleanup(func() { structuredcontentfloat64 = prev })
var got CallToolResult
if err := json.Unmarshal([]byte(`{"content":[],"structuredContent":{"id":9007199254740993}}`), &got); err != nil {
t.Fatal(err)
}
structured, ok := got.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("StructuredContent type = %T, want map[string]any", got.StructuredContent)
}
if got := structured["id"]; got != float64(9007199254740992) {
t.Errorf("id = %v (%T), want rounded float64 value", got, got)
}
}
func TestToolAnnotations_MarshalJSON(t *testing.T) { func TestToolAnnotations_MarshalJSON(t *testing.T) {
boolPtr := func(b bool) *bool { return &b } boolPtr := func(b bool) *bool { return &b }

View file

@ -910,7 +910,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm
} }
res, err := prompt.handler(ctx, req) res, err := prompt.handler(ctx, req)
if err == nil && res != nil { if err == nil && res != nil {
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
return nil, err return nil, err
} }
} }
@ -1012,10 +1012,10 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
} }
res, err := st.handler(ctx, req) res, err := st.handler(ctx, req)
if err == nil && res != nil { if err == nil && res != nil {
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
return nil, err return nil, err
} }
if res.Content == nil && res.InputRequests == nil { if res.Content == nil && res.resultType != resultTypeInputRequired {
res2 := *res res2 := *res
res2.Content = []Content{} // avoid "null" res2.Content = []Content{} // avoid "null"
res = &res2 res = &res2
@ -1080,11 +1080,11 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R
if res == nil { if res == nil {
return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri)
} }
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
return nil, err return nil, err
} }
s.resolveCacheable(ctx, req, &res.Cacheable) s.resolveCacheable(ctx, req, &res.Cacheable)
if res.InputRequests != nil { if res.resultType == resultTypeInputRequired {
return res, nil return res, nil
} }
if res.Contents == nil { if res.Contents == nil {
@ -1276,19 +1276,10 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList
} }
s.mu.Unlock() s.mu.Unlock()
defer func() { defer func() {
// Retire only what this listen registered above: a session can hold
// several listens at once, and deleting by session alone retires a
// registration belonging to a listen that is still open.
s.mu.Lock() s.mu.Lock()
if allowed.ToolsListChanged { delete(s.toolChangeSubscriptions, req.Session)
delete(s.toolChangeSubscriptions, req.Session) delete(s.promptChangeSubscriptions, req.Session)
} delete(s.resourceChangeSubscriptions, req.Session)
if allowed.PromptsListChanged {
delete(s.promptChangeSubscriptions, req.Session)
}
if allowed.ResourcesListChanged {
delete(s.resourceChangeSubscriptions, req.Session)
}
s.mu.Unlock() s.mu.Unlock()
}() }()
@ -1866,9 +1857,6 @@ func (s *Server) AddSendingMiddleware(middleware ...Middleware) {
// //
// Receiving middleware is called when a request is received. It is useful for tasks // Receiving middleware is called when a request is received. It is useful for tasks
// such as authentication, request logging and metrics. // such as authentication, request logging and metrics.
//
// A received message need not carry params: use [HasParams] before inspecting
// [Request.GetParams].
func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { func (s *Server) AddReceivingMiddleware(middleware ...Middleware) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@ -1998,6 +1986,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
} }
} }
default: default:
if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() {
ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
}
if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil {
ss.updateState(func(state *ServerSessionState) { ss.updateState(func(state *ServerSessionState) {
state.InitializeParams = validatedMeta.initializeParams state.InitializeParams = validatedMeta.initializeParams
@ -2005,16 +1997,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
} }
} }
// In legacy protocol versions, a client cannot send requests other than
// pings before the server has responded to the initialize request. A
// new-protocol request is exempt: a SEP-2575 session has no 'initialize'
// to wait for, and the request itself says which protocol it speaks.
if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() &&
req.Method != methodInitialize && req.Method != methodPing {
ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
}
// modelcontextprotocol/go-sdk#26: handle calls asynchronously, and // modelcontextprotocol/go-sdk#26: handle calls asynchronously, and
// notifications synchronously, except for 'initialize' which shouldn't be // notifications synchronously, except for 'initialize' which shouldn't be
// asynchronous to other // asynchronous to other
@ -2057,10 +2039,8 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
if err != nil { if err != nil {
return nil, err return nil, err
} }
// A middleware can return a typed nil value that satisfies the Result if validatedMeta.usesNewProtocol {
// interface. Annotating that value panics. setCompleteResultType(res)
if validatedMeta.usesNewProtocol && res != nil && !res.isNil() {
annotateResultType(res)
annotateServerInfo(res, ss.server.impl) annotateServerInfo(res, ss.server.impl)
} }
return res, nil return res, nil

View file

@ -677,7 +677,7 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) {
name: "primitive number (map-based schema)", name: "primitive number (map-based schema)",
outputSchema: map[string]any{"type": "number"}, outputSchema: map[string]any{"type": "number"},
content: 42.0, content: 42.0,
want: json.Number("42"), want: 42.0,
}, },
{ {
name: "primitive string (RawMessage schema)", name: "primitive string (RawMessage schema)",
@ -724,52 +724,6 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) {
} }
} }
func TestCallToolStructuredContentPreservesLargeInteger(t *testing.T) {
const want int64 = 9007199254740993
server := NewServer(testImpl, nil)
server.AddTool(&Tool{
Name: "large-integer",
InputSchema: &jsonschema.Schema{Type: "object"},
}, func(context.Context, *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{StructuredContent: map[string]any{"id": want}}, nil
})
clientTransport, serverTransport := NewInMemoryTransports()
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
if err != nil {
t.Fatal(err)
}
defer serverSession.Close()
client := NewClient(testImpl, nil)
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
if err != nil {
t.Fatal(err)
}
defer clientSession.Close()
result, err := clientSession.CallTool(context.Background(), &CallToolParams{Name: "large-integer"})
if err != nil {
t.Fatal(err)
}
structured, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("StructuredContent type = %T, want map[string]any", result.StructuredContent)
}
number, ok := structured["id"].(json.Number)
if !ok {
t.Fatalf("id type = %T, want json.Number", structured["id"])
}
got, err := number.Int64()
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("id = %d, want %d", got, want)
}
}
// TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic // TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic
// AddTool[In, Out] helper: Out may be a slice or primitive whose inferred // AddTool[In, Out] helper: Out may be a slice or primitive whose inferred
// JSON Schema has a non-object root. // JSON Schema has a non-object root.
@ -862,7 +816,7 @@ func TestAddToolGenericNonObjectOutput(t *testing.T) {
if res.IsError { if res.IsError {
t.Fatalf("unexpected tool error: %v", res.Content) t.Fatalf("unexpected tool error: %v", res.Content)
} }
if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" { if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" {
t.Errorf("structured content mismatch (-want +got):\n%s", diff) t.Errorf("structured content mismatch (-want +got):\n%s", diff)
} }
}) })
@ -1445,22 +1399,6 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) {
}) })
} }
// newProtocolParams merges fields into the _meta that opts a request into the
// sessionless protocol (SEP-2575).
func newProtocolParams(fields map[string]any) map[string]any {
params := map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260728,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
},
}
for k, v := range fields {
params[k] = v
}
return params
}
func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
server := NewServer(testImpl, &ServerOptions{ server := NewServer(testImpl, &ServerOptions{
CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) {
@ -1486,19 +1424,19 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
}, nil }, nil
}) })
AddTool(server, &Tool{Name: "toolComplete"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { newProtocolParams := func(fields map[string]any) map[string]any {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil params := map[string]any{
}) "_meta": map[string]any{
server.AddPrompt(&Prompt{Name: "promptComplete"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { MetaKeyProtocolVersion: protocolVersion20260728,
return &GetPromptResult{ MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "ok"}}}, MetaKeyClientCapabilities: map[string]any{},
}, nil },
}) }
server.AddResource(&Resource{URI: "test://resource-complete", Name: "resourceComplete"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { for k, v := range fields {
return &ReadResourceResult{ params[k] = v
Contents: []*ResourceContents{{URI: "test://resource-complete", Text: "ok"}}, }
}, nil return params
}) }
tests := []struct { tests := []struct {
name string name string
@ -1569,243 +1507,40 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) {
params: newProtocolParams(map[string]any{"uri": "test://resource"}), params: newProtocolParams(map[string]any{"uri": "test://resource"}),
want: resultTypeInputRequired, want: resultTypeInputRequired,
}, },
{
name: "tool complete",
method: methodCallTool,
params: newProtocolParams(map[string]any{"name": "toolComplete", "arguments": map[string]any{}}),
want: resultTypeComplete,
},
{
name: "prompt complete",
method: methodGetPrompt,
params: newProtocolParams(map[string]any{"name": "promptComplete"}),
want: resultTypeComplete,
},
{
name: "resource complete",
method: methodReadResource,
params: newProtocolParams(map[string]any{"uri": "test://resource-complete"}),
want: resultTypeComplete,
},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) {
t.Errorf("resultType = %q, want %q", got, tc.want)
}
})
}
}
// TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits is a
// regression test for #1225. User middleware wraps outside
// serverMultiRoundTripMiddleware, so a middleware that returns a result
// without calling next bypasses both that shim and the dispatcher.
func TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits(t *testing.T) {
tests := []struct {
name string
method string
params map[string]any
result Result
want resultType
}{
{
name: "tools/call",
method: methodCallTool,
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
result: &CallToolResult{Content: []Content{&TextContent{Text: "denied"}}},
want: resultTypeComplete,
},
{
name: "prompts/get",
method: methodGetPrompt,
params: newProtocolParams(map[string]any{"name": "prompt"}),
result: &GetPromptResult{
Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "denied"}}},
},
want: resultTypeComplete,
},
{
name: "resources/read",
method: methodReadResource,
params: newProtocolParams(map[string]any{"uri": "test://resource"}),
result: &ReadResourceResult{
Contents: []*ResourceContents{{URI: "test://resource", Text: "denied"}},
},
want: resultTypeComplete,
},
{
// setResultType is unexported, so populating InputRequests is
// the only way a middleware can ask for input.
name: "tools/call input required",
method: methodCallTool,
params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}),
result: &CallToolResult{
InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}},
},
want: resultTypeInputRequired,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Nothing is registered: if the middleware fell through to next,
// dispatch would fail rather than return a result.
server := NewServer(testImpl, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == tc.method {
return tc.result, nil
}
return next(ctx, method, req)
}
})
if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) {
t.Errorf("resultType = %q, want %q", got, tc.want)
}
})
}
}
// TestServerSessionHandle_ResultTypeGate pins when a result carries resultType.
// annotateResultType is the only setter, and handle gates it on the request's
// _meta rather than on the version the session negotiated. So the last case
// below gets no resultType even though the session speaks 2026-07-28. Only a
// client that mixes protocol versions reaches that, so it is recorded rather
// than fixed.
func TestServerSessionHandle_ResultTypeGate(t *testing.T) {
version := func(v string) func(*ServerSessionState) {
return func(s *ServerSessionState) {
s.InitializeParams = &InitializeParams{ProtocolVersion: v}
}
}
callParams := map[string]any{"name": "tool", "arguments": map[string]any{}}
tests := []struct {
name string
state func(*ServerSessionState)
params map[string]any
want resultType // "" means the field must be absent
}{
{
name: "legacy session, no _meta",
state: version(protocolVersion20251125),
params: callParams,
},
{
name: "legacy session, new-protocol _meta",
state: version(protocolVersion20251125),
params: newProtocolParams(callParams),
want: resultTypeComplete,
},
{
name: "new-protocol session, no _meta",
state: version(protocolVersion20260728),
params: callParams,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := NewServer(testImpl, nil)
// Short-circuit so this measures handle's gate, not the
// dispatcher's.
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(context.Context, string, Request) (Result, error) {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil
}
})
if got := resultTypeOf(t, server, tc.state, methodCallTool, tc.params); got != string(tc.want) {
t.Errorf("resultType = %q, want %q", got, tc.want)
}
})
}
}
// TestServerSessionHandle_NilResultFromMiddleware checks that a middleware
// returning no result does not panic. A typed nil satisfies Result, so the
// annotation helpers would otherwise dereference it.
func TestServerSessionHandle_NilResultFromMiddleware(t *testing.T) {
tests := []struct {
name string
result Result
}{
{"nil interface", nil},
{"typed nil", (*CallToolResult)(nil)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := NewServer(testImpl, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(context.Context, string, Request) (Result, error) {
return tc.result, nil
}
})
ss := &ServerSession{server: server} ss := &ServerSession{server: server}
id, err := jsonrpc.MakeID("test") id, err := jsonrpc.MakeID("test")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
res, err := ss.handle(context.Background(), &jsonrpc.Request{ result, err := ss.handle(context.Background(), &jsonrpc.Request{
ID: id, ID: id,
Method: methodCallTool, Method: tc.method,
Params: mustMarshal(newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}})), Params: mustMarshal(tc.params),
}) })
if err != nil {
t.Fatalf("handle: %v", err)
}
data, err := json.Marshal(res)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := string(data); got != "null" { data, err := json.Marshal(result)
t.Errorf("result = %s, want null", got) if err != nil {
t.Fatal(err)
}
var got struct {
ResultType string `json:"resultType"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if got.ResultType != string(tc.want) {
t.Fatalf("resultType = %q, want %q; response = %s", got.ResultType, tc.want, data)
} }
}) })
} }
} }
// resultTypeOf handles one request on a transportless session and reports the
// resultType of the marshaled result, or "" if the field is absent.
func resultTypeOf(t *testing.T, server *Server, state func(*ServerSessionState), method string, params map[string]any) string {
t.Helper()
ss := &ServerSession{server: server}
if state != nil {
ss.updateState(state)
}
id, err := jsonrpc.MakeID("test")
if err != nil {
t.Fatal(err)
}
result, err := ss.handle(context.Background(), &jsonrpc.Request{
ID: id,
Method: method,
Params: mustMarshal(params),
})
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
raw, ok := fields["resultType"]
if !ok {
return ""
}
var got string
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshaling resultType from %s: %v", data, err)
}
return got
}
// TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that // TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that
// the methods removed by SEP-2575 (`initialize`, `notifications/initialized`, // the methods removed by SEP-2575 (`initialize`, `notifications/initialized`,
// `ping`) all return Method not found when the request opts into the new // `ping`) all return Method not found when the request opts into the new
@ -2197,217 +1932,3 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) {
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
} }
} }
// TestServerHandle_LegacyCallBeforeInitialize asserts that a call carrying no
// new-protocol `_meta` is refused on a session that has not completed
// 'initialize', whichever branch of the method switch it lands in. The
// handshake itself is what ends that state, and the lifecycle spec exempts
// pings, so those two are served.
//
// The gate used to sit in the default branch, so 'logging/setLevel',
// 'resources/subscribe' and 'resources/unsubscribe', which share a case that
// only refuses new-protocol requests, were served before the handshake. For
// subscribe that registered a session that never handshook as a subscriber,
// so the subtest also asserts that the refusal leaves no subscriber and runs
// no handler.
func TestServerHandle_LegacyCallBeforeInitialize(t *testing.T) {
ctx := context.Background()
const uri = "test://resource"
tests := []struct {
name string
method string
params string
served bool
}{
{
name: "initialize",
method: methodInitialize,
params: fmt.Sprintf(`{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"c","version":"1"}}`, protocolVersion20251125),
served: true,
},
{name: "ping", method: methodPing, params: `{}`, served: true},
{name: "tools/list", method: methodListTools, params: `{}`},
{name: "logging/setLevel", method: methodSetLevel, params: `{"level":"info"}`},
{name: "resources/subscribe", method: methodSubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)},
{name: "resources/unsubscribe", method: methodUnsubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var handlerRan bool
server := NewServer(testImpl, &ServerOptions{
SubscribeHandler: func(context.Context, *SubscribeRequest) error {
handlerRan = true
return nil
},
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error {
handlerRan = true
return nil
},
})
_, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
res, err := ss.handle(ctx, &jsonrpc.Request{
ID: jsonrpc2.Int64ID(1),
Method: tc.method,
Params: json.RawMessage(tc.params),
})
if tc.served {
if err != nil {
t.Fatalf("handle(%q) before initialize returned %v, want it served", tc.method, err)
}
if res == nil {
t.Fatalf("handle(%q) before initialize returned no result", tc.method)
}
if tc.method == methodInitialize && ss.InitializeParams() == nil {
t.Errorf("initialize was answered without recording InitializeParams")
}
return
}
if err == nil {
t.Fatalf("handle(%q) served the call before initialize, want a refusal", tc.method)
}
if want := fmt.Sprintf("method %q is invalid during session initialization", tc.method); err.Error() != want {
t.Errorf("handle(%q) error = %q, want %q", tc.method, err.Error(), want)
}
if handlerRan {
t.Errorf("handle(%q) ran the server's handler for a call it refused", tc.method)
}
server.mu.Lock()
_, subscribed := server.resourceSubscriptions[uri][ss]
server.mu.Unlock()
if subscribed {
t.Errorf("handle(%q) left the session subscribed to %q", tc.method, uri)
}
})
}
}
// TestServerHandle_NewProtocolCallWithoutInitialize asserts that the gate
// stays off the SEP-2575 path. A fresh session that never sends 'initialize'
// is served a new-protocol 'tools/list', and the methods the gate now covers
// are answered, when a new-protocol request names them, with the error the
// new protocol already gave them (the method does not exist there), and not
// with the initialization refusal.
func TestServerHandle_NewProtocolCallWithoutInitialize(t *testing.T) {
ctx := context.Background()
const uri = "test://resource"
tests := []struct {
name string
method string
params map[string]any
served bool
}{
{name: "tools/list", method: methodListTools, params: newProtocolParams(nil), served: true},
{name: "logging/setLevel", method: methodSetLevel, params: newProtocolParams(map[string]any{"level": "info"})},
{name: "resources/subscribe", method: methodSubscribe, params: newProtocolParams(map[string]any{"uri": uri})},
{name: "resources/unsubscribe", method: methodUnsubscribe, params: newProtocolParams(map[string]any{"uri": uri})},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := NewServer(testImpl, &ServerOptions{
SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil },
UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil },
})
_, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
params, err := json.Marshal(tc.params)
if err != nil {
t.Fatalf("marshalling params: %v", err)
}
res, err := ss.handle(ctx, &jsonrpc.Request{
ID: jsonrpc2.Int64ID(1),
Method: tc.method,
Params: params,
})
if tc.served {
if err != nil {
t.Fatalf("handle(%q) on a new-protocol session returned %v, want it served", tc.method, err)
}
if res == nil {
t.Fatalf("handle(%q) on a new-protocol session returned no result", tc.method)
}
return
}
var jerr *jsonrpc.Error
if !errors.As(err, &jerr) {
t.Fatalf("handle(%q) returned %v, want a *jsonrpc.Error", tc.method, err)
}
if jerr.Code != jsonrpc.CodeMethodNotFound {
t.Errorf("handle(%q) error code = %d, want %d (removed in the new protocol)", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound)
}
if strings.Contains(err.Error(), "invalid during session initialization") {
t.Errorf("handle(%q) applied the initialization gate to a new-protocol request: %v", tc.method, err)
}
})
}
}
// TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose
// `_meta.protocolVersion` names a version the SDK does not know is rejected
// with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake
// because its version string happens to sort below 2026-07-28.
func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) {
ctx := context.Background()
// Each case names a version that is not in [supportedProtocolVersions] and
// sorts below 2026-07-28.
tests := []struct {
name string
version string
extraMeta string // further `_meta` entries, each with a leading comma
}{
// The versioning spec uses 1900-01-01 to illustrate this error.
{"documented example", "1900-01-01", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)},
{"unreleased revision", "2025-11-24", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)},
{"not a date", "1.0", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)},
// The rest of the `_meta` triple is defined by the revision the request
// names, so its absence must not mask the version itself.
{"no clientCapabilities", "1900-01-01", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := NewServer(testImpl, nil)
_, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()
params := fmt.Sprintf(`{"_meta":{%q:%q%s}}`, MetaKeyProtocolVersion, tc.version, tc.extraMeta)
_, err = ss.handle(ctx, &jsonrpc.Request{
ID: jsonrpc2.Int64ID(1),
Method: methodListTools,
Params: json.RawMessage(params),
})
var jerr *jsonrpc.Error
if !errors.As(err, &jerr) {
t.Fatalf("handle returned %v, want a *jsonrpc.Error", err)
}
if jerr.Code != CodeUnsupportedProtocolVersion {
t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion)
}
var data UnsupportedProtocolVersionData
if err := json.Unmarshal(jerr.Data, &data); err != nil {
t.Fatalf("unmarshal error data: %v", err)
}
if data.Requested != tc.version {
t.Errorf("UnsupportedProtocolVersionData.Requested = %q, want %q", data.Requested, tc.version)
}
if diff := cmp.Diff(SupportedProtocolVersions(), data.Supported); diff != "" {
t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff)
}
})
}
}

View file

@ -553,19 +553,13 @@ type validatedMeta struct {
// validateRequestMeta inspects a JSON-RPC request to detect whether it follows // validateRequestMeta inspects a JSON-RPC request to detect whether it follows
// the >= 2026-07-28 protocol via the `_meta` field. // the >= 2026-07-28 protocol via the `_meta` field.
// If the request has no _meta, no protocolVersion in _meta, or a protocolVersion // If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil
// naming a supported version that predates 2026-07-28, it returns a non-nil // validatedMeta with usesNewProtocol set to false, and a nil error.
// validatedMeta with usesNewProtocol set to false, and a nil error: such a request // If the request has a protocolVersion in _meta it validates the presence of
// belongs to the initialize handshake. // clientCapabilities in _meta. If it is missing or invalid, it returns nil and
// If the protocolVersion names a version that is not supported at all, it returns // a non-nil error. clientInfo is optional; if present but invalid, an error is
// usesNewProtocol set to true with initializeParams carrying only that version, so // returned. Otherwise, it returns usesNewProtocol set to true and the populated
// that the caller answers with [CodeUnsupportedProtocolVersion]. The rest of the // initializeParams.
// `_meta` triple is not validated in that case, since the revision defining it is
// unknown.
// Otherwise, it validates the presence of clientCapabilities in _meta. If it is
// missing or invalid, it returns nil and a non-nil error. clientInfo is optional;
// if present but invalid, an error is returned. Otherwise, it returns
// usesNewProtocol set to true and the populated initializeParams.
// Notifications always report usesNewProtocol false and never error. // Notifications always report usesNewProtocol false and never error.
func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
// SEP-2575 defines the `_meta` triple for calls only: NotificationParams // SEP-2575 defines the `_meta` triple for calls only: NotificationParams
@ -580,26 +574,9 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
} }
protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) protocolVersion, ok := meta[MetaKeyProtocolVersion].(string)
// An absent or empty value declares no version at all, which is also how the if !ok || protocolVersion < protocolVersion20260728 {
// streamable handler reads `_meta.protocolVersion`.
if !ok || protocolVersion == "" {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
} }
// Classify by membership rather than by ordering: a version the SDK does not
// know sorts arbitrarily against 2026-07-28, and one that sorts below it would
// be served as a legacy handshake instead of reaching the
// CodeUnsupportedProtocolVersion check in ServerSession.handle. The
// streamable handler already applies the same membership rule to the
// Mcp-Protocol-Version header.
supported := slices.Contains(supportedProtocolVersions, protocolVersion)
if supported && protocolVersion < protocolVersion20260728 {
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
}
if !supported {
return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{
ProtocolVersion: protocolVersion,
}}, nil
}
var clientInfo *Implementation var clientInfo *Implementation
if _, present := meta[MetaKeyClientInfo]; present { if _, present := meta[MetaKeyClientInfo]; present {
var ok bool var ok bool
@ -688,30 +665,6 @@ func (r *ServerRequest[P]) GetParams() Params { return r.Params }
func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil } func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil }
func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra }
// HasParams reports whether req carries params.
//
// The "params" member of a message may be omitted for several methods, among
// them the list methods, ping and notifications/initialized. For such a request
// [Request.GetParams] returns a non-nil [Params] holding a nil pointer, so
// comparing its result against nil reports that params are present, while
// calling a method on that result panics. Middleware that inspects params must
// ask this first:
//
// if mcp.HasParams(req) {
// log.Println(method, req.GetParams().GetMeta())
// }
func HasParams(req Request) bool {
params := req.GetParams()
if params == nil {
return false
}
// isNil cannot answer this for every value: a params type embedding
// ParamsBase promotes isNil through a field selector, which dereferences
// the nil outer pointer before the method body runs.
v := reflect.ValueOf(params)
return v.Kind() != reflect.Pointer || !v.IsNil()
}
// ProtocolVersion returns the protocol version negotiated for this request. // ProtocolVersion returns the protocol version negotiated for this request.
// //
// For requests following the >= 2026-07-28 protocol, the value is read from // For requests following the >= 2026-07-28 protocol, the value is read from
@ -773,7 +726,8 @@ func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities {
// getRequestMeta returns the raw `_meta` map from the request's params, or // getRequestMeta returns the raw `_meta` map from the request's params, or
// nil if the params are absent. // nil if the params are absent.
func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any { func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any {
if !HasParams(r) { // In practice P is a pointer type implementing Params.
if any(r.Params) == nil || r.Params.isNil() {
return nil return nil
} }
return r.Params.GetMeta() return r.Params.GetMeta()
@ -855,9 +809,6 @@ type Result interface {
// isResult discourages implementation of Result outside of this package. // isResult discourages implementation of Result outside of this package.
isResult() isResult()
// isNil returns true if the underlying value is nil.
isNil() bool
// GetMeta returns metadata from a value. // GetMeta returns metadata from a value.
GetMeta() map[string]any GetMeta() map[string]any
// SetMeta sets the metadata on a value. // SetMeta sets the metadata on a value.
@ -876,15 +827,13 @@ type ResultBase struct {
Meta `json:"_meta,omitempty"` Meta `json:"_meta,omitempty"`
} }
func (*ResultBase) isResult() {} func (*ResultBase) isResult() {}
func (x *ResultBase) isNil() bool { return x == nil }
// emptyResult is returned by methods that have no result, like ping. // emptyResult is returned by methods that have no result, like ping.
// Those methods cannot return nil, because jsonrpc2 cannot handle nils. // Those methods cannot return nil, because jsonrpc2 cannot handle nils.
type emptyResult struct{} type emptyResult struct{}
func (*emptyResult) isResult() {} func (*emptyResult) isResult() {}
func (x *emptyResult) isNil() bool { return x == nil }
func (*emptyResult) GetMeta() map[string]any { panic("should never be called") } func (*emptyResult) GetMeta() map[string]any { panic("should never be called") }
func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") } func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") }

View file

@ -102,57 +102,6 @@ func TestValidateRequestMeta(t *testing.T) {
params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`), params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`),
wantUsesNew: false, wantUsesNew: false,
}, },
{
name: "supported pre-2026-07-28 version: old protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20251125,
},
"name": "x",
},
wantUsesNew: false,
},
{
name: "empty protocolVersion declares nothing: old protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: "",
},
"name": "x",
},
wantUsesNew: false,
},
{
// An unsupported version sorting below 2026-07-28 is still a
// new-protocol request, so that the caller can answer it with
// CodeUnsupportedProtocolVersion.
name: "unsupported version: new protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: "1900-01-01",
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: true,
},
{
// The triple is defined by a revision the SDK does not know, so an
// unsupported version is reported without validating the rest of it.
name: "unsupported version missing clientCapabilities: new protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: "1900-01-01",
},
"name": "x",
},
wantUsesNew: true,
},
{ {
name: "new protocol with logLevel", name: "new protocol with logLevel",
method: methodCallTool, method: methodCallTool,
@ -324,62 +273,6 @@ func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) {
} }
} }
func TestHasParams(t *testing.T) {
// {"jsonrpc":"2.0","id":1,"method":"tools/list"} is a complete request:
// serverMethodInfos marks these methods missingParamsOK. Build what
// handleReceive hands the middleware chain for one.
for _, method := range []string{
methodListTools,
methodListPrompts,
methodListResources,
notificationInitialized,
} {
t.Run(method, func(t *testing.T) {
info := serverMethodInfos[method]
params, err := info.unmarshalParams(nil)
if err != nil {
t.Fatal(err)
}
req := info.newRequest(&ServerSession{}, params, nil)
if req.GetParams() == nil {
t.Fatal("GetParams returned a nil interface, so this test no longer covers the typed-nil case")
}
if HasParams(req) {
t.Error("HasParams = true for a request whose params member was omitted")
}
})
}
t.Run("params present", func(t *testing.T) {
info := serverMethodInfos[methodListTools]
params, err := info.unmarshalParams(json.RawMessage(`{"cursor":"c"}`))
if err != nil {
t.Fatal(err)
}
req := info.newRequest(&ServerSession{}, params, nil)
if !HasParams(req) {
t.Error("HasParams = false for a request carrying params")
}
})
}
func TestServerRequest_PerRequestAccessors_MissingParams(t *testing.T) {
// Custom methods also allow the params member to be omitted, and their
// params types embed ParamsBase, whose promoted isNil dereferences the
// typed-nil outer pointer.
type customParams struct{ ParamsBase }
req := &ServerRequest[*customParams]{}
if got := req.ProtocolVersion(); got != "" {
t.Errorf("ProtocolVersion = %q, want empty", got)
}
if got := req.ClientInfo(); got != nil {
t.Errorf("ClientInfo = %+v, want nil", got)
}
if got := req.ClientCapabilities(); got != nil {
t.Errorf("ClientCapabilities = %+v, want nil", got)
}
}
func TestImplementationDescriptionJSON(t *testing.T) { func TestImplementationDescriptionJSON(t *testing.T) {
impl := &Implementation{ impl := &Implementation{
Name: "greeter", Name: "greeter",

Some files were not shown because too many files have changed in this diff Show more