diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 7133e26c..26772326 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -309,6 +309,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 @@ -353,6 +359,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 d80c781f..4c9b3140 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 {