This commit is contained in:
Mahesh Sanikommu 2026-10-15 19:36:17 +00:00 • committed by GitHub
commit 1c7d26f703
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 251 additions and 49 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string | number | boolean | string[]>,
): 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<ForgetCandidate | null> {
try {
const preview = await this.client.memories.forgetMatching(
this.namespace,
{ query: content, dryRun: true },
)
const match = preview.matches[0]
if (!match) {
return {
success: false,
message: "No matching memory found to forget.",
namespace: this.namespace,
}
}
return match ? { id: match.id, memory: match.memory } : null
} catch (error) {
this.handleOperationError("Forget memory request", error)
}
}
async getMemory(id: string): Promise<ForgetCandidate | null> {
try {
const memory = await this.client.memories.get(this.namespace, id)
return memory.isForgotten
? null
: { id: memory.id, memory: memory.memory }
} catch (error) {
if (isNotFound(error)) return null
this.handleOperationError("Get memory request", error)
}
}
async forgetMemoryById(
candidate: ForgetCandidate,
): Promise<{ success: boolean; message: string; namespace: string }> {
try {
const result = await this.client.memories.forget(this.namespace, {
ids: [match.id],
ids: [candidate.id],
})
if (result.count === 0) {
const reason = result.errors[0]?.error ?? "unknown error"
return {
success: false,
message: `Could not forget memory ${match.id}: ${reason}`,
message: `Could not forget memory ${candidate.id}: ${reason}`,
namespace: this.namespace,
}
}
return {
success: true,
message: `Forgot memory (ID: ${match.id}): "${limitByChars(match.memory, 100)}"`,
message: `Forgot memory (ID: ${candidate.id}): "${limitByChars(candidate.memory, 100)}"`,
namespace: this.namespace,
}
} catch (error) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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<CallToolResult | InputRequiredResult> {
try {
const namespace = await deps.resolveNamespace(namespaceArg(args))
const result = await deps.getClient(namespace).forgetMemory(args.content)
return {
content: [textContent(result.message)],
structuredContent: withLegacyNamespace({
action: "forget" as const,
success: result.success,
namespace: result.namespace,
message: result.message,
}),
if (!args.id && !args.content) {
throw new Error("Provide the memory id or content to forget.")
}
const namespace = await deps.resolveNamespace(namespaceArg(args))
const client = deps.getClient(namespace)
const candidate = args.id
? await client.getMemory(args.id)
: await client.findMemoryToForget(args.content ?? "")
if (!candidate) {
return result(
namespace,
"not_found",
"No matching memory found to forget.",
)
}
if (canElicit(context)) {
const answer = inputResponse(context?.mcpReq.inputResponses, CONFIRM_KEY)
const askedId = context?.mcpReq.requestState?.()
if (answer.kind !== "elicit" || askedId !== candidate.id) {
return askToConfirm(candidate, namespace)
}
if (answer.action !== "accept" || answer.content?.confirm !== true) {
return result(
namespace,
"cancelled",
"The user chose to keep this memory. Nothing was forgotten.",
{ candidate },
)
}
} else if (!(args.confirm === true && args.id === candidate.id)) {
return result(
namespace,
"confirmation_required",
`Nothing forgotten yet. Show the user this memory and ask whether to forget it:\n"${candidate.memory}" (ID: ${candidate.id})\nOnly if they say yes, call forget_memory again with id "${candidate.id}" and confirm: true.`,
{ candidate },
)
}
const forgotten = await client.forgetMemoryById(candidate)
return result(
forgotten.namespace,
forgotten.success ? "forgotten" : "not_found",
forgotten.message,
{ success: forgotten.success, candidate },
)
} catch (error) {
return deps.errorResult(error)
}
@ -35,18 +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),
)
}

View file

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

View file

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