@remnic/core 9.7.6 → 9.7.7

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 (40) hide show
  1. package/dist/access-boundary.d.ts +2 -0
  2. package/dist/access-boundary.js +1 -1
  3. package/dist/access-cli.js +7 -7
  4. package/dist/access-http.js +5 -5
  5. package/dist/access-mcp.d.ts +2 -0
  6. package/dist/access-mcp.js +4 -4
  7. package/dist/access-operations-batch.js +2 -2
  8. package/dist/access-operations.js +3 -3
  9. package/dist/{chunk-TBQ4CFIP.js → chunk-57INMZ6F.js} +1 -1
  10. package/dist/chunk-57INMZ6F.js.map +1 -0
  11. package/dist/{chunk-BFCQPJ5B.js → chunk-CHVU4RE5.js} +24 -6
  12. package/dist/chunk-CHVU4RE5.js.map +1 -0
  13. package/dist/{chunk-3CRHW42H.js → chunk-FYKIEOG6.js} +2 -2
  14. package/dist/{chunk-ESE55PZJ.js → chunk-NMMKRVUF.js} +10 -6
  15. package/dist/chunk-NMMKRVUF.js.map +1 -0
  16. package/dist/{chunk-3UPBVNBX.js → chunk-Q6JPMCPO.js} +3 -3
  17. package/dist/{chunk-P2UA6XQG.js → chunk-UHGHTOR5.js} +5 -5
  18. package/dist/chunk-UHGHTOR5.js.map +1 -0
  19. package/dist/{chunk-H67QFYUK.js → chunk-UJBESW6X.js} +918 -146
  20. package/dist/chunk-UJBESW6X.js.map +1 -0
  21. package/dist/cli.js +6 -6
  22. package/dist/connectors/index.d.ts +7 -0
  23. package/dist/connectors/index.js +1 -1
  24. package/dist/index.js +7 -7
  25. package/dist/orchestrator.js +7 -7
  26. package/package.json +2 -2
  27. package/src/access-boundary.ts +2 -0
  28. package/src/access-http.ts +15 -2
  29. package/src/access-mcp-cancellation.test.ts +405 -0
  30. package/src/access-mcp.ts +25 -1
  31. package/src/access-operations-batch.ts +3 -3
  32. package/src/connectors/hermes-shim.ts +523 -0
  33. package/src/connectors/index.ts +769 -15
  34. package/dist/chunk-BFCQPJ5B.js.map +0 -1
  35. package/dist/chunk-ESE55PZJ.js.map +0 -1
  36. package/dist/chunk-H67QFYUK.js.map +0 -1
  37. package/dist/chunk-P2UA6XQG.js.map +0 -1
  38. package/dist/chunk-TBQ4CFIP.js.map +0 -1
  39. /package/dist/{chunk-3CRHW42H.js.map → chunk-FYKIEOG6.js.map} +0 -0
  40. /package/dist/{chunk-3UPBVNBX.js.map → chunk-Q6JPMCPO.js.map} +0 -0
package/dist/cli.js CHANGED
@@ -90,7 +90,7 @@ import {
90
90
  runWorkProductStatusCliCommand,
91
91
  runWorkProjectCliCommand,
92
92
  runWorkTaskCliCommand
93
- } from "./chunk-3UPBVNBX.js";
93
+ } from "./chunk-Q6JPMCPO.js";
94
94
  import "./chunk-MMACM5G2.js";
95
95
  import "./chunk-54BGIBCR.js";
96
96
  import "./chunk-H4CKS5B2.js";
@@ -169,7 +169,7 @@ import "./chunk-G6NKALRN.js";
169
169
  import "./chunk-OO42R444.js";
170
170
  import "./chunk-K6MV3TRQ.js";
171
171
  import "./chunk-OADWQ5CR.js";
172
- import "./chunk-ESE55PZJ.js";
172
+ import "./chunk-NMMKRVUF.js";
173
173
  import "./chunk-V7IZKJI3.js";
174
174
  import "./chunk-SEDEKFYQ.js";
175
175
  import "./chunk-RKNJBZ55.js";
@@ -178,13 +178,13 @@ import "./chunk-RSUYKGGZ.js";
178
178
  import "./chunk-42NQ7AVG.js";
179
179
  import "./chunk-TMSXWOBZ.js";
180
180
  import "./chunk-7RXCMVFQ.js";
181
- import "./chunk-BFCQPJ5B.js";
181
+ import "./chunk-CHVU4RE5.js";
182
182
  import "./chunk-IC2TCZJ2.js";
183
183
  import "./chunk-D24OXEPB.js";
184
184
  import "./chunk-5RUE3GHQ.js";
185
- import "./chunk-3CRHW42H.js";
185
+ import "./chunk-FYKIEOG6.js";
186
186
  import "./chunk-2MR3MFQB.js";
187
- import "./chunk-P2UA6XQG.js";
187
+ import "./chunk-UHGHTOR5.js";
188
188
  import "./chunk-HLTAPHZZ.js";
189
189
  import "./chunk-YF6FMLBT.js";
190
190
  import "./chunk-W4PGTAS2.js";
@@ -192,7 +192,7 @@ import "./chunk-KQDNAXOM.js";
192
192
  import "./chunk-35NN3ZFA.js";
193
193
  import "./chunk-FTYDWKO6.js";
194
194
  import "./chunk-T4WDJPEZ.js";
195
- import "./chunk-TBQ4CFIP.js";
195
+ import "./chunk-57INMZ6F.js";
196
196
  import "./chunk-D7FOKIY5.js";
197
197
  import "./chunk-CNWC274T.js";
198
198
  import "./chunk-GDASG7NC.js";
@@ -319,9 +319,16 @@ declare function upsertHermesConfig(opts: {
319
319
  /**
320
320
  * Remove the `remnic:` block from a Hermes profile config.yaml.
321
321
  * Idempotent — if the block is absent, returns skipped.
322
+ *
323
+ * `extraConfigPaths` carries the config.yaml location persisted in the
324
+ * connector JSON at install time, so a HERMES_HOME change between install and
325
+ * remove cannot leave the old home's remnic: block (with its daemon token)
326
+ * behind (Codex P2 on PR #1938, round 6). Extra candidates are shape-guarded
327
+ * and de-duplicated against the environment-resolved paths.
322
328
  */
323
329
  declare function removeHermesConfig(opts: {
324
330
  profile: string;
331
+ extraConfigPaths?: readonly string[];
325
332
  }): HermesConfigResult;
326
333
  declare function doctorConnector(connectorId: string): Promise<DoctorResult>;
327
334
  interface CodexMemoryExtensionPaths {
@@ -22,7 +22,7 @@ import {
22
22
  upsertHermesConfig,
23
23
  validateMarketplaceManifest,
24
24
  writeMarketplaceManifest
25
- } from "../chunk-H67QFYUK.js";
25
+ } from "../chunk-UJBESW6X.js";
26
26
  import "../chunk-4OKLXWDZ.js";
27
27
  import {
28
28
  runCodexMaterialize
package/dist/index.js CHANGED
@@ -185,7 +185,7 @@ import {
185
185
  saveRegistry,
186
186
  validateMarketplaceManifest,
187
187
  writeMarketplaceManifest
188
- } from "./chunk-H67QFYUK.js";
188
+ } from "./chunk-UJBESW6X.js";
189
189
  import "./chunk-V5OCT34X.js";
190
190
  import "./chunk-TECVW3JP.js";
191
191
  import "./chunk-LXOM6IQU.js";
@@ -348,7 +348,7 @@ import {
348
348
  registerTrainingExportAdapter,
349
349
  runBulkImportCliCommand,
350
350
  runWearablesCliCommand
351
- } from "./chunk-3UPBVNBX.js";
351
+ } from "./chunk-Q6JPMCPO.js";
352
352
  import "./chunk-MMACM5G2.js";
353
353
  import "./chunk-54BGIBCR.js";
354
354
  import "./chunk-H4CKS5B2.js";
@@ -551,7 +551,7 @@ import "./chunk-K6MV3TRQ.js";
551
551
  import "./chunk-OADWQ5CR.js";
552
552
  import {
553
553
  EngramAccessHttpServer
554
- } from "./chunk-ESE55PZJ.js";
554
+ } from "./chunk-NMMKRVUF.js";
555
555
  import {
556
556
  WearablesInputError,
557
557
  describeErrorForOperator
@@ -565,7 +565,7 @@ import "./chunk-TMSXWOBZ.js";
565
565
  import "./chunk-7RXCMVFQ.js";
566
566
  import {
567
567
  EngramMcpServer
568
- } from "./chunk-BFCQPJ5B.js";
568
+ } from "./chunk-CHVU4RE5.js";
569
569
  import "./chunk-IC2TCZJ2.js";
570
570
  import {
571
571
  buildCitationGuidance,
@@ -574,7 +574,7 @@ import {
574
574
  sanitizeNoteForCitation
575
575
  } from "./chunk-D24OXEPB.js";
576
576
  import "./chunk-5RUE3GHQ.js";
577
- import "./chunk-3CRHW42H.js";
577
+ import "./chunk-FYKIEOG6.js";
578
578
  import {
579
579
  formatZodError,
580
580
  memoryStoreRequestSchema,
@@ -583,7 +583,7 @@ import {
583
583
  suggestionSubmitRequestSchema,
584
584
  validateRequest
585
585
  } from "./chunk-2MR3MFQB.js";
586
- import "./chunk-P2UA6XQG.js";
586
+ import "./chunk-UHGHTOR5.js";
587
587
  import "./chunk-HLTAPHZZ.js";
588
588
  import "./chunk-YF6FMLBT.js";
589
589
  import {
@@ -616,7 +616,7 @@ import {
616
616
  } from "./chunk-T4WDJPEZ.js";
617
617
  import {
618
618
  OPERATION_NAMES
619
- } from "./chunk-TBQ4CFIP.js";
619
+ } from "./chunk-57INMZ6F.js";
620
620
  import {
621
621
  TOKEN_CAPABILITIES_VERSION,
622
622
  capabilityAllowsNamespace,
@@ -67,7 +67,7 @@ import "./chunk-O7BBFJLP.js";
67
67
  import "./chunk-7N4KAIGN.js";
68
68
  import "./chunk-AD2KK5MT.js";
69
69
  import "./chunk-5CJVLG3H.js";
70
- import "./chunk-H67QFYUK.js";
70
+ import "./chunk-UJBESW6X.js";
71
71
  import "./chunk-V5OCT34X.js";
72
72
  import "./chunk-TECVW3JP.js";
73
73
  import "./chunk-LXOM6IQU.js";
@@ -126,7 +126,7 @@ import "./chunk-Q6IP2ARR.js";
126
126
  import "./chunk-6HEM6HTQ.js";
127
127
  import "./chunk-ZQ7UBUZ6.js";
128
128
  import "./chunk-HXDKC3BI.js";
129
- import "./chunk-3UPBVNBX.js";
129
+ import "./chunk-Q6JPMCPO.js";
130
130
  import "./chunk-MMACM5G2.js";
131
131
  import "./chunk-54BGIBCR.js";
132
132
  import "./chunk-H4CKS5B2.js";
@@ -218,7 +218,7 @@ import "./chunk-G6NKALRN.js";
218
218
  import "./chunk-OO42R444.js";
219
219
  import "./chunk-K6MV3TRQ.js";
220
220
  import "./chunk-OADWQ5CR.js";
221
- import "./chunk-ESE55PZJ.js";
221
+ import "./chunk-NMMKRVUF.js";
222
222
  import "./chunk-V7IZKJI3.js";
223
223
  import "./chunk-SEDEKFYQ.js";
224
224
  import "./chunk-RKNJBZ55.js";
@@ -227,13 +227,13 @@ import "./chunk-RSUYKGGZ.js";
227
227
  import "./chunk-42NQ7AVG.js";
228
228
  import "./chunk-TMSXWOBZ.js";
229
229
  import "./chunk-7RXCMVFQ.js";
230
- import "./chunk-BFCQPJ5B.js";
230
+ import "./chunk-CHVU4RE5.js";
231
231
  import "./chunk-IC2TCZJ2.js";
232
232
  import "./chunk-D24OXEPB.js";
233
233
  import "./chunk-5RUE3GHQ.js";
234
- import "./chunk-3CRHW42H.js";
234
+ import "./chunk-FYKIEOG6.js";
235
235
  import "./chunk-2MR3MFQB.js";
236
- import "./chunk-P2UA6XQG.js";
236
+ import "./chunk-UHGHTOR5.js";
237
237
  import "./chunk-HLTAPHZZ.js";
238
238
  import "./chunk-YF6FMLBT.js";
239
239
  import "./chunk-W4PGTAS2.js";
@@ -241,7 +241,7 @@ import "./chunk-KQDNAXOM.js";
241
241
  import "./chunk-35NN3ZFA.js";
242
242
  import "./chunk-FTYDWKO6.js";
243
243
  import "./chunk-T4WDJPEZ.js";
244
- import "./chunk-TBQ4CFIP.js";
244
+ import "./chunk-57INMZ6F.js";
245
245
  import "./chunk-D7FOKIY5.js";
246
246
  import "./chunk-CNWC274T.js";
247
247
  import "./chunk-GDASG7NC.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.7.6",
3
+ "version": "9.7.7",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3000,7 +3000,7 @@
3000
3000
  "core"
3001
3001
  ],
3002
3002
  "peerDependencies": {
3003
- "@remnic/coding-graph": "^9.7.6"
3003
+ "@remnic/coding-graph": "^9.7.7"
3004
3004
  },
3005
3005
  "peerDependenciesMeta": {
3006
3006
  "@remnic/coding-graph": {
@@ -193,6 +193,8 @@ export interface OperationContext {
193
193
  readonly service: EngramAccessService;
194
194
  readonly authenticatedPrincipal?: string;
195
195
  readonly operatorPrincipal?: string;
196
+ /** Per-request cancellation supplied by transports that own a request lifecycle. */
197
+ readonly abortSignal?: AbortSignal;
196
198
  /** Server-resolved connector identity (Phase 1 provenance). Set by the HTTP auth boundary from the matched token's connector; flows to write handlers so frontmatter records which connector submitted the memory. Client-supplied values are always overridden. */
197
199
  readonly sourceConnector?: string;
198
200
  readonly hooks?: OperationHooks;
@@ -854,7 +854,7 @@ export class EngramAccessHttpServer {
854
854
  }
855
855
 
856
856
  if (req.method === "POST" && pathname === "/mcp") {
857
- await this.handleMcpRequest(req, res);
857
+ await this.handleMcpRequest(req, res, abortSignal);
858
858
  return;
859
859
  }
860
860
 
@@ -2982,7 +2982,11 @@ export class EngramAccessHttpServer {
2982
2982
  req.once("error", cleanup);
2983
2983
  }
2984
2984
 
2985
- private async handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
2985
+ private async handleMcpRequest(
2986
+ req: IncomingMessage,
2987
+ res: ServerResponse,
2988
+ abortSignal: AbortSignal,
2989
+ ): Promise<void> {
2986
2990
  // Reject requests that advertise an unknown MCP protocol version in
2987
2991
  // the streamable-HTTP `MCP-Protocol-Version` header. Absent or
2988
2992
  // valid → proceed. Unknown → 400 with a JSON-RPC-shaped error so
@@ -3143,6 +3147,7 @@ export class EngramAccessHttpServer {
3143
3147
  ? () => this.ensureWriteRateLimitAvailable()
3144
3148
  : undefined,
3145
3149
  sourceConnector: this.resolveConnector(req),
3150
+ abortSignal,
3146
3151
  });
3147
3152
 
3148
3153
  if (isMcpWrite && response !== null) {
@@ -3166,6 +3171,14 @@ export class EngramAccessHttpServer {
3166
3171
  this.recordWriteRateLimitHit();
3167
3172
  }
3168
3173
  }
3174
+ // A mutating tool may have committed just before the client disconnected.
3175
+ // Record that side effect above, then honor cancellation before emitting
3176
+ // any response. Read-only calls reach this guard without accounting.
3177
+ if (abortSignal.aborted) {
3178
+ throw isAbortError(abortSignal.reason)
3179
+ ? abortSignal.reason
3180
+ : abortError("HTTP client disconnected");
3181
+ }
3169
3182
  if (response === null) {
3170
3183
  res.statusCode = 202;
3171
3184
  res.end();
@@ -0,0 +1,405 @@
1
+ import assert from "node:assert/strict";
2
+ import { request as httpRequest } from "node:http";
3
+ import test from "node:test";
4
+
5
+ import { abortError } from "./abort-error.js";
6
+ import { EngramAccessHttpServer } from "./access-http.js";
7
+ import { EngramMcpServer } from "./access-mcp.js";
8
+ import { EngramAccessService, type EngramAccessRecallRequest } from "./access-service.js";
9
+
10
+ function deferred<T = void>(): {
11
+ promise: Promise<T>;
12
+ resolve: (value: T | PromiseLike<T>) => void;
13
+ reject: (reason?: unknown) => void;
14
+ } {
15
+ let resolve!: (value: T | PromiseLike<T>) => void;
16
+ let reject!: (reason?: unknown) => void;
17
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
18
+ resolve = resolvePromise;
19
+ reject = rejectPromise;
20
+ });
21
+ return { promise, resolve, reject };
22
+ }
23
+
24
+ function waitFor<T>(promise: Promise<T>, timeoutMs = 1_000): Promise<T> {
25
+ return Promise.race([
26
+ promise,
27
+ new Promise<never>((_resolve, reject) => {
28
+ const timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
29
+ timer.unref?.();
30
+ }),
31
+ ]);
32
+ }
33
+
34
+ function mcpBody(
35
+ name: string,
36
+ args: Record<string, unknown> = { query: "slow recall" },
37
+ ): string {
38
+ return JSON.stringify({
39
+ jsonrpc: "2.0",
40
+ id: 1,
41
+ method: "tools/call",
42
+ params: { name, arguments: args },
43
+ });
44
+ }
45
+
46
+ function sendMcp(port: number, name: string): {
47
+ client: ReturnType<typeof httpRequest>;
48
+ response: Promise<string>;
49
+ } {
50
+ const response = deferred<string>();
51
+ const client = httpRequest({
52
+ host: "127.0.0.1",
53
+ port,
54
+ path: "/mcp",
55
+ method: "POST",
56
+ headers: {
57
+ authorization: "Bearer test-token",
58
+ "content-type": "application/json",
59
+ },
60
+ }, (res) => {
61
+ const chunks: Buffer[] = [];
62
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
63
+ res.on("end", () => response.resolve(Buffer.concat(chunks).toString("utf8")));
64
+ res.on("error", response.reject);
65
+ });
66
+ client.on("error", (error) => response.reject(error));
67
+ client.end(mcpBody(name));
68
+ return { client, response: response.promise };
69
+ }
70
+
71
+ test("MCP recall aborts in-flight work without writing a JSON-RPC error after disconnect", async () => {
72
+ const recallStarted = deferred<void>();
73
+ const recallSettled = deferred<void>();
74
+ const forceRelease = deferred<void>();
75
+ let observedSignal: AbortSignal | undefined;
76
+ let responseStarted = false;
77
+ const service = {
78
+ recall: async (input: EngramAccessRecallRequest) => {
79
+ observedSignal = input.abortSignal;
80
+ recallStarted.resolve();
81
+ try {
82
+ await Promise.race([
83
+ forceRelease.promise,
84
+ new Promise<void>((_resolve, reject) => {
85
+ input.abortSignal?.addEventListener("abort", () => reject(input.abortSignal?.reason), { once: true });
86
+ }),
87
+ ]);
88
+ } finally {
89
+ recallSettled.resolve();
90
+ }
91
+ return {} as never;
92
+ },
93
+ } as unknown as EngramAccessService;
94
+ const server = new EngramAccessHttpServer({
95
+ service,
96
+ port: 0,
97
+ authToken: "test-token",
98
+ adminConsoleEnabled: false,
99
+ });
100
+ const status = await server.start();
101
+
102
+ try {
103
+ const client = httpRequest({
104
+ host: "127.0.0.1",
105
+ port: status.port,
106
+ path: "/mcp",
107
+ method: "POST",
108
+ headers: {
109
+ authorization: "Bearer test-token",
110
+ "content-type": "application/json",
111
+ },
112
+ }, (res) => {
113
+ responseStarted = true;
114
+ res.resume();
115
+ });
116
+ client.on("error", () => {});
117
+ client.end(mcpBody("remnic.recall"));
118
+
119
+ await waitFor(recallStarted.promise);
120
+ assert.ok(observedSignal, "the HTTP request signal must reach the MCP recall service");
121
+ client.destroy();
122
+
123
+ await waitFor(recallSettled.promise);
124
+ await new Promise<void>((resolve) => setImmediate(resolve));
125
+ assert.equal(observedSignal.aborted, true);
126
+ assert.equal(observedSignal.reason?.name, "AbortError");
127
+ assert.equal(responseStarted, false, "a disconnected client must not receive a JSON-RPC error response");
128
+ } finally {
129
+ forceRelease.resolve();
130
+ await server.stop();
131
+ }
132
+ });
133
+
134
+ test("MCP recall_xray forwards the signal and preserves the original AbortError", async () => {
135
+ const controller = new AbortController();
136
+ const reason = abortError("caller disconnected");
137
+ let observedSignal: AbortSignal | undefined;
138
+ const service = {
139
+ recallXray: async (input: { abortSignal?: AbortSignal }) => {
140
+ observedSignal = input.abortSignal;
141
+ controller.abort(reason);
142
+ return { snapshotFound: false };
143
+ },
144
+ } as unknown as EngramAccessService;
145
+ const mcp = new EngramMcpServer(service);
146
+
147
+ await assert.rejects(
148
+ mcp.handleRequest(
149
+ {
150
+ jsonrpc: "2.0",
151
+ id: 1,
152
+ method: "tools/call",
153
+ params: { name: "remnic.recall_xray", arguments: { query: "why" } },
154
+ },
155
+ { abortSignal: controller.signal },
156
+ ),
157
+ (error: unknown) => error === reason,
158
+ );
159
+ assert.equal(observedSignal, controller.signal);
160
+ });
161
+
162
+ test("MCP memory inspector forwards cancellation to its full recall", async () => {
163
+ const controller = new AbortController();
164
+ const reason = abortError("inspector caller disconnected");
165
+ let observedSignal: AbortSignal | undefined;
166
+ let actionConfidenceStarted = false;
167
+ const service = {
168
+ recallXray: async (input: { abortSignal?: AbortSignal }) => {
169
+ observedSignal = input.abortSignal;
170
+ controller.abort(reason);
171
+ throw input.abortSignal?.reason;
172
+ },
173
+ actionConfidence: async () => {
174
+ actionConfidenceStarted = true;
175
+ return {} as never;
176
+ },
177
+ } as unknown as EngramAccessService;
178
+ const mcp = new EngramMcpServer(service);
179
+
180
+ await assert.rejects(
181
+ mcp.handleRequest(
182
+ {
183
+ jsonrpc: "2.0",
184
+ id: 1,
185
+ method: "tools/call",
186
+ params: {
187
+ name: "remnic.chatgpt_memory_inspector",
188
+ arguments: { query: "inspect this" },
189
+ },
190
+ },
191
+ { abortSignal: controller.signal },
192
+ ),
193
+ (error: unknown) => error === reason,
194
+ );
195
+ assert.equal(observedSignal, controller.signal);
196
+ assert.equal(actionConfidenceStarted, false);
197
+ });
198
+
199
+ test("standalone MCP calls leave recall cancellation undefined", async () => {
200
+ let observedSignal: AbortSignal | undefined;
201
+ const service = {
202
+ recall: async (input: EngramAccessRecallRequest) => {
203
+ observedSignal = input.abortSignal;
204
+ return {} as never;
205
+ },
206
+ } as unknown as EngramAccessService;
207
+ const mcp = new EngramMcpServer(service);
208
+
209
+ const response = await mcp.handleRequest({
210
+ jsonrpc: "2.0",
211
+ id: 1,
212
+ method: "tools/call",
213
+ params: { name: "remnic.recall", arguments: { query: "live" } },
214
+ });
215
+
216
+ assert.equal(observedSignal, undefined);
217
+ assert.equal((response?.result as { isError?: boolean } | undefined)?.isError, false);
218
+ });
219
+
220
+ test("an already-aborted MCP write never starts and preserves the AbortError", async () => {
221
+ const controller = new AbortController();
222
+ const reason = abortError("caller disconnected before dispatch");
223
+ let writeStarted = false;
224
+ const service = {
225
+ memoryStore: async () => {
226
+ writeStarted = true;
227
+ return {} as never;
228
+ },
229
+ } as unknown as EngramAccessService;
230
+ const mcp = new EngramMcpServer(service);
231
+ controller.abort(reason);
232
+
233
+ await assert.rejects(
234
+ mcp.handleRequest(
235
+ {
236
+ jsonrpc: "2.0",
237
+ id: 1,
238
+ method: "tools/call",
239
+ params: {
240
+ name: "remnic.memory_store",
241
+ arguments: { content: "must not be stored", category: "fact" },
242
+ },
243
+ },
244
+ { abortSignal: controller.signal },
245
+ ),
246
+ (error: unknown) => error === reason,
247
+ );
248
+ assert.equal(writeStarted, false);
249
+ });
250
+
251
+ test("MCP write quota is recorded after commit even when the client disconnects before completion", async () => {
252
+ const mutationCommitted = deferred<void>();
253
+ const releaseOperation = deferred<void>();
254
+ const operationSettled = deferred<void>();
255
+ const requestAborted = deferred<void>();
256
+ let responseStarted = false;
257
+ const service = {
258
+ memoryStore: async () => {
259
+ mutationCommitted.resolve();
260
+ try {
261
+ await releaseOperation.promise;
262
+ } finally {
263
+ operationSettled.resolve();
264
+ }
265
+ return {
266
+ schemaVersion: 1,
267
+ operation: "memory_store",
268
+ namespace: "default",
269
+ dryRun: false,
270
+ accepted: true,
271
+ queued: false,
272
+ status: "stored",
273
+ memoryId: "committed-memory",
274
+ };
275
+ },
276
+ } as unknown as EngramAccessService;
277
+ const server = new EngramAccessHttpServer({
278
+ service,
279
+ port: 0,
280
+ authToken: "test-token",
281
+ adminConsoleEnabled: false,
282
+ });
283
+ const serverHost = server as unknown as {
284
+ mcpServer: EngramMcpServer;
285
+ writeRequestTimestamps: number[];
286
+ };
287
+ const originalHandleRequest = serverHost.mcpServer.handleRequest.bind(serverHost.mcpServer);
288
+ serverHost.mcpServer.handleRequest = async (request, options) => {
289
+ options?.abortSignal?.addEventListener("abort", () => requestAborted.resolve(), { once: true });
290
+ return originalHandleRequest(request, options);
291
+ };
292
+ const status = await server.start();
293
+
294
+ try {
295
+ const client = httpRequest({
296
+ host: "127.0.0.1",
297
+ port: status.port,
298
+ path: "/mcp",
299
+ method: "POST",
300
+ headers: {
301
+ authorization: "Bearer test-token",
302
+ "content-type": "application/json",
303
+ },
304
+ }, (res) => {
305
+ responseStarted = true;
306
+ res.resume();
307
+ });
308
+ client.on("error", () => {});
309
+ client.end(mcpBody("remnic.memory_store", {
310
+ content: "committed before disconnect",
311
+ category: "fact",
312
+ }));
313
+
314
+ await waitFor(mutationCommitted.promise);
315
+ client.destroy();
316
+ await waitFor(requestAborted.promise);
317
+ releaseOperation.resolve();
318
+
319
+ await waitFor(operationSettled.promise);
320
+ await new Promise<void>((resolve) => setImmediate(resolve));
321
+ assert.equal(serverHost.writeRequestTimestamps.length, 1);
322
+ assert.equal(responseStarted, false, "the disconnected client must not receive the committed write response");
323
+ } finally {
324
+ releaseOperation.resolve();
325
+ await server.stop();
326
+ }
327
+ });
328
+
329
+ test("a disconnected MCP recall queued on the budget lock never starts and does not poison the lock", async () => {
330
+ const firstStarted = deferred<void>();
331
+ const releaseFirst = deferred<void>();
332
+ const secondQueued = deferred<void>();
333
+ const secondAborted = deferred<void>();
334
+ const secondSettled = deferred<void>();
335
+ let secondStarted = false;
336
+ let thirdStarted = false;
337
+ let callCount = 0;
338
+
339
+ const lockService = Object.create(EngramAccessService.prototype) as EngramAccessService;
340
+ const lockHost = lockService as unknown as {
341
+ budgetLocks: Map<string, Promise<void>>;
342
+ withBudgetLock<T>(
343
+ principal: string,
344
+ abortSignal: AbortSignal | undefined,
345
+ operation: () => Promise<T>,
346
+ ): Promise<T>;
347
+ };
348
+ lockHost.budgetLocks = new Map();
349
+
350
+ const service = {
351
+ recall: async (input: EngramAccessRecallRequest) => {
352
+ callCount += 1;
353
+ const call = callCount;
354
+ if (call === 2) {
355
+ input.abortSignal?.addEventListener("abort", () => secondAborted.resolve(), { once: true });
356
+ secondQueued.resolve();
357
+ }
358
+ try {
359
+ return await lockHost.withBudgetLock("principal", input.abortSignal, async () => {
360
+ if (call === 1) {
361
+ firstStarted.resolve();
362
+ await releaseFirst.promise;
363
+ } else if (call === 2) {
364
+ secondStarted = true;
365
+ } else {
366
+ thirdStarted = true;
367
+ }
368
+ return {} as never;
369
+ });
370
+ } finally {
371
+ if (call === 2) secondSettled.resolve();
372
+ }
373
+ },
374
+ } as unknown as EngramAccessService;
375
+ const server = new EngramAccessHttpServer({
376
+ service,
377
+ port: 0,
378
+ authToken: "test-token",
379
+ adminConsoleEnabled: false,
380
+ });
381
+ const status = await server.start();
382
+
383
+ try {
384
+ const first = sendMcp(status.port, "remnic.recall");
385
+ await waitFor(firstStarted.promise);
386
+
387
+ const second = sendMcp(status.port, "remnic.recall");
388
+ second.response.catch(() => {});
389
+ await waitFor(secondQueued.promise);
390
+ second.client.destroy();
391
+ await waitFor(secondAborted.promise);
392
+ releaseFirst.resolve();
393
+
394
+ await waitFor(first.response);
395
+ await waitFor(secondSettled.promise);
396
+ assert.equal(secondStarted, false);
397
+
398
+ const third = sendMcp(status.port, "remnic.recall");
399
+ await waitFor(third.response);
400
+ assert.equal(thirdStarted, true);
401
+ } finally {
402
+ releaseFirst.resolve();
403
+ await server.stop();
404
+ }
405
+ });