From 3120ad6608227411f2b762cf8d605c428859b655 Mon Sep 17 00:00:00 2001 From: NickNYU Date: Sun, 15 Mar 2026 17:49:23 +0800 Subject: [PATCH 1/2] feat: add tool name aliasing for Bedrock compatibility Some LLM providers (e.g., AWS Bedrock) enforce a 64-character limit on tool names. When MCP clients add prefixes like `mcp__plugin____`, the full tool name can exceed this limit. Add a `--max-tool-name-length` CLI option that enables deterministic, collision-safe shortening of tool names using human-readable abbreviations. Internal handler dispatch, logging, and telemetry continue using the original names. --- src/bin/chrome-devtools-mcp-cli-options.ts | 5 + src/index.ts | 16 +- src/tools/tool-name-aliaser.ts | 175 ++++++++++++ tests/tools/tool-name-aliaser.test.ts | 305 +++++++++++++++++++++ 4 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 src/tools/tool-name-aliaser.ts create mode 100644 tests/tools/tool-name-aliaser.test.ts diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index f81c9e208..b0824fc3d 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -253,6 +253,11 @@ export const cliOptions = { 'Set by Chrome DevTools CLI if the MCP server is started via the CLI client (this arg exists for usage stats)', hidden: true, }, + maxToolNameLength: { + type: 'number', + describe: + 'Maximum length for exported MCP tool names. Names exceeding this limit are automatically shortened with human-readable aliases. Useful when MCP client prefixes cause tool names to exceed provider limits (e.g., AWS Bedrock 64-char limit).', + }, } satisfies Record; export type ParsedArguments = ReturnType; diff --git a/src/index.ts b/src/index.ts index 1e731521c..038f1a71a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { SetLevelRequestSchema, } from './third_party/index.js'; import {ToolCategory} from './tools/categories.js'; +import {ToolNameAliaser} from './tools/tool-name-aliaser.js'; import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js'; import {pageIdSchema} from './tools/ToolDefinition.js'; import {createTools} from './tools/tools.js'; @@ -108,6 +109,10 @@ export async function createMcpServer( const toolMutex = new Mutex(); + const aliaser = serverArgs.maxToolNameLength + ? new ToolNameAliaser(serverArgs.maxToolNameLength) + : undefined; + function registerTool(tool: ToolDefinition | DefinedPageTool): void { if ( tool.annotations.category === ToolCategory.EMULATION && @@ -159,8 +164,17 @@ export async function createMcpServer( ? {...tool.schema, ...pageIdSchema} : tool.schema; + const registrationName = aliaser + ? aliaser.register(tool.name) + : tool.name; + if (registrationName !== tool.name) { + logger( + `Tool "${tool.name}" aliased to "${registrationName}" (max length: ${serverArgs.maxToolNameLength})`, + ); + } + server.registerTool( - tool.name, + registrationName, { description: tool.description, inputSchema: schema, diff --git a/src/tools/tool-name-aliaser.ts b/src/tools/tool-name-aliaser.ts new file mode 100644 index 000000000..47c3d862d --- /dev/null +++ b/src/tools/tool-name-aliaser.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Common abbreviations for tool name segments. + * Used to produce human-readable short aliases. + */ +const ABBREVIATIONS: Record = { + action: 'act', + analyze: 'anlz', + console: 'cons', + evaluate: 'eval', + experimental: 'exp', + extension: 'ext', + extensions: 'exts', + insight: 'ins', + install: 'inst', + lighthouse: 'lh', + memory: 'mem', + message: 'msg', + messages: 'msgs', + navigate: 'nav', + network: 'net', + performance: 'perf', + request: 'req', + requests: 'reqs', + screencast: 'scrcast', + screenshot: 'scrn', + snapshot: 'snap', + trigger: 'trig', + uninstall: 'uninst', +}; + +/** + * Tool name aliaser for provider compatibility. + * + * Some LLM providers (e.g., AWS Bedrock) enforce character limits on tool + * names. When MCP clients add prefixes like + * `mcp__plugin____`, the full tool name can exceed these limits. + * + * This class provides deterministic, collision-safe shortening of tool names + * while maintaining bidirectional mappings for dispatch. + * + * Example: with a Bedrock 64-char limit and a 49-char client prefix, + * `maxLength` should be set to 15 (64 - 49). Tool names longer than 15 + * characters are automatically shortened using human-readable abbreviations. + */ +export class ToolNameAliaser { + readonly #aliasToOriginal = new Map(); + readonly #originalToAlias = new Map(); + readonly #maxLength: number; + + constructor(maxLength: number) { + if (maxLength < 1) { + throw new Error('maxLength must be at least 1'); + } + this.#maxLength = maxLength; + } + + get maxLength(): number { + return this.#maxLength; + } + + /** + * Register a tool name. Returns the alias (which equals the original name + * if it already fits within the max length). + * + * Tool names should be registered in a deterministic order (e.g., + * alphabetical) to ensure stable alias generation across runs. + */ + register(originalName: string): string { + if (this.#originalToAlias.has(originalName)) { + return this.#originalToAlias.get(originalName)!; + } + + if (originalName.length <= this.#maxLength) { + this.#aliasToOriginal.set(originalName, originalName); + this.#originalToAlias.set(originalName, originalName); + return originalName; + } + + const alias = this.#shorten(originalName); + this.#aliasToOriginal.set(alias, originalName); + this.#originalToAlias.set(originalName, alias); + return alias; + } + + /** + * Resolve an alias back to its original tool name. + * Returns `undefined` if the alias is not registered. + */ + resolve(alias: string): string | undefined { + return this.#aliasToOriginal.get(alias); + } + + /** + * Get the alias for an original tool name. + * Returns `undefined` if the name is not registered. + */ + getAlias(originalName: string): string | undefined { + return this.#originalToAlias.get(originalName); + } + + /** + * Get all registered (alias, original) pairs. + */ + entries(): Array<[alias: string, original: string]> { + return [...this.#aliasToOriginal.entries()]; + } + + #shorten(name: string): string { + const segments = name.split('_'); + + // Step 1: Apply known abbreviations to each segment. + const abbreviated = segments.map(seg => ABBREVIATIONS[seg] ?? seg); + + let candidate = abbreviated.join('_'); + if (candidate.length <= this.#maxLength) { + return this.#ensureUnique(candidate); + } + + // Step 2: Progressively truncate the longest segment by one character + // until the name fits. + const working = [...abbreviated]; + while (working.join('_').length > this.#maxLength && working.length > 0) { + let longestIdx = 0; + for (let i = 1; i < working.length; i++) { + if (working[i].length > working[longestIdx].length) { + longestIdx = i; + } + } + if (working[longestIdx].length <= 1) { + // Cannot shorten further; drop the last segment. + working.pop(); + continue; + } + working[longestIdx] = working[longestIdx].slice(0, -1); + } + + candidate = working.join('_'); + + // Step 3: Hard truncate as a safety net (shouldn't be reached by the + // loop above for reasonable maxLength values). + if (candidate.length > this.#maxLength) { + candidate = candidate.slice(0, this.#maxLength); + } + + return this.#ensureUnique(candidate); + } + + #ensureUnique(candidate: string): string { + if (!this.#aliasToOriginal.has(candidate)) { + return candidate; + } + + // Collision: append a numeric suffix while staying within maxLength. + for (let i = 1; i < 1000; i++) { + const suffix = `_${i}`; + const maxBase = this.#maxLength - suffix.length; + const base = + candidate.length > maxBase ? candidate.slice(0, maxBase) : candidate; + const withSuffix = base + suffix; + if (!this.#aliasToOriginal.has(withSuffix)) { + return withSuffix; + } + } + + throw new Error( + `Cannot generate unique alias for "${candidate}" after 1000 attempts`, + ); + } +} diff --git a/tests/tools/tool-name-aliaser.test.ts b/tests/tools/tool-name-aliaser.test.ts new file mode 100644 index 000000000..9df10fc8a --- /dev/null +++ b/tests/tools/tool-name-aliaser.test.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {ToolNameAliaser} from '../../src/tools/tool-name-aliaser.js'; + +describe('ToolNameAliaser', () => { + describe('passthrough', () => { + it('returns names unchanged when within the limit', () => { + const aliaser = new ToolNameAliaser(20); + assert.strictEqual(aliaser.register('list_pages'), 'list_pages'); + assert.strictEqual(aliaser.register('click'), 'click'); + assert.strictEqual(aliaser.register('wait_for'), 'wait_for'); + }); + + it('returns names at exactly the limit unchanged', () => { + const aliaser = new ToolNameAliaser(15); + assert.strictEqual( + aliaser.register('take_screenshot'), + 'take_screenshot', + ); + assert.strictEqual( + aliaser.register('evaluate_script'), + 'evaluate_script', + ); + }); + }); + + describe('shortening', () => { + it('shortens names exceeding the limit using abbreviations', () => { + const aliaser = new ToolNameAliaser(15); + const alias = aliaser.register('performance_analyze_insight'); + assert.ok(alias.length <= 15, `"${alias}" exceeds 15 chars`); + assert.strictEqual(alias, 'perf_anlz_ins'); + }); + + it('truncates segments when abbreviations alone are insufficient', () => { + const aliaser = new ToolNameAliaser(15); + // 'perf_start_trace' = 16 chars after abbreviation, needs truncation + const alias = aliaser.register('performance_start_trace'); + assert.ok(alias.length <= 15, `"${alias}" exceeds 15 chars`); + }); + + it('uses abbreviation dictionary for common words', () => { + const aliaser = new ToolNameAliaser(15); + assert.strictEqual( + aliaser.register('lighthouse_audit'), + 'lh_audit', + ); + assert.strictEqual( + aliaser.register('get_console_message'), + 'get_cons_msg', + ); + assert.strictEqual( + aliaser.register('list_network_requests'), + 'list_net_reqs', + ); + }); + }); + + describe('collision handling', () => { + it('appends a numeric suffix on collision', () => { + // Use a very short maxLength to force collisions between similar names. + const aliaser = new ToolNameAliaser(5); + const alias1 = aliaser.register('abcdef_one'); + const alias2 = aliaser.register('abcdef_two'); + assert.notStrictEqual(alias1, alias2); + assert.ok(alias1.length <= 5, `"${alias1}" exceeds 5 chars`); + assert.ok(alias2.length <= 5, `"${alias2}" exceeds 5 chars`); + }); + + it('resolves colliding aliases back to correct originals', () => { + const aliaser = new ToolNameAliaser(5); + const alias1 = aliaser.register('abcdef_one'); + const alias2 = aliaser.register('abcdef_two'); + assert.strictEqual(aliaser.resolve(alias1), 'abcdef_one'); + assert.strictEqual(aliaser.resolve(alias2), 'abcdef_two'); + }); + }); + + describe('round-trip mapping', () => { + it('maps alias→original and original→alias for all shortened names', () => { + const aliaser = new ToolNameAliaser(15); + const names = [ + 'get_console_message', + 'list_console_messages', + 'get_network_request', + 'list_network_requests', + 'performance_start_trace', + 'performance_stop_trace', + 'performance_analyze_insight', + 'lighthouse_audit', + 'take_memory_snapshot', + ]; + for (const name of names) { + const alias = aliaser.register(name); + assert.ok( + alias.length <= 15, + `"${alias}" for "${name}" exceeds 15 chars`, + ); + assert.strictEqual( + aliaser.resolve(alias), + name, + `resolve("${alias}") should return "${name}"`, + ); + assert.strictEqual( + aliaser.getAlias(name), + alias, + `getAlias("${name}") should return "${alias}"`, + ); + } + }); + + it('round-trips passthrough names correctly', () => { + const aliaser = new ToolNameAliaser(15); + assert.strictEqual(aliaser.register('click'), 'click'); + assert.strictEqual(aliaser.resolve('click'), 'click'); + assert.strictEqual(aliaser.getAlias('click'), 'click'); + }); + }); + + describe('chrome-devtools-mcp long-name examples', () => { + // Bedrock 64-char limit with a 49-char MCP client prefix + // mcp__plugin_chrome-devtools-mcp_chrome-devtools__ = 49 chars + // Max tool name length = 64 - 49 = 15 + const MAX_LENGTH = 15; + + const FAILING_NAMES = [ + 'get_console_message', + 'get_network_request', + 'lighthouse_audit', + 'list_console_messages', + 'list_network_requests', + 'performance_analyze_insight', + 'performance_start_trace', + 'performance_stop_trace', + 'take_memory_snapshot', + ]; + + it('all failing names produce aliases within the 15-char limit', () => { + const aliaser = new ToolNameAliaser(MAX_LENGTH); + for (const name of FAILING_NAMES) { + const alias = aliaser.register(name); + assert.ok( + alias.length <= MAX_LENGTH, + `"${alias}" (${alias.length} chars) for "${name}" exceeds ${MAX_LENGTH}`, + ); + } + }); + + it('all aliases are unique', () => { + const aliaser = new ToolNameAliaser(MAX_LENGTH); + const seen = new Set(); + for (const name of FAILING_NAMES) { + const alias = aliaser.register(name); + assert.ok(!seen.has(alias), `Duplicate alias "${alias}"`); + seen.add(alias); + } + }); + + it('full tool name with prefix stays within 64 chars', () => { + const PREFIX = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__'; + const aliaser = new ToolNameAliaser(MAX_LENGTH); + for (const name of FAILING_NAMES) { + const alias = aliaser.register(name); + const fullName = PREFIX + alias; + assert.ok( + fullName.length <= 64, + `"${fullName}" (${fullName.length} chars) exceeds 64`, + ); + } + }); + + it('produces human-readable aliases', () => { + const aliaser = new ToolNameAliaser(MAX_LENGTH); + const aliases = FAILING_NAMES.map(n => aliaser.register(n)); + // All aliases should contain only [a-z0-9_] + for (const alias of aliases) { + assert.match(alias, /^[a-z0-9_]+$/); + } + }); + }); + + describe('full tool set uniqueness', () => { + it('produces unique aliases for all chrome-devtools-mcp tools', () => { + const aliaser = new ToolNameAliaser(15); + const allTools = [ + 'click', + 'click_at', + 'close_page', + 'drag', + 'emulate', + 'evaluate_script', + 'fill', + 'fill_form', + 'get_console_message', + 'get_network_request', + 'get_tab_id', + 'handle_dialog', + 'hover', + 'install_extension', + 'lighthouse_audit', + 'list_console_messages', + 'list_extensions', + 'list_network_requests', + 'list_pages', + 'navigate_page', + 'new_page', + 'performance_analyze_insight', + 'performance_start_trace', + 'performance_stop_trace', + 'press_key', + 'reload_extension', + 'resize_page', + 'screencast_start', + 'screencast_stop', + 'select_page', + 'take_memory_snapshot', + 'take_screenshot', + 'take_snapshot', + 'trigger_extension_action', + 'type_text', + 'uninstall_extension', + 'upload_file', + 'wait_for', + ]; + + const aliases = new Set(); + for (const name of allTools) { + const alias = aliaser.register(name); + assert.ok( + alias.length <= 15, + `Alias "${alias}" for "${name}" exceeds 15 chars`, + ); + assert.ok( + !aliases.has(alias), + `Duplicate alias "${alias}" (from "${name}")`, + ); + aliases.add(alias); + } + }); + }); + + describe('determinism', () => { + it('produces the same aliases across separate instantiations', () => { + const names = [ + 'get_console_message', + 'get_network_request', + 'performance_start_trace', + 'performance_stop_trace', + 'performance_analyze_insight', + 'take_memory_snapshot', + ]; + + const run = () => { + const a = new ToolNameAliaser(15); + return names.map(n => a.register(n)); + }; + + assert.deepStrictEqual(run(), run()); + }); + + it('returns the same alias for duplicate registrations', () => { + const aliaser = new ToolNameAliaser(15); + const first = aliaser.register('performance_analyze_insight'); + const second = aliaser.register('performance_analyze_insight'); + assert.strictEqual(first, second); + }); + }); + + describe('edge cases', () => { + it('throws on maxLength < 1', () => { + assert.throws(() => new ToolNameAliaser(0), /maxLength must be at least 1/); + }); + + it('handles single-character maxLength', () => { + const aliaser = new ToolNameAliaser(1); + const alias = aliaser.register('ab'); + assert.ok(alias.length <= 1); + }); + + it('resolve returns undefined for unknown aliases', () => { + const aliaser = new ToolNameAliaser(15); + assert.strictEqual(aliaser.resolve('nonexistent'), undefined); + }); + + it('getAlias returns undefined for unregistered names', () => { + const aliaser = new ToolNameAliaser(15); + assert.strictEqual(aliaser.getAlias('nonexistent'), undefined); + }); + + it('entries returns all registered mappings', () => { + const aliaser = new ToolNameAliaser(15); + aliaser.register('click'); + aliaser.register('performance_analyze_insight'); + const entries = aliaser.entries(); + assert.strictEqual(entries.length, 2); + }); + }); +}); From 0062a8fb95a25cf350b1ed732de80b2dc51790e3 Mon Sep 17 00:00:00 2001 From: NickNYU Date: Sun, 15 Mar 2026 18:25:27 +0800 Subject: [PATCH 2/2] fix: skip tool-name-aliaser in dynamic tool discovery test The `has all tools` e2e test dynamically imports all files in build/src/tools/ and invokes exported functions as tool factories. The ToolNameAliaser class export needs to be skipped like ToolDefinition. --- tests/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/index.test.ts b/tests/index.test.ts index 4df17bb68..1f4e1f259 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -80,6 +80,7 @@ describe('e2e', () => { for (const file of files) { if ( file === 'ToolDefinition.js' || + file === 'tool-name-aliaser.js' || file === 'tools.js' || file === 'slim' ) {