Skip to content
Open
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
61 changes: 61 additions & 0 deletions mcp/streamable_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
158 changes: 158 additions & 0 deletions mcp/streamable_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down