@mcp-b/chrome-devtools-mcp 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { jsonSchemaToZod } from '@composio/json-schema-to-zod';
7
+ import { zod, } from '../third_party/index.js';
8
+ /**
9
+ * Convert a JSON Schema inputSchema from WebMCP to a Zod schema.
10
+ *
11
+ * Falls back to a permissive passthrough schema if conversion fails,
12
+ * allowing the tool to still be registered and used.
13
+ *
14
+ * @param inputSchema - The JSON Schema from the WebMCP tool definition.
15
+ * @param logger - Optional logger for conversion errors.
16
+ * @returns A Zod schema for parameter validation.
17
+ */
18
+ function convertInputSchema(inputSchema, logger) {
19
+ if (!inputSchema) {
20
+ return zod.object({}).passthrough();
21
+ }
22
+ try {
23
+ return jsonSchemaToZod(inputSchema);
24
+ }
25
+ catch (err) {
26
+ logger?.('Failed to convert inputSchema to Zod:', err);
27
+ return zod.object({}).passthrough();
28
+ }
29
+ }
30
+ /**
31
+ * WebMCPToolHub manages dynamic registration of WebMCP tools as first-class MCP tools.
32
+ *
33
+ * When a page has WebMCP tools available, this hub:
34
+ * 1. Syncs those tools to the MCP server as native tools
35
+ * 2. Uses naming convention: webmcp_{domain}_page{idx}_{toolName}
36
+ * 3. Updates tools when WebMCP sends list_changed notifications
37
+ * 4. Removes tools when pages navigate or close
38
+ *
39
+ * This allows Claude Code to call WebMCP tools directly without the two-step
40
+ * diff_webmcp_tools -> call_webmcp_tool process.
41
+ */
42
+ export class WebMCPToolHub {
43
+ #server;
44
+ #context;
45
+ #logger;
46
+ /** Map of tool IDs to their registration metadata. */
47
+ #registeredTools = new Map();
48
+ /** Tracks which tool IDs belong to each page (for cleanup on navigation). */
49
+ #pageTools = new WeakMap();
50
+ /** Guards against concurrent sync operations per page. */
51
+ #syncInProgress = new WeakSet();
52
+ /** Whether automatic tool registration is enabled. */
53
+ #enabled = true;
54
+ /** Global diff state for diff_webmcp_tools - tracks last seen tool IDs. */
55
+ #lastSeenToolIds = null;
56
+ constructor(server, context, enabled = true) {
57
+ this.#server = server;
58
+ this.#context = context;
59
+ this.#logger = context.logger;
60
+ this.#enabled = enabled;
61
+ }
62
+ /**
63
+ * Disable automatic tool registration
64
+ */
65
+ disable() {
66
+ this.#enabled = false;
67
+ }
68
+ /**
69
+ * Enable automatic tool registration
70
+ */
71
+ enable() {
72
+ this.#enabled = true;
73
+ }
74
+ /**
75
+ * Check if the hub is enabled
76
+ */
77
+ isEnabled() {
78
+ return this.#enabled;
79
+ }
80
+ /**
81
+ * Sync tools for a page. Called on:
82
+ * 1. Initial WebMCP connection
83
+ * 2. ToolListChangedNotificationSchema notification
84
+ *
85
+ * @param page - The browser page
86
+ * @param client - The MCP client (passed to avoid infinite loop via getWebMCPClient)
87
+ */
88
+ async syncToolsForPage(page, client) {
89
+ if (!this.#enabled) {
90
+ return { synced: 0, removed: 0, updated: 0 };
91
+ }
92
+ if (this.#syncInProgress.has(page)) {
93
+ this.#logger('Sync already in progress for page, skipping');
94
+ return { synced: 0, removed: 0, updated: 0 };
95
+ }
96
+ this.#syncInProgress.add(page);
97
+ const urlAtStart = page.url();
98
+ try {
99
+ const { tools } = await client.listTools();
100
+ // Guard: page navigated during async operation
101
+ if (page.url() !== urlAtStart) {
102
+ this.#logger('Page navigated during sync, aborting');
103
+ return { synced: 0, removed: 0, updated: 0 };
104
+ }
105
+ return this.#applyToolChanges(page, tools);
106
+ }
107
+ catch (err) {
108
+ this.#logger('Failed to sync WebMCP tools:', err);
109
+ return { synced: 0, removed: 0, updated: 0 };
110
+ }
111
+ finally {
112
+ this.#syncInProgress.delete(page);
113
+ }
114
+ }
115
+ /**
116
+ * Remove all tools for a page. Called on:
117
+ * 1. Transport close (navigation/page close)
118
+ * 2. Manual removal
119
+ */
120
+ removeToolsForPage(page) {
121
+ const toolIds = this.#pageTools.get(page);
122
+ if (!toolIds) {
123
+ return 0;
124
+ }
125
+ let removed = 0;
126
+ for (const toolId of toolIds) {
127
+ const registered = this.#registeredTools.get(toolId);
128
+ if (registered) {
129
+ this.#logger(`Removing WebMCP tool: ${toolId}`);
130
+ registered.handle.remove();
131
+ this.#registeredTools.delete(toolId);
132
+ removed++;
133
+ }
134
+ }
135
+ this.#pageTools.delete(page);
136
+ this.#logger(`Removed ${removed} WebMCP tools for page`);
137
+ return removed;
138
+ }
139
+ /**
140
+ * Apply tool changes by comparing current tools with new tools from WebMCP.
141
+ * Handles add, update, and remove operations.
142
+ */
143
+ #applyToolChanges(page, tools) {
144
+ const domain = extractDomain(page.url());
145
+ const pageIdx = this.#context.getPages().indexOf(page);
146
+ // Guard: page not found in pages list (may have been closed)
147
+ if (pageIdx === -1) {
148
+ this.#logger('Page not found in pages list, skipping tool sync');
149
+ return { synced: 0, removed: 0, updated: 0 };
150
+ }
151
+ const newToolIds = new Set();
152
+ let synced = 0;
153
+ let updated = 0;
154
+ for (const tool of tools) {
155
+ const toolId = this.#generateToolId(domain, pageIdx, tool.name);
156
+ newToolIds.add(toolId);
157
+ const existing = this.#registeredTools.get(toolId);
158
+ if (existing) {
159
+ // Remove and re-register to ensure schema updates are applied
160
+ // (MCP SDK's update() doesn't support schema changes)
161
+ this.#logger(`Re-registering WebMCP tool: ${toolId}`);
162
+ existing.handle.remove();
163
+ this.#registeredTools.delete(toolId);
164
+ this.#registerTool(page, domain, pageIdx, tool);
165
+ updated++;
166
+ }
167
+ else {
168
+ // Register new tool
169
+ this.#registerTool(page, domain, pageIdx, tool);
170
+ synced++;
171
+ }
172
+ }
173
+ // Remove tools that no longer exist
174
+ const existingToolIds = this.#pageTools.get(page) || new Set();
175
+ let removed = 0;
176
+ for (const toolId of existingToolIds) {
177
+ if (!newToolIds.has(toolId)) {
178
+ const registered = this.#registeredTools.get(toolId);
179
+ if (registered) {
180
+ this.#logger(`Removing stale WebMCP tool: ${toolId}`);
181
+ registered.handle.remove();
182
+ this.#registeredTools.delete(toolId);
183
+ removed++;
184
+ }
185
+ }
186
+ }
187
+ this.#pageTools.set(page, newToolIds);
188
+ this.#logger(`WebMCP tool sync: ${synced} added, ${updated} updated, ${removed} removed`);
189
+ return { synced, removed, updated };
190
+ }
191
+ /**
192
+ * Register a single tool with the MCP server
193
+ */
194
+ #registerTool(page, domain, pageIdx, tool) {
195
+ const toolId = this.#generateToolId(domain, pageIdx, tool.name);
196
+ const description = this.#generateDescription(domain, pageIdx, tool.description);
197
+ this.#logger(`Registering WebMCP tool: ${toolId}`);
198
+ // Store tool name for use in the callback closure
199
+ const originalToolName = tool.name;
200
+ // Convert the JSON Schema inputSchema to Zod for MCP SDK registration
201
+ const zodSchema = convertInputSchema(tool.inputSchema, this.#logger);
202
+ const handle = this.#server.registerTool(toolId, {
203
+ description,
204
+ inputSchema: zodSchema,
205
+ }, async (params) => {
206
+ // Look up page dynamically to handle potential stale references
207
+ const currentPage = this.#getPageForTool(toolId);
208
+ if (!currentPage) {
209
+ return {
210
+ content: [
211
+ {
212
+ type: 'text',
213
+ text: 'Tool no longer available - page may have closed or navigated',
214
+ },
215
+ ],
216
+ isError: true,
217
+ };
218
+ }
219
+ // Pass params directly - they are already parsed by MCP SDK using the Zod schema
220
+ return this.#executeTool(currentPage, originalToolName, params);
221
+ });
222
+ this.#registeredTools.set(toolId, {
223
+ handle,
224
+ page,
225
+ originalName: tool.name,
226
+ domain,
227
+ toolId,
228
+ description,
229
+ });
230
+ // Track tool for this page
231
+ const pageToolSet = this.#pageTools.get(page) || new Set();
232
+ pageToolSet.add(toolId);
233
+ this.#pageTools.set(page, pageToolSet);
234
+ }
235
+ /**
236
+ * Execute a WebMCP tool on a page
237
+ */
238
+ async #executeTool(page, toolName, args) {
239
+ try {
240
+ const result = await this.#context.getWebMCPClient(page);
241
+ if (!result.connected) {
242
+ return {
243
+ content: [{ type: 'text', text: 'WebMCP connection lost' }],
244
+ isError: true,
245
+ };
246
+ }
247
+ const callResult = await result.client.callTool({
248
+ name: toolName,
249
+ arguments: args,
250
+ });
251
+ // The SDK's callTool returns CallToolResult, but we need to handle
252
+ // the content array properly
253
+ return callResult;
254
+ }
255
+ catch (err) {
256
+ return {
257
+ content: [
258
+ {
259
+ type: 'text',
260
+ text: `Tool error: ${err instanceof Error ? err.message : String(err)}`,
261
+ },
262
+ ],
263
+ isError: true,
264
+ };
265
+ }
266
+ }
267
+ /**
268
+ * Get the page associated with a registered tool
269
+ */
270
+ #getPageForTool(toolId) {
271
+ const registered = this.#registeredTools.get(toolId);
272
+ return registered?.page;
273
+ }
274
+ /**
275
+ * Generate a unique tool ID following the naming convention
276
+ */
277
+ #generateToolId(domain, pageIdx, toolName) {
278
+ return `webmcp_${domain}_page${pageIdx}_${sanitizeName(toolName)}`;
279
+ }
280
+ /**
281
+ * Generate a tool description with WebMCP context
282
+ */
283
+ #generateDescription(domain, pageIdx, originalDescription) {
284
+ const displayDomain = getDisplayDomain(domain);
285
+ return `[WebMCP - ${displayDomain} - Page ${pageIdx}] ${originalDescription || 'No description'}`;
286
+ }
287
+ /**
288
+ * Get the total number of registered tools
289
+ */
290
+ getToolCount() {
291
+ return this.#registeredTools.size;
292
+ }
293
+ /**
294
+ * Get all registered tool IDs
295
+ */
296
+ getRegisteredToolIds() {
297
+ return Array.from(this.#registeredTools.keys());
298
+ }
299
+ /**
300
+ * Get all registered tools with their metadata (for diff_webmcp_tools)
301
+ */
302
+ getRegisteredTools() {
303
+ return Array.from(this.#registeredTools.values()).map(rt => ({
304
+ toolId: rt.toolId,
305
+ originalName: rt.originalName,
306
+ domain: rt.domain,
307
+ pageIdx: this.#context.getPages().indexOf(rt.page),
308
+ description: rt.description,
309
+ }));
310
+ }
311
+ /**
312
+ * Get the last seen tool IDs for diff tracking
313
+ */
314
+ getLastSeenToolIds() {
315
+ return this.#lastSeenToolIds;
316
+ }
317
+ /**
318
+ * Set the last seen tool IDs for diff tracking
319
+ */
320
+ setLastSeenToolIds(toolIds) {
321
+ this.#lastSeenToolIds = toolIds;
322
+ }
323
+ /**
324
+ * Clear the last seen tool IDs (resets diff state)
325
+ */
326
+ clearLastSeenToolIds() {
327
+ this.#lastSeenToolIds = null;
328
+ }
329
+ }
330
+ /**
331
+ * Sanitize a name to be used in tool IDs.
332
+ * Replaces any non-alphanumeric characters (except underscore) with underscore.
333
+ */
334
+ export function sanitizeName(name) {
335
+ return name.replace(/[^a-zA-Z0-9_]/g, '_');
336
+ }
337
+ /**
338
+ * Extract and sanitize domain from a URL.
339
+ * Handles localhost specially to include port in the domain.
340
+ * Returns 'unknown' for URLs without a valid hostname (about:blank, file://, data:, etc.)
341
+ */
342
+ export function extractDomain(url) {
343
+ try {
344
+ const urlObj = new URL(url);
345
+ const hostname = urlObj.hostname;
346
+ // Handle empty hostname (about:blank, file://, data:, etc.)
347
+ if (!hostname) {
348
+ return 'unknown';
349
+ }
350
+ const isLocalhost = hostname === 'localhost' ||
351
+ hostname === '127.0.0.1' ||
352
+ hostname === '[::1]';
353
+ const domain = isLocalhost
354
+ ? `localhost_${urlObj.port || '80'}`
355
+ : hostname;
356
+ return sanitizeName(domain);
357
+ }
358
+ catch {
359
+ return 'unknown';
360
+ }
361
+ }
362
+ /**
363
+ * Convert a sanitized domain back to display format.
364
+ * Handles localhost port conversion and general underscore-to-dot conversion.
365
+ *
366
+ * IMPORTANT: Handle localhost FIRST before general underscore replacement
367
+ */
368
+ export function getDisplayDomain(sanitizedDomain) {
369
+ return sanitizedDomain
370
+ .replace(/^localhost_(\d+)$/, 'localhost:$1')
371
+ .replace(/_/g, '.');
372
+ }
@@ -29,12 +29,18 @@ export const selectPage = defineTool({
29
29
  schema: {
30
30
  pageIdx: zod
31
31
  .number()
32
- .describe('The index of the page to select. Call list_pages to list pages.'),
32
+ .describe(`The index of the page to select. Call ${listPages.name} to get available pages.`),
33
+ bringToFront: zod
34
+ .boolean()
35
+ .optional()
36
+ .describe('Whether to focus the page and bring it to the top.'),
33
37
  },
34
38
  handler: async (request, response, context) => {
35
39
  const page = context.getPageByIdx(request.params.pageIdx);
36
- await page.bringToFront();
37
40
  context.selectPage(page);
41
+ if (request.params.bringToFront) {
42
+ await page.bringToFront();
43
+ }
38
44
  response.setIncludePages(true);
39
45
  },
40
46
  });
@@ -7,153 +7,85 @@ import { zod } from '../third_party/index.js';
7
7
  import { ToolCategory } from './categories.js';
8
8
  import { defineTool } from './ToolDefinition.js';
9
9
  /**
10
- * List all MCP tools available on a webpage.
11
- * Auto-connects to WebMCP if not already connected.
10
+ * Show all WebMCP tools registered across all pages, with diff since last call.
11
+ * First call returns full list. Subsequent calls return only added/removed tools.
12
12
  */
13
- export const listWebMCPTools = defineTool({
14
- name: 'list_webmcp_tools',
15
- description: 'List all MCP tools available on a webpage. ' +
16
- 'Automatically connects to WebMCP if the page has @mcp-b/global loaded. ' +
17
- 'Use page_index to target a specific page (see list_pages for indices).',
13
+ export const diffWebMCPTools = defineTool({
14
+ name: 'diff_webmcp_tools',
15
+ description: 'Show all WebMCP tools registered across all pages, with diff since last call. ' +
16
+ 'First call returns full list. Subsequent calls return only added/removed tools. ' +
17
+ 'Use full=true to force complete list. ' +
18
+ 'Tools are shown with their callable names (e.g., webmcp_localhost_3000_page0_test_add). ' +
19
+ 'Call these tools directly by name instead of using a separate call tool.',
18
20
  annotations: {
19
- title: 'List Website MCP Tools',
21
+ title: 'Diff Website MCP Tools',
20
22
  category: ToolCategory.WEBMCP,
21
23
  readOnlyHint: true,
22
24
  },
23
25
  schema: {
24
- page_index: zod
25
- .number()
26
- .int()
26
+ full: zod
27
+ .boolean()
27
28
  .optional()
28
- .describe('Index of the page to list tools from. If not specified, uses the currently selected page. ' +
29
- 'Use list_pages to see available pages and their indices.'),
29
+ .describe('Force full tool list instead of diff. Default: false'),
30
30
  },
31
31
  handler: async (request, response, context) => {
32
- const { page_index } = request.params;
33
- // Get the target page
34
- const page = page_index !== undefined
35
- ? context.getPageByIdx(page_index)
36
- : context.getSelectedPage();
37
- // Get client from context (handles auto-connect and stale connection detection)
38
- const result = await context.getWebMCPClient(page);
39
- if (!result.connected) {
40
- response.appendResponseLine(result.error || 'No WebMCP tools available on this page.');
32
+ const { full } = request.params;
33
+ const toolHub = context.getToolHub();
34
+ if (!toolHub) {
35
+ response.appendResponseLine('WebMCPToolHub not initialized.');
41
36
  return;
42
37
  }
43
- const client = result.client;
44
- try {
45
- const { tools } = await client.listTools();
46
- if (page_index !== undefined) {
47
- response.appendResponseLine(`Page ${page_index}: ${page.url()}`);
48
- response.appendResponseLine('');
38
+ const tools = toolHub.getRegisteredTools();
39
+ const currentToolIds = new Set(tools.map(t => t.toolId));
40
+ const lastSeen = toolHub.getLastSeenToolIds();
41
+ // First call or full=true: return full list
42
+ if (!lastSeen || full) {
43
+ toolHub.setLastSeenToolIds(currentToolIds);
44
+ if (tools.length === 0) {
45
+ response.appendResponseLine('No WebMCP tools registered.');
46
+ response.appendResponseLine('Navigate to a page with @mcp-b/global loaded to discover tools.');
47
+ return;
49
48
  }
50
- response.appendResponseLine(`${tools.length} tool(s) available:`);
49
+ response.appendResponseLine(`${tools.length} WebMCP tool(s) registered:`);
51
50
  response.appendResponseLine('');
52
51
  for (const tool of tools) {
53
- response.appendResponseLine(`- ${tool.name}`);
52
+ response.appendResponseLine(`- ${tool.toolId}`);
53
+ response.appendResponseLine(` Original: ${tool.originalName}`);
54
+ response.appendResponseLine(` Domain: ${tool.domain} (page ${tool.pageIdx})`);
54
55
  if (tool.description) {
55
56
  response.appendResponseLine(` Description: ${tool.description}`);
56
57
  }
57
- if (tool.inputSchema) {
58
- const schemaStr = JSON.stringify(tool.inputSchema, null, 2);
59
- // Only show schema if it's not too long
60
- if (schemaStr.length < 500) {
61
- response.appendResponseLine(` Input Schema: ${schemaStr}`);
62
- }
63
- else {
64
- response.appendResponseLine(` Input Schema: (complex schema, ${schemaStr.length} chars)`);
65
- }
66
- }
67
58
  response.appendResponseLine('');
68
59
  }
69
- }
70
- catch (err) {
71
- response.appendResponseLine(`Failed to list tools: ${err instanceof Error ? err.message : String(err)}`);
72
- }
73
- },
74
- });
75
- /**
76
- * Call a tool registered on a webpage via WebMCP.
77
- * Auto-connects to WebMCP if not already connected.
78
- */
79
- export const callWebMCPTool = defineTool({
80
- name: 'call_webmcp_tool',
81
- description: 'Call a tool registered on a webpage via WebMCP. ' +
82
- 'Automatically connects if the page has @mcp-b/global loaded. ' +
83
- 'Use list_webmcp_tools to see available tools and their schemas. ' +
84
- 'Use page_index to target a specific page.',
85
- annotations: {
86
- title: 'Call Website MCP Tool',
87
- category: ToolCategory.WEBMCP,
88
- readOnlyHint: false, // Tools may have side effects
89
- },
90
- schema: {
91
- name: zod.string().describe('The name of the tool to call'),
92
- arguments: zod
93
- .record(zod.any())
94
- .optional()
95
- .describe('Arguments to pass to the tool as a JSON object'),
96
- page_index: zod
97
- .number()
98
- .int()
99
- .optional()
100
- .describe('Index of the page to call the tool on. If not specified, uses the currently selected page. ' +
101
- 'Use list_pages to see available pages and their indices.'),
102
- },
103
- handler: async (request, response, context) => {
104
- const { name, arguments: args, page_index } = request.params;
105
- // Get the target page
106
- const page = page_index !== undefined
107
- ? context.getPageByIdx(page_index)
108
- : context.getSelectedPage();
109
- // Get client from context (handles auto-connect and stale connection detection)
110
- const result = await context.getWebMCPClient(page);
111
- if (!result.connected) {
112
- response.appendResponseLine(result.error || 'No WebMCP tools available on this page.');
113
60
  return;
114
61
  }
115
- const client = result.client;
116
- try {
117
- if (page_index !== undefined) {
118
- response.appendResponseLine(`Page ${page_index}: ${page.url()}`);
119
- }
120
- response.appendResponseLine(`Calling tool: ${name}`);
121
- if (args && Object.keys(args).length > 0) {
122
- response.appendResponseLine(`Arguments: ${JSON.stringify(args)}`);
62
+ // Subsequent calls: return diff
63
+ const added = tools.filter(t => !lastSeen.has(t.toolId));
64
+ const removed = [...lastSeen].filter(id => !currentToolIds.has(id));
65
+ toolHub.setLastSeenToolIds(currentToolIds);
66
+ if (added.length === 0 && removed.length === 0) {
67
+ response.appendResponseLine('No changes since last poll.');
68
+ if (tools.length > 0) {
69
+ const toolNames = tools.map(t => t.originalName).join(', ');
70
+ response.appendResponseLine(`${tools.length} tools available: ${toolNames}`);
123
71
  }
124
- response.appendResponseLine('');
125
- const callResult = await client.callTool({
126
- name,
127
- arguments: args || {},
128
- });
129
- response.appendResponseLine('Result:');
130
- // Format the result content
131
- if (callResult.content && Array.isArray(callResult.content)) {
132
- for (const content of callResult.content) {
133
- if (content.type === 'text') {
134
- response.appendResponseLine(content.text);
135
- }
136
- else if (content.type === 'image') {
137
- response.appendResponseLine(`[Image: ${content.mimeType}, ${content.data.length} bytes]`);
138
- }
139
- else if (content.type === 'resource') {
140
- response.appendResponseLine(`[Resource: ${JSON.stringify(content.resource)}]`);
141
- }
142
- else {
143
- response.appendResponseLine(JSON.stringify(content, null, 2));
144
- }
72
+ return;
73
+ }
74
+ if (added.length > 0) {
75
+ response.appendResponseLine(`Added (${added.length}):`);
76
+ for (const tool of added) {
77
+ response.appendResponseLine(`+ ${tool.toolId}`);
78
+ if (tool.description) {
79
+ response.appendResponseLine(` ${tool.description}`);
145
80
  }
146
81
  }
147
- else {
148
- response.appendResponseLine(JSON.stringify(callResult, null, 2));
149
- }
150
- if (callResult.isError) {
151
- response.appendResponseLine('');
152
- response.appendResponseLine('(Tool returned an error)');
153
- }
82
+ response.appendResponseLine('');
154
83
  }
155
- catch (err) {
156
- response.appendResponseLine(`Failed to call tool: ${err instanceof Error ? err.message : String(err)}`);
84
+ if (removed.length > 0) {
85
+ response.appendResponseLine(`Removed (${removed.length}):`);
86
+ for (const id of removed) {
87
+ response.appendResponseLine(`- ${id}`);
88
+ }
157
89
  }
158
90
  },
159
91
  });