From 26a2fd6f17ec489e4c6fb5ee2e6ff33eb1c5a03f Mon Sep 17 00:00:00 2001 From: nikhil rajpurohit Date: Mon, 12 Oct 2026 16:39:27 -0700 Subject: [PATCH] feat(mcp): ask the user before forgetting a memory (#1795) --- apps/mcp/src/server/client/index.ts | 45 ++-- apps/mcp/src/server/tools/add-memory.ts | 4 +- .../src/server/tools/forget-memory.test.ts | 211 ++++++++++++++++++ apps/mcp/src/server/tools/forget-memory.ts | 157 +++++++++++-- apps/mcp/src/server/tools/output-schemas.ts | 1 + 5 files changed, 387 insertions(+), 31 deletions(-) create mode 100644 apps/mcp/src/server/tools/forget-memory.test.ts diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 39256cf7..3a327115 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -48,6 +48,11 @@ export interface DocumentSummary { export type DocumentDetails = GetDocumentsResponse +export interface ForgetCandidate { + id: string + memory: string +} + export interface DocumentsListResponse { documents: DocumentSummary[] pagination: ListResponsePagination @@ -207,39 +212,51 @@ export class SupermemoryClient { } } - // v5 has no exact-content forget: preview semantic matches, then forget the best one by ID. - async forgetMemory( - content: string, - ): Promise<{ success: boolean; message: string; namespace: string }> { + // v5 has no exact-content forget: dry-run a semantic match so the caller can confirm it before forgetting by ID. + async findMemoryToForget(content: string): Promise { try { const preview = await this.client.memories.forgetMatching( this.namespace, { query: content, dryRun: true }, ) const match = preview.matches[0] - if (!match) { - return { - success: false, - message: "No matching memory found to forget.", - namespace: this.namespace, - } - } + return match ? { id: match.id, memory: match.memory } : null + } catch (error) { + this.handleOperationError("Forget memory request", error) + } + } + async getMemory(id: string): Promise { + try { + const memory = await this.client.memories.get(this.namespace, id) + return memory.isForgotten + ? null + : { id: memory.id, memory: memory.memory } + } catch (error) { + if (isNotFound(error)) return null + this.handleOperationError("Get memory request", error) + } + } + + async forgetMemoryById( + candidate: ForgetCandidate, + ): Promise<{ success: boolean; message: string; namespace: string }> { + try { const result = await this.client.memories.forget(this.namespace, { - ids: [match.id], + ids: [candidate.id], }) if (result.count === 0) { const reason = result.errors[0]?.error ?? "unknown error" return { success: false, - message: `Could not forget memory ${match.id}: ${reason}`, + message: `Could not forget memory ${candidate.id}: ${reason}`, namespace: this.namespace, } } return { success: true, - message: `Forgot memory (ID: ${match.id}): "${limitByChars(match.memory, 100)}"`, + message: `Forgot memory (ID: ${candidate.id}): "${limitByChars(candidate.memory, 100)}"`, namespace: this.namespace, } } catch (error) { diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index 97bdd6da..99254f25 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -36,8 +36,8 @@ export function register(deps: ToolDeps) { outputSchema: addMemoryOutputSchema, annotations: MEMORY_TOOL_ANNOTATIONS, }, - async (args) => { - if (args.action === "forget") return forgetMemory(deps, args) + async (args, context) => { + if (args.action === "forget") return forgetMemory(deps, args, context) try { const namespace = await deps.resolveNamespace(namespaceArg(args)) const client = deps.getClient(namespace) diff --git a/apps/mcp/src/server/tools/forget-memory.test.ts b/apps/mcp/src/server/tools/forget-memory.test.ts new file mode 100644 index 00000000..99610995 --- /dev/null +++ b/apps/mcp/src/server/tools/forget-memory.test.ts @@ -0,0 +1,211 @@ +import { + CLIENT_CAPABILITIES_META_KEY, + isInputRequiredResult, +} from "@modelcontextprotocol/server" +import { describe, expect, it, vi } from "vitest" +import type { z } from "zod" +import * as addMemory from "./add-memory" +import * as forgetMemory from "./forget-memory" +import type { ToolDeps } from "./types" +import { errorResult } from "./types" + +type Registered = { + config: { outputSchema: z.ZodTypeAny } + cb: ( + args: Record, + context: unknown, + ) => Promise> +} + +const candidate = { id: "m1", memory: "User lives in Pune" } + +function fakeDeps() { + const tools = new Map() + const client = { + findMemoryToForget: vi.fn().mockResolvedValue(candidate), + getMemory: vi.fn(async (id: string) => (id === "m1" ? candidate : null)), + forgetMemoryById: vi.fn().mockResolvedValue({ + success: true, + message: 'Forgot memory (ID: m1): "User lives in Pune"', + namespace: "active", + }), + } + const deps = { + server: { + registerTool: (name: string, config: unknown, cb: unknown) => { + tools.set(name, { config, cb } as Registered) + }, + }, + getClient: vi.fn(() => client), + resolveNamespace: vi.fn(async (explicit?: string) => explicit ?? "active"), + errorResult, + } as unknown as ToolDeps + forgetMemory.register(deps) + addMemory.register(deps) + const tool = (name: string) => tools.get(name) as Registered + return { tool, client } +} + +// A 2026-era client that declared elicitation, optionally answering a previous prompt. +function elicitingContext( + answer?: Record, + requestState?: string, +) { + return { + mcpReq: { + envelope: { [CLIENT_CAPABILITIES_META_KEY]: { elicitation: {} } }, + inputResponses: answer ? { confirm_forget: answer } : undefined, + requestState: () => requestState, + }, + } +} + +describe("forget_memory without elicitation", () => { + it("only previews the match on the first call", async () => { + const { tool, client } = fakeDeps() + const forget = tool("forget_memory") + + const result = await forget.cb({ content: "where I live" }, {}) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + const output = forget.config.outputSchema.parse(result.structuredContent) + expect(output).toMatchObject({ + success: false, + status: "confirmation_required", + id: "m1", + memory: "User lives in Pune", + }) + }) + + it("forgets once called again with the previewed id and confirm", async () => { + const { tool, client } = fakeDeps() + const forget = tool("forget_memory") + + const result = await forget.cb({ id: "m1", confirm: true }, {}) + + expect(client.forgetMemoryById).toHaveBeenCalledWith(candidate) + expect(result.structuredContent).toMatchObject({ + success: true, + status: "forgotten", + }) + }) + + it("does not treat confirm on a content match as consent", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { content: "where I live", confirm: true }, + {}, + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(result.structuredContent).toMatchObject({ + status: "confirmation_required", + }) + }) + + it("reports a missing memory without forgetting anything", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { id: "gone", confirm: true }, + {}, + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(result.structuredContent).toMatchObject({ status: "not_found" }) + }) + + it("rejects calls with neither id nor content", async () => { + const { tool } = fakeDeps() + + const result = await tool("forget_memory").cb({}, {}) + + expect(result.isError).toBe(true) + }) + + it("routes the legacy add_memory forget action through confirmation", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("add_memory").cb( + { content: "where I live", action: "forget" }, + {}, + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(result.structuredContent).toMatchObject({ + status: "confirmation_required", + }) + }) +}) + +describe("forget_memory with elicitation", () => { + it("asks the user before forgetting", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { content: "where I live" }, + elicitingContext(), + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(isInputRequiredResult(result)).toBe(true) + expect(result).toMatchObject({ requestState: "m1" }) + expect(JSON.stringify(result)).toContain("User lives in Pune") + }) + + it("ignores a model-supplied confirm and still asks the user", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { id: "m1", confirm: true }, + elicitingContext(), + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(isInputRequiredResult(result)).toBe(true) + }) + + it("forgets after the user accepts", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { content: "where I live" }, + elicitingContext({ action: "accept", content: { confirm: true } }, "m1"), + ) + + expect(client.forgetMemoryById).toHaveBeenCalledWith(candidate) + expect(result.structuredContent).toMatchObject({ status: "forgotten" }) + }) + + it.each([ + { action: "decline" }, + { action: "cancel" }, + { action: "accept", content: { confirm: false } }, + ])("keeps the memory when the user answers %o", async (answer) => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { content: "where I live" }, + elicitingContext(answer, "m1"), + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(result.structuredContent).toMatchObject({ + success: false, + status: "cancelled", + }) + }) + + it("asks again when the match changed since the user was asked", async () => { + const { tool, client } = fakeDeps() + + const result = await tool("forget_memory").cb( + { content: "where I live" }, + elicitingContext({ action: "accept", content: { confirm: true } }, "m0"), + ) + + expect(client.forgetMemoryById).not.toHaveBeenCalled() + expect(isInputRequiredResult(result)).toBe(true) + }) +}) diff --git a/apps/mcp/src/server/tools/forget-memory.ts b/apps/mcp/src/server/tools/forget-memory.ts index 1369fbde..668e1021 100644 --- a/apps/mcp/src/server/tools/forget-memory.ts +++ b/apps/mcp/src/server/tools/forget-memory.ts @@ -1,4 +1,13 @@ +import { + CLIENT_CAPABILITIES_META_KEY, + type CallToolResult, + type InputRequiredResult, + inputRequired, + inputResponse, + type ServerContext, +} from "@modelcontextprotocol/server" import { z } from "zod" +import type { ForgetCandidate } from "../client" import { optionalNamespaceSchema } from "../namespace" import { MEMORY_TOOL_ANNOTATIONS } from "./annotations" import { @@ -9,22 +18,125 @@ import { import { forgetMemoryOutputSchema } from "./output-schemas" import { textContent, type ToolDeps } from "./types" +const CONFIRM_KEY = "confirm_forget" + +export interface ForgetMemoryArgs { + content?: string + id?: string + confirm?: boolean + namespace?: string + containerTag?: string +} + +// Only 2026-era requests carry capabilities per request; stateless legacy requests never saw initialize, so they use the confirm flag instead. +function canElicit(context: ServerContext | undefined): boolean { + const envelope = context?.mcpReq?.envelope + if (!envelope) return false + const capabilities = Reflect.get(envelope, CLIENT_CAPABILITIES_META_KEY) + return Boolean( + capabilities && + typeof capabilities === "object" && + Reflect.get(capabilities, "elicitation"), + ) +} + +function askToConfirm( + candidate: ForgetCandidate, + namespace: string, +): InputRequiredResult { + return inputRequired({ + inputRequests: { + [CONFIRM_KEY]: inputRequired.elicit({ + message: `Forget this memory from "${namespace}"?\n\n"${candidate.memory}"`, + requestedSchema: { + type: "object", + properties: { + confirm: { + type: "boolean", + title: "Yes, forget this memory", + }, + }, + required: ["confirm"], + }, + }), + }, + // Only compared against the server-resolved ID, so a tampered value can just trigger a re-ask. + requestState: candidate.id, + }) +} + +function result( + namespace: string, + status: "forgotten" | "confirmation_required" | "cancelled" | "not_found", + message: string, + options: { success?: boolean; candidate?: ForgetCandidate } = {}, +): CallToolResult { + return { + content: [textContent(message)], + structuredContent: withLegacyNamespace({ + action: "forget" as const, + success: options.success ?? false, + namespace, + message, + status, + id: options.candidate?.id, + memory: options.candidate?.memory, + }), + } +} + export async function forgetMemory( deps: ToolDeps, - args: { content: string; namespace?: string; containerTag?: string }, -) { + args: ForgetMemoryArgs, + context?: ServerContext, +): Promise { try { - const namespace = await deps.resolveNamespace(namespaceArg(args)) - const result = await deps.getClient(namespace).forgetMemory(args.content) - return { - content: [textContent(result.message)], - structuredContent: withLegacyNamespace({ - action: "forget" as const, - success: result.success, - namespace: result.namespace, - message: result.message, - }), + if (!args.id && !args.content) { + throw new Error("Provide the memory id or content to forget.") } + const namespace = await deps.resolveNamespace(namespaceArg(args)) + const client = deps.getClient(namespace) + const candidate = args.id + ? await client.getMemory(args.id) + : await client.findMemoryToForget(args.content ?? "") + if (!candidate) { + return result( + namespace, + "not_found", + "No matching memory found to forget.", + ) + } + + if (canElicit(context)) { + const answer = inputResponse(context?.mcpReq.inputResponses, CONFIRM_KEY) + const askedId = context?.mcpReq.requestState?.() + if (answer.kind !== "elicit" || askedId !== candidate.id) { + return askToConfirm(candidate, namespace) + } + if (answer.action !== "accept" || answer.content?.confirm !== true) { + return result( + namespace, + "cancelled", + "The user chose to keep this memory. Nothing was forgotten.", + { candidate }, + ) + } + } else if (!(args.confirm === true && args.id === candidate.id)) { + return result( + namespace, + "confirmation_required", + `Nothing forgotten yet. Show the user this memory and ask whether to forget it:\n"${candidate.memory}" (ID: ${candidate.id})\nOnly if they say yes, call forget_memory again with id "${candidate.id}" and confirm: true.`, + { candidate }, + ) + } + + const forgotten = await client.forgetMemoryById(candidate) + return result( + forgotten.namespace, + forgotten.success ? "forgotten" : "not_found", + forgotten.message, + { success: forgotten.success, candidate }, + ) } catch (error) { return deps.errorResult(error) } @@ -35,18 +147,33 @@ export function register(deps: ToolDeps) { "forget_memory", { description: - "Forget a memory matching the supplied content in the user's ACTIVE space. Use when information is outdated or the user asks to remove it. The target space is the one selected via select-space; pass namespace only to override it.", + "Forget one memory in the user's ACTIVE space. Forgetting always needs the user's confirmation: clients that support elicitation show the user a confirmation prompt; otherwise the first call only returns the matching memory, and you must show it to the user and call again with its id and confirm: true once they agree. Prefer passing an id from search_memory or list_memories; content is matched semantically. Pass namespace only to override the active space.", inputSchema: z.object({ content: z .string() .max(200000, "Content exceeds maximum length") - .describe("The memory content to forget"), + .optional() + .describe( + "Text describing the memory to forget, matched semantically", + ), + id: z + .string() + .optional() + .describe( + "Exact memory ID to forget, from search_memory or list_memories", + ), + confirm: z + .boolean() + .optional() + .describe( + "Set to true only after the user explicitly agreed to forget the memory with this id", + ), namespace: optionalNamespaceSchema, ...legacyNamespaceInput, }), outputSchema: forgetMemoryOutputSchema, annotations: MEMORY_TOOL_ANNOTATIONS, }, - async (args) => forgetMemory(deps, args), + async (args, context) => forgetMemory(deps, args, context), ) } diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 0fe1e96e..321fb9f4 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -31,6 +31,7 @@ export type AddMemoryOutput = z.infer export const forgetMemoryOutputSchema = addMemoryOutputSchema.extend({ action: z.literal("forget"), + memory: z.string().optional(), }) export const getDocumentOutputSchema = z.object({