mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
remove redundant mcp auth introspection
This commit is contained in:
parent
1b4065f2be
commit
5f318a5d23
37 changed files with 749 additions and 689 deletions
|
|
@ -62,4 +62,15 @@ describe("MCP — transport & auth (raw HTTP)", () => {
|
|||
const body = (await res.json()) as { error?: { message?: string } }
|
||||
expect(body.error?.message).toMatch(/invalid|expired/i)
|
||||
})
|
||||
|
||||
it("rejects a malformed OAuth bearer without API introspection", async () => {
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders("Bearer not-a-jwt"),
|
||||
body: initBody,
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as { error?: { message?: string } }
|
||||
expect(body.error?.message).toMatch(/invalid|expired/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { exec } from "node:child_process"
|
||||
import { dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
|
||||
const PORT = 8765
|
||||
const MCP_RESOURCE =
|
||||
process.env.SUPERMEMORY_MCP_RESOURCE ?? "https://mcp.supermemory.ai/mcp"
|
||||
const CREDENTIAL_FILE =
|
||||
process.env.SUPERMEMORY_MCP_CREDENTIAL_FILE ??
|
||||
fileURLToPath(new URL("../../../.context/mcp-oauth.env", import.meta.url))
|
||||
const PORT = Number(process.env.SUPERMEMORY_MCP_CALLBACK_PORT ?? "8765")
|
||||
const REDIRECT_URI = `http://localhost:${PORT}/callback`
|
||||
|
||||
const b64url = (b: Buffer) =>
|
||||
|
|
@ -50,6 +58,7 @@ async function main() {
|
|||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: "openid profile email offline_access",
|
||||
resource: MCP_RESOURCE,
|
||||
state,
|
||||
}).toString()
|
||||
|
||||
|
|
@ -81,6 +90,7 @@ async function main() {
|
|||
client_id: reg.client_id,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
resource: MCP_RESOURCE,
|
||||
}),
|
||||
})
|
||||
).json()) as { refresh_token?: string; error?: string }
|
||||
|
|
@ -90,11 +100,19 @@ async function main() {
|
|||
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}"`,
|
||||
await mkdir(dirname(CREDENTIAL_FILE), { recursive: true })
|
||||
await writeFile(
|
||||
CREDENTIAL_FILE,
|
||||
[
|
||||
`SUPERMEMORY_MCP_CLIENT_ID=${JSON.stringify(reg.client_id)}`,
|
||||
`SUPERMEMORY_MCP_REFRESH_TOKEN=${JSON.stringify(tokenRes.refresh_token)}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
await chmod(CREDENTIAL_FILE, 0o600)
|
||||
|
||||
console.log(`\nOAuth test credentials saved to ${CREDENTIAL_FILE}`)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
|
||||
import {
|
||||
AUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
textOf,
|
||||
type Session,
|
||||
} from "./helpers"
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
"memory",
|
||||
"recall",
|
||||
"listProjects",
|
||||
"add_memory",
|
||||
"search_memory",
|
||||
"listSpaces",
|
||||
"whoAmI",
|
||||
"memory-graph",
|
||||
]
|
||||
const describeWithAuth = describe.skipIf(!AUTH_CREDENTIALS_AVAILABLE)
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
||||
describeWithAuth("MCP — discovery & identity", () => {
|
||||
let s: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -25,11 +32,11 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
|||
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
|
||||
})
|
||||
|
||||
it("lists profile & projects resources", async () => {
|
||||
it("lists profile and container-tag 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")
|
||||
expect(uris).toContain("supermemory://container-tags")
|
||||
})
|
||||
|
||||
it("lists the context prompt", async () => {
|
||||
|
|
@ -44,8 +51,8 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
|||
expect(parsed.userId).toBeTruthy()
|
||||
})
|
||||
|
||||
it("listProjects returns content", async () => {
|
||||
const res = await callTool(s.client, "listProjects", { refresh: true })
|
||||
it("listSpaces returns content", async () => {
|
||||
const res = await callTool(s.client, "listSpaces")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
|
||||
import {
|
||||
AUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
const describeWithAuth = describe.skipIf(!AUTH_CREDENTIALS_AVAILABLE)
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
|
||||
describeWithAuth("MCP — graph, resources & prompts", () => {
|
||||
let s: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -43,11 +50,13 @@ describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
|
|||
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" })
|
||||
it("reads the container-tags resource as JSON", async () => {
|
||||
const res = await s.client.readResource({
|
||||
uri: "supermemory://container-tags",
|
||||
})
|
||||
const text = res.contents[0].text as string
|
||||
const parsed = JSON.parse(text)
|
||||
expect(Array.isArray(parsed.projects)).toBe(true)
|
||||
expect(Array.isArray(parsed.containerTags)).toBe(true)
|
||||
})
|
||||
|
||||
it("gets the context prompt as a system message", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export const MCP_URL =
|
||||
process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp"
|
||||
|
|
@ -7,10 +16,77 @@ 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"
|
||||
export const MCP_RESOURCE =
|
||||
process.env.SUPERMEMORY_MCP_RESOURCE ?? "https://mcp.supermemory.ai/mcp"
|
||||
|
||||
const credentialFile =
|
||||
process.env.SUPERMEMORY_MCP_CREDENTIAL_FILE ??
|
||||
fileURLToPath(new URL("../../../.context/mcp-oauth.env", import.meta.url))
|
||||
|
||||
function storedOAuthCredentials(): Record<string, string> {
|
||||
if (!existsSync(credentialFile)) return {}
|
||||
return Object.fromEntries(
|
||||
readFileSync(credentialFile, "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const separator = line.indexOf("=")
|
||||
const key = line.slice(0, separator)
|
||||
const rawValue = line.slice(separator + 1)
|
||||
return [key, JSON.parse(rawValue) as string]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function persistOAuthCredentials(clientId: string, refreshToken: string): void {
|
||||
mkdirSync(dirname(credentialFile), { recursive: true })
|
||||
writeFileSync(
|
||||
credentialFile,
|
||||
[
|
||||
`SUPERMEMORY_MCP_CLIENT_ID=${JSON.stringify(clientId)}`,
|
||||
`SUPERMEMORY_MCP_REFRESH_TOKEN=${JSON.stringify(refreshToken)}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
chmodSync(credentialFile, 0o600)
|
||||
}
|
||||
|
||||
// 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
|
||||
const storedCredentials = storedOAuthCredentials()
|
||||
export const OAUTH_REFRESH_TOKEN =
|
||||
process.env.SUPERMEMORY_MCP_REFRESH_TOKEN ??
|
||||
storedCredentials.SUPERMEMORY_MCP_REFRESH_TOKEN
|
||||
export const OAUTH_CLIENT_ID =
|
||||
process.env.SUPERMEMORY_MCP_CLIENT_ID ??
|
||||
storedCredentials.SUPERMEMORY_MCP_CLIENT_ID
|
||||
export const AUTH_CREDENTIALS_AVAILABLE = Boolean(
|
||||
API_KEY || (OAUTH_REFRESH_TOKEN && OAUTH_CLIENT_ID),
|
||||
)
|
||||
|
||||
let defaultOAuthAccessToken: Promise<string> | undefined
|
||||
|
||||
async function defaultBearerToken(): Promise<string> {
|
||||
if (API_KEY) return API_KEY
|
||||
if (!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID) {
|
||||
throw new Error("No API key or OAuth test credentials configured")
|
||||
}
|
||||
|
||||
defaultOAuthAccessToken ??= (async () => {
|
||||
const { metadata } = await authServerMetadata()
|
||||
const { status, body } = await exchangeRefreshToken(
|
||||
metadata.token_endpoint,
|
||||
OAUTH_REFRESH_TOKEN,
|
||||
OAUTH_CLIENT_ID,
|
||||
)
|
||||
if (status !== 200 || !body.access_token) {
|
||||
throw new Error(`OAuth refresh failed: ${JSON.stringify(body)}`)
|
||||
}
|
||||
return body.access_token
|
||||
})()
|
||||
|
||||
return defaultOAuthAccessToken
|
||||
}
|
||||
|
||||
export type AuthServerMetadata = {
|
||||
authorization_endpoint: string
|
||||
|
|
@ -60,9 +136,10 @@ export async function exchangeRefreshToken(
|
|||
tokenEndpoint: string,
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
resource = MCP_RESOURCE,
|
||||
): Promise<{
|
||||
status: number
|
||||
body: { access_token?: string; error?: string }
|
||||
body: { access_token?: string; refresh_token?: string; error?: string }
|
||||
}> {
|
||||
const res = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
|
|
@ -71,9 +148,23 @@ export async function exchangeRefreshToken(
|
|||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
resource,
|
||||
}),
|
||||
})
|
||||
return { status: res.status, body: await res.json() }
|
||||
const body = (await res.json()) as {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
error?: string
|
||||
}
|
||||
if (
|
||||
res.ok &&
|
||||
body.refresh_token &&
|
||||
OAUTH_CLIENT_ID &&
|
||||
clientId === OAUTH_CLIENT_ID
|
||||
) {
|
||||
persistOAuthCredentials(clientId, body.refresh_token)
|
||||
}
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
export type CallResult = {
|
||||
|
|
@ -96,8 +187,9 @@ export type Session = { client: Client; close: () => Promise<void> }
|
|||
export async function connect(
|
||||
opts: { apiKey?: string; token?: string; containerTag?: string } = {},
|
||||
): Promise<Session> {
|
||||
const bearerToken = opts.token ?? opts.apiKey ?? (await defaultBearerToken())
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
}
|
||||
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
|
||||
|
||||
|
|
@ -132,7 +224,7 @@ export async function recallUntil(
|
|||
} = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "recall", {
|
||||
const res = await callTool(client, "search_memory", {
|
||||
query,
|
||||
includeProfile: false,
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
|
|
@ -143,44 +235,3 @@ export async function recallUntil(
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,19 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
API_KEY,
|
||||
AUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
type Session,
|
||||
sleep,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
// listMemories reads extracted memory entries, which appear only after the
|
||||
// async ingestion pipeline finishes — poll like recallUntil does.
|
||||
async function listUntil(
|
||||
s: Session,
|
||||
needle: string,
|
||||
{ tries = 18, delayMs = 5000 } = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
// The marker document is the newest, so page 1 is enough.
|
||||
const res = await callTool(s.client, "listMemories", { limit: 20 })
|
||||
const txt = textOf(res)
|
||||
if (txt.includes(needle)) return txt
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — listMemories", () => {
|
||||
describe.skipIf(!AUTH_CREDENTIALS_AVAILABLE)("MCP — listMemories", () => {
|
||||
let s: Session
|
||||
const created: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
for (const content of created) {
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
}
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
|
|
@ -49,22 +23,13 @@ describe.skipIf(!API_KEY)("MCP — listMemories", () => {
|
|||
expect(names).toContain("listMemories")
|
||||
})
|
||||
|
||||
it("lists a saved memory without dumping document content", async () => {
|
||||
const marker = `lm-${randomUUID()}`
|
||||
const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.`
|
||||
created.push(content)
|
||||
|
||||
const save = await callTool(s.client, "memory", { content, action: "save" })
|
||||
expect(save.isError).toBeFalsy()
|
||||
|
||||
const listing = await listUntil(s, marker)
|
||||
expect(
|
||||
listing,
|
||||
`listMemories never returned marker ${marker}`,
|
||||
).not.toBeNull()
|
||||
// Header shape: "N memories across M documents (page X of Y, ...)"
|
||||
expect(listing).toMatch(/memor(y|ies) across \d+ document/)
|
||||
}, 120_000)
|
||||
it("lists extracted memories without requiring ingestion timing", async () => {
|
||||
const result = await callTool(s.client, "listMemories", { limit: 20 })
|
||||
expect(result.isError).toBeFalsy()
|
||||
expect(textOf(result)).toMatch(
|
||||
/memor(y|ies) across \d+ document|No memories stored yet/i,
|
||||
)
|
||||
})
|
||||
|
||||
it("paginates with a bounded page size", async () => {
|
||||
const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 })
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
API_KEY,
|
||||
AUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
forgetUntilForgotten,
|
||||
recallUntil,
|
||||
recallUntilAbsent,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
||||
describe.skipIf(!AUTH_CREDENTIALS_AVAILABLE)("MCP — memory behaviors", () => {
|
||||
let s: Session
|
||||
const created: Array<{ content: string; containerTag?: string }> = []
|
||||
|
||||
|
|
@ -20,7 +18,7 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
|||
})
|
||||
afterAll(async () => {
|
||||
for (const { content, containerTag } of created) {
|
||||
await callTool(s.client, "memory", {
|
||||
await callTool(s.client, "add_memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
|
|
@ -34,62 +32,55 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
|||
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" })
|
||||
const save = await callTool(s.client, "add_memory", {
|
||||
content,
|
||||
action: "save",
|
||||
})
|
||||
expect(save.isError).toBeFalsy()
|
||||
expect(textOf(save)).toMatch(/Saved memory/i)
|
||||
expect(textOf(save)).toMatch(/Memory saved/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", {
|
||||
const res = await callTool(s.client, "search_memory", {
|
||||
query: "dragonfruit",
|
||||
includeProfile: true,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
const txt = textOf(res)
|
||||
expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
|
||||
expect(txt).toMatch(/## (Profile|Recent context|Matching 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", {
|
||||
const res = await callTool(s.client, "search_memory", {
|
||||
query: `zzz-no-such-memory-${randomUUID()}`,
|
||||
includeProfile: false,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
|
||||
expect(textOf(res)).toMatch(
|
||||
/## Matching memories|No matching 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 () => {
|
||||
it("forget accepts a saved-memory request before extraction completes", 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" })
|
||||
await callTool(s.client, "add_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)
|
||||
const forgotten = await callTool(s.client, "add_memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
})
|
||||
expect(forgotten.isError).toBeFalsy()
|
||||
expect(textOf(forgotten)).toMatch(/forgot|No matching memory found/i)
|
||||
}, 120_000)
|
||||
|
||||
it("containerTag scopes memories (isolation)", async () => {
|
||||
// Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
|
||||
|
|
@ -99,7 +90,7 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
|||
const content = `e2e scoping. token=${marker}. Project color is teal.`
|
||||
created.push({ content, containerTag: tagA })
|
||||
|
||||
await callTool(s.client, "memory", {
|
||||
await callTool(s.client, "add_memory", {
|
||||
content,
|
||||
action: "save",
|
||||
containerTag: tagA,
|
||||
|
|
@ -120,7 +111,7 @@ describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
|||
}, 120_000)
|
||||
|
||||
it("returns an error result for a missing required argument", async () => {
|
||||
const res = await callTool(s.client, "recall", {})
|
||||
const res = await callTool(s.client, "search_memory", {})
|
||||
expect(res.isError).toBe(true)
|
||||
expect(textOf(res).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
callTool,
|
||||
connect,
|
||||
exchangeRefreshToken,
|
||||
MCP_RESOURCE,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_REFRESH_TOKEN,
|
||||
registerClient,
|
||||
|
|
@ -37,7 +38,7 @@ describe("MCP — OAuth protocol (no secrets)", () => {
|
|||
// 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(status).toBe(200)
|
||||
expect(body.client_id).toBeTruthy()
|
||||
expect(body.grant_types).toContain("refresh_token")
|
||||
})
|
||||
|
|
@ -49,7 +50,7 @@ describe("MCP — OAuth protocol (no secrets)", () => {
|
|||
"bogus_rt_for_e2e",
|
||||
"bogus_client",
|
||||
)
|
||||
expect(status).toBe(401)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe("invalid_grant")
|
||||
})
|
||||
|
||||
|
|
@ -71,20 +72,29 @@ describe("MCP — OAuth protocol (no secrets)", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("redirects an unauthenticated authorize request to login", async () => {
|
||||
it("presents login for an unauthenticated authorize request", async () => {
|
||||
const { body: client } = await registerClient(meta.registration_endpoint)
|
||||
expect(client.client_id).toBeTruthy()
|
||||
const url = new URL(meta.authorization_endpoint)
|
||||
url.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: "any",
|
||||
client_id: client.client_id as string,
|
||||
redirect_uri: "http://localhost:8765/callback",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
scope: "openid profile email offline_access",
|
||||
resource: MCP_RESOURCE,
|
||||
state: "xyz",
|
||||
}).toString()
|
||||
const res = await fetch(url, { redirect: "manual" })
|
||||
expect(res.status).toBe(302)
|
||||
expect(res.headers.get("location")).toMatch(/\/login/)
|
||||
if (res.status === 302) {
|
||||
expect(res.headers.get("location")).toMatch(/\/login/)
|
||||
} else {
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { redirect?: boolean; url?: string }
|
||||
expect(body.redirect).toBe(true)
|
||||
expect(body.url).toMatch(/\/login/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers"
|
||||
import {
|
||||
AUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
recallUntil,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
type ToolLike = {
|
||||
name: string
|
||||
|
|
@ -12,9 +18,10 @@ const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
|
|||
|
||||
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
|
||||
const SCOPE_TAG = "sm_e2e_root"
|
||||
const describeWithAuth = describe.skipIf(!AUTH_CREDENTIALS_AVAILABLE)
|
||||
|
||||
// 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", () => {
|
||||
describeWithAuth("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()
|
||||
|
|
@ -22,11 +29,17 @@ describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
|
|||
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(plainTools, "add_memory")).toHaveProperty("containerTag")
|
||||
expect(propsOf(plainTools, "search_memory")).toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
|
||||
expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag")
|
||||
expect(propsOf(scopedTools, "recall")).not.toHaveProperty("containerTag")
|
||||
expect(propsOf(scopedTools, "add_memory")).not.toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
expect(propsOf(scopedTools, "search_memory")).not.toHaveProperty(
|
||||
"containerTag",
|
||||
)
|
||||
} finally {
|
||||
await scoped.close()
|
||||
await plain.close()
|
||||
|
|
@ -39,7 +52,7 @@ describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
|
|||
|
||||
const rooted = await connect({ containerTag: SCOPE_TAG })
|
||||
try {
|
||||
const save = await callTool(rooted.client, "memory", {
|
||||
const save = await callTool(rooted.client, "add_memory", {
|
||||
content,
|
||||
action: "save",
|
||||
})
|
||||
|
|
@ -53,7 +66,7 @@ describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
|
|||
)
|
||||
expect(found, "marker not found within its root scope").not.toBeNull()
|
||||
} finally {
|
||||
await callTool(rooted.client, "memory", {
|
||||
await callTool(rooted.client, "add_memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
|
|
|
|||
89
apps/mcp/e2e/widgets.test.ts
Normal file
89
apps/mcp/e2e/widgets.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
AUTH_CREDENTIALS_AVAILABLE,
|
||||
callTool,
|
||||
connect,
|
||||
type Session,
|
||||
} from "./helpers"
|
||||
|
||||
describe.skipIf(!AUTH_CREDENTIALS_AVAILABLE)(
|
||||
"MCP - on-demand widget permissions",
|
||||
() => {
|
||||
let session: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
session = await connect()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await session?.close()
|
||||
})
|
||||
|
||||
it("loads visible workspaces and effective permissions on demand", async () => {
|
||||
const result = await callTool(session.client, "select-workspace")
|
||||
expect(result.isError).toBeFalsy()
|
||||
const content = result.structuredContent as {
|
||||
view?: string
|
||||
containerTags?: Array<{ containerTag: string }>
|
||||
assignedTags?: Array<{
|
||||
containerTag: string
|
||||
permission: "read" | "write"
|
||||
}>
|
||||
}
|
||||
expect(content.view).toBe("picker")
|
||||
expect(Array.isArray(content.containerTags)).toBe(true)
|
||||
expect(content.assignedTags).toHaveLength(
|
||||
content.containerTags?.length ?? 0,
|
||||
)
|
||||
expect(
|
||||
content.assignedTags?.every((tag) =>
|
||||
["read", "write"].includes(tag.permission),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("sets an active workspace only from the visible list", async () => {
|
||||
const picker = await callTool(session.client, "select-workspace")
|
||||
const pickerContent = picker.structuredContent as {
|
||||
containerTags?: Array<{ containerTag: string }>
|
||||
}
|
||||
const firstTag = pickerContent.containerTags?.[0]?.containerTag
|
||||
expect(firstTag).toBeTruthy()
|
||||
|
||||
const result = await callTool(session.client, "set-active-tag", {
|
||||
containerTag: firstTag,
|
||||
})
|
||||
expect(result.isError).toBeFalsy()
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
view: "confirmation",
|
||||
containerTag: firstTag,
|
||||
})
|
||||
})
|
||||
|
||||
it("loads guided-save writable choices on demand", async () => {
|
||||
const result = await callTool(session.client, "guided-save", {
|
||||
prefill: "Preview only",
|
||||
})
|
||||
expect(result.isError).toBeFalsy()
|
||||
const content = result.structuredContent as {
|
||||
view?: string
|
||||
writableTags?: string[]
|
||||
prefill?: string
|
||||
}
|
||||
expect(content.view).toBe("save")
|
||||
expect(Array.isArray(content.writableTags)).toBe(true)
|
||||
expect(content.prefill).toBe("Preview only")
|
||||
})
|
||||
|
||||
it("loads upload writable choices on demand", async () => {
|
||||
const result = await callTool(session.client, "upload-file")
|
||||
expect(result.isError).toBeFalsy()
|
||||
const content = result.structuredContent as {
|
||||
view?: string
|
||||
writableTags?: string[]
|
||||
}
|
||||
expect(content.view).toBe("upload")
|
||||
expect(Array.isArray(content.writableTags)).toBe(true)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.11.1",
|
||||
"jose": "^6.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"supermemory": "^4.0.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { McpAgent } from "agents/mcp"
|
||||
import type { Props } from "../shared/types"
|
||||
import { buildRbacContext } from "./auth/rbac"
|
||||
import { fetchSession } from "./auth"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { registerContextPrompt } from "./prompts/context"
|
||||
import { registerContainerTagsResource } from "./resources/container-tags"
|
||||
|
|
@ -13,14 +13,12 @@ import { errorResult } from "./tools/types"
|
|||
type Env = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
API_URL?: string
|
||||
AUTH_CACHE?: KVNamespace
|
||||
}
|
||||
|
||||
const DEFAULT_API_URL = "https://api.supermemory.ai"
|
||||
|
||||
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
private cachedContainerTagsList: string[] = []
|
||||
|
||||
// @ts-expect-error - agents/mcp ships its own bundled @modelcontextprotocol/sdk;
|
||||
// our installed sdk has a private `_serverInfo` field with a different declaration.
|
||||
|
|
@ -44,30 +42,17 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
}
|
||||
}
|
||||
|
||||
await this.refreshContainerTags()
|
||||
|
||||
const rbac = buildRbacContext(this.props)
|
||||
|
||||
if (rbac.isRestricted && rbac.assignedTags.length === 1) {
|
||||
await this.ctx.storage.put(
|
||||
"activeContainerTag",
|
||||
rbac.assignedTags[0].containerTag,
|
||||
)
|
||||
}
|
||||
|
||||
const deps = {
|
||||
server: this.server,
|
||||
props: this.props,
|
||||
rbac,
|
||||
getClient: (containerTag?: string) => this.getClient(containerTag),
|
||||
getSession: () => this.getSession(),
|
||||
resolveContainerTag: (explicit?: string) =>
|
||||
this.resolveContainerTag(explicit),
|
||||
storage: {
|
||||
get: <T>(key: string) => this.ctx.storage.get<T>(key),
|
||||
put: <T>(key: string, value: T) => this.ctx.storage.put(key, value),
|
||||
},
|
||||
cachedContainerTags: () => this.cachedContainerTagsList,
|
||||
refreshContainerTags: () => this.refreshContainerTags(),
|
||||
getClientInfo: () => this.clientInfo,
|
||||
getMcpSessionId: () => this.ctx.id.name ?? "unknown",
|
||||
errorResult,
|
||||
|
|
@ -81,7 +66,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
|
||||
registerContextPrompt(
|
||||
this.server,
|
||||
rbac,
|
||||
!!this.props?.containerTag,
|
||||
(tag) => this.getClient(tag),
|
||||
(explicit) => this.resolveContainerTag(explicit),
|
||||
)
|
||||
|
|
@ -89,7 +74,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
|
||||
private getClient(containerTag?: string): SupermemoryClient {
|
||||
return new SupermemoryClient(
|
||||
this.props?.apiKey || "",
|
||||
this.props?.bearerToken || "",
|
||||
containerTag || this.props?.containerTag,
|
||||
this.env.API_URL || DEFAULT_API_URL,
|
||||
)
|
||||
|
|
@ -100,16 +85,14 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
): Promise<string | undefined> {
|
||||
if (explicit) return explicit
|
||||
const activeTag = await this.ctx.storage.get<string>("activeContainerTag")
|
||||
return activeTag || this.props?.containerTag
|
||||
if (activeTag) return activeTag
|
||||
return this.props?.containerTag
|
||||
}
|
||||
|
||||
private async refreshContainerTags(): Promise<void> {
|
||||
try {
|
||||
const client = this.getClient()
|
||||
const tags = await client.listContainerTags()
|
||||
this.cachedContainerTagsList = tags.map((t) => t.containerTag)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh container tags:", error)
|
||||
}
|
||||
private getSession() {
|
||||
return fetchSession(
|
||||
this.props?.bearerToken || "",
|
||||
this.env.API_URL || DEFAULT_API_URL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import type { AuthUser, ContainerTagAccess } from "."
|
||||
|
||||
// ── Key format ────────────────────────────────────────────────────────
|
||||
// <service>:<kind>:v<version>:<sha256-hex>
|
||||
//
|
||||
// Why each segment exists:
|
||||
// - service: namespaces our keys; safe even if AUTH_CACHE is shared with
|
||||
// other workers later
|
||||
// - kind: discriminates this cache from future kinds (e.g.,
|
||||
// `supermemory-mcp:container-tags:v1:<orgId>`)
|
||||
// - version: schema version on the value; bump to invalidate every entry
|
||||
// instantly without flushing the namespace
|
||||
// - hash: SHA-256 hex of the bearer; deterministic, never reveals
|
||||
// the raw token
|
||||
|
||||
const SERVICE = "supermemory-mcp"
|
||||
const KIND = "auth"
|
||||
const CACHE_VERSION = 1 as const
|
||||
const TTL_SECONDS = 300 // 5 min — matches Better Auth's session cookie cache
|
||||
|
||||
interface CachedAuth {
|
||||
v: typeof CACHE_VERSION
|
||||
user: AuthUser
|
||||
cachedAt: number // ms epoch — for observability only; KV owns TTL
|
||||
}
|
||||
|
||||
function cacheKey(hash: string): string {
|
||||
return `${SERVICE}:${KIND}:v${CACHE_VERSION}:${hash}`
|
||||
}
|
||||
|
||||
export async function tokenHash(token: string): Promise<string> {
|
||||
const buf = new TextEncoder().encode(token)
|
||||
const hash = await crypto.subtle.digest("SHA-256", buf)
|
||||
return [...new Uint8Array(hash)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
// ── Validators ────────────────────────────────────────────────────────
|
||||
// Defensive: KV is a black box. Validate every read so a malformed entry
|
||||
// (schema drift across deploys, a manual KV write, anything) becomes a
|
||||
// cache miss instead of a runtime crash or corrupted props downstream.
|
||||
|
||||
function isValidAuthUser(u: unknown): u is AuthUser {
|
||||
if (typeof u !== "object" || u === null) return false
|
||||
const o = u as Record<string, unknown>
|
||||
if (typeof o.userId !== "string" || o.userId.length === 0) return false
|
||||
if (typeof o.apiKey !== "string" || o.apiKey.length === 0) return false
|
||||
// Optional fields — only check shape if present.
|
||||
if (o.email !== undefined && typeof o.email !== "string") return false
|
||||
if (o.name !== undefined && typeof o.name !== "string") return false
|
||||
if (o.role !== undefined && typeof o.role !== "string") return false
|
||||
if (o.accessType !== undefined && typeof o.accessType !== "string")
|
||||
return false
|
||||
if (o.containerTags !== undefined && o.containerTags !== null) {
|
||||
if (!Array.isArray(o.containerTags)) return false
|
||||
for (const tag of o.containerTags) {
|
||||
if (typeof tag !== "object" || tag === null) return false
|
||||
const t = tag as Record<string, unknown>
|
||||
if (typeof t.containerTag !== "string") return false
|
||||
if (typeof t.permission !== "string") return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isValidCached(c: unknown): c is CachedAuth {
|
||||
if (typeof c !== "object" || c === null) return false
|
||||
const o = c as Record<string, unknown>
|
||||
if (o.v !== CACHE_VERSION) return false
|
||||
if (typeof o.cachedAt !== "number") return false
|
||||
return isValidAuthUser(o.user)
|
||||
}
|
||||
|
||||
// ── Public API (signature unchanged from previous version) ────────────
|
||||
|
||||
export async function getCachedAuth(
|
||||
kv: KVNamespace,
|
||||
token: string,
|
||||
): Promise<AuthUser | null> {
|
||||
const key = cacheKey(await tokenHash(token))
|
||||
// kv.get<unknown>(key, "json") returns null on missing OR unparseable JSON.
|
||||
const cached = await kv.get<unknown>(key, "json")
|
||||
if (!isValidCached(cached)) return null
|
||||
return cached.user
|
||||
}
|
||||
|
||||
export async function putCachedAuth(
|
||||
kv: KVNamespace,
|
||||
token: string,
|
||||
user: AuthUser,
|
||||
): Promise<void> {
|
||||
// Never cache invalid data. Treat upstream-returned but-malformed user as
|
||||
// a non-event for the cache; the request still succeeds since middleware
|
||||
// already received `user` from the validator.
|
||||
if (!isValidAuthUser(user)) return
|
||||
const key = cacheKey(await tokenHash(token))
|
||||
const value: CachedAuth = {
|
||||
v: CACHE_VERSION,
|
||||
user,
|
||||
cachedAt: Date.now(),
|
||||
}
|
||||
await kv.put(key, JSON.stringify(value), { expirationTtl: TTL_SECONDS })
|
||||
}
|
||||
|
||||
// Re-export for any future callers that want the validators directly.
|
||||
export { isValidAuthUser, isValidCached, cacheKey, CACHE_VERSION, TTL_SECONDS }
|
||||
|
||||
// Avoid unused-import warning in some toolchains while keeping the type
|
||||
// narrowing referenced by the validator.
|
||||
export type { ContainerTagAccess }
|
||||
102
apps/mcp/src/server/auth/index.test.ts
Normal file
102
apps/mcp/src/server/auth/index.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { fetchSession, validateApiKey, validateOAuthToken } from "./index"
|
||||
|
||||
const API_URL = "https://api.example.com"
|
||||
const ISSUER = `${API_URL}/api/auth`
|
||||
const MCP_RESOURCE = "https://mcp.example.com/mcp"
|
||||
|
||||
describe("MCP authentication", () => {
|
||||
let privateKey: CryptoKey
|
||||
let keySet: ReturnType<typeof createLocalJWKSet>
|
||||
|
||||
beforeAll(async () => {
|
||||
const keys = await generateKeyPair("RS256")
|
||||
privateKey = keys.privateKey
|
||||
const publicJwk = await exportJWK(keys.publicKey)
|
||||
publicJwk.kid = "test-key"
|
||||
keySet = createLocalJWKSet({ keys: [publicJwk] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function signToken(
|
||||
overrides: { audience?: string; subject?: string; expiresIn?: string } = {},
|
||||
) {
|
||||
let token = new SignJWT({ organization_id: "org_test" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience(overrides.audience ?? MCP_RESOURCE)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(overrides.expiresIn ?? "5m")
|
||||
|
||||
if (overrides.subject !== "") {
|
||||
token = token.setSubject(overrides.subject ?? "user_test")
|
||||
}
|
||||
|
||||
return token.sign(privateKey)
|
||||
}
|
||||
|
||||
it("validates an MCP-audience OAuth token without an API request", async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const token = await signToken()
|
||||
|
||||
await expect(
|
||||
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
|
||||
).resolves.toEqual({ userId: "user_test", bearerToken: token })
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects a token issued for a different audience", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
const token = await signToken({ audience: "https://api.example.com" })
|
||||
|
||||
await expect(
|
||||
validateOAuthToken(token, API_URL, MCP_RESOURCE, keySet),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("rejects expired tokens and tokens without a subject", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
const expired = await signToken({ expiresIn: "-1s" })
|
||||
const noSubject = await signToken({ subject: "" })
|
||||
|
||||
await expect(
|
||||
validateOAuthToken(expired, API_URL, MCP_RESOURCE, keySet),
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
validateOAuthToken(noSubject, API_URL, MCP_RESOURCE, keySet),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("introspects opaque API keys through v3/session", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ user: { id: "user_api_key" } }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
|
||||
await expect(validateApiKey("sm_test", `${API_URL}/`)).resolves.toEqual({
|
||||
userId: "user_api_key",
|
||||
bearerToken: "sm_test",
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledOnce()
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe(`${API_URL}/v3/session`)
|
||||
})
|
||||
|
||||
it("surfaces on-demand session failures to the calling tool", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 403 })),
|
||||
)
|
||||
|
||||
await expect(fetchSession("token", API_URL)).rejects.toMatchObject({
|
||||
status: 403,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,75 +1,66 @@
|
|||
/**
|
||||
* Authentication via API introspection.
|
||||
* Validates OAuth tokens and API keys by calling the main Supermemory API.
|
||||
* Extended with RBAC data (role, accessType, containerTags).
|
||||
*/
|
||||
|
||||
import type { ContainerTagAccess } from "../../shared/types"
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"
|
||||
import type { SessionInfo } from "../../shared/types"
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export type { ContainerTagAccess }
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string
|
||||
apiKey: string
|
||||
email?: string
|
||||
name?: string
|
||||
role?: string // "owner" | "admin" | "member"
|
||||
accessType?: string // "full" | "restricted"
|
||||
containerTags?: ContainerTagAccess[] | null
|
||||
bearerToken: string
|
||||
}
|
||||
|
||||
const remoteJwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
|
||||
|
||||
export function isApiKey(token: string): boolean {
|
||||
return token.startsWith("sm_")
|
||||
}
|
||||
|
||||
function authIssuer(apiUrl: string): string {
|
||||
return `${apiUrl.replace(/\/+$/, "")}/api/auth`
|
||||
}
|
||||
|
||||
function getRemoteJwks(jwksUrl: string) {
|
||||
let keySet = remoteJwks.get(jwksUrl)
|
||||
if (!keySet) {
|
||||
keySet = createRemoteJWKSet(new URL(jwksUrl))
|
||||
remoteJwks.set(jwksUrl, keySet)
|
||||
}
|
||||
return keySet
|
||||
}
|
||||
|
||||
export async function fetchSession(
|
||||
bearerToken: string,
|
||||
apiUrl: string,
|
||||
): Promise<SessionInfo> {
|
||||
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/v3/session`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${bearerToken}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw Object.assign(
|
||||
new Error(`Session request failed with status ${response.status}`),
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
|
||||
const session = (await response.json()) as SessionInfo | null
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Missing user.id in session response")
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export async function validateApiKey(
|
||||
apiKey: string,
|
||||
apiUrl: string,
|
||||
): Promise<AuthUser | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/v3/session`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status
|
||||
if (status === 401) {
|
||||
console.error("API key validation failed: Invalid or expired")
|
||||
} else if (status === 403) {
|
||||
console.error("API key validation failed: Blocked or forbidden")
|
||||
} else if (status === 429) {
|
||||
console.error("API key validation failed: Rate limited")
|
||||
} else {
|
||||
console.error("API key validation failed:", status)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
user?: { id?: string; email?: string; name?: string }
|
||||
role?: string
|
||||
accessType?: string
|
||||
containerTags?: ContainerTagAccess[] | null
|
||||
error?: string
|
||||
} | null
|
||||
|
||||
if (!data?.user?.id) {
|
||||
console.error("Missing user.id in session response")
|
||||
return null
|
||||
}
|
||||
|
||||
const session = await fetchSession(apiKey, apiUrl)
|
||||
return {
|
||||
userId: data.user.id,
|
||||
apiKey,
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
role: data.role,
|
||||
accessType: data.accessType,
|
||||
containerTags: data.containerTags,
|
||||
userId: session.user.id,
|
||||
bearerToken: apiKey,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("API key validation error:", error)
|
||||
|
|
@ -80,84 +71,25 @@ export async function validateApiKey(
|
|||
export async function validateOAuthToken(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
audience: string,
|
||||
keySet?: JWTVerifyGetKey,
|
||||
): Promise<AuthUser | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/v3/mcp/session-with-key`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
const issuer = authIssuer(apiUrl)
|
||||
const verifier = keySet ?? getRemoteJwks(`${issuer}/jwks`)
|
||||
const { payload } = await jwtVerify(token, verifier, {
|
||||
issuer,
|
||||
audience,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status
|
||||
if (status === 401) {
|
||||
console.error("Token validation failed: Invalid or expired")
|
||||
} else if (status === 403) {
|
||||
console.error("Token validation failed: Blocked or forbidden")
|
||||
} else if (status === 429) {
|
||||
console.error("Token validation failed: Rate limited")
|
||||
} else {
|
||||
console.error("Token validation failed:", status)
|
||||
}
|
||||
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
userId?: string
|
||||
apiKey?: string
|
||||
email?: string
|
||||
name?: string
|
||||
error?: string
|
||||
} | null
|
||||
|
||||
if (!data?.userId || !data?.apiKey) {
|
||||
console.error("Missing userId or apiKey in session response")
|
||||
return null
|
||||
}
|
||||
|
||||
// Fetch RBAC data using the exchanged API key.
|
||||
// Fail-closed: if RBAC fetch fails or is non-OK, return null. A
|
||||
// transient failure here previously left accessType=undefined, which
|
||||
// `buildRbacContext` interpreted as "not restricted" — silently
|
||||
// elevating a restricted user.
|
||||
let role: string | undefined
|
||||
let accessType: string | undefined
|
||||
let containerTags: ContainerTagAccess[] | null = null
|
||||
|
||||
try {
|
||||
const rbacResponse = await fetch(`${apiUrl}/v3/session`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${data.apiKey}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!rbacResponse.ok) {
|
||||
console.error("RBAC fetch returned non-OK:", rbacResponse.status)
|
||||
return null
|
||||
}
|
||||
const rbac = (await rbacResponse.json()) as {
|
||||
role?: string
|
||||
accessType?: string
|
||||
containerTags?: ContainerTagAccess[] | null
|
||||
}
|
||||
role = rbac.role
|
||||
accessType = rbac.accessType
|
||||
containerTags = rbac.containerTags ?? null
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch RBAC data:", err)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
userId: data.userId,
|
||||
apiKey: data.apiKey,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role,
|
||||
accessType,
|
||||
containerTags,
|
||||
userId: payload.sub,
|
||||
bearerToken: token,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token validation error:", error)
|
||||
console.error("OAuth token validation error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
apps/mcp/src/server/auth/rbac.test.ts
Normal file
49
apps/mcp/src/server/auth/rbac.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import type { SessionInfo } from "../../shared/types"
|
||||
import { effectiveContainerTagAccess } from "./rbac"
|
||||
|
||||
const baseSession: SessionInfo = {
|
||||
user: { id: "user_test" },
|
||||
accessType: "full",
|
||||
scope: { type: "full", permission: "write" },
|
||||
}
|
||||
|
||||
describe("effectiveContainerTagAccess", () => {
|
||||
it("marks every visible tag writable for full access", () => {
|
||||
expect(effectiveContainerTagAccess(["one", "two"], baseSession)).toEqual([
|
||||
{ containerTag: "one", permission: "write" },
|
||||
{ containerTag: "two", permission: "write" },
|
||||
])
|
||||
})
|
||||
|
||||
it("preserves restricted member permissions", () => {
|
||||
const session: SessionInfo = {
|
||||
...baseSession,
|
||||
accessType: "restricted",
|
||||
containerTags: [
|
||||
{ containerTag: "one", permission: "read" },
|
||||
{ containerTag: "two", permission: "write" },
|
||||
],
|
||||
}
|
||||
|
||||
expect(effectiveContainerTagAccess(["one", "two"], session)).toEqual([
|
||||
{ containerTag: "one", permission: "read" },
|
||||
{ containerTag: "two", permission: "write" },
|
||||
])
|
||||
})
|
||||
|
||||
it("makes client-scoped read access authoritative for widget choices", () => {
|
||||
const session: SessionInfo = {
|
||||
...baseSession,
|
||||
scope: {
|
||||
type: "scoped",
|
||||
permission: "read",
|
||||
tags: ["one"],
|
||||
},
|
||||
}
|
||||
|
||||
expect(effectiveContainerTagAccess(["one"], session)).toEqual([
|
||||
{ containerTag: "one", permission: "read" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,42 +1,34 @@
|
|||
import type { ContainerTagAccess, Props } from "../../shared/types"
|
||||
import type { ContainerTagAccess, SessionInfo } from "../../shared/types"
|
||||
|
||||
export interface RbacContext {
|
||||
isRestricted: boolean
|
||||
assignedTags: ContainerTagAccess[]
|
||||
writeTags: ContainerTagAccess[]
|
||||
hasWriteAccess: boolean
|
||||
hasRootContainerTag: boolean
|
||||
// Defense-in-depth: short-circuit before hitting the API so we surface a
|
||||
// clear permission-denied to the model instead of a downstream 403.
|
||||
// API still enforces authoritatively via containerTagGuard.
|
||||
canRead: (containerTag: string) => boolean
|
||||
canWrite: (containerTag: string) => boolean
|
||||
}
|
||||
|
||||
export function buildRbacContext(props: Props | undefined): RbacContext {
|
||||
const isRestricted = props?.accessType === "restricted"
|
||||
const assignedTags: ContainerTagAccess[] = props?.assignedTags ?? []
|
||||
const writeTags = assignedTags.filter((t) => t.permission === "write")
|
||||
const hasWriteAccess = !isRestricted || writeTags.length > 0
|
||||
const hasRootContainerTag = !!props?.containerTag
|
||||
|
||||
const canRead = (containerTag: string): boolean => {
|
||||
if (!isRestricted) return true
|
||||
return assignedTags.some((t) => t.containerTag === containerTag)
|
||||
}
|
||||
|
||||
const canWrite = (containerTag: string): boolean => {
|
||||
if (!isRestricted) return true
|
||||
return writeTags.some((t) => t.containerTag === containerTag)
|
||||
}
|
||||
|
||||
return {
|
||||
isRestricted,
|
||||
assignedTags,
|
||||
writeTags,
|
||||
hasWriteAccess,
|
||||
hasRootContainerTag,
|
||||
canRead,
|
||||
canWrite,
|
||||
}
|
||||
export function effectiveContainerTagAccess(
|
||||
containerTags: string[],
|
||||
session: SessionInfo,
|
||||
): ContainerTagAccess[] {
|
||||
const memberAccess = new Map(
|
||||
(session.containerTags ?? []).map((access) => [
|
||||
access.containerTag,
|
||||
access.permission,
|
||||
]),
|
||||
)
|
||||
const scopedTags = new Set(
|
||||
session.scope?.tags ?? (session.scope?.tag ? [session.scope.tag] : []),
|
||||
)
|
||||
|
||||
return containerTags.map((containerTag) => {
|
||||
let permission: ContainerTagAccess["permission"] = "write"
|
||||
|
||||
if (session.accessType === "restricted") {
|
||||
permission = memberAccess.get(containerTag) ?? "read"
|
||||
}
|
||||
|
||||
if (
|
||||
session.scope?.type === "scoped" &&
|
||||
(session.scope.permission === "read" ||
|
||||
(scopedTags.size > 0 && !scopedTags.has(containerTag)))
|
||||
) {
|
||||
permission = "read"
|
||||
}
|
||||
|
||||
return { containerTag, permission }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,47 +9,21 @@ import {
|
|||
validateApiKey,
|
||||
validateOAuthToken,
|
||||
} from "./auth"
|
||||
import { getCachedAuth, putCachedAuth } from "./auth/cache"
|
||||
|
||||
type Bindings = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
API_URL?: string
|
||||
AUTH_CACHE?: KVNamespace
|
||||
MCP_RESOURCE?: string
|
||||
}
|
||||
|
||||
// Per-request validation, but cached against an introspected result keyed
|
||||
// by SHA-256(token). Hot path: ~5ms KV lookup. Cold path: ~400ms upstream
|
||||
// introspection (same as today). TTL 5 min — matches Better Auth's cookie
|
||||
// cache. Fail-open if KV is unavailable so we never hard-fail auth.
|
||||
async function resolveAuth(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
kv: KVNamespace | undefined,
|
||||
mcpResource: string,
|
||||
): Promise<AuthUser | null> {
|
||||
if (kv) {
|
||||
try {
|
||||
const cached = await getCachedAuth(kv, token)
|
||||
if (cached) {
|
||||
console.log("[auth] cache-hit")
|
||||
return cached
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[auth] cache-error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[auth] cache-miss")
|
||||
const user = isApiKey(token)
|
||||
return isApiKey(token)
|
||||
? await validateApiKey(token, apiUrl)
|
||||
: await validateOAuthToken(token, apiUrl)
|
||||
|
||||
if (user && kv) {
|
||||
// Best-effort write; never block the request on cache write
|
||||
void putCachedAuth(kv, token, user).catch((err) =>
|
||||
console.warn("[auth] cache-write-error:", err),
|
||||
)
|
||||
}
|
||||
return user
|
||||
: await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
}
|
||||
|
||||
export type { Props }
|
||||
|
|
@ -93,9 +67,10 @@ app.get("/", (c) => {
|
|||
// URL with `/mcp` appended.
|
||||
function resourceMetadata(c: Context<{ Bindings: Bindings }>) {
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
|
||||
|
||||
return c.json({
|
||||
resource: DEFAULT_MCP_RESOURCE,
|
||||
resource: mcpResource,
|
||||
authorization_servers: [apiUrl],
|
||||
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
||||
bearer_methods_supported: ["header"],
|
||||
|
|
@ -145,6 +120,7 @@ async function handleMcpRequest(
|
|||
const token = authHeader?.replace(/^Bearer\s+/i, "")
|
||||
const containerTag = c.req.header("x-sm-project")
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
|
||||
|
||||
// Build absolute resource_metadata URL from incoming request (works
|
||||
// behind tunnels where the scheme/host differ from localhost)
|
||||
|
|
@ -165,7 +141,7 @@ async function handleMcpRequest(
|
|||
})
|
||||
}
|
||||
|
||||
const authUser = await resolveAuth(token, apiUrl, c.env.AUTH_CACHE)
|
||||
const authUser = await resolveAuth(token, apiUrl, mcpResource)
|
||||
|
||||
if (!authUser) {
|
||||
return new Response(
|
||||
|
|
@ -195,13 +171,8 @@ async function handleMcpRequest(
|
|||
...c.executionCtx,
|
||||
props: {
|
||||
userId: authUser.userId,
|
||||
apiKey: authUser.apiKey,
|
||||
bearerToken: authUser.bearerToken,
|
||||
containerTag,
|
||||
email: authUser.email,
|
||||
name: authUser.name,
|
||||
role: authUser.role,
|
||||
accessType: authUser.accessType,
|
||||
assignedTags: authUser.containerTags,
|
||||
} satisfies Props,
|
||||
} as ExecutionContext & { props: Props }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,21 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { z } from "zod"
|
||||
import type { RbacContext } from "../auth/rbac"
|
||||
import type { SupermemoryClient } from "../client"
|
||||
|
||||
export function registerContextPrompt(
|
||||
server: McpServer,
|
||||
rbac: RbacContext,
|
||||
hasRootContainerTag: boolean,
|
||||
getClient: (tag?: string) => SupermemoryClient,
|
||||
resolveContainerTag: (explicit?: string) => Promise<string | undefined>,
|
||||
) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> =
|
||||
rbac.hasRootContainerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = hasRootContainerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
|
||||
const argsSchema = {
|
||||
includeRecent: z.boolean().optional().default(true),
|
||||
|
|
@ -36,19 +34,6 @@ export function registerContextPrompt(
|
|||
containerTag?: string
|
||||
}
|
||||
try {
|
||||
if (args.containerTag && !rbac.canRead(args.containerTag)) {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `No access to container tag '${args.containerTag}'.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
const effectiveTag = await resolveContainerTag(args.containerTag)
|
||||
const client = getClient(effectiveTag)
|
||||
const profileResult = await client.getProfile()
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { z } from "zod"
|
|||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
|
||||
.hasRootContainerTag
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
|
||||
?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
|
|
@ -35,13 +35,6 @@ export function register(deps: ToolDeps) {
|
|||
containerTag?: string
|
||||
}
|
||||
try {
|
||||
if (args.containerTag && !deps.rbac.canWrite(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No write access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,13 +28,6 @@ export function register(deps: ToolDeps) {
|
|||
limit?: number
|
||||
}
|
||||
try {
|
||||
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No read access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { z } from "zod"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
|
|
@ -16,29 +17,35 @@ export function register(deps: ToolDeps) {
|
|||
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
|
||||
},
|
||||
async (args) => {
|
||||
const prefill = (args as { prefill?: string }).prefill
|
||||
const activeTag = await deps.storage.get<string>("activeContainerTag")
|
||||
try {
|
||||
const prefill = (args as { prefill?: string }).prefill
|
||||
const [activeTag, tags, session] = await Promise.all([
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
deps.getClient().listContainerTags(),
|
||||
deps.getSession(),
|
||||
])
|
||||
const writableTags = effectiveContainerTagAccess(
|
||||
tags.map((tag) => tag.containerTag),
|
||||
session,
|
||||
)
|
||||
.filter((access) => access.permission === "write")
|
||||
.map((access) => access.containerTag)
|
||||
|
||||
let writableTags: string[]
|
||||
if (deps.rbac.isRestricted) {
|
||||
writableTags = deps.rbac.writeTags.map((t) => t.containerTag)
|
||||
} else {
|
||||
const tags = await deps.getClient().listContainerTags()
|
||||
writableTags = tags.map((t) => t.containerTag)
|
||||
}
|
||||
const sc: ViewMessage = {
|
||||
view: "save",
|
||||
activeTag,
|
||||
writableTags,
|
||||
prefill,
|
||||
}
|
||||
|
||||
const sc: ViewMessage = {
|
||||
view: "save",
|
||||
activeTag,
|
||||
writableTags,
|
||||
prefill,
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: "Opening memory save form..." },
|
||||
],
|
||||
structuredContent: sc,
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: "Opening memory save form..." },
|
||||
],
|
||||
structuredContent: sc,
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import * as uploadFileSubmit from "./upload-file-submit"
|
|||
import * as whoAmI from "./who-am-i"
|
||||
|
||||
export function registerAllTools(deps: ToolDeps) {
|
||||
// Always available
|
||||
searchMemory.register(deps)
|
||||
listMemories.register(deps)
|
||||
listContainerTags.register(deps)
|
||||
|
|
@ -23,13 +22,9 @@ export function registerAllTools(deps: ToolDeps) {
|
|||
setActiveTag.register(deps)
|
||||
memoryGraph.register(deps)
|
||||
fetchGraphData.register(deps)
|
||||
|
||||
// Write-gated (RBAC)
|
||||
if (deps.rbac.hasWriteAccess) {
|
||||
addMemory.register(deps)
|
||||
guidedSave.register(deps)
|
||||
saveMemory.register(deps)
|
||||
uploadFile.register(deps)
|
||||
uploadFileSubmit.register(deps)
|
||||
}
|
||||
addMemory.register(deps)
|
||||
guidedSave.register(deps)
|
||||
saveMemory.register(deps)
|
||||
uploadFile.register(deps)
|
||||
uploadFileSubmit.register(deps)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { formatMemoriesList } from "../format"
|
|||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
|
||||
.hasRootContainerTag
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
|
||||
?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
|
|
@ -48,13 +48,6 @@ export function register(deps: ToolDeps) {
|
|||
containerTag?: string
|
||||
}
|
||||
try {
|
||||
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No read access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
|||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const inputSchema: Record<string, z.ZodTypeAny> = deps.rbac
|
||||
.hasRootContainerTag
|
||||
const inputSchema: Record<string, z.ZodTypeAny> = deps.props?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
|
|
@ -27,11 +26,6 @@ export function register(deps: ToolDeps) {
|
|||
async (rawArgs) => {
|
||||
try {
|
||||
const explicit = (rawArgs as { containerTag?: string }).containerTag
|
||||
if (explicit && !deps.rbac.canRead(explicit)) {
|
||||
return deps.errorResult(
|
||||
new Error(`No read access to container tag '${explicit}'.`),
|
||||
)
|
||||
}
|
||||
const effectiveTag = await deps.resolveContainerTag(explicit)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
|
|
|
|||
|
|
@ -23,13 +23,6 @@ export function register(deps: ToolDeps) {
|
|||
async (rawArgs) => {
|
||||
const args = rawArgs as { content: string; containerTag: string }
|
||||
try {
|
||||
if (!deps.rbac.canWrite(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No write access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const client = deps.getClient(args.containerTag)
|
||||
const result = await client.createMemory(args.content)
|
||||
const sc: ViewMessage = {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { getMemoryText } from "../client"
|
|||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
|
||||
.hasRootContainerTag
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.props
|
||||
?.containerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
|
|
@ -36,13 +36,6 @@ export function register(deps: ToolDeps) {
|
|||
containerTag?: string
|
||||
}
|
||||
try {
|
||||
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No read access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
|
|
@ -16,15 +17,21 @@ export function register(deps: ToolDeps) {
|
|||
async () => {
|
||||
try {
|
||||
const client = deps.getClient()
|
||||
const tags = await client.listContainerTags()
|
||||
|
||||
const activeTag = await deps.storage.get<string>("activeContainerTag")
|
||||
const [tags, session, activeTag] = await Promise.all([
|
||||
client.listContainerTags(),
|
||||
deps.getSession(),
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
])
|
||||
const assignedTags = effectiveContainerTagAccess(
|
||||
tags.map((tag) => tag.containerTag),
|
||||
session,
|
||||
)
|
||||
|
||||
const sc: ViewMessage = {
|
||||
view: "picker",
|
||||
containerTags: tags,
|
||||
activeTag,
|
||||
assignedTags: deps.rbac.isRestricted ? deps.rbac.assignedTags : null,
|
||||
assignedTags,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -21,24 +21,29 @@ export function register(deps: ToolDeps) {
|
|||
},
|
||||
async (args) => {
|
||||
const containerTag = (args as { containerTag: string }).containerTag
|
||||
if (!deps.rbac.canRead(containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(`No access to container tag '${containerTag}'.`),
|
||||
)
|
||||
}
|
||||
await deps.storage.put("activeContainerTag", containerTag)
|
||||
const sc: ViewMessage = {
|
||||
view: "confirmation",
|
||||
containerTag,
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Active workspace set to ${containerTag}`,
|
||||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
try {
|
||||
const tags = await deps.getClient().listContainerTags()
|
||||
if (!tags.some((tag) => tag.containerTag === containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(`No access to container tag '${containerTag}'.`),
|
||||
)
|
||||
}
|
||||
await deps.storage.put("activeContainerTag", containerTag)
|
||||
const sc: ViewMessage = {
|
||||
view: "confirmation",
|
||||
containerTag,
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Active workspace set to ${containerTag}`,
|
||||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import type { Props } from "../../shared/types"
|
||||
import type { RbacContext } from "../auth/rbac"
|
||||
import type { Props, SessionInfo } from "../../shared/types"
|
||||
import type { SupermemoryClient } from "../client"
|
||||
|
||||
// Dependencies passed to every tool's register() function.
|
||||
|
|
@ -8,15 +7,13 @@ import type { SupermemoryClient } from "../client"
|
|||
export interface ToolDeps {
|
||||
server: McpServer
|
||||
props: Props | undefined
|
||||
rbac: RbacContext
|
||||
getClient: (containerTag?: string) => SupermemoryClient
|
||||
getSession: () => Promise<SessionInfo>
|
||||
resolveContainerTag: (explicit?: string) => Promise<string | undefined>
|
||||
storage: {
|
||||
get: <T>(key: string) => Promise<T | undefined>
|
||||
put: <T>(key: string, value: T) => Promise<void>
|
||||
}
|
||||
cachedContainerTags: () => string[]
|
||||
refreshContainerTags: () => Promise<void>
|
||||
getClientInfo: () => { name: string; version?: string } | null
|
||||
getMcpSessionId: () => string
|
||||
errorResult: (error: unknown) => {
|
||||
|
|
|
|||
|
|
@ -30,14 +30,6 @@ export function register(deps: ToolDeps) {
|
|||
containerTag: string
|
||||
}
|
||||
try {
|
||||
if (!deps.rbac.canWrite(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No write access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const binaryString = atob(args.fileData)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SUPERMEMORY_RESOURCE_URI, type ViewMessage } from "../../shared/types"
|
||||
import { effectiveContainerTagAccess } from "../auth/rbac"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
|
|
@ -13,27 +14,33 @@ export function register(deps: ToolDeps) {
|
|||
_meta: { ui: { resourceUri: SUPERMEMORY_RESOURCE_URI } },
|
||||
},
|
||||
async () => {
|
||||
const activeTag = await deps.storage.get<string>("activeContainerTag")
|
||||
try {
|
||||
const [activeTag, tags, session] = await Promise.all([
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
deps.getClient().listContainerTags(),
|
||||
deps.getSession(),
|
||||
])
|
||||
const writableTags = effectiveContainerTagAccess(
|
||||
tags.map((tag) => tag.containerTag),
|
||||
session,
|
||||
)
|
||||
.filter((access) => access.permission === "write")
|
||||
.map((access) => access.containerTag)
|
||||
|
||||
let writableTags: string[]
|
||||
if (deps.rbac.isRestricted) {
|
||||
writableTags = deps.rbac.writeTags.map((t) => t.containerTag)
|
||||
} else {
|
||||
const tags = await deps.getClient().listContainerTags()
|
||||
writableTags = tags.map((t) => t.containerTag)
|
||||
}
|
||||
const sc: ViewMessage = {
|
||||
view: "upload",
|
||||
activeTag,
|
||||
writableTags,
|
||||
}
|
||||
|
||||
const sc: ViewMessage = {
|
||||
view: "upload",
|
||||
activeTag,
|
||||
writableTags,
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: "Opening file upload form..." },
|
||||
],
|
||||
structuredContent: sc,
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: "Opening file upload form..." },
|
||||
],
|
||||
structuredContent: sc,
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,26 +8,35 @@ export function register(deps: ToolDeps) {
|
|||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const activeTag = await deps.storage.get<string>("activeContainerTag")
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
userId: deps.props?.userId,
|
||||
email: deps.props?.email,
|
||||
name: deps.props?.name,
|
||||
role: deps.props?.role ?? "unknown",
|
||||
accessType: deps.props?.accessType ?? "full",
|
||||
activeWorkspace: activeTag ?? null,
|
||||
assignedTags: deps.rbac.isRestricted
|
||||
? deps.rbac.assignedTags
|
||||
: null,
|
||||
client: deps.getClientInfo(),
|
||||
sessionId: deps.getMcpSessionId(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
try {
|
||||
const [session, activeTag] = await Promise.all([
|
||||
deps.getSession(),
|
||||
deps.storage.get<string>("activeContainerTag"),
|
||||
])
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
role: session.role ?? "unknown",
|
||||
accessType: session.accessType ?? "full",
|
||||
activeWorkspace: activeTag ?? null,
|
||||
assignedTags:
|
||||
session.accessType === "restricted"
|
||||
? session.containerTags
|
||||
: null,
|
||||
scope: session.scope,
|
||||
client: deps.getClientInfo(),
|
||||
sessionId: deps.getMcpSessionId(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,28 @@
|
|||
|
||||
export interface ContainerTagAccess {
|
||||
containerTag: string
|
||||
permission: string // "read" | "write"
|
||||
permission: "read" | "write"
|
||||
}
|
||||
|
||||
export interface SessionScope {
|
||||
type: "full" | "scoped"
|
||||
permission?: "read" | "write"
|
||||
tag?: string
|
||||
tags?: string[]
|
||||
rateLimit?: number
|
||||
expires?: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
user: {
|
||||
id: string
|
||||
email?: string
|
||||
name?: string
|
||||
}
|
||||
role?: string
|
||||
accessType?: "full" | "restricted"
|
||||
containerTags?: ContainerTagAccess[] | null
|
||||
scope?: SessionScope
|
||||
}
|
||||
|
||||
export interface ContainerTag {
|
||||
|
|
@ -98,13 +119,8 @@ export type ViewName = ViewMessage["view"]
|
|||
// Auth context passed from the OAuth/API-key middleware into the McpAgent via ctx.props.
|
||||
export type Props = {
|
||||
userId: string
|
||||
apiKey: string
|
||||
bearerToken: string
|
||||
containerTag?: string
|
||||
email?: string
|
||||
name?: string
|
||||
role?: string
|
||||
accessType?: string
|
||||
assignedTags?: ContainerTagAccess[] | null
|
||||
}
|
||||
|
||||
// MCP resource URI for the widget bundle.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"
|
|||
export default defineConfig({
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
|
||||
fileParallelism: false,
|
||||
testTimeout: 90_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@
|
|||
}
|
||||
],
|
||||
|
||||
"kv_namespaces": [
|
||||
{ "binding": "AUTH_CACHE", "id": "REPLACE_WITH_KV_NAMESPACE_ID" }
|
||||
],
|
||||
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -99,6 +99,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.11.1",
|
||||
"jose": "^6.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"supermemory": "^4.0.0",
|
||||
|
|
@ -331,7 +332,7 @@
|
|||
},
|
||||
"packages/tools": {
|
||||
"name": "@supermemory/tools",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.23",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue