175 lines
5.7 KiB
JavaScript
175 lines
5.7 KiB
JavaScript
'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,
|
||
};
|