mcp: fix ListTools panic and null normalization on malformed tools (#1120)

Fixes #1119.

`ClientSession.ListTools` post-processes the server response through
`filterValidTools`, which has two problems on malformed input:

1. **Nil-pointer panic on `"tools":[null]`.** A `nil` `*Tool` element
reaches `validateParamHeaderAnnotations(tool)`, which dereferences
`tool.InputSchema`, crashing the client on untrusted server input.
2. **`"tools":null` is normalized into a non-nil empty slice.**
`make([]*Tool, 0, len(tools))` is always non-nil, so a nil `Tools` slice
from a malformed response is silently laundered into valid-looking data.

Reproduction:

```go
filterValidTools(nil, []*Tool{nil}) // panics: nil pointer dereference
filterValidTools(nil, nil)          // returns non-nil slice (len 0), want nil
```

Fix (`mcp/streamable_headers.go`):

- Skip (log and exclude) nil tool elements instead of dereferencing
them, so a malformed `"tools":[null]` can no longer crash the client.
This is consistent with the function’s existing contract of excluding
invalid tools.
- Return early with `nil` for a nil input slice so a `"tools":null`
response is preserved as nil rather than normalized into an empty slice.

Added `TestFilterValidToolsNilHandling` covering both defects plus the
mixed valid/nil case. `go test ./mcp/` passes; `gofmt` and `go vet` are
clean.

Note on scope: this keeps the change minimal and within
`filterValidTools`’s existing signature. If maintainers would rather
surface a hard protocol error for a malformed `"tools":null` /
`"tools":[null]` response at the `ListTools` level (rather than
tolerating and filtering it), I’m happy to follow up with that larger
change.
This commit is contained in:
n0liu 2026-07-30 16:06:14 +08:00 • committed by GitHub
commit 7743b71b1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 15 additions and 0 deletions

View file

@ -262,6 +262,12 @@ func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool {
logger = ensureLogger(logger)
result := make([]*Tool, 0, len(tools))
for _, tool := range tools {
// A nil tool (e.g. from a malformed "tools":[null] response) is
// invalid; exclude it instead of dereferencing it.
if tool == nil {
logger.Error("excluding nil tool from tools/list")
continue
}
if err := validateParamHeaderAnnotations(tool); err != nil {
logger.Error("excluding tool from tools/list", "tool", tool.Name, "error", err)
continue

View file

@ -969,6 +969,15 @@ func TestFilterValidTools(t *testing.T) {
t.Errorf("filterValidTools returned [%s, %s, %s, %s], want [valid, plain, nested-valid, valid-jsonschema]",
result[0].Name, result[1].Name, result[2].Name, result[3].Name)
}
// Regression for #1119: a malformed "tools":[null] response must not panic;
// nil tools are treated as invalid and excluded.
if got := filterValidTools(nil, []*Tool{nil}); len(got) != 0 {
t.Errorf("filterValidTools([nil]) returned %d tools, want 0", len(got))
}
if got := filterValidTools(nil, []*Tool{nil, valid, nil}); len(got) != 1 || got[0].Name != "valid" {
t.Errorf("filterValidTools([nil, valid, nil]) = %d tools, want [valid]", len(got))
}
}
func TestSetStandardHeadersWithParamHeaders(t *testing.T) {