import { afterEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import * as http from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-image-analysis-server.cjs'); function encodeMessage(message: unknown): string { return `${JSON.stringify(message)}\n`; } function encodeLegacyMessage(message: unknown): string { const body = JSON.stringify(message); return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; } function collectResponses( child: ReturnType, expectedCount: number ): Promise>> { return new Promise((resolve, reject) => { let buffer = Buffer.alloc(0); const responses: Array> = []; const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000); function tryParse(): void { while (true) { const newlineIndex = buffer.indexOf('\n'); if (newlineIndex === -1) { return; } const body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); buffer = buffer.slice(newlineIndex + 1); if (!body) { continue; } responses.push(JSON.parse(body) as Record); if (responses.length >= expectedCount) { clearTimeout(timer); resolve(responses); return; } } } child.stdout.on('data', (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]); try { tryParse(); } catch (error) { clearTimeout(timer); reject(error); } }); child.on('error', (error) => { clearTimeout(timer); reject(error); }); }); } function createTestPng(filePath: string): void { const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); writeFileSync(filePath, png); } function createTestPdf(filePath: string): void { writeFileSync(filePath, '%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n', 'utf8'); } describe('ccs-image-analysis MCP server', () => { let tempDir = ''; let imagePath = ''; let mockServer: http.Server | null = null; afterEach(async () => { await new Promise((resolve) => { mockServer?.close(() => resolve()); if (!mockServer) resolve(); }); mockServer = null; if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; imagePath = ''; } }); it('lists the ImageAnalysis tool and posts directly to the provider-scoped CLIProxy route', async () => { tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-')); imagePath = join(tempDir, 'screen.png'); createTestPng(imagePath); let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null; const mockPort = await new Promise((resolve, reject) => { mockServer = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk.toString(); }); req.on('end', () => { receivedRequest = { path: req.url || '/', apiKey: req.headers['x-api-key'] as string, body: body ? JSON.parse(body) : null, }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ content: [{ type: 'text', text: 'The screenshot shows a red pixel debug fixture.' }], }) ); }); }); mockServer.once('error', reject); mockServer.listen(0, '127.0.0.1', () => { const address = mockServer?.address(); if (!address || typeof address === 'string') { reject(new Error('Failed to resolve mock server port')); return; } resolve(address.port); }); }); const child = spawn('node', [serverPath], { cwd: tempDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_SKIP: '0', CCS_CURRENT_PROVIDER: 'agy', CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`, CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key', }, stdio: ['pipe', 'pipe', 'pipe'], }); try { const responsesPromise = collectResponses(child, 3); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bun-test', version: '1.0.0' }, }, }) ); child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'ImageAnalysis', arguments: { filePath: imagePath, focus: 'Describe the visible issue' }, }, }) ); const responses = await responsesPromise; const toolsList = responses.find((message) => message.id === 2); const toolCall = responses.find((message) => message.id === 3); expect(toolsList?.result).toEqual({ tools: [ { name: 'ImageAnalysis', description: 'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.', }, focus: { type: 'string', description: 'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".', }, template: { type: 'string', enum: ['default', 'screenshot', 'document'], description: 'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.', }, }, required: ['filePath'], additionalProperties: false, }, }, ], }); expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages'); expect(receivedRequest?.apiKey).toBe('test-api-key'); expect( (((receivedRequest?.body as { messages: Array<{ content: Array<{ text?: string }> }> }) ?.messages[0]?.content[0] || {}) as { text?: string }).text ).toContain('Specific focus'); expect(toolCall?.result).toBeDefined(); expect( ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text ).toContain('red pixel debug fixture'); expect( ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text ).toContain('Model: gemini-3-1-flash-preview'); } finally { child.kill(); } }); it('returns a structured tool error when the file does not exist', async () => { tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-missing-')); const missingPath = join(tempDir, 'missing-image.png'); const child = spawn('node', [serverPath], { cwd: tempDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_SKIP: '0', CCS_CURRENT_PROVIDER: 'agy', CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', }, stdio: ['pipe', 'pipe', 'pipe'], }); try { const responsesPromise = collectResponses(child, 2); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bun-test', version: '1.0.0' }, }, }) ); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ImageAnalysis', arguments: { filePath: missingPath }, }, }) ); const responses = await responsesPromise; const toolCall = responses.find((message) => message.id === 2); expect(toolCall?.result).toEqual({ content: [ { type: 'text', text: `ImageAnalysis could not find file: ${missingPath}`, }, ], isError: true, }); } finally { child.kill(); } }); it('rejects paths outside the current workspace', async () => { tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-scope-')); const workspaceDir = join(tempDir, 'workspace'); mkdirSync(workspaceDir, { recursive: true }); const outsidePath = join(tempDir, 'outside.png'); createTestPng(outsidePath); const child = spawn('node', [serverPath], { cwd: workspaceDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_SKIP: '0', CCS_CURRENT_PROVIDER: 'agy', CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', }, stdio: ['pipe', 'pipe', 'pipe'], }); try { const responsesPromise = collectResponses(child, 2); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bun-test', version: '1.0.0' }, }, }) ); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ImageAnalysis', arguments: { filePath: '../outside.png' }, }, }) ); const responses = await responsesPromise; const toolCall = responses.find((message) => message.id === 2); expect(toolCall?.error).toEqual({ code: -32602, message: 'ImageAnalysis only allows files inside the current workspace.', }); } finally { child.kill(); } }); it('sends PDF files as document blocks to the provider-scoped route', async () => { tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-')); const pdfPath = join(tempDir, 'manual.pdf'); createTestPdf(pdfPath); let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null; const mockPort = await new Promise((resolve, reject) => { mockServer = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk.toString(); }); req.on('end', () => { receivedRequest = { path: req.url || '/', apiKey: req.headers['x-api-key'] as string, body: body ? JSON.parse(body) : null, }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ content: [{ type: 'text', text: 'The PDF contains a one-page fixture.' }], }) ); }); }); mockServer.once('error', reject); mockServer.listen(0, '127.0.0.1', () => { const address = mockServer?.address(); if (!address || typeof address === 'string') { reject(new Error('Failed to resolve mock server port')); return; } resolve(address.port); }); }); const child = spawn('node', [serverPath], { cwd: tempDir, env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_SKIP: '0', CCS_CURRENT_PROVIDER: 'agy', CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`, CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key', }, stdio: ['pipe', 'pipe', 'pipe'], }); try { const responsesPromise = collectResponses(child, 2); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bun-test', version: '1.0.0' }, }, }) ); child.stdin.write( encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ImageAnalysis', arguments: { filePath: pdfPath }, }, }) ); const responses = await responsesPromise; const requestBody = receivedRequest?.body as { messages: Array<{ content: Array<{ type: string; source?: { media_type?: string } }> }>; }; expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages'); expect(receivedRequest?.apiKey).toBe('test-api-key'); expect(requestBody.messages[0]?.content[1]?.type).toBe('document'); expect(requestBody.messages[0]?.content[1]?.source?.media_type).toBe('application/pdf'); expect(((responses[1]?.result as { content: Array<{ text: string }> }).content[0] || {}).text).toContain( 'one-page fixture' ); } finally { child.kill(); } }); it('accepts legacy Content-Length framed requests for compatibility', async () => { const child = spawn('node', [serverPath], { env: { ...process.env, CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', CCS_CURRENT_PROVIDER: 'agy', }, stdio: ['pipe', 'pipe', 'pipe'], }); try { const responsesPromise = collectResponses(child, 2); child.stdin.write( encodeLegacyMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bun-test', version: '1.0.0' }, }, }) ); child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); const responses = await responsesPromise; const toolsList = responses.find((message) => message.id === 2); expect(toolsList?.result).toEqual({ tools: [] }); } finally { child.kill(); } }); });