diff --git a/README.md b/README.md index 61c3570ec..a4e8fab75 100644 --- a/README.md +++ b/README.md @@ -265,8 +265,9 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) -- **Debugging** (4 tools) +- **Debugging** (5 tools) - [`evaluate_script`](docs/tool-reference.md#evaluate_script) + - [`get_console_message`](docs/tool-reference.md#get_console_message) - [`list_console_messages`](docs/tool-reference.md#list_console_messages) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index c95ed3d38..68c1f9b39 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -29,8 +29,9 @@ - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) -- **[Debugging](#debugging)** (4 tools) +- **[Debugging](#debugging)** (5 tools) - [`evaluate_script`](#evaluate_script) + - [`get_console_message`](#get_console_message) - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) @@ -296,6 +297,16 @@ so returned values have to JSON-serializable. --- +### `get_console_message` + +**Description:** Gets a console message by its ID. You can get all messages by calling [`list_console_messages`](#list_console_messages). + +**Parameters:** + +- **msgid** (number) **(required)**: The msgid of a console message on the page from the listed console messages + +--- + ### `list_console_messages` **Description:** List all console messages for the currently selected page since the last navigation. diff --git a/src/McpContext.ts b/src/McpContext.ts index 90b67b02a..70f401c24 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -132,6 +132,14 @@ export class McpContext implements Context { return this.#consoleCollector.getData(page); } + getConsoleMessageStableId(message: ConsoleMessage | Error): number { + return this.#consoleCollector.getIdForResource(message); + } + + getConsoleMessageById(id: number): ConsoleMessage | Error { + return this.#consoleCollector.getById(this.getSelectedPage(), id); + } + async newPage(): Promise { const page = await this.browser.newPage(); const pages = await this.createPagesSnapshot(); diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 24e8e1575..5826bed52 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -5,7 +5,10 @@ */ import type {ConsoleMessage, ResourceType} from 'puppeteer-core'; -import {formatConsoleEvent} from './formatters/consoleFormatter.js'; +import { + formatConsoleEventShort, + formatConsoleEventVerbose, +} from './formatters/consoleFormatter.js'; import { getFormattedHeaderValue, getFormattedResponseBody, @@ -31,9 +34,10 @@ interface NetworkRequestData { } export interface ConsoleMessageData { - type: string; - message: string; - args: string[]; + consoleMessageStableId: number; + type?: string; + message?: string; + args?: string[]; } export class McpResponse implements Response { @@ -41,6 +45,7 @@ export class McpResponse implements Response { #includeSnapshot = false; #includeVerboseSnapshot = false; #attachedNetworkRequestData?: NetworkRequestData; + #attachedConsoleMessageData?: ConsoleMessageData; #consoleMessagesData?: ConsoleMessageData[]; #textResponseLines: string[] = []; #images: ImageContentData[] = []; @@ -118,6 +123,12 @@ export class McpResponse implements Response { }; } + attachConsoleMessage(id: number): void { + this.#attachedConsoleMessageData = { + consoleMessageStableId: id, + }; + } + get includePages(): boolean { return this.#includePages; } @@ -192,14 +203,62 @@ export class McpResponse implements Response { } } + if (this.#attachedConsoleMessageData?.consoleMessageStableId) { + const message = context.getConsoleMessageById( + this.#attachedConsoleMessageData.consoleMessageStableId, + ); + const consoleMessageStableId = + this.#attachedConsoleMessageData.consoleMessageStableId; + let data: ConsoleMessageData; + if ('args' in message) { + const consoleMessage = message as ConsoleMessage; + data = { + consoleMessageStableId, + type: consoleMessage.type(), + message: consoleMessage.text(), + args: await Promise.all( + consoleMessage.args().map(async arg => { + const stringArg = await arg.jsonValue().catch(() => { + // Ignore errors. + }); + return typeof stringArg === 'object' + ? JSON.stringify(stringArg) + : String(stringArg); + }), + ), + }; + } else { + data = { + consoleMessageStableId, + type: 'error', + message: (message as Error).message, + args: [], + }; + } + this.#attachedConsoleMessageData = data; + } + if (this.#consoleDataOptions?.include) { - const messages = context.getConsoleData(); + let messages = context.getConsoleData(); + + if (this.#consoleDataOptions.types?.length) { + const normalizedTypes = new Set(this.#consoleDataOptions.types); + messages = messages.filter(message => { + if ('type' in message) { + return normalizedTypes.has(message.type()); + } + return normalizedTypes.has('error'); + }); + } this.#consoleMessagesData = await Promise.all( messages.map(async (item): Promise => { + const consoleMessageStableId = + context.getConsoleMessageStableId(item); if ('args' in item) { const consoleMessage = item as ConsoleMessage; return { + consoleMessageStableId, type: consoleMessage.type(), message: consoleMessage.text(), args: await Promise.all( @@ -215,6 +274,7 @@ export class McpResponse implements Response { }; } return { + consoleMessageStableId, type: 'error', message: (item as Error).message, args: [], @@ -283,6 +343,7 @@ Call ${handleDialog.name} to handle it before continuing.`); } response.push(...this.#getIncludeNetworkRequestsData(context)); + response.push(...this.#getAttachedConsoleMessageData()); if (this.#networkRequestsOptions?.include) { let requests = context.getNetworkRequests(); @@ -329,7 +390,7 @@ Call ${handleDialog.name} to handle it before continuing.`); ); response.push(...data.info); response.push( - ...data.items.map(message => formatConsoleEvent(message)), + ...data.items.map(message => formatConsoleEventShort(message)), ); } else { response.push(''); @@ -376,6 +437,18 @@ Call ${handleDialog.name} to handle it before continuing.`); }; } + #getAttachedConsoleMessageData(): string[] { + const response: string[] = []; + const data = this.#attachedConsoleMessageData; + if (!data) { + return response; + } + + response.push(`## Console Message ${data.consoleMessageStableId}`); + response.push(formatConsoleEventVerbose(data)); + return response; + } + #getIncludeNetworkRequestsData(context: McpContext): string[] { const response: string[] = []; const url = this.#attachedNetworkRequestData?.networkRequestStableId; diff --git a/src/formatters/consoleFormatter.ts b/src/formatters/consoleFormatter.ts index 40f608305..47ba47b3e 100644 --- a/src/formatters/consoleFormatter.ts +++ b/src/formatters/consoleFormatter.ts @@ -15,33 +15,55 @@ const logLevels: Record = { assert: 'Assert', }; -export function formatConsoleEvent(msg: ConsoleMessageData): string { - const logLevel = logLevels[msg.type] ?? 'Log'; - const text = msg.message; +// The short format for a console message, based on a previous format. +export function formatConsoleEventShort(msg: ConsoleMessageData): string { + const args = msg.args ? formatArgs(msg, false) : ''; + return `msgid=${msg.consoleMessageStableId} [${msg.type}] ${msg.message}${args}`; +} + +// The verbose format for a console message, including all details. +export function formatConsoleEventVerbose(msg: ConsoleMessageData): string { + const logLevel = msg.type ? (logLevels[msg.type] ?? 'Log') : 'Log'; + let result = `${logLevel}> ${msg.message}`; + + if (msg.args && msg.args.length > 0) { + result += formatArgs(msg, true); + } + + result += ` + ID: ${msg.consoleMessageStableId}`; + result += ` + Type: ${msg.type}`; - const formattedArgs = formatArgs(msg.args, text); - return `${logLevel}> ${text} ${formattedArgs}`.trim(); + return result; } -// Only includes the first arg and indicates that there are more args -function formatArgs(args: string[], messageText: string): string { - if (args.length === 0) { +// If `includeAllArgs` is false, only includes the first arg and indicates that there are more args. +function formatArgs( + consoleData: ConsoleMessageData, + includeAllArgs = false, +): string { + if (!consoleData.args || consoleData.args.length === 0) { return ''; } let formattedArgs = ''; - const firstArg = args[0]; + // In the short format version, we only include the first arg. + const messageArgsToFormat = includeAllArgs + ? consoleData.args + : [consoleData.args[0]]; - if (firstArg !== messageText) { - formattedArgs += - typeof firstArg === 'object' - ? JSON.stringify(firstArg) - : String(firstArg); + for (const arg of messageArgsToFormat) { + if (arg !== consoleData.message) { + formattedArgs += ' '; + formattedArgs += + typeof arg === 'object' ? JSON.stringify(arg) : String(arg); + } } - if (args.length > 1) { - return `${formattedArgs} ...`; + if (!includeAllArgs && consoleData.args.length > 1) { + formattedArgs += ` ...`; } - return formattedArgs; + return formattedArgs.length > 0 ? ` Args:${formattedArgs}` : ''; } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 7c3b379ff..e90264a7e 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -62,6 +62,7 @@ export interface Response { setIncludeSnapshot(value: boolean, verbose?: boolean): void; attachImage(value: ImageContentData): void; attachNetworkRequest(reqid: number): void; + attachConsoleMessage(msgid: number): void; } /** diff --git a/src/tools/console.ts b/src/tools/console.ts index 91ac20d43..3f8967eb9 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -36,7 +36,7 @@ const FILTERABLE_MESSAGE_TYPES: readonly [ 'verbose', ]; -export const consoleTool = defineTool({ +export const listConsoleMessages = defineTool({ name: 'list_console_messages', description: 'List all console messages for the currently selected page since the last navigation.', @@ -76,3 +76,22 @@ export const consoleTool = defineTool({ }); }, }); + +export const getConsoleMessage = defineTool({ + name: 'get_console_message', + description: `Gets a console message by its ID. You can get all messages by calling ${listConsoleMessages.name}.`, + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + msgid: zod + .number() + .describe( + 'The msgid of a console message on the page from the listed console messages', + ), + }, + handler: async (request, response) => { + response.attachConsoleMessage(request.params.msgid); + }, +}); diff --git a/tests/formatters/consoleFormatter.test.ts b/tests/formatters/consoleFormatter.test.ts index b64adbca7..d96ec196b 100644 --- a/tests/formatters/consoleFormatter.test.ts +++ b/tests/formatters/consoleFormatter.test.ts @@ -7,109 +7,137 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {formatConsoleEvent} from '../../src/formatters/consoleFormatter.js'; +import { + formatConsoleEventShort, + formatConsoleEventVerbose, +} from '../../src/formatters/consoleFormatter.js'; import type {ConsoleMessageData} from '../../src/McpResponse.js'; describe('consoleFormatter', () => { - describe('formatConsoleEvent', () => { + describe('formatConsoleEventShort', () => { it('formats a console.log message', () => { const message: ConsoleMessageData = { + consoleMessageStableId: 1, type: 'log', message: 'Hello, world!', args: [], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Log> Hello, world!'); + const result = formatConsoleEventShort(message); + assert.equal(result, 'msgid=1 [log] Hello, world!'); }); it('formats a console.log message with one argument', () => { const message: ConsoleMessageData = { + consoleMessageStableId: 2, type: 'log', message: 'Processing file:', args: ['file.txt'], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Log> Processing file: file.txt'); + const result = formatConsoleEventShort(message); + assert.equal(result, 'msgid=2 [log] Processing file: Args: file.txt'); }); it('formats a console.log message with multiple arguments', () => { const message: ConsoleMessageData = { + consoleMessageStableId: 3, type: 'log', message: 'Processing file:', - args: ['file.txt', JSON.stringify({id: 1, status: 'done'})], + args: ['file.txt', 'another file'], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Log> Processing file: file.txt ...'); + const result = formatConsoleEventShort(message); + assert.equal(result, 'msgid=3 [log] Processing file: Args: file.txt ...'); }); - it('formats a console.error message', () => { + it('does not include args if message is the same as arg', () => { const message: ConsoleMessageData = { - type: 'error', - message: 'Something went wrong', - args: [], + consoleMessageStableId: 4, + type: 'log', + message: 'Hello', + args: ['Hello'], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Error> Something went wrong'); + const result = formatConsoleEventShort(message); + assert.equal(result, 'msgid=4 [log] Hello'); }); + }); - it('formats a console.error message with one argument', () => { + describe('formatConsoleEventVerbose', () => { + it('formats a console.log message', () => { const message: ConsoleMessageData = { - type: 'error', - message: 'Something went wrong:', - args: ['details'], + consoleMessageStableId: 1, + type: 'log', + message: 'Hello, world!', + args: [], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Error> Something went wrong: details'); + const result = formatConsoleEventVerbose(message); + assert.equal( + result, + `Log> Hello, world! + ID: 1 + Type: log`, + ); }); - it('formats a console.error message with multiple arguments', () => { + it('formats a console.log message with one argument', () => { const message: ConsoleMessageData = { - type: 'error', - message: 'Something went wrong:', - args: ['details', JSON.stringify({code: 500})], + consoleMessageStableId: 2, + type: 'log', + message: 'Processing file:', + args: ['file.txt'], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Error> Something went wrong: details ...'); + const result = formatConsoleEventVerbose(message); + assert.equal( + result, + `Log> Processing file: Args: file.txt + ID: 2 + Type: log`, + ); }); - it('formats a console.warn message', () => { + it('formats a console.log message with multiple arguments', () => { const message: ConsoleMessageData = { - type: 'warning', - message: 'This is a warning', - args: [], + consoleMessageStableId: 3, + type: 'log', + message: 'Processing file:', + args: ['file.txt', 'another file'], }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Warning> This is a warning'); + const result = formatConsoleEventVerbose(message); + assert.equal( + result, + `Log> Processing file: Args: file.txt another file + ID: 3 + Type: log`, + ); }); - it('formats a console.info message', () => { + it('formats a console.error message', () => { const message: ConsoleMessageData = { - type: 'info', - message: 'This is an info message', - args: [], - }; - const result = formatConsoleEvent(message); - assert.equal(result, 'Info> This is an info message'); - }); - - it('formats a page error', () => { - const error: ConsoleMessageData = { + consoleMessageStableId: 4, type: 'error', - message: 'Error: Page crashed', - args: [], + message: 'Something went wrong', }; - const result = formatConsoleEvent(error); - assert.equal(result, 'Error> Error: Page crashed'); + const result = formatConsoleEventVerbose(message); + assert.equal( + result, + `Error> Something went wrong + ID: 4 + Type: error`, + ); }); - it('formats a page error without a stack', () => { - const error: ConsoleMessageData = { - type: 'error', - message: 'Error: Page crashed', - args: [], + it('does not include args if message is the same as arg', () => { + const message: ConsoleMessageData = { + consoleMessageStableId: 5, + type: 'log', + message: 'Hello', + args: ['Hello', 'World'], }; - const result = formatConsoleEvent(error); - assert.equal(result, 'Error> Error: Page crashed'); + const result = formatConsoleEventVerbose(message); + assert.equal( + result, + `Log> Hello Args: World + ID: 5 + Type: log`, + ); }); }); }); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index cd91587ca..a96b36449 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -6,14 +6,17 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {consoleTool} from '../../src/tools/console.js'; +import { + getConsoleMessage, + listConsoleMessages, +} from '../../src/tools/console.js'; import {withBrowser} from '../utils.js'; describe('console', () => { describe('list_console_messages', () => { it('list messages', async () => { await withBrowser(async (response, context) => { - await consoleTool.handler({params: {}}, response, context); + await listConsoleMessages.handler({params: {}}, response, context); assert.ok(response.includeConsoleData); }); }); @@ -24,14 +27,45 @@ describe('console', () => { await page.setContent( '', ); - await consoleTool.handler({params: {}}, response, context); + await listConsoleMessages.handler({params: {}}, response, context); await response.handle('test', context); const formattedResponse = response.format('test', context); const textContent = formattedResponse[0] as {text: string}; - assert.ok(textContent.text.includes('Error>')); - assert.ok(textContent.text.includes('This is an error')); + assert.ok( + textContent.text.includes('msgid=1 [error] This is an error'), + ); + }); + }); + }); + + describe('get_console_message', () => { + it('gets a specific console message', async () => { + await withBrowser(async (response, context) => { + const page = await context.newPage(); + await page.setContent( + '', + ); + // The list is needed to populate the console messages in the context. + await listConsoleMessages.handler({params: {}}, response, context); + await getConsoleMessage.handler( + {params: {msgid: 1}}, + response, + context, + ); + await response.handle('test', context); + + const formattedResponse = response.format('test', context); + const textContent = formattedResponse[0] as {text: string}; + assert.ok( + textContent.text.includes('## Console Message 1'), + 'Should contain console message title', + ); + assert.ok( + textContent.text.includes('msgid=1 [error] This is an error'), + 'Should contain console message body', + ); }); }); });