mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-27 06:15:15 +00:00
fix(mcp): expose forget_memory as a separate tool
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
552803c018
commit
5a6425b5e9
8 changed files with 229 additions and 20 deletions
|
|
@ -48,7 +48,8 @@ Your assistant chooses these tools automatically. Use this table when you need t
|
|||
| --- | --- | --- | --- |
|
||||
| `search_memory` | Semantic recall from one space | `query` (required), `containerTag` | Matching memories |
|
||||
| `get_profile` | Stable and recent profile context for one space | `containerTag` | Profile facts |
|
||||
| `add_memory` | Save information or forget outdated information | `content` (required), `action` (`save` or `forget`), `containerTag` | Save or forget confirmation |
|
||||
| `add_memory` | Save information | `content` (required), `namespace` | Save confirmation |
|
||||
| `forget_memory` | Forget outdated information | `content` (required), `namespace` | Forget confirmation |
|
||||
| `list_documents` | Browse stored source documents and their summaries | `page`, `limit`, `containerTag` | Document IDs, titles, types, status, dates, and summaries |
|
||||
| `get_document` | Read the available content of one document | `documentId` (required) | Document metadata, summary, and available content |
|
||||
| `list_memories` | Browse recent extracted memory entries and their source document IDs | `page`, `limit`, `containerTag` | Memory IDs, text, versions, and source document IDs |
|
||||
|
|
@ -70,7 +71,7 @@ Use the retrieval tools for different questions:
|
|||
|
||||
### Save or forget
|
||||
|
||||
`add_memory` saves the supplied `content` by default. Set `action` to `forget` when a fact is outdated or should be removed. There is no separate forget tool.
|
||||
`add_memory` saves the supplied `content`. Use `forget_memory` when a fact is outdated or should be removed. Both tools use the active space unless you pass `namespace`. The deprecated `containerTag` input and `add_memory` with `action: "forget"` remain supported for existing clients. Because the legacy action can remove information, `add_memory` retains its destructive safety annotation.
|
||||
|
||||
If the content is already final, the assistant should use `add_memory`. If you want to review, edit, or choose a space before saving, it should open the `guided-save` widget instead.
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ The client discovers the OAuth authorization server through
|
|||
| `list_memories` | List extracted memory entries and their source document IDs |
|
||||
| `list_spaces` | List spaces visible to the authenticated account |
|
||||
| `who_am_i` | Return identity, access, and active-space context |
|
||||
| `add_memory` | Save or forget a memory |
|
||||
| `add_memory` | Save a memory (legacy `action: "forget"` remains supported) |
|
||||
| `forget_memory` | Forget a memory matching the supplied content |
|
||||
|
||||
### MCP App launchers
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
const EXPECTED_TOOLS = [
|
||||
"add_memory",
|
||||
"fetch-graph-data",
|
||||
"forget_memory",
|
||||
"getDocument",
|
||||
"get_document",
|
||||
"get_profile",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
withLegacyNamespace,
|
||||
} from "./compat"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import { forgetMemory } from "./forget-memory"
|
||||
import { addMemoryOutputSchema, type AddMemoryOutput } from "./output-schemas"
|
||||
import { textContent, type ToolDeps } from "./types"
|
||||
|
||||
|
|
@ -14,8 +15,14 @@ export function register(deps: ToolDeps) {
|
|||
content: z
|
||||
.string()
|
||||
.max(200000, "Content exceeds maximum length")
|
||||
.describe("The memory content to save or forget"),
|
||||
action: z.enum(["save", "forget"]).optional().default("save"),
|
||||
.describe("The memory content to save"),
|
||||
action: z
|
||||
.enum(["save", "forget"])
|
||||
.optional()
|
||||
.default("save")
|
||||
.describe(
|
||||
"Deprecated compatibility input. Use forget_memory to forget content; omit action to save.",
|
||||
),
|
||||
namespace: optionalNamespaceSchema,
|
||||
...legacyNamespaceInput,
|
||||
})
|
||||
|
|
@ -24,30 +31,17 @@ export function register(deps: ToolDeps) {
|
|||
"add_memory",
|
||||
{
|
||||
description:
|
||||
"Add (save) or forget a memory in the user's ACTIVE space. Defaults to 'save'. The target space is the one the user selected via select-space; pass namespace only to override it. Use 'forget' when information is outdated or the user asks to remove it.",
|
||||
"Save a memory in the user's ACTIVE space. The target space is the one the user selected via select-space; pass namespace only to override it. To remove outdated information, use the separately exposed forget_memory tool. Legacy action='forget' remains supported for existing clients.",
|
||||
inputSchema,
|
||||
outputSchema: addMemoryOutputSchema,
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async (args) => {
|
||||
if (args.action === "forget") return forgetMemory(deps, args)
|
||||
try {
|
||||
const namespace = await deps.resolveNamespace(namespaceArg(args))
|
||||
const client = deps.getClient(namespace)
|
||||
|
||||
if (args.action === "forget") {
|
||||
const result = await client.forgetMemory(args.content)
|
||||
const structuredContent: AddMemoryOutput = withLegacyNamespace({
|
||||
action: "forget",
|
||||
success: result.success,
|
||||
namespace: result.namespace,
|
||||
message: result.message,
|
||||
})
|
||||
return {
|
||||
content: [textContent(result.message)],
|
||||
structuredContent,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.createMemory(args.content)
|
||||
const message = `Memory saved (ID: ${result.id}, space: ${result.namespace})`
|
||||
const structuredContent: AddMemoryOutput = withLegacyNamespace({
|
||||
|
|
|
|||
154
apps/mcp/src/server/tools/forget-memory.test.ts
Normal file
154
apps/mcp/src/server/tools/forget-memory.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, expect, it, vi } from "vitest"
|
||||
import { z } from "zod"
|
||||
import * as addMemory from "./add-memory"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import { namespaceArg } from "./compat"
|
||||
import * as forgetMemory from "./forget-memory"
|
||||
import { errorResult, type ToolDeps } from "./types"
|
||||
|
||||
function setup() {
|
||||
const tools = new Map<
|
||||
string,
|
||||
{
|
||||
config: {
|
||||
description: string
|
||||
inputSchema: z.ZodObject<z.ZodRawShape>
|
||||
outputSchema: z.ZodTypeAny
|
||||
annotations: typeof MEMORY_TOOL_ANNOTATIONS
|
||||
}
|
||||
cb: (args: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
>()
|
||||
const client = {
|
||||
createMemory: vi.fn().mockResolvedValue({
|
||||
id: "d1",
|
||||
status: "queued",
|
||||
namespace: "active",
|
||||
}),
|
||||
forgetMemory: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
namespace: "active",
|
||||
message: "Memory forgotten",
|
||||
}),
|
||||
}
|
||||
const deps = {
|
||||
server: {
|
||||
registerTool: (name: string, config: unknown, cb: unknown) => {
|
||||
tools.set(name, { config, cb } as NonNullable<
|
||||
ReturnType<typeof tools.get>
|
||||
>)
|
||||
},
|
||||
},
|
||||
getClient: vi.fn(() => client),
|
||||
resolveNamespace: vi.fn(async (explicit?: string) => explicit ?? "active"),
|
||||
errorResult,
|
||||
} as unknown as ToolDeps
|
||||
addMemory.register(deps)
|
||||
forgetMemory.register(deps)
|
||||
const getTool = (name: string) => {
|
||||
const tool = tools.get(name)
|
||||
if (!tool) throw new Error(`${name} not registered`)
|
||||
return tool
|
||||
}
|
||||
return { tools, getTool, deps, client }
|
||||
}
|
||||
|
||||
describe("separate memory operations", () => {
|
||||
it("exposes save and forget with descriptions, schemas, and safety hints", () => {
|
||||
const { tools, getTool } = setup()
|
||||
expect([...tools.keys()]).toEqual(["add_memory", "forget_memory"])
|
||||
for (const tool of tools.values()) {
|
||||
expect(tool.config.description.length).toBeGreaterThan(0)
|
||||
expect(tool.config.annotations).toEqual(MEMORY_TOOL_ANNOTATIONS)
|
||||
expect(tool.config.inputSchema.safeParse({}).success).toBe(false)
|
||||
expect(
|
||||
tool.config.inputSchema.safeParse({ content: "x".repeat(200001) })
|
||||
.success,
|
||||
).toBe(false)
|
||||
}
|
||||
const schema = z.toJSONSchema(getTool("forget_memory").config.inputSchema)
|
||||
expect(schema.properties).not.toHaveProperty("action")
|
||||
})
|
||||
|
||||
it("saves by default without forgetting", async () => {
|
||||
const { getTool, client } = setup()
|
||||
const tool = getTool("add_memory")
|
||||
const result = await tool.cb(
|
||||
tool.config.inputSchema.parse({ content: "A fact" }),
|
||||
)
|
||||
expect(client.createMemory).toHaveBeenCalledWith("A fact")
|
||||
expect(client.forgetMemory).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({
|
||||
structuredContent: { action: "save", id: "d1" },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{}, "active"],
|
||||
[{ namespace: "explicit" }, "explicit"],
|
||||
[{ containerTag: "legacy" }, "legacy"],
|
||||
[{ namespace: "explicit", containerTag: "legacy" }, "explicit"],
|
||||
])("forgets in the resolved space for %j", async (scope, namespace) => {
|
||||
const { getTool, deps, client } = setup()
|
||||
const tool = getTool("forget_memory")
|
||||
const result = await tool.cb(
|
||||
tool.config.inputSchema.parse({ content: "Old fact", ...scope }),
|
||||
)
|
||||
expect(deps.resolveNamespace).toHaveBeenCalledWith(namespaceArg(scope))
|
||||
expect(deps.getClient).toHaveBeenCalledWith(namespace)
|
||||
expect(client.forgetMemory).toHaveBeenCalledWith("Old fact")
|
||||
expect(client.createMemory).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({
|
||||
structuredContent: {
|
||||
action: "forget",
|
||||
containerTag: "active",
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
expect(
|
||||
tool.config.outputSchema.safeParse(
|
||||
(result as { structuredContent: unknown }).structuredContent,
|
||||
).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("preserves the legacy forget action and response", async () => {
|
||||
const { getTool, client } = setup()
|
||||
const invoke = (name: string, args: Record<string, unknown>) => {
|
||||
const tool = getTool(name)
|
||||
return tool.cb(tool.config.inputSchema.parse(args))
|
||||
}
|
||||
const legacy = await invoke("add_memory", {
|
||||
content: "Old fact",
|
||||
action: "forget",
|
||||
})
|
||||
const separate = await invoke("forget_memory", { content: "Old fact" })
|
||||
expect(legacy).toEqual(separate)
|
||||
expect(client.createMemory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns unsuccessful forget results without saving", async () => {
|
||||
const { getTool, client } = setup()
|
||||
client.forgetMemory.mockResolvedValueOnce({
|
||||
success: false,
|
||||
namespace: "active",
|
||||
message: "No matching memory found",
|
||||
})
|
||||
const result = await getTool("forget_memory").cb({ content: "Missing" })
|
||||
expect(result).toMatchObject({ structuredContent: { success: false } })
|
||||
expect(client.createMemory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns errors from namespace resolution and the client", async () => {
|
||||
const { getTool, deps, client } = setup()
|
||||
vi.mocked(deps.resolveNamespace).mockRejectedValueOnce(new Error("Denied"))
|
||||
expect(await getTool("forget_memory").cb({ content: "Old fact" })).toEqual(
|
||||
errorResult(new Error("Denied")),
|
||||
)
|
||||
expect(client.forgetMemory).not.toHaveBeenCalled()
|
||||
client.forgetMemory.mockRejectedValueOnce(new Error("Unavailable"))
|
||||
expect(await getTool("forget_memory").cb({ content: "Old fact" })).toEqual(
|
||||
errorResult(new Error("Unavailable")),
|
||||
)
|
||||
})
|
||||
})
|
||||
52
apps/mcp/src/server/tools/forget-memory.ts
Normal file
52
apps/mcp/src/server/tools/forget-memory.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { z } from "zod"
|
||||
import { optionalNamespaceSchema } from "../namespace"
|
||||
import { MEMORY_TOOL_ANNOTATIONS } from "./annotations"
|
||||
import {
|
||||
legacyNamespaceInput,
|
||||
namespaceArg,
|
||||
withLegacyNamespace,
|
||||
} from "./compat"
|
||||
import { forgetMemoryOutputSchema } from "./output-schemas"
|
||||
import { textContent, type ToolDeps } from "./types"
|
||||
|
||||
export async function forgetMemory(
|
||||
deps: ToolDeps,
|
||||
args: { content: string; namespace?: string; containerTag?: string },
|
||||
) {
|
||||
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,
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
deps.server.registerTool(
|
||||
"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.",
|
||||
inputSchema: z.object({
|
||||
content: z
|
||||
.string()
|
||||
.max(200000, "Content exceeds maximum length")
|
||||
.describe("The memory content to forget"),
|
||||
namespace: optionalNamespaceSchema,
|
||||
...legacyNamespaceInput,
|
||||
}),
|
||||
outputSchema: forgetMemoryOutputSchema,
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
async (args) => forgetMemory(deps, args),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import * as addMemory from "./add-memory"
|
||||
import * as fetchGraphData from "./fetch-graph-data"
|
||||
import * as forgetMemory from "./forget-memory"
|
||||
import * as getDocument from "./get-document"
|
||||
import * as getProfile from "./get-profile"
|
||||
import * as guidedSave from "./guided-save"
|
||||
|
|
@ -68,6 +69,7 @@ export function registerAllTools(toolDeps: ToolDeps) {
|
|||
memoryGraph.register(deps)
|
||||
fetchGraphData.register(deps)
|
||||
addMemory.register(deps)
|
||||
forgetMemory.register(deps)
|
||||
guidedSave.register(deps)
|
||||
saveMemory.register(deps)
|
||||
uploadFile.register(deps)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export const addMemoryOutputSchema = z.object({
|
|||
|
||||
export type AddMemoryOutput = z.infer<typeof addMemoryOutputSchema>
|
||||
|
||||
export const forgetMemoryOutputSchema = addMemoryOutputSchema.extend({
|
||||
action: z.literal("forget"),
|
||||
})
|
||||
|
||||
export const getDocumentOutputSchema = z.object({
|
||||
namespace: z.string(),
|
||||
...legacyNamespaceOutput,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue