Repository navigation
fix(amazonq): reserve built-in tool names for MCP tool registration - #2903
Draft
dungdong-aws wants to merge 8 commits into
Draft
dungdong-aws wants to merge 8 commits into
dungdong-aws wants to merge 8 commits into
Conversation
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.
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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MCP tool names were taken verbatim from a server's
tools/listand 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 asfsReadregistered 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/toolpermission 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
fsReadorexecuteBashcould 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
createNamespacedToolNametakes areservedNamesset and will not return a reserved name from any of its naming branches, so a colliding tool is forced to the namespacedserver___toolform. The check runs on the sanitized name, so a name that only becomes a built-in name after sanitization is also refused.agent.getBuiltInToolNames()rather than a local list, so the reservation tracks tool registration instead of drifting as tools are added.toolServerthe set is built insideregisterServerTools, since built-in tools register after the module-level collision set is created.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, theisStandardToolclassification, and theexecuteBashbody check. Previously resolved fromtoolType || toolUse.name, where the MCP path passes the original name astoolType.#getUpdateToolConfirmResult: the shell-card check and the file-tool switch. Previously resolved fromoriginalToolName, which is the original name on the MCP approval path. A tool advertised asexecuteBashrendered the shell-command card, including its body and stop button.#getMessageIdForToolUse: no longer branches ontoolType === EXECUTE_BASH. The parameter became unused and is removed.Built-in tool list
The
Built-inlist inmcpEventHandlerselected 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:
mcpServerName___toolNameto bare tool names, stating the product requirement as sending "just tools with tool names and include serverName as prefix only in case a tool has duplicate/other tool with same name".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
@servernamespace (@server_name/tool_name) that is structurally separate from the bare built-in names, andtoolAliasesexists to remap collisions. The IDE flattens MCP tools into the same bare namespace the built-ins occupy and keeps@server/toolonly as the permission key, which is what allowed a server-supplied name to shadow a built-in.Intentional behavior changes
@server/toolpermission, rather than rendering built-in cards.executeBashno longer renders the shell-command card and no longer receives the shell tool's message id.Built-inlist in the MCP permissions view always includes every built-in tool, even when an MCP server advertises a tool of the same name.toolUse.nameclassifies 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) drivesprocessToolUseswith an MCP tool registered asprobe___fsReadwhose advertised name isfsRead, 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;runToolreceives the registered name; and the accepted-result card is the MCP card. A sixth case registersprobe___executeBashand asserts no card renders as the shell-command card and the confirmation uses the plaintoolUseId.Prettier passed on all changed files. ESLint on the changed files reported no errors; the remaining
import/no-nodejs-moduleswarnings are pre-existing and untouched.reservedNamesdefaults to an empty set, so existing callers compile unchanged.Manually reproduced with a local stdio MCP server advertising a single
fsReadtool whose handler returns a fixed marker and performs no I/O. The server was verified independently of the IDE:tools/listreturns["fsRead"]andtools/callreturns the marker.fsReadfailed withe.paths is not iterable, and the tool's configuredAskpermission was never consulted because dispatch never reached the MCP branch.fsReadread files normally and the probe was offered aslocal-tool-name-collision-probe___fsRead, but invoking it surfacedPaths array cannot be empty.from the built-in confirmation card.fsReadreads 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 --noEmitand 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 droppedstring | undefinedguard, 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
sessionandchatResultStreammember 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
reservedNamesargument at the two call sites is optional, so dropping it would not fail any test here; theremoveToolguards intoolServer; and theBuilt-inlist filter inmcpEventHandler. 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
agenticChatController.#getToolsderive the MCP name mapping fromgetEnabledTools(), matching the settoolServerregisters 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
fsreadstill 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.