Skip to content

MCP tool Annotations.Title ignores omitToolNamePrefix, unlike Name #3226

Description

@mshinde-jsq

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

internally-reviewedThe issue has been reviewed internally.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions