diff --git a/package-lock.json b/package-lock.json index 7598995f8..2d6ff7560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "tiktoken": "^1.0.22", "typescript": "^6.0.2", "typescript-eslint": "^8.43.0", + "urlpattern-polyfill": "^10.1.0", "yargs": "18.0.0" }, "engines": { @@ -8998,6 +8999,13 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 65b1edff3..f5330669d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "tiktoken": "^1.0.22", "typescript": "^6.0.2", "typescript-eslint": "^8.43.0", + "urlpattern-polyfill": "^10.1.0", "yargs": "18.0.0" }, "engines": { diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 74ae8e130..dadc7e7c7 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -180,6 +180,11 @@ export const cliOptions = { 'Whether to include all kinds of pages such as webviews or background pages as pages.', hidden: true, }, + experimentalNavigationAllowlist: { + type: 'boolean', + describe: 'Whether to enable navigation allowlist tool parameter.', + hidden: true, + }, experimentalInteropTools: { type: 'boolean', describe: 'Whether to enable interoperability tools', diff --git a/src/telemetry/flag_usage_metrics.json b/src/telemetry/flag_usage_metrics.json index 98ea0642f..5a378fdf6 100644 --- a/src/telemetry/flag_usage_metrics.json +++ b/src/telemetry/flag_usage_metrics.json @@ -130,6 +130,14 @@ "name": "experimental_memory_present", "flagType": "boolean" }, + { + "name": "experimental_navigation_allowlist", + "flagType": "boolean" + }, + { + "name": "experimental_navigation_allowlist_present", + "flagType": "boolean" + }, { "name": "experimental_page_id_routing", "flagType": "boolean" diff --git a/src/third_party/index.ts b/src/third_party/index.ts index d3e00ee6c..caf183248 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import 'urlpattern-polyfill'; import 'core-js/modules/es.promise.with-resolvers.js'; import 'core-js/modules/es.set.union.v2.js'; import 'core-js/proposals/iterator-helpers.js'; diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 401559348..92fe6afcd 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -5,10 +5,11 @@ */ import {logger} from '../logger.js'; -import type {CdpPage, Dialog} from '../third_party/index.js'; +import type {CdpPage, Dialog, HTTPRequest} from '../third_party/index.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; +import type {ContextPage} from './ToolDefinition.js'; import { CLOSE_PAGE_ERROR, definePageTool, @@ -16,6 +17,64 @@ import { timeoutSchema, } from './ToolDefinition.js'; +async function navigateWithInterception( + page: ContextPage, + action: () => Promise, + allowListString?: string, + timeout?: number, +): Promise { + const allowList = allowListString + ? allowListString.split(',').map((p: string) => new URLPattern(p.trim())) + : undefined; + + const requestHandler = (interceptedRequest: HTTPRequest) => { + if (!interceptedRequest.isNavigationRequest()) { + void interceptedRequest.continue(); + return; + } + const requestUrl = interceptedRequest.url(); + const isAllowed = allowList!.some((pattern: URLPattern) => + pattern.test(requestUrl), + ); + + if (isAllowed) { + void interceptedRequest.continue(); + } else { + logger(`Blocking request to: ${requestUrl}`); + void interceptedRequest.abort('blockedbyclient'); + } + }; + + const cleanupInterception = async () => { + if (allowList) { + page.pptrPage.off('request', requestHandler); + await page.pptrPage.setRequestInterception(false).catch(error => { + logger(`Failed to disable request interception`, error); + }); + } + }; + + if (allowList) { + await page.pptrPage.setRequestInterception(true); + page.pptrPage.on('request', requestHandler); + } + + try { + await page.waitForEventsAfterAction( + async () => { + try { + await action(); + } finally { + await cleanupInterception(); + } + }, + {timeout}, + ); + } finally { + await cleanupInterception(); + } +} + export const listPages = defineTool(args => { return { name: 'list_pages', @@ -90,200 +149,229 @@ export const closePage = defineTool({ }, }); -export const newPage = defineTool({ - name: 'new_page', - description: `Open a new tab and load a URL. Use project URL if not specified otherwise.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: false, - }, - schema: { - url: zod.string().describe('URL to load in a new page.'), - background: zod - .boolean() - .optional() - .describe( - 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).', - ), - isolatedContext: zod - .string() - .optional() - .describe( - 'If specified, the page is created in an isolated browser context with the given name. ' + - 'Pages in the same browser context share cookies and storage. ' + - 'Pages in different browser contexts are fully isolated.', - ), - ...timeoutSchema, - }, - handler: async (request, response, context) => { - const page = await context.newPage( - request.params.background, - request.params.isolatedContext, - ); +export const newPage = defineTool(args => { + return { + name: 'new_page', + description: `Open a new tab and load a URL. Use project URL if not specified otherwise.`, + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: false, + }, + schema: { + url: zod.string().describe('URL to load in a new page.'), + background: zod + .boolean() + .optional() + .describe( + 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).', + ), + isolatedContext: zod + .string() + .optional() + .describe( + 'If specified, the page is created in an isolated browser context with the given name. ' + + 'Pages in the same browser context share cookies and storage. ' + + 'Pages in different browser contexts are fully isolated.', + ), + ...(args?.experimentalNavigationAllowlist + ? { + allowList: zod + .string() + .optional() + .describe( + 'Optional comma-separated list of URL patterns to allow. If provided, all other navigations will be blocked.', + ), + } + : {}), + ...timeoutSchema, + }, + handler: async (request, response, context) => { + const page = await context.newPage( + request.params.background, + request.params.isolatedContext, + ); - await page.waitForEventsAfterAction( - async () => { - await page.pptrPage.goto(request.params.url, { - timeout: request.params.timeout, - }); - }, - {timeout: request.params.timeout}, - ); + await navigateWithInterception( + page, + () => + page.pptrPage.goto(request.params.url, { + timeout: request.params.timeout, + }), + request.params.allowList, + request.params.timeout, + ); - response.setIncludePages(true); - response.setListInPageTools(); - }, + response.setIncludePages(true); + response.setListInPageTools(); + }, + }; }); -export const navigatePage = definePageTool({ - name: 'navigate_page', - description: `Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: false, - }, - schema: { - type: zod - .enum(['url', 'back', 'forward', 'reload']) - .optional() - .describe( - 'Navigate the page by URL, back or forward in history, or reload.', - ), - url: zod.string().optional().describe('Target URL (only type=url)'), - ignoreCache: zod - .boolean() - .optional() - .describe('Whether to ignore cache on reload.'), - handleBeforeUnload: zod - .enum(['accept', 'decline']) - .optional() - .describe( - 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.', - ), - initScript: zod - .string() - .optional() - .describe( - 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.', - ), - ...timeoutSchema, - }, - handler: async (request, response) => { - const page = request.page; - const options = { - timeout: request.params.timeout, - }; +export const navigatePage = definePageTool(args => { + return { + name: 'navigate_page', + description: `Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.`, + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: false, + }, + schema: { + type: zod + .enum(['url', 'back', 'forward', 'reload']) + .optional() + .describe( + 'Navigate the page by URL, back or forward in history, or reload.', + ), + url: zod.string().optional().describe('Target URL (only type=url)'), + ignoreCache: zod + .boolean() + .optional() + .describe('Whether to ignore cache on reload.'), + handleBeforeUnload: zod + .enum(['accept', 'decline']) + .optional() + .describe( + 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.', + ), + initScript: zod + .string() + .optional() + .describe( + 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.', + ), + ...(args?.experimentalNavigationAllowlist + ? { + allowList: zod + .string() + .optional() + .describe( + 'Optional comma-separated list of URL patterns to allow. If provided, all other navigations will be blocked.', + ), + } + : {}), + ...timeoutSchema, + }, + handler: async (request, response) => { + const page = request.page; + const options = { + timeout: request.params.timeout, + }; - if (!request.params.type && !request.params.url) { - throw new Error('Either URL or a type is required.'); - } + if (!request.params.type && !request.params.url) { + throw new Error('Either URL or a type is required.'); + } - if (!request.params.type) { - request.params.type = 'url'; - } + if (!request.params.type) { + request.params.type = 'url'; + } - const handleBeforeUnload = request.params.handleBeforeUnload ?? 'accept'; - const dialogHandler = (dialog: Dialog) => { - if (dialog.type() === 'beforeunload') { - if (handleBeforeUnload === 'accept') { - response.appendResponseLine(`Accepted a beforeunload dialog.`); - void dialog.accept(); - } else { - response.appendResponseLine(`Declined a beforeunload dialog.`); - void dialog.dismiss(); + const handleBeforeUnload = request.params.handleBeforeUnload ?? 'accept'; + const dialogHandler = (dialog: Dialog) => { + if (dialog.type() === 'beforeunload') { + if (handleBeforeUnload === 'accept') { + response.appendResponseLine(`Accepted a beforeunload dialog.`); + void dialog.accept(); + } else { + response.appendResponseLine(`Declined a beforeunload dialog.`); + void dialog.dismiss(); + } + // We are not going to report the dialog like regular dialogs. + page.clearDialog(); } - // We are not going to report the dialog like regular dialogs. - page.clearDialog(); - } - }; + }; - let initScriptId: string | undefined; - if (request.params.initScript) { - const {identifier} = await page.pptrPage.evaluateOnNewDocument( - request.params.initScript, - ); - initScriptId = identifier; - } + let initScriptId: string | undefined; + if (request.params.initScript) { + const {identifier} = await page.pptrPage.evaluateOnNewDocument( + request.params.initScript, + ); + initScriptId = identifier; + } - page.pptrPage.on('dialog', dialogHandler); + page.pptrPage.on('dialog', dialogHandler); - try { - await page.waitForEventsAfterAction( - async () => { - switch (request.params.type) { - case 'url': - if (!request.params.url) { - throw new Error( - 'A URL is required for navigation of type=url.', - ); - } - try { - await page.pptrPage.goto(request.params.url, options); - response.appendResponseLine( - `Successfully navigated to ${request.params.url}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate in the selected page: ${error.message}.`, - ); - } - break; - case 'back': - try { - await page.pptrPage.goBack(options); - response.appendResponseLine( - `Successfully navigated back to ${page.pptrPage.url()}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate back in the selected page: ${error.message}.`, - ); - } - break; - case 'forward': - try { - await page.pptrPage.goForward(options); - response.appendResponseLine( - `Successfully navigated forward to ${page.pptrPage.url()}.`, - ); - } catch (error) { - response.appendResponseLine( - `Unable to navigate forward in the selected page: ${error.message}.`, - ); - } - break; - case 'reload': - try { - await page.pptrPage.reload({ - ...options, - ignoreCache: request.params.ignoreCache, - }); - response.appendResponseLine(`Successfully reloaded the page.`); - } catch (error) { - response.appendResponseLine( - `Unable to reload the selected page: ${error.message}.`, - ); - } - break; - } - }, - {timeout: request.params.timeout}, - ); - } finally { - page.pptrPage.off('dialog', dialogHandler); - if (initScriptId) { - await page.pptrPage - .removeScriptToEvaluateOnNewDocument(initScriptId) - .catch(error => { - logger(`Failed to remove init script`, error); - }); + try { + await navigateWithInterception( + page, + async () => { + switch (request.params.type) { + case 'url': + if (!request.params.url) { + throw new Error( + 'A URL is required for navigation of type=url.', + ); + } + try { + await page.pptrPage.goto(request.params.url, options); + response.appendResponseLine( + `Successfully navigated to ${request.params.url}.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to navigate in the selected page: ${error.message}.`, + ); + } + break; + case 'back': + try { + await page.pptrPage.goBack(options); + response.appendResponseLine( + `Successfully navigated back to ${page.pptrPage.url()}.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to navigate back in the selected page: ${error.message}.`, + ); + } + break; + case 'forward': + try { + await page.pptrPage.goForward(options); + response.appendResponseLine( + `Successfully navigated forward to ${page.pptrPage.url()}.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to navigate forward in the selected page: ${error.message}.`, + ); + } + break; + case 'reload': + try { + await page.pptrPage.reload({ + ...options, + ignoreCache: request.params.ignoreCache, + }); + response.appendResponseLine( + `Successfully reloaded the page.`, + ); + } catch (error) { + response.appendResponseLine( + `Unable to reload the selected page: ${error.message}.`, + ); + } + break; + } + }, + request.params.allowList, + request.params.timeout, + ); + } finally { + page.pptrPage.off('dialog', dialogHandler); + if (initScriptId) { + await page.pptrPage + .removeScriptToEvaluateOnNewDocument(initScriptId) + .catch(error => { + logger(`Failed to remove init script`, error); + }); + } } - } - response.setIncludePages(true); - response.setListInPageTools(); - response.setListWebMcpTools(); - }, + response.setIncludePages(true); + response.setListInPageTools(); + response.setListWebMcpTools(); + }, + }; }); export const resizePage = definePageTool({ diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index d49d94057..295450b49 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -1193,7 +1193,7 @@ describe('inPage tools', () => { it('includes in-page tools in navigate_page response', async () => { await testIncludesInPageTools(async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( { params: {type: 'url', url: 'about:blank'}, page: context.getSelectedMcpPage(), @@ -1209,7 +1209,7 @@ describe('inPage tools', () => { // Workaround to ensure the test environment's new page contain in-page tools sinon.stub(context, 'newPage').resolves(context.getSelectedMcpPage()); - await newPage.handler( + await newPage().handler( { params: {url: 'about:blank'}, }, @@ -1540,7 +1540,7 @@ describe('webmcp', () => { t, {experimentalWebmcp: true} as ParsedArguments, async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( { params: {type: 'url', url: 'about:blank'}, page: context.getSelectedMcpPage(), @@ -1581,7 +1581,7 @@ describe('webmcp', () => { t, {experimentalWebmcp: false} as ParsedArguments, async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( { params: {type: 'url', url: 'about:blank'}, page: context.getSelectedMcpPage(), diff --git a/tests/third_party_notices.test.js.snapshot b/tests/third_party_notices.test.js.snapshot index bbc79a248..ca5c749e4 100644 --- a/tests/third_party_notices.test.js.snapshot +++ b/tests/third_party_notices.test.js.snapshot @@ -1,4 +1,31 @@ exports[`THIRD_PARTY_NOTICES > matches snapshot if exists 1`] = ` +Name: urlpattern-polyfill +URL: https://github.com/kenchris/urlpattern-polyfill +Version: +License: MIT + +Copyright 2020 Intel Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-------------------- DEPENDENCY DIVIDER -------------------- + Name: core-js URL: https://core-js.io Version: diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 1617f9f6b..f1128afd2 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -213,7 +213,7 @@ describe('pages', () => { context.getPageById(1), context.getSelectedMcpPage(), ); - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank'}}, response, context, @@ -235,7 +235,7 @@ describe('pages', () => { await originalPage.pptrPage.evaluate(() => document.hasFocus()), true, ); - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', background: true}}, response, context, @@ -256,7 +256,7 @@ describe('pages', () => { describe('new_page with isolatedContext', () => { it('creates a page in an isolated context', async () => { await withMcpContext(async (response, context) => { - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-a'}}, response, context, @@ -269,13 +269,13 @@ describe('pages', () => { it('reuses the same context for the same isolatedContext name', async () => { await withMcpContext(async (response, context) => { - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-a'}}, response, context, ); const page1 = context.getSelectedPptrPage(); - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-a'}}, response, context, @@ -290,13 +290,13 @@ describe('pages', () => { it('creates separate contexts for different isolatedContext names', async () => { await withMcpContext(async (response, context) => { - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-a'}}, response, context, ); const pageA = context.getSelectedPptrPage(); - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-b'}}, response, context, @@ -310,7 +310,7 @@ describe('pages', () => { it('includes isolatedContext in page listing', async () => { await withMcpContext(async (response, context) => { - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-a'}}, response, context, @@ -328,7 +328,7 @@ describe('pages', () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPptrPage(); assert.strictEqual(context.getIsolatedContextName(page), undefined); - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank'}}, response, context, @@ -342,7 +342,7 @@ describe('pages', () => { it('closes an isolated page without errors', async () => { await withMcpContext(async (response, context) => { - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'session-a'}}, response, context, @@ -358,7 +358,7 @@ describe('pages', () => { it('navigate_page targets the pageId page, not the global selection', async () => { await withMcpContext(async (response, context) => { - await newPage.handler( + await newPage().handler( { params: { url: 'data:text/html,

Initial

', @@ -375,7 +375,7 @@ describe('pages', () => { assert.notStrictEqual(context.getSelectedMcpPage(), isolatedPage); // Navigate using page; should target the isolated page. - await navigatePage.handler( + await navigatePage().handler( { params: { url: 'data:text/html,

Navigated

', @@ -473,7 +473,7 @@ describe('pages', () => { it('preserves focus across different browser contexts', async () => { await withMcpContext(async (response, context) => { // Create pages in separate isolated contexts. - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'ctx-a'}}, response, context, @@ -481,7 +481,7 @@ describe('pages', () => { const pageA = context.getSelectedPptrPage(); const pageAId = context.getPageId(pageA)!; - await newPage.handler( + await newPage().handler( {params: {url: 'about:blank', isolatedContext: 'ctx-b'}}, response, context, @@ -518,7 +518,7 @@ describe('pages', () => { describe('navigate_page', () => { it('navigates to correct page', async () => { await withMcpContext(async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( { params: {url: 'data:text/html,
Hello MCP
'}, page: context.getSelectedMcpPage(), @@ -547,7 +547,7 @@ describe('pages', () => { await page.pptrPage.close(); try { - await navigatePage.handler( + await navigatePage().handler( { params: {url: 'data:text/html,
Hello MCP
'}, page: context.getSelectedMcpPage(), @@ -571,7 +571,7 @@ describe('pages', () => { const stub = sinon.stub(page, 'waitForNavigation').resolves(null); try { - await navigatePage.handler( + await navigatePage().handler( { params: { url: 'about:blank', @@ -597,7 +597,7 @@ describe('pages', () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPptrPage(); await page.goto('data:text/html,
Hello MCP
'); - await navigatePage.handler( + await navigatePage().handler( {params: {type: 'back'}, page: context.getSelectedMcpPage()}, response, context, @@ -615,7 +615,7 @@ describe('pages', () => { const page = context.getSelectedPptrPage(); await page.goto('data:text/html,
Hello MCP
'); await page.goBack(); - await navigatePage.handler( + await navigatePage().handler( {params: {type: 'forward'}, page: context.getSelectedMcpPage()}, response, context, @@ -632,7 +632,7 @@ describe('pages', () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPptrPage(); await page.goto('data:text/html,
Hello MCP
'); - await navigatePage.handler( + await navigatePage().handler( {params: {type: 'reload'}, page: context.getSelectedMcpPage()}, response, context, @@ -658,7 +658,7 @@ describe('pages', () => { `, ); - await navigatePage.handler( + await navigatePage().handler( {params: {type: 'reload'}, page: context.getSelectedMcpPage()}, response, context, @@ -685,7 +685,7 @@ describe('pages', () => { `, ); - await navigatePage.handler( + await navigatePage().handler( { params: { type: 'reload', @@ -709,7 +709,7 @@ describe('pages', () => { it('go forward with error', async () => { await withMcpContext(async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( {params: {type: 'forward'}, page: context.getSelectedMcpPage()}, response, context, @@ -725,7 +725,7 @@ describe('pages', () => { }); it('go back with error', async () => { await withMcpContext(async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( {params: {type: 'back'}, page: context.getSelectedMcpPage()}, response, context, @@ -741,7 +741,7 @@ describe('pages', () => { }); it('navigates to correct page with initScript', async () => { await withMcpContext(async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( { params: { url: 'data:text/html,
Hello MCP
', diff --git a/tests/tools/pagesNavigateAllowlist.test.ts b/tests/tools/pagesNavigateAllowlist.test.ts new file mode 100644 index 000000000..98da02d79 --- /dev/null +++ b/tests/tools/pagesNavigateAllowlist.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import {navigatePage} from '../../src/tools/pages.js'; +import {serverHooks} from '../server.js'; +import {withMcpContext} from '../utils.js'; + +describe('pages allowList', () => { + const server = serverHooks(); + const args = {experimentalNavigationAllowlist: true} as ParsedArguments; + + it('navigates through redirects when all URLs are allowed', async () => { + server.addRoute('/a.html', (_req, res) => { + res.writeHead(302, {Location: '/b.html'}); + res.end(); + }); + server.addRoute('/b.html', (_req, res) => { + res.writeHead(302, {Location: '/c.html'}); + res.end(); + }); + server.addHtmlRoute( + '/c.html', + '

Final Destination

', + ); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage(); + const baseUrl = server.baseUrl; + const allowList = `${baseUrl}/a.html,${baseUrl}/b.html,${baseUrl}/c.html`; + + await navigatePage(args).handler( + { + params: { + url: `${baseUrl}/a.html`, + allowList, + }, + page, + }, + response, + context, + ); + + assert.strictEqual(page.pptrPage.url(), `${baseUrl}/c.html`); + const content = await page.pptrPage.evaluate( + () => document.querySelector('h1')?.textContent, + ); + assert.strictEqual(content, 'Final Destination'); + }); + }); + + it('blocks navigation when a redirect target is not allowed', async () => { + server.addRoute('/a.html', (_req, res) => { + res.writeHead(302, {Location: '/b.html'}); + res.end(); + }); + server.addRoute('/b.html', (_req, res) => { + res.writeHead(302, {Location: '/c.html'}); + res.end(); + }); + server.addHtmlRoute( + '/c.html', + '

Final Destination

', + ); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage(); + const baseUrl = server.baseUrl; + // b.html is missing from allowList + const allowList = `${baseUrl}/a.html,${baseUrl}/c.html`; + + await navigatePage(args).handler( + { + params: { + url: `${baseUrl}/a.html`, + allowList, + timeout: 2000, // Short timeout for failure + }, + page, + }, + response, + context, + ); + + // The navigation to b.html should be blocked. + // Puppeteer's goto will likely throw a timeout or net::ERR_ABORTED error. + const url = page.pptrPage.url(); + assert.notStrictEqual(url, `${baseUrl}/c.html`); + assert.ok( + response.responseLines.some(line => + line.includes('Unable to navigate'), + ), + ); + }); + }); +}); diff --git a/tests/tools/webmcp.test.ts b/tests/tools/webmcp.test.ts index fcbe5f3e1..5d91849f7 100644 --- a/tests/tools/webmcp.test.ts +++ b/tests/tools/webmcp.test.ts @@ -17,7 +17,7 @@ describe('webmcp', () => { describe('list_webmcp_tools', () => { it('list webmcp tools in navigate_page response', async () => { await withMcpContext(async (response, context) => { - await navigatePage.handler( + await navigatePage().handler( {params: {url: 'about:blank'}, page: context.getSelectedMcpPage()}, response, context,