mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-27 06:15:15 +00:00
feat(mcp): ask the user before forgetting a memory (#1795)
This commit is contained in:
parent
570bc1bf47
commit
26a2fd6f17
5 changed files with 387 additions and 31 deletions
|
|
@ -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<ForgetCandidate | null> {
|
||||
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<ForgetCandidate | null> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
211
apps/mcp/src/server/tools/forget-memory.test.ts
Normal file
211
apps/mcp/src/server/tools/forget-memory.test.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
context: unknown,
|
||||
) => Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const candidate = { id: "m1", memory: "User lives in Pune" }
|
||||
|
||||
function fakeDeps() {
|
||||
const tools = new Map<string, Registered>()
|
||||
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<string, unknown>,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<CallToolResult | InputRequiredResult> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export type AddMemoryOutput = z.infer<typeof addMemoryOutputSchema>
|
|||
|
||||
export const forgetMemoryOutputSchema = addMemoryOutputSchema.extend({
|
||||
action: z.literal("forget"),
|
||||
memory: z.string().optional(),
|
||||
})
|
||||
|
||||
export const getDocumentOutputSchema = z.object({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue