mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-29 01:41:22 +00:00
## Summary Fixes MCP OAuth discovery so the client-server handshake actually validates against the **MCP 2025-06-18 authorization spec** (which adopts RFC 9728 Protected Resource Metadata + RFC 8707 Resource Indicators). Previously, a client connecting to `https://mcp.dev.supermemory.ai/mcp` would receive `resource: "https://mcp.supermemory.ai"` (bare host, prod fallback) and reject the connection: > Protected resource https://mcp.supermemory.ai does not match expected https://mcp.dev.supermemory.ai/mcp (or origin) ## Changes - **`resource` now includes the `/mcp` endpoint path** — the spec wants the canonical MCP server URI, and the bundled `@modelcontextprotocol/sdk` reference implementation emits the same shape (`new URL(rsPath, base).href`). Bare-host worked with lenient clients that fell back to origin-matching; strict clients rejected it. - **Path-suffixed metadata route** added at `/.well-known/oauth-protected-resource/mcp` alongside the bare path. The SDK's `metadataHandler` mounts under the resource path, so this matches what spec-strict clients probe first. - **`WWW-Authenticate`'s `resource_metadata` URL** points to the canonical full URL (`https://host/.well-known/oauth-protected-resource/mcp`). - **Centralized base-URL derivation** in a new `mcpBaseUrl()` helper, with priority: 1. `MCP_URL` env var — set by portless dev script so dev requests resolve to the tunneled host, not whatever the local proxy sticks in `Host` 2. `x-forwarded-host` / `host` request headers 3. `https://mcp.supermemory.ai` last-resort fallback (only hit when the worker can't see the inbound host at all) ## Production impact `MCP_URL` is dev-only (not in `wrangler.jsonc` vars), so prod falls through to the `Host` header → `https://mcp.supermemory.ai/mcp`. The wire change in prod is that `resource` now ends with `/mcp` instead of being bare — spec-correct, what strict clients require, and tolerated by lenient ones. ## Contributor DX Added `apps/mcp/.dev.vars.example` documenting `API_URL`, `MCP_URL`, and `POSTHOG_API_KEY` for contributors running plain `wrangler dev` without portless. ## Test plan - [x] `curl https://mcp.dev.supermemory.ai/.well-known/oauth-protected-resource` returns `resource: https://mcp.dev.supermemory.ai/mcp` - [x] `curl https://mcp.dev.supermemory.ai/.well-known/oauth-protected-resource/mcp` returns the same payload - [x] 401 from `/mcp` carries `WWW-Authenticate: Bearer resource_metadata="…/oauth-protected-resource/mcp"` - [x] MCP client (vscode extension) connects successfully — previously failed with the resource-mismatch error - [ ] Verify in prod that bare-host clients continue to work after deploy
200 lines
5.3 KiB
TypeScript
200 lines
5.3 KiB
TypeScript
import { cors } from "hono/cors"
|
|
import { Hono, type Context } from "hono"
|
|
import { SupermemoryMCP } from "./server"
|
|
import { isApiKey, validateApiKey, validateOAuthToken } from "./auth"
|
|
import { initPosthog } from "./posthog"
|
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|
|
|
type Bindings = {
|
|
MCP_SERVER: DurableObjectNamespace
|
|
API_URL?: string
|
|
MCP_URL?: string
|
|
POSTHOG_API_KEY?: string
|
|
}
|
|
|
|
type Props = {
|
|
userId: string
|
|
apiKey: string
|
|
containerTag?: string
|
|
email?: string
|
|
name?: string
|
|
}
|
|
|
|
const app = new Hono<{ Bindings: Bindings }>()
|
|
|
|
const DEFAULT_API_URL = "https://api.supermemory.ai"
|
|
const DEFAULT_MCP_URL = "https://mcp.supermemory.ai"
|
|
|
|
const mcpBaseUrl = (c: Context<{ Bindings: Bindings }>) => {
|
|
if (c.env.MCP_URL) return c.env.MCP_URL.replace(/\/$/, "")
|
|
const host = c.req.header("x-forwarded-host") || c.req.header("host")
|
|
const proto = c.req.header("x-forwarded-proto") || "https"
|
|
return host ? `${proto}://${host}` : DEFAULT_MCP_URL
|
|
}
|
|
|
|
// CORS
|
|
app.use(
|
|
"*",
|
|
cors({
|
|
origin: "*",
|
|
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
allowHeaders: [
|
|
"Content-Type",
|
|
"Authorization",
|
|
"x-sm-project",
|
|
"Accept",
|
|
"Mcp-Session-Id",
|
|
"MCP-Protocol-Version",
|
|
"Last-Event-ID",
|
|
],
|
|
exposeHeaders: ["Mcp-Session-Id", "WWW-Authenticate"],
|
|
}),
|
|
)
|
|
|
|
app.use("*", async (c, next) => {
|
|
initPosthog(c.env.POSTHOG_API_KEY)
|
|
await next()
|
|
})
|
|
|
|
app.get("/", (c) => {
|
|
return c.json({
|
|
name: "supermemory-mcp",
|
|
version: "4.0.0",
|
|
description: "Give your AI a memory",
|
|
docs: "https://docs.supermemory.ai/mcp",
|
|
})
|
|
})
|
|
|
|
// MCP clients use this to discover the authorization server
|
|
const protectedResourceHandler = (c: Context<{ Bindings: Bindings }>) => {
|
|
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
|
return c.json({
|
|
resource: `${mcpBaseUrl(c)}/mcp`,
|
|
authorization_servers: [apiUrl],
|
|
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
|
bearer_methods_supported: ["header"],
|
|
resource_documentation: "https://docs.supermemory.ai/mcp",
|
|
})
|
|
}
|
|
app.get("/.well-known/oauth-protected-resource", protectedResourceHandler)
|
|
app.get("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler)
|
|
|
|
// Proxy endpoint for MCP clients that don't follow the spec correctly
|
|
// Some clients look for oauth-authorization-server on the MCP server domain
|
|
// instead of following the authorization_servers array
|
|
app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
|
|
|
try {
|
|
// Fetch the authorization server metadata from the main API
|
|
const response = await fetch(
|
|
`${apiUrl}/.well-known/oauth-authorization-server`,
|
|
)
|
|
|
|
if (!response.ok) {
|
|
return c.json(
|
|
{ error: "Failed to fetch authorization server metadata" },
|
|
{ status: response.status as ContentfulStatusCode },
|
|
)
|
|
}
|
|
|
|
const metadata = await response.json()
|
|
return c.json(metadata)
|
|
} catch (error) {
|
|
console.error("Error fetching OAuth authorization server metadata:", error)
|
|
return c.json({ error: "Internal server error" }, 500)
|
|
}
|
|
})
|
|
|
|
const mcpHandler = SupermemoryMCP.serve("/mcp", {
|
|
binding: "MCP_SERVER",
|
|
corsOptions: {
|
|
origin: "*",
|
|
methods: "GET, POST, DELETE, OPTIONS",
|
|
headers:
|
|
"Content-Type, Authorization, x-sm-project, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID",
|
|
},
|
|
})
|
|
|
|
const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
|
|
const authHeader = c.req.header("Authorization")
|
|
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 resourceMetadataUrl = `${mcpBaseUrl(c)}/.well-known/oauth-protected-resource/mcp`
|
|
|
|
if (!token) {
|
|
return new Response("Unauthorized", {
|
|
status: 401,
|
|
headers: {
|
|
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
|
|
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
})
|
|
}
|
|
|
|
let authUser: {
|
|
userId: string
|
|
apiKey: string
|
|
email?: string
|
|
name?: string
|
|
} | null = null
|
|
|
|
if (isApiKey(token)) {
|
|
console.log("Authenticating with API key")
|
|
authUser = await validateApiKey(token, apiUrl)
|
|
} else {
|
|
console.log("Authenticating with OAuth token")
|
|
authUser = await validateOAuthToken(token, apiUrl)
|
|
}
|
|
|
|
if (!authUser) {
|
|
const errorMessage = isApiKey(token)
|
|
? "Unauthorized: Invalid or expired API key"
|
|
: "Unauthorized: Invalid or expired token"
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32000,
|
|
message: errorMessage,
|
|
},
|
|
id: null,
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"`,
|
|
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
// Create execution context with authenticated user props
|
|
const ctx = {
|
|
...c.executionCtx,
|
|
props: {
|
|
userId: authUser.userId,
|
|
apiKey: authUser.apiKey,
|
|
containerTag,
|
|
email: authUser.email,
|
|
name: authUser.name,
|
|
} satisfies Props,
|
|
} as ExecutionContext & { props: Props }
|
|
|
|
return mcpHandler.fetch(c.req.raw, c.env, ctx)
|
|
}
|
|
|
|
app.all("/mcp", handleMcpRequest)
|
|
app.all("/mcp/*", handleMcpRequest)
|
|
|
|
// Export the Durable Object class for Cloudflare Workers
|
|
export { SupermemoryMCP }
|
|
|
|
export default app
|