229 lines
6.6 KiB
JavaScript
229 lines
6.6 KiB
JavaScript
// kimi-eyes MCP stdio server — zero-dependency.
|
|
// Speaks JSON-RPC 2.0 over LSP-style framing (Content-Length headers) on stdio.
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
VERSION,
|
|
getConfig,
|
|
requireConfigured,
|
|
callVLM,
|
|
sniffImage,
|
|
clipboardImageToTemp,
|
|
lookupModelCapability,
|
|
} from './vision.mjs';
|
|
|
|
const TOOLS = [
|
|
{
|
|
name: 'read_image',
|
|
description:
|
|
'Analyze a local image file using the configured vision API and return a text description. ' +
|
|
'Use when the user provides an image path or an @ file reference and your model cannot see images directly.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'Absolute path to a local image file (png, jpg, jpeg, webp, gif, bmp).',
|
|
},
|
|
prompt: {
|
|
type: 'string',
|
|
description: 'Optional instruction describing what to analyze (default: describe the image).',
|
|
},
|
|
},
|
|
required: ['path'],
|
|
},
|
|
},
|
|
{
|
|
name: 'read_clipboard_image',
|
|
description:
|
|
'Capture the image currently in the system clipboard (e.g. a screenshot) and analyze it with the ' +
|
|
'configured vision API. Use when the user says they just took a screenshot or copied an image and no file path is given.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
description: 'Optional instruction describing what to analyze (default: describe the image).',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tools
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* When the user declares their main model (config.mainModel) and the models.dev
|
|
* database says it supports image input, the tools are unnecessary — the model can
|
|
* see images natively. Refuse with a hint instead of wasting a VLM call.
|
|
*/
|
|
function assertNeedsExternalVision(cfg) {
|
|
if (!cfg.mainModel) return;
|
|
const cap = lookupModelCapability(cfg.mainModel);
|
|
if (cap.known && cap.image) {
|
|
throw new Error(
|
|
`Model "${cfg.mainModel}" supports native image input (per the models.dev database), ` +
|
|
`so the kimi-eyes tools are not needed. Paste the image directly and the model will see it. ` +
|
|
`If you still want kimi-eyes active, remove "mainModel" from the kimi-eyes config.`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function readImageTool(args) {
|
|
const p = args?.path;
|
|
if (typeof p !== 'string' || !p.trim()) {
|
|
throw new Error('Missing required parameter: path');
|
|
}
|
|
const cfg = getConfig();
|
|
assertNeedsExternalVision(cfg);
|
|
const abs = path.resolve(p.trim());
|
|
let stat;
|
|
try {
|
|
stat = fs.statSync(abs);
|
|
} catch {
|
|
throw new Error(`File not found: ${abs}`);
|
|
}
|
|
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
|
|
const img = sniffImage(abs);
|
|
if (!img) {
|
|
throw new Error(
|
|
`Unsupported or invalid image file: ${abs} (supported: png, jpg, jpeg, webp, gif, bmp)`
|
|
);
|
|
}
|
|
requireConfigured(cfg);
|
|
return callVLM({ ...img, prompt: args?.prompt }, cfg);
|
|
}
|
|
|
|
async function readClipboardImageTool(args) {
|
|
const cfg = getConfig();
|
|
assertNeedsExternalVision(cfg);
|
|
requireConfigured(cfg);
|
|
let tmp = null;
|
|
try {
|
|
tmp = clipboardImageToTemp();
|
|
const img = sniffImage(tmp);
|
|
if (!img) throw new Error('Clipboard does not contain a valid image.');
|
|
return callVLM({ ...img, prompt: args?.prompt }, cfg);
|
|
} finally {
|
|
if (tmp) {
|
|
try {
|
|
fs.unlinkSync(tmp);
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function callTool(name, args) {
|
|
if (name === 'read_image') return readImageTool(args);
|
|
if (name === 'read_clipboard_image') return readClipboardImageTool(args);
|
|
throw new Error(`Unknown tool: ${name}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON-RPC over stdio (MCP stdio transport = newline-delimited JSON)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function send(obj) {
|
|
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
}
|
|
|
|
function sendError(id, code, message) {
|
|
send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
}
|
|
|
|
async function handleRequest(msg) {
|
|
const { id, method, params } = msg;
|
|
try {
|
|
switch (method) {
|
|
case 'initialize':
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: {
|
|
protocolVersion: params?.protocolVersion || '2025-06-18',
|
|
capabilities: { tools: { listChanged: false } },
|
|
serverInfo: { name: 'kimi-eyes', version: VERSION },
|
|
},
|
|
});
|
|
return;
|
|
case 'ping':
|
|
send({ jsonrpc: '2.0', id, result: {} });
|
|
return;
|
|
case 'tools/list':
|
|
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
return;
|
|
case 'tools/call': {
|
|
const name = params?.name;
|
|
const args = params?.arguments || {};
|
|
try {
|
|
const text = await callTool(name, args);
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: { content: [{ type: 'text', text }] },
|
|
});
|
|
} catch (err) {
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: {
|
|
content: [{ type: 'text', text: err.message || String(err) }],
|
|
isError: true,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
sendError(id, -32601, `Method not found: ${method}`);
|
|
}
|
|
} catch (err) {
|
|
sendError(id, -32603, err.message || String(err));
|
|
}
|
|
}
|
|
|
|
function handleMessage(msg) {
|
|
if (!msg || typeof msg !== 'object' || typeof msg.method !== 'string') return;
|
|
const isRequest = msg.id !== undefined && msg.id !== null;
|
|
if (isRequest) {
|
|
handleRequest(msg).catch((err) => sendError(msg.id, -32603, err.message || String(err)));
|
|
}
|
|
// Notifications (e.g. notifications/initialized) are intentionally ignored.
|
|
}
|
|
|
|
// --- newline-delimited JSON parser -----------------------------------------
|
|
|
|
let buf = Buffer.alloc(0);
|
|
|
|
function pump() {
|
|
for (;;) {
|
|
const idx = buf.indexOf('\n');
|
|
if (idx === -1) return;
|
|
const line = buf.subarray(0, idx).toString('utf8').replace(/\r$/, '');
|
|
buf = buf.subarray(idx + 1);
|
|
if (!line.trim()) continue;
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(line);
|
|
} catch (err) {
|
|
console.error(`[kimi-eyes] invalid JSON line: ${err.message}`);
|
|
continue;
|
|
}
|
|
handleMessage(msg);
|
|
}
|
|
}
|
|
|
|
process.stdin.on('data', (chunk) => {
|
|
buf = Buffer.concat([buf, chunk]);
|
|
pump();
|
|
});
|
|
|
|
process.stdin.on('end', () => process.exit(0));
|
|
|
|
console.error(`[kimi-eyes] MCP server started (v${VERSION}, node ${process.version})`);
|