From 73e382802f8932665e0ec74b8524c951dc24e68e Mon Sep 17 00:00:00 2001 From: Swayam Mishra Date: Sat, 3 Oct 2026 23:26:18 +0530 Subject: [PATCH] mcp: reject x-mcp-header annotations not reachable through properties The 2026-07-28 Streamable HTTP spec only allows x-mcp-header on a property reached from the schema root through "properties" keys. An annotation under items, a composition or conditional keyword, or a $ref target makes the tool definition invalid. validateParamHeaderAnnotations only looked at "properties", because headerSchemaProperty decodes nothing else. An annotation anywhere else was never seen, so Server.AddTool accepted the tool and the annotation was silently dropped. Walk the subschema keywords as well and report any x-mcp-header found under them. The walk uses the decoded JSON rather than new fields on headerSchemaProperty, since keywords like additionalProperties can be booleans and a typed field would make the whole decode fail. With this change Server.AddTool panics for such a tool, as it already does for the other invalid annotations, and ClientSession.ListTools drops it. New TestValidateToolParamHeaders cases cover items, oneOf, anyOf, allOf, not, if/then/else and a $defs entry reached through $ref; all seven fail without the change. Another case checks that a nested properties annotation next to unannotated subschemas is still accepted. Fixes #1250 Signed-off-by: Swayam Mishra --- mcp/streamable_headers.go | 61 +++++++++++++ mcp/streamable_headers_test.go | 158 +++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 72270208c..dd0d879e0 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -286,6 +286,12 @@ func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { // are valid. Annotations may appear on properties at any nesting // depth within the inputSchema and must be unique across all of them. func validateParamHeaderAnnotations(tool *Tool) error { + var schema any + if err := remarshal(tool.InputSchema, &schema); err == nil { + if err := checkUnreachableParamHeaders(schema, "", true); err != nil { + return err + } + } props := unmarshalSchemaProperties(tool.InputSchema) if len(props) == 0 { return nil @@ -326,6 +332,61 @@ func validateParamHeadersIn(props map[string]headerSchemaProperty, prefix string return nil } +// Keywords whose values are subschemas. The spec only allows x-mcp-header on +// a property reached from the root through "properties" alone, so an +// annotation anywhere under one of these makes the tool invalid. +var ( + subschemaKeywords = []string{ + "items", "prefixItems", "additionalItems", "contains", + "additionalProperties", "unevaluatedItems", "unevaluatedProperties", "propertyNames", + "allOf", "anyOf", "oneOf", "not", "if", "then", "else", + } + subschemaMapKeywords = []string{"patternProperties", "dependentSchemas", "$defs", "definitions"} +) + +// checkUnreachableParamHeaders reports an x-mcp-header annotation that is not +// statically reachable from the schema root through "properties" keys. +func checkUnreachableParamHeaders(schema any, path string, reachable bool) error { + m, ok := schema.(map[string]any) + if !ok { + return nil + } + if _, ok := m["x-mcp-header"]; ok && !reachable { + return fmt.Errorf("x-mcp-header at %q is not reachable from the schema root through properties", path) + } + if props, ok := m["properties"].(map[string]any); ok { + for name, prop := range props { + if err := checkUnreachableParamHeaders(prop, path+"/properties/"+name, reachable); err != nil { + return err + } + } + } + for _, kw := range subschemaKeywords { + switch v := m[kw].(type) { + case map[string]any: + if err := checkUnreachableParamHeaders(v, path+"/"+kw, false); err != nil { + return err + } + case []any: + for i, s := range v { + if err := checkUnreachableParamHeaders(s, fmt.Sprintf("%s/%s/%d", path, kw, i), false); err != nil { + return err + } + } + } + } + for _, kw := range subschemaMapKeywords { + if defs, ok := m[kw].(map[string]any); ok { + for name, s := range defs { + if err := checkUnreachableParamHeaders(s, path+"/"+kw+"/"+name, false); err != nil { + return err + } + } + } + } + return nil +} + // validateHeaderName checks that a header name matches the HTTP field-name // token syntax (1*tchar). func validateHeaderName(name string) error { diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 2042187cf..ac0236dbe 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -897,6 +897,164 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErr: true, wantErrSub: "primitive types", }, + { + name: "x-mcp-header under items is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string", "x-mcp-header": "Tag"}, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header under oneOf is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "oneOf": []any{ + map[string]any{"type": "string", "x-mcp-header": "Region"}, + map[string]any{"type": "integer"}, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header under anyOf is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "anyOf": []any{ + map[string]any{ + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header under allOf is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "allOf": []any{ + map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header under not is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "not": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header under if/then/else is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "if": map[string]any{ + "properties": map[string]any{"kind": map[string]any{"const": "a"}}, + }, + "then": map[string]any{ + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + "else": map[string]any{ + "properties": map[string]any{ + "zone": map[string]any{"type": "string", "x-mcp-header": "Zone"}, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header in $defs reached through $ref is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "$defs": map[string]any{ + "Region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + "properties": map[string]any{ + "region": map[string]any{"$ref": "#/$defs/Region"}, + }, + }, + }, + wantErr: true, + wantErrSub: "not reachable", + }, + { + name: "x-mcp-header on nested properties next to unannotated subschemas is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + "mode": map[string]any{ + "oneOf": []any{ + map[string]any{"const": "fast"}, + map[string]any{"const": "slow"}, + }, + }, + }, + }, + }, + }, } for _, tt := range tests {