Skip to content

Commit 2f0ef4a

Browse files
feat: Add ToolsetRegistry with scope-aware NewToolsetGroup
Add ToolsetRegistry struct that holds toolset definitions and tools, with a NewToolsetGroup method that accepts ToolsetGroupConfig: - ReadOnly: restricts to read-only tools - ActiveToolsets: which toolsets to enable - AvailableScopes: filter tools by OAuth scope requirements - nil: no scope checking (all tools available) - []: empty means no scopes (only tools with no requirements) - [...]: tools must have required scopes in the list This enables dynamic tool filtering based on the user's actual OAuth token scopes, ensuring only authorized tools are exposed.
1 parent 06655a0 commit 2f0ef4a

File tree

2 files changed

+325
-7
lines changed

2 files changed

+325
-7
lines changed

pkg/toolsets/toolsets.go

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -213,22 +213,58 @@ type ToolsetMetadata struct {
213213
Description string
214214
}
215215

216-
// NewToolsetGroupFromTools creates a ToolsetGroup from a list of ServerTools.
216+
// ToolsetRegistry holds a collection of toolset definitions and their tools.
217+
// It provides a NewToolsetGroup method to create configured ToolsetGroups.
218+
type ToolsetRegistry struct {
219+
toolsetMetadatas []ToolsetMetadata
220+
tools []ServerTool
221+
}
222+
223+
// NewToolsetRegistry creates a new ToolsetRegistry with the given toolset metadata and tools.
224+
func NewToolsetRegistry(toolsetMetadatas []ToolsetMetadata, tools []ServerTool) *ToolsetRegistry {
225+
return &ToolsetRegistry{
226+
toolsetMetadatas: toolsetMetadatas,
227+
tools: tools,
228+
}
229+
}
230+
231+
// ToolsetGroupConfig specifies the configuration for creating a ToolsetGroup.
232+
type ToolsetGroupConfig struct {
233+
// ReadOnly when true restricts the group to read-only tools
234+
ReadOnly bool
235+
// ActiveToolsets specifies which toolsets should be enabled.
236+
// If nil, no toolsets are enabled by default.
237+
ActiveToolsets []string
238+
// AvailableScopes specifies the OAuth scopes available to the user.
239+
// - nil means scopes are not checked (all tools available)
240+
// - empty slice means no scopes (only tools requiring no scopes are available)
241+
// - non-empty slice means only tools whose required scopes are satisfied are available
242+
AvailableScopes []string
243+
}
244+
245+
// NewToolsetGroup creates a ToolsetGroup from the registry with the given configuration.
217246
// Tools are automatically categorized as read or write based on their ReadOnlyHint annotation.
218247
// Tools are grouped into toolsets based on the "toolset" field in their Meta.
219-
// The toolsetMetadatas slice provides IDs and descriptions for each toolset.
220-
func NewToolsetGroupFromTools(readOnly bool, toolsetMetadatas []ToolsetMetadata, tools ...ServerTool) *ToolsetGroup {
221-
tsg := NewToolsetGroup(readOnly)
248+
// If AvailableScopes is non-nil, tools are filtered based on scope requirements.
249+
func (r *ToolsetRegistry) NewToolsetGroup(config ToolsetGroupConfig) *ToolsetGroup {
250+
tsg := NewToolsetGroup(config.ReadOnly)
222251

223252
// Build a map for quick lookup of toolset metadata by ID
224253
metadataByID := make(map[string]ToolsetMetadata)
225-
for _, meta := range toolsetMetadatas {
254+
for _, meta := range r.toolsetMetadatas {
226255
metadataByID[meta.ID] = meta
227256
}
228257

229-
// Group tools by toolset name
258+
// Group tools by toolset name, filtering by scopes if specified
230259
toolsByToolset := make(map[string][]ServerTool)
231-
for _, tool := range tools {
260+
for _, tool := range r.tools {
261+
// Check scope requirements if AvailableScopes is specified
262+
if config.AvailableScopes != nil {
263+
if !toolScopeSatisfied(tool, config.AvailableScopes) {
264+
continue // Skip tools that don't have required scopes
265+
}
266+
}
267+
232268
toolsetID := getToolsetFromMeta(tool.Tool.Meta)
233269
if toolsetID == "" {
234270
panic(fmt.Sprintf("tool %q has no toolset in Meta", tool.Tool.Name))
@@ -257,9 +293,80 @@ func NewToolsetGroupFromTools(readOnly bool, toolsetMetadatas []ToolsetMetadata,
257293
tsg.AddToolset(ts)
258294
}
259295

296+
// Enable active toolsets
297+
if len(config.ActiveToolsets) > 0 {
298+
// Ignore errors for non-existent toolsets (they may have been filtered out by scopes)
299+
_ = tsg.EnableToolsets(config.ActiveToolsets, nil)
300+
}
301+
260302
return tsg
261303
}
262304

305+
// toolScopeSatisfied checks if the tool's required scopes are satisfied by available scopes.
306+
// Returns true if the tool has no scope requirements or if all requirements are met.
307+
func toolScopeSatisfied(tool ServerTool, availableScopes []string) bool {
308+
if tool.Tool.Meta == nil {
309+
return true // No meta means no scope requirements
310+
}
311+
312+
requiredScopes, ok := tool.Tool.Meta[scopeMetaKey]
313+
if !ok {
314+
return true // No scope requirements
315+
}
316+
317+
// Handle both []string and []any (from JSON unmarshaling)
318+
var required []string
319+
switch v := requiredScopes.(type) {
320+
case []string:
321+
required = v
322+
case []any:
323+
required = make([]string, 0, len(v))
324+
for _, s := range v {
325+
if str, ok := s.(string); ok {
326+
required = append(required, str)
327+
}
328+
}
329+
default:
330+
return true // Unknown format, allow
331+
}
332+
333+
if len(required) == 0 {
334+
return true // Empty requirements
335+
}
336+
337+
// Check if any available scope satisfies the requirements
338+
// For now, simple string matching - scope hierarchy should be handled by caller
339+
availableSet := make(map[string]bool)
340+
for _, s := range availableScopes {
341+
availableSet[s] = true
342+
}
343+
344+
for _, req := range required {
345+
if !availableSet[req] {
346+
return false
347+
}
348+
}
349+
350+
return true
351+
}
352+
353+
// scopeMetaKey is the key used to store OAuth scopes in the mcp.Tool.Meta field.
354+
const scopeMetaKey = "requiredOAuthScopes"
355+
356+
// NewToolsetGroupFromTools creates a ToolsetGroup from a list of ServerTools.
357+
// Tools are automatically categorized as read or write based on their ReadOnlyHint annotation.
358+
// Tools are grouped into toolsets based on the "toolset" field in their Meta.
359+
// The toolsetMetadatas slice provides IDs and descriptions for each toolset.
360+
//
361+
// Deprecated: Use NewToolsetRegistry and ToolsetRegistry.NewToolsetGroup instead.
362+
func NewToolsetGroupFromTools(readOnly bool, toolsetMetadatas []ToolsetMetadata, tools ...ServerTool) *ToolsetGroup {
363+
registry := NewToolsetRegistry(toolsetMetadatas, tools)
364+
return registry.NewToolsetGroup(ToolsetGroupConfig{
365+
ReadOnly: readOnly,
366+
AvailableScopes: nil, // No scope filtering
367+
})
368+
}
369+
263370
// getToolsetFromMeta extracts the toolset name from tool metadata
264371
func getToolsetFromMeta(meta mcp.Meta) string {
265372
if meta == nil {

pkg/toolsets/toolsets_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,24 @@ func mockToolWithMeta(name string, toolsetName string, readOnly bool) ServerTool
408408
}
409409
}
410410

411+
// mockToolWithScopes creates a ServerTool with metadata including required scopes
412+
func mockToolWithScopes(name string, toolsetName string, readOnly bool, requiredScopes []string) ServerTool {
413+
meta := mcp.Meta{"toolset": toolsetName}
414+
if requiredScopes != nil {
415+
meta["requiredOAuthScopes"] = requiredScopes
416+
}
417+
return ServerTool{
418+
Tool: mcp.Tool{
419+
Name: name,
420+
Meta: meta,
421+
Annotations: &mcp.ToolAnnotations{
422+
ReadOnlyHint: readOnly,
423+
},
424+
},
425+
RegisterFunc: func(_ *mcp.Server) {},
426+
}
427+
}
428+
411429
func TestNewToolsetGroupFromTools(t *testing.T) {
412430
toolsetMetadatas := []ToolsetMetadata{
413431
{ID: "repos", Description: "Repository tools"},
@@ -599,3 +617,196 @@ func TestGetToolsetFromMeta(t *testing.T) {
599617
})
600618
}
601619
}
620+
621+
func TestToolsetRegistry_NewToolsetGroup(t *testing.T) {
622+
toolsetMetadatas := []ToolsetMetadata{
623+
{ID: "repos", Description: "Repository tools"},
624+
{ID: "issues", Description: "Issue tools"},
625+
}
626+
627+
tools := []ServerTool{
628+
mockToolWithMeta("get_repo", "repos", true),
629+
mockToolWithMeta("create_repo", "repos", false),
630+
mockToolWithMeta("get_issue", "issues", true),
631+
mockToolWithMeta("create_issue", "issues", false),
632+
}
633+
634+
registry := NewToolsetRegistry(toolsetMetadatas, tools)
635+
636+
t.Run("basic creation", func(t *testing.T) {
637+
tsg := registry.NewToolsetGroup(ToolsetGroupConfig{})
638+
639+
if len(tsg.Toolsets) != 2 {
640+
t.Fatalf("expected 2 toolsets, got %d", len(tsg.Toolsets))
641+
}
642+
643+
// Verify toolsets are not enabled by default
644+
if tsg.IsEnabled("repos") {
645+
t.Error("expected repos to be disabled by default")
646+
}
647+
})
648+
649+
t.Run("with active toolsets", func(t *testing.T) {
650+
tsg := registry.NewToolsetGroup(ToolsetGroupConfig{
651+
ActiveToolsets: []string{"repos"},
652+
})
653+
654+
if !tsg.IsEnabled("repos") {
655+
t.Error("expected repos to be enabled")
656+
}
657+
if tsg.IsEnabled("issues") {
658+
t.Error("expected issues to be disabled")
659+
}
660+
})
661+
662+
t.Run("with read-only mode", func(t *testing.T) {
663+
tsg := registry.NewToolsetGroup(ToolsetGroupConfig{
664+
ReadOnly: true,
665+
ActiveToolsets: []string{"repos"},
666+
})
667+
668+
reposToolset := tsg.Toolsets["repos"]
669+
if !reposToolset.readOnly {
670+
t.Error("expected toolset to be in read-only mode")
671+
}
672+
673+
activeTools := reposToolset.GetActiveTools()
674+
if len(activeTools) != 1 {
675+
t.Errorf("expected 1 active tool in read-only mode, got %d", len(activeTools))
676+
}
677+
})
678+
}
679+
680+
func TestToolsetRegistry_NewToolsetGroup_WithScopes(t *testing.T) {
681+
toolsetMetadatas := []ToolsetMetadata{
682+
{ID: "repos", Description: "Repository tools"},
683+
{ID: "issues", Description: "Issue tools"},
684+
}
685+
686+
tools := []ServerTool{
687+
mockToolWithScopes("get_repo", "repos", true, nil), // No scope required
688+
mockToolWithScopes("create_repo", "repos", false, []string{"repo"}), // Requires repo scope
689+
mockToolWithScopes("get_issue", "issues", true, []string{"repo"}), // Requires repo scope
690+
mockToolWithScopes("public_issue", "issues", true, []string{}), // Empty scopes (no requirement)
691+
}
692+
693+
registry := NewToolsetRegistry(toolsetMetadatas, tools)
694+
695+
t.Run("nil scopes allows all tools", func(t *testing.T) {
696+
tsg := registry.NewToolsetGroup(ToolsetGroupConfig{
697+
AvailableScopes: nil,
698+
})
699+
700+
reposToolset := tsg.Toolsets["repos"]
701+
if len(reposToolset.readTools)+len(reposToolset.writeTools) != 2 {
702+
t.Errorf("expected 2 tools in repos, got %d", len(reposToolset.readTools)+len(reposToolset.writeTools))
703+
}
704+
})
705+
706+
t.Run("empty scopes filters tools requiring scopes", func(t *testing.T) {
707+
tsg := registry.NewToolsetGroup(ToolsetGroupConfig{
708+
AvailableScopes: []string{}, // No scopes available
709+
})
710+
711+
// repos should only have get_repo (no scope required)
712+
reposToolset, exists := tsg.Toolsets["repos"]
713+
if !exists {
714+
t.Fatal("expected repos toolset to exist")
715+
}
716+
if len(reposToolset.readTools) != 1 {
717+
t.Errorf("expected 1 read tool in repos (no scope required), got %d", len(reposToolset.readTools))
718+
}
719+
if len(reposToolset.writeTools) != 0 {
720+
t.Errorf("expected 0 write tools in repos (scope required), got %d", len(reposToolset.writeTools))
721+
}
722+
723+
// issues should only have public_issue (empty scopes)
724+
issuesToolset, exists := tsg.Toolsets["issues"]
725+
if !exists {
726+
t.Fatal("expected issues toolset to exist")
727+
}
728+
if len(issuesToolset.readTools) != 1 {
729+
t.Errorf("expected 1 read tool in issues, got %d", len(issuesToolset.readTools))
730+
}
731+
})
732+
733+
t.Run("with repo scope allows repo-scoped tools", func(t *testing.T) {
734+
tsg := registry.NewToolsetGroup(ToolsetGroupConfig{
735+
AvailableScopes: []string{"repo"},
736+
})
737+
738+
reposToolset := tsg.Toolsets["repos"]
739+
if len(reposToolset.readTools) != 1 {
740+
t.Errorf("expected 1 read tool, got %d", len(reposToolset.readTools))
741+
}
742+
if len(reposToolset.writeTools) != 1 {
743+
t.Errorf("expected 1 write tool, got %d", len(reposToolset.writeTools))
744+
}
745+
746+
issuesToolset := tsg.Toolsets["issues"]
747+
if len(issuesToolset.readTools) != 2 {
748+
t.Errorf("expected 2 read tools in issues, got %d", len(issuesToolset.readTools))
749+
}
750+
})
751+
}
752+
753+
func TestToolScopeSatisfied(t *testing.T) {
754+
tests := []struct {
755+
name string
756+
tool ServerTool
757+
availableScopes []string
758+
expected bool
759+
}{
760+
{
761+
name: "no meta",
762+
tool: ServerTool{Tool: mcp.Tool{Meta: nil}},
763+
availableScopes: []string{},
764+
expected: true,
765+
},
766+
{
767+
name: "no scope requirement",
768+
tool: mockToolWithScopes("test", "repos", true, nil),
769+
availableScopes: []string{},
770+
expected: true,
771+
},
772+
{
773+
name: "empty scope requirement",
774+
tool: mockToolWithScopes("test", "repos", true, []string{}),
775+
availableScopes: []string{},
776+
expected: true,
777+
},
778+
{
779+
name: "scope required and available",
780+
tool: mockToolWithScopes("test", "repos", true, []string{"repo"}),
781+
availableScopes: []string{"repo"},
782+
expected: true,
783+
},
784+
{
785+
name: "scope required but not available",
786+
tool: mockToolWithScopes("test", "repos", true, []string{"repo"}),
787+
availableScopes: []string{"gist"},
788+
expected: false,
789+
},
790+
{
791+
name: "multiple scopes required all available",
792+
tool: mockToolWithScopes("test", "repos", true, []string{"repo", "gist"}),
793+
availableScopes: []string{"repo", "gist", "user"},
794+
expected: true,
795+
},
796+
{
797+
name: "multiple scopes required one missing",
798+
tool: mockToolWithScopes("test", "repos", true, []string{"repo", "admin:org"}),
799+
availableScopes: []string{"repo"},
800+
expected: false,
801+
},
802+
}
803+
804+
for _, tt := range tests {
805+
t.Run(tt.name, func(t *testing.T) {
806+
result := toolScopeSatisfied(tt.tool, tt.availableScopes)
807+
if result != tt.expected {
808+
t.Errorf("expected %v, got %v", tt.expected, result)
809+
}
810+
})
811+
}
812+
}

0 commit comments

Comments
 (0)