diff --git a/mcp/client.go b/mcp/client.go index 74037990..e162bae5 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -515,21 +515,26 @@ func (cs *ClientSession) usesNewProtocol() bool { return res != nil && res.ProtocolVersion >= protocolVersion20260728 } -// injectRequestMeta populates the SEP-2575 per-request `_meta` fields -// (protocolVersion, optional clientInfo, clientCapabilities) on the given -// outgoing request params. Keys already present in params.Meta are not -// overwritten. Per PR modelcontextprotocol/modelcontextprotocol#3002 -// clientInfo is SHOULD (not MUST), and is omitted when the client has no -// [Implementation] configured. -func injectRequestMeta[T any, P interface { - *T - Params -}](cs *ClientSession, params P) P { - res := cs.state.InitializeResult +func clientMethodUsesLegacyCompatPath(method string) bool { + switch method { + case methodPing, + methodSetLevel, + notificationRootsListChanged, + methodSubscribe, + methodUnsubscribe: + return true + default: + return false + } +} + +func injectRequestMetaParams(cs *ClientSession, params Params) Params { if params == nil { - params = new(T) + return nil } + res := cs.state.InitializeResult m := params.GetMeta() + m = maps.Clone(m) if m == nil { m = map[string]any{} } @@ -1222,7 +1227,7 @@ func newClientRequest[P Params](cs *ClientSession, params P) *ClientRequest[P] { // Ping makes an MCP "ping" request to the server. func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { - _, err := handleSend[*emptyResult](ctx, methodPing, newClientRequest(cs, orZero[Params](params))) + _, err := handleSend[*emptyResult](ctx, methodPing, newClientRequest(cs, params)) return err } @@ -1235,24 +1240,24 @@ func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsPar if result, ok := cachedListResult(&cs.promptsCache, params); ok { return result, nil } - params = injectRequestMeta(cs, params) } - result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + cursor := "" + if params != nil { + cursor = params.Cursor + } + result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, params)) if err != nil { return nil, err } if cs.usesNewProtocol() { - cs.promptsCache.put(params.Cursor, result) + cs.promptsCache.put(cursor, result) } return result, nil } // GetPrompt gets a prompt from the server. func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) { - if cs.usesNewProtocol() { - params = injectRequestMeta(cs, params) - } - return handleSend[*GetPromptResult](ctx, methodGetPrompt, newClientRequest(cs, orZero[Params](params))) + return handleSend[*GetPromptResult](ctx, methodGetPrompt, newClientRequest(cs, params)) } // ListTools lists tools that are currently available on the server. @@ -1261,15 +1266,18 @@ func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) if result, ok := cachedListResult(&cs.toolsCache, params); ok { return result, nil } - params = injectRequestMeta(cs, params) } - result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) + cursor := "" + if params != nil { + cursor = params.Cursor + } + result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, params)) if err != nil { return nil, err } result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) if cs.usesNewProtocol() { - cs.toolsCache.put(params.Cursor, result) + cs.toolsCache.put(cursor, result) } return result, nil } @@ -1288,10 +1296,7 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( if tool := cs.lookupTool(params.Name); tool != nil { ctx = context.WithValue(ctx, toolContextKey, tool) } - if cs.usesNewProtocol() { - params = injectRequestMeta(cs, params) - } - return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) + return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, params)) } // SetLoggingLevel sets the minimum severity level for log messages sent by @@ -1303,7 +1308,7 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( // servers) or OpenTelemetry. See // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLoggingLevelParams) error { - _, err := handleSend[*emptyResult](ctx, methodSetLevel, newClientRequest(cs, orZero[Params](params))) + _, err := handleSend[*emptyResult](ctx, methodSetLevel, newClientRequest(cs, params)) return err } @@ -1313,14 +1318,17 @@ func (cs *ClientSession) ListResources(ctx context.Context, params *ListResource if result, ok := cachedListResult(&cs.resourcesCache, params); ok { return result, nil } - params = injectRequestMeta(cs, params) } - result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + cursor := "" + if params != nil { + cursor = params.Cursor + } + result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, params)) if err != nil { return nil, err } if cs.usesNewProtocol() { - cs.resourcesCache.put(params.Cursor, result) + cs.resourcesCache.put(cursor, result) } return result, nil } @@ -1331,52 +1339,51 @@ func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *List if result, ok := cachedListResult(&cs.resourceTemplatesCache, params); ok { return result, nil } - params = injectRequestMeta(cs, params) } - result, err := handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + cursor := "" + if params != nil { + cursor = params.Cursor + } + result, err := handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, params)) if err != nil { return nil, err } if cs.usesNewProtocol() { - cs.resourceTemplatesCache.put(params.Cursor, result) + cs.resourceTemplatesCache.put(cursor, result) } return result, nil } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { + uri := "" + if params != nil { + uri = params.URI + } if cs.usesNewProtocol() { - var uri string - if params != nil { - uri = params.URI - } if result, ok := cs.readResourceCache.get(uri); ok { return result, nil } - params = injectRequestMeta(cs, params) } - result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, params)) if err != nil { return nil, err } if cs.usesNewProtocol() { - cs.readResourceCache.put(params.URI, result) + cs.readResourceCache.put(uri, result) } return result, nil } func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) { - if cs.usesNewProtocol() { - params = injectRequestMeta(cs, params) - } - return handleSend[*CompleteResult](ctx, methodComplete, newClientRequest(cs, orZero[Params](params))) + return handleSend[*CompleteResult](ctx, methodComplete, newClientRequest(cs, params)) } // Subscribe sends a "resources/subscribe" request to the server, asking for // notifications when the specified resource changes. func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) error { if !cs.usesNewProtocol() { - _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params))) + _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, params)) return err } if params == nil || params.URI == "" { @@ -1416,7 +1423,7 @@ func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) // a URI that is not currently subscribed is a no-op. func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error { if !cs.usesNewProtocol() { - _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params))) + _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, params)) return err } if params == nil || params.URI == "" { @@ -1451,8 +1458,7 @@ func (cs *ClientSession) cancelAllResourceSubscriptions() { // subsequent opted-in notifications (e.g. tools/list_changed) are delivered through the // usual handlers registered in [ClientOptions]. func (cs *ClientSession) subscriptionsListen(ctx context.Context, params *SubscriptionsListenParams) error { - params = injectRequestMeta(cs, params) - _, err := handleSend[*SubscriptionsListenResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params))) + _, err := handleSend[*SubscriptionsListenResult](ctx, methodSubscriptionsListen, newClientRequest(cs, params)) return err } @@ -1541,7 +1547,7 @@ func (c *Client) callElicitationCompleteHandler(ctx context.Context, req *Elicit // This can be used if the client is performing a long-running task that was // initiated by the server. func (cs *ClientSession) NotifyProgress(ctx context.Context, params *ProgressNotificationParams) error { - return handleNotify(ctx, notificationProgress, newClientRequest(cs, orZero[Params](params))) + return handleNotify(ctx, notificationProgress, newClientRequest(cs, params)) } // Tools provides an iterator for all tools available on the server, @@ -1674,9 +1680,6 @@ func CallCustomMethod[P paramsPtr[PT], R Result, PT any]( var zero R return zero, fmt.Errorf("mcp: CallCustomMethod: %q is not registered; call AddSendingCustomMethod first", method) } - if cs.usesNewProtocol() { - params = injectRequestMeta(cs, params) - } return handleSend[R](ctx, method, &ClientRequest[P]{ Session: cs, Params: params, diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 8e2f06d3..b19b5860 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "net/http/httptest" "net/url" @@ -2166,6 +2167,57 @@ func TestNoDistributedDeadlock(t *testing.T) { }) } +func TestClientNotifyProgressInjectsMetaOnNewProtocol(t *testing.T) { + ctx := context.Background() + metaCh := make(chan Meta, 1) + + server := NewServer(testImpl, &ServerOptions{ + ProgressNotificationHandler: func(_ context.Context, req *ProgressNotificationServerRequest) { + metaCh <- maps.Clone(req.Params.Meta) + }, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + if err := cs.NotifyProgress(ctx, &ProgressNotificationParams{ + ProgressToken: "tok-1", + Progress: 1, + Message: "working", + }); err != nil { + t.Fatalf("NotifyProgress: %v", err) + } + + select { + case meta := <-metaCh: + if got, want := meta[MetaKeyProtocolVersion], any(protocolVersion20260728); got != want { + t.Fatalf("_meta[%s] = %v, want %v", MetaKeyProtocolVersion, got, want) + } + if _, ok := meta[MetaKeyClientCapabilities].(map[string]any); !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientCapabilities, meta[MetaKeyClientCapabilities]) + } + info, ok := meta[MetaKeyClientInfo].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientInfo, meta[MetaKeyClientInfo]) + } + if got, want := info["name"], any(testImpl.Name); got != want { + t.Fatalf("clientInfo.name = %v, want %v", got, want) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for progress notification") + } +} + var testImpl = &Implementation{Name: "test", Version: "v1.0.0"} // This test checks that when we use pointer types for tools, we get the same @@ -3460,10 +3512,69 @@ func TestAddCustomMethodRejectsStandardMethods(t *testing.T) { }) } +func TestCallCustomMethodInjectsMetaOnNewProtocol(t *testing.T) { + type pingParams struct{ ParamsBase } + type pingResult struct{ ResultBase } + + ctx := context.Background() + metaCh := make(chan Meta, 1) + s := NewServer(testImpl, nil) + if err := AddReceivingCustomMethod(s, "acme/ping", + func(ctx context.Context, ss *ServerSession, p *pingParams) (*pingResult, error) { + metaCh <- maps.Clone(p.Meta) + return &pingResult{}, nil + }); err != nil { + t.Fatal(err) + } + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*pingParams, *pingResult](c, "acme/ping"); err != nil { + t.Fatal(err) + } + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + params := &pingParams{ParamsBase: ParamsBase{Meta: Meta{"keep": "original"}}} + if _, err := CallCustomMethod[*pingParams, *pingResult](ctx, cs, "acme/ping", params); err != nil { + t.Fatalf("CallCustomMethod: %v", err) + } + if _, ok := params.Meta[MetaKeyProtocolVersion]; ok { + t.Fatalf("CallCustomMethod mutated caller params Meta: %v", params.Meta) + } + + select { + case meta := <-metaCh: + if got, want := meta["keep"], any("original"); got != want { + t.Fatalf("_meta[keep] = %v, want %v", got, want) + } + if got, want := meta[MetaKeyProtocolVersion], any(protocolVersion20260728); got != want { + t.Fatalf("_meta[%s] = %v, want %v", MetaKeyProtocolVersion, got, want) + } + if _, ok := meta[MetaKeyClientCapabilities].(map[string]any); !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientCapabilities, meta[MetaKeyClientCapabilities]) + } + if _, ok := meta[MetaKeyClientInfo].(map[string]any); !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientInfo, meta[MetaKeyClientInfo]) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for custom method") + } +} + // TestCallCustomMethodTypedNilParams exercises the typed-nil params path. // User param types embed ParamsBase, so the inherited isNil forwarder would -// dereference a typed-nil outer if injectRequestMeta were called with it. -// CallCustomMethod must allocate a fresh value before the meta-injection step. +// dereference a typed-nil outer if the default sending handler inspected it +// without first cloning it. The handler must allocate a fresh value before +// the meta-injection step. func TestCallCustomMethodTypedNilParams(t *testing.T) { type pingParams struct{ ParamsBase } type pingResult struct{ ResultBase } diff --git a/mcp/shared.go b/mcp/shared.go index 5069a470..f7a6ee7b 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -123,6 +123,16 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request return nil, jsonrpc2.ErrNotHandled } params := req.GetParams() + if cs, ok := req.GetSession().(*ClientSession); ok { + if cs.usesNewProtocol() && params != nil && !clientMethodUsesLegacyCompatPath(method) { + params = cloneParams(params) + params = injectRequestMetaParams(cs, params) + } else if params != nil && params.isNil() { + // Keep nil optional params omitted from the wire for legacy and + // compatibility methods; a typed nil otherwise marshals as JSON null. + params = nil + } + } if initParams, ok := params.(*InitializeParams); ok { // Fix the marshaling of initialize params, to work around #607. // @@ -156,6 +166,22 @@ func orZero[T any, P *U, U any](p P) T { return any(p).(T) } +func cloneParams(params Params) Params { + if params == nil { + return nil + } + value := reflect.ValueOf(params) + if value.Kind() != reflect.Pointer { + return params + } + if value.IsNil() { + return reflect.New(value.Type().Elem()).Interface().(Params) + } + clone := reflect.New(value.Elem().Type()) + clone.Elem().Set(value.Elem()) + return clone.Interface().(Params) +} + func handleNotify(ctx context.Context, method string, req Request) error { mh := req.GetSession().sendingMethodHandler() _, err := mh(ctx, method, req)