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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 56 additions & 53 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
}
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Comment on lines -1677 to -1679

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why not removing this call from all the other client send methods

return handleSend[R](ctx, method, &ClientRequest[P]{
Session: cs,
Params: params,
Expand Down
115 changes: 113 additions & 2 deletions mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading