From c1dea1930ca7932e25bf11ce62043b55abbb09e1 Mon Sep 17 00:00:00 2001 From: agentnightshift Date: Sat, 11 Jul 2026 10:47:01 -0500 Subject: [PATCH] feat(mcp): user-initiated correction prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structurally enforce that only a user can trigger a memory correction (a belief-axis retraction, not a fact that merely changed) by exposing correct_memory as an MCP Prompt rather than a Tool — prompts are user-controlled by the protocol, so the agent can never invoke one autonomously. The prompt composes the existing remember_context and deprecate_context tools (supersession_reason: "corrected", initiator: "user") and is registered unconditionally for every client, since the underlying tools stay gated by each client's allowedTools. Also expose valid_from/valid_until on remember_context's MCP schema — the service layer already threaded them through metadata, they were just missing from the tool's Zod input schema. Implements INVEST #4 from metacortexplan.md. --- CLAUDE.md | 10 +- functions/src/mcpServer.ts | 68 ++++++++- functions/test/mcp.integration.test.ts | 193 +++++++++++++++++++++++++ metacortexplan.md | 2 +- 4 files changed, 270 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb01347..de222f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,14 @@ Auth uses timing-safe token comparison. Origin allowlisting supports `"*"` wildc | `deprecate_context` | Soft-delete: mark document as deprecated, record superseding document ID, and record supersession_reason ("changed" sets valid_until, "corrected" does not) | | `consolidate_context` | Merge N related memories into one canonical active memory via LLM; deprecates all sources with `superseded_by` pointing to the merged result. Defaults to WIP queue for a topic; accepts explicit `source_ids` for targeted consolidation | +`remember_context` also accepts optional `valid_from`/`valid_until` (epoch-ms numbers) so a write can carry its temporal validity window from creation. + +### MCP Prompts + +| Prompt | Purpose | +|--------|---------| +| `correct_memory` | User-initiated correction (belief-axis retraction: the old record was never true). Registered as an MCP Prompt, not a Tool, so only a user — never the agent — can invoke it. Arguments: `incorrect_memory_id`, `corrected_content` (required), `topic`, `valid_from`, `valid_until` (optional, strings). Returns a single user-role message instructing the agent to call `remember_context` (corrected content), then `deprecate_context` (`supersession_reason: "corrected"`, `initiator: "user"`) against the old id, then report both ids back. Registered unconditionally for every client; the underlying tools stay gated by each client's `allowedTools`. | + ### Key Source Files (all under `functions/src/`) | File | Lines | Purpose | @@ -89,7 +97,7 @@ Auth uses timing-safe token comparison. Origin allowlisting supports `"*"` wildc | `normalize.ts` | ~8 | Shared text-normalization helper | | `memoryRepository.ts` | ~228 | Firestore CRUD: `store()`, `search()` (findNearest + cosine), `deprecate()`, `getConsolidationQueue()` | | `types.ts` | ~138 | Enums (`BRANCH_STATES`, `MEMORY_MODALITIES`, `MCP_TOOL_NAMES`) and interfaces | -| `mcpServer.ts` | ~447 | MCP tool registration with Zod schemas, filtered by client's `allowedTools` and `allowedFilterStates` | +| `mcpServer.ts` | ~639 | MCP tool registration with Zod schemas, filtered by client's `allowedTools` and `allowedFilterStates`; also registers the `correct_memory` prompt unconditionally | ### Data Flow diff --git a/functions/src/mcpServer.ts b/functions/src/mcpServer.ts index c65c647..75f38e7 100644 --- a/functions/src/mcpServer.ts +++ b/functions/src/mcpServer.ts @@ -64,6 +64,18 @@ export function createMetaCortexMcpServer( .describe( "Optional advanced lifecycle state. Defaults to active. Use this instead of draft when you need explicit lifecycle control such as merged or deprecated." ), + valid_from: z + .number() + .optional() + .describe( + "Optional epoch-ms timestamp marking when this fact becomes valid. Omit for facts valid from creation." + ), + valid_until: z + .number() + .optional() + .describe( + "Optional epoch-ms timestamp marking when this fact stops being valid. Omit for facts with no known end." + ), image_base64: z .string() .optional() @@ -234,7 +246,9 @@ export function createMetaCortexMcpServer( draft: args.draft, content_length: args.content?.trim().length ?? 0, image_present: Boolean(args.image_base64), - artifact_ref_count: args.artifact_refs?.length ?? 0 + artifact_ref_count: args.artifact_refs?.length ?? 0, + valid_from: args.valid_from, + valid_until: args.valid_until }; const result = await observeToolCall( "remember_context", @@ -505,6 +519,58 @@ export function createMetaCortexMcpServer( ); } + server.registerPrompt( + "correct_memory", + { + title: "Correct Memory", + description: + "User-initiated correction: retract a memory that was never true and replace it with the corrected fact. This composes remember_context and deprecate_context; only a user can invoke a prompt, so the agent can never trigger a correction on its own.", + argsSchema: { + incorrect_memory_id: z + .string() + .min(1) + .describe("The id of the existing memory that was never true and must be retracted."), + corrected_content: z + .string() + .min(1) + .describe("The corrected fact to store in place of the incorrect memory."), + topic: z + .string() + .optional() + .describe("Optional topic label for the corrected memory."), + valid_from: z + .string() + .optional() + .describe("Optional epoch-ms timestamp (as a string) marking when the corrected fact becomes valid."), + valid_until: z + .string() + .optional() + .describe("Optional epoch-ms timestamp (as a string) marking when the corrected fact stops being valid.") + } + }, + args => { + const topicLine = args.topic ? `\n- topic: ${args.topic}` : ""; + const validFromLine = args.valid_from ? `\n- valid_from: ${args.valid_from}` : ""; + const validUntilLine = args.valid_until ? `\n- valid_until: ${args.valid_until}` : ""; + + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: + "This is a USER-INITIATED correction: the memory below was never true (a belief-axis retraction), not a fact that merely changed over time. Perform the following steps in order:\n\n" + + `1. Call remember_context with content: "${args.corrected_content}"${topicLine}${validFromLine}${validUntilLine}\n` + + `2. Call deprecate_context with id: "${args.incorrect_memory_id}", superseding_id: , supersession_reason: "corrected", initiator: "user"\n` + + "3. Report both the deprecated id and the new corrected id back to the user." + } + } + ] + }; + } + ); + return server; } diff --git a/functions/test/mcp.integration.test.ts b/functions/test/mcp.integration.test.ts index 64be376..c0be656 100644 --- a/functions/test/mcp.integration.test.ts +++ b/functions/test/mcp.integration.test.ts @@ -369,6 +369,199 @@ describe("MCP integration", () => { expect(searchAfterPayload.matches.map((m: any) => m.id)).not.toContain(thirdId); }); + it("registers the correct_memory prompt with its arguments and composes a correction message", async () => { + const runtime = createTestRuntime(); + const baseUrl = await startServer( + createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }), + cleanup + ); + + const client = new Client({ + name: "test-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("test") + } + } + }); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + const prompts = await client.listPrompts(); + const correctMemoryPrompt = prompts.prompts.find(prompt => prompt.name === "correct_memory"); + expect(correctMemoryPrompt).toBeDefined(); + expect(correctMemoryPrompt?.arguments?.map(arg => arg.name).sort()).toEqual( + ["corrected_content", "incorrect_memory_id", "topic", "valid_from", "valid_until"].sort() + ); + expect( + correctMemoryPrompt?.arguments?.find(arg => arg.name === "incorrect_memory_id")?.required + ).toBe(true); + expect( + correctMemoryPrompt?.arguments?.find(arg => arg.name === "corrected_content")?.required + ).toBe(true); + expect( + correctMemoryPrompt?.arguments?.find(arg => arg.name === "topic")?.required + ).toBe(false); + + const result = await client.getPrompt({ + name: "correct_memory", + arguments: { + incorrect_memory_id: "memory-old-1", + corrected_content: "We actually shipped v2 in March, not February.", + topic: "release-timeline", + valid_from: "1700000000000" + } + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + const text = (result.messages[0].content as { text: string }).text; + expect(text).toContain("USER-INITIATED correction"); + expect(text).toContain("memory-old-1"); + expect(text).toContain("We actually shipped v2 in March, not February."); + expect(text).toContain("release-timeline"); + expect(text).toContain("1700000000000"); + expect(text).toContain("corrected"); + expect(text).toContain('initiator: "user"'); + }); + + it("registers the correct_memory prompt even for a tool-scoped client", async () => { + const runtime = createTestRuntime({ + clientProfiles: [ + { + id: "scoped-client", + [authTokenField]: accessCredential("scoped"), + allowedOrigins: ["https://example.com"], + allowedTools: ["search_context"], + allowedFilterStates: ["active"] + } + ] + }); + + const baseUrl = await startServer( + createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }), + cleanup + ); + + const client = new Client({ + name: "scoped-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/clients/scoped-client/mcp`), + { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("scoped") + } + } + } + ); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(["search_context"]); + + const prompts = await client.listPrompts(); + expect(prompts.prompts.map(prompt => prompt.name)).toContain("correct_memory"); + }); + + it("accepts valid_from/valid_until on remember_context over MCP and honors them on search", async () => { + const runtime = createTestRuntime(); + const baseUrl = await startServer( + createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }), + cleanup + ); + + const client = new Client({ + name: "test-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("test") + } + } + }); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + const rememberResult = await client.callTool({ + name: "remember_context", + arguments: { + content: "We are using Ktor for networking.", + topic: "kmp-networking", + valid_from: 1000, + valid_until: 2000 + } + }); + + const rememberPayload = parseJsonTextContent(rememberResult) as any; + expect(rememberPayload).toMatchObject({ + write_status: "created" + }); + const memoryId = rememberPayload.item.id; + + const insideResult = await client.callTool({ + name: "search_context", + arguments: { + query: "networking", + valid_at: 1500 + } + }); + const insidePayload = parseJsonTextContent(insideResult) as any; + expect(insidePayload.matches.map((m: any) => m.id)).toContain(memoryId); + + const beforeResult = await client.callTool({ + name: "search_context", + arguments: { + query: "networking", + valid_at: 500 + } + }); + const beforePayload = parseJsonTextContent(beforeResult) as any; + expect(beforePayload.matches.map((m: any) => m.id)).not.toContain(memoryId); + + const afterResult = await client.callTool({ + name: "search_context", + arguments: { + query: "networking", + valid_at: 2500 + } + }); + const afterPayload = parseJsonTextContent(afterResult) as any; + expect(afterPayload.matches.map((m: any) => m.id)).not.toContain(memoryId); + }); + it("enforces tool scoping on client-specific endpoints", async () => { const runtime = createTestRuntime({ clientProfiles: [ diff --git a/metacortexplan.md b/metacortexplan.md index 0cca7c9..242f8d0 100644 --- a/metacortexplan.md +++ b/metacortexplan.md @@ -81,7 +81,7 @@ The first hardening release addressed Firestore collection scaling, payload opti * **Effort:** Medium. ### 4. Correction as a User-Initiated Action -* **Status:** Proposed (*Added 2026-06 following a design review*) +* **Status:** Implemented 2026-07-11 * **Goal:** Prevent "agent drift" during error corrections by ensuring only the user can initiate corrections (retracting assertions that were never true). * **Proposal:** Enforce the user-only constraint structurally by exposing corrections as an MCP Prompt (user-controlled), NOT an MCP Tool (model-controlled). This prevents the agent from invoking corrections autonomously.