#!/usr/bin/env node 'use strict'; const http = require('http'); const https = require('https'); const SERVER_INFO = { name: 'ccweb', version: '1.0.0', }; const TOOLS = [ { name: 'ccweb_list_conversations', description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间,不返回对话正文。', inputSchema: { type: 'object', properties: { 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_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 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。', inputSchema: { type: 'object', properties: { targetConversationId: { type: 'string', description: '目标对话 ID。', }, content: { type: 'string', description: '要发送到目标对话的纯文本消息。', }, }, required: ['targetConversationId', 'content'], 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_request_reply', description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复写回当前对话,然后继续触发当前对话运行。', inputSchema: { type: 'object', properties: { targetConversationId: { type: 'string', description: '目标对话 ID。', }, content: { type: 'string', description: '要发送到目标对话的纯文本消息。', }, }, required: ['targetConversationId', 'content'], 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: '候选项选择模式,默认 single;none 表示只输入答案。', }, 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 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 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: TOOLS }); break; case 'tools/call': { const name = String(message.params?.name || ''); const args = message.params?.arguments || {}; if (!TOOLS.some((tool) => tool.name === 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, runStdioServer }; if (require.main === module) { runStdioServer(); }