feat: support MCP elicitation and rebuild release
This commit is contained in:
174
lib/gitea-mcp-probe.js
Normal file
174
lib/gitea-mcp-probe.js
Normal file
@@ -0,0 +1,174 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user