Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions internal/group/group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package group

import (
"fmt"

"github.com/googleapis/mcp-toolbox/internal/prompts"
"github.com/googleapis/mcp-toolbox/internal/tools"
)

// GroupConfig is the parsed configuration for a group: a single named collection
// that holds both tools and prompts. Its description doubles as the MCP server
// instructions for clients connected to the group.
type GroupConfig struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
ToolNames []string `yaml:"tools"`
PromptNames []string `yaml:"prompts"`
}

// Group is an initialized group and the source of truth from which the legacy
// toolset and promptset views are derived (see ToToolset and ToPromptset). It
// holds the fully-initialized toolset and promptset so the derived views retain
// their O(1) lookup sets instead of being rebuilt on every projection.
type Group struct {
GroupConfig
toolset tools.Toolset
promptset prompts.Promptset
}

// Initialize validates the declared tools and prompts against the provided maps
// and builds the derived toolset and promptset. It delegates to
// tools.ToolsetConfig.Initialize and prompts.PromptsetConfig.Initialize so that
// validation and manifest-building stay identical to the legacy types.
func (gc GroupConfig) Initialize(serverVersion string, toolsMap map[string]tools.Tool, promptsMap map[string]prompts.Prompt) (Group, error) {
if !tools.IsValidName(gc.Name) {
return Group{}, fmt.Errorf("invalid group name: %s", gc.Name)
}

toolset, err := tools.ToolsetConfig{Name: gc.Name, ToolNames: gc.ToolNames}.Initialize(serverVersion, toolsMap)
if err != nil {
return Group{}, err
}
promptset, err := prompts.PromptsetConfig{Name: gc.Name, PromptNames: gc.PromptNames}.Initialize(serverVersion, promptsMap)
if err != nil {
return Group{}, err
}

return Group{GroupConfig: gc, toolset: toolset, promptset: promptset}, nil
}

// ToToolset returns the derived toolset view, keyed by the group's name, so that
// existing toolset call sites keep working unchanged.
func (g Group) ToToolset() tools.Toolset {
return g.toolset
}

// ToPromptset returns the derived promptset view, keyed by the group's name, so
// that prompts scope to the connected group.
func (g Group) ToPromptset() prompts.Promptset {
return g.promptset
}
Comment thread
twishabansal marked this conversation as resolved.
201 changes: 201 additions & 0 deletions internal/group/group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package group_test

import (
"slices"
"strings"
"testing"

"github.com/googleapis/mcp-toolbox/internal/group"
"github.com/googleapis/mcp-toolbox/internal/prompts"
"github.com/googleapis/mcp-toolbox/internal/testutils"
"github.com/googleapis/mcp-toolbox/internal/tools"
"github.com/googleapis/mcp-toolbox/internal/util/parameters"
)

const serverVersion = "test-version"

func testFixtures() (map[string]tools.Tool, map[string]prompts.Prompt) {
toolsMap := map[string]tools.Tool{
"tool1": testutils.NewMockTool("tool1", "first tool", []parameters.Parameter{}, false, false),
"tool2": testutils.NewMockTool("tool2", "second tool", []parameters.Parameter{}, false, false),
}
promptsMap := map[string]prompts.Prompt{
"prompt1": testutils.NewMockPrompt("prompt1", "first prompt", prompts.Arguments{}),
"prompt2": testutils.NewMockPrompt("prompt2", "second prompt", prompts.Arguments{}),
}
return toolsMap, promptsMap
}

func TestGroupConfig_Initialize(t *testing.T) {
t.Parallel()
toolsMap, promptsMap := testFixtures()

testCases := []struct {
name string
config group.GroupConfig
wantTools []string
wantPrompts []string
wantErr string
}{
{
name: "tools and prompts",
config: group.GroupConfig{
Name: "mygroup",
Description: "a group",
ToolNames: []string{"tool1", "tool2"},
PromptNames: []string{"prompt1", "prompt2"},
},
wantTools: []string{"tool1", "tool2"},
wantPrompts: []string{"prompt1", "prompt2"},
},
{
name: "tools only",
config: group.GroupConfig{
Name: "toolsonly",
ToolNames: []string{"tool1"},
},
wantTools: []string{"tool1"},
wantPrompts: []string{},
},
{
name: "prompts only",
config: group.GroupConfig{
Name: "promptsonly",
PromptNames: []string{"prompt1"},
},
wantTools: []string{},
wantPrompts: []string{"prompt1"},
},
{
name: "default nameless group",
config: group.GroupConfig{
Name: "",
ToolNames: []string{"tool1"},
PromptNames: []string{"prompt1"},
},
wantTools: []string{"tool1"},
wantPrompts: []string{"prompt1"},
},
{
name: "invalid group name",
config: group.GroupConfig{
Name: "bad name!",
ToolNames: []string{"tool1"},
},
wantErr: "invalid group name",
},
{
name: "missing tool",
config: group.GroupConfig{
Name: "g",
ToolNames: []string{"nope"},
},
wantErr: "tool does not exist: nope",
},
{
name: "missing prompt",
config: group.GroupConfig{
Name: "g",
PromptNames: []string{"nope"},
},
wantErr: "prompt does not exist: nope",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g, err := tc.config.Initialize(serverVersion, toolsMap, promptsMap)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %q, want it to contain %q", err.Error(), tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ts := g.ToToolset()
if got := toolNames(ts.Tools); !slices.Equal(got, tc.wantTools) {
t.Errorf("tools = %v, want %v", got, tc.wantTools)
}
ps := g.ToPromptset()
if len(ps.Prompts) != len(tc.wantPrompts) {
t.Errorf("got %d prompts, want %d", len(ps.Prompts), len(tc.wantPrompts))
}
for _, name := range tc.wantPrompts {
if !ps.ContainsPrompt(name) {
t.Errorf("derived promptset missing prompt %q", name)
}
}
if ts.Manifest.ServerVersion != serverVersion {
t.Errorf("tools manifest server version = %q, want %q", ts.Manifest.ServerVersion, serverVersion)
}
if ps.Manifest.ServerVersion != serverVersion {
t.Errorf("prompts manifest server version = %q, want %q", ps.Manifest.ServerVersion, serverVersion)
}
})
}
}

func TestGroup_Projections(t *testing.T) {
t.Parallel()
toolsMap, promptsMap := testFixtures()

g, err := group.GroupConfig{
Name: "mygroup",
Description: "a group",
ToolNames: []string{"tool1", "tool2"},
PromptNames: []string{"prompt1", "prompt2"},
}.Initialize(serverVersion, toolsMap, promptsMap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

ts := g.ToToolset()
if ts.Name != "mygroup" {
t.Errorf("toolset name = %q, want %q", ts.Name, "mygroup")
}
if !ts.ContainsTool("tool1") || !ts.ContainsTool("tool2") {
t.Errorf("derived toolset missing expected tools")
}
if ts.ContainsTool("tool3") {
t.Errorf("derived toolset reports an absent tool")
}

ps := g.ToPromptset()
if ps.Name != "mygroup" {
t.Errorf("promptset name = %q, want %q", ps.Name, "mygroup")
}
if !ps.ContainsPrompt("prompt1") || !ps.ContainsPrompt("prompt2") {
t.Errorf("derived promptset missing expected prompts")
}
if ps.ContainsPrompt("prompt3") {
t.Errorf("derived promptset reports an absent prompt")
}
}

func toolNames(ts []*tools.Tool) []string {
names := make([]string, 0, len(ts))
for _, t := range ts {
names = append(names, (*t).GetName())
}
return names
}
Loading