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

271 lines
7.2 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_send_message',
description: '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。',
inputSchema: {
type: 'object',
properties: {
targetConversationId: {
type: 'string',
description: '目标对话 ID。',
},
content: {
type: 'string',
description: '要发送到目标对话的纯文本消息。',
},
},
required: ['targetConversationId', 'content'],
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');
}
}
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);
});