feat: support MCP elicitation and rebuild release

This commit is contained in:
shiyue
2026-08-24 17:39:41 +08:00
parent dd233a40e8
commit bd20a79d4b
69 changed files with 8978 additions and 13 deletions

174
lib/gitea-mcp-probe.js Normal file
View 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,
};