mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
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<typeof spawn>,
|
|
expectedCount: number
|
|
): Promise<Array<Record<string, unknown>>> {
|
|
return new Promise((resolve, reject) => {
|
|
let buffer = Buffer.alloc(0);
|
|
const responses: Array<Record<string, unknown>> = [];
|
|
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<string, unknown>);
|
|
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<void>((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<number>((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<number>((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();
|
|
}
|
|
});
|
|
});
|