Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 6 additions & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(enabledToolsets).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithTools(cfg.EnabledTools).
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))

// Apply token scope filtering if scopes are known (for PAT filtering)
Expand All @@ -235,6 +235,11 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))
}

// Check for unrecognized tools and error out (unlike toolsets which just warn)
if unrecognized := inventory.UnrecognizedTools(); len(unrecognized) > 0 {
return nil, fmt.Errorf("unrecognized tools: %s", strings.Join(unrecognized, ", "))
}

// Register GitHub tools/resources/prompts from the inventory.
// In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
// is empty - users enable toolsets at runtime via the dynamic tools below (but can
Expand Down
38 changes: 35 additions & 3 deletions pkg/inventory/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func (b *Builder) WithToolsets(toolsetIDs []string) *Builder {
// WithTools specifies additional tools that bypass toolset filtering.
// These tools are additive - they will be included even if their toolset is not enabled.
// Read-only filtering still applies to these tools.
// Input is cleaned (trimmed, deduplicated) during Build().
// Deprecated tool aliases are automatically resolved to their canonical names during Build().
// Returns self for chaining.
func (b *Builder) WithTools(toolNames []string) *Builder {
Expand All @@ -127,6 +128,24 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder {
return b
}

// cleanTools trims whitespace and removes duplicates from tool names.
// Empty strings after trimming are excluded.
func cleanTools(tools []string) []string {
seen := make(map[string]bool)
var cleaned []string
for _, name := range tools {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if !seen[trimmed] {
seen[trimmed] = true
cleaned = append(cleaned, trimmed)
}
}
return cleaned
}

// Build creates the final Inventory with all configuration applied.
// This processes toolset filtering, tool name resolution, and sets up
// the inventory for use. The returned Inventory is ready for use with
Expand All @@ -145,10 +164,19 @@ func (b *Builder) Build() *Inventory {
// Process toolsets and pre-compute metadata in a single pass
r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets()

// Process additional tools (resolve aliases)
// Build set of valid tool names for validation
validToolNames := make(map[string]bool, len(b.tools))
for i := range b.tools {
validToolNames[b.tools[i].Tool.Name] = true
}

// Process additional tools (clean, resolve aliases, and track unrecognized)
if len(b.additionalTools) > 0 {
r.additionalTools = make(map[string]bool, len(b.additionalTools))
for _, name := range b.additionalTools {
cleanedTools := cleanTools(b.additionalTools)

r.additionalTools = make(map[string]bool, len(cleanedTools))
var unrecognizedTools []string
for _, name := range cleanedTools {
// Always include the original name - this handles the case where
// the tool exists but is controlled by a feature flag that's OFF.
r.additionalTools[name] = true
Expand All @@ -157,8 +185,12 @@ func (b *Builder) Build() *Inventory {
// the new consolidated tool is available.
if canonical, isAlias := b.deprecatedAliases[name]; isAlias {
r.additionalTools[canonical] = true
} else if !validToolNames[name] {
// Not a valid tool and not a deprecated alias - track as unrecognized
unrecognizedTools = append(unrecognizedTools, name)
}
}
r.unrecognizedTools = unrecognizedTools
}

return r
Expand Down
10 changes: 10 additions & 0 deletions pkg/inventory/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type Inventory struct {
filters []ToolFilter
// unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets
unrecognizedToolsets []string
// unrecognizedTools holds tool names that were requested via WithTools but don't exist
unrecognizedTools []string
}

// UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't
Expand All @@ -66,6 +68,13 @@ func (r *Inventory) UnrecognizedToolsets() []string {
return r.unrecognizedToolsets
}

// UnrecognizedTools returns tool names that were passed to WithTools but don't
// match any registered tools (and are not deprecated aliases). This is used to
// error out when invalid tool names are specified.
func (r *Inventory) UnrecognizedTools() []string {
return r.unrecognizedTools
}

// MCP method constants for use with ForMCPRequest.
const (
MCPMethodInitialize = "initialize"
Expand Down Expand Up @@ -112,6 +121,7 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory {
featureChecker: r.featureChecker,
filters: r.filters, // shared, not modified
unrecognizedToolsets: r.unrecognizedToolsets,
unrecognizedTools: r.unrecognizedTools,
}

// Helper to clear all item types
Expand Down
71 changes: 71 additions & 0 deletions pkg/inventory/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,77 @@ func TestUnrecognizedToolsets(t *testing.T) {
}
}

func TestUnrecognizedTools(t *testing.T) {
tools := []ServerTool{
mockTool("tool1", "toolset1", true),
mockTool("tool2", "toolset2", true),
}

deprecatedAliases := map[string]string{
"old_tool": "tool1",
}

tests := []struct {
name string
withTools []string
expectedUnrecognized []string
}{
{
name: "all valid",
withTools: []string{"tool1", "tool2"},
expectedUnrecognized: nil,
},
{
name: "one invalid",
withTools: []string{"tool1", "blabla"},
expectedUnrecognized: []string{"blabla"},
},
{
name: "multiple invalid",
withTools: []string{"invalid1", "tool1", "invalid2"},
expectedUnrecognized: []string{"invalid1", "invalid2"},
},
{
name: "deprecated alias is valid",
withTools: []string{"old_tool"},
expectedUnrecognized: nil,
},
{
name: "mixed valid and deprecated alias",
withTools: []string{"old_tool", "tool2"},
expectedUnrecognized: nil,
},
{
name: "empty input",
withTools: []string{},
expectedUnrecognized: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inv := NewBuilder().
SetTools(tools).
WithDeprecatedAliases(deprecatedAliases).
WithToolsets([]string{"all"}).
WithTools(tt.withTools).
Build()
unrecognized := inv.UnrecognizedTools()

if len(unrecognized) != len(tt.expectedUnrecognized) {
t.Fatalf("Expected %d unrecognized, got %d: %v",
len(tt.expectedUnrecognized), len(unrecognized), unrecognized)
}

for i, expected := range tt.expectedUnrecognized {
if unrecognized[i] != expected {
t.Errorf("Expected unrecognized[%d] = %q, got %q", i, expected, unrecognized[i])
}
}
})
}
}

func TestWithTools(t *testing.T) {
tools := []ServerTool{
mockTool("tool1", "toolset1", true),
Expand Down
Loading