Skip to content

Commit a5d4947

Browse files
AlexTalrejaYuan325
andauthored
feat(groups): add ttlMs and cacheScope customization to config (#3805)
## Description This PR allows users to configure time-to-live and cache scope parameters for groups through Toolbox's config file. Previously, these values were hardcoded to `300000ms` and `public`, which Toolbox will still default to if not directly set. --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
1 parent 74d18ae commit a5d4947

4 files changed

Lines changed: 173 additions & 10 deletions

File tree

‎cmd/root.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
383383

384384
err = handleDynamicReload(ctx, reloadedConfig, s)
385385
if err != nil {
386-
errMsg := fmt.Errorf("unable to parse reloaded config at %q: %w", reloadedConfig, err)
386+
errMsg := fmt.Errorf("unable to parse reloaded config at %+v: %w", reloadedConfig, err)
387387
logger.WarnContext(ctx, errMsg.Error())
388388
continue
389389
}

‎internal/group/group.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import (
2222
"github.com/googleapis/mcp-toolbox/internal/tools"
2323
)
2424

25+
const (
26+
DefaultTTLMs = 300000 // default TTL for groups in milliseconds (5 minutes)
27+
DefaultCacheScope = "public" // default cache scope for groups
28+
)
29+
2530
// GroupConfig is the parsed configuration for a group: a single named collection
2631
// that holds both tools and prompts. Its description doubles as the MCP server
2732
// instructions for clients connected to the group.
@@ -30,6 +35,8 @@ type GroupConfig struct {
3035
Description string `yaml:"description"`
3136
ToolNames []string `yaml:"tools"`
3237
PromptNames []string `yaml:"prompts"`
38+
CacheScope string `yaml:"cacheScope" validate:"omitempty,oneof=public private"`
39+
TTLMs *int `yaml:"ttlMs" validate:"omitempty,gte=0"`
3340
}
3441

3542
// Group is an initialized group: the source of truth for a named collection of
@@ -94,6 +101,22 @@ func NewGroup(config GroupConfig) Group {
94101
return Group{GroupConfig: config, toolNameSet: toolNameSet, promptNameSet: promptNameSet}
95102
}
96103

104+
// GetTTLMs returns the time to live for the group
105+
func (g Group) GetTTLMs() int {
106+
if g.TTLMs == nil {
107+
return DefaultTTLMs
108+
}
109+
return *g.TTLMs
110+
}
111+
112+
// GetCacheScope returns the cache scope for the group
113+
func (g Group) GetCacheScope() string {
114+
if g.CacheScope == "" {
115+
return DefaultCacheScope
116+
}
117+
return g.CacheScope
118+
}
119+
97120
// ContainsTool reports whether the group includes a tool with the given name.
98121
func (g Group) ContainsTool(name string) bool {
99122
_, ok := g.toolNameSet[name]

‎internal/group/group_test.go‎

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
package group_test
1616

1717
import (
18+
"context"
1819
"slices"
1920
"strings"
2021
"testing"
2122

23+
"github.com/google/go-cmp/cmp"
2224
"github.com/googleapis/mcp-toolbox/internal/group"
2325
"github.com/googleapis/mcp-toolbox/internal/prompts"
26+
"github.com/googleapis/mcp-toolbox/internal/server"
2427
"github.com/googleapis/mcp-toolbox/internal/server/primitives"
2528
"github.com/googleapis/mcp-toolbox/internal/testutils"
2629
"github.com/googleapis/mcp-toolbox/internal/tools"
@@ -39,16 +42,22 @@ func testFixtures() (map[string]tools.Tool, map[string]prompts.Prompt) {
3942
return toolsMap, promptsMap
4043
}
4144

45+
func intPtr(v int) *int {
46+
return &v
47+
}
48+
4249
func TestGroupConfig_Initialize(t *testing.T) {
4350
t.Parallel()
4451
toolsMap, promptsMap := testFixtures()
4552

4653
testCases := []struct {
47-
name string
48-
config group.GroupConfig
49-
wantTools []string
50-
wantPrompts []string
51-
wantErr string
54+
name string
55+
config group.GroupConfig
56+
wantTools []string
57+
wantPrompts []string
58+
wantErr string
59+
wantTTLMs *int
60+
wantCacheScope string
5261
}{
5362
{
5463
name: "tools and prompts",
@@ -113,6 +122,44 @@ func TestGroupConfig_Initialize(t *testing.T) {
113122
},
114123
wantErr: "prompt does not exist: \"nope\"",
115124
},
125+
{
126+
name: "valid ttlMs",
127+
config: group.GroupConfig{
128+
Name: "g",
129+
TTLMs: intPtr(10000),
130+
},
131+
wantTTLMs: intPtr(10000),
132+
},
133+
{
134+
name: "empty ttlMs",
135+
config: group.GroupConfig{
136+
Name: "g",
137+
},
138+
wantTTLMs: intPtr(300000),
139+
},
140+
{
141+
name: "public cacheScope",
142+
config: group.GroupConfig{
143+
Name: "g",
144+
CacheScope: "public",
145+
},
146+
wantCacheScope: "public",
147+
},
148+
{
149+
name: "private cacheScope",
150+
config: group.GroupConfig{
151+
Name: "g",
152+
CacheScope: "private",
153+
},
154+
wantCacheScope: "private",
155+
},
156+
{
157+
name: "empty cacheScope",
158+
config: group.GroupConfig{
159+
Name: "g",
160+
},
161+
wantCacheScope: "public",
162+
},
116163
}
117164

118165
for _, tc := range testCases {
@@ -147,6 +194,22 @@ func TestGroupConfig_Initialize(t *testing.T) {
147194
t.Errorf("group missing prompt %q", name)
148195
}
149196
}
197+
198+
expectedScope := tc.wantCacheScope
199+
if expectedScope == "" {
200+
expectedScope = group.DefaultCacheScope
201+
}
202+
if g.GetCacheScope() != expectedScope {
203+
t.Errorf("CacheScope = %q, want %q", g.GetCacheScope(), expectedScope)
204+
}
205+
206+
expectedTTL := group.DefaultTTLMs
207+
if tc.wantTTLMs != nil {
208+
expectedTTL = *tc.wantTTLMs
209+
}
210+
if g.GetTTLMs() != expectedTTL {
211+
t.Errorf("TTLMs = %d, want %d", g.GetTTLMs(), expectedTTL)
212+
}
150213
})
151214
}
152215
}
@@ -217,3 +280,80 @@ func TestGroup_Contains(t *testing.T) {
217280
t.Errorf("group reports an absent prompt")
218281
}
219282
}
283+
284+
func TestParseFromYamlGroup(t *testing.T) {
285+
tcs := []struct {
286+
desc string
287+
in string
288+
want server.GroupConfigs
289+
}{
290+
{
291+
desc: "basic group",
292+
in: `
293+
kind: group
294+
name: my-group
295+
ttlMs: 60000
296+
cacheScope: private
297+
`,
298+
want: map[string]group.GroupConfig{
299+
"my-group": {
300+
Name: "my-group",
301+
TTLMs: intPtr(60000),
302+
CacheScope: "private",
303+
},
304+
},
305+
},
306+
}
307+
for _, tc := range tcs {
308+
t.Run(tc.desc, func(t *testing.T) {
309+
// Parse contents
310+
_, _, _, _, _, got, err := server.UnmarshalPrimitiveConfig(context.Background(), testutils.FormatYaml(tc.in))
311+
if err != nil {
312+
t.Fatalf("unable to unmarshal: %s", err)
313+
}
314+
if !cmp.Equal(tc.want, got) {
315+
t.Fatalf("incorrect parse: want %v, got %v", tc.want, got)
316+
}
317+
})
318+
}
319+
}
320+
321+
func TestFailParseFromYaml(t *testing.T) {
322+
tcs := []struct {
323+
desc string
324+
in string
325+
err string
326+
}{
327+
{
328+
desc: "invalid cacheScope",
329+
in: `
330+
kind: group
331+
name: my-group
332+
cacheScope: secret
333+
`,
334+
err: "Field validation for 'CacheScope' failed on the 'oneof' tag",
335+
},
336+
{
337+
desc: "invalid ttlMs",
338+
in: `
339+
kind: group
340+
name: my-group
341+
ttlMs: -100
342+
`,
343+
err: "Field validation for 'TTLMs' failed on the 'gte' tag",
344+
},
345+
}
346+
for _, tc := range tcs {
347+
t.Run(tc.desc, func(t *testing.T) {
348+
// Parse contents
349+
_, _, _, _, _, _, err := server.UnmarshalPrimitiveConfig(context.Background(), testutils.FormatYaml(tc.in))
350+
if err == nil {
351+
t.Fatalf("expect parsing to fail")
352+
}
353+
errStr := err.Error()
354+
if !strings.Contains(errStr, tc.err) {
355+
t.Fatalf("unexpected error: got %q, want it to contain %q", errStr, tc.err)
356+
}
357+
})
358+
}
359+
}

‎internal/server/mcp/v20260728/manifests.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ func GenerateListToolsResult(pMgr *primitives.PrimitiveManager, g group.Group, u
128128
ResultType: resultTypeComplete,
129129
},
130130
CacheableResult: CacheableResult{
131-
TtlMs: 300000, // 5 minutes
132-
CacheScope: cacheScopePublic,
131+
TtlMs: g.GetTTLMs(),
132+
CacheScope: cacheScope(g.GetCacheScope()),
133133
},
134134
}
135135
return res, nil
@@ -170,8 +170,8 @@ func GenerateListPromptsResult(pMgr *primitives.PrimitiveManager, g group.Group)
170170
ResultType: resultTypeComplete,
171171
},
172172
CacheableResult: CacheableResult{
173-
TtlMs: 300000, // 5 minutes
174-
CacheScope: cacheScopePublic,
173+
TtlMs: g.GetTTLMs(),
174+
CacheScope: cacheScope(g.GetCacheScope()),
175175
},
176176
}
177177
return res, nil

0 commit comments

Comments
 (0)