Files
ccs/lib/mcp/ccs-image-analysis-server.cjs
T

441 lines
12 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function loadRuntimeModule() {
const candidates = [
path.join(__dirname, 'image-analysis-runtime.cjs'),
path.join(__dirname, '../hooks/image-analysis-runtime.cjs'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return require(candidate);
}
}
throw new Error(
`ccs-image-analysis runtime not found. Checked: ${candidates.map((candidate) => path.basename(candidate)).join(', ')}`
);
}
const { analyzeFile, isAnalyzableFile } = loadRuntimeModule();
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_NAME = 'ccs-image-analysis';
const SERVER_VERSION = '1.0.0';
const TOOL_NAME = 'ImageAnalysis';
const TOOL_ALIASES = ['AnalyzeImage', 'ReadImage'];
const TEMPLATE_NAMES = ['default', 'screenshot', 'document'];
const TOOL_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.';
function isSupportedToolName(name) {
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
}
function shouldExposeTools() {
return (
process.env.CCS_IMAGE_ANALYSIS_ENABLED === '1' &&
process.env.CCS_IMAGE_ANALYSIS_SKIP !== '1' &&
Boolean(process.env.CCS_CURRENT_PROVIDER || process.env.CCS_IMAGE_ANALYSIS_MODEL)
);
}
function getTools() {
if (!shouldExposeTools()) {
return [];
}
return [
{
name: TOOL_NAME,
description: TOOL_DESCRIPTION,
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: TEMPLATE_NAMES,
description:
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
},
},
required: ['filePath'],
additionalProperties: false,
},
},
];
}
function writeMessage(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function writeResponse(id, result) {
writeMessage({
jsonrpc: '2.0',
id,
result,
});
}
function writeError(id, code, message) {
writeMessage({
jsonrpc: '2.0',
id,
error: {
code,
message,
},
});
}
function formatResult(filePath, result, focus) {
const lines = [
'[Image Analysis via CCS]',
'',
`File: ${path.basename(filePath)} (${(result.fileSize / 1024).toFixed(1)} KB)`,
`Model: ${result.model}`,
`Template: ${result.template}`,
];
if (focus && focus.trim()) {
lines.push(`Focus: ${focus.trim()}`);
}
lines.push('', '---', '', result.description);
return lines.join('\n');
}
function normalizeTemplate(value) {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined;
}
function normalizePathForComparison(value) {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
function isPathWithinWorkspace(workspaceRoot, candidatePath) {
const relativePath = path.relative(workspaceRoot, candidatePath);
return (
relativePath === '' ||
(!relativePath.startsWith('..') &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
function resolveFilePath(toolArgs) {
if (!toolArgs || typeof toolArgs !== 'object') {
return '';
}
const candidates = [toolArgs.filePath, toolArgs.file_path, toolArgs.path];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return '';
}
function resolveWorkspaceFilePath(toolArgs) {
const requestedPath = resolveFilePath(toolArgs);
if (!requestedPath) {
return { filePath: '', error: null };
}
const workspaceRoot = (() => {
try {
return fs.realpathSync(process.cwd());
} catch {
return path.resolve(process.cwd());
}
})();
const absolutePath = path.resolve(process.cwd(), requestedPath);
const comparisonPath = (() => {
if (fs.existsSync(absolutePath)) {
return fs.realpathSync(absolutePath);
}
const suffixSegments = [];
let currentPath = absolutePath;
while (!fs.existsSync(currentPath)) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
suffixSegments.unshift(path.basename(currentPath));
currentPath = parentPath;
}
const resolvedExistingPath = fs.existsSync(currentPath)
? fs.realpathSync(currentPath)
: path.resolve(currentPath);
return path.join(resolvedExistingPath, ...suffixSegments);
})();
if (
!isPathWithinWorkspace(
normalizePathForComparison(workspaceRoot),
normalizePathForComparison(comparisonPath)
)
) {
return {
filePath: '',
error: 'ImageAnalysis only allows files inside the current workspace.',
};
}
return {
filePath: absolutePath,
error: null,
};
}
function resolveFocus(toolArgs) {
return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0
? toolArgs.focus.trim()
: undefined;
}
function formatErrorDetail(filePath, error) {
const message = error instanceof Error ? error.message : String(error);
if (message.startsWith('FILE_TOO_LARGE:')) {
const fileSizeBytes = Number.parseInt(message.split(':')[1], 10);
const sizeMb = Number.isFinite(fileSizeBytes) ? (fileSizeBytes / 1024 / 1024).toFixed(1) : '?';
return `ImageAnalysis cannot process ${path.basename(filePath)} because it is too large (${sizeMb} MB). The limit is 10 MB.`;
}
if (message.startsWith('AUTH_ERROR:')) {
return `ImageAnalysis failed because CCS vision auth for this provider is unavailable (${message.split(':')[1]}).`;
}
if (message.startsWith('RATE_LIMIT:')) {
return `ImageAnalysis hit a provider rate limit while analyzing ${path.basename(filePath)}.`;
}
if (message.startsWith('API_ERROR:')) {
return `ImageAnalysis failed at the CCS provider route while analyzing ${path.basename(filePath)}.`;
}
if (
message === 'TIMEOUT' ||
message.includes('timed out') ||
message.includes('timeout') ||
message.includes('ECONNREFUSED') ||
message.includes('ENOTFOUND') ||
message.includes('ENETUNREACH') ||
message.includes('EAI_AGAIN')
) {
return `ImageAnalysis could not reach the configured CCS provider route for ${path.basename(filePath)}.`;
}
if (message.includes('EACCES') || message.includes('EPERM')) {
return `ImageAnalysis could not read ${filePath} because access was denied.`;
}
return `ImageAnalysis failed for ${path.basename(filePath)}: ${message}`;
}
async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
const toolArgs = params.arguments || {};
const toolName = params.name || '<missing>';
if (!isSupportedToolName(toolName)) {
writeError(id, -32602, `Unknown tool: ${toolName}`);
return;
}
if (!shouldExposeTools()) {
writeResponse(id, {
content: [
{
type: 'text',
text: 'CCS ImageAnalysis is unavailable for this profile or no provider-backed vision route is ready.',
},
],
isError: true,
});
return;
}
const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs);
if (!filePath) {
writeError(
id,
-32602,
filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.`
);
return;
}
if (!fs.existsSync(filePath)) {
writeResponse(id, {
content: [{ type: 'text', text: `ImageAnalysis could not find file: ${filePath}` }],
isError: true,
});
return;
}
if (!isAnalyzableFile(filePath)) {
writeResponse(id, {
content: [
{
type: 'text',
text: `ImageAnalysis only supports image and PDF files. Use Read for ${path.basename(filePath)} instead.`,
},
],
isError: true,
});
return;
}
const focus = resolveFocus(toolArgs);
const template = normalizeTemplate(toolArgs.template);
try {
const result = await analyzeFile(filePath, { focus, template });
writeResponse(id, {
content: [{ type: 'text', text: formatResult(filePath, result, focus) }],
});
} catch (error) {
writeResponse(id, {
content: [{ type: 'text', text: formatErrorDetail(filePath, error) }],
isError: true,
});
}
}
async function handleMessage(message) {
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
return;
}
switch (message.method) {
case 'initialize':
writeResponse(message.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: {},
},
serverInfo: {
name: SERVER_NAME,
version: SERVER_VERSION,
},
});
return;
case 'notifications/initialized':
return;
case 'ping':
writeResponse(message.id, {});
return;
case 'tools/list':
writeResponse(message.id, { tools: getTools() });
return;
case 'tools/call':
await handleToolCall(message);
return;
default:
if (message.id !== undefined) {
writeError(message.id, -32601, `Method not found: ${message.method}`);
}
}
}
let inputBuffer = Buffer.alloc(0);
function processIncomingBuffer() {
while (true) {
let body;
const startsWithLegacyHeaders = inputBuffer
.slice(0, Math.min(inputBuffer.length, 32))
.toString('utf8')
.toLowerCase()
.startsWith('content-length:');
if (startsWithLegacyHeaders) {
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
if (!contentLengthMatch) {
inputBuffer = Buffer.alloc(0);
return;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageEnd = headerEnd + 4 + contentLength;
if (inputBuffer.length < messageEnd) {
return;
}
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
inputBuffer = inputBuffer.slice(messageEnd);
} else {
const newlineIndex = inputBuffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
inputBuffer = inputBuffer.slice(newlineIndex + 1);
if (!body) {
continue;
}
}
try {
const message = JSON.parse(body);
Promise.resolve(handleMessage(message)).catch((error) => {
if (message && message.id !== undefined) {
writeError(message.id, -32603, (error && error.message) || 'Internal error');
} else if (process.env.CCS_DEBUG) {
console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`);
}
});
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
`[ccs-image-analysis] Failed to parse JSON-RPC message: ${
error instanceof Error ? error.message : error
}`
);
}
}
}
}
process.stdin.on('data', (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, chunk]);
processIncomingBuffer();
});
process.stdin.on('end', () => {
process.exit(0);
});