Files
cc-web/lib/ccweb-mcp-server.js

561 lines
19 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.

#!/usr/bin/env node
'use strict';
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const SERVER_INFO = {
name: 'ccweb',
version: '1.0.0',
};
const CCWEB_REPLY_MODES = Object.freeze({
ONE_WAY: 'one_way',
RETURN_AND_CONTINUE: 'return_and_continue',
});
const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode仅当来源不需要目标结果时使用 one_way涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时必须使用 return_and_continue。工具调用会立即返回不阻塞也不等待目标对话完成。';
const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([
'ccweb_list_conversations',
'ccweb_list_user_inputs',
'ccweb_set_title',
'ccweb_create_conversation',
'ccweb_send_message',
'ccweb_list_pending_replies',
'ccweb_get_pending_reply',
]);
const HIDDEN_CALLABLE_TOOL_NAMES = new Set([
'ccweb_request_reply',
'ccweb_task_update',
]);
const TOOLS = [
{
name: 'ccweb_display_image',
description: '当你需要输出、显示图片时,请调用该 MCP。',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string', description: '要发给用户查看的图片来源:图片 URL、data:image/*;base64,... 或截图/生成图片的本地绝对路径。' },
alt: { type: 'string', maxLength: 240, description: '图片替代文本。' },
title: { type: 'string', maxLength: 240, description: '图片标题。' },
},
required: ['source'],
additionalProperties: false,
},
},
{
name: 'ccweb_list_conversations',
description: '列出当前 ccweb 中可投递消息的对话。默认返回全部对话scope=children 时只返回当前来源对话通过 ccweb_create_conversation 直接创建的持久子对话。只返回 ID、标题、Agent、运行状态和更新时间不返回对话正文。',
inputSchema: {
type: 'object',
properties: {
scope: {
type: 'string',
enum: ['all', 'children'],
description: '可选。all 返回全部对话children 只返回当前来源对话直接创建的持久子对话,默认 all。',
},
agent: {
type: 'string',
enum: ['claude', 'codex', 'codexapp'],
description: '可选。只返回指定 Agent 的对话。',
},
status: {
type: 'string',
enum: ['all', 'running', 'idle'],
description: '可选。按运行状态过滤,默认 all。',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
description: '可选。最多返回多少条,默认 50。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_list_user_inputs',
description: '该工具返回用户输入的近 N 次对话列表。',
inputSchema: {
type: 'object',
properties: {
conversationId: {
type: 'string',
description: '可选。目标对话 ID缺省时使用当前 MCP 来源对话。',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 50,
description: '可选。最多返回最近多少条用户输入,默认 15。',
},
maxChars: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: '可选。所有返回正文合计最多多少个 Unicode 字符,默认 1000。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_set_title',
description: '设置当前 ccweb 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
maxLength: 120,
description: '新的当前对话标题。应简短概括用户主要目标。',
},
},
required: ['title'],
additionalProperties: false,
},
},
{
name: 'ccweb_create_conversation',
description: '创建一个新的 ccweb 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。',
inputSchema: {
type: 'object',
properties: {
cwd: {
type: 'string',
description: '可选。新对话工作目录;指定时必须是已存在的绝对路径,默认继承来源对话 cwd。',
},
title: {
type: 'string',
maxLength: 120,
description: '可选。新对话标题。',
},
mode: {
type: 'string',
enum: ['default', 'plan', 'yolo'],
description: '可选。权限模式,默认 yolo只有显式传 default/plan/yolo 时才使用指定模式。',
},
initialMessage: {
type: 'string',
description: '可选。创建后立即发送到新对话的首条消息。',
},
requestReply: {
type: 'boolean',
description: '可选。若为 true会在新对话完成本轮输出后把回复写回来源对话并继续触发来源对话运行。默认 false。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_send_message',
description: CCWEB_SEND_MESSAGE_DESCRIPTION,
inputSchema: {
type: 'object',
properties: {
targetConversationId: {
type: 'string',
description: '目标对话 ID。',
},
content: {
type: 'string',
description: '要发送到目标对话的纯文本消息。',
},
replyMode: {
type: 'string',
enum: Object.values(CCWEB_REPLY_MODES),
description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话return_and_continue 表示目标完成后自动回传结果并触发来源继续运行。',
},
},
required: ['targetConversationId', 'content', 'replyMode'],
additionalProperties: false,
},
},
{
name: 'ccweb_list_pending_replies',
description: '列出当前来源对话等待中的跨对话回复,包括已完成但尚未处理的子对话返回摘要。',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['all', 'waiting', 'ready', 'delivering', 'returned', 'failed'],
description: '可选。按回复状态过滤,默认 all。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_get_pending_reply',
description: '读取指定 requestId 的跨对话回复状态和正文;用于主线程判断是否继续追问指定子对话。',
inputSchema: {
type: 'object',
properties: {
requestId: {
type: 'string',
description: '等待回复 requestId。',
},
},
required: ['requestId'],
additionalProperties: false,
},
},
{
name: 'ccweb_prompt_user',
description: '在当前来源 ccweb 对话前台渲染一个多问题表单。工具会立即返回不等待用户用户提交后ccweb 会把问题、选择和答案作为一条普通用户消息发回当前对话。',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
maxLength: 160,
description: '表单标题。',
},
description: {
type: 'string',
maxLength: 2000,
description: '可选。表单整体说明。',
},
questions: {
type: 'array',
minItems: 1,
maxItems: 10,
description: '问题数组。每个问题都会渲染候选项和可编辑答案输入区。',
items: {
type: 'object',
properties: {
id: {
type: 'string',
maxLength: 80,
description: '问题 ID。建议稳定唯一缺失时 ccweb 会按顺序生成。',
},
title: {
type: 'string',
maxLength: 160,
description: '问题标题。',
},
question: {
type: 'string',
maxLength: 4000,
description: '问题正文。',
},
required: {
type: 'boolean',
description: '是否必答,默认 true。',
},
selectionMode: {
type: 'string',
enum: ['single', 'multi', 'none'],
description: '候选项选择模式,默认 singlenone 表示只输入答案。',
},
answerPlaceholder: {
type: 'string',
maxLength: 240,
description: '答案输入区占位文案。',
},
defaultAnswer: {
type: 'string',
maxLength: 4000,
description: '答案输入区默认值。',
},
options: {
type: 'array',
maxItems: 8,
description: '候选/推荐选项。点击选项会把 answerText 写入该问题的答案输入区。',
items: {
type: 'object',
properties: {
id: {
type: 'string',
maxLength: 80,
description: '选项 ID。建议稳定唯一缺失时 ccweb 会按顺序生成。',
},
label: {
type: 'string',
maxLength: 240,
description: '选项展示文本。',
},
description: {
type: 'string',
maxLength: 1000,
description: '选项说明。',
},
answerText: {
type: 'string',
maxLength: 4000,
description: '点击该选项后预填到答案输入区的文本。',
},
recommended: {
type: 'boolean',
description: '是否推荐选项。',
},
},
additionalProperties: false,
},
},
},
required: ['question'],
additionalProperties: false,
},
},
},
required: ['questions'],
additionalProperties: false,
},
},
];
function imageMimeFromPath(filePath) {
const ext = path.extname(filePath).toLowerCase();
return ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' })[ext] || 'application/octet-stream';
}
function isCallableToolName(name) {
const toolName = String(name || '');
return TOOLS.some((tool) => tool.name === toolName) || HIDDEN_CALLABLE_TOOL_NAMES.has(toolName);
}
function codexAppCommunicationTools() {
return TOOLS
.filter((tool) => CODEX_APP_COMMUNICATION_TOOL_NAMES.has(tool.name))
.map((tool) => ({ ...tool, namespace: 'ccweb' }));
}
function prepareImagePayload(args = {}) {
const source = String(args.source || '').trim();
if (!source) return { ok: false, code: 'missing_source', message: 'source 不能为空。' };
if (/^https?:\/\//i.test(source)) return { ok: true, image: { type: 'image', url: source, mimeType: '', alt: String(args.alt || ''), title: String(args.title || '') } };
if (/^data:image\//i.test(source)) {
const match = source.match(/^data:(image\/[\w.+-]+);base64,([\s\S]+)$/i);
if (!match) return { ok: false, code: 'invalid_data_image', message: '仅支持 base64 编码的 data:image 图片。' };
if (Buffer.byteLength(match[2], 'base64') > 10 * 1024 * 1024) return { ok: false, code: 'image_too_large', message: '图片不能超过 10 MB。' };
return { ok: true, image: { type: 'image', data: match[2], mimeType: match[1], alt: String(args.alt || ''), title: String(args.title || '') } };
}
if (!path.isAbsolute(source)) return { ok: false, code: 'invalid_local_path', message: '本地图片必须使用绝对路径。' };
try {
const stat = fs.statSync(source);
if (!stat.isFile() || stat.size > 10 * 1024 * 1024) return { ok: false, code: 'image_too_large', message: '本地图片不存在或超过 10 MB。' };
const mimeType = imageMimeFromPath(source);
if (!mimeType.startsWith('image/')) return { ok: false, code: 'unsupported_image_type', message: '不支持该本地图片格式。' };
return { ok: true, image: { type: 'image', data: fs.readFileSync(source).toString('base64'), mimeType, alt: String(args.alt || path.basename(source)), title: String(args.title || path.basename(source)) } };
} catch { return { ok: false, code: 'image_read_failed', message: '无法读取本地图片。' }; }
}
function writeMessage(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function jsonRpcResult(id, result) {
writeMessage({ jsonrpc: '2.0', id, result });
}
function jsonRpcError(id, code, message, data) {
const error = { code, message };
if (data !== undefined) error.data = data;
writeMessage({ jsonrpc: '2.0', id, error });
}
function parseInteger(value, fallback = 0) {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function callCcweb(tool, args) {
return new Promise((resolve) => {
const urlText = String(process.env.CC_WEB_MCP_URL || '').trim();
const token = String(process.env.CC_WEB_MCP_TOKEN || '').trim();
if (!urlText || !token) {
resolve({
ok: false,
code: 'mcp_not_configured',
message: 'ccweb MCP 环境变量未配置完整。',
});
return;
}
let url;
try {
url = new URL(urlText);
} catch {
resolve({
ok: false,
code: 'mcp_bad_url',
message: 'ccweb MCP 内部地址无效。',
});
return;
}
const body = JSON.stringify({
tool,
args: args && typeof args === 'object' ? args : {},
sourceSessionId: process.env.CC_WEB_SOURCE_SESSION_ID || '',
sourceHopCount: parseInteger(process.env.CC_WEB_CROSS_HOP_COUNT, 0),
});
const transport = url.protocol === 'https:' ? https : http;
const req = transport.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-CC-Web-MCP-Token': token,
},
timeout: 60000,
}, (res) => {
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const payload = JSON.parse(data || '{}');
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(payload);
} else {
resolve({
ok: false,
code: payload.code || 'ccweb_http_error',
message: payload.message || `ccweb 内部接口返回 HTTP ${res.statusCode}`,
statusCode: res.statusCode,
});
}
} catch {
resolve({
ok: false,
code: 'ccweb_bad_response',
message: 'ccweb 内部接口返回了无法解析的响应。',
statusCode: res.statusCode,
});
}
});
});
req.on('timeout', () => {
req.destroy();
resolve({
ok: false,
code: 'ccweb_timeout',
message: 'ccweb 内部接口调用超时。',
});
});
req.on('error', (err) => {
resolve({
ok: false,
code: 'ccweb_request_failed',
message: err.message || 'ccweb 内部接口调用失败。',
});
});
req.write(body);
req.end();
});
}
function toolResponse(payload) {
const text = JSON.stringify(payload, null, 2);
return {
content: [{ type: 'text', text }],
structuredContent: payload,
isError: !payload?.ok,
};
}
async function listToolsForCurrentSource() {
const payload = await callCcweb('ccweb_internal_tools_list', {});
if (!payload?.ok || !Array.isArray(payload.tools)) return TOOLS;
return [...TOOLS, ...payload.tools];
}
async function handleRequest(message) {
const { id, method } = message;
const hasId = Object.prototype.hasOwnProperty.call(message, 'id');
if (!hasId) return;
try {
switch (method) {
case 'initialize': {
jsonRpcResult(id, {
protocolVersion: message.params?.protocolVersion || '2024-11-05',
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
});
break;
}
case 'ping':
jsonRpcResult(id, {});
break;
case 'tools/list':
jsonRpcResult(id, { tools: await listToolsForCurrentSource() });
break;
case 'tools/call': {
const name = String(message.params?.name || '');
const args = message.params?.arguments || {};
if (!isCallableToolName(name)) {
jsonRpcResult(id, toolResponse({
ok: false,
code: 'unknown_tool',
message: `未知工具: ${name}`,
}));
break;
}
const payload = await callCcweb(name, args);
jsonRpcResult(id, toolResponse(payload));
break;
}
case 'resources/list':
jsonRpcResult(id, { resources: [] });
break;
case 'prompts/list':
jsonRpcResult(id, { prompts: [] });
break;
default:
jsonRpcError(id, -32601, `Method not found: ${method}`);
}
} catch (err) {
jsonRpcError(id, -32603, err.message || 'Internal error');
}
}
function runStdioServer() {
let lineBuffer = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
lineBuffer += chunk;
let index;
while ((index = lineBuffer.indexOf('\n')) >= 0) {
const line = lineBuffer.slice(0, index).trim();
lineBuffer = lineBuffer.slice(index + 1);
if (!line) continue;
let message;
try {
message = JSON.parse(line);
} catch (err) {
jsonRpcError(null, -32700, 'Parse error', err.message);
continue;
}
handleRequest(message);
}
});
process.stdin.on('end', () => {
process.exit(0);
});
}
module.exports = {
TOOLS,
CCWEB_REPLY_MODES,
codexAppCommunicationTools,
isCallableToolName,
prepareImagePayload,
runStdioServer,
};
if (require.main === module) {
runStdioServer();
}