mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-08 03:08:21 +00:00
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:
parent
5b129d7d32
commit
9808441f6b
12 changed files with 860 additions and 2 deletions
|
|
@ -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
65
apps/mcp/e2e/auth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
103
apps/mcp/e2e/capture-oauth-token.ts
Normal file
103
apps/mcp/e2e/capture-oauth-token.ts
Normal 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)
|
||||
})
|
||||
52
apps/mcp/e2e/discovery.test.ts
Normal file
52
apps/mcp/e2e/discovery.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
59
apps/mcp/e2e/graph.test.ts
Normal file
59
apps/mcp/e2e/graph.test.ts
Normal 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
186
apps/mcp/e2e/helpers.ts
Normal 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
127
apps/mcp/e2e/memory.test.ts
Normal 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
126
apps/mcp/e2e/oauth.test.ts
Normal 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()
|
||||
})
|
||||
},
|
||||
)
|
||||
80
apps/mcp/e2e/root-scope.test.ts
Normal file
80
apps/mcp/e2e/root-scope.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
apps/mcp/vitest.config.ts
Normal file
9
apps/mcp/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts"],
|
||||
testTimeout: 90_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
})
|
||||
3
bun.lock
3
bun.lock
|
|
@ -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=="],
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue