fix: list fresh page titles (#3103)

Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/2937
This commit is contained in:
Alex Rudenko authored and GitHub committed 2026-10-14 07:13:47 +00:00
1 parent 0ef528a52f
commit e7170825e7
8 files changed
+162 -14

No files matched your search

+20
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+63
View File
@@ -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();
+8 -8
View File
@@ -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
}
]
+29
View File
@@ -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(
+1
View File
@@ -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;
}
+1 -1
View File
@@ -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
`;