From 26a2fd6f17ec489e4c6fb5ee2e6ff33eb1c5a03f Mon Sep 17 00:00:00 2001 From: nikhil rajpurohit Date: Mon, 12 Oct 2026 16:39:27 -0700 Subject: [PATCH 1/9] 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({ From e2af9e0205eda23c59aa29b24d4c4317ba6af158 Mon Sep 17 00:00:00 2001 From: Rishi Gupta Date: Mon, 12 Oct 2026 16:39:44 -0700 Subject: [PATCH 2/9] feat(mcp): support metadata in add_memory (#1748) --- apps/docs/supermemory-mcp/mcp.mdx | 2 +- apps/mcp/e2e/space-scope.test.ts | 2 ++ apps/mcp/src/server/client/index.ts | 3 ++- apps/mcp/src/server/tools/add-memory.ts | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/docs/supermemory-mcp/mcp.mdx b/apps/docs/supermemory-mcp/mcp.mdx index 2835f497..094a3705 100644 --- a/apps/docs/supermemory-mcp/mcp.mdx +++ b/apps/docs/supermemory-mcp/mcp.mdx @@ -48,7 +48,7 @@ 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 | `content` (required), `namespace` | Save confirmation | +| `add_memory` | Save information | `content` (required), `namespace`, optional `metadata` | 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 | diff --git a/apps/mcp/e2e/space-scope.test.ts b/apps/mcp/e2e/space-scope.test.ts index c1157951..27b7fb15 100644 --- a/apps/mcp/e2e/space-scope.test.ts +++ b/apps/mcp/e2e/space-scope.test.ts @@ -22,10 +22,12 @@ describeWithAuth("MCP - space scoping", () => { const plainTools = (await plain.client.listTools()).tools expect(propsOf(plainTools, "add_memory")).toHaveProperty("namespace") + expect(propsOf(plainTools, "add_memory")).toHaveProperty("metadata") expect(propsOf(plainTools, "search_memory")).toHaveProperty("namespace") expect(propsOf(plainTools, "get_profile")).toHaveProperty("namespace") expect(propsOf(scopedTools, "add_memory")).toHaveProperty("namespace") + expect(propsOf(scopedTools, "add_memory")).toHaveProperty("metadata") expect(propsOf(scopedTools, "search_memory")).toHaveProperty("namespace") expect(propsOf(scopedTools, "get_profile")).toHaveProperty("namespace") } finally { diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 3a327115..0835ca32 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -196,11 +196,12 @@ export class SupermemoryClient { async createMemory( content: string, + metadata?: Record, ): Promise<{ id: string; status: string; namespace: string }> { try { const result = await this.client.add(this.namespace, { content, - metadata: { sm_source: MCP_SOURCE }, + metadata: { ...metadata, sm_source: MCP_SOURCE }, }) return { id: result.id, diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index 99254f25..e2dd70a4 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -25,6 +25,7 @@ export function register(deps: ToolDeps) { ), namespace: optionalNamespaceSchema, ...legacyNamespaceInput, + metadata: z.record(z.string(), z.unknown()).optional().describe("Optional metadata to attach to the saved memory"), }) deps.server.registerTool( @@ -42,7 +43,7 @@ export function register(deps: ToolDeps) { const namespace = await deps.resolveNamespace(namespaceArg(args)) const client = deps.getClient(namespace) - const result = await client.createMemory(args.content) + const result = await client.createMemory(args.content, args.metadata) const message = `Memory saved (ID: ${result.id}, space: ${result.namespace})` const structuredContent: AddMemoryOutput = withLegacyNamespace({ action: "save", From 8b9fecf4c6f973eae5fdcc8df32a33ace081b658 Mon Sep 17 00:00:00 2001 From: abhinav7x94 <204053250+abhinav7x94@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:45 -0700 Subject: [PATCH 3/9] fix(mcp): harden OAuth metadata discovery (#1483) --- apps/mcp/README.md | 10 ++- apps/mcp/src/server/index.ts | 27 +++++-- apps/mcp/src/server/oauth-metadata.test.ts | 91 ++++++++++++++++++++++ apps/mcp/src/server/oauth-metadata.ts | 16 ++++ 4 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 apps/mcp/src/server/oauth-metadata.test.ts create mode 100644 apps/mcp/src/server/oauth-metadata.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 26476920..4f7b9762 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -39,8 +39,12 @@ Example client configuration: } ``` -The client discovers the OAuth authorization server through -`/.well-known/oauth-protected-resource/mcp`. +For the default resource, the client discovers the OAuth authorization server +through `/.well-known/oauth-protected-resource/mcp`. Custom deployments derive +this location from `MCP_RESOURCE` by inserting +`/.well-known/oauth-protected-resource` before the resource path while +preserving its query string. For example, `https://memory.example.com/gateway/mcp` +uses `https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp`. ## Tools @@ -131,7 +135,7 @@ discovery and rejection tests still run. | Variable | Purpose | Default | | --- | --- | --- | | `API_URL` | Supermemory API and OAuth issuer | `https://api.supermemory.ai` | -| `MCP_RESOURCE` | Expected OAuth audience | `https://mcp.supermemory.ai/mcp` | +| `MCP_RESOURCE` | Expected OAuth audience and canonical resource URL used for metadata discovery | `https://mcp.supermemory.ai/mcp` | | `ALLOWED_MCP_ORIGIN_HOSTNAMES` | Additional comma-separated browser origins | Built-in host allowlist | | `POSTHOG_API_KEY` | Server-side MCP tool analytics project key | Disabled | | `POSTHOG_HOST` | PostHog ingestion host | `https://us.i.posthog.com` | diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index eef351c2..be520df9 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -10,6 +10,10 @@ import { type AuthUser, } from "./auth" import { SupermemoryMCP } from "./legacy-protocol-state" +import { + PROTECTED_RESOURCE_METADATA_PATH, + protectedResourceMetadataUrl, +} from "./oauth-metadata" import { createSupermemoryServer } from "./server" import type { ActorContext, ServerEnv } from "./types" import { SpaceState, uploadStateName } from "./space-state" @@ -20,8 +24,6 @@ const app = new Hono<{ Bindings: Bindings }>() const DEFAULT_API_URL = "https://api.supermemory.ai" const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp" -const PROTECTED_RESOURCE_METADATA_PATH = - "/.well-known/oauth-protected-resource/mcp" const UPLOAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [ @@ -64,6 +66,19 @@ app.get("/", (c) => { function resourceMetadata(c: Context<{ Bindings: Bindings }>) { const apiUrl = c.env.API_URL || DEFAULT_API_URL const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE + const requestUrl = new URL(c.req.url) + const canonicalMetadataUrl = new URL( + protectedResourceMetadataUrl(mcpResource), + ) + // MCP clients also try the root well-known location as a compatibility fallback. + const isMcpRootFallback = + requestUrl.pathname === PROTECTED_RESOURCE_METADATA_PATH && + requestUrl.search === "" + const isCanonicalLocation = + requestUrl.pathname === canonicalMetadataUrl.pathname && + requestUrl.search === canonicalMetadataUrl.search + + if (!isMcpRootFallback && !isCanonicalLocation) return c.notFound() return c.json({ resource: mcpResource, @@ -74,8 +89,8 @@ function resourceMetadata(c: Context<{ Bindings: Bindings }>) { }) } -app.get("/.well-known/oauth-protected-resource", resourceMetadata) app.get(PROTECTED_RESOURCE_METADATA_PATH, resourceMetadata) +app.get(`${PROTECTED_RESOURCE_METADATA_PATH}/*`, resourceMetadata) app.get("/.well-known/openai-apps-challenge", (c) => { return c.text(c.env.OPENAI_APPS_CHALLENGE || "") @@ -197,11 +212,7 @@ async function handleMcpRequest( const apiUrl = c.env.API_URL || DEFAULT_API_URL const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE - const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || "" - const reqProto = c.req.header("x-forwarded-proto") || "https" - const resourceMetadataUrl = reqHost - ? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}` - : PROTECTED_RESOURCE_METADATA_PATH + const resourceMetadataUrl = protectedResourceMetadataUrl(mcpResource) const mcpOrigin = c.env.MCP_PUBLIC_ORIGIN || new URL(mcpResource).origin if (!token) return unauthorizedResponse(resourceMetadataUrl) diff --git a/apps/mcp/src/server/oauth-metadata.test.ts b/apps/mcp/src/server/oauth-metadata.test.ts new file mode 100644 index 00000000..6fe3d54d --- /dev/null +++ b/apps/mcp/src/server/oauth-metadata.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest" +import app from "./index" +import { PROTECTED_RESOURCE_METADATA_PATH } from "./oauth-metadata" +import type { ServerEnv } from "./types" + +vi.mock("cloudflare:workers", () => ({ DurableObject: class {} })) +vi.mock("../../dist/src/widget/index.html", () => ({ default: "" })) + +describe("OAuth protected-resource metadata challenge", () => { + it("ignores spoofed forwarding headers", async () => { + const resource = "https://mcp.example.com/mcp" + const response = await app.request( + "https://mcp.example.com/mcp", + { + method: "POST", + headers: { + host: "attacker.example", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "http", + }, + }, + { MCP_RESOURCE: resource } as ServerEnv, + ) + + expect(response.status).toBe(401) + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}/mcp"`, + ) + }) + + it("keeps discovery aligned behind a trusted proxy", async () => { + const resource = "https://memory.example.com/gateway/mcp/?tenant=acme" + const metadataUrl = + "https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp/?tenant=acme" + const env = { MCP_RESOURCE: resource } as ServerEnv + const response = await app.request( + "http://worker.internal/mcp", + { method: "POST" }, + env, + ) + + expect(response.status).toBe(401) + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="${metadataUrl}"`, + ) + + const metadataResponse = await app.request(metadataUrl, undefined, env) + expect(metadataResponse.status).toBe(200) + await expect(metadataResponse.json()).resolves.toMatchObject({ resource }) + + const rootFallbackResponse = await app.request( + `https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + undefined, + env, + ) + expect(rootFallbackResponse.status).toBe(200) + + for (const mismatchedUrl of [ + metadataUrl.replace("tenant=acme", "tenant=other"), + metadataUrl.replace("/gateway/mcp/", "/other/"), + `https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}?tenant=acme`, + ]) { + const mismatchedResponse = await app.request( + mismatchedUrl, + undefined, + env, + ) + expect(mismatchedResponse.status).toBe(404) + } + }) + + it("supports a resource identifier without a path", async () => { + const resource = "https://mcp.example.com" + const response = await app.request(resource, { method: "POST" }, { + MCP_RESOURCE: resource, + } as ServerEnv) + + expect(response.status).toBe(401) + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}"`, + ) + + const metadataResponse = await app.request( + `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + undefined, + { MCP_RESOURCE: resource } as ServerEnv, + ) + expect(metadataResponse.status).toBe(200) + await expect(metadataResponse.json()).resolves.toMatchObject({ resource }) + }) +}) diff --git a/apps/mcp/src/server/oauth-metadata.ts b/apps/mcp/src/server/oauth-metadata.ts new file mode 100644 index 00000000..73d8a01a --- /dev/null +++ b/apps/mcp/src/server/oauth-metadata.ts @@ -0,0 +1,16 @@ +export const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource" + +/** + * Builds the protected-resource metadata URL advertised to OAuth clients. + * + * The canonical resource is the single source of truth so the advertised URL + * and the metadata document cannot disagree. Forwarded headers are intentionally + * excluded because they are request-controlled in some proxy deployments. + */ +export function protectedResourceMetadataUrl(resource: string): string { + const resourceUrl = new URL(resource) + const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname + resourceUrl.pathname = `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}` + return resourceUrl.toString() +} From 2222e41d8af83b44e02ccea79a210edbc64de98d Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:50 -0700 Subject: [PATCH 4/9] fix(mcp): preserve pagination after filtering memories (#1518) --- .github/workflows/ci.yml | 4 ++ apps/mcp/src/server/format.test.ts | 107 +++++++++++++++++++++++++++++ apps/mcp/src/server/format.ts | 17 ++++- 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 apps/mcp/src/server/format.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e93d88f..37e2966a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,5 +96,9 @@ jobs: - name: Run Memory Graph unit tests run: bun run --cwd packages/memory-graph test + - name: Run MCP unit tests + working-directory: apps/mcp + run: bun run test:unit + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/mcp/src/server/format.test.ts b/apps/mcp/src/server/format.test.ts new file mode 100644 index 00000000..42d0ec3a --- /dev/null +++ b/apps/mcp/src/server/format.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest" +import type { MemoryEntriesResponse, MemoryEntry } from "./client" +import { formatMemoryEntriesList } from "./format" + +function memoryEntry(overrides: Partial = {}): MemoryEntry { + return { + id: "memory-1", + memory: "Current fact", + version: 1, + isLatest: true, + isForgotten: false, + createdAt: "2026-08-15T10:00:00.000Z", + updatedAt: "2026-08-15T10:00:00.000Z", + ...overrides, + } +} + +function memoryResponse( + overrides: Partial = {}, +): MemoryEntriesResponse { + return { + memoryEntries: [], + pagination: { + currentPage: 1, + limit: 10, + totalItems: 0, + totalPages: 1, + }, + ...overrides, + } +} + +describe("formatMemoryEntriesList", () => { + it("reports a truly empty store", () => { + expect(formatMemoryEntriesList(memoryResponse())).toBe( + "No active memories stored yet.", + ) + }) + + it("continues past a filtered-empty page when a later page has active data", () => { + const firstPage = formatMemoryEntriesList( + memoryResponse({ + memoryEntries: [ + memoryEntry({ id: "forgotten", isForgotten: true }), + memoryEntry({ id: "superseded", isLatest: false }), + ], + pagination: { + currentPage: 1, + limit: 2, + totalItems: 3, + totalPages: 2, + }, + }), + ) + const secondPage = formatMemoryEntriesList( + memoryResponse({ + memoryEntries: [memoryEntry({ id: "still-active" })], + pagination: { + currentPage: 2, + limit: 2, + totalItems: 3, + totalPages: 2, + }, + }), + ) + + expect(firstPage).toBe( + "No active memories on page 1 (2 pages total).\n\nMore available - call listMemories with page: 2.", + ) + expect(secondPage).toContain("[still-active] Current fact") + }) + + it("does not offer another page from a filtered-empty last page", () => { + const result = formatMemoryEntriesList( + memoryResponse({ + memoryEntries: [memoryEntry({ isForgotten: true })], + pagination: { + currentPage: 2, + limit: 10, + totalItems: 11, + totalPages: 2, + }, + }), + ) + + expect(result).toBe("No active memories on page 2 (2 pages total).") + expect(result).not.toContain("More available") + }) + + it("keeps active-memory output unchanged", () => { + const result = formatMemoryEntriesList( + memoryResponse({ + memoryEntries: [memoryEntry()], + pagination: { + currentPage: 1, + limit: 10, + totalItems: 1, + totalPages: 1, + }, + }), + ) + + expect(result).toBe( + "1 active memory (page 1 of 1, 1 memory entry total), newest first.\n\n- [memory-1] Current fact\n version 1 | updated 2026-08-15", + ) + }) +}) diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts index d177ac68..387f887c 100644 --- a/apps/mcp/src/server/format.ts +++ b/apps/mcp/src/server/format.ts @@ -81,9 +81,20 @@ export function formatMemoryEntriesList( ) if (activeEntries.length === 0) { - return pagination.currentPage > 1 - ? `No active memories on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).` - : "No active memories stored yet." + if (pagination.currentPage === 1 && pagination.totalItems === 0) { + return "No active memories stored yet." + } + + const parts = [ + `No active memories on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`, + ] + if (pagination.currentPage < pagination.totalPages) { + parts.push( + "", + `More available - call listMemories with page: ${pagination.currentPage + 1}.`, + ) + } + return parts.join("\n") } const blocks = activeEntries.map((entry) => { From 8200e7f27dbc45b06fd8b8949a0261f1da79c0b3 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Mon, 12 Oct 2026 16:42:02 -0700 Subject: [PATCH 5/9] fix(mcp): adapt community fixes to v5 Restricts add_memory metadata to the flat value types v5 accepts, points the pagination hint at list_memories, and documents MCP_RESOURCE in .dev.vars.example now that OAuth metadata is advertised from it. --- apps/mcp/.dev.vars.example | 2 ++ apps/mcp/src/server/client/index.ts | 2 +- apps/mcp/src/server/format.test.ts | 2 +- apps/mcp/src/server/format.ts | 2 +- apps/mcp/src/server/tools/add-memory.ts | 9 ++++++++- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/mcp/.dev.vars.example b/apps/mcp/.dev.vars.example index dfc969e5..2e18c643 100644 --- a/apps/mcp/.dev.vars.example +++ b/apps/mcp/.dev.vars.example @@ -3,6 +3,8 @@ API_URL= MCP_URL= +# OAuth metadata is advertised from this, so set it to your local MCP URL + /mcp (defaults to prod). +MCP_RESOURCE= #(optional) POSTHOG_API_KEY= \ No newline at end of file diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 0835ca32..3052d898 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -196,7 +196,7 @@ export class SupermemoryClient { async createMemory( content: string, - metadata?: Record, + metadata?: Record, ): Promise<{ id: string; status: string; namespace: string }> { try { const result = await this.client.add(this.namespace, { diff --git a/apps/mcp/src/server/format.test.ts b/apps/mcp/src/server/format.test.ts index 42d0ec3a..a4b9ff36 100644 --- a/apps/mcp/src/server/format.test.ts +++ b/apps/mcp/src/server/format.test.ts @@ -65,7 +65,7 @@ describe("formatMemoryEntriesList", () => { ) expect(firstPage).toBe( - "No active memories on page 1 (2 pages total).\n\nMore available - call listMemories with page: 2.", + "No active memories on page 1 (2 pages total).\n\nMore available - call list_memories with page: 2.", ) expect(secondPage).toContain("[still-active] Current fact") }) diff --git a/apps/mcp/src/server/format.ts b/apps/mcp/src/server/format.ts index 387f887c..963f1ccf 100644 --- a/apps/mcp/src/server/format.ts +++ b/apps/mcp/src/server/format.ts @@ -91,7 +91,7 @@ export function formatMemoryEntriesList( if (pagination.currentPage < pagination.totalPages) { parts.push( "", - `More available - call listMemories with page: ${pagination.currentPage + 1}.`, + `More available - call list_memories with page: ${pagination.currentPage + 1}.`, ) } return parts.join("\n") diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index e2dd70a4..6cad7416 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -25,7 +25,14 @@ export function register(deps: ToolDeps) { ), namespace: optionalNamespaceSchema, ...legacyNamespaceInput, - metadata: z.record(z.string(), z.unknown()).optional().describe("Optional metadata to attach to the saved memory"), + // v5 metadata values are flat: string, number, boolean or string list. + metadata: z + .record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]), + ) + .optional() + .describe("Optional metadata to attach to the saved memory"), }) deps.server.registerTool( From cec177a7a379ed21d7641bc2d221991e099cd834 Mon Sep 17 00:00:00 2001 From: Siddhesh Gawade Date: Wed, 14 Oct 2026 13:58:26 -0700 Subject: [PATCH 6/9] feat(mcp): support optional metadata in add_memory and save-memory tools (#1817) --- apps/mcp/src/server/tools/save-memory.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mcp/src/server/tools/save-memory.ts b/apps/mcp/src/server/tools/save-memory.ts index c844a3b6..036b0487 100644 --- a/apps/mcp/src/server/tools/save-memory.ts +++ b/apps/mcp/src/server/tools/save-memory.ts @@ -13,6 +13,10 @@ export function register(deps: ToolDeps) { description: "Save content to memory", inputSchema: z.object({ content: z.string().min(1), + metadata: z + .record(z.string(), z.unknown()) + .optional() + .describe("Optional key-value metadata to attach to the memory"), namespace: namespaceSchema.optional(), ...legacyNamespaceInput, viewId: z.string().uuid().optional(), @@ -28,7 +32,7 @@ export function register(deps: ToolDeps) { return deps.errorResult(new Error("namespace is required")) const viewId = args.viewId ?? crypto.randomUUID() const client = deps.getClient(namespace) - const result = await client.createMemory(args.content) + const result = await client.createMemory(args.content, args.metadata) const sc: ViewMessage = withLegacyView({ view: "save-success", viewId, From a954a818c0679e3469e18c105362a2e49502373a Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Wed, 14 Oct 2026 13:58:57 -0700 Subject: [PATCH 7/9] fix(mcp): share the v5 metadata schema between add_memory and save_memory --- apps/mcp/src/server/tools/add-memory.ts | 18 ++++++++++-------- apps/mcp/src/server/tools/save-memory.ts | 6 ++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index 6cad7416..ff3f7f00 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -10,6 +10,15 @@ import { forgetMemory } from "./forget-memory" import { addMemoryOutputSchema, type AddMemoryOutput } from "./output-schemas" import { textContent, type ToolDeps } from "./types" +// v5 metadata values are flat: string, number, boolean or string list. +export const memoryMetadataSchema = z + .record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]), + ) + .optional() + .describe("Optional metadata to attach to the saved memory") + export function register(deps: ToolDeps) { const inputSchema = z.object({ content: z @@ -25,14 +34,7 @@ export function register(deps: ToolDeps) { ), namespace: optionalNamespaceSchema, ...legacyNamespaceInput, - // v5 metadata values are flat: string, number, boolean or string list. - metadata: z - .record( - z.string(), - z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]), - ) - .optional() - .describe("Optional metadata to attach to the saved memory"), + metadata: memoryMetadataSchema, }) deps.server.registerTool( diff --git a/apps/mcp/src/server/tools/save-memory.ts b/apps/mcp/src/server/tools/save-memory.ts index 036b0487..3ffc3d19 100644 --- a/apps/mcp/src/server/tools/save-memory.ts +++ b/apps/mcp/src/server/tools/save-memory.ts @@ -3,6 +3,7 @@ import { saveSuccessViewSchema, type ViewMessage } from "../../shared/types" import { appResultMeta, appToolMeta } from "../app-metadata" import { namespaceSchema } from "../namespace" import { legacyNamespaceInput, namespaceArg, withLegacyView } from "./compat" +import { memoryMetadataSchema } from "./add-memory" import { ADDITIVE_MEMORY_TOOL_ANNOTATIONS } from "./annotations" import { textContent, type ToolDeps } from "./types" @@ -13,10 +14,7 @@ export function register(deps: ToolDeps) { description: "Save content to memory", inputSchema: z.object({ content: z.string().min(1), - metadata: z - .record(z.string(), z.unknown()) - .optional() - .describe("Optional key-value metadata to attach to the memory"), + metadata: memoryMetadataSchema, namespace: namespaceSchema.optional(), ...legacyNamespaceInput, viewId: z.string().uuid().optional(), From d700fc1fad36fd3081f84eb67ff43615874eb94f Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:11:48 +0000 Subject: [PATCH 8/9] test(mcp): remove newly added unit test files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- apps/mcp/src/server/format.test.ts | 107 --------- apps/mcp/src/server/oauth-metadata.test.ts | 91 -------- .../src/server/tools/forget-memory.test.ts | 211 ------------------ 3 files changed, 409 deletions(-) delete mode 100644 apps/mcp/src/server/format.test.ts delete mode 100644 apps/mcp/src/server/oauth-metadata.test.ts delete mode 100644 apps/mcp/src/server/tools/forget-memory.test.ts diff --git a/apps/mcp/src/server/format.test.ts b/apps/mcp/src/server/format.test.ts deleted file mode 100644 index a4b9ff36..00000000 --- a/apps/mcp/src/server/format.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest" -import type { MemoryEntriesResponse, MemoryEntry } from "./client" -import { formatMemoryEntriesList } from "./format" - -function memoryEntry(overrides: Partial = {}): MemoryEntry { - return { - id: "memory-1", - memory: "Current fact", - version: 1, - isLatest: true, - isForgotten: false, - createdAt: "2026-08-15T10:00:00.000Z", - updatedAt: "2026-08-15T10:00:00.000Z", - ...overrides, - } -} - -function memoryResponse( - overrides: Partial = {}, -): MemoryEntriesResponse { - return { - memoryEntries: [], - pagination: { - currentPage: 1, - limit: 10, - totalItems: 0, - totalPages: 1, - }, - ...overrides, - } -} - -describe("formatMemoryEntriesList", () => { - it("reports a truly empty store", () => { - expect(formatMemoryEntriesList(memoryResponse())).toBe( - "No active memories stored yet.", - ) - }) - - it("continues past a filtered-empty page when a later page has active data", () => { - const firstPage = formatMemoryEntriesList( - memoryResponse({ - memoryEntries: [ - memoryEntry({ id: "forgotten", isForgotten: true }), - memoryEntry({ id: "superseded", isLatest: false }), - ], - pagination: { - currentPage: 1, - limit: 2, - totalItems: 3, - totalPages: 2, - }, - }), - ) - const secondPage = formatMemoryEntriesList( - memoryResponse({ - memoryEntries: [memoryEntry({ id: "still-active" })], - pagination: { - currentPage: 2, - limit: 2, - totalItems: 3, - totalPages: 2, - }, - }), - ) - - expect(firstPage).toBe( - "No active memories on page 1 (2 pages total).\n\nMore available - call list_memories with page: 2.", - ) - expect(secondPage).toContain("[still-active] Current fact") - }) - - it("does not offer another page from a filtered-empty last page", () => { - const result = formatMemoryEntriesList( - memoryResponse({ - memoryEntries: [memoryEntry({ isForgotten: true })], - pagination: { - currentPage: 2, - limit: 10, - totalItems: 11, - totalPages: 2, - }, - }), - ) - - expect(result).toBe("No active memories on page 2 (2 pages total).") - expect(result).not.toContain("More available") - }) - - it("keeps active-memory output unchanged", () => { - const result = formatMemoryEntriesList( - memoryResponse({ - memoryEntries: [memoryEntry()], - pagination: { - currentPage: 1, - limit: 10, - totalItems: 1, - totalPages: 1, - }, - }), - ) - - expect(result).toBe( - "1 active memory (page 1 of 1, 1 memory entry total), newest first.\n\n- [memory-1] Current fact\n version 1 | updated 2026-08-15", - ) - }) -}) diff --git a/apps/mcp/src/server/oauth-metadata.test.ts b/apps/mcp/src/server/oauth-metadata.test.ts deleted file mode 100644 index 6fe3d54d..00000000 --- a/apps/mcp/src/server/oauth-metadata.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import app from "./index" -import { PROTECTED_RESOURCE_METADATA_PATH } from "./oauth-metadata" -import type { ServerEnv } from "./types" - -vi.mock("cloudflare:workers", () => ({ DurableObject: class {} })) -vi.mock("../../dist/src/widget/index.html", () => ({ default: "" })) - -describe("OAuth protected-resource metadata challenge", () => { - it("ignores spoofed forwarding headers", async () => { - const resource = "https://mcp.example.com/mcp" - const response = await app.request( - "https://mcp.example.com/mcp", - { - method: "POST", - headers: { - host: "attacker.example", - "x-forwarded-host": "attacker.example", - "x-forwarded-proto": "http", - }, - }, - { MCP_RESOURCE: resource } as ServerEnv, - ) - - expect(response.status).toBe(401) - expect(response.headers.get("WWW-Authenticate")).toBe( - `Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}/mcp"`, - ) - }) - - it("keeps discovery aligned behind a trusted proxy", async () => { - const resource = "https://memory.example.com/gateway/mcp/?tenant=acme" - const metadataUrl = - "https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp/?tenant=acme" - const env = { MCP_RESOURCE: resource } as ServerEnv - const response = await app.request( - "http://worker.internal/mcp", - { method: "POST" }, - env, - ) - - expect(response.status).toBe(401) - expect(response.headers.get("WWW-Authenticate")).toBe( - `Bearer resource_metadata="${metadataUrl}"`, - ) - - const metadataResponse = await app.request(metadataUrl, undefined, env) - expect(metadataResponse.status).toBe(200) - await expect(metadataResponse.json()).resolves.toMatchObject({ resource }) - - const rootFallbackResponse = await app.request( - `https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, - undefined, - env, - ) - expect(rootFallbackResponse.status).toBe(200) - - for (const mismatchedUrl of [ - metadataUrl.replace("tenant=acme", "tenant=other"), - metadataUrl.replace("/gateway/mcp/", "/other/"), - `https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}?tenant=acme`, - ]) { - const mismatchedResponse = await app.request( - mismatchedUrl, - undefined, - env, - ) - expect(mismatchedResponse.status).toBe(404) - } - }) - - it("supports a resource identifier without a path", async () => { - const resource = "https://mcp.example.com" - const response = await app.request(resource, { method: "POST" }, { - MCP_RESOURCE: resource, - } as ServerEnv) - - expect(response.status).toBe(401) - expect(response.headers.get("WWW-Authenticate")).toBe( - `Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}"`, - ) - - const metadataResponse = await app.request( - `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, - undefined, - { MCP_RESOURCE: resource } as ServerEnv, - ) - expect(metadataResponse.status).toBe(200) - await expect(metadataResponse.json()).resolves.toMatchObject({ resource }) - }) -}) diff --git a/apps/mcp/src/server/tools/forget-memory.test.ts b/apps/mcp/src/server/tools/forget-memory.test.ts deleted file mode 100644 index 99610995..00000000 --- a/apps/mcp/src/server/tools/forget-memory.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -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) - }) -}) From d797bf249f883b55623f36a76da6d35c4f317b73 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:11:48 +0000 Subject: [PATCH 9/9] fix(mcp): preserve confirmation compatibility across clients Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- apps/mcp/src/server/index.ts | 1 - apps/mcp/src/server/oauth-metadata.ts | 8 +------- apps/mcp/src/server/tools/add-memory.ts | 1 - apps/mcp/src/server/tools/forget-memory.ts | 12 +++++++----- apps/mcp/src/server/tools/output-schemas.ts | 2 +- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index be520df9..c4cbeb3a 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -70,7 +70,6 @@ function resourceMetadata(c: Context<{ Bindings: Bindings }>) { const canonicalMetadataUrl = new URL( protectedResourceMetadataUrl(mcpResource), ) - // MCP clients also try the root well-known location as a compatibility fallback. const isMcpRootFallback = requestUrl.pathname === PROTECTED_RESOURCE_METADATA_PATH && requestUrl.search === "" diff --git a/apps/mcp/src/server/oauth-metadata.ts b/apps/mcp/src/server/oauth-metadata.ts index 73d8a01a..01ca5846 100644 --- a/apps/mcp/src/server/oauth-metadata.ts +++ b/apps/mcp/src/server/oauth-metadata.ts @@ -1,13 +1,7 @@ export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource" -/** - * Builds the protected-resource metadata URL advertised to OAuth clients. - * - * The canonical resource is the single source of truth so the advertised URL - * and the metadata document cannot disagree. Forwarded headers are intentionally - * excluded because they are request-controlled in some proxy deployments. - */ +// Discovery must use the configured resource, never request-controlled forwarding headers. export function protectedResourceMetadataUrl(resource: string): string { const resourceUrl = new URL(resource) const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index ff3f7f00..92e6b24f 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -10,7 +10,6 @@ import { forgetMemory } from "./forget-memory" import { addMemoryOutputSchema, type AddMemoryOutput } from "./output-schemas" import { textContent, type ToolDeps } from "./types" -// v5 metadata values are flat: string, number, boolean or string list. export const memoryMetadataSchema = z .record( z.string(), diff --git a/apps/mcp/src/server/tools/forget-memory.ts b/apps/mcp/src/server/tools/forget-memory.ts index 668e1021..feacef4a 100644 --- a/apps/mcp/src/server/tools/forget-memory.ts +++ b/apps/mcp/src/server/tools/forget-memory.ts @@ -28,15 +28,18 @@ export interface ForgetMemoryArgs { containerTag?: string } -// Only 2026-era requests carry capabilities per request; stateless legacy requests never saw initialize, so they use the confirm flag instead. +// Stateless legacy requests cannot receive elicitation, so they use explicit two-call confirmation. function canElicit(context: ServerContext | undefined): boolean { const envelope = context?.mcpReq?.envelope if (!envelope) return false const capabilities = Reflect.get(envelope, CLIENT_CAPABILITIES_META_KEY) + if (!capabilities || typeof capabilities !== "object") return false + const elicitation = Reflect.get(capabilities, "elicitation") return Boolean( - capabilities && - typeof capabilities === "object" && - Reflect.get(capabilities, "elicitation"), + elicitation && + typeof elicitation === "object" && + (Reflect.get(elicitation, "form") !== undefined || + Reflect.get(elicitation, "url") === undefined), ) } @@ -60,7 +63,6 @@ function askToConfirm( }, }), }, - // Only compared against the server-resolved ID, so a tampered value can just trigger a re-ask. requestState: candidate.id, }) } diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 321fb9f4..f8074881 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -25,13 +25,13 @@ export const addMemoryOutputSchema = z.object({ message: z.string(), id: z.string().optional(), status: z.string().optional(), + memory: z.string().optional(), }) export type AddMemoryOutput = z.infer export const forgetMemoryOutputSchema = addMemoryOutputSchema.extend({ action: z.literal("forget"), - memory: z.string().optional(), }) export const getDocumentOutputSchema = z.object({