Files
天天的鸟蛋蛋 b9655a5ab1 SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37
26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
2026-11-12 06:27:36 +08:00

217 lines
8.1 KiB
TypeScript
Raw Permalink 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.
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import express, { Express, Request, Response } from 'express';
import http from 'http';
import cors from 'cors';
import { McpServerConfig } from '@/snowcat-onebot/config/config';
import { IOB11NetworkAdapter } from '@/snowcat-onebot/network/adapter';
import { ActionName } from '@/snowcat-onebot/action/router';
import { OneBotAction } from '@/snowcat-onebot/action/OneBotAction';
/**
* SnowCat MCP 服务器适配器
*
* 把 OneBot v11 全部动作以 MCP(Model Context Protocol)工具形式暴露,
* AI 宿主(Claude / dsh / 任何 MCP 客户端)直连调用。
*
* 传输:Streamable HTTP(POST /mcp 单次 JSON-RPC;GET /mcp 返回 SSE 事件流)。
* 鉴权:Bearer token(与网络配置 token 一致;为空则不鉴权)。
*/
export class OB11McpServerAdapter extends IOB11NetworkAdapter<McpServerConfig> {
private app: Express | undefined;
private server: http.Server | undefined;
private sseClients: Response[] = [];
override get isActive (): boolean {
return this.isEnable;
}
override async onEvent<T extends OB11EmitEventContent> (event: T) {
// MCP 侧事件推给已建立 SSE 流的客户端(notifications 可选通道)
const payload = `event: ob11_event\ndata: ${JSON.stringify(event)}\n\n`;
const promises = this.sseClients.map((res) => new Promise<void>((resolve) => {
res.write(payload, () => resolve());
}));
await Promise.allSettled(promises);
}
open () {
try {
if (this.isEnable) {
this.core.context.logger.logError('Cannot open a closed MCP server');
return;
}
this.initializeServer();
this.isEnable = true;
} catch (e) {
this.core.context.logger.logError(`[OneBot] [MCP Server Adapter] Boot Error: ${e}`);
}
}
async close () {
this.isEnable = false;
this.sseClients.forEach((res) => res.end());
this.sseClients = [];
this.server?.close();
this.app = undefined;
}
async reload (newConfig: McpServerConfig) {
const wasEnabled = this.isEnable;
const oldPort = this.config.port;
const oldHost = this.config.host;
const oldToken = this.config.token;
this.config = newConfig;
if (newConfig.enable && !wasEnabled) {
this.open();
return OB11NetworkReloadType.NetWorkOpen;
} else if (!newConfig.enable && wasEnabled) {
await this.close();
return OB11NetworkReloadType.NetWorkClose;
}
if (oldPort !== newConfig.port || oldHost !== newConfig.host || oldToken !== newConfig.token) {
await this.close();
if (newConfig.enable) {
this.open();
return OB11NetworkReloadType.NetWorkOpen;
}
return OB11NetworkReloadType.NetWorkClose;
}
return OB11NetworkReloadType.Normal;
}
private authOk (req: Request): boolean {
const token = this.config.token;
if (!token) return true;
const auth = req.headers.authorization ?? '';
return auth === `Bearer ${token}`;
}
private jsonRpcError (res: Response, id: unknown, code: number, message: string) {
res.json({ jsonrpc: '2.0', id, error: { code, message } });
}
private toolNameFor (actionName: string): string {
// action 名已是 PascalCase(如 SendGroupMsg),MCP 工具名转 snake_case 更顺手
return actionName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
}
private initializeServer () {
this.app = express();
this.server = http.createServer(this.app);
this.app.use(cors());
this.app.use(express.json({ limit: '50mb' }));
// 鉴权
this.app.use((req, res, next) => {
if (!this.authOk(req)) {
res.status(401).json({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Unauthorized' } });
return;
}
next();
});
// GET /mcp → SSE 事件流(可选)
this.app.get('/mcp', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
res.write(': stream open\n\n');
this.sseClients.push(res);
req.on('close', () => {
this.sseClients = this.sseClients.filter((c) => c !== res);
});
});
// POST /mcp → JSON-RPC
this.app.post('/mcp', async (req, res) => {
const body = req.body ?? {};
const { id, method, params } = body as { id?: unknown; method?: string; params?: Record<string, unknown> };
try {
switch (method) {
case 'initialize':
res.json({
jsonrpc: '2.0',
id,
result: {
protocolVersion: '2025-03-26',
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'snowcat-onebot', version: '1.0.0' },
},
});
return;
case 'notifications/initialized':
res.status(202).end();
return;
case 'ping':
res.json({ jsonrpc: '2.0', id, result: {} });
return;
case 'tools/list': {
const tools = (Object.values(ActionName) as string[]).filter(n => n !== ActionName.Unknown).map((name) => {
const action = this.actions.get(name as never) as OneBotAction<unknown, unknown> | undefined;
if (!action) return null;
return {
name: this.toolNameFor(name),
description: `[OneBot:${name}] ${action.actionDescription || name}`,
inputSchema: this.actionInputSchema(action),
};
}).filter(Boolean);
res.json({ jsonrpc: '2.0', id, result: { tools } });
return;
}
case 'tools/call': {
const toolName = String(params?.['name'] ?? '');
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
// 工具名反查动作名(snake_case → PascalCase)
const pascal = toolName.split('_').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');
const actionName = (ActionName as Record<string, string>)[pascal] ?? pascal;
const action = this.actions.get(actionName as never) as OneBotAction<unknown, unknown> | undefined;
if (!action) {
this.jsonRpcError(res, id, -32602, `Unknown tool: ${toolName}`);
return;
}
const result = await action.handle(args, this.name, this.config, { send: async () => {} });
// OB11Return: { status, retcode, data, message, ... } → MCP content
const ret = result as { data?: unknown; message?: string; retcode?: number };
const ok = ret.retcode === 0;
res.json({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: JSON.stringify(ret.data ?? ret, null, 2) }],
isError: !ok,
},
});
return;
}
case 'resources/list':
res.json({ jsonrpc: '2.0', id, result: { resources: [] } });
return;
case 'prompts/list':
res.json({ jsonrpc: '2.0', id, result: { prompts: [] } });
return;
default:
this.jsonRpcError(res, id, -32601, `Method not found: ${method}`);
}
} catch (e) {
this.core.context.logger.logError(`[OneBot] [MCP] handler error: ${e}`);
this.jsonRpcError(res, id, -32603, `Internal error: ${(e as Error).message}`);
}
});
this.server.listen(this.config.port, this.config.host, () => {
this.core.context.logger.log(`[OneBot] [MCP Server] SnowCat MCP 已启动: http://${this.config.host}:${this.config.port}/mcp(OneBot v11 全量动作)`);
});
}
private actionInputSchema (action: OneBotAction<unknown, unknown>): Record<string, unknown> {
// 有 typebox payloadSchema 的动作直接透传 JSON Schema;没有的收任意对象
const ps = action.payloadSchema as unknown as Record<string, unknown> | undefined;
if (ps && typeof ps === 'object' && ps['type'] === 'object') {
return ps;
}
return { type: 'object', properties: {}, additionalProperties: true };
}
}