Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ memories, profile, spaces, and interactive MCP Apps.

The space used by an operation resolves in this order:

1. An explicit `containerTag` tool or prompt argument
1. An explicit `namespace` tool or prompt argument
2. The account's durable active space
3. The Supermemory client default, `sm_project_default`

Expand Down Expand Up @@ -137,7 +137,8 @@ discovery and rejection tests still run.

## Storage And Rollout

`SpaceState` stores only the active space's container tag. It never stores bearer
`SpaceState` stores only the active space's namespace (under the legacy
`activeContainerTag` key, so existing selections survive). It never stores bearer
tokens, MCP client identity, or protocol messages.

The old `SupermemoryMCP` class and binding remain inert for one rollout. This
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/e2e/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
expect(res.contents[0].mimeType).toBe("text/plain")
expect(text).toMatch(/# My Spaces/)
expect(text).toMatch(/Active:/)
expect(text).not.toMatch(/"containerTags":/)
expect(text).not.toMatch(/"namespaces":/)
})

it("gets compact active-space context without prompt arguments", async () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/mcp/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,13 @@ export async function recallUntil(
{
tries = 18,
delayMs = 5000,
containerTag = undefined as string | undefined,
namespace = undefined as string | undefined,
} = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "search_memory", {
query,
...(containerTag ? { containerTag } : {}),
...(namespace ? { namespace } : {}),
})
const txt = textOf(res)
if (txt.includes(needle)) return txt
Expand Down
55 changes: 20 additions & 35 deletions apps/mcp/e2e/list-memories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ type AppView = {
viewId?: string
id?: string
fileName?: string
containerTag?: string
writableTags?: string[]
namespace?: string
writableNamespaces?: string[]
}

async function waitForToolText(
Expand All @@ -45,7 +45,7 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
let session: Session
const createdMemories: Array<{
content: string
containerTag: string
namespace: string
}> = []

beforeAll(async () => {
Expand All @@ -57,7 +57,7 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
await callTool(session.client, "add_memory", {
content: memory.content,
action: "forget",
containerTag: memory.containerTag,
namespace: memory.namespace,
}).catch(() => {})
}
await session?.close()
Expand All @@ -72,33 +72,33 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
expect(launcher.isError).toBeFalsy()

const launcherView = launcher.structuredContent as AppView
const containerTag = launcherView.writableTags?.[0]
const namespace = launcherView.writableNamespaces?.[0]
expect(launcherView.view).toBe("save")
expect(launcherView.viewId).toBeTruthy()
expect(containerTag).toBeTruthy()
if (!launcherView.viewId || !containerTag) {
expect(namespace).toBeTruthy()
if (!launcherView.viewId || !namespace) {
throw new Error("Guided save did not provide a writable space")
}

const saved = await callTool(session.client, "save-memory", {
content,
containerTag,
namespace,
viewId: launcherView.viewId,
})
expect(saved.isError).toBeFalsy()
const savedView = saved.structuredContent as AppView
expect(savedView).toMatchObject({
view: "save-success",
containerTag,
namespace,
})
expect(savedView.id).toBeTruthy()
if (!savedView.id) throw new Error("Save did not return a document ID")
createdMemories.push({ content, containerTag })
createdMemories.push({ content, namespace })

const listedDocument = await waitForToolText(
session,
"list_documents",
{ page: 1, limit: 50, containerTag },
{ page: 1, limit: 50, namespace },
`[${savedView.id}]`,
20,
1000,
Expand All @@ -108,7 +108,7 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
const document = await waitForToolText(
session,
"get_document",
{ documentId: savedView.id },
{ documentId: savedView.id, namespace },
`Document ID: ${savedView.id}`,
20,
1000,
Expand All @@ -118,27 +118,11 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
const memoriesResult = await callTool(session.client, "list_memories", {
page: 1,
limit: 10,
containerTag: "sm_project_default",
namespace: "sm_project_default",
})
expect(memoriesResult.isError).toBeFalsy()
const memories = textOf(memoriesResult)
expect(memories).toMatch(/active memor(?:y|ies) \(page 1 of \d+/i)

const sourceDocumentId = memories.match(
/Source documents: ([^,\n]+)/,
)?.[1]
expect(sourceDocumentId).toBeTruthy()
if (!sourceDocumentId) {
throw new Error("Listed memory did not include a source document")
}

const sourceDocument = await callTool(session.client, "get_document", {
documentId: sourceDocumentId,
})
expect(sourceDocument.isError).toBeFalsy()
expect(textOf(sourceDocument)).toContain(
`Document ID: ${sourceDocumentId}`,
)
}, 60_000)

it("uploads and reads a text document", async () => {
Expand All @@ -149,22 +133,23 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
expect(launcher.isError).toBeFalsy()

const launcherView = launcher.structuredContent as AppView
const containerTag = launcherView.writableTags?.[0]
const namespace = launcherView.writableNamespaces?.[0]
expect(launcherView.view).toBe("upload")
expect(launcherView.viewId).toBeTruthy()
expect(containerTag).toBeTruthy()
if (!launcherView.viewId || !containerTag) {
expect(namespace).toBeTruthy()
if (!launcherView.viewId || !namespace) {
throw new Error("Upload did not provide a writable space")
}

const prepared = await callTool(session.client, "prepare-file-upload")
const prepared = await callTool(session.client, "prepare-file-upload", {
namespace,
})
expect(prepared.isError).toBeFalsy()
const preparation = uploadPreparationSchema.parse(
prepared.structuredContent,
)
const formData = new FormData()
formData.append("file", new Blob([fileContent]), fileName)
formData.append("containerTag", containerTag)
formData.append(
"metadata",
JSON.stringify({ sm_source: "supermemory-mcp" }),
Expand All @@ -183,7 +168,7 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
const document = await waitForToolText(
session,
"get_document",
{ documentId: uploaded.id },
{ documentId: uploaded.id, namespace },
`Document ID: ${uploaded.id}`,
20,
1000,
Expand Down
28 changes: 14 additions & 14 deletions apps/mcp/e2e/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ import {

describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)("MCP — memory behaviors", () => {
let s: Session
const created: Array<{ content: string; containerTag?: string }> = []
const created: Array<{ content: string; namespace?: string }> = []

beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
for (const { content, containerTag } of created) {
for (const { content, namespace } of created) {
await callTool(s.client, "add_memory", {
content,
action: "forget",
...(containerTag ? { containerTag } : {}),
...(namespace ? { namespace } : {}),
}).catch(() => {})
}
await s?.close()
Expand Down Expand Up @@ -90,32 +90,32 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)("MCP — memory behaviors", () =>
expect(textOf(forgotten)).toMatch(/forgot|No matching memory found/i)
}, 120_000)

it("containerTag scopes memories (isolation)", async () => {
// Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
const tagA = "sm_e2e_scope_a"
const tagB = "sm_e2e_scope_b"
it("namespace scopes memories (isolation)", async () => {
// Fixed namespaces (not per-run UUIDs) so the test doesn't mint a new one each run.
const namespaceA = "sm_e2e_scope_a"
const namespaceB = "sm_e2e_scope_b"
const marker = `sc-${randomUUID()}`
const content = `e2e scoping. token=${marker}. Project color is teal.`
created.push({ content, containerTag: tagA })
created.push({ content, namespace: namespaceA })

await callTool(s.client, "add_memory", {
content,
action: "save",
containerTag: tagA,
namespace: namespaceA,
})

const inA = await recallUntil(s.client, "project color teal", marker, {
containerTag: tagA,
namespace: namespaceA,
})
expect(inA, "marker should be found in its own container").not.toBeNull()
expect(inA, "marker should be found in its own namespace").not.toBeNull()

// Same query scoped to a different container must NOT see it.
// Same query scoped to a different namespace must NOT see it.
const leaked = await recallUntil(s.client, "project color teal", marker, {
containerTag: tagB,
namespace: namespaceB,
tries: 3,
delayMs: 3000,
})
expect(leaked, "marker leaked across containers").toBeNull()
expect(leaked, "marker leaked across namespaces").toBeNull()
}, 120_000)

it("returns an error result for a missing required argument", async () => {
Expand Down
18 changes: 7 additions & 11 deletions apps/mcp/e2e/space-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)

describeWithAuth("MCP - space scoping", () => {
it("keeps per-call containerTag overrides when an obsolete header is sent", async () => {
it("keeps per-call namespace overrides when an obsolete header is sent", async () => {
const scoped = await connect({
headers: { "x-sm-project": "obsolete-root-scope" },
})
Expand All @@ -21,17 +21,13 @@ describeWithAuth("MCP - space scoping", () => {
const scopedTools = (await scoped.client.listTools()).tools
const plainTools = (await plain.client.listTools()).tools

expect(propsOf(plainTools, "add_memory")).toHaveProperty("containerTag")
expect(propsOf(plainTools, "search_memory")).toHaveProperty(
"containerTag",
)
expect(propsOf(plainTools, "get_profile")).toHaveProperty("containerTag")
expect(propsOf(plainTools, "add_memory")).toHaveProperty("namespace")
expect(propsOf(plainTools, "search_memory")).toHaveProperty("namespace")
expect(propsOf(plainTools, "get_profile")).toHaveProperty("namespace")

expect(propsOf(scopedTools, "add_memory")).toHaveProperty("containerTag")
expect(propsOf(scopedTools, "search_memory")).toHaveProperty(
"containerTag",
)
expect(propsOf(scopedTools, "get_profile")).toHaveProperty("containerTag")
expect(propsOf(scopedTools, "add_memory")).toHaveProperty("namespace")
expect(propsOf(scopedTools, "search_memory")).toHaveProperty("namespace")
expect(propsOf(scopedTools, "get_profile")).toHaveProperty("namespace")
} finally {
await scoped.close()
await plain.close()
Expand Down
28 changes: 14 additions & 14 deletions apps/mcp/e2e/widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,47 +25,47 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)(
expect(result.isError).toBeFalsy()
const content = result.structuredContent as {
view?: string
containerTags?: Array<{ containerTag: string }>
assignedTags?: Array<{
containerTag: string
namespaces?: Array<{ namespace: string }>
assignedNamespaces?: Array<{
namespace: string
permission: "read" | "write"
}>
}
expect(content.view).toBe("picker")
expect(Array.isArray(content.containerTags)).toBe(true)
expect(content.assignedTags).toHaveLength(
content.containerTags?.length ?? 0,
expect(Array.isArray(content.namespaces)).toBe(true)
expect(content.assignedNamespaces).toHaveLength(
content.namespaces?.length ?? 0,
)
expect(
content.assignedTags?.every((tag) =>
["read", "write"].includes(tag.permission),
content.assignedNamespaces?.every((access) =>
["read", "write"].includes(access.permission),
),
).toBe(true)
})

it("shares the selected space across MCP transport sessions", async () => {
const picker = await callTool(session.client, "select-space")
const pickerContent = picker.structuredContent as {
containerTags?: Array<{ containerTag: string }>
namespaces?: Array<{ namespace: string }>
}
const firstTag = pickerContent.containerTags?.[0]?.containerTag
expect(firstTag).toBeTruthy()
const firstNamespace = pickerContent.namespaces?.[0]?.namespace
expect(firstNamespace).toBeTruthy()

const result = await callTool(session.client, "set-active-tag", {
containerTag: firstTag,
namespace: firstNamespace,
})
expect(result.isError).toBeFalsy()
expect(result.structuredContent).toMatchObject({
view: "confirmation",
containerTag: firstTag,
namespace: firstNamespace,
})

const separateSession = await connect()
try {
const identity = await callTool(separateSession.client, "who_am_i")
expect(identity.isError).toBeFalsy()
expect(JSON.parse(textOf(identity))).toMatchObject({
activeSpace: firstTag,
activeSpace: firstNamespace,
})
} finally {
await separateSession.close()
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"posthog-node": "^5.18.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"supermemory": "^4.0.0",
"supermemory": "^5.0.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.4.3"
},
Expand Down
Loading
Loading