mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-20 22:14:11 +00:00
End-to-end tests that drive the deployed Supermemory MCP over streamable HTTP (no mocks): - discovery: handshake + tool/resource/prompt listing - identity: whoAmI, listProjects - memory: save -> recall round-trip, profile variants, forget, container scoping - graph/resources/prompts: memory-graph, fetch-graph-data, profile/projects, context - oauth: protected-resource discovery chain, dynamic client registration, token-endpoint negatives, and a real refresh -> access token round-trip - auth: GET / info, 401 on missing/invalid token - root-scope: x-sm-project scoping behavior Tests skip without SUPERMEMORY_API_KEY (OAuth tier-D skips without its token env vars) so CI is safe without secrets. Adds vitest + a test:e2e script and documents running + the OAuth flow in apps/mcp/README.md.
127 lines
4.1 KiB
TypeScript
127 lines
4.1 KiB
TypeScript
import { randomUUID } from "node:crypto"
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
import {
|
|
API_KEY,
|
|
callTool,
|
|
connect,
|
|
forgetUntilForgotten,
|
|
recallUntil,
|
|
recallUntilAbsent,
|
|
type Session,
|
|
textOf,
|
|
} from "./helpers"
|
|
|
|
describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
|
let s: Session
|
|
const created: Array<{ content: string; containerTag?: string }> = []
|
|
|
|
beforeAll(async () => {
|
|
s = await connect()
|
|
})
|
|
afterAll(async () => {
|
|
for (const { content, containerTag } of created) {
|
|
await callTool(s.client, "memory", {
|
|
content,
|
|
action: "forget",
|
|
...(containerTag ? { containerTag } : {}),
|
|
}).catch(() => {})
|
|
}
|
|
await s?.close()
|
|
})
|
|
|
|
it("save → recall round-trips the saved memory", async () => {
|
|
const marker = `rt-${randomUUID()}`
|
|
const content = `e2e round-trip. token=${marker}. The test fruit is dragonfruit.`
|
|
created.push({ content })
|
|
|
|
const save = await callTool(s.client, "memory", { content, action: "save" })
|
|
expect(save.isError).toBeFalsy()
|
|
expect(textOf(save)).toMatch(/Saved memory/i)
|
|
|
|
const found = await recallUntil(s.client, "test fruit dragonfruit", marker)
|
|
expect(found, `recall never returned marker ${marker}`).not.toBeNull()
|
|
}, 120_000)
|
|
|
|
it("recall includeProfile=true returns profile + memories sections", async () => {
|
|
const res = await callTool(s.client, "recall", {
|
|
query: "dragonfruit",
|
|
includeProfile: true,
|
|
})
|
|
expect(res.isError).toBeFalsy()
|
|
const txt = textOf(res)
|
|
expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
|
|
}, 30_000)
|
|
|
|
// Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty.
|
|
it("recall responds gracefully for an unmatched query", async () => {
|
|
const res = await callTool(s.client, "recall", {
|
|
query: `zzz-no-such-memory-${randomUUID()}`,
|
|
includeProfile: false,
|
|
})
|
|
expect(res.isError).toBeFalsy()
|
|
expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
|
|
})
|
|
|
|
// Hard-asserts forget is accepted; removal is eventually-consistent, so disappearance is best-effort.
|
|
it("forget accepts and removes a saved memory", async () => {
|
|
const marker = `fg-${randomUUID()}`
|
|
const content = `e2e forget target. token=${marker}. Secret animal is axolotl.`
|
|
created.push({ content })
|
|
|
|
await callTool(s.client, "memory", { content, action: "save" })
|
|
const found = await recallUntil(s.client, "secret animal axolotl", marker)
|
|
expect(found, "memory should exist before forget").not.toBeNull()
|
|
|
|
// Polls forget until it confirms real removal ("forgot"), past the extraction window.
|
|
const forgotten = await forgetUntilForgotten(s.client, content)
|
|
expect(
|
|
forgotten,
|
|
`forget never confirmed removal for ${marker} (memory entry never extracted in time)`,
|
|
).not.toBeNull()
|
|
|
|
const gone = await recallUntilAbsent(
|
|
s.client,
|
|
"secret animal axolotl",
|
|
marker,
|
|
)
|
|
if (!gone) {
|
|
console.warn(
|
|
`[e2e] forget confirmed but ${marker} still indexed after ~60s (eventual deletion)`,
|
|
)
|
|
}
|
|
}, 240_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"
|
|
const marker = `sc-${randomUUID()}`
|
|
const content = `e2e scoping. token=${marker}. Project color is teal.`
|
|
created.push({ content, containerTag: tagA })
|
|
|
|
await callTool(s.client, "memory", {
|
|
content,
|
|
action: "save",
|
|
containerTag: tagA,
|
|
})
|
|
|
|
const inA = await recallUntil(s.client, "project color teal", marker, {
|
|
containerTag: tagA,
|
|
})
|
|
expect(inA, "marker should be found in its own container").not.toBeNull()
|
|
|
|
// Same query scoped to a different container must NOT see it.
|
|
const leaked = await recallUntil(s.client, "project color teal", marker, {
|
|
containerTag: tagB,
|
|
tries: 3,
|
|
delayMs: 3000,
|
|
})
|
|
expect(leaked, "marker leaked across containers").toBeNull()
|
|
}, 120_000)
|
|
|
|
it("returns an error result for a missing required argument", async () => {
|
|
const res = await callTool(s.client, "recall", {})
|
|
expect(res.isError).toBe(true)
|
|
expect(textOf(res).length).toBeGreaterThan(0)
|
|
})
|
|
})
|