2284 lines
74 KiB
Go
2284 lines
74 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// mustBuild is a test helper that calls Build() and fails the test if an error occurs.
|
|
// Use this for tests where Build() is not expected to fail.
|
|
func mustBuild(t *testing.T, b *Builder) *Inventory {
|
|
t.Helper()
|
|
inv, err := b.Build()
|
|
require.NoError(t, err)
|
|
return inv
|
|
}
|
|
|
|
// testToolsetMetadata returns a ToolsetMetadata for testing
|
|
func testToolsetMetadata(id string) ToolsetMetadata {
|
|
return ToolsetMetadata{
|
|
ID: ToolsetID(id),
|
|
Description: "Test toolset: " + id,
|
|
}
|
|
}
|
|
|
|
// testToolsetMetadataWithDefault returns a ToolsetMetadata with Default flag for testing
|
|
func testToolsetMetadataWithDefault(id string, isDefault bool) ToolsetMetadata {
|
|
return ToolsetMetadata{
|
|
ID: ToolsetID(id),
|
|
Description: "Test toolset: " + id,
|
|
Default: isDefault,
|
|
}
|
|
}
|
|
|
|
// mockToolWithDefault creates a mock tool with a default toolset flag
|
|
func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault bool) ServerTool {
|
|
return NewServerTool(
|
|
mcp.Tool{
|
|
Name: name,
|
|
Annotations: &mcp.ToolAnnotations{
|
|
ReadOnlyHint: readOnly,
|
|
},
|
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
|
},
|
|
testToolsetMetadataWithDefault(toolsetID, isDefault),
|
|
func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// mockTool creates a minimal ServerTool for testing
|
|
func mockTool(name string, toolsetID string, readOnly bool) ServerTool {
|
|
return NewServerTool(
|
|
mcp.Tool{
|
|
Name: name,
|
|
Annotations: &mcp.ToolAnnotations{
|
|
ReadOnlyHint: readOnly,
|
|
},
|
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
|
},
|
|
testToolsetMetadata(toolsetID),
|
|
func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestNewRegistryEmpty(t *testing.T) {
|
|
reg := mustBuild(t, NewBuilder())
|
|
if len(reg.AvailableTools(context.Background())) != 0 {
|
|
t.Fatalf("Expected tools to be empty")
|
|
}
|
|
if len(reg.AvailableResourceTemplates(context.Background())) != 0 {
|
|
t.Fatalf("Expected resourceTemplates to be empty")
|
|
}
|
|
if len(reg.AvailablePrompts(context.Background())) != 0 {
|
|
t.Fatalf("Expected prompts to be empty")
|
|
}
|
|
}
|
|
|
|
func TestNewRegistryWithTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", false),
|
|
mockTool("tool3", "toolset2", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools))
|
|
|
|
if len(reg.AllTools()) != 3 {
|
|
t.Errorf("Expected 3 tools, got %d", len(reg.AllTools()))
|
|
}
|
|
}
|
|
|
|
func TestAvailableTools_NoFilters(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool_b", "toolset1", true),
|
|
mockTool("tool_a", "toolset1", false),
|
|
mockTool("tool_c", "toolset2", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
available := reg.AvailableTools(context.Background())
|
|
|
|
if len(available) != 3 {
|
|
t.Fatalf("Expected 3 available tools, got %d", len(available))
|
|
}
|
|
|
|
// Verify deterministic sorting: by toolset ID, then tool name
|
|
expectedOrder := []string{"tool_a", "tool_b", "tool_c"}
|
|
for i, tool := range available {
|
|
if tool.Tool.Name != expectedOrder[i] {
|
|
t.Errorf("Tool at index %d: expected %s, got %s", i, expectedOrder[i], tool.Tool.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWithReadOnly(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("read_tool", "toolset1", true),
|
|
mockTool("write_tool", "toolset1", false),
|
|
}
|
|
|
|
// Build without read-only - should have both tools
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
allTools := reg.AvailableTools(context.Background())
|
|
if len(allTools) != 2 {
|
|
t.Fatalf("Expected 2 tools without read-only, got %d", len(allTools))
|
|
}
|
|
|
|
// Build with read-only - should filter out write tools
|
|
readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true))
|
|
readOnlyTools := readOnlyReg.AvailableTools(context.Background())
|
|
if len(readOnlyTools) != 1 {
|
|
t.Fatalf("Expected 1 tool in read-only, got %d", len(readOnlyTools))
|
|
}
|
|
if readOnlyTools[0].Tool.Name != "read_tool" {
|
|
t.Errorf("Expected read_tool, got %s", readOnlyTools[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestWithToolsets(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset2", true),
|
|
mockTool("tool3", "toolset3", true),
|
|
}
|
|
|
|
// Build with all toolsets
|
|
allReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
allTools := allReg.AvailableTools(context.Background())
|
|
if len(allTools) != 3 {
|
|
t.Fatalf("Expected 3 tools without filter, got %d", len(allTools))
|
|
}
|
|
|
|
// Build with specific toolsets
|
|
filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"}))
|
|
filteredTools := filteredReg.AvailableTools(context.Background())
|
|
|
|
if len(filteredTools) != 2 {
|
|
t.Fatalf("Expected 2 filtered tools, got %d", len(filteredTools))
|
|
}
|
|
|
|
// Verify correct tools are included
|
|
toolNames := make(map[string]bool)
|
|
for _, tool := range filteredTools {
|
|
toolNames[tool.Tool.Name] = true
|
|
}
|
|
if !toolNames["tool1"] || !toolNames["tool3"] {
|
|
t.Errorf("Expected tool1 and tool3, got %v", toolNames)
|
|
}
|
|
}
|
|
|
|
func TestWithToolsetsTrimsWhitespace(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset2", true),
|
|
}
|
|
|
|
// Whitespace should be trimmed
|
|
filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "}))
|
|
filteredTools := filteredReg.AvailableTools(context.Background())
|
|
|
|
if len(filteredTools) != 2 {
|
|
t.Fatalf("Expected 2 tools after whitespace trimming, got %d", len(filteredTools))
|
|
}
|
|
}
|
|
|
|
func TestWithToolsetsDeduplicates(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
}
|
|
|
|
// Duplicates should be removed
|
|
filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "}))
|
|
filteredTools := filteredReg.AvailableTools(context.Background())
|
|
|
|
if len(filteredTools) != 1 {
|
|
t.Fatalf("Expected 1 tool after deduplication, got %d", len(filteredTools))
|
|
}
|
|
}
|
|
|
|
func TestWithToolsetsIgnoresEmptyStrings(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
}
|
|
|
|
// Empty strings should be ignored
|
|
filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""}))
|
|
filteredTools := filteredReg.AvailableTools(context.Background())
|
|
|
|
if len(filteredTools) != 1 {
|
|
t.Fatalf("Expected 1 tool, got %d", len(filteredTools))
|
|
}
|
|
}
|
|
|
|
func TestUnrecognizedToolsets(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset2", true),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input []string
|
|
expectedUnrecognized []string
|
|
}{
|
|
{
|
|
name: "all valid",
|
|
input: []string{"toolset1", "toolset2"},
|
|
expectedUnrecognized: nil,
|
|
},
|
|
{
|
|
name: "one invalid",
|
|
input: []string{"toolset1", "invalid_toolset"},
|
|
expectedUnrecognized: []string{"invalid_toolset"},
|
|
},
|
|
{
|
|
name: "multiple invalid",
|
|
input: []string{"typo1", "toolset1", "typo2"},
|
|
expectedUnrecognized: []string{"typo1", "typo2"},
|
|
},
|
|
{
|
|
name: "invalid with whitespace trimmed",
|
|
input: []string{" invalid_tool "},
|
|
expectedUnrecognized: []string{"invalid_tool"},
|
|
},
|
|
{
|
|
name: "empty input",
|
|
input: []string{},
|
|
expectedUnrecognized: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets(tt.input))
|
|
unrecognized := filtered.UnrecognizedToolsets()
|
|
|
|
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 TestBuildErrorsOnUnrecognizedTools(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
|
|
expectError bool
|
|
errorContains string
|
|
}{
|
|
{
|
|
name: "all valid",
|
|
withTools: []string{"tool1", "tool2"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "one invalid",
|
|
withTools: []string{"tool1", "blabla"},
|
|
expectError: true,
|
|
errorContains: "blabla",
|
|
},
|
|
{
|
|
name: "multiple invalid",
|
|
withTools: []string{"invalid1", "tool1", "invalid2"},
|
|
expectError: true,
|
|
errorContains: "invalid1",
|
|
},
|
|
{
|
|
name: "deprecated alias is valid",
|
|
withTools: []string{"old_tool"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "mixed valid and deprecated alias",
|
|
withTools: []string{"old_tool", "tool2"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty input",
|
|
withTools: []string{},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "whitespace trimmed from valid tool",
|
|
withTools: []string{" tool1 ", " tool2 "},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "whitespace trimmed from invalid tool",
|
|
withTools: []string{" invalid_tool "},
|
|
expectError: true,
|
|
errorContains: "invalid_tool",
|
|
},
|
|
{
|
|
name: "duplicate tools deduplicated",
|
|
withTools: []string{"tool1", "tool1"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "duplicate invalid tools deduplicated",
|
|
withTools: []string{"blabla", "blabla"},
|
|
expectError: true,
|
|
errorContains: "blabla",
|
|
},
|
|
{
|
|
name: "mixed whitespace and duplicates",
|
|
withTools: []string{" tool1 ", "tool1", " tool1 "},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty strings ignored",
|
|
withTools: []string{"", "tool1", " ", ""},
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
inv, err := NewBuilder().
|
|
SetTools(tools).
|
|
WithDeprecatedAliases(deprecatedAliases).
|
|
WithToolsets([]string{"all"}).
|
|
WithTools(tt.withTools).
|
|
Build()
|
|
|
|
if tt.expectError {
|
|
require.Error(t, err, "Expected error for unrecognized tools")
|
|
require.Contains(t, err.Error(), tt.errorContains)
|
|
require.Nil(t, inv)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, inv)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", true),
|
|
mockTool("tool3", "toolset2", true),
|
|
}
|
|
|
|
// WithTools adds additional tools that bypass toolset filtering
|
|
// When combined with WithToolsets([]), only the additional tools should be available
|
|
filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}))
|
|
filteredTools := filteredReg.AvailableTools(context.Background())
|
|
|
|
if len(filteredTools) != 2 {
|
|
t.Fatalf("Expected 2 filtered tools, got %d", len(filteredTools))
|
|
}
|
|
|
|
toolNames := make(map[string]bool)
|
|
for _, tool := range filteredTools {
|
|
toolNames[tool.Tool.Name] = true
|
|
}
|
|
if !toolNames["tool1"] || !toolNames["tool3"] {
|
|
t.Errorf("Expected tool1 and tool3, got %v", toolNames)
|
|
}
|
|
}
|
|
|
|
func TestChainedFilters(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("read1", "toolset1", true),
|
|
mockTool("write1", "toolset1", false),
|
|
mockTool("read2", "toolset2", true),
|
|
mockTool("write2", "toolset2", false),
|
|
}
|
|
|
|
// Chain read-only and toolset filter
|
|
filtered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"}))
|
|
result := filtered.AvailableTools(context.Background())
|
|
|
|
if len(result) != 1 {
|
|
t.Fatalf("Expected 1 tool after chained filters, got %d", len(result))
|
|
}
|
|
if result[0].Tool.Name != "read1" {
|
|
t.Errorf("Expected read1, got %s", result[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestToolsetIDs(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset_b", true),
|
|
mockTool("tool2", "toolset_a", true),
|
|
mockTool("tool3", "toolset_b", true), // duplicate toolset
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools))
|
|
ids := reg.ToolsetIDs()
|
|
|
|
if len(ids) != 2 {
|
|
t.Fatalf("Expected 2 unique toolset IDs, got %d", len(ids))
|
|
}
|
|
|
|
// Should be sorted
|
|
if ids[0] != "toolset_a" || ids[1] != "toolset_b" {
|
|
t.Errorf("Expected sorted IDs [toolset_a, toolset_b], got %v", ids)
|
|
}
|
|
}
|
|
|
|
func TestToolsetDescriptions(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset2", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools))
|
|
descriptions := reg.ToolsetDescriptions()
|
|
|
|
if len(descriptions) != 2 {
|
|
t.Fatalf("Expected 2 descriptions, got %d", len(descriptions))
|
|
}
|
|
|
|
if descriptions["toolset1"] != "Test toolset: toolset1" {
|
|
t.Errorf("Wrong description for toolset1: %s", descriptions["toolset1"])
|
|
}
|
|
}
|
|
|
|
func TestWithDeprecatedAliases(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("new_name", "toolset1", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{
|
|
"old_name": "new_name",
|
|
"get_issue": "issue_read",
|
|
}))
|
|
|
|
// Test resolving aliases
|
|
resolved, aliasesUsed := reg.ResolveToolAliases([]string{"old_name"})
|
|
if len(resolved) != 1 || resolved[0] != "new_name" {
|
|
t.Errorf("expected alias to resolve to 'new_name', got %v", resolved)
|
|
}
|
|
if len(aliasesUsed) != 1 || aliasesUsed["old_name"] != "new_name" {
|
|
t.Errorf("expected alias mapping, got %v", aliasesUsed)
|
|
}
|
|
}
|
|
|
|
func TestResolveToolAliases(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("issue_read", "toolset1", true),
|
|
mockTool("some_tool", "toolset1", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).
|
|
WithDeprecatedAliases(map[string]string{
|
|
"get_issue": "issue_read",
|
|
}))
|
|
|
|
// Test resolving a mix of aliases and canonical names
|
|
input := []string{"get_issue", "some_tool"}
|
|
resolved, aliasesUsed := reg.ResolveToolAliases(input)
|
|
|
|
if len(resolved) != 2 {
|
|
t.Fatalf("expected 2 resolved names, got %d", len(resolved))
|
|
}
|
|
if resolved[0] != "issue_read" {
|
|
t.Errorf("expected 'issue_read', got '%s'", resolved[0])
|
|
}
|
|
if resolved[1] != "some_tool" {
|
|
t.Errorf("expected 'some_tool' (unchanged), got '%s'", resolved[1])
|
|
}
|
|
|
|
if len(aliasesUsed) != 1 {
|
|
t.Fatalf("expected 1 alias used, got %d", len(aliasesUsed))
|
|
}
|
|
if aliasesUsed["get_issue"] != "issue_read" {
|
|
t.Errorf("expected aliasesUsed['get_issue'] = 'issue_read', got '%s'", aliasesUsed["get_issue"])
|
|
}
|
|
}
|
|
|
|
func TestFindToolByName(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("issue_read", "toolset1", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools))
|
|
|
|
// Find by name
|
|
tool, toolsetID, err := reg.FindToolByName("issue_read")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if tool.Tool.Name != "issue_read" {
|
|
t.Errorf("expected tool name 'issue_read', got '%s'", tool.Tool.Name)
|
|
}
|
|
if toolsetID != "toolset1" {
|
|
t.Errorf("expected toolset ID 'toolset1', got '%s'", toolsetID)
|
|
}
|
|
|
|
// Non-existent tool
|
|
_, _, err = reg.FindToolByName("nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent tool")
|
|
}
|
|
}
|
|
|
|
func TestWithToolsAdditive(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("issue_read", "toolset1", true),
|
|
mockTool("issue_write", "toolset1", false),
|
|
mockTool("repo_read", "toolset2", true),
|
|
}
|
|
|
|
// Test WithTools bypasses toolset filtering
|
|
// Enable only toolset2, but add issue_read as additional tool
|
|
filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"}))
|
|
|
|
available := filtered.AvailableTools(context.Background())
|
|
if len(available) != 2 {
|
|
t.Errorf("expected 2 tools (repo_read from toolset + issue_read additional), got %d", len(available))
|
|
}
|
|
|
|
// Verify both tools are present
|
|
toolNames := make(map[string]bool)
|
|
for _, tool := range available {
|
|
toolNames[tool.Tool.Name] = true
|
|
}
|
|
if !toolNames["issue_read"] {
|
|
t.Error("expected issue_read to be included as additional tool")
|
|
}
|
|
if !toolNames["repo_read"] {
|
|
t.Error("expected repo_read to be included from toolset2")
|
|
}
|
|
|
|
// Test WithTools respects read-only mode
|
|
readOnlyFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"}))
|
|
available = readOnlyFiltered.AvailableTools(context.Background())
|
|
|
|
// issue_write should be excluded because read-only applies to additional tools too
|
|
for _, tool := range available {
|
|
if tool.Tool.Name == "issue_write" {
|
|
t.Error("expected issue_write to be excluded in read-only mode")
|
|
}
|
|
}
|
|
|
|
// Test WithTools with non-existent tool (should error during Build)
|
|
_, err := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build()
|
|
require.Error(t, err, "expected error for non-existent tool")
|
|
require.Contains(t, err.Error(), "nonexistent")
|
|
}
|
|
|
|
func TestWithToolsResolvesAliases(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("issue_read", "toolset1", true),
|
|
}
|
|
|
|
// Using deprecated alias should resolve to canonical name
|
|
filtered := mustBuild(t, NewBuilder().SetTools(tools).
|
|
WithDeprecatedAliases(map[string]string{
|
|
"get_issue": "issue_read",
|
|
}).
|
|
WithToolsets([]string{}).
|
|
WithTools([]string{"get_issue"}))
|
|
available := filtered.AvailableTools(context.Background())
|
|
|
|
if len(available) != 1 {
|
|
t.Errorf("expected 1 tool, got %d", len(available))
|
|
}
|
|
if available[0].Tool.Name != "issue_read" {
|
|
t.Errorf("expected issue_read, got %s", available[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestHasToolset(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
|
|
if !reg.HasToolset("toolset1") {
|
|
t.Error("expected HasToolset to return true for existing toolset")
|
|
}
|
|
if reg.HasToolset("nonexistent") {
|
|
t.Error("expected HasToolset to return false for non-existent toolset")
|
|
}
|
|
}
|
|
|
|
func TestAllTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("read_tool", "toolset1", true),
|
|
mockTool("write_tool", "toolset1", false),
|
|
}
|
|
|
|
// Even with read-only filter, AllTools returns everything
|
|
readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true))
|
|
|
|
allTools := readOnlyReg.AllTools()
|
|
if len(allTools) != 2 {
|
|
t.Fatalf("Expected 2 tools from AllTools, got %d", len(allTools))
|
|
}
|
|
|
|
// But AvailableTools respects the filter
|
|
availableTools := readOnlyReg.AvailableTools(context.Background())
|
|
if len(availableTools) != 1 {
|
|
t.Fatalf("Expected 1 tool from AvailableTools, got %d", len(availableTools))
|
|
}
|
|
}
|
|
|
|
func TestServerToolIsReadOnly(t *testing.T) {
|
|
readTool := mockTool("read_tool", "toolset1", true)
|
|
writeTool := mockTool("write_tool", "toolset1", false)
|
|
|
|
if !readTool.IsReadOnly() {
|
|
t.Error("Expected read tool to be read-only")
|
|
}
|
|
if writeTool.IsReadOnly() {
|
|
t.Error("Expected write tool to not be read-only")
|
|
}
|
|
}
|
|
|
|
// mockResource creates a minimal ServerResourceTemplate for testing
|
|
func mockResource(name string, toolsetID string, uriTemplate string) ServerResourceTemplate {
|
|
return NewServerResourceTemplate(
|
|
testToolsetMetadata(toolsetID),
|
|
mcp.ResourceTemplate{
|
|
Name: name,
|
|
URITemplate: uriTemplate,
|
|
},
|
|
func(_ any) mcp.ResourceHandler {
|
|
return func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
|
|
return nil, nil
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
// mockPrompt creates a minimal ServerPrompt for testing
|
|
func mockPrompt(name string, toolsetID string) ServerPrompt {
|
|
return NewServerPrompt(
|
|
testToolsetMetadata(toolsetID),
|
|
mcp.Prompt{Name: name},
|
|
func(_ context.Context, _ *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
|
|
return nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestForMCPRequest_Initialize(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
mockTool("tool2", "issues", false),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
}
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodInitialize, "")
|
|
|
|
// Initialize should return empty - capabilities come from ServerOptions
|
|
if len(filtered.AvailableTools(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 tools for initialize, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 resources for initialize, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(filtered.AvailablePrompts(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 prompts for initialize, got %d", len(filtered.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ToolsList(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
mockTool("tool2", "issues", true),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
}
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodToolsList, "")
|
|
|
|
// tools/list should return all tools, no resources or prompts
|
|
if len(filtered.AvailableTools(context.Background())) != 2 {
|
|
t.Errorf("Expected 2 tools for tools/list, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 resources for tools/list, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(filtered.AvailablePrompts(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 prompts for tools/list, got %d", len(filtered.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ToolsCall(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("get_me", "context", true),
|
|
mockTool("create_issue", "issues", false),
|
|
mockTool("list_repos", "repos", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "get_me")
|
|
|
|
available := filtered.AvailableTools(context.Background())
|
|
if len(available) != 1 {
|
|
t.Fatalf("Expected 1 tool for tools/call with name, got %d", len(available))
|
|
}
|
|
if available[0].Tool.Name != "get_me" {
|
|
t.Errorf("Expected tool name 'get_me', got %q", available[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ToolsCall_NotFound(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("get_me", "context", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "nonexistent")
|
|
|
|
if len(filtered.AvailableTools(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 tools for nonexistent tool, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("get_me", "context", true),
|
|
mockTool("list_commits", "repos", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithDeprecatedAliases(map[string]string{
|
|
"old_get_me": "get_me",
|
|
}))
|
|
|
|
// Request using the deprecated alias
|
|
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "old_get_me")
|
|
|
|
available := filtered.AvailableTools(context.Background())
|
|
if len(available) != 1 {
|
|
t.Fatalf("Expected 1 tool when using deprecated alias, got %d", len(available))
|
|
}
|
|
if available[0].Tool.Name != "get_me" {
|
|
t.Errorf("Expected canonical name 'get_me', got %q", available[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ToolsCall_RespectsFilters(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("create_issue", "issues", false), // write tool
|
|
}
|
|
|
|
// Apply read-only filter at build time, then ForMCPRequest
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true))
|
|
filtered := reg.ForMCPRequest(MCPMethodToolsCall, "create_issue")
|
|
|
|
// The tool exists in the filtered group, but AvailableTools respects read-only
|
|
available := filtered.AvailableTools(context.Background())
|
|
if len(available) != 0 {
|
|
t.Errorf("Expected 0 tools - write tool should be filtered by read-only, got %d", len(available))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ResourcesList(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"),
|
|
}
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodResourcesList, "")
|
|
|
|
if len(filtered.AvailableTools(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 tools for resources/list, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 2 {
|
|
t.Errorf("Expected 2 resources for resources/list, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(filtered.AvailablePrompts(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 prompts for resources/list, got %d", len(filtered.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ResourcesRead(t *testing.T) {
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}))
|
|
// Pass a concrete URI - all resources remain registered, SDK handles matching
|
|
filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://owner/repo")
|
|
|
|
// All resources should be available - SDK handles URI template matching internally
|
|
available := filtered.AvailableResourceTemplates(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 resources for resources/read (SDK handles matching), got %d", len(available))
|
|
}
|
|
}
|
|
func TestForMCPRequest_PromptsList(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
}
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
mockPrompt("prompt2", "issues"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodPromptsList, "")
|
|
|
|
if len(filtered.AvailableTools(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 tools for prompts/list, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 resources for prompts/list, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(filtered.AvailablePrompts(context.Background())) != 2 {
|
|
t.Errorf("Expected 2 prompts for prompts/list, got %d", len(filtered.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_PromptsGet(t *testing.T) {
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
mockPrompt("prompt2", "issues"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodPromptsGet, "prompt1")
|
|
|
|
available := filtered.AvailablePrompts(context.Background())
|
|
if len(available) != 1 {
|
|
t.Fatalf("Expected 1 prompt for prompts/get, got %d", len(available))
|
|
}
|
|
if available[0].Prompt.Name != "prompt1" {
|
|
t.Errorf("Expected prompt name 'prompt1', got %q", available[0].Prompt.Name)
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_UnknownMethod(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
}
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest("unknown/method", "")
|
|
|
|
// Unknown methods should return empty
|
|
if len(filtered.AvailableTools(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 tools for unknown method, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 resources for unknown method, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(filtered.AvailablePrompts(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 prompts for unknown method, got %d", len(filtered.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_DoesNotMutateOriginal(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
mockTool("tool2", "issues", true),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
}
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("prompt1", "repos"),
|
|
}
|
|
|
|
original := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
filtered := original.ForMCPRequest(MCPMethodToolsCall, "tool1")
|
|
|
|
// Original should be unchanged
|
|
if len(original.AvailableTools(context.Background())) != 2 {
|
|
t.Errorf("Original was mutated! Expected 2 tools, got %d", len(original.AvailableTools(context.Background())))
|
|
}
|
|
if len(original.AvailableResourceTemplates(context.Background())) != 1 {
|
|
t.Errorf("Original was mutated! Expected 1 resource, got %d", len(original.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(original.AvailablePrompts(context.Background())) != 1 {
|
|
t.Errorf("Original was mutated! Expected 1 prompt, got %d", len(original.AvailablePrompts(context.Background())))
|
|
}
|
|
|
|
// Filtered should have only the requested tool
|
|
if len(filtered.AvailableTools(context.Background())) != 1 {
|
|
t.Errorf("Expected 1 tool in filtered, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 resources in filtered, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
if len(filtered.AvailablePrompts(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 prompts in filtered, got %d", len(filtered.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ChainedWithOtherFilters(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockToolWithDefault("get_me", "context", true, true), // default toolset
|
|
mockToolWithDefault("create_issue", "issues", false, false), // not default
|
|
mockToolWithDefault("list_repos", "repos", true, true), // default toolset
|
|
mockToolWithDefault("delete_repo", "repos", false, true), // default but write
|
|
}
|
|
|
|
// Chain: default toolsets -> read-only -> specific method
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).
|
|
WithToolsets([]string{"default"}).
|
|
WithReadOnly(true))
|
|
filtered := reg.ForMCPRequest(MCPMethodToolsList, "")
|
|
|
|
available := filtered.AvailableTools(context.Background())
|
|
|
|
// Should have: get_me (context, read), list_repos (repos, read)
|
|
// Should NOT have: create_issue (issues not in default), delete_repo (write)
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 tools after filter chain, got %d", len(available))
|
|
}
|
|
|
|
toolNames := make(map[string]bool)
|
|
for _, tool := range available {
|
|
toolNames[tool.Tool.Name] = true
|
|
}
|
|
|
|
if !toolNames["get_me"] {
|
|
t.Error("Expected get_me to be available")
|
|
}
|
|
if !toolNames["list_repos"] {
|
|
t.Error("Expected list_repos to be available")
|
|
}
|
|
if toolNames["create_issue"] {
|
|
t.Error("create_issue should not be available (toolset not enabled)")
|
|
}
|
|
if toolNames["delete_repo"] {
|
|
t.Error("delete_repo should not be available (write tool in read-only mode)")
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ResourcesTemplatesList(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "repos", true),
|
|
}
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("res1", "repos", "repo://{owner}/{repo}"),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"}))
|
|
filtered := reg.ForMCPRequest(MCPMethodResourcesTemplatesList, "")
|
|
|
|
// Same behavior as resources/list
|
|
if len(filtered.AvailableTools(context.Background())) != 0 {
|
|
t.Errorf("Expected 0 tools, got %d", len(filtered.AvailableTools(context.Background())))
|
|
}
|
|
if len(filtered.AvailableResourceTemplates(context.Background())) != 1 {
|
|
t.Errorf("Expected 1 resource, got %d", len(filtered.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestMCPMethodConstants(t *testing.T) {
|
|
// Verify constants match expected MCP method names
|
|
tests := []struct {
|
|
constant string
|
|
expected string
|
|
}{
|
|
{MCPMethodInitialize, "initialize"},
|
|
{MCPMethodToolsList, "tools/list"},
|
|
{MCPMethodToolsCall, "tools/call"},
|
|
{MCPMethodResourcesList, "resources/list"},
|
|
{MCPMethodResourcesRead, "resources/read"},
|
|
{MCPMethodResourcesTemplatesList, "resources/templates/list"},
|
|
{MCPMethodPromptsList, "prompts/list"},
|
|
{MCPMethodPromptsGet, "prompts/get"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if tt.constant != tt.expected {
|
|
t.Errorf("Constant mismatch: got %q, expected %q", tt.constant, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// mockToolWithFlags creates a ServerTool with feature flags for testing
|
|
func mockToolWithFlags(name string, toolsetID string, readOnly bool, enableFlag, disableFlag string) ServerTool {
|
|
tool := mockTool(name, toolsetID, readOnly)
|
|
tool.FeatureFlagEnable = enableFlag
|
|
if disableFlag != "" {
|
|
tool.FeatureFlagDisable = []string{disableFlag}
|
|
}
|
|
return tool
|
|
}
|
|
|
|
func TestFeatureFlagEnable(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("always_available", "toolset1", true),
|
|
mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""),
|
|
}
|
|
|
|
// Without feature checker, feature-flag filtering is skipped: both tools pass
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 tools without feature checker (filtering skipped), got %d", len(available))
|
|
}
|
|
|
|
// With feature checker returning false, FeatureFlagEnable tool is excluded
|
|
checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil }
|
|
regFalse := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse))
|
|
availableFalse := regFalse.AvailableTools(context.Background())
|
|
if len(availableFalse) != 1 {
|
|
t.Fatalf("Expected 1 tool with false checker, got %d", len(availableFalse))
|
|
}
|
|
if availableFalse[0].Tool.Name != "always_available" {
|
|
t.Errorf("Expected always_available, got %s", availableFalse[0].Tool.Name)
|
|
}
|
|
|
|
// With feature checker returning true for "my_feature", tool should be included
|
|
checkerTrue := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == "my_feature", nil
|
|
}
|
|
regTrue := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue))
|
|
availableTrue := regTrue.AvailableTools(context.Background())
|
|
if len(availableTrue) != 2 {
|
|
t.Fatalf("Expected 2 tools with true checker, got %d", len(availableTrue))
|
|
}
|
|
}
|
|
|
|
func TestFeatureFlagDisable(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("always_available", "toolset1", true),
|
|
mockToolWithFlags("disabled_by_flag", "toolset1", true, "", "kill_switch"),
|
|
}
|
|
|
|
// Without feature checker, tool with FeatureFlagDisable should be included (flag is false)
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 tools without feature checker, got %d", len(available))
|
|
}
|
|
|
|
// With feature checker returning true for "kill_switch", tool should be excluded
|
|
checkerTrue := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == "kill_switch", nil
|
|
}
|
|
regFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue))
|
|
availableFiltered := regFiltered.AvailableTools(context.Background())
|
|
if len(availableFiltered) != 1 {
|
|
t.Fatalf("Expected 1 tool with kill_switch enabled, got %d", len(availableFiltered))
|
|
}
|
|
if availableFiltered[0].Tool.Name != "always_available" {
|
|
t.Errorf("Expected always_available, got %s", availableFiltered[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestFeatureFlagBoth(t *testing.T) {
|
|
// Tool that requires "new_feature" AND is disabled by "kill_switch"
|
|
tools := []ServerTool{
|
|
mockToolWithFlags("complex_tool", "toolset1", true, "new_feature", "kill_switch"),
|
|
}
|
|
|
|
// Enable flag not set -> excluded
|
|
checker1 := func(_ context.Context, _ string) (bool, error) { return false, nil }
|
|
reg1 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1))
|
|
if len(reg1.AvailableTools(context.Background())) != 0 {
|
|
t.Error("Tool should be excluded when enable flag is false")
|
|
}
|
|
|
|
// Enable flag set, disable flag not set -> included
|
|
checker2 := func(_ context.Context, flag string) (bool, error) { return flag == "new_feature", nil }
|
|
reg2 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2))
|
|
if len(reg2.AvailableTools(context.Background())) != 1 {
|
|
t.Error("Tool should be included when enable flag is true and disable flag is false")
|
|
}
|
|
|
|
// Enable flag set, disable flag also set -> excluded (disable wins)
|
|
checker3 := func(_ context.Context, _ string) (bool, error) { return true, nil }
|
|
reg3 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3))
|
|
if len(reg3.AvailableTools(context.Background())) != 0 {
|
|
t.Error("Tool should be excluded when both flags are true (disable wins)")
|
|
}
|
|
}
|
|
|
|
func TestFeatureFlagError(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""),
|
|
}
|
|
|
|
// Checker that returns error should treat as false (tool excluded)
|
|
checkerError := func(_ context.Context, _ string) (bool, error) {
|
|
return false, fmt.Errorf("simulated error")
|
|
}
|
|
reg := mustBuild(t, NewBuilder().SetTools(tools).WithFeatureChecker(checkerError))
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 0 {
|
|
t.Errorf("Expected 0 tools when checker errors, got %d", len(available))
|
|
}
|
|
}
|
|
|
|
func TestFeatureFlagResources(t *testing.T) {
|
|
resources := []ServerResourceTemplate{
|
|
mockResource("always_available", "toolset1", "uri1"),
|
|
{
|
|
Template: mcp.ResourceTemplate{Name: "needs_flag", URITemplate: "uri2"},
|
|
Toolset: testToolsetMetadata("toolset1"),
|
|
FeatureFlagEnable: "my_feature",
|
|
},
|
|
}
|
|
|
|
// Without checker, feature-flag filtering is skipped: both resources pass
|
|
reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}))
|
|
available := reg.AvailableResourceTemplates(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 resources without checker (filtering skipped), got %d", len(available))
|
|
}
|
|
|
|
// With checker returning true, both should be included
|
|
checker := func(_ context.Context, _ string) (bool, error) { return true, nil }
|
|
regWithChecker := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker))
|
|
if len(regWithChecker.AvailableResourceTemplates(context.Background())) != 2 {
|
|
t.Errorf("Expected 2 resources with checker, got %d", len(regWithChecker.AvailableResourceTemplates(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestFeatureFlagPrompts(t *testing.T) {
|
|
prompts := []ServerPrompt{
|
|
mockPrompt("always_available", "toolset1"),
|
|
{
|
|
Prompt: mcp.Prompt{Name: "needs_flag"},
|
|
Toolset: testToolsetMetadata("toolset1"),
|
|
FeatureFlagEnable: "my_feature",
|
|
},
|
|
}
|
|
|
|
// Without checker, feature-flag filtering is skipped: both prompts pass
|
|
reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}))
|
|
available := reg.AvailablePrompts(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 prompts without checker (filtering skipped), got %d", len(available))
|
|
}
|
|
|
|
// With checker returning true, both should be included
|
|
checker := func(_ context.Context, _ string) (bool, error) { return true, nil }
|
|
regWithChecker := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker))
|
|
if len(regWithChecker.AvailablePrompts(context.Background())) != 2 {
|
|
t.Errorf("Expected 2 prompts with checker, got %d", len(regWithChecker.AvailablePrompts(context.Background())))
|
|
}
|
|
}
|
|
|
|
func TestServerToolHasHandler(t *testing.T) {
|
|
// Tool with handler
|
|
toolWithHandler := mockTool("has_handler", "toolset1", true)
|
|
if !toolWithHandler.HasHandler() {
|
|
t.Error("Expected HasHandler() to return true for tool with handler")
|
|
}
|
|
|
|
// Tool without handler
|
|
toolWithoutHandler := ServerTool{
|
|
Tool: mcp.Tool{Name: "no_handler"},
|
|
Toolset: testToolsetMetadata("toolset1"),
|
|
}
|
|
if toolWithoutHandler.HasHandler() {
|
|
t.Error("Expected HasHandler() to return false for tool without handler")
|
|
}
|
|
}
|
|
|
|
func TestServerToolHandlerPanicOnNil(t *testing.T) {
|
|
tool := ServerTool{
|
|
Tool: mcp.Tool{Name: "no_handler"},
|
|
Toolset: testToolsetMetadata("toolset1"),
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("Expected Handler() to panic when HandlerFunc is nil")
|
|
}
|
|
}()
|
|
|
|
tool.Handler(nil)
|
|
}
|
|
|
|
// Tests for Enabled function on ServerTool
|
|
func TestServerToolEnabled(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
enabledFunc func(ctx context.Context) (bool, error)
|
|
expectedCount int
|
|
expectInResult bool
|
|
}{
|
|
{
|
|
name: "nil Enabled function - tool included",
|
|
enabledFunc: nil,
|
|
expectedCount: 1,
|
|
expectInResult: true,
|
|
},
|
|
{
|
|
name: "Enabled returns true - tool included",
|
|
enabledFunc: func(_ context.Context) (bool, error) {
|
|
return true, nil
|
|
},
|
|
expectedCount: 1,
|
|
expectInResult: true,
|
|
},
|
|
{
|
|
name: "Enabled returns false - tool excluded",
|
|
enabledFunc: func(_ context.Context) (bool, error) {
|
|
return false, nil
|
|
},
|
|
expectedCount: 0,
|
|
expectInResult: false,
|
|
},
|
|
{
|
|
name: "Enabled returns error - tool excluded",
|
|
enabledFunc: func(_ context.Context) (bool, error) {
|
|
return false, fmt.Errorf("simulated error")
|
|
},
|
|
expectedCount: 0,
|
|
expectInResult: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tool := mockTool("test_tool", "toolset1", true)
|
|
tool.Enabled = tt.enabledFunc
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}))
|
|
available := reg.AvailableTools(context.Background())
|
|
|
|
if len(available) != tt.expectedCount {
|
|
t.Errorf("Expected %d tools, got %d", tt.expectedCount, len(available))
|
|
}
|
|
|
|
found := false
|
|
for _, t := range available {
|
|
if t.Tool.Name == "test_tool" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found != tt.expectInResult {
|
|
t.Errorf("Expected tool in result: %v, got: %v", tt.expectInResult, found)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerToolEnabledWithContext(t *testing.T) {
|
|
type contextKey string
|
|
const userKey contextKey = "user"
|
|
|
|
// Tool that checks context for user
|
|
tool := mockTool("context_aware_tool", "toolset1", true)
|
|
tool.Enabled = func(ctx context.Context) (bool, error) {
|
|
user := ctx.Value(userKey)
|
|
return user != nil && user.(string) == "authorized", nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}))
|
|
|
|
// Without user in context - tool should be excluded
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 0 {
|
|
t.Errorf("Expected 0 tools without user, got %d", len(available))
|
|
}
|
|
|
|
// With authorized user - tool should be included
|
|
ctxWithUser := context.WithValue(context.Background(), userKey, "authorized")
|
|
availableWithUser := reg.AvailableTools(ctxWithUser)
|
|
if len(availableWithUser) != 1 {
|
|
t.Errorf("Expected 1 tool with authorized user, got %d", len(availableWithUser))
|
|
}
|
|
|
|
// With unauthorized user - tool should be excluded
|
|
ctxWithBadUser := context.WithValue(context.Background(), userKey, "unauthorized")
|
|
availableWithBadUser := reg.AvailableTools(ctxWithBadUser)
|
|
if len(availableWithBadUser) != 0 {
|
|
t.Errorf("Expected 0 tools with unauthorized user, got %d", len(availableWithBadUser))
|
|
}
|
|
}
|
|
|
|
// Tests for WithFilter builder method
|
|
func TestBuilderWithFilter(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", true),
|
|
mockTool("tool3", "toolset1", true),
|
|
}
|
|
|
|
// Filter that excludes tool2
|
|
filter := func(_ context.Context, tool *ServerTool) (bool, error) {
|
|
return tool.Tool.Name != "tool2", nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFilter(filter))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 tools after filter, got %d", len(available))
|
|
}
|
|
|
|
for _, tool := range available {
|
|
if tool.Tool.Name == "tool2" {
|
|
t.Error("tool2 should have been filtered out")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuilderWithMultipleFilters(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", true),
|
|
mockTool("tool3", "toolset1", true),
|
|
mockTool("tool4", "toolset1", true),
|
|
}
|
|
|
|
// First filter excludes tool2
|
|
filter1 := func(_ context.Context, tool *ServerTool) (bool, error) {
|
|
return tool.Tool.Name != "tool2", nil
|
|
}
|
|
|
|
// Second filter excludes tool3
|
|
filter2 := func(_ context.Context, tool *ServerTool) (bool, error) {
|
|
return tool.Tool.Name != "tool3", nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFilter(filter1).
|
|
WithFilter(filter2))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 2 {
|
|
t.Fatalf("Expected 2 tools after multiple filters, got %d", len(available))
|
|
}
|
|
|
|
toolNames := make(map[string]bool)
|
|
for _, tool := range available {
|
|
toolNames[tool.Tool.Name] = true
|
|
}
|
|
|
|
if !toolNames["tool1"] || !toolNames["tool4"] {
|
|
t.Error("Expected tool1 and tool4 to be available")
|
|
}
|
|
if toolNames["tool2"] || toolNames["tool3"] {
|
|
t.Error("tool2 and tool3 should have been filtered out")
|
|
}
|
|
}
|
|
|
|
func TestBuilderFilterError(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
}
|
|
|
|
// Filter that returns an error
|
|
filter := func(_ context.Context, _ *ServerTool) (bool, error) {
|
|
return false, fmt.Errorf("filter error")
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFilter(filter))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 0 {
|
|
t.Errorf("Expected 0 tools when filter returns error, got %d", len(available))
|
|
}
|
|
}
|
|
|
|
func TestBuilderFilterWithContext(t *testing.T) {
|
|
type contextKey string
|
|
const scopeKey contextKey = "scope"
|
|
|
|
tools := []ServerTool{
|
|
mockTool("public_tool", "toolset1", true),
|
|
mockTool("private_tool", "toolset1", true),
|
|
}
|
|
|
|
// Filter that checks context for scope
|
|
filter := func(ctx context.Context, tool *ServerTool) (bool, error) {
|
|
scope := ctx.Value(scopeKey)
|
|
if scope == "public" && tool.Tool.Name == "private_tool" {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFilter(filter))
|
|
|
|
// With public scope - private_tool should be excluded
|
|
ctxPublic := context.WithValue(context.Background(), scopeKey, "public")
|
|
availablePublic := reg.AvailableTools(ctxPublic)
|
|
if len(availablePublic) != 1 {
|
|
t.Fatalf("Expected 1 tool with public scope, got %d", len(availablePublic))
|
|
}
|
|
if availablePublic[0].Tool.Name != "public_tool" {
|
|
t.Error("Expected only public_tool to be available")
|
|
}
|
|
|
|
// With private scope - both tools should be available
|
|
ctxPrivate := context.WithValue(context.Background(), scopeKey, "private")
|
|
availablePrivate := reg.AvailableTools(ctxPrivate)
|
|
if len(availablePrivate) != 2 {
|
|
t.Errorf("Expected 2 tools with private scope, got %d", len(availablePrivate))
|
|
}
|
|
}
|
|
|
|
// Tests for interaction between Enabled, feature flags, and filters
|
|
func TestEnabledAndFeatureFlagInteraction(t *testing.T) {
|
|
// Tool with both Enabled function and feature flag
|
|
tool := mockToolWithFlags("complex_tool", "toolset1", true, "my_feature", "")
|
|
tool.Enabled = func(_ context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
// Feature flag not enabled - tool should be excluded despite Enabled returning true
|
|
checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil }
|
|
reg1 := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checkerOff))
|
|
available1 := reg1.AvailableTools(context.Background())
|
|
if len(available1) != 0 {
|
|
t.Error("Tool should be excluded when feature flag is not enabled")
|
|
}
|
|
|
|
// Feature flag enabled - tool should be included
|
|
checker := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == "my_feature", nil
|
|
}
|
|
reg2 := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checker))
|
|
available2 := reg2.AvailableTools(context.Background())
|
|
if len(available2) != 1 {
|
|
t.Error("Tool should be included when both Enabled and feature flag pass")
|
|
}
|
|
|
|
// Enabled returns false - tool should be excluded despite feature flag
|
|
tool.Enabled = func(_ context.Context) (bool, error) {
|
|
return false, nil
|
|
}
|
|
reg3 := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checker))
|
|
available3 := reg3.AvailableTools(context.Background())
|
|
if len(available3) != 0 {
|
|
t.Error("Tool should be excluded when Enabled returns false")
|
|
}
|
|
}
|
|
|
|
func TestEnabledAndBuilderFilterInteraction(t *testing.T) {
|
|
tool := mockTool("test_tool", "toolset1", true)
|
|
tool.Enabled = func(_ context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
// Filter that excludes the tool
|
|
filter := func(_ context.Context, _ *ServerTool) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFilter(filter))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 0 {
|
|
t.Error("Tool should be excluded when filter returns false, despite Enabled returning true")
|
|
}
|
|
}
|
|
|
|
func TestAllFiltersInteraction(t *testing.T) {
|
|
// Tool with Enabled, feature flag, and subject to builder filter
|
|
tool := mockToolWithFlags("complex_tool", "toolset1", true, "my_feature", "")
|
|
tool.Enabled = func(_ context.Context) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
filter := func(_ context.Context, _ *ServerTool) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
checker := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == "my_feature", nil
|
|
}
|
|
|
|
// All conditions pass - tool should be included
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checker).
|
|
WithFilter(filter))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
if len(available) != 1 {
|
|
t.Error("Tool should be included when all filters pass")
|
|
}
|
|
|
|
// Change filter to return false - tool should be excluded
|
|
filterFalse := func(_ context.Context, _ *ServerTool) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
reg2 := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checker).
|
|
WithFilter(filterFalse))
|
|
|
|
available2 := reg2.AvailableTools(context.Background())
|
|
if len(available2) != 0 {
|
|
t.Error("Tool should be excluded when any filter fails")
|
|
}
|
|
}
|
|
|
|
// Test FilteredTools method
|
|
func TestFilteredTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", true),
|
|
}
|
|
|
|
filter := func(_ context.Context, tool *ServerTool) (bool, error) {
|
|
return tool.Tool.Name == "tool1", nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFilter(filter))
|
|
|
|
filtered, err := reg.FilteredTools(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("FilteredTools returned error: %v", err)
|
|
}
|
|
|
|
if len(filtered) != 1 {
|
|
t.Fatalf("Expected 1 filtered tool, got %d", len(filtered))
|
|
}
|
|
|
|
if filtered[0].Tool.Name != "tool1" {
|
|
t.Errorf("Expected tool1, got %s", filtered[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
func TestFilteredToolsMatchesAvailableTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", false),
|
|
mockTool("tool3", "toolset2", true),
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"toolset1"}).
|
|
WithReadOnly(true))
|
|
|
|
ctx := context.Background()
|
|
filtered, err := reg.FilteredTools(ctx)
|
|
if err != nil {
|
|
t.Fatalf("FilteredTools returned error: %v", err)
|
|
}
|
|
|
|
available := reg.AvailableTools(ctx)
|
|
|
|
// Both methods should return the same results
|
|
if len(filtered) != len(available) {
|
|
t.Errorf("FilteredTools and AvailableTools returned different counts: %d vs %d",
|
|
len(filtered), len(available))
|
|
}
|
|
|
|
for i := range filtered {
|
|
if filtered[i].Tool.Name != available[i].Tool.Name {
|
|
t.Errorf("Tool at index %d differs: FilteredTools=%s, AvailableTools=%s",
|
|
i, filtered[i].Tool.Name, available[i].Tool.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilteringOrder(t *testing.T) {
|
|
// Test that filters are applied in the correct order:
|
|
// 1. Tool.Enabled
|
|
// 2. Read-only
|
|
// 3. Builder filters (feature-flag filter is at the head of this list
|
|
// when WithFeatureChecker is set)
|
|
// 4. Toolset/additional tools
|
|
|
|
callOrder := []string{}
|
|
|
|
tool := mockToolWithFlags("test_tool", "toolset1", false, "my_feature", "")
|
|
tool.Enabled = func(_ context.Context) (bool, error) {
|
|
callOrder = append(callOrder, "Enabled")
|
|
return true, nil
|
|
}
|
|
|
|
filter := func(_ context.Context, _ *ServerTool) (bool, error) {
|
|
callOrder = append(callOrder, "Filter")
|
|
return true, nil
|
|
}
|
|
|
|
checker := func(_ context.Context, _ string) (bool, error) {
|
|
callOrder = append(callOrder, "FeatureFlag")
|
|
return true, nil
|
|
}
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{tool}).
|
|
WithToolsets([]string{"all"}).
|
|
WithReadOnly(true). // This will exclude the tool (it's not read-only)
|
|
WithFeatureChecker(checker).
|
|
WithFilter(filter))
|
|
|
|
// Reset call order — Build() may call the checker for MCP Apps metadata.
|
|
// We're testing the AvailableTools filter order here.
|
|
callOrder = callOrder[:0]
|
|
|
|
_ = reg.AvailableTools(context.Background())
|
|
|
|
// Expected order: Enabled, then Read-only stops (write tool, read-only mode);
|
|
// neither the feature-flag filter nor the user filter is reached.
|
|
expectedOrder := []string{"Enabled"}
|
|
if len(callOrder) != len(expectedOrder) {
|
|
t.Errorf("Expected %d checks, got %d: %v", len(expectedOrder), len(callOrder), callOrder)
|
|
}
|
|
|
|
for i, expected := range expectedOrder {
|
|
if i >= len(callOrder) || callOrder[i] != expected {
|
|
t.Errorf("At position %d: expected %s, got %v", i, expected, callOrder)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) {
|
|
// Simulate the get_job_logs scenario: two tools with the same name but different feature flags
|
|
// - "get_job_logs" with FeatureFlagDisable (available when flag is OFF)
|
|
// - "get_job_logs" with FeatureFlagEnable (available when flag is ON)
|
|
tools := []ServerTool{
|
|
mockToolWithFlags("get_job_logs", "actions", true, "", "consolidated_flag"), // disabled when flag is ON
|
|
mockToolWithFlags("get_job_logs", "actions", true, "consolidated_flag", ""), // enabled when flag is ON
|
|
mockTool("other_tool", "actions", true),
|
|
}
|
|
|
|
// Test 1: Flag is OFF - first tool variant should be available
|
|
checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil }
|
|
regFlagOff := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checkerOff))
|
|
filteredOff := regFlagOff.ForMCPRequest(MCPMethodToolsCall, "get_job_logs")
|
|
availableOff := filteredOff.AvailableTools(context.Background())
|
|
if len(availableOff) != 1 {
|
|
t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff))
|
|
}
|
|
if len(availableOff[0].FeatureFlagDisable) != 1 || availableOff[0].FeatureFlagDisable[0] != "consolidated_flag" {
|
|
t.Errorf("Flag OFF: Expected tool with FeatureFlagDisable, got FeatureFlagEnable=%q, FeatureFlagDisable=%v",
|
|
availableOff[0].FeatureFlagEnable, availableOff[0].FeatureFlagDisable)
|
|
}
|
|
|
|
// Test 2: Flag is ON - second tool variant should be available
|
|
checker := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == "consolidated_flag", nil
|
|
}
|
|
regFlagOn := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(checker))
|
|
filteredOn := regFlagOn.ForMCPRequest(MCPMethodToolsCall, "get_job_logs")
|
|
availableOn := filteredOn.AvailableTools(context.Background())
|
|
if len(availableOn) != 1 {
|
|
t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn))
|
|
}
|
|
if availableOn[0].FeatureFlagEnable != "consolidated_flag" {
|
|
t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%v",
|
|
availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable)
|
|
}
|
|
}
|
|
|
|
// TestWithTools_DeprecatedAliasAndFeatureFlag tests that deprecated aliases work correctly
|
|
// when the old tool is controlled by a feature flag. This covers the scenario where:
|
|
// - Old tool "old_tool" has FeatureFlagDisable="my_flag" (available when flag is OFF)
|
|
// - New tool "new_tool" has FeatureFlagEnable="my_flag" (available when flag is ON)
|
|
// - Deprecated alias maps "old_tool" -> "new_tool"
|
|
// - User specifies --tools=old_tool
|
|
// Expected behavior:
|
|
// - Flag OFF: old_tool should be available (not the new_tool via alias)
|
|
// - Flag ON: new_tool should be available (via alias resolution)
|
|
func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) {
|
|
oldTool := mockToolWithFlags("old_tool", "actions", true, "", "my_flag")
|
|
newTool := mockToolWithFlags("new_tool", "actions", true, "my_flag", "")
|
|
tools := []ServerTool{oldTool, newTool}
|
|
|
|
deprecatedAliases := map[string]string{
|
|
"old_tool": "new_tool",
|
|
}
|
|
|
|
// Test 1: Flag OFF - old_tool should be available via direct name match
|
|
// (not via alias resolution to new_tool, since old_tool still exists)
|
|
checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil }
|
|
regFlagOff := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithDeprecatedAliases(deprecatedAliases).
|
|
WithToolsets([]string{}). // No toolsets enabled
|
|
WithTools([]string{"old_tool"}). // Explicitly request old tool
|
|
WithFeatureChecker(checkerOff))
|
|
availableOff := regFlagOff.AvailableTools(context.Background())
|
|
if len(availableOff) != 1 {
|
|
t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff))
|
|
}
|
|
if availableOff[0].Tool.Name != "old_tool" {
|
|
t.Errorf("Flag OFF: Expected old_tool, got %s", availableOff[0].Tool.Name)
|
|
}
|
|
|
|
// Test 2: Flag ON - new_tool should be available via alias resolution
|
|
checker := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == "my_flag", nil
|
|
}
|
|
regFlagOn := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithDeprecatedAliases(deprecatedAliases).
|
|
WithToolsets([]string{}). // No toolsets enabled
|
|
WithTools([]string{"old_tool"}). // Request old tool name
|
|
WithFeatureChecker(checker))
|
|
availableOn := regFlagOn.AvailableTools(context.Background())
|
|
if len(availableOn) != 1 {
|
|
t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn))
|
|
}
|
|
if availableOn[0].Tool.Name != "new_tool" {
|
|
t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name)
|
|
}
|
|
}
|
|
|
|
// mockToolWithMeta creates a ServerTool with Meta for testing insiders mode
|
|
func mockToolWithMeta(name string, toolsetID string, meta map[string]any) ServerTool {
|
|
return NewServerTool(
|
|
mcp.Tool{
|
|
Name: name,
|
|
Annotations: &mcp.ToolAnnotations{
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
|
Meta: meta,
|
|
},
|
|
testToolsetMetadata(toolsetID),
|
|
func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) {
|
|
toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{
|
|
"ui": map[string]any{"html": "<div>hello</div>"},
|
|
"description": "kept",
|
|
})
|
|
|
|
// Default: MCP Apps is disabled - UI meta should be stripped on registration.
|
|
reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{toolWithUI}).WithToolsets([]string{"all"}))
|
|
registered := captureRegisteredTools(context.Background(), t, reg)
|
|
|
|
require.Len(t, registered, 1)
|
|
if registered[0].Meta["ui"] != nil {
|
|
t.Errorf("Expected 'ui' meta to be stripped, but it was present")
|
|
}
|
|
if registered[0].Meta["description"] != "kept" {
|
|
t.Errorf("Expected 'description' meta to be preserved, got %v", registered[0].Meta["description"])
|
|
}
|
|
}
|
|
|
|
func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) {
|
|
uiData := map[string]any{"html": "<div>hello</div>"}
|
|
toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{
|
|
"ui": uiData,
|
|
"description": "kept",
|
|
})
|
|
|
|
// Feature checker enables MCP Apps - UI meta should be preserved
|
|
mcpAppsChecker := func(_ context.Context, flag string) (bool, error) {
|
|
return flag == mcpAppsFeatureFlag, nil
|
|
}
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{toolWithUI}).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(mcpAppsChecker))
|
|
available := reg.AvailableTools(context.Background())
|
|
|
|
require.Len(t, available, 1)
|
|
// UI metadata should be preserved
|
|
if available[0].Tool.Meta["ui"] == nil {
|
|
t.Errorf("Expected 'ui' meta to be preserved with MCP Apps enabled")
|
|
}
|
|
// Other metadata should also be preserved
|
|
if available[0].Tool.Meta["description"] != "kept" {
|
|
t.Errorf("Expected 'description' meta to be preserved, got %v", available[0].Tool.Meta["description"])
|
|
}
|
|
}
|
|
|
|
func TestWithMCPApps_ToolsWithoutUIMetaUnaffected(t *testing.T) {
|
|
toolNoUI := mockToolWithMeta("tool_no_ui", "toolset1", map[string]any{
|
|
"description": "kept",
|
|
"version": "1.0",
|
|
})
|
|
toolNilMeta := mockTool("tool_nil_meta", "toolset1", true)
|
|
|
|
// Test with MCP Apps disabled (default) - non-UI meta should be unaffected
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{toolNoUI, toolNilMeta}).
|
|
WithToolsets([]string{"all"}))
|
|
available := reg.AvailableTools(context.Background())
|
|
|
|
require.Len(t, available, 2)
|
|
|
|
// Find toolNoUI
|
|
var foundNoUI, foundNilMeta *ServerTool
|
|
for i := range available {
|
|
switch available[i].Tool.Name {
|
|
case "tool_no_ui":
|
|
foundNoUI = &available[i]
|
|
case "tool_nil_meta":
|
|
foundNilMeta = &available[i]
|
|
}
|
|
}
|
|
|
|
require.NotNil(t, foundNoUI)
|
|
require.NotNil(t, foundNilMeta)
|
|
|
|
// toolNoUI should have its metadata preserved
|
|
if foundNoUI.Tool.Meta["description"] != "kept" || foundNoUI.Tool.Meta["version"] != "1.0" {
|
|
t.Errorf("Expected toolNoUI meta to be unchanged, got %v", foundNoUI.Tool.Meta)
|
|
}
|
|
|
|
// toolNilMeta should still have nil meta
|
|
if foundNilMeta.Tool.Meta != nil {
|
|
t.Errorf("Expected toolNilMeta to have nil meta, got %v", foundNilMeta.Tool.Meta)
|
|
}
|
|
}
|
|
|
|
func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) {
|
|
toolUIOnly := mockToolWithMeta("tool_ui_only", "toolset1", map[string]any{
|
|
"ui": map[string]any{"html": "<div>hello</div>"},
|
|
})
|
|
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{toolUIOnly}).
|
|
WithToolsets([]string{"all"}))
|
|
registered := captureRegisteredTools(context.Background(), t, reg)
|
|
|
|
require.Len(t, registered, 1)
|
|
if registered[0].Meta != nil {
|
|
t.Errorf("Expected Meta to be nil after stripping only key, got %v", registered[0].Meta)
|
|
}
|
|
}
|
|
|
|
func TestStripMetaKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
meta map[string]any
|
|
keys []string
|
|
expectChange bool
|
|
expectedMeta map[string]any // nil means Meta should be nil
|
|
}{
|
|
{
|
|
name: "nil meta - no change",
|
|
meta: nil,
|
|
keys: mcpAppsMetaKeys,
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "no matching keys - no change",
|
|
meta: map[string]any{"description": "test", "version": "1.0"},
|
|
keys: mcpAppsMetaKeys,
|
|
expectChange: false,
|
|
},
|
|
{
|
|
name: "ui key only - becomes nil",
|
|
meta: map[string]any{"ui": "data"},
|
|
keys: mcpAppsMetaKeys,
|
|
expectChange: true,
|
|
expectedMeta: nil,
|
|
},
|
|
{
|
|
name: "ui key with other keys - ui stripped",
|
|
meta: map[string]any{"ui": "data", "description": "kept"},
|
|
keys: mcpAppsMetaKeys,
|
|
expectChange: true,
|
|
expectedMeta: map[string]any{"description": "kept"},
|
|
},
|
|
{
|
|
name: "ui is nil value - ui stripped",
|
|
meta: map[string]any{"ui": nil, "description": "kept"},
|
|
keys: mcpAppsMetaKeys,
|
|
expectChange: true,
|
|
expectedMeta: map[string]any{"description": "kept"},
|
|
},
|
|
{
|
|
name: "empty keys list - no change",
|
|
meta: map[string]any{"ui": "data"},
|
|
keys: []string{},
|
|
expectChange: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tool := mockToolWithMeta("test", "toolset1", tt.meta)
|
|
result := stripMetaKeys(tool, tt.keys)
|
|
|
|
if tt.expectChange {
|
|
require.NotNil(t, result, "expected change but got nil")
|
|
if tt.expectedMeta == nil {
|
|
require.Nil(t, result.Tool.Meta, "expected Meta to be nil")
|
|
} else {
|
|
// Compare values by key since types may differ (map[string]any vs mcp.Meta)
|
|
for k, v := range tt.expectedMeta {
|
|
require.Equal(t, v, result.Tool.Meta[k], "key %s should match", k)
|
|
}
|
|
require.Len(t, result.Tool.Meta, len(tt.expectedMeta))
|
|
}
|
|
} else {
|
|
require.Nil(t, result, "expected no change but got result")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStripMCPAppsMetadata(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockToolWithMeta("tool1", "toolset1", map[string]any{"ui": "data"}),
|
|
mockToolWithMeta("tool2", "toolset1", map[string]any{"description": "kept"}),
|
|
mockTool("tool3", "toolset1", true), // nil meta
|
|
}
|
|
|
|
result := stripMCPAppsMetadata(tools)
|
|
|
|
require.Len(t, result, 3)
|
|
|
|
// tool1: ui should be stripped, meta becomes nil
|
|
require.Nil(t, result[0].Tool.Meta, "tool1 meta should be nil after stripping ui")
|
|
|
|
// tool2: unchanged (compare by key since types differ)
|
|
require.Equal(t, "kept", result[1].Tool.Meta["description"])
|
|
require.Len(t, result[1].Tool.Meta, 1)
|
|
|
|
// tool3: unchanged (nil)
|
|
require.Nil(t, result[2].Tool.Meta)
|
|
}
|
|
|
|
func TestStripMetaKeys_MultipleKeys(t *testing.T) {
|
|
// This test verifies the mechanism works for multiple keys
|
|
keys := []string{"ui", "experimental_feature", "beta"}
|
|
|
|
tool := mockToolWithMeta("test", "toolset1", map[string]any{
|
|
"ui": "ui data",
|
|
"experimental_feature": "exp data",
|
|
"beta": "beta data",
|
|
"description": "kept",
|
|
})
|
|
|
|
result := stripMetaKeys(tool, keys)
|
|
|
|
require.NotNil(t, result)
|
|
require.NotNil(t, result.Tool.Meta)
|
|
require.Nil(t, result.Tool.Meta["ui"], "ui should be stripped")
|
|
require.Nil(t, result.Tool.Meta["experimental_feature"], "experimental_feature should be stripped")
|
|
require.Nil(t, result.Tool.Meta["beta"], "beta should be stripped")
|
|
require.Equal(t, "kept", result.Tool.Meta["description"], "description should be preserved")
|
|
}
|
|
|
|
func TestWithMCPApps_DoesNotMutateOriginalTools(t *testing.T) {
|
|
originalMeta := map[string]any{"ui": "data", "description": "kept"}
|
|
tool := mockToolWithMeta("test", "toolset1", originalMeta)
|
|
tools := []ServerTool{tool}
|
|
|
|
// Build with MCP Apps disabled (default) - should strip ui
|
|
_ = mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}))
|
|
|
|
// Original tool should be unchanged
|
|
require.Equal(t, "data", tools[0].Tool.Meta["ui"], "original tool should not be mutated")
|
|
require.Equal(t, "kept", tools[0].Tool.Meta["description"], "original tool should not be mutated")
|
|
}
|
|
|
|
func TestWithExcludeTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", true),
|
|
mockTool("tool3", "toolset2", true),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
excluded []string
|
|
toolsets []string
|
|
expectedNames []string
|
|
unexpectedNames []string
|
|
}{
|
|
{
|
|
name: "single tool excluded",
|
|
excluded: []string{"tool2"},
|
|
toolsets: []string{"all"},
|
|
expectedNames: []string{"tool1", "tool3"},
|
|
unexpectedNames: []string{"tool2"},
|
|
},
|
|
{
|
|
name: "multiple tools excluded",
|
|
excluded: []string{"tool1", "tool3"},
|
|
toolsets: []string{"all"},
|
|
expectedNames: []string{"tool2"},
|
|
unexpectedNames: []string{"tool1", "tool3"},
|
|
},
|
|
{
|
|
name: "empty excluded list is a no-op",
|
|
excluded: []string{},
|
|
toolsets: []string{"all"},
|
|
expectedNames: []string{"tool1", "tool2", "tool3"},
|
|
unexpectedNames: nil,
|
|
},
|
|
{
|
|
name: "nil excluded list is a no-op",
|
|
excluded: nil,
|
|
toolsets: []string{"all"},
|
|
expectedNames: []string{"tool1", "tool2", "tool3"},
|
|
unexpectedNames: nil,
|
|
},
|
|
{
|
|
name: "excluding non-existent tool is a no-op",
|
|
excluded: []string{"nonexistent"},
|
|
toolsets: []string{"all"},
|
|
expectedNames: []string{"tool1", "tool2", "tool3"},
|
|
unexpectedNames: nil,
|
|
},
|
|
{
|
|
name: "exclude all tools",
|
|
excluded: []string{"tool1", "tool2", "tool3"},
|
|
toolsets: []string{"all"},
|
|
expectedNames: nil,
|
|
unexpectedNames: []string{"tool1", "tool2", "tool3"},
|
|
},
|
|
{
|
|
name: "whitespace is trimmed",
|
|
excluded: []string{" tool2 ", " tool3 "},
|
|
toolsets: []string{"all"},
|
|
expectedNames: []string{"tool1"},
|
|
unexpectedNames: []string{"tool2", "tool3"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets(tt.toolsets).
|
|
WithExcludeTools(tt.excluded))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
names := make(map[string]bool)
|
|
for _, tool := range available {
|
|
names[tool.Tool.Name] = true
|
|
}
|
|
|
|
for _, expected := range tt.expectedNames {
|
|
require.True(t, names[expected], "tool %q should be available", expected)
|
|
}
|
|
for _, unexpected := range tt.unexpectedNames {
|
|
require.False(t, names[unexpected], "tool %q should be excluded", unexpected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithExcludeTools_OverridesAdditionalTools(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("tool1", "toolset1", true),
|
|
mockTool("tool2", "toolset1", true),
|
|
mockTool("tool3", "toolset2", true),
|
|
}
|
|
|
|
// tool3 is explicitly enabled via WithTools, but also excluded
|
|
// excluded should win because builder filters run before additional tools check
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"toolset1"}).
|
|
WithTools([]string{"tool3"}).
|
|
WithExcludeTools([]string{"tool3"}))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
names := make(map[string]bool)
|
|
for _, tool := range available {
|
|
names[tool.Tool.Name] = true
|
|
}
|
|
|
|
require.True(t, names["tool1"], "tool1 should be available")
|
|
require.True(t, names["tool2"], "tool2 should be available")
|
|
require.False(t, names["tool3"], "tool3 should be excluded even though explicitly added via WithTools")
|
|
}
|
|
|
|
func TestWithExcludeTools_CombinesWithReadOnly(t *testing.T) {
|
|
tools := []ServerTool{
|
|
mockTool("read_tool", "toolset1", true),
|
|
mockTool("write_tool", "toolset1", false),
|
|
mockTool("another_read", "toolset1", true),
|
|
}
|
|
|
|
// read-only excludes write_tool, exclude-tools excludes read_tool
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools(tools).
|
|
WithToolsets([]string{"all"}).
|
|
WithReadOnly(true).
|
|
WithExcludeTools([]string{"read_tool"}))
|
|
|
|
available := reg.AvailableTools(context.Background())
|
|
require.Len(t, available, 1)
|
|
require.Equal(t, "another_read", available[0].Tool.Name)
|
|
}
|
|
|
|
func TestCreateExcludeToolsFilter(t *testing.T) {
|
|
filter := CreateExcludeToolsFilter([]string{"blocked_tool"})
|
|
|
|
blockedTool := mockTool("blocked_tool", "toolset1", true)
|
|
allowedTool := mockTool("allowed_tool", "toolset1", true)
|
|
|
|
allowed, err := filter(context.Background(), &blockedTool)
|
|
require.NoError(t, err)
|
|
require.False(t, allowed, "blocked_tool should be excluded")
|
|
|
|
allowed, err = filter(context.Background(), &allowedTool)
|
|
require.NoError(t, err)
|
|
require.True(t, allowed, "allowed_tool should be included")
|
|
}
|
|
|
|
// captureRegisteredTools mirrors RegisterTools' per-request strip behavior so
|
|
// tests can verify what the wire sees, without requiring tools to have real
|
|
// handlers (RegisterTools panics on tools without HandlerFunc). It delegates
|
|
// to ToolsForRegistration so any future strip added there is picked up
|
|
// automatically.
|
|
func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) []*mcp.Tool {
|
|
t.Helper()
|
|
forReg := reg.ToolsForRegistration(ctx)
|
|
out := make([]*mcp.Tool, 0, len(forReg))
|
|
for i := range forReg {
|
|
toolCopy := forReg[i].Tool
|
|
out = append(out, &toolCopy)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestShouldStripMCPAppsMetadata verifies the spec-conformant strip decision:
|
|
// strip when the feature flag is off, OR when the client explicitly does not
|
|
// advertise the io.modelcontextprotocol/ui extension.
|
|
func TestShouldStripMCPAppsMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
setupCtx func() context.Context
|
|
ffOn bool
|
|
want bool
|
|
}{
|
|
{
|
|
name: "FF off, capability unknown -> strip",
|
|
setupCtx: context.Background,
|
|
ffOn: false,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "FF off, capability present -> strip (FF wins)",
|
|
setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) },
|
|
ffOn: false,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "FF on, capability unknown -> keep",
|
|
setupCtx: context.Background,
|
|
ffOn: true,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "FF on, capability present -> keep",
|
|
setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) },
|
|
ffOn: true,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "FF on, capability explicitly absent -> strip",
|
|
setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), false) },
|
|
ffOn: true,
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := shouldStripMCPAppsMetadata(tc.setupCtx(), tc.ffOn)
|
|
require.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestForMCPRequest_PreservesInstructions(t *testing.T) {
|
|
reg := mustBuild(t, NewBuilder().
|
|
SetTools([]ServerTool{mockTool("tool1", "repos", true)}).
|
|
WithToolsets([]string{"all"}).
|
|
WithServerInstructions())
|
|
want := reg.Instructions()
|
|
require.NotEmpty(t, want, "expected base inventory to generate instructions")
|
|
for _, m := range []string{MCPMethodDiscover, MCPMethodInitialize, MCPMethodToolsList} {
|
|
require.Equal(t, want, reg.ForMCPRequest(m, "").Instructions(),
|
|
"instructions must be preserved for %s (server identity)", m)
|
|
}
|
|
}
|