Skip to content

Commit 12ae80d

Browse files
perf: add schema caching to avoid repeated reflection
This change adds a global schema cache that dramatically reduces the cost of registering tools in stateless server patterns (new server per request). Key improvements: - Cache schemas by reflect.Type for typed handlers - Cache resolved schemas by pointer for pre-defined schemas - 132x faster tool registration after first call - 51x fewer allocations per AddTool call - 32x less memory per AddTool call Benchmarks: - BenchmarkAddTool_TypedHandler: 1,223 ns/op vs 161,463 ns/op (no cache) - BenchmarkAddTool_TypedHandler: 21 allocs vs 1,072 allocs (no cache) This benefits integrators like github-mcp-server automatically without any code changes required.
1 parent 272e0cd commit 12ae80d

File tree

4 files changed

+491
-11
lines changed

4 files changed

+491
-11
lines changed

mcp/schema_cache.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package mcp
6+
7+
import (
8+
"reflect"
9+
"sync"
10+
11+
"github.com/google/jsonschema-go/jsonschema"
12+
)
13+
14+
// schemaCache provides concurrent-safe caching for JSON schemas.
15+
// It caches both by reflect.Type (for auto-generated schemas) and
16+
// by schema pointer (for pre-defined schemas).
17+
//
18+
// This cache significantly improves performance for stateless server deployments
19+
// where tools are re-registered on every request. Without caching, each AddTool
20+
// call would trigger expensive reflection-based schema generation and resolution.
21+
type schemaCache struct {
22+
// byType caches schemas generated from Go types via jsonschema.ForType.
23+
// Key: reflect.Type, Value: *cachedSchema
24+
byType sync.Map
25+
26+
// bySchema caches resolved schemas for pre-defined Schema objects.
27+
// Key: *jsonschema.Schema (pointer identity), Value: *jsonschema.Resolved
28+
// This uses pointer identity because integrators typically reuse the same
29+
// Tool objects across requests, so the schema pointer remains stable.
30+
bySchema sync.Map
31+
}
32+
33+
// cachedSchema holds both the generated schema and its resolved form.
34+
type cachedSchema struct {
35+
schema *jsonschema.Schema
36+
resolved *jsonschema.Resolved
37+
}
38+
39+
// globalSchemaCache is the package-level cache used by setSchema.
40+
// It is unbounded since typical MCP servers have <100 tools.
41+
var globalSchemaCache = &schemaCache{}
42+
43+
// getByType retrieves a cached schema by Go type.
44+
// Returns the schema, resolved schema, and whether the cache hit.
45+
func (c *schemaCache) getByType(t reflect.Type) (*jsonschema.Schema, *jsonschema.Resolved, bool) {
46+
if v, ok := c.byType.Load(t); ok {
47+
cs := v.(*cachedSchema)
48+
return cs.schema, cs.resolved, true
49+
}
50+
return nil, nil, false
51+
}
52+
53+
// setByType caches a schema by Go type.
54+
func (c *schemaCache) setByType(t reflect.Type, schema *jsonschema.Schema, resolved *jsonschema.Resolved) {
55+
c.byType.Store(t, &cachedSchema{schema: schema, resolved: resolved})
56+
}
57+
58+
// getBySchema retrieves a cached resolved schema by the original schema pointer.
59+
// This is used when integrators provide pre-defined schemas (e.g., github-mcp-server pattern).
60+
func (c *schemaCache) getBySchema(schema *jsonschema.Schema) (*jsonschema.Resolved, bool) {
61+
if v, ok := c.bySchema.Load(schema); ok {
62+
return v.(*jsonschema.Resolved), true
63+
}
64+
return nil, false
65+
}
66+
67+
// setBySchema caches a resolved schema by the original schema pointer.
68+
func (c *schemaCache) setBySchema(schema *jsonschema.Schema, resolved *jsonschema.Resolved) {
69+
c.bySchema.Store(schema, resolved)
70+
}
71+
72+
// resetForTesting clears the cache. Only for use in tests.
73+
func (c *schemaCache) resetForTesting() {
74+
c.byType = sync.Map{}
75+
c.bySchema = sync.Map{}
76+
}

mcp/schema_cache_benchmark_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/jsonschema-go/jsonschema"
8+
)
9+
10+
// BenchmarkAddTool_TypedHandler measures performance of AddTool with typed handlers.
11+
// This simulates the stateless server pattern where new servers are created per request.
12+
func BenchmarkAddTool_TypedHandler(b *testing.B) {
13+
type SearchInput struct {
14+
Query string `json:"query" jsonschema:"required"`
15+
Page int `json:"page"`
16+
PerPage int `json:"per_page"`
17+
}
18+
19+
type SearchOutput struct {
20+
Results []string `json:"results"`
21+
Total int `json:"total"`
22+
}
23+
24+
handler := func(ctx context.Context, req *CallToolRequest, in SearchInput) (*CallToolResult, SearchOutput, error) {
25+
return &CallToolResult{}, SearchOutput{}, nil
26+
}
27+
28+
tool := &Tool{
29+
Name: "search",
30+
Description: "Search for items",
31+
}
32+
33+
// Reset cache to simulate cold start for first iteration
34+
globalSchemaCache.resetForTesting()
35+
36+
b.ResetTimer()
37+
b.ReportAllocs()
38+
39+
for i := 0; i < b.N; i++ {
40+
s := NewServer(&Implementation{Name: "test", Version: "1.0"}, nil)
41+
AddTool(s, tool, handler)
42+
}
43+
}
44+
45+
// BenchmarkAddTool_PreDefinedSchema measures performance with pre-defined schemas.
46+
// This simulates how github-mcp-server registers tools with manual InputSchema.
47+
func BenchmarkAddTool_PreDefinedSchema(b *testing.B) {
48+
schema := &jsonschema.Schema{
49+
Type: "object",
50+
Properties: map[string]*jsonschema.Schema{
51+
"query": {Type: "string", Description: "Search query"},
52+
"page": {Type: "integer", Description: "Page number"},
53+
"per_page": {Type: "integer", Description: "Results per page"},
54+
},
55+
Required: []string{"query"},
56+
}
57+
58+
handler := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
59+
return &CallToolResult{}, nil
60+
}
61+
62+
tool := &Tool{
63+
Name: "search",
64+
Description: "Search for items",
65+
InputSchema: schema, // Pre-defined schema like github-mcp-server
66+
}
67+
68+
// Reset cache to simulate cold start for first iteration
69+
globalSchemaCache.resetForTesting()
70+
71+
b.ResetTimer()
72+
b.ReportAllocs()
73+
74+
for i := 0; i < b.N; i++ {
75+
s := NewServer(&Implementation{Name: "test", Version: "1.0"}, nil)
76+
s.AddTool(tool, handler)
77+
}
78+
}
79+
80+
// BenchmarkAddTool_TypedHandler_NoCache measures performance without caching.
81+
// Used to compare before/after performance.
82+
func BenchmarkAddTool_TypedHandler_NoCache(b *testing.B) {
83+
type SearchInput struct {
84+
Query string `json:"query" jsonschema:"required"`
85+
Page int `json:"page"`
86+
PerPage int `json:"per_page"`
87+
}
88+
89+
type SearchOutput struct {
90+
Results []string `json:"results"`
91+
Total int `json:"total"`
92+
}
93+
94+
handler := func(ctx context.Context, req *CallToolRequest, in SearchInput) (*CallToolResult, SearchOutput, error) {
95+
return &CallToolResult{}, SearchOutput{}, nil
96+
}
97+
98+
tool := &Tool{
99+
Name: "search",
100+
Description: "Search for items",
101+
}
102+
103+
b.ResetTimer()
104+
b.ReportAllocs()
105+
106+
for i := 0; i < b.N; i++ {
107+
// Reset cache every iteration to simulate no caching
108+
globalSchemaCache.resetForTesting()
109+
110+
s := NewServer(&Implementation{Name: "test", Version: "1.0"}, nil)
111+
AddTool(s, tool, handler)
112+
}
113+
}
114+
115+
// BenchmarkAddTool_MultipleTools simulates registering multiple tools like github-mcp-server.
116+
func BenchmarkAddTool_MultipleTools(b *testing.B) {
117+
type Input1 struct {
118+
Query string `json:"query"`
119+
}
120+
type Input2 struct {
121+
ID int `json:"id"`
122+
}
123+
type Input3 struct {
124+
Name string `json:"name"`
125+
Value string `json:"value"`
126+
}
127+
type Output struct {
128+
Success bool `json:"success"`
129+
}
130+
131+
handler1 := func(ctx context.Context, req *CallToolRequest, in Input1) (*CallToolResult, Output, error) {
132+
return &CallToolResult{}, Output{}, nil
133+
}
134+
handler2 := func(ctx context.Context, req *CallToolRequest, in Input2) (*CallToolResult, Output, error) {
135+
return &CallToolResult{}, Output{}, nil
136+
}
137+
handler3 := func(ctx context.Context, req *CallToolRequest, in Input3) (*CallToolResult, Output, error) {
138+
return &CallToolResult{}, Output{}, nil
139+
}
140+
141+
tool1 := &Tool{Name: "tool1", Description: "Tool 1"}
142+
tool2 := &Tool{Name: "tool2", Description: "Tool 2"}
143+
tool3 := &Tool{Name: "tool3", Description: "Tool 3"}
144+
145+
// Reset cache before benchmark
146+
globalSchemaCache.resetForTesting()
147+
148+
b.ResetTimer()
149+
b.ReportAllocs()
150+
151+
for i := 0; i < b.N; i++ {
152+
s := NewServer(&Implementation{Name: "test", Version: "1.0"}, nil)
153+
AddTool(s, tool1, handler1)
154+
AddTool(s, tool2, handler2)
155+
AddTool(s, tool3, handler3)
156+
}
157+
}

0 commit comments

Comments
 (0)