Files
cc-web/lib/ccweb-mcp-server.js
2026-06-22 18:22:53 +08:00

337 lines
9.5 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 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,
},
},
];
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');
}
}
module.exports = { TOOLS };
if (require.main === module) {
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);
});
}