feat: add cross conversation messaging

This commit is contained in:
shiyue
2026-06-12 17:46:37 +08:00
parent 8b2173be8f
commit 04e15c9c89
7 changed files with 1033 additions and 111 deletions

251
lib/ccweb-mcp-server.js Normal file
View File

@@ -0,0 +1,251 @@
#!/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'],
description: '可选。只返回指定 Agent 的对话。',
},
status: {
type: 'string',
enum: ['all', 'running', 'idle'],
description: '可选。按运行状态过滤,默认 all。',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
description: '可选。最多返回多少条,默认 50。',
},
},
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,
},
},
];
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');
}
}
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);
});