diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 6578939cd..761c8ac12 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -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 diff --git a/apps/mcp/e2e/auth.test.ts b/apps/mcp/e2e/auth.test.ts new file mode 100644 index 000000000..404487d6c --- /dev/null +++ b/apps/mcp/e2e/auth.test.ts @@ -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) + }) +}) diff --git a/apps/mcp/e2e/capture-oauth-token.ts b/apps/mcp/e2e/capture-oauth-token.ts new file mode 100644 index 000000000..aad14d905 --- /dev/null +++ b/apps/mcp/e2e/capture-oauth-token.ts @@ -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) +}) diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts new file mode 100644 index 000000000..e18375cc7 --- /dev/null +++ b/apps/mcp/e2e/discovery.test.ts @@ -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) + }) +}) diff --git a/apps/mcp/e2e/graph.test.ts b/apps/mcp/e2e/graph.test.ts new file mode 100644 index 000000000..33de0d2b3 --- /dev/null +++ b/apps/mcp/e2e/graph.test.ts @@ -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) + }) +}) diff --git a/apps/mcp/e2e/helpers.ts b/apps/mcp/e2e/helpers.ts new file mode 100644 index 000000000..1c9c2a52f --- /dev/null +++ b/apps/mcp/e2e/helpers.ts @@ -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 } + +export async function connect( + opts: { apiKey?: string; token?: string; containerTag?: string } = {}, +): Promise { + const headers: Record = { + 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 = {}, +): Promise { + 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 { + 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 { + 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 { + 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 +} diff --git a/apps/mcp/e2e/memory.test.ts b/apps/mcp/e2e/memory.test.ts new file mode 100644 index 000000000..65f936128 --- /dev/null +++ b/apps/mcp/e2e/memory.test.ts @@ -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) + }) +}) diff --git a/apps/mcp/e2e/oauth.test.ts b/apps/mcp/e2e/oauth.test.ts new file mode 100644 index 000000000..4c815391b --- /dev/null +++ b/apps/mcp/e2e/oauth.test.ts @@ -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() + }) + }, +) diff --git a/apps/mcp/e2e/root-scope.test.ts b/apps/mcp/e2e/root-scope.test.ts new file mode 100644 index 000000000..000490a94 --- /dev/null +++ b/apps/mcp/e2e/root-scope.test.ts @@ -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 } +} + +const propsOf = (tools: ToolLike[], name: string): Record => + 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) +}) diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 936c84468..2def592a7 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -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" } } diff --git a/apps/mcp/vitest.config.ts b/apps/mcp/vitest.config.ts new file mode 100644 index 000000000..8289ccd9a --- /dev/null +++ b/apps/mcp/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["e2e/**/*.test.ts"], + testTimeout: 90_000, + hookTimeout: 30_000, + }, +}) diff --git a/apps/web/components/chat/message/user-message.tsx b/apps/web/components/chat/message/user-message.tsx index 2d3d18f31..476ceb7d7 100644 --- a/apps/web/components/chat/message/user-message.tsx +++ b/apps/web/components/chat/message/user-message.tsx @@ -126,7 +126,7 @@ export const UserMessage = memo(function UserMessage({ transition={{ duration: 0.18, ease: "easeOut" }} className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]" > -

{text}

+

{text}

)} diff --git a/bun.lock b/bun.lock index f29b45a1b..7f815b139 100644 --- a/bun.lock +++ b/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=="],