Skip to content

Commit 213a9e2

Browse files
fix(inventory): harden typed schema registration contracts
Cache immutable schema variants and enum overrides, preserve runtime input compatibility and request-era output behavior, and normalize dynamic scope challenges before dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 22e536a commit 213a9e2

21 files changed

Lines changed: 1711 additions & 256 deletions

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ For UI dependency upgrades, compare all four views (`get-me`, `issue-write`, `pr
6060
- Update readme documentation: `script/generate-docs`
6161
- If renaming a tool, add a deprecation alias (see [Tool Renaming Guide](docs/tool-renaming.md))
6262
- For toolset and icon configuration, see [Toolsets and Icons Guide](docs/toolsets-and-icons.md)
63+
- For typed tool registration and compatibility schemas, see [Typed Tool Schemas](docs/typed-tool-schemas.md)
6364
6. Push to your fork and [submit a pull request][pr] targeting the `main` branch
6465
7. Pat yourself on the back and wait for your pull request to be reviewed and merged.
6566

‎docs/typed-tool-schemas.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Typed tool schemas
2+
3+
Typed tool registrations use concrete Go input and output types with the MCP
4+
Go SDK's `mcp.AddTool` path. The SDK infers schemas when the tool definition
5+
does not provide them, validates arguments before the handler runs, and
6+
validates typed output. Keep business rules that JSON Schema cannot express
7+
in the handler or a preflight callback.
8+
9+
```go
10+
tool := github.NewToolWithSchemaOptions[workflowInput, workflowOutput](
11+
toolset,
12+
mcp.Tool{Name: "list_workflow_runs"},
13+
scopeAccess,
14+
inventory.TypedSchemaOptions{
15+
InputEnums: []inventory.SchemaEnum{
16+
{Path: "status", Values: github.WorkflowStatusValues()},
17+
},
18+
},
19+
listWorkflowRuns,
20+
)
21+
```
22+
23+
`SchemaEnum.Path` addresses inferred properties with dot-separated names and
24+
uses `items` to descend into array elements. `EnumSchema` creates a standalone
25+
string enum schema, while `WithEnum` returns a cloned explicit schema with an
26+
enum applied at a path. Inferred and explicit schema helpers cache immutable
27+
schemas; do not mutate the returned pointers.
28+
29+
When compatibility requires a broader runtime input contract than the one
30+
advertised to clients, provide `ValidationInputSchema`. The tool's declared
31+
`InputSchema` remains visible while the SDK validates calls against the
32+
runtime-only schema. Use `Preflight` for checks that need raw arguments or
33+
request dependencies before typed decoding; it may return a derived context
34+
for the handler. Input normalizers are only for compatibility transformations,
35+
not a replacement for schema validation.
36+
37+
The SDK applies defaults from the runtime input schema before decoding. If an
38+
omitted field must remain omitted, build the runtime schema with
39+
`inventory.CloneSchemaWithoutDefaults` before applying validation-only
40+
changes. The advertised schema can retain its defaults; the constructor caches
41+
the runtime schema without mutating either caller-owned schema.
42+
43+
The output schema and `structuredContent` are exposed only for the exact
44+
protocol version `2026-07-28`. Older, absent, and unrecognized versions retain
45+
the legacy text result. Normal `Inventory.RegisterTools` and
46+
`ServerTool.RegisterFunc` registrations select behavior per request. Use
47+
`RegisterToolsForProtocolEra` or `RegisterFuncForProtocolEra` only when the
48+
protocol era is already known before server construction, such as a stateless
49+
request-scoped server.
50+
51+
If a handler intentionally returns non-JSON text or content blocks, set
52+
`PreserveHandlerContent` in `TypedSchemaOptions`, or call
53+
`inventory.PreserveToolHandlerContent(ctx)` from the handler middleware for
54+
request-dependent output such as CSV.

‎pkg/context/mcp_info.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type MCPMethodInfo struct {
2424
ItemName string
2525
// RawArguments contains the unmaterialized tool arguments for tools/call requests.
2626
RawArguments json.RawMessage
27+
// NormalizedArguments contains canonical arguments prepared by HTTP scope
28+
// middleware so tool adapters do not rerun non-idempotent normalizers.
29+
NormalizedArguments json.RawMessage
30+
// ArgumentsNormalized distinguishes an intentionally empty normalized value
31+
// from a request that has not been normalized.
32+
ArgumentsNormalized bool
2733
// ProtocolVersion and ClientCapabilities describe the requesting MCP client
2834
// when stateless HTTP parsing makes them available before registration.
2935
ProtocolVersion string

‎pkg/github/csv_output.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func csvOutputMiddleware(deps any) inventory.ToolHandlerMiddleware {
7777
if csvDeps == nil || !csvDeps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) {
7878
return result, err
7979
}
80+
inventory.PreserveToolHandlerContent(ctx)
8081
return convertJSONTextResultToCSV(result), nil
8182
}
8283
}

‎pkg/github/dependencies.go‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,31 @@ func NewTool[In, Out any](
233233
handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error),
234234
inputNormalizers ...inventory.InputNormalizer,
235235
) inventory.ServerTool {
236-
st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) {
236+
return NewToolWithSchemaOptions(
237+
toolset,
238+
tool,
239+
scopeAccess,
240+
inventory.TypedSchemaOptions{},
241+
handler,
242+
inputNormalizers...,
243+
)
244+
}
245+
246+
// NewToolWithSchemaOptions is like NewTool, with options for schema inference,
247+
// runtime-only input validation schemas, and pre-decode compatibility checks.
248+
// The original tool schema remains the schema advertised to clients.
249+
func NewToolWithSchemaOptions[In, Out any](
250+
toolset inventory.ToolsetMetadata,
251+
tool mcp.Tool,
252+
scopeAccess inventory.ScopeAccess,
253+
schemaOptions inventory.TypedSchemaOptions,
254+
handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error),
255+
inputNormalizers ...inventory.InputNormalizer,
256+
) inventory.ServerTool {
257+
st := inventory.NewServerToolWithContextHandlerAndSchemaOptions(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) {
237258
deps := MustDepsFromContext(ctx)
238259
return handler(ctx, deps, req, args)
239-
}, inputNormalizers...)
260+
}, schemaOptions, inputNormalizers...)
240261
st.ScopeAccess = scopeAccess
241262
return st
242263
}

‎pkg/github/server.go‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,23 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci
110110

111111
ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts)
112112

113-
// Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured,
114-
// and any middleware that needs to read or modify the context should be before it.
115-
ghServer.AddReceivingMiddleware(middleware...)
116-
ghServer.AddReceivingMiddleware(injectFeatureStateMiddleware(inv))
117-
ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps))
118-
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
119-
120113
if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 {
121114
cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", "))
122115
}
123116

124117
// Register GitHub tools/resources/prompts from the inventory.
125118
inv.RegisterAll(ctx, ghServer, deps, cfg.ToolHandlerMiddleware...)
126119

120+
// Add request middleware after registering tools so it wraps the protocol
121+
// adapter. Preflight and legacy typed calls then receive the same request
122+
// context as modern typed calls.
123+
// Middleware order matters: the error context wrapper is applied last so it
124+
// runs first and can observe errors from every inner middleware.
125+
ghServer.AddReceivingMiddleware(middleware...)
126+
ghServer.AddReceivingMiddleware(injectFeatureStateMiddleware(inv))
127+
ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps))
128+
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
129+
127130
// Register MCP App UI resources whenever the embedded UI assets are
128131
// available. The resources are static HTML referenced by tools' _meta.ui
129132
// block (the inventory strips that block for clients that do not support

‎pkg/github/server_test.go‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,69 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) {
194194
// is already tested in pkg/github/*_test.go.
195195
}
196196

197+
func TestNewMCPServerTypedLegacyPreflightAndHandlerReceiveDependencies(t *testing.T) {
198+
type output struct {
199+
OK bool `json:"ok"`
200+
}
201+
deps := stubDeps{obsv: stubExporters()}
202+
tool := NewToolWithSchemaOptions[struct{}, output](
203+
inventory.ToolsetMetadata{ID: inventory.ToolsetID("test"), Description: "Test"},
204+
mcp.Tool{Name: "typed_dependency_tool"},
205+
inventory.ScopeAccess{},
206+
inventory.TypedSchemaOptions{
207+
Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) {
208+
if _, ok := DepsFromContext(ctx); !ok {
209+
return ctx, nil, errors.New("dependencies missing in preflight")
210+
}
211+
return ctx, nil, nil
212+
},
213+
},
214+
func(ctx context.Context, handlerDeps ToolDependencies, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, output, error) {
215+
if _, ok := DepsFromContext(ctx); !ok || handlerDeps == nil {
216+
return nil, output{}, errors.New("dependencies missing in handler")
217+
}
218+
return &mcp.CallToolResult{
219+
Content: []mcp.Content{&mcp.TextContent{Text: "legacy text"}},
220+
}, output{OK: true}, nil
221+
},
222+
)
223+
inv, err := inventory.NewBuilder().
224+
SetTools([]inventory.ServerTool{tool}).
225+
WithToolsets([]string{"all"}).
226+
Build()
227+
require.NoError(t, err)
228+
cfg := MCPServerConfig{
229+
Version: "test",
230+
Translator: translations.NullTranslationHelper,
231+
Logger: slog.New(slog.DiscardHandler),
232+
}
233+
server, err := NewMCPServer(context.Background(), &cfg, deps, inv)
234+
require.NoError(t, err)
235+
236+
serverTransport, clientTransport := mcp.NewInMemoryTransports()
237+
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
238+
require.NoError(t, err)
239+
t.Cleanup(func() { _ = serverSession.Close() })
240+
client := mcp.NewClient(&mcp.Implementation{Name: "legacy-test", Version: "1"}, nil)
241+
clientSession, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{
242+
ProtocolVersion: "2025-11-25",
243+
})
244+
require.NoError(t, err)
245+
t.Cleanup(func() { _ = clientSession.Close() })
246+
247+
list, err := clientSession.ListTools(context.Background(), nil)
248+
require.NoError(t, err)
249+
require.Len(t, list.Tools, 1)
250+
assert.Nil(t, list.Tools[0].OutputSchema)
251+
252+
result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_dependency_tool"})
253+
require.NoError(t, err)
254+
require.False(t, result.IsError)
255+
assert.Nil(t, result.StructuredContent)
256+
require.Len(t, result.Content, 1)
257+
assert.Equal(t, "legacy text", result.Content[0].(*mcp.TextContent).Text)
258+
}
259+
197260
func TestFeatureStateMiddlewareCachesHandlerChecks(t *testing.T) {
198261
var calls int
199262
checker := func(_ context.Context, flag string) (bool, error) {

‎pkg/github/workflow_enums.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package github
2+
3+
import "slices"
4+
5+
// WorkflowStatus is a GitHub Actions workflow-run status.
6+
type WorkflowStatus string
7+
8+
var workflowStatusValues = []string{
9+
"queued",
10+
"in_progress",
11+
"completed",
12+
"requested",
13+
"waiting",
14+
"pending",
15+
}
16+
17+
// Values returns the supported workflow statuses.
18+
func (WorkflowStatus) Values() []string {
19+
return slices.Clone(workflowStatusValues)
20+
}
21+
22+
// WorkflowStatusValues returns the supported workflow statuses.
23+
func WorkflowStatusValues() []string {
24+
return (WorkflowStatus("")).Values()
25+
}
26+
27+
// WorkflowConclusion is a GitHub Actions workflow-run conclusion.
28+
type WorkflowConclusion string
29+
30+
var workflowConclusionValues = []string{
31+
"success",
32+
"failure",
33+
"neutral",
34+
"cancelled",
35+
"skipped",
36+
"timed_out",
37+
"action_required",
38+
"stale",
39+
"startup_failure",
40+
}
41+
42+
// Values returns the supported workflow conclusions.
43+
func (WorkflowConclusion) Values() []string {
44+
return slices.Clone(workflowConclusionValues)
45+
}
46+
47+
// WorkflowConclusionValues returns the supported workflow conclusions.
48+
func WorkflowConclusionValues() []string {
49+
return (WorkflowConclusion("")).Values()
50+
}

‎pkg/http/handler_test.go‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,21 +1265,22 @@ func TestHTTPStatelessTypedOutputProtocolHeaders(t *testing.T) {
12651265
)
12661266

12671267
for _, tc := range []struct {
1268-
name string
1269-
protocolVersion string
1270-
wantModern bool
1268+
name string
1269+
headerVersion string
1270+
metaVersion string
1271+
wantModern bool
12711272
}{
1272-
{name: "modern header", protocolVersion: inventory.ProtocolVersionMultiRoundTrip, wantModern: true},
1273-
{name: "legacy header", protocolVersion: "2025-11-25"},
1274-
{name: "absent header"},
1273+
{name: "modern header and metadata", headerVersion: inventory.ProtocolVersionMultiRoundTrip, metaVersion: inventory.ProtocolVersionMultiRoundTrip, wantModern: true},
1274+
{name: "legacy header and metadata", headerVersion: "2025-11-25", metaVersion: "2025-11-25"},
1275+
{name: "absent protocol version"},
12751276
} {
12761277
t.Run(tc.name, func(t *testing.T) {
12771278
post := func(method string, id int) map[string]json.RawMessage {
12781279
t.Helper()
12791280
params := map[string]any{"name": "typed_http_tool", "arguments": map[string]any{}}
1280-
if tc.protocolVersion != "" {
1281+
if tc.metaVersion != "" {
12811282
params["_meta"] = map[string]any{
1282-
mcp.MetaKeyProtocolVersion: tc.protocolVersion,
1283+
mcp.MetaKeyProtocolVersion: tc.metaVersion,
12831284
mcp.MetaKeyClientInfo: map[string]any{"name": "test", "version": "1.0.0"},
12841285
mcp.MetaKeyClientCapabilities: map[string]any{},
12851286
}
@@ -1298,8 +1299,8 @@ func TestHTTPStatelessTypedOutputProtocolHeaders(t *testing.T) {
12981299
if method == "tools/call" {
12991300
req.Header.Set(headers.MCPNameHeader, "typed_http_tool")
13001301
}
1301-
if tc.protocolVersion != "" {
1302-
req.Header.Set("MCP-Protocol-Version", tc.protocolVersion)
1302+
if tc.headerVersion != "" {
1303+
req.Header.Set(headers.MCPProtocolVersionHeader, tc.headerVersion)
13031304
}
13041305

13051306
recorder := httptest.NewRecorder()
@@ -1352,10 +1353,11 @@ func TestHTTPStatelessTypedOutputProtocolHeaders(t *testing.T) {
13521353
require.NoError(t, json.Unmarshal(callResultJSON, &called))
13531354
require.Len(t, called.Content, 1, "SDK fallback serialization must not duplicate the existing text")
13541355
assert.Equal(t, "text", called.Content[0].Type)
1355-
assert.Equal(t, "legacy text", called.Content[0].Text)
13561356
if tc.wantModern {
13571357
assert.JSONEq(t, `{"values":["one","two"]}`, string(called.StructuredContent))
1358+
assert.Equal(t, `{"values":["one","two"]}`, called.Content[0].Text)
13581359
} else {
1360+
assert.Equal(t, "legacy text", called.Content[0].Text)
13591361
assert.Empty(t, called.StructuredContent, "legacy and unknown protocol versions must not expose structured content")
13601362
}
13611363
})

‎pkg/http/headers/headers.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ const (
3939

4040
// MCP-specific headers.
4141

42+
// MCPProtocolVersionHeader carries the protocol version for HTTP MCP requests.
43+
MCPProtocolVersionHeader = "MCP-Protocol-Version"
4244
// MCPMethodHeader mirrors the JSON-RPC method for request routing.
4345
MCPMethodHeader = "Mcp-Method"
4446
// MCPNameHeader identifies the requested MCP primitive.

0 commit comments

Comments
 (0)