Skip to content

Commit 6c6cae1

Browse files
committed
fix(inventory): clone schema before header annotation to avoid shared-map race
AnnotateHeaderParams mutated the *jsonschema.Schema (and per-property Extra maps) shared with the original tool definition via the caller's shallow copy. Under per-request registration (remote server), concurrent requests could race on — and fatally panic from — the same Extra map. Now clone only what we touch (schema value, Properties map, annotated property schemas + their Extra maps); the original is never written. Adds a no-mutation test and a 64-goroutine race regression (go test -race clean). Addresses Copilot review on #2787. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent df7d63c commit 6c6cae1

2 files changed

Lines changed: 74 additions & 12 deletions

File tree

pkg/inventory/server_tool.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"maps"
78

89
"github.com/github/github-mcp-server/pkg/octicons"
910
"github.com/google/jsonschema-go/jsonschema"
@@ -130,25 +131,49 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) {
130131
// for every tool; the enforcement test in pkg/github guards full coverage.
131132
var HeaderParams = map[string]string{"owner": "owner", "repo": "repo"}
132133

133-
// AnnotateHeaderParams adds an "x-mcp-header" annotation to matching top-level
134-
// input properties, which the SDK projects onto Mcp-Param-{name} request headers.
134+
// AnnotateHeaderParams returns a copy of tool whose routing-relevant input
135+
// properties (per HeaderParams) carry an "x-mcp-header" annotation, which the
136+
// SDK projects onto Mcp-Param-{name} request headers. It never mutates the
137+
// input tool's schema or any map shared with the original tool definition:
138+
// callers shallow-copy ServerTool.Tool, so the *jsonschema.Schema (and its
139+
// per-property Extra maps) are shared, and per-request registration must not
140+
// race on them. Only the schema, its Properties map, and the specific property
141+
// schemas/Extra maps that gain an annotation are cloned.
135142
func AnnotateHeaderParams(tool *mcp.Tool) {
136143
schema, ok := tool.InputSchema.(*jsonschema.Schema)
137144
if !ok || schema == nil {
138145
return
139146
}
140-
for prop, header := range HeaderParams {
141-
ps := schema.Properties[prop]
142-
if ps == nil {
143-
continue
144-
}
145-
if ps.Extra == nil {
146-
ps.Extra = map[string]any{}
147-
}
148-
if _, exists := ps.Extra["x-mcp-header"]; !exists {
149-
ps.Extra["x-mcp-header"] = header
147+
148+
// Collect params that actually need an annotation, so a tool without
149+
// owner/repo (or already annotated) is left untouched and unCloned.
150+
var toAnnotate []string
151+
for prop := range HeaderParams {
152+
if ps := schema.Properties[prop]; ps != nil {
153+
if _, exists := ps.Extra["x-mcp-header"]; !exists {
154+
toAnnotate = append(toAnnotate, prop)
155+
}
150156
}
151157
}
158+
if len(toAnnotate) == 0 {
159+
return
160+
}
161+
162+
// Clone only what we mutate: a fresh schema value, a fresh Properties map,
163+
// and fresh property schemas with fresh Extra maps. The original schema and
164+
// its maps are never written to, so concurrent per-request registration is
165+
// race-free and deterministic.
166+
schemaCopy := *schema
167+
schemaCopy.Properties = maps.Clone(schema.Properties)
168+
for _, prop := range toAnnotate {
169+
propCopy := *schemaCopy.Properties[prop]
170+
extra := make(map[string]any, len(propCopy.Extra)+1)
171+
maps.Copy(extra, propCopy.Extra)
172+
extra["x-mcp-header"] = HeaderParams[prop]
173+
propCopy.Extra = extra
174+
schemaCopy.Properties[prop] = &propCopy
175+
}
176+
tool.InputSchema = &schemaCopy
152177
}
153178

154179
// NewServerToolWithContextHandler creates a ServerTool with a handler that receives deps via context.

pkg/inventory/server_tool_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package inventory
33
import (
44
"context"
55
"encoding/json"
6+
"sync"
67
"testing"
78

89
"github.com/google/jsonschema-go/jsonschema"
@@ -99,3 +100,39 @@ func TestAnnotateHeaderParams(t *testing.T) {
99100
AnnotateHeaderParams(&mcp.Tool{InputSchema: &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"x": {}}}})
100101
AnnotateHeaderParams(&mcp.Tool{InputSchema: json.RawMessage(`{}`)})
101102
}
103+
104+
func TestAnnotateHeaderParams_DoesNotMutateOriginal(t *testing.T) {
105+
orig := &jsonschema.Schema{
106+
Type: "object",
107+
Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}, "repo": {Type: "string"}},
108+
}
109+
tool := &mcp.Tool{InputSchema: orig}
110+
AnnotateHeaderParams(tool)
111+
112+
// Original schema and its property Extra maps must be untouched.
113+
require.Nil(t, orig.Properties["owner"].Extra, "must not mutate original owner schema")
114+
require.Nil(t, orig.Properties["repo"].Extra, "must not mutate original repo schema")
115+
// Returned copy carries the annotation.
116+
got := tool.InputSchema.(*jsonschema.Schema)
117+
require.NotSame(t, orig, got, "must replace InputSchema with a copy")
118+
require.Equal(t, "owner", got.Properties["owner"].Extra["x-mcp-header"])
119+
}
120+
121+
func TestAnnotateHeaderParams_ConcurrentRegistrationIsRaceFree(t *testing.T) {
122+
// Shared base schema, as ServerTool.Tool is shallow-copied per registration.
123+
base := &jsonschema.Schema{
124+
Type: "object",
125+
Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}, "repo": {Type: "string"}},
126+
}
127+
var wg sync.WaitGroup
128+
for range 64 {
129+
wg.Go(func() {
130+
tool := mcp.Tool{InputSchema: base} // shallow copy shares *Schema
131+
AnnotateHeaderParams(&tool)
132+
got := tool.InputSchema.(*jsonschema.Schema)
133+
require.Equal(t, "repo", got.Properties["repo"].Extra["x-mcp-header"])
134+
})
135+
}
136+
wg.Wait()
137+
require.Nil(t, base.Properties["owner"].Extra, "shared base must remain unmutated")
138+
}

0 commit comments

Comments
 (0)