From f968cdbce52c636c09224585e0f4b6721d597ad2 Mon Sep 17 00:00:00 2001 From: geobelsky Date: Sun, 21 Jun 2026 12:26:45 +0000 Subject: [PATCH] fix(mcp): prevent + clarify empty-args failures on axme_save_memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two agent sessions independently hit axme_save_memory failing with "expected string, received undefined" for every required field — the args object arrived empty {}. Controlled testing (see SAVE_MEMORY_ARG_REPORT.md) confirmed it is NOT a server/handler/schema defect: every call whose args actually reached the server persisted fine, and save_decision worked in the same session. Root cause is a client-side generative slip — the agent emits the tool-call shell while deferring the heavy free-text fields, and the fill never happens. Two factors make save_memory uniquely prone: heaviest required surface among the axme tools, and an over-generalized "batch axme calls in parallel" habit inherited from the read-tool instructions. The SDK validates against the zod schema BEFORE our handler runs, so an empty payload never reaches our code — we cannot echo received keys without loosening the advertised schema (which would worsen the root cause by hiding the required fields from the model). So the fixes are all on the instruction/description surface, which is what actually steers generation, plus better error text: A. Custom zod v4 { error } messages on the required fields of save_memory (type/title/description) and save_decision (title/decision/reasoning). Instead of a bland "received undefined" (which the first agent misread as "the server lost my arguments" and retried 9x before filing a server-bug report), each field now says it is REQUIRED, must be composed in THIS call, and that an empty/deferred emission is the usual cause. B. Hardened tool descriptions: explicit "call standalone, not in a parallel batch; include all required fields in the same call" plus a worked example object for save_memory. C. Clarified server instructions: parallelism is for the READ tools (oracle/decisions/memories) only; a new SAVE-TOOL RULE states that save_memory/save_decision/update_safety are called one at a time with all required fields composed in that same call. No schema loosening, no behavior change for valid calls (verified: {type,title,description} still parses; empty {} now returns the actionable messages). Type-check clean; 613/613 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server.ts | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/server.ts b/src/server.ts index 2d243b4..d4af5fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -242,11 +242,11 @@ function buildInstructions(): string { ]; if (isWorkspace) { parts.push(`Workspace: ${defaultWorkspacePath} (${serverWorkspace.type}, ${serverWorkspace.projects.length} projects).`); - parts.push("Call axme_context at session start to load workspace overview. It returns compact meta and instructions to call axme_oracle, axme_decisions, axme_memories in parallel."); + parts.push("Call axme_context at session start to load workspace overview. It returns compact meta and instructions to call the READ tools axme_oracle, axme_decisions, axme_memories in parallel (parallelism is for those read tools only — see the save-tool rule below)."); parts.push("Each repo has its own .axme-code/ storage initialized during setup."); parts.push("Before working with any specific repo, call axme_context with that repo's path."); } else { - parts.push("Call axme_context at session start. It returns compact meta and instructions to call axme_oracle, axme_decisions, axme_memories in parallel."); + parts.push("Call axme_context at session start. It returns compact meta and instructions to call the READ tools axme_oracle, axme_decisions, axme_memories in parallel (parallelism is for those read tools only — see the save-tool rule below)."); } // Per-project setup gate: AXME is configured ONCE per repo (writes @@ -314,6 +314,7 @@ function buildInstructions(): string { } parts.push("TRUNCATED OUTPUT RULE: if ANY MCP tool output is truncated or saved to a file (you see 'Output too large' or 'saved to file'), you MUST use the Read tool to read the full file content into your context. Do not proceed with partial data."); parts.push("Save memories, decisions, and safety rules immediately when discovered during work."); + parts.push("SAVE-TOOL RULE: call axme_save_memory / axme_save_decision / axme_update_safety ONE AT A TIME, each as a standalone call with ALL required fields composed in that same call. Do NOT place a save call in a parallel batch with other tool calls (parallelism is for the read tools only) and never emit a save call with empty/deferred arguments — that produces an 'expected string, received undefined' validation error for every required field."); parts.push("GIT COMMIT/PUSH GATE: every git commit and git push command MUST end with `#!axme pr= repo=`. Example: `git commit -m \"fix bug\" #!axme pr=42 repo=AxmeAI/axme-code`. Use pr=none if no PR exists yet. Without this suffix the command will be blocked."); parts.push("RELEASE/TAG PROHIBITION: agent must NEVER run git tag, npm publish, twine upload, dotnet nuget push, mvn deploy, gh release create, or gh workflow run deploy-prod. These are blocked by safety hooks. To release: prepare version bump + CHANGELOG + PR, then provide ready-to-run tag/publish commands to the user."); parts.push("SESSION CLOSE: when the user asks to close/end the session (any language), call axme_begin_close to get the close checklist. Follow it: extract memories/decisions/safety (choosing correct scope for each), prepare handoff data, then call axme_finalize_close with everything. After finalize, output to the user: storage summary (what saved where), then startup_text."); @@ -537,14 +538,24 @@ server.tool( ); // --- axme_save_memory --- +// IMPORTANT (agent-facing): call this tool STANDALONE — never emit it inside a +// parallel batch with other tool calls, and compose type+title+description IN +// THE SAME call. The read tools (axme_oracle/decisions/memories) are the ones +// meant to be parallelized; this write tool carries a heavy text payload that +// gets dropped if the call shell is emitted with the intent to "fill params +// later". The required-field error messages below are written to catch exactly +// that failure (empty/parameterless emission) and tell the agent what to do, +// rather than reading as a mysterious "server lost my arguments" error. server.tool( "axme_save_memory", - "Save a feedback or pattern memory. Use 'feedback' for learned mistakes, 'pattern' for successful approaches.", + "Save a feedback or pattern memory. Use 'feedback' for learned mistakes, 'pattern' for successful approaches. " + + "Call this tool ON ITS OWN (do NOT batch it in a parallel block with other tool calls), and include type, title, and description in THIS SAME call. " + + "Worked example: { \"type\": \"pattern\", \"title\": \"Retry npm publish via automation token\", \"description\": \"CI npm publish 404s when NPM_TOKEN is a non-automation granular token; regenerate as a Classic Automation token to bypass 2FA at publish.\" }", { project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), - type: z.enum(["feedback", "pattern"]).describe("Memory type"), - title: z.string().describe("Short title"), - description: z.string().describe("1-2 sentences: what happened + specific action/command/rule. Must be self-contained without body."), + type: z.enum(["feedback", "pattern"], { error: "type is REQUIRED — compose it in THIS call (one of \"feedback\" | \"pattern\"); do not emit axme_save_memory with empty arguments." }).describe("Memory type"), + title: z.string({ error: "title is REQUIRED — compose it in THIS call. If you emitted the call expecting to fill params later, re-emit with title+type+description all present." }).describe("Short title"), + description: z.string({ error: "description is REQUIRED — compose it in THIS call (1-2 sentences, self-contained). An empty axme_save_memory call is the usual cause of this error: re-send with all three required fields." }).describe("1-2 sentences: what happened + specific action/command/rule. Must be self-contained without body."), body: z.string().optional().describe("Optional archive detail. Context output uses description only, so put all essential info there."), keywords: z.array(z.string()).optional().describe("Search keywords"), scope: z.array(z.string()).optional().describe("Project scope (omit for current project only)"), @@ -569,14 +580,17 @@ server.tool( ); // --- axme_save_decision --- +// Same agent-facing guidance as axme_save_memory above: call standalone with +// all required fields composed in the same emission, not in a parallel batch. server.tool( "axme_save_decision", - "Save a new architectural decision. Use enforce='required' for rules that must be followed, 'advisory' for recommendations.", + "Save a new architectural decision. Use enforce='required' for rules that must be followed, 'advisory' for recommendations. " + + "Call this tool ON ITS OWN (do NOT batch it in a parallel block with other tool calls), and include title, decision, and reasoning in THIS SAME call.", { project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), - title: z.string().describe("Decision title"), - decision: z.string().describe("2-3 sentences: what was decided + why. Must be self-contained."), - reasoning: z.string().describe("Optional additional context. Context output uses decision field only."), + title: z.string({ error: "title is REQUIRED — compose it in THIS call; do not emit axme_save_decision with empty arguments." }).describe("Decision title"), + decision: z.string({ error: "decision is REQUIRED — compose it in THIS call (2-3 sentences, self-contained). An empty call is the usual cause of this error: re-send with title+decision+reasoning present." }).describe("2-3 sentences: what was decided + why. Must be self-contained."), + reasoning: z.string({ error: "reasoning is REQUIRED — compose it in THIS call. Re-emit with all required fields present." }).describe("Optional additional context. Context output uses decision field only."), enforce: z.enum(["required", "advisory"]).optional().describe("Enforcement level"), scope: z.array(z.string()).optional().describe("Project scope"), },