From ffe24daaf713f8a6c66d81ddf1c1a2a3ea0bab10 Mon Sep 17 00:00:00 2001 From: abhinav7x94 <204053250+abhinav7x94@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:27:53 +0530 Subject: [PATCH 1/2] fix(mcp): keep implicit recall within readable scope --- apps/docs/supermemory-mcp/mcp.mdx | 6 +- apps/docs/supermemory-mcp/setup.mdx | 2 +- apps/mcp/README.md | 7 +- apps/mcp/src/server/client/index.test.ts | 56 +++++ apps/mcp/src/server/server.ts | 3 +- apps/mcp/src/server/tools/output-schemas.ts | 2 +- .../src/server/tools/search-memory.test.ts | 209 ++++++++++++++++++ apps/mcp/src/server/tools/search-memory.ts | 28 ++- apps/mcp/src/server/tools/types.ts | 5 + 9 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 apps/mcp/src/server/client/index.test.ts create mode 100644 apps/mcp/src/server/tools/search-memory.test.ts diff --git a/apps/docs/supermemory-mcp/mcp.mdx b/apps/docs/supermemory-mcp/mcp.mdx index f38845af..81ff15ff 100644 --- a/apps/docs/supermemory-mcp/mcp.mdx +++ b/apps/docs/supermemory-mcp/mcp.mdx @@ -38,7 +38,7 @@ MCP clients that use JSON configuration generally accept this shape: A **space** keeps a team's documents, memories, and profile context focused, so AI retrieves the right knowledge without mixing unrelated work. Use separate spaces for an engineering launch, research project, finance workflow, legal matter, medical knowledge base, or any other shared initiative. Teammates collaborate within the spaces they are allowed to read or write. -Name a space to use it for one request without changing your active space. Otherwise, Supermemory uses your active space or account default. Ask to switch spaces when you want future requests to use a different active space. +Name a space to use it for one request without changing your active space. Otherwise, Supermemory uses your active space or account default. `search_memory` uses a named space as given. Without one, it uses the active space when the current OAuth grant can read it; otherwise authorization chooses the caller's readable scope instead of forcing an inaccessible default. Ask to switch spaces when you want future requests to use a different active space. ## Tools @@ -46,7 +46,7 @@ Your assistant chooses these tools automatically. Use this table when you need t | Tool | Use it for | Inputs | Result | | --- | --- | --- | --- | -| `search_memory` | Semantic recall from one space, with optional profile context | `query` (required), `includeProfile`, `containerTag` | Profile context and matching memories | +| `search_memory` | Semantic recall from a selected space or authorized readable scope | `query` (required), `includeProfile`, `containerTag` | Matching memories, optional profile context, and the selected `containerTag` or `null` | | `add_memory` | Save information or forget outdated information | `content` (required), `action` (`save` or `forget`), `containerTag` | Save or forget confirmation | | `listDocuments` | Browse stored source documents and their summaries | `page`, `limit`, `containerTag` | Document IDs, titles, types, status, dates, and summaries | | `getDocument` | Read the available content of one document | `documentId` (required) | Document metadata, summary, and available content | @@ -56,7 +56,7 @@ Your assistant chooses these tools automatically. Use this table when you need t ### Search -`search_memory` accepts a natural-language query and returns semantically relevant memories. By default, it also includes stable and recent profile context from the same space. Set `includeProfile` to `false` when only matching memories are needed. +`search_memory` accepts a natural-language query and returns semantically relevant memories. With an explicit space or an active space readable by the current OAuth grant, it also includes stable and recent profile context from that space by default. When no space is named and no readable active space is available, it searches the caller's authorized readable scope without forcing `sm_project_default`; the structured result uses `containerTag: null` and does not add space-specific profile facts. Set `includeProfile` to `false` when only matching memories are needed. Use the retrieval tools for different questions: diff --git a/apps/docs/supermemory-mcp/setup.mdx b/apps/docs/supermemory-mcp/setup.mdx index 0110e58d..50378faa 100644 --- a/apps/docs/supermemory-mcp/setup.mdx +++ b/apps/docs/supermemory-mcp/setup.mdx @@ -26,7 +26,7 @@ Supermemory MCP uses OAuth. Your client opens the authorization page so you can ## Choose a space -After connecting, space-aware tools use your active Supermemory space or account default. You can work in another space in two ways: +After connecting, space-aware tools use your active Supermemory space or account default. When `search_memory` has no named space, it uses the active space if the current OAuth grant can read it; otherwise it searches the caller's OAuth-authorized readable scope without forcing the account default. You can work in another space in two ways: - Name a space for a one-off action without changing your active space. - Ask to switch your active space for future actions. diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 1cf39bc1..d7368fe9 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -12,14 +12,17 @@ memories, profile, spaces, and interactive MCP Apps. - Active space stored as application state in a dedicated Durable Object - Space state keyed by authenticated `organizationId + userId` -The space used by an operation resolves in this order: +Most operations resolve their space in this order: 1. An explicit `containerTag` tool or prompt argument 2. The account's durable active space 3. The Supermemory client default, `sm_project_default` An explicit override applies only to that call. It does not mutate the active -space. +space. `search_memory` is the exception: when it has neither an explicit space +nor an active space readable by the current OAuth grant, it omits `containerTag` +so authorization can select the caller's readable scope. Its structured result +reports `containerTag: null` in that case. ## Server URL diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts new file mode 100644 index 00000000..215adab8 --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const sdk = vi.hoisted(() => ({ + searchMemories: vi.fn(), + getProfile: vi.fn(), +})) + +vi.mock("supermemory", () => ({ + default: class { + search = { memories: sdk.searchMemories } + profile = sdk.getProfile + }, +})) + +import { SupermemoryClient } from "." + +describe("SupermemoryClient search scope", () => { + beforeEach(() => { + sdk.searchMemories.mockReset().mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + sdk.getProfile.mockReset() + }) + + it("omits containerTag when no search scope is selected", async () => { + const client = new SupermemoryClient("token") + + await client.search("remember me") + + expect(sdk.searchMemories).toHaveBeenCalledOnce() + expect(sdk.searchMemories.mock.calls[0]?.[0]).not.toHaveProperty( + "containerTag", + ) + }) + + it("includes containerTag when a search scope is selected", async () => { + const client = new SupermemoryClient("token", "readable-space") + + await client.search("remember me") + + expect(sdk.searchMemories.mock.calls[0]?.[0]).toMatchObject({ + containerTag: "readable-space", + }) + }) + + it("does not request a profile without a concrete search scope", async () => { + const client = new SupermemoryClient("token") + + await expect(client.getProfile("remember me")).resolves.toEqual({ + profile: { static: [], dynamic: [] }, + }) + expect(sdk.getProfile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mcp/src/server/server.ts b/apps/mcp/src/server/server.ts index 432bddd3..5ffd9e90 100644 --- a/apps/mcp/src/server/server.ts +++ b/apps/mcp/src/server/server.ts @@ -26,7 +26,7 @@ import { uploadStateName } from "./space-state" const DEFAULT_API_URL = "https://api.supermemory.ai" const UPLOAD_SESSION_TTL_MS = 2 * 60 * 1000 const SERVER_INSTRUCTIONS = - "Supermemory is the authenticated user's persistent memory and knowledge layer across conversations and spaces. Use these tools whenever the user wants to recall something they may have saved, inspect stored sources or extracted memories, remember or upload new information, check their Supermemory account or access, change their active space, or explore their memory graph, even if they do not mention Supermemory by name. Use the active or account-default space when none is named. Resolve a named space with listSpaces and pass its key to the relevant tool; change the active space only when the user explicitly asks." + "Supermemory is the authenticated user's persistent memory and knowledge layer across conversations and spaces. Use these tools whenever the user wants to recall something they may have saved, inspect stored sources or extracted memories, remember or upload new information, check their Supermemory account or access, change their active space, or explore their memory graph, even if they do not mention Supermemory by name. Use the active or account-default space when none is named, except search_memory may use the caller's readable OAuth scope when no space is named and the active space is unavailable to the current grant. Resolve a named space with listSpaces and pass its key to the relevant tool; change the active space only when the user explicitly asks." type ClientInfo = { name: string; version?: string } @@ -101,6 +101,7 @@ export function createSupermemoryServer( getClient, getSession: () => fetchSession(actor.bearerToken, apiUrl), resolveContainerTag, + resolveSelectedContainerTag, getActiveContainerTag, setActiveContainerTag, createUploadSession, diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 0156d9d1..5983eccc 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -113,7 +113,7 @@ export type ListMemoriesOutput = z.infer export const searchMemoryOutputSchema = z.object({ query: z.string(), - containerTag: z.string(), + containerTag: z.string().nullable(), profile: z .object({ static: z.array(z.string()), diff --git a/apps/mcp/src/server/tools/search-memory.test.ts b/apps/mcp/src/server/tools/search-memory.test.ts new file mode 100644 index 00000000..394891b8 --- /dev/null +++ b/apps/mcp/src/server/tools/search-memory.test.ts @@ -0,0 +1,209 @@ +import type { McpServer } from "@modelcontextprotocol/server" +import { describe, expect, it, vi } from "vitest" +import type { SupermemoryClient } from "../client" +import { register } from "./search-memory" +import { + searchMemoryOutputSchema, + type SearchMemoryOutput, +} from "./output-schemas" +import type { ToolDeps } from "./types" + +type SearchMemoryArgs = { + query: string + includeProfile?: boolean + containerTag?: string +} + +type SearchMemoryResult = { + structuredContent?: SearchMemoryOutput +} + +function createHarness( + activeTag?: string, + visibleTags: string[] = activeTag ? [activeTag] : [], +) { + let handler: + | ((args: SearchMemoryArgs) => Promise) + | undefined + const registerTool = vi.fn( + ( + _name: string, + _config: unknown, + registeredHandler: ( + args: SearchMemoryArgs, + ) => Promise, + ) => { + handler = registeredHandler + return {} + }, + ) + const search = vi.fn().mockResolvedValue({ + results: [], + total: 0, + timing: 1, + }) + const getProfile = vi.fn().mockResolvedValue({ + profile: { static: [], dynamic: [] }, + }) + const listContainerTags = vi + .fn() + .mockResolvedValue(visibleTags.map((containerTag) => ({ containerTag }))) + const client = { + search, + getProfile, + listContainerTags, + } as unknown as SupermemoryClient + const getClient = vi.fn((_containerTag?: string) => client) + const resolveSelectedContainerTag = vi.fn( + async (explicit?: string) => explicit ?? activeTag, + ) + const resolveContainerTag = vi.fn(async () => "sm_project_default") + const errorResult = vi.fn((error: unknown) => ({ + content: [], + isError: true, + error, + })) + + register({ + server: { registerTool } as unknown as Pick, + actor: { + userId: "user-1", + organizationId: "org-1", + bearerToken: "token", + }, + getClient, + getSession: vi.fn(), + resolveContainerTag, + resolveSelectedContainerTag, + getActiveContainerTag: vi.fn(), + setActiveContainerTag: vi.fn(), + createUploadSession: vi.fn(), + getClientInfo: vi.fn(), + errorResult, + } as unknown as ToolDeps) + + return { + invoke(args: SearchMemoryArgs) { + if (!handler) throw new Error("search_memory was not registered") + return handler(args) + }, + getClient, + resolveContainerTag, + resolveSelectedContainerTag, + search, + getProfile, + errorResult, + listContainerTags, + } +} + +describe("search_memory space selection", () => { + it.each([ + { + name: "leaves an unselected search unscoped", + activeTag: undefined, + explicitTag: undefined, + expectedClientTag: undefined, + expectedOutputTag: null, + expectsVisibilityCheck: false, + }, + { + name: "uses a visible active space when one is selected", + activeTag: "active-space", + explicitTag: undefined, + expectedClientTag: "active-space", + expectedOutputTag: "active-space", + expectsVisibilityCheck: true, + }, + { + name: "prefers an explicit space over the active space", + activeTag: "active-space", + explicitTag: "explicit-space", + expectedClientTag: "explicit-space", + expectedOutputTag: "explicit-space", + expectsVisibilityCheck: false, + }, + ])("$name", async ({ + activeTag, + explicitTag, + expectedClientTag, + expectedOutputTag, + expectsVisibilityCheck, + }) => { + const harness = createHarness(activeTag) + const result = await harness.invoke({ + query: "remember me", + includeProfile: false, + ...(explicitTag ? { containerTag: explicitTag } : {}), + }) + + expect(harness.resolveSelectedContainerTag).toHaveBeenCalledWith( + explicitTag, + ) + expect(harness.resolveContainerTag).not.toHaveBeenCalled() + expect(harness.getClient.mock.calls.at(-1)?.[0]).toBe(expectedClientTag) + expect(harness.listContainerTags).toHaveBeenCalledTimes( + expectsVisibilityCheck ? 1 : 0, + ) + expect(harness.search).toHaveBeenCalledWith("remember me") + expect(result.structuredContent?.containerTag).toBe(expectedOutputTag) + expect( + searchMemoryOutputSchema.safeParse(result.structuredContent).success, + ).toBe(true) + }) + + it("ignores an active space outside the current OAuth grant", async () => { + const harness = createHarness("other-space", ["readable-space"]) + + const result = await harness.invoke({ + query: "remember me", + includeProfile: false, + }) + + expect(harness.getClient.mock.calls[0]?.[0]).toBeUndefined() + expect(harness.getClient).toHaveBeenCalledOnce() + expect(result.structuredContent?.containerTag).toBeNull() + }) + + it("returns the standard tool error when active-space validation fails", async () => { + const harness = createHarness("active-space") + const error = new Error("space list unavailable") + harness.listContainerTags.mockRejectedValueOnce(error) + + await harness.invoke({ query: "remember me", includeProfile: false }) + + expect(harness.errorResult).toHaveBeenCalledWith(error) + expect(harness.search).not.toHaveBeenCalled() + }) + + it("keeps profile enrichment for a visible active space", async () => { + const harness = createHarness("active-space") + const result = await harness.invoke({ query: "remember me" }) + + expect(harness.listContainerTags).toHaveBeenCalledOnce() + expect(harness.getProfile).toHaveBeenCalledWith("remember me") + expect(result.structuredContent?.containerTag).toBe("active-space") + }) + + it("keeps profile enrichment enabled for an unselected search", async () => { + const harness = createHarness() + const result = await harness.invoke({ query: "remember me" }) + + expect(harness.getProfile).toHaveBeenCalledWith("remember me") + expect(result.structuredContent?.profile).toEqual({ + static: [], + dynamic: [], + }) + }) + + it("returns the standard tool error when scope resolution fails", async () => { + const harness = createHarness() + const error = new Error("space state unavailable") + harness.resolveSelectedContainerTag.mockRejectedValueOnce(error) + + await harness.invoke({ query: "remember me" }) + + expect(harness.errorResult).toHaveBeenCalledWith(error) + expect(harness.getClient).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index f4bfbc63..990fead2 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -9,28 +9,46 @@ import { import { textContent, type ToolDeps } from "./types" export function register(deps: ToolDeps) { + const searchContainerTagSchema = optionalContainerTagSchema.describe( + "Space key to search. If the user names a space, call listSpaces to resolve its key and pass it here. If no space is named, omit this field so the server uses an active space readable by the current grant or lets authorization select the caller's readable scope.", + ) const inputSchema = z.object({ query: z .string() .max(1000, "Query exceeds maximum length") .describe("The search query to find relevant memories"), includeProfile: z.boolean().optional().default(true), - containerTag: optionalContainerTagSchema, + containerTag: searchContainerTagSchema, }) deps.server.registerTool( "search_memory", { description: - "Search memories in one space with a natural-language query. Returns relevant memories plus that space's profile summary. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.", + "Search memories with a natural-language query. Returns relevant memories plus a profile summary when a concrete space is selected. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use an active space readable by the current grant or the caller's authorized readable scope.", inputSchema, outputSchema: searchMemoryOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async (args) => { try { - const effectiveTag = await deps.resolveContainerTag(args.containerTag) - const client = deps.getClient(effectiveTag) + let effectiveTag = await deps.resolveSelectedContainerTag( + args.containerTag, + ) + let unscopedClient: ReturnType | undefined + if (args.containerTag === undefined && effectiveTag) { + // Active state is account-wide and can outlive an OAuth grant. + // Revalidate inherited state before sending it as an explicit scope. + unscopedClient = deps.getClient() + const visibleTags = await unscopedClient.listContainerTags() + if (!visibleTags.some((tag) => tag.containerTag === effectiveTag)) { + effectiveTag = undefined + } + } + const client = + effectiveTag === undefined + ? (unscopedClient ?? deps.getClient()) + : deps.getClient(effectiveTag) const parts: string[] = [] let profile: SearchMemoryOutput["profile"] @@ -75,7 +93,7 @@ export function register(deps: ToolDeps) { const structuredContent: SearchMemoryOutput = { query: args.query, - containerTag: effectiveTag, + containerTag: effectiveTag ?? null, ...(profile ? { profile } : {}), results, total: searchResult.total, diff --git a/apps/mcp/src/server/tools/types.ts b/apps/mcp/src/server/tools/types.ts index badbdd74..1933103c 100644 --- a/apps/mcp/src/server/tools/types.ts +++ b/apps/mcp/src/server/tools/types.ts @@ -21,7 +21,12 @@ export interface ToolDeps { actor: ActorContext getClient: (containerTag?: string) => SupermemoryClient getSession: () => Promise + // Use for operations that require a concrete write/list target. resolveContainerTag: (explicit?: string) => Promise + // Use for reads that can safely defer scope selection to authorization. + resolveSelectedContainerTag: ( + explicit?: string, + ) => Promise getActiveContainerTag: () => Promise setActiveContainerTag: (containerTag: string) => Promise createUploadSession: () => Promise From b58fb0a14c9dc9e8bc3f0938b8cb9b01f4846dea Mon Sep 17 00:00:00 2001 From: abhinav7x94 <204053250+abhinav7x94@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:31:30 +0530 Subject: [PATCH 2/2] refactor(mcp): simplify unscoped client selection --- apps/mcp/src/server/tools/search-memory.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index 990fead2..e093aed6 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -35,11 +35,13 @@ export function register(deps: ToolDeps) { let effectiveTag = await deps.resolveSelectedContainerTag( args.containerTag, ) - let unscopedClient: ReturnType | undefined - if (args.containerTag === undefined && effectiveTag) { + const unscopedClient = + args.containerTag === undefined && effectiveTag + ? deps.getClient() + : undefined + if (unscopedClient) { // Active state is account-wide and can outlive an OAuth grant. // Revalidate inherited state before sending it as an explicit scope. - unscopedClient = deps.getClient() const visibleTags = await unscopedClient.listContainerTags() if (!visibleTags.some((tag) => tag.containerTag === effectiveTag)) { effectiveTag = undefined