feat: support experimental allowlist for navigate tool calls (#1935)
This PR adds an experimental allowlist for the page navigate tool call (requires `--experimentalNavigationAllowlist`, off by default). The feature uses a list of URLPatterns to decline navigations that land on disallowed URLs. If that happens, the client can update the allowlist and re-try. The purpose of the this feature is to offer additional guardrails on top of the MCP server. It does not restrict subresources or JS/iframe navigations in any way. The performance impact is minimized by turning off interception as soon as the navigation request is done.
This commit is contained in:
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
@@ -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';
|
||||
|
||||
+277
-189
@@ -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<unknown>,
|
||||
allowListString?: string,
|
||||
timeout?: number,
|
||||
): Promise<void> {
|
||||
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,
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
let initScriptId: string | undefined;
|
||||
if (request.params.initScript) {
|
||||
const {identifier} = await page.pptrPage.evaluateOnNewDocument(
|
||||
request.params.initScript,
|
||||
);
|
||||
initScriptId = identifier;
|
||||
}
|
||||
|
||||
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;
|
||||
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.',
|
||||
),
|
||||
}
|
||||
},
|
||||
{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);
|
||||
});
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...timeoutSchema,
|
||||
},
|
||||
handler: async (request, response) => {
|
||||
const page = request.page;
|
||||
const options = {
|
||||
timeout: request.params.timeout,
|
||||
};
|
||||
|
||||
response.setIncludePages(true);
|
||||
response.setListInPageTools();
|
||||
response.setListWebMcpTools();
|
||||
},
|
||||
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';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
let initScriptId: string | undefined;
|
||||
if (request.params.initScript) {
|
||||
const {identifier} = await page.pptrPage.evaluateOnNewDocument(
|
||||
request.params.initScript,
|
||||
);
|
||||
initScriptId = identifier;
|
||||
}
|
||||
|
||||
page.pptrPage.on('dialog', dialogHandler);
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const resizePage = definePageTool({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
exports[`THIRD_PARTY_NOTICES > matches snapshot if exists 1`] = `
|
||||
Name: urlpattern-polyfill
|
||||
URL: https://github.com/kenchris/urlpattern-polyfill
|
||||
Version: <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: <VERSION>
|
||||
|
||||
+25
-25
@@ -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,<h1>Initial</h1>',
|
||||
@@ -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,<h1>Navigated</h1>',
|
||||
@@ -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,<div>Hello MCP</div>'},
|
||||
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,<div>Hello MCP</div>'},
|
||||
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,<div>Hello MCP</div>');
|
||||
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,<div>Hello MCP</div>');
|
||||
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,<div>Hello MCP</div>');
|
||||
await navigatePage.handler(
|
||||
await navigatePage().handler(
|
||||
{params: {type: 'reload'}, page: context.getSelectedMcpPage()},
|
||||
response,
|
||||
context,
|
||||
@@ -658,7 +658,7 @@ describe('pages', () => {
|
||||
</script>`,
|
||||
);
|
||||
|
||||
await navigatePage.handler(
|
||||
await navigatePage().handler(
|
||||
{params: {type: 'reload'}, page: context.getSelectedMcpPage()},
|
||||
response,
|
||||
context,
|
||||
@@ -685,7 +685,7 @@ describe('pages', () => {
|
||||
</script>`,
|
||||
);
|
||||
|
||||
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,<div>Hello MCP</div>',
|
||||
|
||||
@@ -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',
|
||||
'<html><body><h1>Final Destination</h1></body></html>',
|
||||
);
|
||||
|
||||
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',
|
||||
'<html><body><h1>Final Destination</h1></body></html>',
|
||||
);
|
||||
|
||||
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'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user