Skip to content

Commit ab8fc71

Browse files
Theaxiomclaude
andcommitted
refactor(zaru-mcp-server): export memory handlers for unit testability
Lift `injectMemoryIntoInit` and the `zaru.memory.get` / `zaru.memory.set` dispatch handlers out of the `createMcpServerForUser` closure into top-level exported functions. Mirrors the existing `handleZaruScriptTool` pattern: explicit dependencies (`client`, `user`, `args`, optional `logger`), `Pick<>`-typed client param so tests can inject a fake without constructing a real `ZaruClient`. Pure mechanical refactor — no behavior change. Dispatch loop now calls the new exports. Adds `test/memory-handlers.test.ts` (12 new tests) covering: - injectMemoryIntoInit: success / empty content / client error / no input mutation - handleZaruMemoryGet: success / generic error / non-Error throw - handleZaruMemorySet: happy path / missing-args / VersionConflictError with `current` payload / generic error / non-Error throw Closes the cycle-3 coverage gap on previously-private memory closures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ab7143 commit ab8fc71

2 files changed

Lines changed: 417 additions & 63 deletions

File tree

zaru-mcp-server/src/mcp/streamable-http.ts

Lines changed: 112 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -197,26 +197,131 @@ function normalizeToolResult(result: unknown): {
197197
* fetch fails (network / zaru-client unreachable) the prompt is
198198
* returned unchanged and a warning is logged — memory injection
199199
* MUST NOT block session init.
200+
*
201+
* Exported for unit testing. The `client` parameter accepts any object
202+
* with a `getMemory(user)` method so tests can inject a fake without
203+
* standing up a real `ZaruClient`. The `logger` parameter defaults to
204+
* `console` and exists so tests can capture warnings.
200205
*/
201-
async function injectMemoryIntoInit<T extends { system_prompt: string }>(
206+
export async function injectMemoryIntoInit<T extends { system_prompt: string }>(
207+
client: Pick<ZaruClient, "getMemory">,
202208
user: ZaruUser,
203209
init: T,
210+
logger: Pick<Console, "warn"> = console,
204211
): Promise<T> {
205212
try {
206-
const memory = await zaruClient.getMemory(user);
213+
const memory = await client.getMemory(user);
207214
return {
208215
...init,
209216
system_prompt: appendMemoryToSystemPrompt(init.system_prompt, memory),
210217
};
211218
} catch (error) {
212-
console.warn(
219+
logger.warn(
213220
"[zaru-mcp-server] failed to fetch Zaru User Memory — proceeding without injection:",
214221
error instanceof Error ? error.message : error,
215222
);
216223
return init;
217224
}
218225
}
219226

227+
/**
228+
* Dispatch `zaru.memory.get` — fetch the user's Zaru User Memory record
229+
* (ADR-118) and wrap it in the standard MCP tool-call envelope. Errors
230+
* are surfaced as structured tool errors rather than thrown so the LLM
231+
* can react.
232+
*
233+
* Exported for unit testing.
234+
*/
235+
export async function handleZaruMemoryGet(
236+
client: Pick<ZaruClient, "getMemory">,
237+
user: ZaruUser,
238+
logger: Pick<Console, "error"> = console,
239+
): Promise<{
240+
content: Array<{ type: string; text: string }>;
241+
isError: boolean;
242+
}> {
243+
try {
244+
const memory = await client.getMemory(user);
245+
return normalizeToolResult(memory);
246+
} catch (error) {
247+
const message =
248+
error instanceof Error ? error.message : "zaru.memory.get failed";
249+
logger.error("[zaru.memory.get] failed:", message);
250+
return {
251+
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
252+
isError: true,
253+
};
254+
}
255+
}
256+
257+
/**
258+
* Dispatch `zaru.memory.set` — replace the user's Zaru User Memory
259+
* (ADR-118) with optimistic concurrency on `version`. On
260+
* `VersionConflictError` we return a structured tool error containing
261+
* the server's current `{ content, version, updated_at }` so the LLM
262+
* can re-read, merge, and retry. All other failure modes (missing
263+
* args, network error, generic upstream error) produce structured
264+
* tool errors rather than throwing.
265+
*
266+
* Exported for unit testing.
267+
*/
268+
export async function handleZaruMemorySet(
269+
client: Pick<ZaruClient, "setMemory">,
270+
user: ZaruUser,
271+
args: unknown,
272+
logger: Pick<Console, "error"> = console,
273+
): Promise<{
274+
content: Array<{ type: string; text: string }>;
275+
isError: boolean;
276+
}> {
277+
const a = (args as Record<string, unknown>) ?? {};
278+
const content = typeof a.content === "string" ? a.content : undefined;
279+
const version = typeof a.version === "number" ? a.version : undefined;
280+
if (content === undefined || version === undefined) {
281+
return {
282+
content: [
283+
{
284+
type: "text",
285+
text: JSON.stringify({
286+
error:
287+
"zaru.memory.set requires both 'content' (string) and 'version' (number from the latest zaru.memory.get).",
288+
}),
289+
},
290+
],
291+
isError: true,
292+
};
293+
}
294+
try {
295+
const updated = await client.setMemory(user, content, version);
296+
return normalizeToolResult(updated);
297+
} catch (error) {
298+
if (error instanceof VersionConflictError) {
299+
// Structured conflict so the LLM can re-read, merge, retry.
300+
return {
301+
content: [
302+
{
303+
type: "text",
304+
text: JSON.stringify({
305+
error: "version_conflict",
306+
message:
307+
"Memory was updated by another writer. Re-read, merge your update into the new content, and retry with the new version.",
308+
current: error.current,
309+
}),
310+
},
311+
],
312+
isError: true,
313+
};
314+
}
315+
const message =
316+
error instanceof Error ? error.message : "zaru.memory.set failed";
317+
logger.error("[zaru.memory.set] failed:", message);
318+
return {
319+
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
320+
isError: true,
321+
};
322+
}
323+
}
324+
220325
/**
221326
* Tool calls that may carry an `attachments` array per ADR-113. Only clients
222327
* that declare the "chat-uploads" capability are permitted to forward
@@ -568,7 +673,7 @@ Available modes:
568673
isError: true,
569674
};
570675
}
571-
const withMemory = await injectMemoryIntoInit(user, result);
676+
const withMemory = await injectMemoryIntoInit(zaruClient, user, result);
572677
return normalizeToolResult(withMemory);
573678
}
574679

@@ -623,7 +728,7 @@ Available modes:
623728
isError: true,
624729
};
625730
}
626-
const withMemory = await injectMemoryIntoInit(user, result);
731+
const withMemory = await injectMemoryIntoInit(zaruClient, user, result);
627732
return normalizeToolResult({
628733
...withMemory,
629734
reason,
@@ -651,67 +756,11 @@ Available modes:
651756
}
652757

653758
if (name === "zaru.memory.get") {
654-
try {
655-
const memory = await zaruClient.getMemory(user);
656-
return normalizeToolResult(memory);
657-
} catch (error) {
658-
const message =
659-
error instanceof Error ? error.message : "zaru.memory.get failed";
660-
console.error("[zaru.memory.get] failed:", message);
661-
return {
662-
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
663-
isError: true,
664-
};
665-
}
759+
return handleZaruMemoryGet(zaruClient, user);
666760
}
667761

668762
if (name === "zaru.memory.set") {
669-
const a = (args as Record<string, unknown>) ?? {};
670-
const content = typeof a.content === "string" ? a.content : undefined;
671-
const version = typeof a.version === "number" ? a.version : undefined;
672-
if (content === undefined || version === undefined) {
673-
return {
674-
content: [
675-
{
676-
type: "text",
677-
text: JSON.stringify({
678-
error:
679-
"zaru.memory.set requires both 'content' (string) and 'version' (number from the latest zaru.memory.get).",
680-
}),
681-
},
682-
],
683-
isError: true,
684-
};
685-
}
686-
try {
687-
const updated = await zaruClient.setMemory(user, content, version);
688-
return normalizeToolResult(updated);
689-
} catch (error) {
690-
if (error instanceof VersionConflictError) {
691-
// Structured conflict so the LLM can re-read, merge, retry.
692-
return {
693-
content: [
694-
{
695-
type: "text",
696-
text: JSON.stringify({
697-
error: "version_conflict",
698-
message:
699-
"Memory was updated by another writer. Re-read, merge your update into the new content, and retry with the new version.",
700-
current: error.current,
701-
}),
702-
},
703-
],
704-
isError: true,
705-
};
706-
}
707-
const message =
708-
error instanceof Error ? error.message : "zaru.memory.set failed";
709-
console.error("[zaru.memory.set] failed:", message);
710-
return {
711-
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
712-
isError: true,
713-
};
714-
}
763+
return handleZaruMemorySet(zaruClient, user, args);
715764
}
716765

717766
// ADR-113 defence-in-depth: reject `attachments` from any client that has

0 commit comments

Comments
 (0)