diff --git a/router-tests/mcp_test.go b/router-tests/mcp_test.go index e89a2c0388..39de7ffbe7 100644 --- a/router-tests/mcp_test.go +++ b/router-tests/mcp_test.go @@ -161,6 +161,84 @@ func TestMCP(t *testing.T) { }) }) + t.Run("List user Operations / Tool names omit prefix when OmitToolNamePrefix is enabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + OmitToolNamePrefix: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + + toolsRequest := mcp.ListToolsRequest{} + resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) + require.NoError(t, err) + require.NotNil(t, resp) + + toolNames := make([]string, len(resp.Tools)) + for i, tool := range resp.Tools { + toolNames[i] = tool.Name + } + + expectedToolNames := []string{"get_operation_info", "my_employees", "update_mood"} + assert.ElementsMatch(t, expectedToolNames, toolNames) + }) + }) + + t.Run("Execute operation using short tool name when OmitToolNamePrefix is enabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + OmitToolNamePrefix: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "my_employees", + Arguments: map[string]any{ + "criteria": map[string]any{}, + }, + }, + } + + resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) + assert.NoError(t, err) + assert.NotNil(t, resp) + + assert.Len(t, resp.Content, 1) + + content, ok := resp.Content[0].(mcp.TextContent) + assert.True(t, ok) + + assert.Equal(t, content.Type, "text") + assert.Contains(t, content.Text, "findEmployees") + }) + }) + + t.Run("Tool name collision with built-in tool uses prefixed name when OmitToolNamePrefix is enabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCPOperationsPath: "testdata/mcp_operations_collision", + MCP: config.MCPConfiguration{ + Enabled: true, + OmitToolNamePrefix: true, + ExposeSchema: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + toolsRequest := mcp.ListToolsRequest{} + resp, err := xEnv.MCPClient.ListTools(xEnv.Context, toolsRequest) + require.NoError(t, err) + require.NotNil(t, resp) + + toolNames := make([]string, len(resp.Tools)) + for i, tool := range resp.Tools { + toolNames[i] = tool.Name + } + + assert.Contains(t, toolNames, "get_schema") // built-in tool (ExposeSchema=true) + assert.Contains(t, toolNames, "execute_operation_get_schema") // collision uses prefix + }) + }) + t.Run("List user Operations / Static operations of type mutation aren't exposed when excludeMutations is set", func(t *testing.T) { testenv.Run(t, &testenv.Config{ MCP: config.MCPConfiguration{ diff --git a/router-tests/testdata/mcp_operations_collision/GetSchema.graphql b/router-tests/testdata/mcp_operations_collision/GetSchema.graphql new file mode 100644 index 0000000000..101a0f67f2 --- /dev/null +++ b/router-tests/testdata/mcp_operations_collision/GetSchema.graphql @@ -0,0 +1,5 @@ +query GetSchema { + employees { + id + } +} diff --git a/router-tests/testdata/mcp_operations_collision/MyMutation.graphql b/router-tests/testdata/mcp_operations_collision/MyMutation.graphql new file mode 100644 index 0000000000..944f9e85b6 --- /dev/null +++ b/router-tests/testdata/mcp_operations_collision/MyMutation.graphql @@ -0,0 +1,9 @@ +mutation UpdateMood($employeeID: Int!, $mood: Mood!) { + updateMood(employeeID: $employeeID, mood: $mood) { + id + details { + forename + } + currentMood + } +} \ No newline at end of file diff --git a/router-tests/testdata/mcp_operations_collision/MyQuery.graphql b/router-tests/testdata/mcp_operations_collision/MyQuery.graphql new file mode 100644 index 0000000000..f592461a53 --- /dev/null +++ b/router-tests/testdata/mcp_operations_collision/MyQuery.graphql @@ -0,0 +1,12 @@ +query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + id + isAvailable + currentMood + products + details { + forename + nationality + } + } +} \ No newline at end of file diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 2d1e5f64de..bc99b90a1a 100644 --- a/router-tests/testenv/testenv.go +++ b/router-tests/testenv/testenv.go @@ -341,6 +341,7 @@ type Config struct { UseVersionedGraph bool NoShutdownTestServer bool MCP config.MCPConfiguration + MCPOperationsPath string EnableRedis bool EnableRedisCluster bool Plugins PluginConfig @@ -1438,12 +1439,15 @@ func configureRouter(listenerAddr string, testConfig *Config, routerConfig *node } if testConfig.MCP.Enabled { - // Add Storage provider + mcpOperationsPath := "testdata/mcp_operations" + if testConfig.MCPOperationsPath != "" { + mcpOperationsPath = testConfig.MCPOperationsPath + } routerOpts = append(routerOpts, core.WithStorageProviders(config.StorageProviders{ FileSystem: []config.FileSystemStorageProvider{ { ID: "test", - Path: "testdata/mcp_operations", + Path: mcpOperationsPath, }, }, })) diff --git a/router/core/router.go b/router/core/router.go index 8bdc557b29..ad4b77cc33 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -939,6 +939,7 @@ func (r *Router) bootstrap(ctx context.Context) error { mcpserver.WithExcludeMutations(r.mcp.ExcludeMutations), mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), mcpserver.WithExposeSchema(r.mcp.ExposeSchema), + mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), mcpserver.WithStateless(r.mcp.Session.Stateless), } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index a354a72378..8eb71bc5f1 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -999,6 +999,9 @@ type MCPConfiguration struct { EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` + // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names. + // When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user. + OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` } type MCPSessionConfig struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index ad795c31d7..a531fa4af3 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2144,6 +2144,11 @@ "type": "boolean", "default": false, "description": "Expose the full GraphQL schema through MCP. When enabled, AI models can request the complete schema of your API." + }, + "omit_tool_name_prefix": { + "type": "boolean", + "default": false, + "description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'." } } }, diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 515d08f45d..13628a925a 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -45,6 +45,7 @@ mcp: expose_schema: false enable_arbitrary_operations: false exclude_mutations: false + omit_tool_name_prefix: false graph_name: cosmo router_url: https://cosmo-router.wundergraph.com server: diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index ce91d417e7..b4ddad685e 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -137,7 +137,8 @@ "ExcludeMutations": false, "EnableArbitraryOperations": false, "ExposeSchema": false, - "RouterURL": "" + "RouterURL": "", + "OmitToolNamePrefix": false }, "DemoMode": false, "Modules": null, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index ae7699a8f4..d4707aa1a8 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -172,7 +172,8 @@ "ExcludeMutations": false, "EnableArbitraryOperations": false, "ExposeSchema": false, - "RouterURL": "https://cosmo-router.wundergraph.com" + "RouterURL": "https://cosmo-router.wundergraph.com", + "OmitToolNamePrefix": false }, "DemoMode": true, "Modules": { diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 173e2bf9d2..075ba20c42 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -90,6 +91,8 @@ type Options struct { EnableArbitraryOperations bool // ExposeSchema determines whether the GraphQL schema is exposed ExposeSchema bool + // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names + OmitToolNamePrefix bool // Stateless determines whether the MCP server should be stateless Stateless bool // CorsConfig is the CORS configuration for the MCP server @@ -110,6 +113,7 @@ type GraphQLSchemaServer struct { excludeMutations bool enableArbitraryOperations bool exposeSchema bool + omitToolNamePrefix bool stateless bool operationsManager *OperationsManager schemaCompiler *SchemaCompiler @@ -240,6 +244,7 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) excludeMutations: options.ExcludeMutations, enableArbitraryOperations: options.EnableArbitraryOperations, exposeSchema: options.ExposeSchema, + omitToolNamePrefix: options.OmitToolNamePrefix, stateless: options.Stateless, corsConfig: options.CorsConfig, } @@ -307,6 +312,13 @@ func WithStateless(stateless bool) func(*Options) { } } +// WithOmitToolNamePrefix sets the omit tool name prefix option +func WithOmitToolNamePrefix(omitToolNamePrefix bool) func(*Options) { + return func(o *Options) { + o.OmitToolNamePrefix = omitToolNamePrefix + } +} + func WithCORS(corsCfg cors.Config) func(*Options) { return func(o *Options) { // Force specific CORS settings for MCP server @@ -547,7 +559,17 @@ func (s *GraphQLSchemaServer) registerTools() error { toolDescription = fmt.Sprintf("Executes the GraphQL operation '%s' of type %s.", op.Name, op.OperationType) } - toolName := fmt.Sprintf("execute_operation_%s", operationToolName) + toolName := operationToolName + if !s.omitToolNamePrefix { + toolName = fmt.Sprintf("execute_operation_%s", operationToolName) + } else if slices.Contains(s.registeredTools, operationToolName) { + s.logger.Warn("Operation name collides with built-in MCP tool, using prefixed name", + zap.String("operation", op.Name), + zap.String("conflicting_tool", operationToolName), + zap.String("using_name", fmt.Sprintf("execute_operation_%s", operationToolName)), + ) + toolName = fmt.Sprintf("execute_operation_%s", operationToolName) + } tool := mcp.NewToolWithRawSchema( toolName, toolDescription,