Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/auth/oauth-integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";

Check failure on line 1 in src/auth/oauth-integration.test.ts

View workflow job for this annotation

GitHub Actions / pr-checks

format

File content differs from formatting output
import type { Server } from "node:http";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import {
Expand Down Expand Up @@ -130,6 +130,21 @@
});

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);
Expand Down
15 changes: 15 additions & 0 deletions src/auth/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";

Check failure on line 1 in src/auth/oauth.test.ts

View workflow job for this annotation

GitHub Actions / pr-checks

format

File content differs from formatting output
import type { Server } from "node:http";
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
Expand Down Expand Up @@ -243,6 +243,21 @@

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);
Expand Down
28 changes: 19 additions & 9 deletions src/server-http.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test";

Check failure on line 1 in src/server-http.test.ts

View workflow job for this annotation

GitHub Actions / pr-checks

format

File content differs from formatting output
import type { Request, Response } from "express";
import { getWellKnownRedirectUrl } from "./server-http";
import { getProtectedResourceMetadata, getWellKnownRedirectUrl } from "./server-http";

interface MockRequest extends Partial<Request> {
headers: Record<string, string | string[] | undefined>;
Expand Down Expand Up @@ -198,18 +198,26 @@
});
});

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", () => {
Expand All @@ -221,6 +229,8 @@
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();
});
});
Expand Down
46 changes: 25 additions & 21 deletions src/server-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,23 @@ export function getWellKnownRedirectUrl(
path: string,
config: Pick<ServerConfig, "apiBaseUrl" | "authServerIssuerUrl">,
): 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();
}

return null;
}

export function getProtectedResourceMetadata(
config: Pick<ServerConfig, "mcpServerUrl" | "authServerIssuerUrl">,
) {
return {
resource: `${config.mcpServerUrl}/mcp`,
authorization_servers: [config.authServerIssuerUrl],
scopes_supported: ["openid"],
};
}

interface SessionData {
transport: StreamableHTTPServerTransport;
clientWrapper: ShortcutClientWrapper;
Expand Down Expand Up @@ -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),
);
Expand Down
Loading