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:
Dhravya 2026-10-11 02:37:14 +00:00
parent 552803c018
commit 5a6425b5e9
No known key found for this signature in database
GPG key ID: AB7AE7EA7D4AF7AD
8 changed files with 229 additions and 20 deletions

View file

@ -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.

View file

@ -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

View file

@ -10,6 +10,7 @@ import {
const EXPECTED_TOOLS = [
"add_memory",
"fetch-graph-data",
"forget_memory",
"getDocument",
"get_document",
"get_profile",

View file

@ -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({

View 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")),
)
})
})

View 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),
)
}

View file

@ -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)

View file

@ -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,