/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import {describe, it} from 'node:test'; import sinon from 'sinon'; import type {McpContext} from '../../src/McpContext.js'; import type {McpResponse} from '../../src/McpResponse.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; import { executeThirdPartyDeveloperTool, listThirdPartyDeveloperTools, } from '../../src/tools/thirdPartyDeveloper.js'; import type {ToolGroups} from '../../src/tools/thirdPartyDeveloper.js'; import {withMcpContext} from '../utils.js'; describe('thirdPartyDeveloperTools', () => { describe('list_3p_developer_tools', () => { it('lists tools', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); await page.pptrPage.evaluate(() => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: { type: 'object', properties: { arg: {type: 'string'}, }, }, execute: () => 'result', }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); const result = await response.handle( 'list_3p_developer_tools', context, ); // @ts-expect-error `structuredContent` has `thirdPartyDeveloperTools` const groups = result.structuredContent.thirdPartyDeveloperTools; assert.strictEqual(groups.length, 1); const actualGroup = groups[0]; assert.strictEqual(actualGroup.name, 'test-group'); assert.strictEqual(actualGroup.description, 'test description'); assert.strictEqual(actualGroup.tools.length, 1); assert.strictEqual(actualGroup.tools[0].name, 'test-tool'); assert.strictEqual( actualGroup.tools[0].description, 'test tool description', ); assert.deepEqual(actualGroup.tools[0].inputSchema, { type: 'object', properties: { arg: {type: 'string'}, }, }); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('handles empty response', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); await page.pptrPage.evaluate(() => { window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith({}); }); }); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); const result = await response.handle( 'list_3p_developer_tools', context, ); assert.ok(result.structuredContent); assert.deepStrictEqual( ( result.structuredContent as { thirdPartyDeveloperTools?: ToolGroups; } ).thirdPartyDeveloperTools, undefined, ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('handles no response', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); await page.pptrPage.evaluate(() => { window.addEventListener('devtoolstooldiscovery', () => { // do nothing }); }); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); const result = await response.handle( 'list_3p_developer_tools', context, ); assert.ok(result.structuredContent); assert.deepStrictEqual( ( result.structuredContent as { thirdPartyDeveloperTools?: ToolGroups; } ).thirdPartyDeveloperTools, undefined, ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('handles no eventListener', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); const result = await response.handle( 'list_3p_developer_tools', context, ); assert.ok(result.structuredContent); assert.deepStrictEqual( ( result.structuredContent as { thirdPartyDeveloperTools?: ToolGroups; } ).thirdPartyDeveloperTools, undefined, ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('lists multiple toolgroups', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); await page.pptrPage.evaluate(() => { window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith?.({ name: 'group-1', description: 'desc-1', tools: [ { name: 'tool-1', description: 'tool-1-desc', inputSchema: {}, execute: () => 'r1', }, ], }); }); window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith?.({ name: 'group-2', description: 'desc-2', tools: [ { name: 'tool-2', description: 'tool-2-desc', inputSchema: {}, execute: () => 'r2', }, ], }); }); }); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); const result = await response.handle( 'list_3p_developer_tools', context, ); const actualGroups = // @ts-expect-error structuredContent has `thirdPartyDeveloperTools` result.structuredContent.thirdPartyDeveloperTools; assert.ok(actualGroups); assert.strictEqual(actualGroups.length, 2); assert.strictEqual(actualGroups[0].name, 'group-1'); assert.strictEqual(actualGroups[1].name, 'group-2'); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('clears window.__dtmcp.toolGroups on subsequent getToolGroups calls', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); await page.pptrPage.evaluate(() => { const mockToolGroup = { name: 'group-1', description: 'desc-1', tools: [ { name: 'tool-1', description: 'tool-1-desc', inputSchema: {}, execute: () => 'r1', }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); await response.handle('list_3p_developer_tools', context); let groupsLength = await page.pptrPage.evaluate( () => window.__dtmcp?.toolGroups?.length, ); assert.strictEqual(groupsLength, 1); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); await response.handle('list_3p_developer_tools', context); groupsLength = await page.pptrPage.evaluate( () => window.__dtmcp?.toolGroups?.length, ); assert.strictEqual(groupsLength, 1); }, undefined, {categoryExperimentalThirdParty: true}, ); }); }); describe('execute_3p_developer_tool', () => { async function setupThirdPartyDeveloperTools( response: McpResponse, context: McpContext, evaluateFn: () => void, ) { const page = await context.newPage(); response.setPage(page); await page.pptrPage.evaluate(evaluateFn); await listThirdPartyDeveloperTools.handler( {params: {}, page}, response, context, ); await response.handle('list_3p_developer_tools', context); } it('executes a tool', async () => { await withMcpContext( async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: { type: 'object', properties: { arg: {type: 'string'}, }, required: ['arg'], }, execute: () => 'result', }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({arg: 'value'}), }, page: context.getSelectedMcpPage(), }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify('result', null, 2), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('throws if tool not found in list', async () => { await withMcpContext(async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await assert.rejects( async () => { await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'missing-tool', params: JSON.stringify({}), }, page: context.getSelectedMcpPage(), }, response, context, ); }, {message: /Tool missing-tool not found/}, ); }); }); it('throws if parameters are invalid', async () => { await withMcpContext( async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: { type: 'object', properties: { arg: {type: 'string'}, }, required: ['arg'], }, execute: () => 'result', }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await assert.rejects( async () => { await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), // Missing required 'arg' }, page: context.getSelectedMcpPage(), }, response, context, ); }, {message: /Invalid parameters for tool test-tool/}, ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('handles JSON result', async () => { await withMcpContext( async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, execute: () => ({foo: 'bar'}), }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: context.getSelectedMcpPage(), }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify({foo: 'bar'}, null, 2), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('replaces uid with element handle in params', async () => { await withMcpContext(async (response, context) => { const page = await context.newPage(); response.setPage(page); page.thirdPartyDeveloperTools = [ { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: { type: 'object', properties: { element: {type: 'object'}, }, required: ['element'], }, }, ], }, ]; await page.pptrPage.evaluate(() => { window.__dtmcp = { executeTool: async ( _name: string, args: Record, ) => { const el = args.element; if (el instanceof HTMLElement) { return { isElement: true, tagName: el.tagName, id: el.id, }; } return { isElement: false, tagName: '', id: '', }; }, }; }); await page.pptrPage.evaluate(() => { const div = document.createElement('div'); div.id = 'test-id'; document.body.appendChild(div); }); const handle = await page.pptrPage.$('#test-id'); if (!handle) { throw new Error('Handle not found'); } page.getElementByUid = async (uid: string) => { if (uid === 'some-uid') { return handle; } throw new Error('Not found'); }; await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({element: {uid: 'some-uid'}}), }, page: page, }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify( { isElement: true, tagName: 'DIV', id: 'test-id', }, null, 2, ), ); }); }); it('processToolResult replaces functions with ""', async () => { await withMcpContext( async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, execute: () => ({ foo: 'bar', func: () => undefined, }), }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: context.getSelectedMcpPage(), }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify({foo: 'bar', func: ''}, null, 2), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('processToolResult replaces circular references with ""', async () => { await withMcpContext( async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, execute: () => { const obj: Record = {foo: 'bar'}; obj.self = obj; return obj; }, }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: context.getSelectedMcpPage(), }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify({foo: 'bar', self: ''}, null, 2), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('processToolResult replaces non-plain objects with ""', async () => { await withMcpContext( async (response, context) => { await setupThirdPartyDeveloperTools(response, context, () => { class CustomClass { val = 'value'; } const mockToolGroup = { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, execute: () => ({ foo: 'bar', custom: new CustomClass(), }), }, ], }; window.addEventListener('devtoolstooldiscovery', (e: Event) => { // @ts-expect-error Event has `respondWith` e.respondWith(mockToolGroup); }); }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: context.getSelectedMcpPage(), }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify( {foo: 'bar', custom: ''}, null, 2, ), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('stashDOMElement stashes elements and returns UID', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); page.thirdPartyDeveloperTools = [ { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, }, ], }, ]; await page.pptrPage.evaluate(() => { window.__dtmcp = { executeTool: async () => { const div = document.createElement('div'); div.id = 'test-element'; document.body.appendChild(div); return div; }, }; }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: page, }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify({uid: '1_1'}, null, 2), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('creates a new snapshot if the third-party developer tool response contains a DOM element', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); page.thirdPartyDeveloperTools = [ { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, }, ], }, ]; await page.pptrPage.evaluate(() => { window.__dtmcp = { executeTool: async () => { const div = document.createElement('div'); div.id = 'test-element'; document.body.appendChild(div); return div; }, }; }); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: page, }, response, context, ); assert.strictEqual( response.responseLines[0], JSON.stringify({uid: '1_1'}, null, 2), ); }, undefined, {categoryExperimentalThirdParty: true}, ); }); it('does not create a new snapshot if the third-party developer tool response does not contain a DOM element', async () => { await withMcpContext( async (response, context) => { const page = await context.newPage(); response.setPage(page); page.thirdPartyDeveloperTools = [ { name: 'test-group', description: 'test description', tools: [ { name: 'test-tool', description: 'test tool description', inputSchema: {}, }, ], }, ]; await page.pptrPage.evaluate(() => { window.__dtmcp = { executeTool: async () => { return 'simple-result'; }, }; }); const stubSnapshot = sinon .stub(TextSnapshot, 'create') .resolves({} as TextSnapshot); await executeThirdPartyDeveloperTool.handler( { params: { toolName: 'test-tool', params: JSON.stringify({}), }, page: page, }, response, context, ); assert.ok( stubSnapshot.notCalled, 'Expected TextSnapshot.create not to be called', ); assert.strictEqual( response.responseLines[0], JSON.stringify('simple-result', null, 2), ); stubSnapshot.restore(); }, undefined, {categoryExperimentalThirdParty: true}, ); }); }); });