fix(mcp): harden OAuth metadata discovery

Derive protected-resource metadata URLs from the canonical MCP resource instead of request forwarding headers. Preserve RFC 9728 path and query semantics, constrain metadata routing, add route-level regression coverage, and document custom deployment behavior.
This commit is contained in:
abhinav7x94 2026-08-16 02:24:43 +05:30
parent e651045ac5
commit bdb21ab25e
4 changed files with 133 additions and 11 deletions

View file

@ -39,8 +39,12 @@ Example client configuration:
}
```
The client discovers the OAuth authorization server through
`/.well-known/oauth-protected-resource/mcp`.
For the default resource, the client discovers the OAuth authorization server
through `/.well-known/oauth-protected-resource/mcp`. Custom deployments derive
this location from `MCP_RESOURCE` by inserting
`/.well-known/oauth-protected-resource` before the resource path while
preserving its query string. For example, `https://memory.example.com/gateway/mcp`
uses `https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp`.
## Tools
@ -129,7 +133,7 @@ discovery and rejection tests still run.
| Variable | Purpose | Default |
| --- | --- | --- |
| `API_URL` | Supermemory API and OAuth issuer | `https://api.supermemory.ai` |
| `MCP_RESOURCE` | Expected OAuth audience | `https://mcp.supermemory.ai/mcp` |
| `MCP_RESOURCE` | Expected OAuth audience and canonical resource URL used for metadata discovery | `https://mcp.supermemory.ai/mcp` |
| `ALLOWED_MCP_ORIGIN_HOSTNAMES` | Additional comma-separated browser origins | Built-in host allowlist |
| `POSTHOG_API_KEY` | Server-side MCP tool analytics project key | Disabled |
| `POSTHOG_HOST` | PostHog ingestion host | `https://us.i.posthog.com` |

View file

@ -4,6 +4,10 @@ import { Hono, type Context } from "hono"
import { cors } from "hono/cors"
import { validateOAuthToken, type AuthUser } from "./auth"
import { SupermemoryMCP } from "./legacy-protocol-state"
import {
PROTECTED_RESOURCE_METADATA_PATH,
protectedResourceMetadataUrl,
} from "./oauth-metadata"
import { createSupermemoryServer } from "./server"
import type { ActorContext, ServerEnv } from "./types"
import { SpaceState, uploadStateName } from "./space-state"
@ -14,8 +18,6 @@ const app = new Hono<{ Bindings: Bindings }>()
const DEFAULT_API_URL = "https://api.supermemory.ai"
const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp"
const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource/mcp"
const UPLOAD_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [
@ -58,6 +60,19 @@ app.get("/", (c) => {
function resourceMetadata(c: Context<{ Bindings: Bindings }>) {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
const requestUrl = new URL(c.req.url)
const canonicalMetadataUrl = new URL(
protectedResourceMetadataUrl(mcpResource),
)
// MCP clients also try the root well-known location as a compatibility fallback.
const isMcpRootFallback =
requestUrl.pathname === PROTECTED_RESOURCE_METADATA_PATH &&
requestUrl.search === ""
const isCanonicalLocation =
requestUrl.pathname === canonicalMetadataUrl.pathname &&
requestUrl.search === canonicalMetadataUrl.search
if (!isMcpRootFallback && !isCanonicalLocation) return c.notFound()
return c.json({
resource: mcpResource,
@ -68,8 +83,8 @@ function resourceMetadata(c: Context<{ Bindings: Bindings }>) {
})
}
app.get("/.well-known/oauth-protected-resource", resourceMetadata)
app.get(PROTECTED_RESOURCE_METADATA_PATH, resourceMetadata)
app.get(`${PROTECTED_RESOURCE_METADATA_PATH}/*`, resourceMetadata)
app.get("/.well-known/openai-apps-challenge", (c) => {
return c.text(c.env.OPENAI_APPS_CHALLENGE || "")
@ -167,11 +182,7 @@ async function handleMcpRequest(
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
const reqProto = c.req.header("x-forwarded-proto") || "https"
const resourceMetadataUrl = reqHost
? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}`
: PROTECTED_RESOURCE_METADATA_PATH
const resourceMetadataUrl = protectedResourceMetadataUrl(mcpResource)
const mcpOrigin = c.env.MCP_PUBLIC_ORIGIN || new URL(mcpResource).origin
if (!token) return unauthorizedResponse(resourceMetadataUrl)

View file

@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest"
import app from "./index"
import { PROTECTED_RESOURCE_METADATA_PATH } from "./oauth-metadata"
import type { ServerEnv } from "./types"
vi.mock("cloudflare:workers", () => ({ DurableObject: class {} }))
vi.mock("../../dist/src/widget/index.html", () => ({ default: "" }))
describe("OAuth protected-resource metadata challenge", () => {
it("ignores spoofed forwarding headers", async () => {
const resource = "https://mcp.example.com/mcp"
const response = await app.request(
"https://mcp.example.com/mcp",
{
method: "POST",
headers: {
host: "attacker.example",
"x-forwarded-host": "attacker.example",
"x-forwarded-proto": "http",
},
},
{ MCP_RESOURCE: resource } as ServerEnv,
)
expect(response.status).toBe(401)
expect(response.headers.get("WWW-Authenticate")).toBe(
`Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}/mcp"`,
)
})
it("keeps discovery aligned behind a trusted proxy", async () => {
const resource = "https://memory.example.com/gateway/mcp/?tenant=acme"
const metadataUrl =
"https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp/?tenant=acme"
const env = { MCP_RESOURCE: resource } as ServerEnv
const response = await app.request(
"http://worker.internal/mcp",
{ method: "POST" },
env,
)
expect(response.status).toBe(401)
expect(response.headers.get("WWW-Authenticate")).toBe(
`Bearer resource_metadata="${metadataUrl}"`,
)
const metadataResponse = await app.request(metadataUrl, undefined, env)
expect(metadataResponse.status).toBe(200)
await expect(metadataResponse.json()).resolves.toMatchObject({ resource })
const rootFallbackResponse = await app.request(
`https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}`,
undefined,
env,
)
expect(rootFallbackResponse.status).toBe(200)
for (const mismatchedUrl of [
metadataUrl.replace("tenant=acme", "tenant=other"),
metadataUrl.replace("/gateway/mcp/", "/other/"),
`https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}?tenant=acme`,
]) {
const mismatchedResponse = await app.request(
mismatchedUrl,
undefined,
env,
)
expect(mismatchedResponse.status).toBe(404)
}
})
it("supports a resource identifier without a path", async () => {
const resource = "https://mcp.example.com"
const response = await app.request(resource, { method: "POST" }, {
MCP_RESOURCE: resource,
} as ServerEnv)
expect(response.status).toBe(401)
expect(response.headers.get("WWW-Authenticate")).toBe(
`Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}"`,
)
const metadataResponse = await app.request(
`https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`,
undefined,
{ MCP_RESOURCE: resource } as ServerEnv,
)
expect(metadataResponse.status).toBe(200)
await expect(metadataResponse.json()).resolves.toMatchObject({ resource })
})
})

View file

@ -0,0 +1,16 @@
export const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource"
/**
* Builds the protected-resource metadata URL advertised to OAuth clients.
*
* The canonical resource is the single source of truth so the advertised URL
* and the metadata document cannot disagree. Forwarded headers are intentionally
* excluded because they are request-controlled in some proxy deployments.
*/
export function protectedResourceMetadataUrl(resource: string): string {
const resourceUrl = new URL(resource)
const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname
resourceUrl.pathname = `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}`
return resourceUrl.toString()
}