From 0b33e74f495666ec410d84b26863e281fc0a1ce4 Mon Sep 17 00:00:00 2001 From: "Abnoz.v2" Date: Sun, 20 Sep 2026 13:56:50 +0100 Subject: [PATCH 1/2] fix(mcp): rebind page-registered WebMCP tools when the tab or frame changes The dynamic tool list was only replaced when the tool schemas changed, so handlers stayed bound to a detached frame after a reload, or to the previous tab when two tabs exposed the same tools. Fixes https://github.com/microsoft/playwright/issues/42816 --- .../src/tools/backend/context.ts | 4 +- tests/mcp/webmcp-dynamic.spec.ts | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 0de784b4ff306..791eb728d4a62 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -333,11 +333,13 @@ export class Context { maybeNotifyWebMCPToolsChanged() { const tools = this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; + // Handlers are bound to the tab and frame they were listed from, so always take + // the fresh ones. Only the listChanged notification depends on the schemas. + this._webmcpTools = tools; const signature = JSON.stringify(tools.map(tool => tool.schema)); if (signature === this._webmcpToolsSignature) return; this._webmcpToolsSignature = signature; - this._webmcpTools = tools; this.options.onWebMCPToolsChanged?.(); } diff --git a/tests/mcp/webmcp-dynamic.spec.ts b/tests/mcp/webmcp-dynamic.spec.ts index 3725780cc5727..35ef61a109dfd 100644 --- a/tests/mcp/webmcp-dynamic.spec.ts +++ b/tests/mcp/webmcp-dynamic.spec.ts @@ -186,6 +186,59 @@ test('only the current tab contributes tools, switching tabs swaps them', async expect(await names()).toEqual(['webmcp_add']); }); +test('a tool registered in an iframe can be called after the page is reloaded', async ({ startClient, server, mcpBrowser }) => { + server.setRoute('/frame', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`frame${registerScript(kAdd)}`); + }); + server.setRoute('/', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`top`); + }); + + const { client } = await startClient({ config: webmcpConfig(mcpBrowser) }); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 1, b: 2 } })).toHaveResponse({ + result: expect.stringContaining('"text": "3"'), + }); + + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + expect(await client.callTool({ name: 'webmcp_add', arguments: { a: 2, b: 3 } })).toHaveResponse({ + result: expect.stringContaining('"text": "5"'), + }); +}); + +test('a tool with the same name in two tabs runs in the current tab', async ({ startClient, server, mcpBrowser }) => { + const whoami = (answer: string) => registerScript(` + modelContext.registerTool({ + name: 'whoami', + description: 'Answers with the tab name', + async execute() { return { content: [{ type: 'text', text: '${answer}' }] }; }, + }); + `); + server.setRoute('/one', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`one${whoami('tab-one')}`); + }); + server.setRoute('/two', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`two${whoami('tab-two')}`); + }); + + const { client } = await startClient({ config: webmcpConfig(mcpBrowser) }); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/one' } }); + await client.callTool({ name: 'browser_tabs', arguments: { action: 'new' } }); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/two' } }); + expect(await client.callTool({ name: 'webmcp_whoami', arguments: {} })).toHaveResponse({ + result: expect.stringContaining('"text": "tab-two"'), + }); + + await client.callTool({ name: 'browser_tabs', arguments: { action: 'select', index: 0 } }); + expect(await client.callTool({ name: 'webmcp_whoami', arguments: {} })).toHaveResponse({ + result: expect.stringContaining('"text": "tab-one"'), + }); +}); + test('--no-webmcp opts out of page tools entirely', async ({ startClient, server, mcpBrowser }) => { server.setRoute('/', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); From 8dab0741a110544ffc2d7c332dda2036ada9714c Mon Sep 17 00:00:00 2001 From: "Abnoz.v2" Date: Tue, 22 Sep 2026 12:35:32 +0100 Subject: [PATCH 2/2] fix(mcp): read WebMCP tools from the current tab instead of caching them Address review: drop the cached tool list on Context, keep only the schema signature for listChanged notifications, drop the redundant two-tab test and skip the iframe test on Firefox, which does not list tools registered in iframes. --- .../src/tools/backend/context.ts | 10 ++---- tests/mcp/webmcp-dynamic.spec.ts | 32 +------------------ 2 files changed, 4 insertions(+), 38 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 791eb728d4a62..132f7e790cadf 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -119,7 +119,6 @@ export class Context { private _recordedActions: string[] | undefined; private _disposables: Disposable[] = []; - private _webmcpTools: WebMCPToolDefinition[] = []; private _webmcpToolsSignature = ''; private _runningToolName: string | undefined; @@ -328,15 +327,12 @@ export class Context { } currentWebMCPTools(): WebMCPToolDefinition[] { - return this._webmcpTools; + // Handlers are bound to a tab and frame, always take the fresh ones. + return this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; } maybeNotifyWebMCPToolsChanged() { - const tools = this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? []; - // Handlers are bound to the tab and frame they were listed from, so always take - // the fresh ones. Only the listChanged notification depends on the schemas. - this._webmcpTools = tools; - const signature = JSON.stringify(tools.map(tool => tool.schema)); + const signature = JSON.stringify(this.currentWebMCPTools().map(tool => tool.schema)); if (signature === this._webmcpToolsSignature) return; this._webmcpToolsSignature = signature; diff --git a/tests/mcp/webmcp-dynamic.spec.ts b/tests/mcp/webmcp-dynamic.spec.ts index 35ef61a109dfd..8e55c9fe4ceaa 100644 --- a/tests/mcp/webmcp-dynamic.spec.ts +++ b/tests/mcp/webmcp-dynamic.spec.ts @@ -187,6 +187,7 @@ test('only the current tab contributes tools, switching tabs swaps them', async }); test('a tool registered in an iframe can be called after the page is reloaded', async ({ startClient, server, mcpBrowser }) => { + test.skip(mcpBrowser === 'firefox', 'Firefox does not list tools registered in iframes'); server.setRoute('/frame', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(`frame${registerScript(kAdd)}`); @@ -208,37 +209,6 @@ test('a tool registered in an iframe can be called after the page is reloaded', }); }); -test('a tool with the same name in two tabs runs in the current tab', async ({ startClient, server, mcpBrowser }) => { - const whoami = (answer: string) => registerScript(` - modelContext.registerTool({ - name: 'whoami', - description: 'Answers with the tab name', - async execute() { return { content: [{ type: 'text', text: '${answer}' }] }; }, - }); - `); - server.setRoute('/one', (req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`one${whoami('tab-one')}`); - }); - server.setRoute('/two', (req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`two${whoami('tab-two')}`); - }); - - const { client } = await startClient({ config: webmcpConfig(mcpBrowser) }); - await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/one' } }); - await client.callTool({ name: 'browser_tabs', arguments: { action: 'new' } }); - await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + '/two' } }); - expect(await client.callTool({ name: 'webmcp_whoami', arguments: {} })).toHaveResponse({ - result: expect.stringContaining('"text": "tab-two"'), - }); - - await client.callTool({ name: 'browser_tabs', arguments: { action: 'select', index: 0 } }); - expect(await client.callTool({ name: 'webmcp_whoami', arguments: {} })).toHaveResponse({ - result: expect.stringContaining('"text": "tab-one"'), - }); -}); - test('--no-webmcp opts out of page tools entirely', async ({ startClient, server, mcpBrowser }) => { server.setRoute('/', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' });