Component(s)
router
Component version
v0.346.0 (also reproduces on current main, 45e6fd9d)
wgc version
N/A — this report doesn't involve wgc.
controlplane version
N/A — reproduced in isolation via the router's Go test suite, no controlplane involved (see Runnable Test below).
router version
v0.346.0 (and current main, 45e6fd9d)
What happened?
Description
WithOmitToolNamePrefix (added in #2441) strips the execute_operation_ prefix from an MCP tool's Name, but the corresponding Annotations.Title is built unconditionally as fmt.Sprintf("Execute operation %s", op.Name) regardless of the option. With the prefix omitted, a client ends up with a wire Name of list_employees next to a Title of "Execute operation ListEmployees" — the two fields disagree about whether the prefix is present, with nothing in the config explaining why.
Steps to Reproduce
Register an operation ListEmployees, start the router's MCP server with WithOmitToolNamePrefix(true), and call tools/list.
Expected Result
Title tracks the same flag Name does — e.g. ListEmployees (no "Execute operation " prefix), matching Name's list_employees.
Actual Result
omitToolNamePrefix |
Name |
Annotations.Title |
false (default) |
execute_operation_list_employees |
Execute operation ListEmployees |
true |
list_employees |
Execute operation ListEmployees ← unchanged |
Title stays fixed regardless of the option.
Root Cause
router/pkg/mcpserver/server.go, in the operation-registration loop:
|
} |
|
|
|
toolName := operationToolName |
|
if !s.omitToolNamePrefix { |
|
toolName = fmt.Sprintf("execute_operation_%s", operationToolName) |
|
} else if slices.Contains(s.registeredTools, operationToolName) || slices.Contains(reservedToolNames, operationToolName) { |
|
s.logger.Error("Skipping operation due to tool name collision", |
|
zap.String("operation", op.Name), |
|
zap.String("conflicting_tool", operationToolName), |
|
) |
|
continue |
|
} |
|
// Parse JSON schema into map for the official SDK |
|
var inputSchema any |
|
if len(op.JSONSchema) > 0 { |
|
if err := json.Unmarshal(op.JSONSchema, &inputSchema); err != nil { |
|
s.logger.Error("failed to parse JSON schema for operation", |
|
zap.String("operation", op.Name), |
|
zap.Error(err)) |
|
continue |
|
} |
|
} else { |
|
inputSchema = map[string]any{"type": "object", "properties": map[string]any{}} |
|
} |
|
|
|
// Declare the response envelope of the operation's selection set as the |
|
// tool's output schema. A build failure only degrades the tool: it is |
|
// registered without an output schema. |
|
var outputSchema any |
|
if s.outputSchemaEnabled { |
|
if outputJSONSchema, err := buildResponseSchema(&op.Document, s.operationsManager.GetSchema()); err != nil { |
|
s.logger.Warn("failed to build output schema for operation; registering tool without output schema", |
|
zap.String("operation", op.Name), |
|
zap.Error(err)) |
|
} else { |
|
outputSchema = outputJSONSchema |
|
} |
|
} |
|
|
|
openWorld := true |
|
tool := &mcp.Tool{ |
|
Name: toolName, |
|
Description: toolDescription, |
|
InputSchema: inputSchema, |
|
OutputSchema: outputSchema, |
|
Annotations: &mcp.ToolAnnotations{ |
|
IdempotentHint: op.OperationType != "mutation", |
|
Title: fmt.Sprintf("Execute operation %s", op.Name), |
toolName := operationToolName
if !s.omitToolNamePrefix {
toolName = fmt.Sprintf("execute_operation_%s", operationToolName)
} else if slices.Contains(s.registeredTools, operationToolName) || slices.Contains(reservedToolNames, operationToolName) {
...
}
...
tool := &mcp.Tool{
Name: toolName,
...
Annotations: &mcp.ToolAnnotations{
...
Title: fmt.Sprintf("Execute operation %s", op.Name), // always "Execute operation ...", regardless of s.omitToolNamePrefix
...
},
}
toolName branches on s.omitToolNamePrefix; the Title expression a few lines below does not consult it at all. The option's own doc comment says it "removes the execute_operation_ prefix from MCP tool names" with no carve-out for the display title:
|
// OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names |
|
OmitToolNamePrefix bool |
This is not new in this router version — it dates back to when omitToolNamePrefix/OmitToolNamePrefix was introduced in #2441, which added the flag and branched Name on it but never touched Title.
Suggested Fix
Mirror the same branch used for toolName:
+ toolTitle := fmt.Sprintf("Execute operation %s", op.Name)
+ if s.omitToolNamePrefix {
+ toolTitle = op.Name
+ }
+
tool := &mcp.Tool{
Name: toolName,
...
Annotations: &mcp.ToolAnnotations{
...
- Title: fmt.Sprintf("Execute operation %s", op.Name),
+ Title: toolTitle,
...
},
}
Deliberately the bare, non-humanized form (op.Name, e.g. ListEmployees) rather than a spaced/Title-Case rewrite — keeps the change minimal and non-breaking for the default (omitToolNamePrefix: false) path, where Title is untouched.
Runnable Test
Exercises both flag states through a real in-memory MCP client/server round trip (reusing the existing newTestSession harness in router/pkg/mcpserver/discover_test.go), asserting Annotations.Title directly rather than just Name:
tool_title_test.go
package mcpserver
import (
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Prior to the fix, the Title annotation always read "Execute operation %s"
// regardless of WithOmitToolNamePrefix -- the one field that setting never
// touched, even though its own doc comment says it "removes the
// execute_operation_ prefix from MCP tool names" with no carve-out for the
// display title. A client showing Title next to Name would read
// "Execute operation ListEmployees" over a wire name of "list_employees",
// with nothing in the config explaining why one lost the prefix and the
// other didn't.
func TestToolTitle_MirrorsNamePrefixState(t *testing.T) {
t.Run("prefixed (default): title keeps the descriptive form", func(t *testing.T) {
cs := newTestSession(t, WithOmitToolNamePrefix(false))
result, err := cs.ListTools(t.Context(), nil)
require.NoError(t, err)
tool := findTool(t, result.Tools, "execute_operation_list_employees")
require.NotNil(t, tool.Annotations)
assert.Equal(t, "Execute operation ListEmployees", tool.Annotations.Title)
})
t.Run("omitted: title drops the same words Name drops", func(t *testing.T) {
cs := newTestSession(t, WithOmitToolNamePrefix(true))
result, err := cs.ListTools(t.Context(), nil)
require.NoError(t, err)
tool := findTool(t, result.Tools, "list_employees")
require.NotNil(t, tool.Annotations)
assert.Equal(t, "ListEmployees", tool.Annotations.Title)
})
}
func findTool(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool {
t.Helper()
for _, tool := range tools {
if tool.Name == name {
return tool
}
}
t.Fatalf("tool %q not found in %d returned tools", name, len(tools))
return nil
}
Fails on current main (both subtests observe "Execute operation ListEmployees"); passes with the one-line fix above.
Additional context
We hit this downstream while auditing MCP tool naming for a per-tool RBAC rollout, and already have this exact patch running in our fork (junipersquare/cosmo#64). Happy to open it here as a PR against main if that's welcome — it's the minimal one-line-branch version above, not a broader humanization of the title.
Component(s)
router
Component version
v0.346.0(also reproduces on currentmain,45e6fd9d)wgc version
N/A — this report doesn't involve
wgc.controlplane version
N/A — reproduced in isolation via the router's Go test suite, no controlplane involved (see Runnable Test below).
router version
v0.346.0(and currentmain,45e6fd9d)What happened?
Description
WithOmitToolNamePrefix(added in #2441) strips theexecute_operation_prefix from an MCP tool'sName, but the correspondingAnnotations.Titleis built unconditionally asfmt.Sprintf("Execute operation %s", op.Name)regardless of the option. With the prefix omitted, a client ends up with a wireNameoflist_employeesnext to aTitleof"Execute operation ListEmployees"— the two fields disagree about whether the prefix is present, with nothing in the config explaining why.Steps to Reproduce
Register an operation
ListEmployees, start the router's MCP server withWithOmitToolNamePrefix(true), and calltools/list.Expected Result
Titletracks the same flagNamedoes — e.g.ListEmployees(no"Execute operation "prefix), matchingName'slist_employees.Actual Result
omitToolNamePrefixNameAnnotations.Titlefalse(default)execute_operation_list_employeesExecute operation ListEmployeestruelist_employeesExecute operation ListEmployees← unchangedTitlestays fixed regardless of the option.Root Cause
router/pkg/mcpserver/server.go, in the operation-registration loop:cosmo/router/pkg/mcpserver/server.go
Lines 813 to 860 in 45e6fd9
toolNamebranches ons.omitToolNamePrefix; theTitleexpression a few lines below does not consult it at all. The option's own doc comment says it "removes theexecute_operation_prefix from MCP tool names" with no carve-out for the display title:cosmo/router/pkg/mcpserver/server.go
Lines 85 to 86 in 45e6fd9
This is not new in this router version — it dates back to when
omitToolNamePrefix/OmitToolNamePrefixwas introduced in #2441, which added the flag and branchedNameon it but never touchedTitle.Suggested Fix
Mirror the same branch used for
toolName:Deliberately the bare, non-humanized form (
op.Name, e.g.ListEmployees) rather than a spaced/Title-Case rewrite — keeps the change minimal and non-breaking for the default (omitToolNamePrefix: false) path, whereTitleis untouched.Runnable Test
Exercises both flag states through a real in-memory MCP client/server round trip (reusing the existing
newTestSessionharness inrouter/pkg/mcpserver/discover_test.go), assertingAnnotations.Titledirectly rather than justName:tool_title_test.goFails on current
main(both subtests observe"Execute operation ListEmployees"); passes with the one-line fix above.Additional context
We hit this downstream while auditing MCP tool naming for a per-tool RBAC rollout, and already have this exact patch running in our fork (junipersquare/cosmo#64). Happy to open it here as a PR against
mainif that's welcome — it's the minimal one-line-branch version above, not a broader humanization of the title.