Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f00d0f4
refactor: centralize MCP headers and add support for validating stand…
guglielmo-san Apr 24, 2026
d00288d
fix
guglielmo-san Apr 24, 2026
57659c0
formatter
guglielmo-san Apr 24, 2026
017e0fc
Merge branch 'main' into guglielmoc/SEP-2243_http_standardization
guglielmo-san Apr 24, 2026
604f2d4
fix after merge
guglielmo-san Apr 24, 2026
7df5ab6
refactor: decouple standard header population from setMCPHeaders in s…
guglielmo-san Apr 24, 2026
9de3bec
feat: implement SEP-2243 header encoding and support Mcp-Param- heade…
guglielmo-san Apr 26, 2026
f429bc5
feat: implement validation for tool-specific parameter headers in MCP…
guglielmo-san Apr 26, 2026
ad17562
feat: implement base64 header decoding and standardize primitive valu…
guglielmo-san Apr 26, 2026
a4e1e23
refactor: simplify MCP header logic and remove redundant tool cache a…
guglielmo-san Apr 27, 2026
aeada36
refactor: prohibit x-mcp-header annotations on nested object properti…
guglielmo-san Apr 27, 2026
b223143
refactor: rename mcp_http_headers to streamable_headers
guglielmo-san Apr 29, 2026
005d33d
Merge remote-tracking branch 'origin/main' into guglielmoc/SEP-2243_h…
guglielmo-san Apr 29, 2026
24cc607
feat: enable MCP parameter headers and add validation tests using int…
guglielmo-san Apr 29, 2026
9dd6907
fix: add mutex protection to toolCache and provide thread-safe access…
guglielmo-san Apr 30, 2026
8d4e94d
test: remove assertion for non-annotated query parameter headers in s…
guglielmo-san Apr 30, 2026
d8c5a76
Merge branch 'main' into guglielmoc/SEP-2243_http_standardization_2
guglielmo-san Apr 30, 2026
e754ff7
refactor: remove redundant blank line in streamable_headers.go
guglielmo-san Apr 30, 2026
87093cb
Merge remote-tracking branch 'refs/remotes/origin/guglielmoc/SEP-2243…
guglielmo-san Apr 30, 2026
04652ee
merge tests
guglielmo-san Apr 30, 2026
d7ef2c1
minor fix
guglielmo-san Apr 30, 2026
c218557
Merge branch 'main' into guglielmoc/SEP-2243_http_standardization_2
guglielmo-san May 4, 2026
e83292b
refactor: inline header encoding logic and update tool parameter head…
guglielmo-san May 5, 2026
9bdd1df
refactor: remove unused mcpHeaderExtension constant from streamable h…
guglielmo-san May 5, 2026
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
39 changes: 38 additions & 1 deletion mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ type ClientOptions struct {
KeepAlive time.Duration
}

// toolContextKeyType is the context key type for passing tool definitions
// from CallTool to the transport layer.
type toolContextKeyType struct{}

var toolContextKey = toolContextKeyType{}

// bind implements the binder[*ClientSession] interface, so that Clients can
// be connected using [connect].
func (c *Client) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *clientSessionState, onClose func()) *ClientSession {
Expand Down Expand Up @@ -318,6 +324,13 @@ type ClientSession struct {
// Pending URL elicitations waiting for completion notifications.
pendingElicitationsMu sync.Mutex
pendingElicitations map[string]chan struct{}

// toolCacheMu guards toolCache.
toolCacheMu sync.RWMutex
// toolCache stores tool definitions keyed by name.
// It is used to look up x-mcp-header annotations when
// constructing Mcp-Param-* headers for tools/call requests.
toolCache map[string]*Tool
}

type clientSessionState struct {
Expand Down Expand Up @@ -363,6 +376,21 @@ func (cs *ClientSession) Wait() error {
return cs.conn.Wait()
}

func (cs *ClientSession) cacheTools(tools []*Tool) {
cs.toolCacheMu.Lock()
defer cs.toolCacheMu.Unlock()
cs.toolCache = make(map[string]*Tool, len(tools))
for _, tool := range tools {
cs.toolCache[tool.Name] = tool
Comment thread
guglielmo-san marked this conversation as resolved.
}
}

func (cs *ClientSession) getCachedTool(name string) *Tool {
cs.toolCacheMu.RLock()
defer cs.toolCacheMu.RUnlock()
return cs.toolCache[name]
}

// registerElicitationWaiter registers a waiter for an elicitation complete
// notification with the given elicitation ID. It returns two functions: an await
// function that waits for the notification or context cancellation, and a cleanup
Expand Down Expand Up @@ -981,7 +1009,13 @@ func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams)

// ListTools lists tools that are currently available on the server.
func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) {
return handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params)))
result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params)))
if err != nil {
return nil, err
}
result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools)
cs.cacheTools(result.Tools)
return result, nil
}

// CallTool calls the tool with the given parameters.
Expand All @@ -995,6 +1029,9 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) (
// Avoid sending nil over the wire.
params.Arguments = map[string]any{}
}
if tool := cs.getCachedTool(params.Name); tool != nil {
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.

Please also think how modelcontextprotocol/modelcontextprotocol#2549 will factor in into this solution.

ctx = context.WithValue(ctx, toolContextKey, tool)
}
return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params)))
}

Expand Down
74 changes: 74 additions & 0 deletions mcp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,80 @@ func TestClientCapabilities(t *testing.T) {
}
}

func TestToolCache(t *testing.T) {
tool1 := &Tool{Name: "tool1", Description: "first"}
tool2 := &Tool{Name: "tool2", Description: "second"}
tool1Updated := &Tool{Name: "tool1", Description: "updated"}

testCases := []struct {
name string
cacheBatches [][]*Tool
lookup string
want *Tool
}{
{
name: "empty cache",
lookup: "tool1",
want: nil,
},
{
name: "single tool found",
cacheBatches: [][]*Tool{{tool1}},
lookup: "tool1",
want: tool1,
},
{
name: "unknown tool",
cacheBatches: [][]*Tool{{tool1}},
lookup: "nonexistent",
want: nil,
},
{
name: "multiple tools single batch",
cacheBatches: [][]*Tool{{tool1, tool2}},
lookup: "tool2",
want: tool2,
},
{
name: "replace clears old entries",
cacheBatches: [][]*Tool{{tool1}, {tool2}},
lookup: "tool1",
want: nil,
},
{
name: "replace keeps new entries",
cacheBatches: [][]*Tool{{tool1}, {tool2}},
lookup: "tool2",
want: tool2,
},
{
name: "overwrite existing entry",
cacheBatches: [][]*Tool{{tool1}, {tool1Updated}},
lookup: "tool1",
want: tool1Updated,
},
{
name: "empty batch no-op",
cacheBatches: [][]*Tool{{}},
lookup: "tool1",
want: nil,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cs := &ClientSession{}
for _, batch := range tc.cacheBatches {
cs.cacheTools(batch)
}
got := cs.getCachedTool(tc.lookup)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("getCachedTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff)
}
})
}
}

func TestClientCapabilitiesOverWire(t *testing.T) {
testCases := []struct {
name string
Expand Down
22 changes: 19 additions & 3 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,14 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque
http.Error(w, "failed connection", http.StatusInternalServerError)
return
}
transport.connection.toolLookup = func(name string) *Tool {
Comment thread
guglielmo-san marked this conversation as resolved.
server.mu.Lock()
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.

Using the mutex from a different file is probably not a good practice. Consider exposing a private method on the server.

defer server.mu.Unlock()
if st, ok := server.tools.get(name); ok {
return st.tool
}
return nil
}
// Capture the user ID from the token info to enable session hijacking
// prevention on subsequent requests.
var userID string
Expand Down Expand Up @@ -669,6 +677,8 @@ type streamableServerConn struct {

logger *slog.Logger

toolLookup func(name string) *Tool

incoming chan jsonrpc.Message // messages from the client to the server

mu sync.Mutex // guards all fields below
Expand Down Expand Up @@ -1186,9 +1196,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
}
}

// Validate MCP standard headers (Mcp-Method, Mcp-Name)
// Validate MCP standard headers (Mcp-Method, Mcp-Name, Mcp-Param-*)
if !isBatch && len(incoming) == 1 {
if err := validateMcpHeaders(req.Header, incoming[0]); err != nil {
var tool *Tool
if jreq, ok := incoming[0].(*jsonrpc.Request); ok && jreq.Method == "tools/call" && c.toolLookup != nil {
if name, ok := extractName(jreq.Method, jreq.Params); ok {
tool = c.toolLookup(name)
}
}
if err := validateMcpHeaders(req.Header, incoming[0], tool); err != nil {
resp := &jsonrpc.Response{
Error: jsonrpc2.NewError(CodeHeaderMismatch, err.Error()),
}
Expand Down Expand Up @@ -1813,7 +1829,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
}
// Keep this after the setMCPHeaders call to ensure that the
// protocol version header is set.
setStandardHeaders(req.Header, msg)
setStandardHeaders(ctx, req.Header, msg)
resp, err := c.client.Do(req)
if err != nil {
// Any error from client.Do means the request didn't reach the server.
Expand Down
Loading
Loading