@snowluma/mcp 1.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // @snowluma/mcp — an MCP server for the SnowLuma OneBot action catalog.
3
+ //
4
+ // Two modes in one binary:
5
+ // • docs (default, no endpoint): read-only catalog tools (list/get/search/
6
+ // categories) + a catalog resource. Zero contact with any live instance.
7
+ // • read / write (when SNOWLUMA_MCP_ENDPOINT is set): the catalog tools PLUS
8
+ // execution — query_action (read-only actions) and, in write mode,
9
+ // invoke_action (any known action) — proxied to a running OneBot instance
10
+ // over HTTP.
11
+ //
12
+ // Env:
13
+ // SNOWLUMA_MCP_ENDPOINT OneBot HTTP endpoint (e.g. http://127.0.0.1:3000/).
14
+ // Absent → docs-only (execution tools hidden).
15
+ // SNOWLUMA_MCP_TOKEN access token (Bearer) for the endpoint.
16
+ // SNOWLUMA_MCP_TIMEOUT_MS per-request timeout (default SDK 30s).
17
+ // SNOWLUMA_MCP_MODE docs | read | write. Default: read when an
18
+ // endpoint is set, else docs.
19
+ //
20
+ // NOTE: stdout is the MCP protocol channel — all diagnostics go to stderr.
21
+ import { readFileSync } from 'node:fs';
22
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
23
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
24
+ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
25
+ import { ACTIONS, CATEGORIES } from './generated/catalog.js';
26
+ import { makeHttpClient } from './client.js';
27
+ import { callTool, computeTools } from './tools.js';
28
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
29
+ const VERSION = pkg.version;
30
+ const RESOURCE_URI = 'snowluma://onebot/actions';
31
+ // ── resolve mode + client from the environment ────────────────────────────
32
+ function resolveRuntime() {
33
+ const endpoint = process.env.SNOWLUMA_MCP_ENDPOINT?.trim();
34
+ const token = process.env.SNOWLUMA_MCP_TOKEN?.trim() || undefined;
35
+ const timeoutRaw = Number(process.env.SNOWLUMA_MCP_TIMEOUT_MS);
36
+ const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
37
+ const requested = process.env.SNOWLUMA_MCP_MODE?.trim().toLowerCase();
38
+ const validRequest = requested === 'docs' || requested === 'read' || requested === 'write' ? requested : undefined;
39
+ if (!endpoint) {
40
+ if (validRequest && validRequest !== 'docs') {
41
+ console.error(`[snowluma-mcp] SNOWLUMA_MCP_MODE=${validRequest} ignored: no SNOWLUMA_MCP_ENDPOINT set — docs-only.`);
42
+ }
43
+ return { mode: 'docs' };
44
+ }
45
+ const mode = validRequest ?? 'read';
46
+ if (mode === 'docs') {
47
+ // Endpoint set but operator explicitly wants docs-only — honor it.
48
+ return { mode: 'docs' };
49
+ }
50
+ const client = makeHttpClient({ endpoint, accessToken: token, timeoutMs });
51
+ return { mode, client };
52
+ }
53
+ const { mode, client } = resolveRuntime();
54
+ const server = new Server({ name: 'snowluma-mcp', version: VERSION }, { capabilities: { tools: {}, resources: {} } });
55
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: computeTools(mode) }));
56
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
57
+ const args = (req.params.arguments ?? {});
58
+ return callTool(req.params.name, args, { mode, client });
59
+ });
60
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
61
+ resources: [
62
+ {
63
+ uri: RESOURCE_URI,
64
+ name: 'SnowLuma OneBot action catalog',
65
+ mimeType: 'application/json',
66
+ description: `SnowLuma v${VERSION} 的 ${ACTIONS.length} 个 OneBot action 完整目录(文档 + JSON Schema)。`,
67
+ },
68
+ ],
69
+ }));
70
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
71
+ if (req.params.uri !== RESOURCE_URI)
72
+ throw new Error(`unknown resource: ${req.params.uri}`);
73
+ return {
74
+ contents: [
75
+ {
76
+ uri: RESOURCE_URI,
77
+ mimeType: 'application/json',
78
+ text: JSON.stringify({ version: VERSION, categories: CATEGORIES, actions: ACTIONS }, null, 2),
79
+ },
80
+ ],
81
+ };
82
+ });
83
+ const transport = new StdioServerTransport();
84
+ await server.connect(transport);
85
+ const where = client ? ` → ${process.env.SNOWLUMA_MCP_ENDPOINT?.trim()}` : '';
86
+ console.error(`[snowluma-mcp] v${VERSION} ready — ${ACTIONS.length} actions, ${CATEGORIES.length} categories, mode=${mode}${where}`);
@@ -0,0 +1,11 @@
1
+ import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { ActionClient } from './client.js';
3
+ export type Mode = 'docs' | 'read' | 'write';
4
+ /** Tools visible for a given mode: docs always; +query in read/write; +invoke in write. */
5
+ export declare function computeTools(mode: Mode): Tool[];
6
+ export type ToolResult = CallToolResult;
7
+ export interface CallCtx {
8
+ mode: Mode;
9
+ client?: ActionClient;
10
+ }
11
+ export declare function callTool(name: string, args: Record<string, unknown>, ctx: CallCtx): Promise<ToolResult>;
package/dist/tools.js ADDED
@@ -0,0 +1,160 @@
1
+ // Tool surface for the MCP — kept free of any stdio / transport / env concerns
2
+ // so it is directly unit-testable. `server.ts` reads the environment, builds a
3
+ // client, and wires `computeTools` / `callTool` into the MCP request handlers.
4
+ //
5
+ // Two execution tools partition the action space by side-effect:
6
+ // • query_action — read-only actions only (readOnlyHint)
7
+ // • invoke_action — any known action (destructiveHint, write mode)
8
+ // Visibility is gated by `mode`, and `callTool` RE-checks every permission
9
+ // (defense in depth: a client may call a tool that was never listed).
10
+ import { ACTIONS, CATEGORIES } from './generated/catalog.js';
11
+ // name + every alias → action, so lookups accept aliases too.
12
+ const byName = new Map();
13
+ for (const a of ACTIONS) {
14
+ for (const n of [a.name, ...a.aliases])
15
+ byName.set(n, a);
16
+ }
17
+ /** Lightweight index entry (keeps list/search payloads small). */
18
+ function lite(a) {
19
+ return { name: a.name, category: a.category, summary: a.summary, aliases: a.aliases, readOnly: a.readOnly };
20
+ }
21
+ const DOCS_TOOLS = [
22
+ {
23
+ name: 'list_actions',
24
+ description: '列出所有 OneBot action(可按 category 过滤)。返回轻量索引(名称/分类/摘要/别名/是否只读)。',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: { category: { type: 'string', description: '按分类过滤,如 群管理 / 消息 / 好友' } },
28
+ additionalProperties: false,
29
+ },
30
+ annotations: { readOnlyHint: true, openWorldHint: false },
31
+ },
32
+ {
33
+ name: 'get_action',
34
+ description: '获取某个 OneBot action 的完整文档:摘要、参数表、跨字段约束、返回、是否只读,以及可直接用于构造调用的参数 JSON Schema(inputSchema)。',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: { name: { type: 'string', description: 'action 名(接受别名)' } },
38
+ required: ['name'],
39
+ additionalProperties: false,
40
+ },
41
+ annotations: { readOnlyHint: true, openWorldHint: false },
42
+ },
43
+ {
44
+ name: 'search_actions',
45
+ description: '按关键字模糊搜索 action(匹配名称 / 摘要 / 别名)。返回轻量索引。',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: { query: { type: 'string', description: '关键字' } },
49
+ required: ['query'],
50
+ additionalProperties: false,
51
+ },
52
+ annotations: { readOnlyHint: true, openWorldHint: false },
53
+ },
54
+ {
55
+ name: 'list_categories',
56
+ description: '列出所有分类及其 action 数量。',
57
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
58
+ annotations: { readOnlyHint: true, openWorldHint: false },
59
+ },
60
+ ];
61
+ const EXEC_INPUT_SCHEMA = {
62
+ type: 'object',
63
+ properties: {
64
+ action: { type: 'string', description: 'action 名(接受别名)。先用 get_action 查它的参数 inputSchema。' },
65
+ params: { type: 'object', description: '该 action 的参数对象', additionalProperties: true },
66
+ },
67
+ required: ['action'],
68
+ additionalProperties: false,
69
+ };
70
+ function queryTool() {
71
+ return {
72
+ name: 'query_action',
73
+ description: '调用一个【只读】OneBot action(如 get_*/can_*)并返回完整响应。仅接受只读 action;写操作请用 invoke_action。',
74
+ inputSchema: EXEC_INPUT_SCHEMA,
75
+ annotations: { readOnlyHint: true, openWorldHint: true },
76
+ };
77
+ }
78
+ function invokeTool() {
79
+ return {
80
+ name: 'invoke_action',
81
+ description: '调用一个 OneBot action(可产生副作用:发消息、改群、改资料等)并返回完整响应。仅在写模式可用。',
82
+ inputSchema: EXEC_INPUT_SCHEMA,
83
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
84
+ };
85
+ }
86
+ /** Tools visible for a given mode: docs always; +query in read/write; +invoke in write. */
87
+ export function computeTools(mode) {
88
+ const tools = [...DOCS_TOOLS];
89
+ if (mode === 'read' || mode === 'write')
90
+ tools.push(queryTool());
91
+ if (mode === 'write')
92
+ tools.push(invokeTool());
93
+ return tools;
94
+ }
95
+ function asText(data) {
96
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
97
+ }
98
+ function asError(message) {
99
+ return { content: [{ type: 'text', text: message }], isError: true };
100
+ }
101
+ export async function callTool(name, args, ctx) {
102
+ switch (name) {
103
+ case 'list_actions': {
104
+ const category = typeof args.category === 'string' ? args.category : undefined;
105
+ const list = ACTIONS.filter((a) => !category || a.category === category).map(lite);
106
+ return asText({ count: list.length, actions: list });
107
+ }
108
+ case 'get_action': {
109
+ const q = typeof args.name === 'string' ? args.name : '';
110
+ const action = byName.get(q);
111
+ if (!action)
112
+ return asError(`未找到 action: ${JSON.stringify(q)}。用 list_actions / search_actions 查可用项。`);
113
+ return asText(action);
114
+ }
115
+ case 'search_actions': {
116
+ const q = (typeof args.query === 'string' ? args.query : '').toLowerCase();
117
+ const list = ACTIONS.filter((a) => a.name.toLowerCase().includes(q) ||
118
+ (a.summary ?? '').toLowerCase().includes(q) ||
119
+ a.aliases.some((al) => al.toLowerCase().includes(q))).map(lite);
120
+ return asText({ count: list.length, actions: list });
121
+ }
122
+ case 'list_categories':
123
+ return asText(CATEGORIES);
124
+ case 'query_action':
125
+ return execute(args, ctx, 'read');
126
+ case 'invoke_action':
127
+ return execute(args, ctx, 'write');
128
+ default:
129
+ return asError(`未知工具: ${name}`);
130
+ }
131
+ }
132
+ /** Shared execution path for query_action (read) / invoke_action (write).
133
+ * Re-checks mode + readOnly routing regardless of which tools were listed. */
134
+ async function execute(args, ctx, kind) {
135
+ if (kind === 'write' && ctx.mode !== 'write') {
136
+ return asError('写操作未启用:需设置 SNOWLUMA_MCP_MODE=write。');
137
+ }
138
+ if (kind === 'read' && ctx.mode !== 'read' && ctx.mode !== 'write') {
139
+ return asError('执行未启用:需配置 SNOWLUMA_MCP_ENDPOINT。');
140
+ }
141
+ if (!ctx.client)
142
+ return asError('未配置 OneBot 端点(SNOWLUMA_MCP_ENDPOINT),无法执行。');
143
+ const action = typeof args.action === 'string' ? args.action : '';
144
+ const cat = byName.get(action);
145
+ if (!cat)
146
+ return asError(`未知 action: ${JSON.stringify(action)}。用 list_actions / search_actions 查可用项。`);
147
+ if (kind === 'read' && !cat.readOnly) {
148
+ return asError(`${action} 是写操作,不能用 query_action;请用 invoke_action(需 SNOWLUMA_MCP_MODE=write)。`);
149
+ }
150
+ const params = args.params && typeof args.params === 'object' && !Array.isArray(args.params)
151
+ ? args.params
152
+ : {};
153
+ try {
154
+ const envelope = await ctx.client.call(action, params);
155
+ return asText(envelope);
156
+ }
157
+ catch (err) {
158
+ return asError(`调用失败: ${err instanceof Error ? err.message : String(err)}`);
159
+ }
160
+ }
@@ -0,0 +1,31 @@
1
+ export interface CatalogParam {
2
+ name: string;
3
+ /** Display type ('uint' | 'int' | 'messageId' | 'string' | 'bool' | 'message' | 'enum' | 'X[]' | 'raw'). */
4
+ type: string;
5
+ required: boolean;
6
+ default?: unknown;
7
+ desc?: string;
8
+ values?: ReadonlyArray<string | number>;
9
+ /** JSON Schema fragment for this single field. */
10
+ schema?: Record<string, unknown>;
11
+ }
12
+ export interface CatalogAction {
13
+ name: string;
14
+ aliases: string[];
15
+ category?: string;
16
+ summary?: string;
17
+ returns?: string;
18
+ /** True only for pure data-fetch actions (no side effects). Drives the
19
+ * read/write tool routing: read-only → query_action, else → invoke_action.
20
+ * Classified at the source spec by what the action's `run` actually does. */
21
+ readOnly: boolean;
22
+ params: CatalogParam[];
23
+ /** Cross-field invariants, e.g. "exactly one of: message_id | (group_id+user_id)". */
24
+ invariants: string[];
25
+ /** Composed JSON Schema for the whole params object. */
26
+ inputSchema: Record<string, unknown>;
27
+ }
28
+ export interface CatalogCategory {
29
+ category: string;
30
+ count: number;
31
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ // The MCP's own view of the OneBot action catalog. Mirrors @snowluma/onebot's
2
+ // ActionDoc, but is defined HERE so the published package does not couple to
3
+ // onebot's internals — the generated `catalog.ts` (a build-time snapshot) is the
4
+ // only contract (ADR-0005: the wire/data shape is the seam, not a shared type).
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@snowluma/mcp",
3
+ "version": "1.9.5",
4
+ "description": "MCP server exposing the SnowLuma OneBot action catalog (docs + JSON Schemas) and optional execution to LLM clients.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/SnowLuma/SnowLuma.git",
8
+ "directory": "packages/mcp"
9
+ },
10
+ "homepage": "https://github.com/SnowLuma/SnowLuma#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/SnowLuma/SnowLuma/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "snowluma-mcp": "./dist/server.js"
17
+ },
18
+ "main": "./dist/server.js",
19
+ "types": "./dist/server.d.ts",
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true})\"",
26
+ "gen": "vitest run scripts/gen-catalog.test.ts",
27
+ "prebuild": "pnpm run clean && pnpm run gen",
28
+ "build": "tsc -p tsconfig.json",
29
+ "prepack": "pnpm run build",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "start": "node dist/server.js"
33
+ },
34
+ "keywords": [
35
+ "mcp",
36
+ "model-context-protocol",
37
+ "onebot",
38
+ "snowluma",
39
+ "qq"
40
+ ],
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@snowluma/onebot": "workspace:*",
46
+ "@snowluma/proton": "workspace:*",
47
+ "@types/node": "^24.12.2",
48
+ "typescript": "catalog:",
49
+ "vite": "catalog:",
50
+ "vitest": "^4.1.4"
51
+ },
52
+ "engines": {
53
+ "node": ">=22"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }