Skip to content

Commit a456b82

Browse files
perf: add inventory cache for stateless server patterns
Add CachedInventory to build tool/resource/prompt definitions once at startup rather than per-request. This is particularly useful for the remote server pattern where a new server instance is created per request. Key features: - InitInventoryCache(t) initializes the cache once at startup - InitInventoryCacheWithExtras(t, tools, resources, prompts) allows injecting additional items (e.g., remote-only Copilot tools) - CachedInventoryBuilder() returns a builder with pre-cached definitions - Per-request configuration (read-only, toolsets, feature flags) still works - Thread-safe via sync.Once - Backward compatible: NewInventory(t) still works without caching This addresses the performance concern raised in go-sdk PR #685 at a higher level by caching the entire []ServerTool slice rather than individual schemas. Related: modelcontextprotocol/go-sdk#685
1 parent 97feb5c commit a456b82

File tree

2 files changed

+428
-0
lines changed

2 files changed

+428
-0
lines changed

pkg/github/inventory_cache.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package github
2+
3+
import (
4+
"sync"
5+
6+
"github.com/github/github-mcp-server/pkg/inventory"
7+
"github.com/github/github-mcp-server/pkg/translations"
8+
)
9+
10+
// CachedInventory provides a cached inventory builder that builds tool definitions
11+
// only once, regardless of how many times NewInventoryBuilder is called.
12+
//
13+
// This is particularly useful for stateless server patterns (like the remote server)
14+
// where a new server instance is created per request. Without caching, every request
15+
// would rebuild all ~130 tool definitions including JSON schema generation, causing
16+
// significant performance overhead.
17+
//
18+
// Usage:
19+
//
20+
// // Option 1: Initialize once at startup with your translator
21+
// github.InitInventoryCache(myTranslator)
22+
//
23+
// // Then get pre-built inventory on each request
24+
// inv := github.CachedInventoryBuilder().
25+
// WithReadOnly(cfg.ReadOnly).
26+
// WithToolsets(cfg.Toolsets).
27+
// Build()
28+
//
29+
// // Option 2: Use NewInventory which doesn't use the cache (legacy behavior)
30+
// inv := github.NewInventory(myTranslator).Build()
31+
//
32+
// The cache stores the built []ServerTool, []ServerResourceTemplate, and []ServerPrompt.
33+
// Per-request configuration (read-only, toolsets, feature flags, filters) is still
34+
// applied when building the Inventory from the cached data.
35+
type CachedInventory struct {
36+
once sync.Once
37+
tools []inventory.ServerTool
38+
resources []inventory.ServerResourceTemplate
39+
prompts []inventory.ServerPrompt
40+
}
41+
42+
// global singleton for caching
43+
var globalInventoryCache = &CachedInventory{}
44+
45+
// InitInventoryCache initializes the global inventory cache with the given translator.
46+
// This should be called once at startup before any requests are processed.
47+
// It's safe to call multiple times - only the first call has any effect.
48+
//
49+
// For the local server, this is typically called with the configured translator.
50+
// For the remote server, use translations.NullTranslationHelper since translations
51+
// aren't needed per-request.
52+
//
53+
// Example:
54+
//
55+
// func main() {
56+
// t, _ := translations.TranslationHelper()
57+
// github.InitInventoryCache(t)
58+
// // ... start server
59+
// }
60+
func InitInventoryCache(t translations.TranslationHelperFunc) {
61+
globalInventoryCache.init(t, nil, nil, nil)
62+
}
63+
64+
// InitInventoryCacheWithExtras initializes the global inventory cache with the given
65+
// translator plus additional tools, resources, and prompts.
66+
//
67+
// This is useful for the remote server which has additional tools (e.g., Copilot tools)
68+
// that aren't part of the base github-mcp-server package.
69+
//
70+
// The extra items are appended to the base items from AllTools/AllResources/AllPrompts.
71+
// It's safe to call multiple times - only the first call has any effect.
72+
//
73+
// Example:
74+
//
75+
// func init() {
76+
// github.InitInventoryCacheWithExtras(
77+
// translations.NullTranslationHelper,
78+
// remoteOnlyTools, // []inventory.ServerTool
79+
// remoteOnlyResources, // []inventory.ServerResourceTemplate
80+
// remoteOnlyPrompts, // []inventory.ServerPrompt
81+
// )
82+
// }
83+
func InitInventoryCacheWithExtras(
84+
t translations.TranslationHelperFunc,
85+
extraTools []inventory.ServerTool,
86+
extraResources []inventory.ServerResourceTemplate,
87+
extraPrompts []inventory.ServerPrompt,
88+
) {
89+
globalInventoryCache.init(t, extraTools, extraResources, extraPrompts)
90+
}
91+
92+
// init initializes the cache with the given translator and optional extras (sync.Once protected).
93+
func (c *CachedInventory) init(
94+
t translations.TranslationHelperFunc,
95+
extraTools []inventory.ServerTool,
96+
extraResources []inventory.ServerResourceTemplate,
97+
extraPrompts []inventory.ServerPrompt,
98+
) {
99+
c.once.Do(func() {
100+
c.tools = AllTools(t)
101+
c.resources = AllResources(t)
102+
c.prompts = AllPrompts(t)
103+
104+
// Append extra items if provided
105+
if len(extraTools) > 0 {
106+
c.tools = append(c.tools, extraTools...)
107+
}
108+
if len(extraResources) > 0 {
109+
c.resources = append(c.resources, extraResources...)
110+
}
111+
if len(extraPrompts) > 0 {
112+
c.prompts = append(c.prompts, extraPrompts...)
113+
}
114+
})
115+
}
116+
117+
// CachedInventoryBuilder returns an inventory.Builder pre-populated with cached
118+
// tool/resource/prompt definitions.
119+
//
120+
// The cache must be initialized via InitInventoryCache before calling this function.
121+
// If the cache is not initialized, this will initialize it with NullTranslationHelper.
122+
//
123+
// Per-request configuration can still be applied via the builder methods:
124+
// - WithReadOnly(bool) - filter to read-only tools
125+
// - WithToolsets([]string) - enable specific toolsets
126+
// - WithTools([]string) - enable specific tools
127+
// - WithFeatureChecker(func) - per-request feature flag evaluation
128+
// - WithFilter(func) - custom filtering
129+
//
130+
// Example:
131+
//
132+
// inv := github.CachedInventoryBuilder().
133+
// WithReadOnly(cfg.ReadOnly).
134+
// WithToolsets(cfg.EnabledToolsets).
135+
// WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)).
136+
// Build()
137+
func CachedInventoryBuilder() *inventory.Builder {
138+
// Ensure cache is initialized (with NullTranslationHelper as fallback)
139+
globalInventoryCache.init(translations.NullTranslationHelper, nil, nil, nil)
140+
141+
return inventory.NewBuilder().
142+
SetTools(globalInventoryCache.tools).
143+
SetResources(globalInventoryCache.resources).
144+
SetPrompts(globalInventoryCache.prompts)
145+
}
146+
147+
// IsCacheInitialized returns true if the inventory cache has been initialized.
148+
// This is primarily useful for testing.
149+
func IsCacheInitialized() bool {
150+
// We can't directly check sync.Once state, but we can check if tools are populated
151+
return len(globalInventoryCache.tools) > 0
152+
}
153+
154+
// ResetInventoryCache resets the global inventory cache, allowing it to be
155+
// reinitialized with a different translator. This should only be used in tests.
156+
//
157+
// WARNING: This is not thread-safe and should never be called in production code.
158+
func ResetInventoryCache() {
159+
globalInventoryCache = &CachedInventory{}
160+
}

0 commit comments

Comments
 (0)