@mnemoverse/mcp-memory-server 0.3.0 → 0.3.2

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/src/index.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createRequire } from "node:module";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
6
  import { z } from "zod";
6
7
 
8
+ // Version is read at runtime from package.json so there is exactly one place
9
+ // to bump on each release. Works both from `dist/` during local dev and from
10
+ // `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
11
+ const require = createRequire(import.meta.url);
12
+ const pkg = require("../package.json") as { version: string };
13
+
7
14
  const API_URL =
8
15
  process.env.MNEMOVERSE_API_URL || "https://core.mnemoverse.com/api/v1";
9
16
  const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
@@ -21,10 +28,23 @@ if (!API_KEY) {
21
28
  process.exit(1);
22
29
  }
23
30
 
24
- async function apiFetch(
31
+ /**
32
+ * Fetch from the Mnemoverse core API with authentication.
33
+ *
34
+ * Generic so call sites can declare the expected response shape:
35
+ *
36
+ * const r = await apiFetch<{ stored: boolean; atom_id: string }>("/memory/write", { ... });
37
+ *
38
+ * Handles 204 No Content and empty bodies defensively — FastAPI DELETE
39
+ * handlers may switch to 204 in the future even though today they return
40
+ * a JSON body.
41
+ *
42
+ * @throws Error with message `Mnemoverse API error {status}: {body}` on non-2xx.
43
+ */
44
+ async function apiFetch<T = unknown>(
25
45
  path: string,
26
46
  options: RequestInit = {},
27
- ): Promise<unknown> {
47
+ ): Promise<T> {
28
48
  const res = await fetch(`${API_URL}${path}`, {
29
49
  ...options,
30
50
  headers: {
@@ -39,16 +59,31 @@ async function apiFetch(
39
59
  throw new Error(`Mnemoverse API error ${res.status}: ${text}`);
40
60
  }
41
61
 
42
- return res.json();
62
+ // 204 No Content or empty body — return an empty object cast as T so
63
+ // call sites using optional chaining still work without crashing.
64
+ if (res.status === 204 || res.headers.get("content-length") === "0") {
65
+ return {} as T;
66
+ }
67
+
68
+ return (await res.json()) as T;
43
69
  }
44
70
 
45
71
  /**
46
72
  * Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
47
73
  * Required by Claude Connectors Directory submission policy.
74
+ *
75
+ * Defensive against splitting UTF-16 surrogate pairs: if the character right
76
+ * before the cut point is a high surrogate (U+D800–U+DBFF), drop it so the
77
+ * result stays well-formed. Otherwise an emoji or non-BMP character at the
78
+ * boundary can produce a lone surrogate and corrupt downstream JSON encoding.
48
79
  */
49
80
  function capResult(text: string): string {
50
81
  if (text.length <= MAX_RESULT_CHARS) return text;
51
- const truncated = text.slice(0, MAX_RESULT_CHARS - 200);
82
+ let truncated = text.slice(0, MAX_RESULT_CHARS - 200);
83
+ const lastCode = truncated.charCodeAt(truncated.length - 1);
84
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
85
+ truncated = truncated.slice(0, -1);
86
+ }
52
87
  return (
53
88
  truncated +
54
89
  `\n\n[…truncated to fit 25K token limit. Use a more specific query or smaller top_k to see all results.]`
@@ -59,7 +94,7 @@ function capResult(text: string): string {
59
94
 
60
95
  const server = new McpServer({
61
96
  name: "mnemoverse-memory",
62
- version: "0.3.0",
97
+ version: pkg.version,
63
98
  });
64
99
 
65
100
  // --- Tool: memory_write ---
@@ -97,7 +132,12 @@ server.registerTool(
97
132
  },
98
133
  },
99
134
  async ({ content, concepts, domain }) => {
100
- const result = await apiFetch("/memory/write", {
135
+ const r = await apiFetch<{
136
+ stored?: boolean;
137
+ atom_id?: string | null;
138
+ importance?: number;
139
+ reason?: string;
140
+ }>("/memory/write", {
101
141
  method: "POST",
102
142
  body: JSON.stringify({
103
143
  content,
@@ -106,19 +146,14 @@ server.registerTool(
106
146
  }),
107
147
  });
108
148
 
109
- const r = result as {
110
- stored: boolean;
111
- atom_id: string | null;
112
- importance: number;
113
- reason: string;
114
- };
149
+ const importance = (r?.importance ?? 0).toFixed(2);
115
150
 
116
- if (r.stored) {
151
+ if (r?.stored) {
117
152
  return {
118
153
  content: [
119
154
  {
120
155
  type: "text" as const,
121
- text: `Stored (importance: ${r.importance.toFixed(2)}). ID: ${r.atom_id}`,
156
+ text: `Stored (importance: ${importance}). ID: ${r.atom_id ?? "unknown"}`,
122
157
  },
123
158
  ],
124
159
  };
@@ -127,7 +162,7 @@ server.registerTool(
127
162
  content: [
128
163
  {
129
164
  type: "text" as const,
130
- text: `Filtered — ${r.reason} (importance: ${r.importance.toFixed(2)})`,
165
+ text: `Filtered — ${r?.reason ?? "unknown reason"} (importance: ${importance})`,
131
166
  },
132
167
  ],
133
168
  };
@@ -168,7 +203,15 @@ server.registerTool(
168
203
  },
169
204
  },
170
205
  async ({ query, top_k, domain }) => {
171
- const result = await apiFetch("/memory/read", {
206
+ const r = await apiFetch<{
207
+ items?: Array<{
208
+ content?: string;
209
+ relevance?: number;
210
+ concepts?: string[];
211
+ domain?: string;
212
+ }>;
213
+ search_time_ms?: number;
214
+ }>("/memory/read", {
172
215
  method: "POST",
173
216
  body: JSON.stringify({
174
217
  query,
@@ -178,17 +221,9 @@ server.registerTool(
178
221
  }),
179
222
  });
180
223
 
181
- const r = result as {
182
- items: Array<{
183
- content: string;
184
- relevance: number;
185
- concepts: string[];
186
- domain: string;
187
- }>;
188
- search_time_ms: number;
189
- };
224
+ const items = Array.isArray(r?.items) ? r.items : [];
190
225
 
191
- if (r.items.length === 0) {
226
+ if (items.length === 0) {
192
227
  return {
193
228
  content: [
194
229
  { type: "text" as const, text: "No memories found for this query." },
@@ -196,15 +231,17 @@ server.registerTool(
196
231
  };
197
232
  }
198
233
 
199
- const lines = r.items.map(
200
- (item, i) =>
201
- `${i + 1}. [${(item.relevance * 100).toFixed(0)}%] ${item.content}` +
202
- (item.concepts.length > 0
203
- ? ` (${item.concepts.join(", ")})`
204
- : ""),
205
- );
234
+ const lines = items.map((item, i) => {
235
+ const relevance = ((item?.relevance ?? 0) * 100).toFixed(0);
236
+ const content = item?.content ?? "(empty)";
237
+ const concepts = Array.isArray(item?.concepts) && item.concepts.length > 0
238
+ ? ` (${item.concepts.join(", ")})`
239
+ : "";
240
+ return `${i + 1}. [${relevance}%] ${content}${concepts}`;
241
+ });
206
242
 
207
- const text = lines.join("\n\n") + `\n\n(${r.search_time_ms.toFixed(0)}ms)`;
243
+ const searchMs = (r?.search_time_ms ?? 0).toFixed(0);
244
+ const text = lines.join("\n\n") + `\n\n(${searchMs}ms)`;
208
245
 
209
246
  return {
210
247
  content: [
@@ -238,24 +275,28 @@ server.registerTool(
238
275
  annotations: {
239
276
  title: "Rate Memory Helpfulness",
240
277
  readOnlyHint: false,
241
- destructiveHint: false,
278
+ // Feedback permanently mutates the memory's valence and importance
279
+ // scores on the backend — per MCP spec, that is a destructive update
280
+ // to the stored state (cf. ToolAnnotations.destructiveHint), even
281
+ // though the caller intends it as quality signal rather than delete.
282
+ destructiveHint: true,
242
283
  idempotentHint: false,
243
284
  openWorldHint: true,
244
285
  },
245
286
  },
246
287
  async ({ atom_ids, outcome }) => {
247
- const result = await apiFetch("/memory/feedback", {
288
+ const r = await apiFetch<{ updated_count?: number }>("/memory/feedback", {
248
289
  method: "POST",
249
290
  body: JSON.stringify({ atom_ids, outcome }),
250
291
  });
251
292
 
252
- const r = result as { updated_count: number };
293
+ const count = r?.updated_count ?? 0;
253
294
 
254
295
  return {
255
296
  content: [
256
297
  {
257
298
  type: "text" as const,
258
- text: `Feedback recorded for ${r.updated_count} memor${r.updated_count === 1 ? "y" : "ies"}.`,
299
+ text: `Feedback recorded for ${count} memor${count === 1 ? "y" : "ies"}.`,
259
300
  },
260
301
  ],
261
302
  };
@@ -279,23 +320,25 @@ server.registerTool(
279
320
  },
280
321
  },
281
322
  async () => {
282
- const result = await apiFetch("/memory/stats");
283
-
284
- const r = result as {
285
- total_atoms: number;
286
- episodes: number;
287
- prototypes: number;
288
- hebbian_edges: number;
289
- domains: string[];
290
- avg_valence: number;
291
- avg_importance: number;
292
- };
323
+ const r = await apiFetch<{
324
+ total_atoms?: number;
325
+ episodes?: number;
326
+ prototypes?: number;
327
+ hebbian_edges?: number;
328
+ domains?: string[];
329
+ avg_valence?: number;
330
+ avg_importance?: number;
331
+ }>("/memory/stats");
332
+
333
+ const domains = Array.isArray(r?.domains) && r.domains.length > 0
334
+ ? r.domains.join(", ")
335
+ : "general";
293
336
 
294
337
  const text = [
295
- `Memories: ${r.total_atoms} (${r.episodes} episodes, ${r.prototypes} prototypes)`,
296
- `Associations: ${r.hebbian_edges} Hebbian edges`,
297
- `Domains: ${r.domains.length > 0 ? r.domains.join(", ") : "general"}`,
298
- `Avg quality: valence ${r.avg_valence.toFixed(2)}, importance ${r.avg_importance.toFixed(2)}`,
338
+ `Memories: ${r?.total_atoms ?? 0} (${r?.episodes ?? 0} episodes, ${r?.prototypes ?? 0} prototypes)`,
339
+ `Associations: ${r?.hebbian_edges ?? 0} Hebbian edges`,
340
+ `Domains: ${domains}`,
341
+ `Avg quality: valence ${(r?.avg_valence ?? 0).toFixed(2)}, importance ${(r?.avg_importance ?? 0).toFixed(2)}`,
299
342
  ].join("\n");
300
343
 
301
344
  return { content: [{ type: "text" as const, text }] };
@@ -326,15 +369,15 @@ server.registerTool(
326
369
  },
327
370
  },
328
371
  async ({ atom_id }) => {
329
- const result = await apiFetch(`/memory/atoms/${encodeURIComponent(atom_id)}`, {
330
- method: "DELETE",
331
- });
332
-
333
372
  // Core API returns { deleted: <count>, atom_id }. count == 0 means
334
- // the atom didn't exist (or was already removed). count >= 1 means it was deleted.
335
- const r = result as { deleted: number; atom_id?: string };
373
+ // the atom didn't exist (or was already removed). count >= 1 means
374
+ // it was deleted.
375
+ const r = await apiFetch<{ deleted?: number; atom_id?: string }>(
376
+ `/memory/atoms/${encodeURIComponent(atom_id)}`,
377
+ { method: "DELETE" },
378
+ );
336
379
 
337
- if (!r.deleted) {
380
+ if (!r?.deleted) {
338
381
  return {
339
382
  content: [
340
383
  {
@@ -385,24 +428,23 @@ server.registerTool(
385
428
  openWorldHint: true,
386
429
  },
387
430
  },
388
- async ({ domain, confirm }) => {
389
- if (confirm !== true) {
390
- throw new Error(
391
- "memory_delete_domain requires confirm=true as a safety interlock.",
392
- );
393
- }
394
-
395
- const result = await apiFetch(`/memory/domain/${encodeURIComponent(domain)}`, {
396
- method: "DELETE",
397
- });
431
+ // The `confirm: z.literal(true)` in the input schema is the safety
432
+ // interlock — Zod rejects any call without confirm === true before it
433
+ // reaches this handler, so no runtime re-check is needed here.
434
+ async ({ domain }) => {
435
+ const r = await apiFetch<{ deleted?: number; domain?: string }>(
436
+ `/memory/domain/${encodeURIComponent(domain)}`,
437
+ { method: "DELETE" },
438
+ );
398
439
 
399
- const r = result as { deleted: number; domain: string };
440
+ const count = r?.deleted ?? 0;
441
+ const domainName = r?.domain ?? domain;
400
442
 
401
443
  return {
402
444
  content: [
403
445
  {
404
446
  type: "text" as const,
405
- text: `Deleted ${r.deleted} ${r.deleted === 1 ? "memory" : "memories"} from domain "${r.domain}".`,
447
+ text: `Deleted ${count} ${count === 1 ? "memory" : "memories"} from domain "${domainName}".`,
406
448
  },
407
449
  ],
408
450
  };