@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.
- package/index.js +291 -34
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
222
|
+
version: "0.3.0"
|
|
30
223
|
});
|
|
31
224
|
|
|
32
|
-
/*
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
signals,
|
|
45
|
-
context,
|
|
46
|
-
limit: Math.min(Number(limit || 5), 10)
|
|
47
|
-
};
|
|
267
|
+
await ensureHeartbeat();
|
|
48
268
|
|
|
49
|
-
const
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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: [
|
|
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
|
-
|
|
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
|
|
330
|
+
const data = await safeCall("post", `${API}/a2a/validate`, bundle);
|
|
331
|
+
|
|
332
|
+
const notice = bindingNoticeIfNeeded();
|
|
83
333
|
return {
|
|
84
|
-
content: [
|
|
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.
|
|
4
|
-
"description": "
|
|
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"
|