test(mcp): add deployed-server e2e suite (#1057)

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.
This commit is contained in:
MaheshtheDev 2026-06-07 04:30:17 +00:00
parent 5b129d7d32
commit 9808441f6b
12 changed files with 860 additions and 2 deletions

View file

@ -170,6 +170,54 @@ The server will start at `http://localhost:8788`.
**Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation.
### End-to-End Tests
The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the
core journey: handshake → tool/resource/prompt discovery → `whoAmI` → `listProjects` →
`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads,
the `context` prompt, container-tag isolation, and auth rejections.
```bash
export SUPERMEMORY_API_KEY=sm_... # staging key (required; tests skip without it)
export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default
export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server
bun run test:e2e
```
| File | Covers |
|------|--------|
| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) |
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |
#### OAuth flow tests
`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main
API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers:
- **A–C (no secrets)** — discovery chain, dynamic client registration, and token/authorize
negatives. These exercise the protocol wiring with no key and no browser, so they always run.
- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to
`/mcp` with it, exercising the OAuth-token validation path (not the `sm_` API-key path). It
**skips** unless both env vars below are set.
```bash
# One-time capture (opens a browser for login + consent, prints the env vars):
bun e2e/capture-oauth-token.ts
export SUPERMEMORY_MCP_CLIENT_ID=...
export SUPERMEMORY_MCP_REFRESH_TOKEN=...
```
Notes:
- Tests **skip** (not fail) without `SUPERMEMORY_API_KEY`; Tier D OAuth tests skip without the
refresh-token env vars — so CI is safe without secrets.
- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip
**polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort.
- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account.
### Deploy
```bash

65
apps/mcp/e2e/auth.test.ts Normal file
View file

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest"
import { MCP_URL, ORIGIN } from "./helpers"
const initBody = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "smtest", version: "0.0.1" },
},
})
const mcpHeaders = (auth?: string) => ({
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
...(auth ? { Authorization: auth } : {}),
})
// No API key needed — exercises the public surface and auth rejections.
describe("MCP — transport & auth (raw HTTP)", () => {
it("GET / returns service info", async () => {
const res = await fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
const body = (await res.json()) as { name?: string; version?: string }
expect(body.name).toBe("supermemory-mcp")
expect(body.version).toBeTruthy()
})
it("exposes OAuth protected-resource discovery", async () => {
const res = await fetch(
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
)
expect(res.status).toBe(200)
const body = (await res.json()) as {
resource?: string
authorization_servers?: string[]
}
expect(body.resource).toMatch(/\/mcp$/)
expect(Array.isArray(body.authorization_servers)).toBe(true)
expect(body.authorization_servers?.length).toBeGreaterThan(0)
})
it("rejects a request with no token (401 + WWW-Authenticate)", async () => {
const res = await fetch(MCP_URL, {
method: "POST",
headers: mcpHeaders(),
body: initBody,
})
expect(res.status).toBe(401)
expect(res.headers.get("www-authenticate")).toMatch(/Bearer/)
})
it("rejects an invalid API key (401 with JSON-RPC error)", async () => {
const res = await fetch(MCP_URL, {
method: "POST",
headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"),
body: initBody,
})
expect(res.status).toBe(401)
const body = (await res.json()) as { error?: { message?: string } }
expect(body.error?.message).toMatch(/invalid|expired/i)
})
})

View file

@ -0,0 +1,103 @@
// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
import { createHash, randomBytes } from "node:crypto"
import { createServer } from "node:http"
import { exec } from "node:child_process"
const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
const PORT = 8765
const REDIRECT_URI = `http://localhost:${PORT}/callback`
const b64url = (b: Buffer) =>
b
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
async function main() {
const meta = (await (
await fetch(`${API_URL}/.well-known/oauth-authorization-server`)
).json()) as {
authorization_endpoint: string
token_endpoint: string
registration_endpoint: string
}
const reg = (await (
await fetch(meta.registration_endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "sm-mcp-e2e-capture",
redirect_uris: [REDIRECT_URI],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})
).json()) as { client_id: string }
const verifier = b64url(randomBytes(32))
const challenge = b64url(createHash("sha256").update(verifier).digest())
const state = b64url(randomBytes(16))
const authUrl = new URL(meta.authorization_endpoint)
authUrl.search = new URLSearchParams({
response_type: "code",
client_id: reg.client_id,
redirect_uri: REDIRECT_URI,
code_challenge: challenge,
code_challenge_method: "S256",
scope: "openid profile email offline_access",
state,
}).toString()
const code: string = await new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const u = new URL(req.url ?? "", `http://localhost:${PORT}`)
if (u.pathname !== "/callback") return res.end()
if (u.searchParams.get("state") !== state) {
res.end("state mismatch")
return reject(new Error("state mismatch"))
}
const c = u.searchParams.get("code")
res.end("Done — you can close this tab.")
server.close()
c ? resolve(c) : reject(new Error("no code in callback"))
}).listen(PORT, () => {
console.log(`\nOpening browser to log in:\n${authUrl}\n`)
exec(`open "${authUrl}" || xdg-open "${authUrl}"`)
})
})
const tokenRes = (await (
await fetch(meta.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: reg.client_id,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
}),
})
).json()) as { refresh_token?: string; error?: string }
if (!tokenRes.refresh_token) {
console.error("No refresh_token returned:", tokenRes)
process.exit(1)
}
console.log("\nExport these to enable Tier D OAuth tests:\n")
console.log(`export SUPERMEMORY_MCP_CLIENT_ID="${reg.client_id}"`)
console.log(
`export SUPERMEMORY_MCP_REFRESH_TOKEN="${tokenRes.refresh_token}"`,
)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View file

@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
const EXPECTED_TOOLS = [
"memory",
"recall",
"listProjects",
"whoAmI",
"memory-graph",
]
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
let s: Session
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
await s?.close()
})
it("handshakes and lists the expected tools", async () => {
const { tools } = await s.client.listTools()
const names = tools.map((t) => t.name)
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
})
it("lists profile & projects resources", async () => {
const { resources } = await s.client.listResources()
const uris = resources.map((r) => r.uri)
expect(uris).toContain("supermemory://profile")
expect(uris).toContain("supermemory://projects")
})
it("lists the context prompt", async () => {
const { prompts } = await s.client.listPrompts()
expect(prompts.map((p) => p.name)).toContain("context")
})
it("whoAmI resolves to the authenticated account", async () => {
const res = await callTool(s.client, "whoAmI")
expect(res.isError).toBeFalsy()
const parsed = JSON.parse(textOf(res))
expect(parsed.userId).toBeTruthy()
})
it("listProjects returns content", async () => {
const res = await callTool(s.client, "listProjects", { refresh: true })
expect(res.isError).toBeFalsy()
expect(textOf(res).length).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,59 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
let s: Session
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
await s?.close()
})
it("memory-graph returns a summary + structured documents", async () => {
const res = await callTool(s.client, "memory-graph")
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/)
const sc = res.structuredContent as {
documents?: unknown[]
totalCount?: number
}
expect(Array.isArray(sc?.documents)).toBe(true)
})
it("fetch-graph-data returns paginated documents", async () => {
const res = await callTool(s.client, "fetch-graph-data", {
page: 1,
limit: 5,
})
expect(res.isError).toBeFalsy()
const sc = res.structuredContent as {
documents?: unknown[]
pagination?: { limit?: number }
}
expect(Array.isArray(sc?.documents)).toBe(true)
expect(sc?.pagination?.limit).toBe(5)
})
it("reads the profile resource", async () => {
const res = await s.client.readResource({ uri: "supermemory://profile" })
expect(res.contents.length).toBeGreaterThan(0)
expect(res.contents[0].mimeType).toBe("text/plain")
expect(typeof res.contents[0].text).toBe("string")
})
it("reads the projects resource as JSON", async () => {
const res = await s.client.readResource({ uri: "supermemory://projects" })
const text = res.contents[0].text as string
const parsed = JSON.parse(text)
expect(Array.isArray(parsed.projects)).toBe(true)
})
it("gets the context prompt as a system message", async () => {
const res = await s.client.getPrompt({ name: "context", arguments: {} })
expect(res.messages.length).toBeGreaterThan(0)
const text = res.messages[0].content.text as string
expect(text).toMatch(/memory|context/i)
})
})

186
apps/mcp/e2e/helpers.ts Normal file
View file

@ -0,0 +1,186 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
export const MCP_URL =
process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp"
export const API_KEY = process.env.SUPERMEMORY_API_KEY
export const ORIGIN = new URL(MCP_URL).origin
export const API_URL =
process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
// Tier D (real OAuth token) creds — captured once via e2e/capture-oauth-token.ts.
export const OAUTH_REFRESH_TOKEN = process.env.SUPERMEMORY_MCP_REFRESH_TOKEN
export const OAUTH_CLIENT_ID = process.env.SUPERMEMORY_MCP_CLIENT_ID
export type AuthServerMetadata = {
authorization_endpoint: string
token_endpoint: string
registration_endpoint: string
grant_types_supported?: string[]
code_challenge_methods_supported?: string[]
response_types_supported?: string[]
}
// Walk the discovery chain a real MCP client follows: protected-resource → authorization server.
export async function authServerMetadata(): Promise<{
authServer: string
metadata: AuthServerMetadata
}> {
const prRes = await fetch(
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
)
const pr = (await prRes.json()) as { authorization_servers?: string[] }
const authServer = pr.authorization_servers?.[0]
if (!authServer) throw new Error("no authorization_servers in metadata")
const metaRes = await fetch(
`${authServer}/.well-known/oauth-authorization-server`,
)
return { authServer, metadata: (await metaRes.json()) as AuthServerMetadata }
}
export async function registerClient(registrationEndpoint: string): Promise<{
status: number
body: { client_id?: string; grant_types?: string[] }
}> {
const res = await fetch(registrationEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "sm-mcp-e2e",
redirect_uris: ["http://localhost:8765/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})
return { status: res.status, body: await res.json() }
}
export async function exchangeRefreshToken(
tokenEndpoint: string,
refreshToken: string,
clientId: string,
): Promise<{
status: number
body: { access_token?: string; error?: string }
}> {
const res = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
}),
})
return { status: res.status, body: await res.json() }
}
export type CallResult = {
content?: Array<{ type: string; text?: string }>
structuredContent?: unknown
isError?: boolean
}
export function textOf(res: CallResult): string {
return (res.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n")
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export type Session = { client: Client; close: () => Promise<void> }
export async function connect(
opts: { apiKey?: string; token?: string; containerTag?: string } = {},
): Promise<Session> {
const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
}
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: { headers },
})
const client = new Client({ name: "sm-mcp-e2e", version: "0.0.1" })
await client.connect(transport)
return {
client,
close: () => transport.close().catch(() => {}),
}
}
export async function callTool(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<CallResult> {
return (await client.callTool({ name, arguments: args })) as CallResult
}
// recall is eventually-consistent (save → ingestion pipeline → memories), so poll.
export async function recallUntil(
client: Client,
query: string,
needle: string,
{
tries = 18,
delayMs = 5000,
containerTag = undefined as string | undefined,
} = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "recall", {
query,
includeProfile: false,
...(containerTag ? { containerTag } : {}),
})
const txt = textOf(res)
if (txt.includes(needle)) return txt
await sleep(delayMs)
}
return null
}
// forget only matches extracted memory entries, not raw chunks, so a just-saved doc
// returns "No matching memory found..." until extraction completes — poll for real removal.
export async function forgetUntilForgotten(
client: Client,
content: string,
{
tries = 18,
delayMs = 5000,
containerTag = undefined as string | undefined,
} = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "memory", {
content,
action: "forget",
...(containerTag ? { containerTag } : {}),
})
if (!res.isError && /forgot/i.test(textOf(res))) return textOf(res)
await sleep(delayMs)
}
return null
}
// poll until a memory is NO LONGER returned (for verifying forget).
export async function recallUntilAbsent(
client: Client,
query: string,
needle: string,
{ tries = 12, delayMs = 5000 } = {},
): Promise<boolean> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "recall", {
query,
includeProfile: false,
})
if (!textOf(res).includes(needle)) return true
await sleep(delayMs)
}
return false
}

127
apps/mcp/e2e/memory.test.ts Normal file
View file

@ -0,0 +1,127 @@
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)
})
})

126
apps/mcp/e2e/oauth.test.ts Normal file
View file

@ -0,0 +1,126 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
authServerMetadata,
type AuthServerMetadata,
callTool,
connect,
exchangeRefreshToken,
OAUTH_CLIENT_ID,
OAUTH_REFRESH_TOKEN,
registerClient,
type Session,
textOf,
} from "./helpers"
// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser.
describe("MCP — OAuth protocol (no secrets)", () => {
let meta: AuthServerMetadata
beforeAll(async () => {
meta = (await authServerMetadata()).metadata
})
// Tier A — the discovery chain a client walks from a 401 to the auth server.
it("discovers the authorization server from protected-resource metadata", () => {
expect(meta.authorization_endpoint).toMatch(/\/authorize$/)
expect(meta.token_endpoint).toMatch(/\/token$/)
expect(meta.registration_endpoint).toMatch(/\/register$/)
})
it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => {
expect(meta.code_challenge_methods_supported).toContain("S256")
expect(meta.response_types_supported).toContain("code")
expect(meta.grant_types_supported).toContain("authorization_code")
expect(meta.grant_types_supported).toContain("refresh_token")
})
// Tier B — Dynamic Client Registration, the first authenticated-flow step.
it("issues a client_id via dynamic client registration", async () => {
const { status, body } = await registerClient(meta.registration_endpoint)
expect(status).toBe(201)
expect(body.client_id).toBeTruthy()
expect(body.grant_types).toContain("refresh_token")
})
// Tier C — token endpoint rejects forged grants with proper OAuth errors.
it("rejects a bogus refresh_token with invalid_grant", async () => {
const { status, body } = await exchangeRefreshToken(
meta.token_endpoint,
"bogus_rt_for_e2e",
"bogus_client",
)
expect(status).toBe(401)
expect(body.error).toBe("invalid_grant")
})
it("rejects a bogus authorization code with invalid_grant", async () => {
const res = await fetch(meta.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: "bogus_code",
client_id: "bogus",
code_verifier: "abc",
redirect_uri: "http://localhost:8765/callback",
}),
})
expect(res.status).toBe(401)
expect(((await res.json()) as { error?: string }).error).toBe(
"invalid_grant",
)
})
it("redirects an unauthenticated authorize request to login", async () => {
const url = new URL(meta.authorization_endpoint)
url.search = new URLSearchParams({
response_type: "code",
client_id: "any",
redirect_uri: "http://localhost:8765/callback",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
scope: "openid profile email offline_access",
state: "xyz",
}).toString()
const res = await fetch(url, { redirect: "manual" })
expect(res.status).toBe(302)
expect(res.headers.get("location")).toMatch(/\/login/)
})
})
// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
"MCP — real OAuth token round-trip",
() => {
let s: Session
let accessToken: string
beforeAll(async () => {
const { metadata } = await authServerMetadata()
const { status, body } = await exchangeRefreshToken(
metadata.token_endpoint,
OAUTH_REFRESH_TOKEN as string,
OAUTH_CLIENT_ID as string,
)
expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe(
200,
)
expect(body.access_token).toBeTruthy()
accessToken = body.access_token as string
})
afterAll(async () => {
await s?.close()
})
it("mints an OAuth access token that is not an sm_ API key", () => {
expect(accessToken.startsWith("sm_")).toBe(false)
})
it("connects to /mcp with the OAuth token and resolves identity", async () => {
s = await connect({ token: accessToken })
const res = await callTool(s.client, "whoAmI")
expect(res.isError).toBeFalsy()
expect(JSON.parse(textOf(res)).userId).toBeTruthy()
})
},
)

View file

@ -0,0 +1,80 @@
import { randomUUID } from "node:crypto"
import { describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers"
type ToolLike = {
name: string
inputSchema?: { properties?: Record<string, unknown> }
}
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
const SCOPE_TAG = "sm_e2e_root"
// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
it("strips containerTag from tool schemas when x-sm-project is set", async () => {
const scoped = await connect({ containerTag: SCOPE_TAG })
const plain = await connect()
try {
const scopedTools = (await scoped.client.listTools()).tools
const plainTools = (await plain.client.listTools()).tools
expect(propsOf(plainTools, "memory")).toHaveProperty("containerTag")
expect(propsOf(plainTools, "recall")).toHaveProperty("containerTag")
expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag")
expect(propsOf(scopedTools, "recall")).not.toHaveProperty("containerTag")
} finally {
await scoped.close()
await plain.close()
}
})
it("scopes saves to the connection project and isolates them from default", async () => {
const marker = `root-${randomUUID()}`
const content = `e2e root scope. token=${marker}. The root flower is bluebell.`
const rooted = await connect({ containerTag: SCOPE_TAG })
try {
const save = await callTool(rooted.client, "memory", {
content,
action: "save",
})
expect(save.isError).toBeFalsy()
expect(textOf(save)).toContain(SCOPE_TAG)
const found = await recallUntil(
rooted.client,
"root flower bluebell",
marker,
)
expect(found, "marker not found within its root scope").not.toBeNull()
} finally {
await callTool(rooted.client, "memory", {
content,
action: "forget",
}).catch(() => {})
await rooted.close()
}
// A default connection searches sm_project_default only — must not see it.
const plain = await connect()
try {
const leaked = await recallUntil(
plain.client,
"root flower bluebell",
marker,
{
tries: 3,
delayMs: 3000,
},
)
expect(leaked, "rooted memory leaked into the default project").toBeNull()
} finally {
await plain.close()
}
}, 120_000)
})

View file

@ -8,7 +8,8 @@
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"deploy": "vite build && wrangler deploy --minify",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"test:e2e": "vitest run"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
@ -27,6 +28,7 @@
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["e2e/**/*.test.ts"],
testTimeout: 90_000,
hookTimeout: 30_000,
},
})

View file

@ -103,6 +103,7 @@
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0",
},
},
@ -5974,7 +5975,7 @@
"supermemory-mcp/agents": ["agents@0.3.10", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/sdk": "1.25.2", "cron-schedule": "^6.0.0", "escape-html": "^1.0.3", "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.4", "mimetext": "^3.0.28", "nanoid": "^5.1.6", "partyserver": "^0.1.2", "partysocket": "1.1.11", "yargs": "^18.0.0" }, "peerDependencies": { "@ai-sdk/openai": "^3.0.0", "@ai-sdk/react": "^3.0.0", "@cloudflare/ai-chat": "^0.0.6", "@cloudflare/codemode": "^0.0.6", "ai": "^6.0.0", "react": "^19.0.0", "viem": ">=2.0.0", "x402": "^0.7.1", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/openai", "@ai-sdk/react", "viem", "x402"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-hKj3nbej14GA2SgE6/stQheJF35LCS7DxMuHG0QDl1/npnnECYyG0Yf5DXl6AvJAZOFNDwT3iEzKi8yLZngPDA=="],
"supermemory-mcp/supermemory": ["supermemory@4.15.0", "", {}, "sha512-FNBBOEi1HMvbBecXC6gn84nGy7ZEdFW5kLv7VlWirHDPEM08omBBBHQ7H2cwBIGWUenBMDaao3kPKe9W6Ki+UQ=="],
"supermemory-mcp/supermemory": ["supermemory@4.21.1", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-KayOHtD94g7O+yN2qxaHEO5UIXtDl+duaKuhW7gvaraVtP1RHxFn80Pb5s5rKmqIvC+ruaARRlgMw7s/y+6LGQ=="],
"supermemory-mcp/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],