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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions apps/mcp/e2e/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 103 additions & 0 deletions apps/mcp/e2e/capture-oauth-token.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 52 additions & 0 deletions apps/mcp/e2e/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 59 additions & 0 deletions apps/mcp/e2e/graph.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading