no-duplicate-tool-name
Disallow registering or listing two MCP tools with the same name on one server.
CWE: CWE-694 OWASP: A04:2021 – Insecure Design
Detects two registerTool / tool calls that give one server the same static name, and a tools/list response that lists two tools with one name. This rule is part of eslint-plugin-mcp-sdk-security.
💼 This rule is set to warn in the recommended and strict configs.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-694 (Use of Multiple Resources with Duplicate Identifier) |
| Severity | Medium (CVSS 5.3) |
| Auto-Fix | ❌ No auto-fix available |
| Category | Security |
Why this matters
A tool's name is the identifier a client calls. Two tools with one name never both exist, and what happens instead depends on the server class:
McpServerthrowsTool X is already registeredat startup. The duplicate either crashes the server, or, when the second registration sits behind atry/catchor a conditional factory, silently keeps the FIRST handler while the author believes the second one runs. A "safe" read-only variant registered second never takes effect.- The low-level
Serverhas no such check. Itstools/listresponse shows clients two tools with one name and two descriptions, and which one atools/callreaches is decided by the call handler alone. The model picks a tool by its description; the server runs a different one.
❌ Incorrect
// ❌ the second registration throws, or is swallowed and never runs
server.registerTool('search', { description: 'Search docs' }, searchDocs);
server.registerTool('search', { description: 'Search code' }, searchCode);
// ❌ the legacy tool() and registerTool() share one namespace
server.tool('ping', async () => ({ content: [] }));
server.registerTool('ping', cfg, ping);
// ❌ behind a try/catch: the server starts, and forceDeploy is never reachable
server.registerTool('deploy', cfg, safeDeploy);
try {
server.registerTool('deploy', cfg, forceDeploy);
} catch (e) {
log(e);
}
// ❌ the low-level Server lists two tools with one name
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{ name: 'query', description: 'Read-only query', inputSchema },
{ name: 'query', description: 'Run any SQL', inputSchema },
],
}));How duplicates are recognised
- Registrations are
registerTooland the legacytool(), read as the other rules in this plugin read them. The name is the first argument: a literal, or aconstit resolves to (in this file or a relative module). - The same server means the receiver is a plain identifier and both calls
resolve, through the scope manager, to the same variable.
this.serverorgetServer()is not compared. - Both calls must run. On the path from either call up to their lowest
common ancestor there may be no
if/elsebranch, ternary branch,switchcase, right operand of&&/||/??, loop body,catchblock or function boundary. Registrations in two branches, two factories or a loop are alternatives, not duplicates. - Listings are
setRequestHandler(ListToolsRequestSchema, …)(resolved to the SDK export) and v2's'tools/list'. The handler (inline, or a same-file function) returns{ tools: [...] }, inline or a same-fileconstarray, whose elements are tool objects inline or held inconsts. Two elements with the same staticnameare reported.
The later occurrence is reported, and the message names the line of the earlier one.
✅ Correct
// ✅ one name per tool
server.registerTool('search_docs', { description: 'Search docs' }, searchDocs);
server.registerTool('search_code', { description: 'Search code' }, searchCode);
// ✅ alternatives: only one branch registers
if (readOnly) server.registerTool('files', readOnlyConfig, readFiles);
else server.registerTool('files', readWriteConfig, editFiles);
// ✅ one table drives both tools/list and tools/call
const TOOLS = {
query: { description: 'Read-only query', inputSchema, run: query },
};
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: Object.entries(TOOLS).map(([name, t]) => ({
name,
description: t.description,
inputSchema: t.inputSchema,
})),
}));What this rule deliberately does not report
- Duplicate
caselabels in atools/callswitch. That is core ESLint'sno-duplicate-case;switchstatements are out of scope here. - Alternatives. The same name in an
if/else, a ternary, aswitch, a loop, acatchor two separate functions. - Two different servers. The same name on two server variables is two tools.
- Dynamic names, receivers that are not a plain variable, and tool lists the rule cannot read (built by a call, or defined in another module).
- A file that imports no MCP package.
Relationship to node-security and secure-coding
There is no overlap. Neither plugin knows that a tool name is an MCP identifier; this is an MCP-SDK-specific rule.
When Not To Use It
A test that deliberately registers a duplicate to assert the SDK throws can disable this rule for that line. The rule has no options.
Further Reading
- CWE-694: Use of Multiple Resources with Duplicate Identifier
- OWASP A04:2021 – Insecure Design
- MCP: Tools
- ESLint: no-duplicate-case
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.