This commit is contained in:
abhinav7x94 2026-08-26 04:01:27 +05:30 committed by GitHub
commit 197fe9a556
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 307 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -86,7 +86,7 @@ export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
export const searchMemoryOutputSchema = z.object({
query: z.string(),
containerTag: z.string(),
containerTag: z.string().nullable(),
profile: z
.object({
static: z.array(z.string()),

View file

@ -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<SearchMemoryResult>)
| undefined
const registerTool = vi.fn(
(
_name: string,
_config: unknown,
registeredHandler: (
args: SearchMemoryArgs,
) => Promise<SearchMemoryResult>,
) => {
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<McpServer, "registerTool">,
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()
})
})

View file

@ -9,28 +9,48 @@ 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,
)
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.
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 +95,7 @@ export function register(deps: ToolDeps) {
const structuredContent: SearchMemoryOutput = {
query: args.query,
containerTag: effectiveTag,
containerTag: effectiveTag ?? null,
...(profile ? { profile } : {}),
results,
total: searchResult.total,

View file

@ -21,7 +21,12 @@ export interface ToolDeps {
actor: ActorContext
getClient: (containerTag?: string) => SupermemoryClient
getSession: () => Promise<SessionInfo>
// Use for operations that require a concrete write/list target.
resolveContainerTag: (explicit?: string) => Promise<string>
// Use for reads that can safely defer scope selection to authorization.
resolveSelectedContainerTag: (
explicit?: string,
) => Promise<string | undefined>
getActiveContainerTag: () => Promise<string | undefined>
setActiveContainerTag: (containerTag: string) => Promise<void>
createUploadSession: () => Promise<PreparedUpload>