Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
68 changes: 67 additions & 1 deletion functions/src/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: <the id returned by step 1>, supersession_reason: "corrected", initiator: "user"\n` +
"3. Report both the deprecated id and the new corrected id back to the user."
}
}
]
};
}
);

return server;
}

Expand Down
193 changes: 193 additions & 0 deletions functions/test/mcp.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion metacortexplan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading