diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index ea954cdd..c7b8cd55 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -69,6 +69,9 @@ const TOOL_CLEAR_RECORDING = 'browser_clear_recording'; const TOOL_START_REPLAY = 'browser_start_replay'; const TOOL_GET_REPLAY = 'browser_get_replay'; const TOOL_CANCEL_REPLAY = 'browser_cancel_replay'; +const TOOL_START_ORCHESTRATION = 'browser_start_orchestration'; +const TOOL_GET_ORCHESTRATION = 'browser_get_orchestration'; +const TOOL_CANCEL_ORCHESTRATION = 'browser_cancel_orchestration'; const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -107,6 +110,9 @@ const TOOL_NAMES = [ TOOL_START_REPLAY, TOOL_GET_REPLAY, TOOL_CANCEL_REPLAY, + TOOL_START_ORCHESTRATION, + TOOL_GET_ORCHESTRATION, + TOOL_CANCEL_ORCHESTRATION, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -148,6 +154,9 @@ let latestRecordingSession = null; let nextReplayCounter = 1; let activeReplaySession = null; let latestReplaySession = null; +let nextOrchestrationCounter = 1; +let activeOrchestrationSession = null; +let latestOrchestrationSession = null; const interceptRules = []; const recentRequests = []; const recentDownloads = []; @@ -800,6 +809,38 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_START_ORCHESTRATION, + description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.', + inputSchema: { + type: 'object', + properties: { + blocks: { type: 'array', items: { type: 'object' } }, + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + }, + required: ['blocks'], + additionalProperties: false, + }, + }, + { + name: TOOL_GET_ORCHESTRATION, + description: 'Read the current orchestration status and result summary from session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_CANCEL_ORCHESTRATION, + description: 'Cancel the active orchestration session and keep the summary in session-local state.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, { name: TOOL_TAKE_SCREENSHOT, description: @@ -2351,6 +2392,12 @@ function formatRecordingSummary(session) { ].join('\n'); } +function createOrchestrationId() { + const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`; + nextOrchestrationCounter += 1; + return orchestrationId; +} + function requireLatestReplay() { if (!latestReplaySession) { throw new Error('no replay available'); @@ -2358,6 +2405,13 @@ function requireLatestReplay() { return latestReplaySession; } +function requireLatestOrchestration() { + if (!latestOrchestrationSession) { + throw new Error('no orchestration available'); + } + return latestOrchestrationSession; +} + function formatReplaySummary(session) { return [ `replayId: ${session.replayId}`, @@ -2372,6 +2426,20 @@ function formatReplaySummary(session) { ].join('\n'); } +function formatOrchestrationSummary(session) { + return [ + `orchestrationId: ${session.orchestrationId}`, + `pageId: ${session.pageId}`, + `pageIndex: ${session.pageIndex}`, + `blockCount: ${session.blockCount}`, + `completedBlocks: ${session.completedBlocks}`, + `currentBlockIndex: ${session.currentBlockIndex}`, + `failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`, + `error: ${session.error || 'none'}`, + `status: ${session.status}`, + ].join('\n'); +} + function formatRecordingDetail(session) { const lines = [formatRecordingSummary(session), 'steps:']; if (session.steps.length === 0) { @@ -2441,6 +2509,31 @@ function validateReplayStep(step, replayPageId) { } } +const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([ + 'wait_for_then_click', + 'wait_for_then_type', + 'wait_for_then_press_key', + 'run_replay_sequence', + 'assert_query', +]); + +function requireOrchestrationBlocks(toolArgs) { + const blocks = Array.isArray(toolArgs.blocks) ? toolArgs.blocks : null; + if (!blocks || blocks.length === 0) { + throw new Error('blocks must be a non-empty array'); + } + return blocks; +} + +function validateOrchestrationBlock(block) { + if (!block || typeof block !== 'object') { + throw new Error('invalid orchestration block payload'); + } + if (!SUPPORTED_ORCHESTRATION_BLOCK_TYPES.has(block.type)) { + throw new Error('unsupported orchestration block type'); + } +} + function normalizeRecordedEvent(pageId, event) { if (!event || typeof event !== 'object') { return null; @@ -4843,6 +4936,118 @@ async function handleCancelReplay() { return formatReplaySummary(canceled); } +async function executeOrchestrationBlock(session, block) { + if (block.type === 'wait_for_then_click') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handleClick({ pageId: session.pageId, ...block.args.click }); + return; + } + + if (block.type === 'wait_for_then_type') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handleType({ pageId: session.pageId, ...block.args.type }); + return; + } + + if (block.type === 'wait_for_then_press_key') { + await handleWaitFor({ pageId: session.pageId, ...block.args.wait }); + await handlePressKey({ pageId: session.pageId, ...block.args.pressKey }); + return; + } + + if (block.type === 'run_replay_sequence') { + const replayResultText = await handleStartReplay({ pageId: session.pageId, steps: block.args.steps }); + if (replayResultText.includes('status: failed')) { + throw new Error(replayResultText); + } + return; + } + + if (block.type === 'assert_query') { + const queryText = await handleQuery({ pageId: session.pageId, ...block.args.query }); + const expectedField = String(block.args.assert.field || ''); + const expectedValue = String(block.args.assert.equals || ''); + if (!queryText.includes(`${expectedField}: ${expectedValue}`)) { + throw new Error(`assert_query failed for field ${expectedField}`); + } + return; + } + + throw new Error('unsupported orchestration block type'); +} + +async function executeOrchestrationBlocks(session) { + for (let index = 0; index < session.blocks.length; index += 1) { + const block = session.blocks[index]; + session.currentBlockIndex = index; + validateOrchestrationBlock(block); + await executeOrchestrationBlock(session, block); + session.completedBlocks = index + 1; + } +} + +async function handleStartOrchestration(toolArgs) { + if (activeOrchestrationSession) { + throw new Error('orchestration already active'); + } + + const pages = await listPageTargets(); + if (pages.length === 0) { + throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); + } + + const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); + if (!page.webSocketDebuggerUrl) { + throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); + } + + const blocks = requireOrchestrationBlocks(toolArgs); + const session = { + orchestrationId: createOrchestrationId(), + pageId: page.id, + pageIndex, + status: 'running', + blockCount: blocks.length, + completedBlocks: 0, + currentBlockIndex: 0, + failedBlockIndex: null, + error: null, + blocks, + }; + + latestOrchestrationSession = session; + activeOrchestrationSession = session; + + try { + await executeOrchestrationBlocks(session); + session.status = 'completed'; + } catch (error) { + session.status = 'failed'; + session.failedBlockIndex = session.currentBlockIndex; + session.error = error instanceof Error ? error.message : String(error); + } + + activeOrchestrationSession = null; + latestOrchestrationSession = session; + return formatOrchestrationSummary(session); +} + +async function handleGetOrchestration() { + const session = requireLatestOrchestration(); + return formatOrchestrationSummary(session); +} + +async function handleCancelOrchestration() { + if (!activeOrchestrationSession) { + throw new Error('no active orchestration'); + } + activeOrchestrationSession.status = 'canceled'; + const canceled = activeOrchestrationSession; + activeOrchestrationSession = null; + latestOrchestrationSession = canceled; + return formatOrchestrationSummary(canceled); +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -5087,6 +5292,30 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_START_ORCHESTRATION) { + const text = await handleStartOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_GET_ORCHESTRATION) { + const text = await handleGetOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_CANCEL_ORCHESTRATION) { + const text = await handleCancelOrchestration(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + if (toolName === TOOL_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index b01ad4d2..275a8801 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -439,6 +439,10 @@ function createReplayStep(step: Record): Record): Record { + return block; +} + function parseJsonArgument(expression: string, key: string): string | undefined { const marker = `const ${key} = JSON.parse(`; const start = expression.indexOf(marker); @@ -1836,6 +1840,9 @@ describe('ccs-browser MCP server', () => { 'browser_start_replay', 'browser_get_replay', 'browser_cancel_replay', + 'browser_start_orchestration', + 'browser_get_orchestration', + 'browser_cancel_orchestration', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1968,6 +1975,17 @@ describe('ccs-browser MCP server', () => { const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay'); expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' }); + const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration'); + expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' }); + expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' }); + + const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration'); + expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + + const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); + expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + const queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -6100,6 +6118,298 @@ describe('ccs-browser MCP server', () => { expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2'); }); + it('starts orchestration, reports progress, and completes a wait_then_click flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Orchestration Page', + currentUrl: 'https://example.com/orchestration', + click: { + '#menu': {}, + }, + wait: { + selectorSnapshots: { + '#menu': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + query: { + '#menu': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1201, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_click', + args: { + wait: { + selector: '#menu', + condition: { kind: 'visibility' }, + timeoutMs: 1000, + }, + click: { + selector: '#menu', + }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1202, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1201))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('completedBlocks: 1'); + expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed'); + }); + + it('completes a wait_then_type flow', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Type Orchestration Page', + currentUrl: 'https://example.com/type-orchestration', + wait: { + selectorSnapshots: { + '#email': [ + [ + { + exists: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + ], + ], + }, + }, + type: { + '#email': { kind: 'input', inputType: 'email', value: '' }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1203, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'wait_for_then_type', + args: { + wait: { selector: '#email', condition: { kind: 'visibility' }, timeoutMs: 1000 }, + type: { selector: '#email', text: 'walker@example.com', clearFirst: true }, + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('status: completed'); + expect(getResponseText(responses.find((message) => message.id === 1203))).toContain('completedBlocks: 1'); + }); + + it('completes a run_replay_sequence block', async () => { + const pages: MockPageState[] = [ + { + id: 'page-1', + title: 'Replay Sequence Orchestration Page', + currentUrl: 'https://example.com/orchestration-replay', + click: { '#submit': {} }, + query: { + '#submit': { + exists: true, + connected: true, + rect: { + x: 20, + y: 30, + width: 100, + height: 40, + top: 30, + right: 120, + bottom: 70, + left: 20, + }, + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ]; + + const responses = await runMcpRequests(pages, [ + { + jsonrpc: '2.0', + id: 1211, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'run_replay_sequence', + args: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }), + ], + }, + }, + }, + ]); + + expect(getResponseText(responses.find((message) => message.id === 1211))).toContain('status: completed'); + }); + + it('completes an assert_query block', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Assert Query Page', + currentUrl: 'https://example.com/assert-query', + query: { + '#status': { + exists: true, + connected: true, + innerText: 'ready', + textContent: 'ready', + display: 'block', + visibility: 'visible', + opacity: '1', + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1212, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assert: { field: 'innerText', equals: 'ready' }, + }, + }), + ], + }, + }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1212))).toContain('status: completed'); + }); + + it('fails orchestration on the first failing block', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Failure Page', currentUrl: 'https://example.com/orchestration-failure' }], + [ + { + jsonrpc: '2.0', + id: 1213, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#missing', fields: ['exists'] }, + assert: { field: 'exists', equals: 'true' }, + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1214, + method: 'tools/call', + params: { name: 'browser_get_orchestration', arguments: {} }, + }, + ] + ); + + const text = getResponseText(responses.find((message) => message.id === 1214)); + expect(text).toContain('status: failed'); + expect(text).toContain('failedBlockIndex: 0'); + }); + it('drags local files onto normal, frame, and shadow dropzones', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-')); const invoicePath = join(tempDir, 'invoice.pdf');