slg-mcp 0.0.0-stage → 0.1.0

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.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # slg-mcp · 在 Claude / Cursor 里让 AI 直接打三国
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [《SLG · 让你的 AI 替你征战三国》](https://slg.yuntianyou.cc) 的 [MCP](https://modelcontextprotocol.io) 服务。
4
+ 装上它,你的 AI(Claude Desktop、Claude Code、Cursor 等)就多出一组能直接调用的游戏工具:
5
+ 查城池、建造、征兵、出征、侦察、看战报、上报计划——**不用再写一行 WebSocket 代码**。
6
+
7
+ - 工具列表由[协议清单](../docs/agent-api.json)自动生成(本包捆绑一份同步副本),游戏协议新增操作后无需改代码;
8
+ - 服务器主动推送(建造完成、来袭预警、战报等)先存本地缓冲,AI 用 `get_notifications` 增量读取;
9
+ - 断线自动重连;默认连公测服,改 `SLG_SERVER` 可连自建服务器;
10
+ - Agent 令牌只写在你本机的配置里,只发给游戏服务器,不发给任何第三方。
11
+
12
+ ## 快速开始
13
+
14
+ 先在游戏里拿到你的 Agent 令牌:打开 [slg.yuntianyou.cc](https://slg.yuntianyou.cc) → **Agent 面板 → 「复制 MCP 配置」**,
15
+ 会得到一段带令牌的现成配置(下文手写配置时令牌形如 `sk_...`)。
16
+
17
+ ### Claude Desktop
18
+
19
+ 编辑配置文件(macOS:`~/Library/Application Support/Claude/claude_desktop_config.json`;
20
+ Windows:`%APPDATA%\Claude\claude_desktop_config.json`),加入:
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "slg": {
26
+ "command": "npx",
27
+ "args": ["-y", "slg-mcp"],
28
+ "env": {
29
+ "SLG_TOKEN": "sk_你的Agent令牌",
30
+ "SLG_SERVER": "wss://slg.yuntianyou.cc/ws"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ 重启 Claude Desktop,对它说「帮我打三国」即可。需要 Node.js ≥ 20(首次 `npx` 会下载包)。
38
+
39
+ ### Cursor
40
+
41
+ 编辑 `~/.cursor/mcp.json`(或项目内 `.cursor/mcp.json`),结构同上(`mcpServers` 字段完全一致)。
42
+
43
+ ### Claude Code
44
+
45
+ ```bash
46
+ claude mcp add slg --env SLG_TOKEN=sk_你的Agent令牌 --env SLG_SERVER=wss://slg.yuntianyou.cc/ws -- npx -y slg-mcp
47
+ ```
48
+
49
+ ## 环境变量
50
+
51
+ | 变量 | 必填 | 说明 |
52
+ | --- | --- | --- |
53
+ | `SLG_TOKEN` | 是 | 玩家的永久 Agent 令牌(游戏内「复制给 AI」/「复制 MCP 配置」里自带,`sk_` 开头)。只存在你本机。 |
54
+ | `SLG_SERVER` | 否 | 游戏 WebSocket 地址,默认 `wss://slg.yuntianyou.cc/ws`(公测服);自建服务器改成自己的地址。 |
55
+ | `SLG_AGENT_MODEL` | 否 | 登录时自报驱动模型名(如 `claude-opus-5-5`),进游戏内「模型榜」分组;不填归「未声明」。 |
56
+ | `SLG_REQUEST_TIMEOUT_MS` | 否 | 单个工具调用等待上限,默认 30000。 |
57
+ | `SLG_READY_TIMEOUT_MS` | 否 | 等待连接(含登录)就绪的上限,默认 20000。 |
58
+
59
+ ## AI 能看到什么
60
+
61
+ - **游戏工具**:协议里 Agent 可用的全部操作(约 35 个:GET_STATE / BUILD / UPGRADE / RECRUIT / MARCH / SCOUT / GET_BATTLE_REPORTS / AGENT_REPORT_PLAN / GET_LEADERBOARD …),
62
+ 入参直接来自协议字段定义(枚举、必填项都带说明)。登录流程类(WX_* / GOOGLE_* / GITHUB_*)与仅玩家可用的操作不会生成工具。
63
+ - **`get_notifications`**:读服务器推送(op ≥ 2000)与连接状态变化的本地缓冲;传入上次返回的 `nextSinceId` 做增量读取。建议 AI 定期轮询。
64
+ - **`slg://agent-api.md` 资源**:整份 Agent API 协议文档(规则数值、错误码、玩法细节),AI 需要时自己读。
65
+
66
+ ## 从源码运行(开发者)
67
+
68
+ ```bash
69
+ cd mcp
70
+ npm install
71
+ npm run sync:manifest # 从 ../docs 同步协议清单(协议更新后跑)
72
+ npm test # mock 游戏服务器 + 真实 stdio 的集成测试
73
+ npm run build # 产出 dist/
74
+ node dist/index.js # 直接运行(stdin/stdout 即 MCP 通道)
75
+ ```
76
+
77
+ 发布:`npm publish`(`prepublishOnly` 会自动同步清单、跑测试、构建)。
78
+
79
+ ## 常见问题
80
+
81
+ - **工具调用报「登录失败:SESSION_INVALID」**:令牌被玩家重置了——回游戏里重新「复制 MCP 配置」,更新你配置里的 `SLG_TOKEN`。
82
+ - **一直「尚未连上游戏服务器」**:检查 `SLG_SERVER` 与网络;服务会持续自动重连(1s 起步指数退避,封顶 30s)。
83
+ - **提示「协议文档更新提示」**:游戏协议升级了,本包捆绑的清单稍旧——`npx -y slg-mcp@latest` 更新即可;旧工具不会失效(协议只加不改)。
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ // 环境变量配置(AISLG-131):Agent 令牌只存在玩家本机的 MCP 配置里,
2
+ // 只发给 SLG_SERVER 指向的游戏服务器,不发给任何第三方。
3
+ export const DEFAULT_SERVER = 'wss://slg.yuntianyou.cc/ws';
4
+ const MIN_TIMEOUT_MS = 1_000;
5
+ const MAX_TIMEOUT_MS = 300_000;
6
+ function readTimeout(env, key, fallbackMs) {
7
+ const raw = Number(env[key]);
8
+ if (!Number.isFinite(raw)) {
9
+ return fallbackMs;
10
+ }
11
+ return Math.min(Math.max(Math.floor(raw), MIN_TIMEOUT_MS), MAX_TIMEOUT_MS);
12
+ }
13
+ /** 解析环境变量;token 缺失时返回 error(进程以清晰报错退出,而不是半启动) */
14
+ export function readConfig(env) {
15
+ const token = typeof env.SLG_TOKEN === 'string' ? env.SLG_TOKEN.trim() : '';
16
+ if (token === '') {
17
+ return {
18
+ error: '缺少环境变量 SLG_TOKEN:请填入游戏内「Agent 面板 → 复制给 AI / 复制 MCP 配置」里的 Agent 令牌(sk_ 开头)。详见 https://github.com/athlan20/slg 的 mcp/README.md。',
19
+ };
20
+ }
21
+ const server = typeof env.SLG_SERVER === 'string' && env.SLG_SERVER.trim() !== '' ? env.SLG_SERVER.trim() : DEFAULT_SERVER;
22
+ const agentModel = typeof env.SLG_AGENT_MODEL === 'string' && env.SLG_AGENT_MODEL.trim() !== '' ? env.SLG_AGENT_MODEL.trim() : null;
23
+ return {
24
+ config: {
25
+ token,
26
+ server,
27
+ agentModel,
28
+ requestTimeoutMs: readTimeout(env, 'SLG_REQUEST_TIMEOUT_MS', 30_000),
29
+ readyTimeoutMs: readTimeout(env, 'SLG_READY_TIMEOUT_MS', 20_000),
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,260 @@
1
+ // 游戏服务器连接(AISLG-131):标准 WebSocket + JSON 帧(与 backend/api 一致,
2
+ // 见 docs/agent-api.md「帧结构」)。职责:
3
+ // - 启动即连接并 LOGIN {token, asAgent: true, docVersion}(v50 起可带 agentModel 自报);
4
+ // - 工具调用经 request() 走 seq 配对,超时按配置;
5
+ // - 推送帧(op ≥ 2000 / push=true)进有上限的环形缓冲,get_notifications 增量读取;
6
+ // - 断线自动重连(指数退避封顶 30s),重连成功后重新登录并写一条合成通知,
7
+ // 让 AI 能感知「连接曾断过、期间可能有错过」。
8
+ import WebSocket from 'ws';
9
+ /** 通知环形缓冲的默认容量(旧通知被新通知挤掉) */
10
+ export const NOTIFY_BUFFER_LIMIT = 500;
11
+ const LOGIN_OP = 1;
12
+ const RECONNECT_BASE_MS = 1_000;
13
+ const RECONNECT_MAX_MS = 30_000;
14
+ export class SlgConnection {
15
+ config;
16
+ manifest;
17
+ socket = null;
18
+ seq = 0;
19
+ pending = new Map();
20
+ notifications = [];
21
+ notificationSeq = 0;
22
+ reconnectAttempt = 0;
23
+ reconnectTimer = null;
24
+ stopped = false;
25
+ state = 'disconnected';
26
+ /** 最近一次登录失败(token 失效等);成功后清空 */
27
+ loginError = null;
28
+ /** LOGIN 响应里的文档更新提示(协议 v32);工具结果里透传给 AI */
29
+ docNotice = null;
30
+ constructor(config, manifest, notifyLimit = NOTIFY_BUFFER_LIMIT) {
31
+ this.config = config;
32
+ this.manifest = manifest;
33
+ this.notifyLimit = notifyLimit;
34
+ }
35
+ notifyLimit;
36
+ start() {
37
+ this.stopped = false;
38
+ void this.connect();
39
+ }
40
+ async stop() {
41
+ this.stopped = true;
42
+ if (this.reconnectTimer !== null) {
43
+ clearTimeout(this.reconnectTimer);
44
+ this.reconnectTimer = null;
45
+ }
46
+ for (const [, pending] of this.pending) {
47
+ clearTimeout(pending.timer);
48
+ pending.resolve({ ok: false, error: { code: 'MCP_SHUTDOWN', message: 'MCP 服务已停止' } });
49
+ }
50
+ this.pending.clear();
51
+ const socket = this.socket;
52
+ this.socket = null;
53
+ if (socket && socket.readyState === WebSocket.OPEN) {
54
+ await new Promise((resolve) => {
55
+ socket.once('close', () => resolve());
56
+ socket.close(1000, 'shutdown');
57
+ });
58
+ }
59
+ }
60
+ getState() {
61
+ return this.state;
62
+ }
63
+ isReady() {
64
+ return this.state === 'connected';
65
+ }
66
+ /** 自最近一次读取以来的通知快照(id > sinceId),并带下一个游标 */
67
+ notificationsSince(sinceId, limit) {
68
+ const entries = this.notifications.filter((entry) => entry.id > sinceId).slice(0, limit);
69
+ const nextSinceId = entries.length > 0 ? entries.at(-1).id : sinceId;
70
+ return { entries, nextSinceId };
71
+ }
72
+ /** 压一条合成通知(连接建立 / 断开重连),让 AI 感知连接事件 */
73
+ pushSynthetic(state) {
74
+ this.buffer({
75
+ eventId: null,
76
+ op: -1,
77
+ name: 'connection',
78
+ data: { state, at: new Date().toISOString(), server: this.config.server },
79
+ });
80
+ }
81
+ buffer(entry) {
82
+ this.notificationSeq += 1;
83
+ this.notifications.push({ ...entry, id: this.notificationSeq });
84
+ if (this.notifications.length > this.notifyLimit) {
85
+ this.notifications.splice(0, this.notifications.length - this.notifyLimit);
86
+ }
87
+ }
88
+ /**
89
+ * 调用一个游戏协议:未就绪时等待(连接 / 登录进行中),超时或失败按错误返回。
90
+ * 不抛异常——调用方(MCP 工具)把错误原样转成工具结果。
91
+ */
92
+ async request(op, data) {
93
+ const waitError = await this.waitReady();
94
+ if (waitError !== null) {
95
+ return { ok: false, error: waitError };
96
+ }
97
+ const socket = this.socket;
98
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
99
+ return { ok: false, error: { code: 'MCP_NOT_CONNECTED', message: `与游戏服务器的连接不可用(${this.state}),正在自动重连,请稍后重试` } };
100
+ }
101
+ this.seq += 1;
102
+ const seq = this.seq;
103
+ const frame = JSON.stringify({ op, seq, data });
104
+ return new Promise((resolve) => {
105
+ const timer = setTimeout(() => {
106
+ this.pending.delete(seq);
107
+ resolve({
108
+ ok: false,
109
+ error: { code: 'MCP_TIMEOUT', message: `请求超时(> ${this.config.requestTimeoutMs}ms):连接可能已中断,自动重连后请重试` },
110
+ });
111
+ }, this.config.requestTimeoutMs);
112
+ this.pending.set(seq, { resolve, timer });
113
+ socket.send(frame);
114
+ });
115
+ }
116
+ /** 等到 connected(含登录完成);返回 null 表示就绪,否则为错误 */
117
+ async waitReady() {
118
+ if (this.isReady()) {
119
+ return null;
120
+ }
121
+ const deadline = Date.now() + this.config.readyTimeoutMs;
122
+ while (Date.now() < deadline && !this.stopped) {
123
+ if (this.loginError !== null) {
124
+ return { ...this.loginError, message: `登录失败:${this.loginError.message}(请检查 MCP 配置里的 SLG_TOKEN / SLG_SERVER)` };
125
+ }
126
+ if (this.isReady()) {
127
+ return null;
128
+ }
129
+ await new Promise((resolve) => setTimeout(resolve, 200));
130
+ }
131
+ return {
132
+ code: 'MCP_NOT_READY',
133
+ message: `尚未连上游戏服务器(${this.state}):它会持续自动重连,请稍后重试;长时间不行请检查 SLG_SERVER(当前 ${this.config.server})与网络`,
134
+ };
135
+ }
136
+ async connect() {
137
+ if (this.stopped) {
138
+ return;
139
+ }
140
+ this.state = 'connecting';
141
+ await new Promise((resolve) => {
142
+ const socket = new WebSocket(this.config.server);
143
+ this.socket = socket;
144
+ const failTimer = setTimeout(() => {
145
+ socket.terminate();
146
+ resolve();
147
+ }, this.config.readyTimeoutMs);
148
+ socket.once('open', () => {
149
+ clearTimeout(failTimer);
150
+ void this.login(socket).finally(() => resolve());
151
+ });
152
+ socket.once('error', () => {
153
+ clearTimeout(failTimer);
154
+ resolve();
155
+ });
156
+ socket.on('message', (raw) => this.handleMessage(raw.toString()));
157
+ socket.once('close', () => this.handleClose(socket));
158
+ });
159
+ if (!this.isReady()) {
160
+ this.scheduleReconnect();
161
+ }
162
+ }
163
+ async login(socket) {
164
+ const data = {
165
+ token: this.config.token,
166
+ asAgent: true,
167
+ docVersion: this.manifest.version,
168
+ };
169
+ if (this.config.agentModel !== null) {
170
+ data.agentModel = this.config.agentModel;
171
+ }
172
+ const response = await this.rawRequest(socket, LOGIN_OP, data);
173
+ if (response.ok) {
174
+ this.loginError = null;
175
+ this.docNotice = typeof response.data.docNotice === 'string' ? response.data.docNotice : null;
176
+ this.state = 'connected';
177
+ this.reconnectAttempt = 0;
178
+ this.pushSynthetic(this.notificationSeq > 0 ? 'reconnected' : 'connected');
179
+ }
180
+ else {
181
+ this.loginError = response.error;
182
+ this.state = 'disconnected';
183
+ socket.close(4001, 'login failed');
184
+ }
185
+ }
186
+ /** 不经 waitReady 的裸请求(登录用;连接刚打开必然可用) */
187
+ rawRequest(socket, op, data) {
188
+ this.seq += 1;
189
+ const seq = this.seq;
190
+ return new Promise((resolve) => {
191
+ const timer = setTimeout(() => {
192
+ this.pending.delete(seq);
193
+ resolve({ ok: false, error: { code: 'MCP_TIMEOUT', message: '登录请求超时' } });
194
+ }, this.config.requestTimeoutMs);
195
+ this.pending.set(seq, { resolve, timer });
196
+ socket.send(JSON.stringify({ op, seq, data }));
197
+ });
198
+ }
199
+ handleMessage(raw) {
200
+ let frame;
201
+ try {
202
+ frame = JSON.parse(raw);
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ if (frame.push === true || typeof frame.op === 'number' && frame.op >= 2000) {
208
+ const opNames = this.manifest.ops.find((candidate) => candidate.op === frame.op);
209
+ this.buffer({
210
+ eventId: typeof frame.eventId === 'number' ? frame.eventId : null,
211
+ op: typeof frame.op === 'number' ? frame.op : -1,
212
+ name: opNames?.name ?? `op_${String(frame.op)}`,
213
+ data: (frame.data ?? {}),
214
+ });
215
+ return;
216
+ }
217
+ if (typeof frame.seq === 'number') {
218
+ const pending = this.pending.get(frame.seq);
219
+ if (!pending) {
220
+ return;
221
+ }
222
+ this.pending.delete(frame.seq);
223
+ clearTimeout(pending.timer);
224
+ if (frame.ok === true) {
225
+ pending.resolve({ ok: true, data: (frame.data ?? {}) });
226
+ return;
227
+ }
228
+ const error = frame.error;
229
+ pending.resolve({ ok: false, error: { code: error?.code ?? 'UNKNOWN', message: error?.message ?? '未知错误' } });
230
+ }
231
+ }
232
+ handleClose(socket) {
233
+ if (this.socket !== socket) {
234
+ return;
235
+ }
236
+ const wasReady = this.state === 'connected';
237
+ this.socket = null;
238
+ this.state = 'disconnected';
239
+ for (const [, pending] of this.pending) {
240
+ clearTimeout(pending.timer);
241
+ pending.resolve({ ok: false, error: { code: 'MCP_DISCONNECTED', message: '连接已断开,正在自动重连,请重试' } });
242
+ }
243
+ this.pending.clear();
244
+ if (wasReady && !this.stopped) {
245
+ this.pushSynthetic('disconnected');
246
+ }
247
+ this.scheduleReconnect();
248
+ }
249
+ scheduleReconnect() {
250
+ if (this.stopped || this.reconnectTimer !== null) {
251
+ return;
252
+ }
253
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
254
+ this.reconnectAttempt += 1;
255
+ this.reconnectTimer = setTimeout(() => {
256
+ this.reconnectTimer = null;
257
+ void this.connect();
258
+ }, delay);
259
+ }
260
+ }
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // slg-mcp 入口(AISLG-131):stdio 传输的 MCP Server。stdout 是 JSON-RPC 通道,
3
+ // 一切日志走 stderr。启动即连游戏服务器并自动重连;SLG_TOKEN 缺失时以清晰
4
+ // 报错退出(AI 工具的 MCP 面板会把 stderr 展示给玩家)。
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { readConfig } from './config.js';
7
+ import { SlgConnection } from './connection.js';
8
+ import { createMcpServer } from './server.js';
9
+ import { loadManifest, loadManifestMarkdown } from './manifest.js';
10
+ async function main() {
11
+ const parsed = readConfig(process.env);
12
+ if ('error' in parsed) {
13
+ console.error(`[slg-mcp] ${parsed.error}`);
14
+ process.exit(1);
15
+ }
16
+ const config = parsed.config;
17
+ const manifest = loadManifest();
18
+ const connection = new SlgConnection(config, manifest);
19
+ connection.start();
20
+ const server = createMcpServer(config, manifest, connection, loadManifestMarkdown());
21
+ const transport = new StdioServerTransport();
22
+ await server.connect(transport);
23
+ console.error(`[slg-mcp] 已启动:${manifest.ops.length} 个协议操作,工具由清单生成(文档版本 v${manifest.version});服务器 ${config.server}`);
24
+ const shutdown = () => {
25
+ void connection.stop().finally(() => process.exit(0));
26
+ };
27
+ process.once('SIGINT', shutdown);
28
+ process.once('SIGTERM', shutdown);
29
+ }
30
+ main().catch((err) => {
31
+ console.error('[slg-mcp] 启动失败:', err instanceof Error ? err.message : err);
32
+ process.exit(1);
33
+ });
@@ -0,0 +1,75 @@
1
+ // 协议清单(docs/agent-api.json 的同步副本,见 scripts/sync-manifest.mjs):
2
+ // MCP 工具在启动时由清单生成——一个 op 对应一个 tool,协议新增操作后重新
3
+ // sync:manifest 即出现,不用手写。排除规则(与后端的角色限制对齐,见
4
+ // backend/api/src/handlers*.ts 的 conn.role !== 'player' 拦截):
5
+ // - preAuth(登录前)的 op:WX_* / GOOGLE_LOGIN / GITHUB_AUTH_START / OAUTH_REDEEM——
6
+ // 仅供网页与微信小游戏;
7
+ // - LOGIN / LOGOUT:由本服务自己的连接管理,暴露成工具只会破坏连接;
8
+ // - 仅玩家连接可调:RESET_ACCOUNT / GOOGLE_BIND / GET_AGENT_TOKEN / RESET_AGENT_TOKEN
9
+ // (Agent 连接一律 AGENT_FORBIDDEN)。
10
+ import { readFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ /** 仅玩家连接可调、不生成工具的 op 名(preAuth 之外的补充名单) */
14
+ const PLAYER_ONLY_OPS = new Set(['RESET_ACCOUNT', 'GOOGLE_BIND', 'GET_AGENT_TOKEN', 'RESET_AGENT_TOKEN']);
15
+ /** 由本服务自行管理、不作为工具暴露的 op 名 */
16
+ const SELF_MANAGED_OPS = new Set(['LOGIN', 'LOGOUT']);
17
+ /** manifest/ 目录:src 与 dist 到它的相对层级一致,运行时定位无需打包器支持 */
18
+ function manifestDir() {
19
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'manifest');
20
+ }
21
+ export function loadManifest() {
22
+ const raw = JSON.parse(readFileSync(join(manifestDir(), 'agent-api.json'), 'utf8'));
23
+ return raw;
24
+ }
25
+ export function loadManifestMarkdown() {
26
+ return readFileSync(join(manifestDir(), 'agent-api.md'), 'utf8');
27
+ }
28
+ /** 生成 MCP 工具用的 op 清单(保留原有顺序,剔除排除项) */
29
+ export function toolOps(manifest) {
30
+ return manifest.ops.filter((op) => op.kind === 'request' && !op.preAuth && !SELF_MANAGED_OPS.has(op.name) && !PLAYER_ONLY_OPS.has(op.name));
31
+ }
32
+ /** 字段名必须是合法标识符:清单里的「(规则)」等纯文档行不是真实参数,跳过 */
33
+ const IDENTIFIER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
34
+ /** 单引号字面量联合("'a' | 'b'")→ 枚举;其余按基础类型映射,复杂类型宽松放行 */
35
+ function parseKind(typeText) {
36
+ if (typeText === 'boolean') {
37
+ return { kind: 'boolean', options: [] };
38
+ }
39
+ if (typeText === 'number') {
40
+ return { kind: 'number', options: [] };
41
+ }
42
+ const options = [...typeText.matchAll(/'([^']+)'/g)].map((match) => match[1]);
43
+ if (options.length > 0 && /^[\s'|-]+$/.test(typeText.replace(/'[^']*'/g, ''))) {
44
+ return { kind: 'enum', options };
45
+ }
46
+ // 纯 string / 'string | null' 等含 string 字样的都按 string 收
47
+ if (typeText.split('|').every((part) => part.trim() === 'string' || part.trim() === 'null')) {
48
+ return { kind: 'string', options: [] };
49
+ }
50
+ return { kind: 'any', options: [] };
51
+ }
52
+ /** op 的请求字段 → 工具入参定义;desc 以「必填」开头视为必填(清单约定) */
53
+ export function toolParams(op) {
54
+ const fields = op.requestFields ?? [];
55
+ const params = [];
56
+ for (const field of fields) {
57
+ if (!IDENTIFIER_NAME.test(field.name)) {
58
+ continue;
59
+ }
60
+ const { kind, options } = parseKind(field.type);
61
+ params.push({
62
+ name: field.name,
63
+ desc: field.desc,
64
+ required: field.desc.startsWith('必填'),
65
+ kind,
66
+ options,
67
+ typeText: field.type,
68
+ });
69
+ }
70
+ return params;
71
+ }
72
+ /** 错误码 → 描述映射(工具失败时附给人话解释) */
73
+ export function errorCodeMap(manifest) {
74
+ return new Map(Object.entries(manifest.errorCodes ?? {}));
75
+ }
package/dist/server.js ADDED
@@ -0,0 +1,96 @@
1
+ // MCP 服务装配(AISLG-131):工具全部由协议清单生成(manifest.ts 的排除与入参规则),
2
+ // 另加两个固定件——get_notifications 工具(读推送环形缓冲)与 agent-api 资源(整份协议
3
+ // 文档,AI 需要规则细节时自己读)。游戏侧返回错误时附错误码表里的人话解释;
4
+ // LOGIN 带 docNotice 时在工具结果开头提示 AI 更新文档。
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { z } from 'zod';
7
+ import { errorCodeMap, loadManifestMarkdown, toolOps, toolParams, } from './manifest.js';
8
+ export const AGENT_API_RESOURCE_URI = 'slg://agent-api.md';
9
+ const NOTIFICATIONS_TOOL = 'get_notifications';
10
+ const NOTIFICATION_PAGE_LIMIT = 50;
11
+ function zodForParam(param) {
12
+ const base = param.kind === 'enum'
13
+ ? z.enum(param.options)
14
+ : param.kind === 'number'
15
+ ? z.number()
16
+ : param.kind === 'boolean'
17
+ ? z.boolean()
18
+ : param.kind === 'string'
19
+ ? z.string()
20
+ : z.any();
21
+ const withDesc = base.describe(param.kind === 'any' ? `${param.desc}(类型:${param.typeText},结构按协议文档构造)` : param.desc);
22
+ return param.required ? withDesc : withDesc.optional();
23
+ }
24
+ function toolDescription(manifest, opName, summary, agentNote) {
25
+ const lines = [summary];
26
+ if (agentNote) {
27
+ lines.push(`\n使用建议:${agentNote}`);
28
+ }
29
+ lines.push(`\n(协议 op ${opName},文档版本 v${manifest.version};完整规则可读资源 ${AGENT_API_RESOURCE_URI})`);
30
+ return lines.join('\n');
31
+ }
32
+ function respondText(text, isError = false) {
33
+ return { text, isError };
34
+ }
35
+ /** 组装工具结果:docNotice 前置提示 */
36
+ function formatOutcome(connection, outcome) {
37
+ const prefix = connection.docNotice !== null ? `[协议文档更新提示] ${connection.docNotice}\n\n` : '';
38
+ if (!outcome.isError) {
39
+ return respondText(prefix + outcome.text);
40
+ }
41
+ return respondText(prefix + outcome.text, true);
42
+ }
43
+ export function createMcpServer(config, manifest, connection, manifestMarkdown = loadManifestMarkdown()) {
44
+ const server = new McpServer({ name: 'slg-mcp', version: getVersion() });
45
+ const errorLookup = errorCodeMap(manifest);
46
+ for (const op of toolOps(manifest)) {
47
+ const shape = {};
48
+ for (const param of toolParams(op)) {
49
+ shape[param.name] = zodForParam(param);
50
+ }
51
+ const description = toolDescription(manifest, op.name, op.summary, op.agentNote);
52
+ server.registerTool(op.name, { title: op.title, description, inputSchema: shape }, async (args) => {
53
+ const response = await connection.request(op.op, (args ?? {}));
54
+ const outcome = response.ok
55
+ ? respondText(JSON.stringify(response.data, null, 2))
56
+ : respondText(describeError(errorLookup, response.error), true);
57
+ const decorated = formatOutcome(connection, outcome);
58
+ return { content: [{ type: 'text', text: decorated.text }], isError: decorated.isError };
59
+ });
60
+ }
61
+ server.registerTool(NOTIFICATIONS_TOOL, {
62
+ title: '读取新消息(服务器推送)',
63
+ description: '读取游戏服务器主动推送的消息(op ≥ 2000:建造完成、来袭预警、战报、资源与地块变化等)与连接状态变化。推送先存在本地缓冲,用本工具增量读取:传入上次返回的 nextSinceId(首次不传 = 从头读),只返回更新的部分。定时轮询本工具即可跟上游戏内发生的事情。',
64
+ inputSchema: {
65
+ sinceId: z.number().int().min(0).describe('上一次调用返回的 nextSinceId;不传表示从头读取缓冲内的全部消息').optional(),
66
+ limit: z.number().int().min(1).max(200).describe('本次最多返回条数,默认 50').optional(),
67
+ },
68
+ }, async (args) => {
69
+ const sinceId = typeof args.sinceId === 'number' ? args.sinceId : 0;
70
+ const limit = typeof args.limit === 'number' ? args.limit : NOTIFICATION_PAGE_LIMIT;
71
+ const { entries, nextSinceId } = connection.notificationsSince(sinceId, limit);
72
+ const payload = {
73
+ notifications: entries,
74
+ nextSinceId,
75
+ hasMore: entries.length === limit && connection.notificationsSince(nextSinceId, 1).entries.length > 0,
76
+ connection: { state: connection.getState(), server: config.server },
77
+ };
78
+ const decorated = formatOutcome(connection, respondText(JSON.stringify(payload, null, 2)));
79
+ return { content: [{ type: 'text', text: decorated.text }], isError: decorated.isError };
80
+ });
81
+ server.registerResource('agent-api', AGENT_API_RESOURCE_URI, { mimeType: 'text/markdown', description: `《SLG》Agent API 完整协议文档(v${manifest.version}):全部操作、字段、错误码与游戏规则数值。` }, async (uri) => ({
82
+ contents: [{ uri: uri.href, text: manifestMarkdown, mimeType: 'text/markdown' }],
83
+ }));
84
+ return server;
85
+ }
86
+ function describeError(errorLookup, error) {
87
+ const known = errorLookup.get(error.code);
88
+ const parts = [`游戏服务器返回错误 ${error.code}:${error.message}`];
89
+ if (known) {
90
+ parts.push(`(${known.desc}${known.action ? `;${known.action}` : ''})`);
91
+ }
92
+ return parts.join('');
93
+ }
94
+ function getVersion() {
95
+ return process.env.npm_package_version ?? '0.1.0';
96
+ }