@bsbofmusic/evomap-mcp-lite 0.1.0 → 0.3.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.
Files changed (2) hide show
  1. package/index.js +291 -34
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -1,93 +1,350 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
3
7
  import axios from "axios";
4
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
10
 
11
+ /* =========================
12
+ CONFIG
13
+ ========================= */
7
14
  const API = process.env.EVOMAP_API_BASE || "https://evomap.ai";
8
15
  const TIMEOUT_MS = Number(process.env.EVOMAP_TIMEOUT || "30") * 1000;
9
16
 
10
- function clipJson(obj, maxChars = 6000) {
17
+ // retry settings (stability)
18
+ const RETRY_MAX = Number(process.env.EVOMAP_RETRY_MAX || "3"); // 0..5 recommended
19
+ const RETRY_BASE_DELAY_MS = Number(process.env.EVOMAP_RETRY_BASE_DELAY_MS || "250");
20
+
21
+ // output clipping (token control)
22
+ const MAX_RETURN_CHARS = Number(process.env.EVOMAP_MAX_RETURN_CHARS || "8000");
23
+
24
+ // node identity persistence
25
+ const DEFAULT_NODE_FILE = path.join(os.homedir(), ".evomap-mcp", "node.json");
26
+ const NODE_FILE = process.env.EVOMAP_NODE_FILE || DEFAULT_NODE_FILE;
27
+
28
+ // heartbeat throttling
29
+ const HEARTBEAT_MIN_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
30
+ let lastHeartbeatAt = 0;
31
+
32
+ /* =========================
33
+ STATE (binding)
34
+ ========================= */
35
+ const state = {
36
+ sender_id: null,
37
+ bound: null, // true/false/unknown
38
+ claim_code: null,
39
+ claim_url: null,
40
+ lastHelloAt: null,
41
+ lastHelloRaw: null
42
+ };
43
+
44
+ /* =========================
45
+ UTILS
46
+ ========================= */
47
+ function nowIso() {
48
+ return new Date().toISOString();
49
+ }
50
+
51
+ function newMsgId(prefix = "msg") {
52
+ return `${prefix}_${Date.now()}_${crypto.randomBytes(6).toString("hex")}`;
53
+ }
54
+
55
+ function clipJson(obj, maxChars = MAX_RETURN_CHARS) {
11
56
  const s = JSON.stringify(obj, null, 2);
12
57
  if (s.length <= maxChars) return s;
13
58
  return s.slice(0, maxChars) + "\n...<clipped>";
14
59
  }
15
60
 
61
+ function ensureDirForFile(filePath) {
62
+ const dir = path.dirname(filePath);
63
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
64
+ }
65
+
66
+ function loadOrCreateNodeId() {
67
+ if (process.env.EVOMAP_NODE_ID && process.env.EVOMAP_NODE_ID.trim()) {
68
+ return { sender_id: process.env.EVOMAP_NODE_ID.trim(), source: "env" };
69
+ }
70
+
71
+ try {
72
+ if (fs.existsSync(NODE_FILE)) {
73
+ const raw = fs.readFileSync(NODE_FILE, "utf8");
74
+ const obj = JSON.parse(raw);
75
+ if (obj?.sender_id) return { sender_id: String(obj.sender_id), source: "file" };
76
+ }
77
+ } catch {
78
+ // ignore
79
+ }
80
+
81
+ const sender_id = `node_${crypto.randomBytes(10).toString("hex")}`;
82
+ try {
83
+ ensureDirForFile(NODE_FILE);
84
+ fs.writeFileSync(NODE_FILE, JSON.stringify({ sender_id, created_at: nowIso() }, null, 2), "utf8");
85
+ } catch {
86
+ // ignore
87
+ }
88
+ return { sender_id, source: "generated" };
89
+ }
90
+
91
+ function makeEnvelope({ message_type, sender_id, payload }) {
92
+ return {
93
+ protocol: "gep-a2a",
94
+ protocol_version: "1.0.0",
95
+ message_type,
96
+ message_id: newMsgId(message_type),
97
+ sender_id,
98
+ timestamp: nowIso(),
99
+ payload
100
+ };
101
+ }
102
+
103
+ function sleep(ms) {
104
+ return new Promise((r) => setTimeout(r, ms));
105
+ }
106
+
107
+ function isRetryable(err) {
108
+ const status = err?.response?.status;
109
+ if (!status) return true;
110
+ if (status === 429) return true;
111
+ if (status >= 500) return true;
112
+ return false;
113
+ }
114
+
16
115
  async function http(method, url, data) {
17
- const res = await axios({
116
+ return axios({
18
117
  method,
19
118
  url,
20
119
  data,
21
120
  timeout: TIMEOUT_MS,
22
121
  headers: { "content-type": "application/json" }
122
+ }).then((r) => r.data);
123
+ }
124
+
125
+ async function safeCall(method, url, data) {
126
+ let attempt = 0;
127
+ while (true) {
128
+ try {
129
+ const out = await http(method, url, data);
130
+ return out;
131
+ } catch (err) {
132
+ attempt += 1;
133
+
134
+ const wrapped = err?.response
135
+ ? { error: true, status: err.response.status, data: err.response.data }
136
+ : { error: true, message: err?.message || String(err) };
137
+
138
+ if (attempt > RETRY_MAX || !isRetryable(err)) return wrapped;
139
+
140
+ const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
141
+ await sleep(delay);
142
+ }
143
+ }
144
+ }
145
+
146
+ function bindingNoticeIfNeeded() {
147
+ if (state.bound === false && state.claim_url) {
148
+ return `⚠️ EvoMap 账号未绑定。请用 claim 链接绑定后再长期使用:${state.claim_url}(claim_code: ${state.claim_code || "N/A"})`;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /* =========================
154
+ HELLO + HEARTBEAT
155
+ ========================= */
156
+ async function ensureHeartbeat() {
157
+ const now = Date.now();
158
+ if (now - lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
159
+
160
+ const payload = { node_id: state.sender_id, timestamp: nowIso() };
161
+ const data = await safeCall("post", `${API}/a2a/heartbeat`, payload);
162
+ lastHeartbeatAt = now;
163
+
164
+ if (data?.error) console.error("[evomap-lite] heartbeat failed:", data);
165
+ }
166
+
167
+ async function helloAndUpdateBinding() {
168
+ const env = makeEnvelope({
169
+ message_type: "hello",
170
+ sender_id: state.sender_id,
171
+ payload: {
172
+ capabilities: ["mcp", "bugfix", "asset_reuse", "validate"],
173
+ gene_count: 0,
174
+ capsule_count: 0,
175
+ env_fingerprint: { platform: process.platform, arch: process.arch, node: process.version }
176
+ }
23
177
  });
24
- return res.data;
178
+
179
+ const data = await safeCall("post", `${API}/a2a/hello`, env);
180
+ state.lastHelloAt = nowIso();
181
+ state.lastHelloRaw = data;
182
+
183
+ if (data?.error) {
184
+ console.error("[evomap-lite] hello failed (continuing):", data);
185
+ state.bound = null;
186
+ return;
187
+ }
188
+
189
+ const claim_code = data?.claim_code || data?.payload?.claim_code || data?.data?.claim_code;
190
+ const claim_url = data?.claim_url || data?.payload?.claim_url || data?.data?.claim_url;
191
+
192
+ state.claim_code = claim_code || null;
193
+ state.claim_url = claim_url || null;
194
+ state.bound = claim_url ? false : true;
195
+
196
+ const maybeNodeId =
197
+ data?.your_node_id ||
198
+ data?.node_id ||
199
+ data?.payload?.your_node_id ||
200
+ data?.payload?.node_id;
201
+
202
+ if (maybeNodeId && typeof maybeNodeId === "string" && !process.env.EVOMAP_NODE_ID) {
203
+ try {
204
+ ensureDirForFile(NODE_FILE);
205
+ fs.writeFileSync(NODE_FILE, JSON.stringify({ sender_id: maybeNodeId, updated_at: nowIso() }, null, 2), "utf8");
206
+ state.sender_id = maybeNodeId;
207
+ console.error("[evomap-lite] node_id updated from hello response:", maybeNodeId);
208
+ } catch {
209
+ // ignore
210
+ }
211
+ }
25
212
  }
26
213
 
214
+ /* =========================
215
+ MCP SERVER
216
+ ========================= */
217
+ const { sender_id } = loadOrCreateNodeId();
218
+ state.sender_id = sender_id;
219
+
27
220
  const server = new McpServer({
28
221
  name: "evomap-lite",
29
- version: "0.1.0"
222
+ version: "0.3.0"
30
223
  });
31
224
 
32
- /* ========================
33
- TOOL 1: FETCH
34
- ======================== */
225
+ /* TOOL: account status (NEW) */
226
+ server.tool(
227
+ "evomap_account_status",
228
+ {
229
+ type: "object",
230
+ properties: {},
231
+ required: []
232
+ },
233
+ async () => {
234
+ if (state.bound === null) await helloAndUpdateBinding();
235
+
236
+ const notice = bindingNoticeIfNeeded();
237
+ const out = {
238
+ sender_id: state.sender_id,
239
+ bound: state.bound,
240
+ claim_code: state.claim_code,
241
+ claim_url: state.claim_url,
242
+ last_hello_at: state.lastHelloAt
243
+ };
244
+
245
+ return {
246
+ content: [
247
+ ...(notice ? [{ type: "text", text: notice }] : []),
248
+ { type: "text", text: clipJson(out) }
249
+ ]
250
+ };
251
+ }
252
+ );
253
+
254
+ /* TOOL 1: fetch (A2A envelope) */
35
255
  server.tool(
36
256
  "evomap_fetch",
37
257
  {
38
- signals: { type: "array", items: { type: "string" } },
39
- context: { type: "string" },
40
- limit: { type: "number", optional: true }
258
+ type: "object",
259
+ properties: {
260
+ signals: { type: "array", items: { type: "string" }, description: "keywords array" },
261
+ context: { type: "string", description: "short bug context", default: "" },
262
+ limit: { type: "number", description: "default 5, max 10", default: 5 }
263
+ },
264
+ required: ["signals"]
41
265
  },
42
266
  async ({ signals, context, limit }) => {
43
- const payload = {
44
- signals,
45
- context,
46
- limit: Math.min(Number(limit || 5), 10)
47
- };
267
+ await ensureHeartbeat();
48
268
 
49
- const data = await http("post", `${API}/a2a/fetch`, payload);
269
+ const env = makeEnvelope({
270
+ message_type: "fetch",
271
+ sender_id: state.sender_id,
272
+ payload: {
273
+ signals,
274
+ context: context || "",
275
+ limit: Math.min(Number(limit || 5), 10)
276
+ }
277
+ });
50
278
 
279
+ const data = await safeCall("post", `${API}/a2a/fetch`, env);
280
+
281
+ const notice = bindingNoticeIfNeeded();
51
282
  return {
52
- content: [{ type: "text", text: clipJson(data) }]
283
+ content: [
284
+ ...(notice ? [{ type: "text", text: notice }] : []),
285
+ { type: "text", text: clipJson(data) }
286
+ ]
53
287
  };
54
288
  }
55
289
  );
56
290
 
57
- /* ========================
58
- TOOL 2: ASSET DETAIL
59
- ======================== */
291
+ /* TOOL 2: asset detail (REST) */
60
292
  server.tool(
61
293
  "evomap_asset",
62
294
  {
63
- asset_id: { type: "string" }
295
+ type: "object",
296
+ properties: {
297
+ asset_id: { type: "string", description: "asset id" },
298
+ detail_level: { type: "string", enum: ["brief", "full"], default: "brief" }
299
+ },
300
+ required: ["asset_id"]
64
301
  },
65
- async ({ asset_id }) => {
66
- const data = await http("get", `${API}/a2a/assets/${asset_id}`);
302
+ async ({ asset_id, detail_level }) => {
303
+ const detailed = detail_level === "full" ? "true" : "false";
304
+ const data = await safeCall(
305
+ "get",
306
+ `${API}/a2a/assets/${encodeURIComponent(asset_id)}?detailed=${detailed}`
307
+ );
308
+
309
+ const notice = bindingNoticeIfNeeded();
67
310
  return {
68
- content: [{ type: "text", text: clipJson(data) }]
311
+ content: [
312
+ ...(notice ? [{ type: "text", text: notice }] : []),
313
+ { type: "text", text: clipJson(data) }
314
+ ]
69
315
  };
70
316
  }
71
317
  );
72
318
 
73
- /* ========================
74
- TOOL 3: VALIDATE
75
- ======================== */
319
+ /* TOOL 3: validate bundle (REST) */
76
320
  server.tool(
77
321
  "evomap_validate_bundle",
78
322
  {
79
- bundle: { type: "object" }
323
+ type: "object",
324
+ properties: {
325
+ bundle: { type: "object", description: "gene + capsule + evolution_event bundle object" }
326
+ },
327
+ required: ["bundle"]
80
328
  },
81
329
  async ({ bundle }) => {
82
- const data = await http("post", `${API}/a2a/validate`, bundle);
330
+ const data = await safeCall("post", `${API}/a2a/validate`, bundle);
331
+
332
+ const notice = bindingNoticeIfNeeded();
83
333
  return {
84
- content: [{ type: "text", text: clipJson(data) }]
334
+ content: [
335
+ ...(notice ? [{ type: "text", text: notice }] : []),
336
+ { type: "text", text: clipJson(data) }
337
+ ]
85
338
  };
86
339
  }
87
340
  );
88
341
 
89
- /* ========================
90
- 正确启动 MCP (stdio)
91
- ======================== */
342
+ /* START */
92
343
  const transport = new StdioServerTransport();
93
- await server.connect(transport);
344
+ await server.connect(transport);
345
+
346
+ // hello after connect
347
+ await helloAndUpdateBinding();
348
+ if (state.bound === false && state.claim_url) {
349
+ console.error("[evomap-lite] account not bound; claim_url:", state.claim_url);
350
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bsbofmusic/evomap-mcp-lite",
3
- "version": "0.1.0",
4
- "description": "Lite MCP server for EvoMap asset reuse (bugfix oriented)",
3
+ "version": "0.3.0",
4
+ "description": "EvoMap MCP Lite (Plus) - compliant A2A + stable connectivity + binding check",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "evomap-mcp-lite": "./index.js"