Files
cc-web/lib/gitea-mcp-probe.js

175 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* 对线程级 gitea-mcp stdio 配置做最小 MCP initialize 预检。
*
* 预检只负责判断“命令能否启动并完成协议握手”,不调用 Gitea 工具,也不把
* stderr、Token 或响应正文写入日志。真正的 Agent 工作仍由 Codex App 按线程
* 配置启动另一份 gitea-mcp 进程完成。
*/
const { spawn: defaultSpawn } = require('child_process');
const readline = require('readline');
const DEFAULT_TIMEOUT_MS = 8000;
const MAX_TIMEOUT_MS = 30000;
const MCP_PROTOCOL_VERSION = '2024-11-05';
function safeText(value, max = 512) {
return String(value ?? '').trim().slice(0, max);
}
function probeError(code, message, details = {}) {
return Object.assign(new Error(message), { code, ...details });
}
function normalizeTimeout(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_TIMEOUT_MS;
return Math.min(MAX_TIMEOUT_MS, Math.max(250, Math.floor(numeric)));
}
function initializeRequest() {
return {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: 'cc-web-gitea-preflight', version: '1.0.0' },
},
};
}
function isInitializeResponse(message) {
return message
&& message.jsonrpc === '2.0'
&& (message.id === 1 || message.id === '1')
&& (Object.prototype.hasOwnProperty.call(message, 'result')
|| Object.prototype.hasOwnProperty.call(message, 'error'));
}
function sendJsonLine(stdin, value) {
stdin.write(`${JSON.stringify(value)}\n`);
}
/**
* @param {object} options
* @param {string} options.command MCP 命令或绝对路径
* @param {string[]} [options.args] 命令参数
* @param {object} [options.env] 线程级环境变量
* @param {string} [options.cwd] 子进程工作目录
* @param {number} [options.timeoutMs] 握手超时,默认 8 秒
* @param {Function} [options.spawnImpl] 测试替身,签名同 child_process.spawn
* @returns {Promise<{ok:true,protocolVersion:string}>}
*/
function probeGiteaMcp(options = {}) {
const command = safeText(options.command, 2048);
if (!command) return Promise.reject(probeError('gitea_mcp_command_empty', 'Gitea MCP 命令为空。'));
const args = Array.isArray(options.args) ? options.args.map((item) => String(item)) : [];
const timeoutMs = normalizeTimeout(options.timeoutMs);
const spawnImpl = typeof options.spawnImpl === 'function' ? options.spawnImpl : defaultSpawn;
const env = { ...process.env, ...(options.env && typeof options.env === 'object' ? options.env : {}) };
const cwd = safeText(options.cwd, 4096) || process.cwd();
return new Promise((resolve, reject) => {
let child;
let settled = false;
let timer = null;
let sawInvalidLine = false;
const finish = (error, result) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
try {
if (child?.stdin && !child.stdin.destroyed) child.stdin.end();
} catch {}
try {
if (child && !child.killed) child.kill('SIGTERM');
} catch {}
if (error) reject(error);
else resolve(result);
};
try {
child = spawnImpl(command, args, {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
} catch (error) {
finish(probeError('gitea_mcp_spawn_failed', 'Gitea MCP 进程无法启动。', { cause: error }));
return;
}
const handleLine = (line) => {
const text = String(line || '').trim();
if (!text) return;
let message;
try {
message = JSON.parse(text);
} catch {
// stdio 协议要求 JSONL兼容少数实现的启动提示最终仍以超时/退出分类。
sawInvalidLine = true;
return;
}
if (!isInitializeResponse(message)) return;
if (message.error) {
finish(probeError('gitea_mcp_handshake_failed', 'Gitea MCP initialize 握手失败。', {
rpcCode: message.error.code ?? null,
}));
return;
}
const result = message.result && typeof message.result === 'object' ? message.result : {};
try {
// MCP 客户端在收到 initialize 响应后必须发送 initialized 通知。
sendJsonLine(child.stdin, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
} catch (error) {
finish(probeError('gitea_mcp_handshake_failed', 'Gitea MCP initialized 通知发送失败。', { cause: error }));
return;
}
finish(null, {
ok: true,
protocolVersion: safeText(result.protocolVersion, 64) || MCP_PROTOCOL_VERSION,
});
};
if (child?.stdout) {
const lineReader = readline.createInterface({ input: child.stdout });
lineReader.on('line', handleLine);
child.once?.('close', () => lineReader.close());
}
child?.once?.('error', (error) => {
finish(probeError('gitea_mcp_spawn_failed', 'Gitea MCP 进程启动失败。', { cause: error }));
});
child?.once?.('exit', (code, signal) => {
if (settled) return;
finish(probeError('gitea_mcp_start_failed', 'Gitea MCP 进程在握手前退出。', {
exitCode: code ?? null,
signal: signal || null,
invalidOutput: sawInvalidLine,
}));
});
timer = setTimeout(() => {
finish(probeError('gitea_mcp_handshake_timeout', `Gitea MCP initialize 握手超时(${timeoutMs}ms`));
}, timeoutMs);
try {
sendJsonLine(child.stdin, initializeRequest());
} catch (error) {
finish(probeError('gitea_mcp_handshake_failed', 'Gitea MCP initialize 请求发送失败。', { cause: error }));
}
});
}
module.exports = {
DEFAULT_TIMEOUT_MS,
MAX_TIMEOUT_MS,
MCP_PROTOCOL_VERSION,
probeGiteaMcp,
};