diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 817759d9..90dd5812 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -72,6 +72,10 @@ 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_EXPORT_ARTIFACT = 'browser_export_artifact'; +const TOOL_IMPORT_ARTIFACT = 'browser_import_artifact'; +const TOOL_LIST_ARTIFACTS = 'browser_list_artifacts'; +const TOOL_DELETE_ARTIFACT = 'browser_delete_artifact'; const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -113,6 +117,10 @@ const TOOL_NAMES = [ TOOL_START_ORCHESTRATION, TOOL_GET_ORCHESTRATION, TOOL_CANCEL_ORCHESTRATION, + TOOL_EXPORT_ARTIFACT, + TOOL_IMPORT_ARTIFACT, + TOOL_LIST_ARTIFACTS, + TOOL_DELETE_ARTIFACT, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -841,6 +849,52 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_EXPORT_ARTIFACT, + description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['kind', 'name'], + additionalProperties: false, + }, + }, + { + name: TOOL_IMPORT_ARTIFACT, + description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_ARTIFACTS, + description: 'List locally saved Browser MCP artifacts.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_DELETE_ARTIFACT, + description: 'Delete a locally saved Browser MCP artifact by name.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, { name: TOOL_TAKE_SCREENSHOT, description: @@ -2392,6 +2446,52 @@ function formatRecordingSummary(session) { ].join('\n'); } +function getArtifactDir() { + const scoped = Buffer.from(process.cwd()).toString('base64url'); + const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped); + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + return baseDir; +} + +function getArtifactPath(name) { + return path.join(getArtifactDir(), `${name}.json`); +} + +function buildArtifact(kind, name, payload) { + return { + kind, + version: 1, + name, + createdAt: new Date().toISOString(), + payload, + }; +} + +function readArtifactFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error('artifact file not found'); + } + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('invalid artifact payload'); + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('invalid artifact payload'); + } + if (parsed.version !== 1) { + throw new Error('unsupported artifact version'); + } + if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) { + throw new Error('invalid artifact payload'); + } + return parsed; +} + function createOrchestrationId() { const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`; nextOrchestrationCounter += 1; @@ -5265,6 +5365,91 @@ async function handleGetOrchestration() { return formatOrchestrationSummary(session); } +function getArtifactPayloadForKind(kind) { + if (kind === 'recording') { + return requireLatestRecording(); + } + if (kind === 'replay') { + return requireLatestReplay(); + } + if (kind === 'orchestration') { + return requireLatestOrchestration(); + } + throw new Error(`unsupported artifact kind: ${kind}`); +} + +function resolveArtifactPath(reference) { + const value = requireNonEmptyString(reference, 'path'); + if (value.startsWith('artifact:')) { + return getArtifactPath(value.slice('artifact:'.length)); + } + return value; +} + +function deleteArtifactByName(name) { + const filePath = getArtifactPath(name); + if (!fs.existsSync(filePath)) { + throw new Error('artifact not found'); + } + fs.unlinkSync(filePath); + return filePath; +} + +async function handleExportArtifact(toolArgs) { + const kind = requireNonEmptyString(toolArgs.kind, 'kind'); + const name = requireNonEmptyString(toolArgs.name, 'name'); + const filePath = getArtifactPath(name); + if (fs.existsSync(filePath)) { + throw new Error('artifact already exists'); + } + const payload = getArtifactPayloadForKind(kind); + const artifact = buildArtifact(kind, name, payload); + fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2), 'utf8'); + return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`; +} + +async function handleListArtifacts() { + const dir = getArtifactDir(); + const entries = fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.json')) + .map((entry) => readArtifactFile(path.join(dir, entry))); + if (entries.length === 0) { + return 'artifacts: []'; + } + return entries + .map((entry) => [ + `name: ${entry.name}`, + `kind: ${entry.kind}`, + `createdAt: ${entry.createdAt}`, + `path: ${getArtifactPath(entry.name)}`, + ].join('\n')) + .join('\n---\n'); +} + +async function handleImportArtifact(toolArgs) { + const filePath = resolveArtifactPath(toolArgs.path); + const artifact = readArtifactFile(filePath); + + if (artifact.kind === 'recording') { + latestRecordingSession = artifact.payload; + } else if (artifact.kind === 'replay') { + latestReplaySession = artifact.payload; + } else if (artifact.kind === 'orchestration') { + latestOrchestrationSession = artifact.payload; + } else { + throw new Error('artifact kind mismatch'); + } + + return `name: ${artifact.name}\nkind: ${artifact.kind}\nstatus: imported`; +} + +async function handleDeleteArtifact(toolArgs) { + const name = requireNonEmptyString(toolArgs.name, 'name'); + deleteArtifactByName(name); + return `name: ${name}\nstatus: deleted`; +} + async function handleCancelOrchestration() { if (!activeOrchestrationSession) { throw new Error('no active orchestration'); @@ -5544,6 +5729,38 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_EXPORT_ARTIFACT) { + const text = await handleExportArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_IMPORT_ARTIFACT) { + const text = await handleImportArtifact(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_ARTIFACTS) { + const text = await handleListArtifacts(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_DELETE_ARTIFACT) { + const text = await handleDeleteArtifact(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 31618c4a..60fc5493 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -1843,6 +1843,10 @@ describe('ccs-browser MCP server', () => { 'browser_start_orchestration', 'browser_get_orchestration', 'browser_cancel_orchestration', + 'browser_export_artifact', + 'browser_import_artifact', + 'browser_list_artifacts', + 'browser_delete_artifact', 'browser_take_screenshot', 'browser_wait_for', 'browser_eval', @@ -1986,6 +1990,19 @@ describe('ccs-browser MCP server', () => { const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration'); expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' }); + const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact'); + expect(exportArtifactTool?.inputSchema?.properties?.kind).toMatchObject({ type: 'string' }); + expect(exportArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + + const importArtifactTool = tools.find((tool) => tool.name === 'browser_import_artifact'); + expect(importArtifactTool?.inputSchema?.properties?.path).toMatchObject({ type: 'string' }); + + const listArtifactsTool = tools.find((tool) => tool.name === 'browser_list_artifacts'); + expect(listArtifactsTool?.inputSchema).toMatchObject({ type: 'object' }); + + const deleteArtifactTool = tools.find((tool) => tool.name === 'browser_delete_artifact'); + expect(deleteArtifactTool?.inputSchema?.properties?.name).toMatchObject({ type: 'string' }); + const queryTool = tools.find((tool) => tool.name === 'browser_query'); expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({ type: 'array', @@ -7278,6 +7295,222 @@ describe('ccs-browser MCP server', () => { expect(text).toContain('failure[0].sequenceStepIndex: 0'); }); + it('exports recording and replay artifacts and lists them', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + recording: { + events: [ + { + kind: 'click', + selector: '#submit', + timestamp: 1710000000000, + }, + ], + }, + 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', + }, + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1601, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1602, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { + jsonrpc: '2.0', + id: 1603, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'rec-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1604, + method: 'tools/call', + params: { + name: 'browser_start_replay', + arguments: { + steps: [createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} })], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1605, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'replay', name: 'replay-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1606, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1603))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1605))).toContain('status: exported'); + const listText = getResponseText(responses.find((message) => message.id === 1606)); + expect(listText).toContain('name: rec-smoke'); + expect(listText).toContain('name: replay-smoke'); + }); + + it('imports and deletes orchestration artifacts', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Page', + currentUrl: 'https://example.com/artifact', + click: { '#submit': {} }, + wait: { + selectorSnapshots: { + '#submit': [ + [ + { + 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: { + '#status': { + exists: true, + connected: true, + innerText: 'ready state', + textContent: 'ready state', + display: 'block', + visibility: 'visible', + opacity: '1', + rect: { + x: 20, + y: 80, + width: 100, + height: 40, + top: 80, + right: 120, + bottom: 120, + left: 20, + }, + }, + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 1607, + method: 'tools/call', + params: { + name: 'browser_start_orchestration', + arguments: { + blocks: [ + createOrchestrationBlock({ + type: 'assert_query', + args: { + query: { selector: '#status', fields: ['innerText'] }, + assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }], + }, + }), + ], + }, + }, + }, + { + jsonrpc: '2.0', + id: 1608, + method: 'tools/call', + params: { name: 'browser_export_artifact', arguments: { kind: 'orchestration', name: 'orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1609, + method: 'tools/call', + params: { name: 'browser_list_artifacts', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 1610, + method: 'tools/call', + params: { name: 'browser_import_artifact', arguments: { path: 'artifact:orc-smoke' } }, + }, + { + jsonrpc: '2.0', + id: 1611, + method: 'tools/call', + params: { name: 'browser_delete_artifact', arguments: { name: 'orc-smoke' } }, + }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1608))).toContain('status: exported'); + expect(getResponseText(responses.find((message) => message.id === 1610))).toContain('status: imported'); + expect(getResponseText(responses.find((message) => message.id === 1611))).toContain('status: deleted'); + }); + + it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Artifact Failure Page', + currentUrl: 'https://example.com/artifact-failure', + recording: { + events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000000 }], + }, + }, + ], + [ + { jsonrpc: '2.0', id: 1612, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1613, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } }, + { jsonrpc: '2.0', id: 1614, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } }, + { jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } }, + { jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } }, + ] + ); + + expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists'); + expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found'); + expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found'); + }); + 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');