Skip to content

fix(amazonq): reserve built-in tool names for MCP tool registration - #2903

Draft
dungdong-aws wants to merge 8 commits into
Amazon-Q-Developer:feature/mcp-security-enchancefrom
dungdong-aws:fix/mcp-builtin-tool-name-reservation
Draft

dungdong-aws wants to merge 8 commits into
Amazon-Q-Developer:feature/mcp-security-enchancefrom
dungdong-aws:fix/mcp-builtin-tool-name-reservation

Conversation

@dungdong-aws

@dungdong-aws dungdong-aws commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

MCP tool names were taken verbatim from a server's tools/list and preferred in their bare form whenever the name was not already present in the collision set. That set was never seeded with the built-in tool names, so a server advertising a name such as fsRead registered under it and replaced the built-in entry. Requests for that name were then handled by the built-in branch of the tool dispatch switch, whose input schema and permission handling do not match an MCP tool, so the tool's @server/tool permission was never consulted.

The same shape recurred downstream. Several controller branches selected built-in behavior from a tool name that, on the MCP path, is the server's original advertised name rather than the registered one. A tool advertised as fsRead or executeBash could therefore steer the confirmation card, the accepted-result card, and the card's message id into their built-in forms even after registration was namespaced.

Registration

  • createNamespacedToolName takes a reservedNames set and will not return a reserved name from any of its naming branches, so a colliding tool is forced to the namespaced server___tool form. The check runs on the sanitized name, so a name that only becomes a built-in name after sanitization is also refused.
  • A persisted name mapping that points at a reserved name is discarded rather than reused, so a mapping saved by an earlier build cannot reclaim the bare name.
  • Both call sites seed the set from agent.getBuiltInToolNames() rather than a local list, so the reservation tracks tool registration instead of drifting as tools are added.
  • In toolServer the set is built inside registerServerTools, since built-in tools register after the module-level collision set is created.
  • MCP cleanup paths skip built-in names, so removing or re-registering a server can no longer unregister a built-in tool.

Controller dispatch

Every branch below now keys off toolUse.name, the registered name, which is namespaced for MCP tools. The server-supplied original name is retained only for display.

  • #processToolConfirmation: the confirmation switch, the isStandardTool classification, and the executeBash body check. Previously resolved from toolType || toolUse.name, where the MCP path passes the original name as toolType.
  • #getUpdateToolConfirmResult: the shell-card check and the file-tool switch. Previously resolved from originalToolName, which is the original name on the MCP approval path. A tool advertised as executeBash rendered the shell-command card, including its body and stop button.
  • #getMessageIdForToolUse: no longer branches on toolType === EXECUTE_BASH. The parameter became unused and is removed.

Built-in tool list

The Built-in list in mcpEventHandler selected tools by excluding raw MCP tool names, which hid a built-in whenever an MCP server advertised the same name. The filter is now additive: a built-in is always listed, and any other tool is listed unless it matches an MCP tool name. The MCP name set includes the namespaced registered names, so a colliding MCP tool is excluded rather than listed beside the built-in it shadowed. Tools registered without a classification keep their previous behavior.

Relationship to prior naming changes

Bare tool names are deliberate, so this change is scoped to preserve them:

The duplicate rule from #1676 is retained. What this change does is include the built-in tool names in the set a name can duplicate, which previously held only other MCP tool names. A colliding tool takes the server prefix that #1676 already defines for duplicates; every non-colliding MCP tool keeps its bare name, so the tool list the model sees is otherwise unchanged.

This also narrows a gap against the Q CLI parity target that drove #1610 and #1621. In the CLI, MCP tools are addressed under an @server namespace (@server_name/tool_name) that is structurally separate from the bare built-in names, and toolAliases exists to remap collisions. The IDE flattens MCP tools into the same bare namespace the built-ins occupy and keeps @server/tool only as the permission key, which is what allowed a server-supplied name to shadow a built-in.

Intentional behavior changes

  • An MCP tool whose name matches a built-in tool is exposed to the model under its namespaced name instead of the bare name. Clients that referenced the bare name for such a tool will see the namespaced name.
  • Non-colliding MCP tools keep their bare names. Length-based truncation and numeric-suffix fallback are unchanged apart from the added reserved-name check.
  • A name mapping persisted by an earlier build that points at a built-in name is dropped and re-derived.
  • A colliding MCP tool renders the MCP confirmation and result cards and is gated by its @server/tool permission, rather than rendering built-in cards.
  • An MCP tool advertised as executeBash no longer renders the shell-command card and no longer receives the shell tool's message id.
  • The Built-in list in the MCP permissions view always includes every built-in tool, even when an MCP server advertises a tool of the same name.
  • An undefined toolUse.name classifies as non-built-in in the confirmation renderer.

Tests and verification

createNamespacedToolName (mcpUtils.test.ts, ten cases): a colliding built-in name; a stale mapping pointing at a reserved name; every one of the seven built-in names; two servers advertising the same built-in name; re-registration reusing the namespaced name; a long server name truncated to the length limit; the numeric-suffix fallback skipping a reserved candidate; names that only collapse onto a built-in name after sanitization; an unaffected non-colliding name; and the default empty reserved set preserving prior behavior.

Controller dispatch (agenticChatController.test.ts, six cases) drives processToolUses with an MCP tool registered as probe___fsRead whose advertised name is fsRead, and asserts the advertised name never selects built-in behavior: the confirmation card is the MCP summary card rather than the built-in read card; the built-in path validation error does not surface in the tool results; the MCP permission is consulted under the server and original tool name and a prompt is shown; runTool receives the registered name; and the accepted-result card is the MCP card. A sixth case registers probe___executeBash and asserts no card renders as the shell-command card and the confirmation uses the plain toolUseId.

Prettier passed on all changed files. ESLint on the changed files reported no errors; the remaining import/no-nodejs-modules warnings are pre-existing and untouched. reservedNames defaults to an empty set, so existing callers compile unchanged.

Manually reproduced with a local stdio MCP server advertising a single fsRead tool whose handler returns a fixed marker and performs no I/O. The server was verified independently of the IDE: tools/list returns ["fsRead"] and tools/call returns the marker.

  • Before: built-in fsRead failed with e.paths is not iterable, and the tool's configured Ask permission was never consulted because dispatch never reached the MCP branch.
  • With the registration fix only: built-in fsRead read files normally and the probe was offered as local-tool-name-collision-probe___fsRead, but invoking it surfaced Paths array cannot be empty. from the built-in confirmation card.
  • With the dispatch fixes, against a rebuilt language-server bundle: built-in fsRead reads files normally and the probe is reachable only under its namespaced name.

Verification limits

Package compilation and the unit suites were not run locally. The authoring host enforces memory-capped build invocation through a cgroup scope, and the sandboxed session could not create one, so tsc --noEmit and the Mocha suites were refused rather than run uncapped. CI is the first execution of the added tests. An earlier revision of this branch failed CI typecheck on a dropped string | undefined guard, which is fixed and is the reason that guard is called out above.

The controller tests exercise a long code path through many mocks. Each session and chatResultStream member the MCP branch touches was traced and provided, but integration-shaped tests are where mock gaps surface, so a failing case in CI is most likely a harness gap rather than a product defect and should be read that way first.

Still uncovered: the reservedNames argument at the two call sites is optional, so dropping it would not fail any test here; the removeTool guards in toolServer; and the Built-in list filter in mcpEventHandler. The last of these regressed once during this change, by dropping tools registered without a classification, and was corrected in a follow-up commit. It would benefit from a dedicated test.

Related changes

  • fix(amazonq): derive MCP tool name mapping from enabled tools #2904 makes agenticChatController.#getTools derive the MCP name mapping from getEnabledTools(), matching the set toolServer registers from. It was split out of this change because it is a separate defect: two name-assignment sites disagreeing with each other, rather than a server-supplied name selecting built-in behavior. It does not depend on this change and merges in either order.

Not changed

  • Built-in name reservation is exact-match, so a case variant such as fsread still takes a bare name. It does not overwrite the built-in entry and is still gated by its MCP permission, so this is a tool-list readability issue rather than a safety one. Making the reservation case-insensitive would namespace legitimately distinct tools, so it is left as is.

MCP tool names came from a server's tools/list and were used in their
bare form whenever the name was not already in the collision set. That
set was not seeded with the built-in tool names, so a server could
register under one and replace the built-in entry.

createNamespacedToolName now takes a reservedNames set, never returns a
reserved name from any naming branch, and discards a persisted name
mapping that points at one. Both call sites seed the set from
agent.getBuiltInToolNames() so the reservation tracks tool registration.
MCP cleanup paths skip built-in names.
processToolConfirmation resolved its switch subject as toolType ||
toolUse.name. The MCP branch passes the server's original tool name as
toolType for display, so a tool advertised as fsRead selected the
built-in fsRead card, which reads input.paths and threw "Paths array
cannot be empty." before any approval card was shown. The same name
also drove the isStandardTool classification and the executeBash body
check.

Dispatch and classification now use toolUse.name, the registered name,
which is namespaced for MCP tools. toolType stays display-only.
ToolUse.name is string | undefined, so the isStandardTool check needs
the undefined guard that the previous expression carried. Restores it
on the dispatch name. An undefined name classifies as non-built-in.
Adds cases for every built-in name, two servers advertising the same
built-in name, re-registration reusing the namespaced name, truncation
of a long server name, the numeric-suffix fallback skipping a reserved
candidate, and the default empty reserved set keeping prior behavior.
sanitizeName strips disallowed characters, so an advertised name such as
"fs Read" becomes "fsRead". The reserved check runs on the sanitized
form; this pins that behavior.
@dungdong-aws dungdong-aws reopened this Oct 5, 2026
getUpdateToolConfirmResult resolved its subject as originalToolName,
which is the server's original tool name on the MCP approval path. A
tool advertised as executeBash therefore rendered the shell card, which
reads input.command, and one advertised as a filesystem tool selected
those cases. Dispatch now uses toolUse.name; originalToolName and
toolType remain display-only. The built-in path already passes
toolUse.name, so built-in rendering is unchanged.

getMessageIdForToolUse no longer branches on toolType, so a server
cannot steer a card to the built-in executeBash message id. The
parameter is now unused and is removed.

The Built-in tool list in mcpEventHandler selected tools by excluding
raw MCP tool names, which hid a built-in whose name an MCP server also
advertises. It now selects by getBuiltInToolNames().
The previous commit selected the Built-in list purely by
getBuiltInToolNames(), which dropped the three LSP tools that register
without a ToolClassification and were previously listed.

The filter is now additive: a built-in is always listed, and any other
tool is listed unless it matches an MCP tool name. The MCP name set also
includes the namespaced names tools are actually registered under, so a
colliding MCP tool is excluded rather than appearing alongside the
built-in it shadowed.
Drives processToolUses with an MCP tool registered as probe___fsRead
whose server-advertised name is fsRead, and asserts the server's name
never selects built-in behavior: the confirmation card is the MCP
summary card rather than the built-in read card, the built-in path
validation error does not surface, the MCP permission is consulted
under the server and original tool name, runTool receives the
registered name, and the accepted-result card is the MCP card.

A second case registers probe___executeBash and asserts no card renders
as the shell command card and the confirmation uses the plain
toolUseId rather than the shell tool's message id.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 42.22222% with 52 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ge-server/agenticChat/tools/mcp/mcpEventHandler.ts 0.00% 19 Missing ⚠️
...nguage-server/agenticChat/agenticChatController.ts 48.48% 17 Missing ⚠️
...rc/language-server/agenticChat/tools/toolServer.ts 0.00% 16 Missing ⚠️

📢 Thoughts on this report? Let us know!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants