diff --git a/src/auth/oauth-integration.test.ts b/src/auth/oauth-integration.test.ts index 5d5edcb..306cd4a 100644 --- a/src/auth/oauth-integration.test.ts +++ b/src/auth/oauth-integration.test.ts @@ -61,6 +61,14 @@ function createIntegrationTestApp(): express.Express { }), ); + app.get("/.well-known/oauth-protected-resource", (_req, res) => { + res.json({ + resource: mcpResourceUrl.toString(), + authorization_servers: [new URL("/", placeholderUrl).toString()], + scopes_supported: ["openid"], + }); + }); + app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); @@ -130,6 +138,21 @@ describe.skipIf(SKIP)("OAuth Integration Tests (staging)", () => { }); describe("Metadata Discovery (live)", () => { + test("local MCP server serves protected resource metadata on both well-known paths", async () => { + const [rootRes, mcpRes] = await Promise.all([ + fetch(`${baseUrl}/.well-known/oauth-protected-resource`), + fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`), + ]); + expect(rootRes.status).toBe(200); + expect(mcpRes.status).toBe(200); + + const [rootData, mcpData] = (await Promise.all([rootRes.json(), mcpRes.json()])) as [ + ResourceMetadataResponse, + ResourceMetadataResponse, + ]; + expect(rootData).toEqual(mcpData); + }); + test("local MCP server serves protected resource metadata", async () => { const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`); expect(res.status).toBe(200); diff --git a/src/auth/oauth.test.ts b/src/auth/oauth.test.ts index 6fbdf5b..b9baf1c 100644 --- a/src/auth/oauth.test.ts +++ b/src/auth/oauth.test.ts @@ -169,6 +169,14 @@ function createTestApp(): express.Express { }), ); + app.get("/.well-known/oauth-protected-resource", (_req, res) => { + res.json({ + resource: mcpResourceUrl.toString(), + authorization_servers: [new URL("/", placeholderUrl).toString()], + scopes_supported: ["openid"], + }); + }); + // OAuth callback relay app.get(provider.callbackPath, (req, res) => { const { code, state, error, error_description } = req.query as Record; @@ -243,6 +251,21 @@ beforeEach(() => { describe("OAuth Flow Tests", () => { describe("Metadata Discovery", () => { + test("GET /.well-known/oauth-protected-resource returns the same metadata as /mcp", async () => { + const [rootRes, mcpRes] = await Promise.all([ + fetch(`${baseUrl}/.well-known/oauth-protected-resource`), + fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`), + ]); + expect(rootRes.status).toBe(200); + expect(mcpRes.status).toBe(200); + + const [rootData, mcpData] = (await Promise.all([rootRes.json(), mcpRes.json()])) as [ + ResourceMetadataResponse, + ResourceMetadataResponse, + ]; + expect(rootData).toEqual(mcpData); + }); + test("GET /.well-known/oauth-protected-resource/mcp returns resource metadata", async () => { const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`); expect(res.status).toBe(200); diff --git a/src/server-http.test.ts b/src/server-http.test.ts index b020171..d024921 100644 --- a/src/server-http.test.ts +++ b/src/server-http.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { Request, Response } from "express"; -import { getWellKnownRedirectUrl } from "./server-http"; +import { getProtectedResourceMetadata, getWellKnownRedirectUrl } from "./server-http"; interface MockRequest extends Partial { headers: Record; @@ -198,18 +198,26 @@ describe("server-http (no-auth) smoke tests", () => { }); }); - test("maps well-known protected resource paths to the API server", () => { + test("builds protected-resource metadata from local server config", () => { + const previousAuthServer = process.env.AUTH_SERVER; + process.env.AUTH_SERVER = "auth.example.com"; + const config = { - apiBaseUrl: "https://api.example.com", + mcpServerUrl: "http://localhost:9292", authServerIssuerUrl: "https://auth.example.com", }; - expect(getWellKnownRedirectUrl("/.well-known/oauth-protected-resource", config)).toBe( - "https://api.example.com/.well-known/oauth-protected-resource", - ); - expect(getWellKnownRedirectUrl("/.well-known/oauth-protected-resource/mcp", config)).toBe( - "https://api.example.com/.well-known/oauth-protected-resource/mcp", - ); + expect(getProtectedResourceMetadata(config)).toEqual({ + resource: "http://localhost:9292/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["openid"], + }); + + if (previousAuthServer === undefined) { + delete process.env.AUTH_SERVER; + } else { + process.env.AUTH_SERVER = previousAuthServer; + } }); test("maps the OAuth authorization-server path to the auth server", () => { @@ -221,6 +229,10 @@ describe("server-http (no-auth) smoke tests", () => { expect(getWellKnownRedirectUrl("/.well-known/oauth-authorization-server", config)).toBe( "https://auth.example.com/.well-known/oauth-authorization-server", ); + expect(getWellKnownRedirectUrl("/.well-known/oauth-protected-resource", config)).toBeNull(); + expect( + getWellKnownRedirectUrl("/.well-known/oauth-protected-resource/mcp", config), + ).toBeNull(); expect(getWellKnownRedirectUrl("/not-well-known", config)).toBeNull(); }); }); diff --git a/src/server-http.ts b/src/server-http.ts index f5a7f9b..d6a51c5 100644 --- a/src/server-http.ts +++ b/src/server-http.ts @@ -175,13 +175,6 @@ export function getWellKnownRedirectUrl( path: string, config: Pick, ): string | null { - if ( - path === "/.well-known/oauth-protected-resource" || - path === "/.well-known/oauth-protected-resource/mcp" - ) { - return new URL(path, config.apiBaseUrl).toString(); - } - if (path === "/.well-known/oauth-authorization-server") { return new URL(path, config.authServerIssuerUrl).toString(); } @@ -189,6 +182,16 @@ export function getWellKnownRedirectUrl( return null; } +export function getProtectedResourceMetadata( + config: Pick, +) { + return { + resource: `${config.mcpServerUrl}/mcp`, + authorization_servers: [config.authServerIssuerUrl], + scopes_supported: ["openid"], + }; +} + interface SessionData { transport: StreamableHTTPServerTransport; clientWrapper: ShortcutClientWrapper; @@ -809,24 +812,25 @@ export async function startServer() { }); }); - // Redirect metadata discovery to the upstream Shortcut API/auth server so - // clients can fetch the authoritative well-known documents directly. + // Serve protected-resource metadata locally so clients discover this MCP + // server's `/mcp` endpoint without being redirected upstream. app.get( - [ - "/.well-known/oauth-protected-resource", - "/.well-known/oauth-protected-resource/mcp", - "/.well-known/oauth-authorization-server", - ], - (req, res) => { - const redirectUrl = getWellKnownRedirectUrl(req.path, config); - if (!redirectUrl) { - res.sendStatus(404); - return; - } - res.redirect(302, redirectUrl); + ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"], + (_req, res) => { + res.json(getProtectedResourceMetadata(config)); }, ); + // Redirect auth-server metadata discovery to the upstream auth server. + app.get("/.well-known/oauth-authorization-server", (req, res) => { + const redirectUrl = getWellKnownRedirectUrl(req.path, config); + if (!redirectUrl) { + res.sendStatus(404); + return; + } + res.redirect(302, redirectUrl); + }); + app.post("/mcp", requireBearerHeader, (req, res) => handleMcpPost(req, res, sessionManager, config), );