Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ non-ASCII values). On the server, the headers are validated against the body
and either accepted or rejected with `-32020`. Properties so annotated must
be typed `string`, `integer`, or `boolean`; tools that fail validation at
registration time are silently dropped from `tools/list`.
If a `tools/call` is rejected with `-32020`, `ClientSession.CallTool` fetches
the tool's current definition with `tools/list` and retries once if the tool's `x-mcp-header` annotations changed.

#### Resumability and Redelivery

Expand Down
2 changes: 2 additions & 0 deletions internal/docs/protocol.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ non-ASCII values). On the server, the headers are validated against the body
and either accepted or rejected with `-32020`. Properties so annotated must
be typed `string`, `integer`, or `boolean`; tools that fail validation at
registration time are silently dropped from `tools/list`.
If a `tools/call` is rejected with `-32020`, `ClientSession.CallTool` fetches
the tool's current definition with `tools/list` and retries once if the tool's `x-mcp-header` annotations changed.

#### Resumability and Redelivery

Expand Down
59 changes: 55 additions & 4 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"maps"
"reflect"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
Expand Down Expand Up @@ -1228,6 +1229,10 @@ func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams)
// CallTool calls the tool with the given parameters.
//
// The params.Arguments can be any value that marshals into a JSON object.
//
// If the server rejects the call with [CodeHeaderMismatch], CallTool refreshes
// its definition of the tool with tools/list and retries once if the tool's
// x-mcp-header annotations changed.
func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) (*CallToolResult, error) {
if params == nil {
params = new(CallToolParams)
Expand All @@ -1238,13 +1243,59 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) (
params = &copy
params.Arguments = map[string]any{}
}
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)))

call := func(tool *Tool) (*CallToolResult, error) {
callCtx := ctx
if tool != nil {
callCtx = context.WithValue(ctx, toolContextKey, tool)
}
return handleSend[*CallToolResult](callCtx, methodCallTool, newClientRequest(cs, orZero[Params](params)))
}

tool := cs.lookupTool(params.Name)
res, err := call(tool)
var wireErr *jsonrpc.Error
if cs.usesNewProtocol() && errors.As(err, &wireErr) && wireErr.Code == CodeHeaderMismatch {
if fresh := cs.refreshTool(ctx, params.Name); fresh != nil && !sameParamHeaders(tool, fresh) {
return call(fresh)
}
}
return res, err
}

// refreshTool clears the tools cache and re-lists tools until it finds the
// named tool. It returns nil if the tool is not found or listing fails.
func (cs *ClientSession) refreshTool(ctx context.Context, name string) *Tool {
cs.toolsCache.invalidate()
for t, err := range cs.Tools(ctx, nil) {
if err != nil {
return nil
}
if t.Name == name {
return t
}
}
return nil
}

// sameParamHeaders reports whether tool1 and tool2 produce the same
// Mcp-Param-* headers for the same arguments.
func sameParamHeaders(tool1, tool2 *Tool) bool {
return maps.EqualFunc(paramHeaderPaths(tool1), paramHeaderPaths(tool2), slices.Equal)
}

func paramHeaderPaths(tool *Tool) map[string][]string {
if tool == nil {
return nil
}
m := make(map[string][]string)
for _, binding := range extractParamHeaderAnnotations(tool) {
m[strings.ToLower(binding.Header)] = binding.Path
}
return m
}

// SetLoggingLevel sets the minimum severity level for log messages sent by
Expand Down
132 changes: 132 additions & 0 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,138 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
}
}

// TestCallToolFallbackOnHeaderMismatch verifies that CallTool refreshes a
// missing or stale tool definition and retries once when the server rejects
// the call with CodeHeaderMismatch.
func TestCallToolFallbackOnHeaderMismatch(t *testing.T) {
ctx := t.Context()

server := NewServer(&Implementation{Name: "s", Version: "v1"}, nil)
server.AddTool(
&Tool{
Name: "get_weather",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"region": map[string]any{
"type": "string",
"x-mcp-header": "Region"},
},
},
},
func(context.Context, *CallToolRequest) (*CallToolResult, error) {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil
},
)
handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{
Stateless: true,
})
defer handler.closeAll()
httpServer := httptest.NewServer(mustNotPanic(t, handler))
defer httpServer.Close()

tests := []struct {
name string
cached *Tool
listFirst bool
wantRequests []string
}{
{
name: "empty cache",
wantRequests: []string{"tools/call Region=", "tools/list", "tools/call Region=us-east4"},
},
{
name: "cached without annotation",
cached: &Tool{Name: "get_weather", InputSchema: map[string]any{"type": "object"}},
wantRequests: []string{"tools/call Region=", "tools/list", "tools/call Region=us-east4"},
},
{
name: "cache up to date",
listFirst: true,
wantRequests: []string{"tools/call Region=us-east4"},
},
{
name: "cached with renamed header",
cached: &Tool{
Name: "get_weather",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"region": map[string]any{
"type": "string",
"x-mcp-header": "Zone"},
},
},
},
wantRequests: []string{"tools/call Region=", "tools/list", "tools/call Region=us-east4"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
mu sync.Mutex
requests []string
)
httpClient := &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch method := req.Header.Get(methodHeader); method {
case methodCallTool:
mu.Lock()
requests = append(requests, method+" Region="+req.Header.Get(paramHeaderPrefix+"Region"))
mu.Unlock()
case methodListTools:
mu.Lock()
requests = append(requests, method)
mu.Unlock()
}
return http.DefaultTransport.RoundTrip(req)
}),
}

client := NewClient(&Implementation{Name: "c", Version: "v1"}, nil)
session, err := client.Connect(ctx,
&StreamableClientTransport{Endpoint: httpServer.URL, HTTPClient: httpClient},
&ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders},
)
if err != nil {
t.Fatal(err)
}
defer session.Close()

if tt.listFirst {
if _, err := session.ListTools(ctx, nil); err != nil {
t.Fatal(err)
}
}

if tt.cached != nil {
session.toolsCache.put("", &ListToolsResult{
Cacheable: Cacheable{TTLMs: 60_000},
Tools: []*Tool{tt.cached},
})
}
mu.Lock()
requests = nil
mu.Unlock()
_, err = session.CallTool(ctx, &CallToolParams{
Name: "get_weather",
Arguments: map[string]any{"region": "us-east4"},
})
if err != nil {
t.Errorf("CallTool() failed: %v", err)
}

mu.Lock()
got := requests
mu.Unlock()
if diff := cmp.Diff(tt.wantRequests, got); diff != "" {
t.Errorf("requests mismatch (-want +got): \n%s", diff)
}
})
}
}

// TestStreamableFilterValidToolsIntegration verifies that AddTool rejects
// tools with invalid x-mcp-header annotations at registration time, and
// that valid tools are returned by ListTools.
Expand Down
Loading