@@ -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