mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
fix: list fresh page titles (#3103)
Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/2937
This commit is contained in:
1 parent
0ef528a52f
commit
e7170825e7
8 files changed
+162
-14
No files matched your search
@@ -26,11 +26,13 @@ import {
|
||||
Locator,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Connection,
|
||||
type ConsoleMessage,
|
||||
type GetPWAStateOptions,
|
||||
type InstallPWAOptions,
|
||||
type LaunchPWAOptions,
|
||||
type Page,
|
||||
type Protocol,
|
||||
type PWAState,
|
||||
type ScreenRecorder,
|
||||
type Target,
|
||||
@@ -420,6 +422,24 @@ export class McpContext implements Context {
|
||||
return Array.from(this.#mcpPages.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches current target info for all targets from the browser, keyed by
|
||||
* target id. Unlike Puppeteer's cached target info, this reflects title
|
||||
* changes, which Chrome does not report via `Target.targetInfoChanged`.
|
||||
* Returns an empty map if the browser cannot be queried.
|
||||
*/
|
||||
async getTargetInfos(): Promise<Map<string, Protocol.Target.TargetInfo>> {
|
||||
try {
|
||||
// @ts-expect-error use existing browser connection (internal Puppeteer API).
|
||||
const connection: Connection = this.browser._connection;
|
||||
const {targetInfos} = await connection.send('Target.getTargets');
|
||||
return new Map(targetInfos.map(info => [info.targetId, info]));
|
||||
} catch (err) {
|
||||
this.logger?.('Failed to fetch target infos', err);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedMcpPage(): McpPage {
|
||||
const page = this.#selectedPage;
|
||||
if (!page) {
|
||||
|
||||
+32
-3
@@ -118,6 +118,19 @@ function isDebuggingWebMcpTool(tool: WebMCPTool): boolean {
|
||||
const DEFAULT_TIMEOUT = 5_000;
|
||||
const NAVIGATION_TIMEOUT = 10_000;
|
||||
|
||||
/**
|
||||
* Chrome reports a URL-derived placeholder as the target title until the
|
||||
* document provides one. The placeholder omits the `http://` / `https://`
|
||||
* scheme and the trailing slash of a bare hostname.
|
||||
*/
|
||||
function isPlaceholderTitle(title: string, url: string): boolean {
|
||||
if (title === url) {
|
||||
return true;
|
||||
}
|
||||
const withoutScheme = url.replace(/^https?:\/\//, '');
|
||||
return title === withoutScheme || title === withoutScheme.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function isBackendNodeId(
|
||||
id: unknown,
|
||||
): id is DevTools.Protocol.DOM.BackendNodeId {
|
||||
@@ -229,10 +242,26 @@ export class McpPage implements ContextPage {
|
||||
return this.#pptrPage ? this.#pptrPage.url() : this.target.url();
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
get targetId(): string {
|
||||
// @ts-expect-error internal types
|
||||
const info = this.target._getTargetInfo();
|
||||
return info.title !== this.target.url() ? info.title : '';
|
||||
const info: Protocol.Target.TargetInfo = this.target._getTargetInfo();
|
||||
return info.targetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page title or an empty string if the target only has Chrome's
|
||||
* URL-based placeholder title.
|
||||
*
|
||||
* Puppeteer's cached target info is only refreshed by
|
||||
* `Target.targetInfoChanged`, which Chrome emits on navigation commit (before
|
||||
* the document title is known) but not on title changes. Callers should pass
|
||||
* fresh target info (e.g., from `Target.getTargets`) when available.
|
||||
*/
|
||||
getTitle(freshTargetInfo?: Protocol.Target.TargetInfo): string {
|
||||
// @ts-expect-error internal types
|
||||
const cachedInfo: Protocol.Target.TargetInfo = this.target._getTargetInfo();
|
||||
const info = freshTargetInfo ?? cachedInfo;
|
||||
return isPlaceholderTitle(info.title, info.url) ? '' : info.title;
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
|
||||
+8
-2
@@ -49,6 +49,7 @@ import type {
|
||||
TextContent,
|
||||
Extension,
|
||||
HTTPRequest,
|
||||
Protocol,
|
||||
} from './third_party/index.js';
|
||||
import {handleDialog, listPages} from './tools/pages.js';
|
||||
import type {ToolGroups} from './tools/thirdPartyDeveloper.js';
|
||||
@@ -1080,6 +1081,11 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
|
||||
`Note: the previously selected page ${selectionFallback.wasClosed ? 'was closed' : 'is no longer listed'}.${selectedPageId !== undefined ? ` Page ${selectedPageId} is now selected.` : ''}`,
|
||||
);
|
||||
}
|
||||
const targetInfos =
|
||||
regularPages.length ||
|
||||
(this.#includeExtensionPages && extensionPages.length)
|
||||
? await context.getTargetInfos()
|
||||
: new Map<string, Protocol.Target.TargetInfo>();
|
||||
if (regularPages.length) {
|
||||
const parts = [`## Pages`];
|
||||
const structuredPages = [];
|
||||
@@ -1088,7 +1094,7 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
|
||||
const contextLabel = isolatedContextName
|
||||
? ` isolatedContext=${isolatedContextName}`
|
||||
: '';
|
||||
const title = mcpPage.getTitle();
|
||||
const title = mcpPage.getTitle(targetInfos.get(mcpPage.targetId));
|
||||
const pageLabel = title
|
||||
? `${truncateTitle(title)} (${mcpPage.url()})`
|
||||
: mcpPage.url();
|
||||
@@ -1110,7 +1116,7 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
|
||||
const contextLabel = isolatedContextName
|
||||
? ` isolatedContext=${isolatedContextName}`
|
||||
: '';
|
||||
const title = mcpPage.getTitle();
|
||||
const title = mcpPage.getTitle(targetInfos.get(mcpPage.targetId));
|
||||
const pageLabel = title
|
||||
? `${truncateTitle(title)} (${mcpPage.url()})`
|
||||
: mcpPage.url();
|
||||
|
||||
@@ -391,6 +391,69 @@ describe('McpPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTitle()', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
function createPage(url: string, title: string): McpPage {
|
||||
const target = createMockPuppeteerTarget({url, title});
|
||||
return new McpPage(target, 1, {
|
||||
hasNetworkBlockOrAllowlist: false,
|
||||
locatorClass: Locator,
|
||||
});
|
||||
}
|
||||
|
||||
function targetInfo(url: string, title: string) {
|
||||
return {
|
||||
targetId: '<mock-target-id>',
|
||||
type: 'page',
|
||||
title,
|
||||
url,
|
||||
attached: true,
|
||||
canAccessOpener: false,
|
||||
};
|
||||
}
|
||||
|
||||
it('prefers fresh target info over cached target info', () => {
|
||||
const mcpPage = createPage(
|
||||
'http://127.0.0.1:8080/titled.html',
|
||||
'127.0.0.1:8080/titled.html',
|
||||
);
|
||||
assert.strictEqual(
|
||||
mcpPage.getTitle(
|
||||
targetInfo('http://127.0.0.1:8080/titled.html', 'Real Title'),
|
||||
),
|
||||
'Real Title',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores Chrome URL placeholder titles', () => {
|
||||
assert.strictEqual(
|
||||
createPage(
|
||||
'http://127.0.0.1:8080/titled.html',
|
||||
'127.0.0.1:8080/titled.html',
|
||||
).getTitle(),
|
||||
'',
|
||||
);
|
||||
assert.strictEqual(
|
||||
createPage('https://example.com/', 'example.com').getTitle(),
|
||||
'',
|
||||
);
|
||||
assert.strictEqual(
|
||||
createPage('about:blank', 'about:blank').getTitle(),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the target id', () => {
|
||||
assert.strictEqual(
|
||||
createPage('https://example.com', '').targetId,
|
||||
'<mock-target-id>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emulate()', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
|
||||
@@ -1459,7 +1459,7 @@ exports[`third-party developer tools > lists third-party developer tools 2`] = `
|
||||
|
||||
exports[`webmcp > includes webmcp tools in list_pages response 1`] = `
|
||||
## Pages
|
||||
1: about:blank [selected]
|
||||
1: My test page (about:blank) [selected]
|
||||
## WebMCP tools
|
||||
name="test_tool", description="A test tool", inputSchema={"type":"object","properties":{},"required":[]}, annotations=undefined
|
||||
`;
|
||||
@@ -1470,7 +1470,7 @@ exports[`webmcp > includes webmcp tools in list_pages response 2`] = `
|
||||
{
|
||||
"id": 1,
|
||||
"url": "about:blank",
|
||||
"title": "",
|
||||
"title": "My test page",
|
||||
"selected": true
|
||||
}
|
||||
],
|
||||
@@ -1491,7 +1491,7 @@ exports[`webmcp > includes webmcp tools in list_pages response 2`] = `
|
||||
exports[`webmcp > includes webmcp tools in navigate_page response 1`] = `
|
||||
Successfully navigated to about:blank.
|
||||
## Pages
|
||||
1: about:blank [selected]
|
||||
1: My test page (about:blank) [selected]
|
||||
## WebMCP tools
|
||||
name="test_tool", description="A test tool", inputSchema={"type":"object","properties":{},"required":[]}, annotations=undefined
|
||||
`;
|
||||
@@ -1503,7 +1503,7 @@ exports[`webmcp > includes webmcp tools in navigate_page response 2`] = `
|
||||
{
|
||||
"id": 1,
|
||||
"url": "about:blank",
|
||||
"title": "",
|
||||
"title": "My test page",
|
||||
"selected": true
|
||||
}
|
||||
],
|
||||
@@ -1523,7 +1523,7 @@ exports[`webmcp > includes webmcp tools in navigate_page response 2`] = `
|
||||
|
||||
exports[`webmcp > includes webmcp tools in select_page response 1`] = `
|
||||
## Pages
|
||||
1: about:blank [selected]
|
||||
1: My test page (about:blank) [selected]
|
||||
## WebMCP tools
|
||||
name="test_tool", description="A test tool", inputSchema={"type":"object","properties":{},"required":[]}, annotations=undefined
|
||||
`;
|
||||
@@ -1534,7 +1534,7 @@ exports[`webmcp > includes webmcp tools in select_page response 2`] = `
|
||||
{
|
||||
"id": 1,
|
||||
"url": "about:blank",
|
||||
"title": "",
|
||||
"title": "My test page",
|
||||
"selected": true
|
||||
}
|
||||
],
|
||||
@@ -1555,7 +1555,7 @@ exports[`webmcp > includes webmcp tools in select_page response 2`] = `
|
||||
exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 1`] = `
|
||||
Successfully navigated to about:blank.
|
||||
## Pages
|
||||
1: about:blank [selected]
|
||||
1: My test page (about:blank) [selected]
|
||||
`;
|
||||
|
||||
exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 2`] = `
|
||||
@@ -1565,7 +1565,7 @@ exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 2`] = `
|
||||
{
|
||||
"id": 1,
|
||||
"url": "about:blank",
|
||||
"title": "",
|
||||
"title": "My test page",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -262,6 +262,35 @@ describe('McpResponse', () => {
|
||||
describe('navigation', () => {
|
||||
const server = serverHooks();
|
||||
|
||||
it('lists current page titles', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
server.addHtmlRoute(
|
||||
'/titled.html',
|
||||
'<!doctype html><title>Real Title</title><h1>Titled</h1>',
|
||||
);
|
||||
const page = context.getSelectedMcpPage().pptrPage;
|
||||
await page.goto(server.getRoute('/titled.html'));
|
||||
|
||||
response.setIncludePages(true);
|
||||
const res1 = await response.handle(context);
|
||||
assert.ok(
|
||||
getTextContent(res1.content[0]).includes(
|
||||
`Real Title (${server.getRoute('/titled.html')})`,
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.title = 'Changed By Script';
|
||||
});
|
||||
const res2 = await response.handle(context);
|
||||
assert.ok(
|
||||
getTextContent(res2.content[0]).includes(
|
||||
`Changed By Script (${server.getRoute('/titled.html')})`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets ids after navigation', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
server.addHtmlRoute(
|
||||
|
||||
@@ -420,6 +420,7 @@ export function createMockMcpContext(
|
||||
const context = sinon.createStubInstance(McpContext);
|
||||
const page = options.selectedPage ?? createMockMcpPage();
|
||||
context.getSelectedMcpPage.returns(page satisfies McpPage);
|
||||
context.getTargetInfos.resolves(new Map());
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ exports[`pages > list_pages > list pages for side panels with --category-extensi
|
||||
## Pages
|
||||
1: about:blank
|
||||
## Extension Pages
|
||||
2: chrome-extension://<extension-id>/sidepanel.html [selected]
|
||||
2: Side Panel (chrome-extension://<extension-id>/sidepanel.html) [selected]
|
||||
## Extension Service Workers
|
||||
sw-1: chrome-extension://<extension-id>/sw.js
|
||||
`;
|
||||
|
||||
Reference in new issue
Block a user