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/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/.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/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/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 39256cf7..3052d898 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 @@ -191,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, @@ -207,39 +213,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/format.ts b/apps/mcp/src/server/format.ts index d177ac68..963f1ccf 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 list_memories with page: ${pagination.currentPage + 1}.`, + ) + } + return parts.join("\n") } const blocks = activeEntries.map((entry) => { diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index eef351c2..c4cbeb3a 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,18 @@ 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), + ) + 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 +88,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 +211,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.ts b/apps/mcp/src/server/oauth-metadata.ts new file mode 100644 index 00000000..01ca5846 --- /dev/null +++ b/apps/mcp/src/server/oauth-metadata.ts @@ -0,0 +1,10 @@ +export const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource" + +// 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 + resourceUrl.pathname = `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}` + return resourceUrl.toString() +} diff --git a/apps/mcp/src/server/tools/add-memory.ts b/apps/mcp/src/server/tools/add-memory.ts index 97bdd6da..92e6b24f 100644 --- a/apps/mcp/src/server/tools/add-memory.ts +++ b/apps/mcp/src/server/tools/add-memory.ts @@ -10,6 +10,14 @@ import { forgetMemory } from "./forget-memory" import { addMemoryOutputSchema, type AddMemoryOutput } from "./output-schemas" import { textContent, type ToolDeps } from "./types" +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,6 +33,7 @@ export function register(deps: ToolDeps) { ), namespace: optionalNamespaceSchema, ...legacyNamespaceInput, + metadata: memoryMetadataSchema, }) deps.server.registerTool( @@ -36,13 +45,13 @@ 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) - 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", diff --git a/apps/mcp/src/server/tools/forget-memory.ts b/apps/mcp/src/server/tools/forget-memory.ts index 1369fbde..feacef4a 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,127 @@ 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 +} + +// 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( + elicitation && + typeof elicitation === "object" && + (Reflect.get(elicitation, "form") !== undefined || + Reflect.get(elicitation, "url") === undefined), + ) +} + +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"], + }, + }), + }, + 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 +149,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..f8074881 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -25,6 +25,7 @@ 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 diff --git a/apps/mcp/src/server/tools/save-memory.ts b/apps/mcp/src/server/tools/save-memory.ts index c844a3b6..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,6 +14,7 @@ export function register(deps: ToolDeps) { description: "Save content to memory", inputSchema: z.object({ content: z.string().min(1), + metadata: memoryMetadataSchema, namespace: namespaceSchema.optional(), ...legacyNamespaceInput, viewId: z.string().uuid().optional(), @@ -28,7 +30,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,