Copilot review round 1. `refreshStoredAuthToken` consumes the same fallback and made a single SDK discovery call, so preserving the path there — without the walk the other two consumers got — would have regressed exactly the case the walk exists to keep: a path-hosted MCP server whose authorization metadata is published at the domain root. It injects its own discovery function as a test seam, so the walk is now also exposed as `discoverAuthorizationServerMetadataFromCandidates(candidates, discover)`, which takes that function and reports *which* candidate answered — the CLI needs the winning URL, since it is the base the token request is made against, not just the metadata. Also drops the `as unknown as typeof fetch` from the new test in favour of `vi.fn<typeof fetch>()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
508 lines
18 KiB
TypeScript
508 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import {
|
|
discoverAuthorizationServerMetadataForServer,
|
|
discoverScopes,
|
|
getAuthorizationServerUrl,
|
|
getAuthorizationServerUrlCandidates,
|
|
} from "@inspector/core/auth/discovery.js";
|
|
import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/client";
|
|
|
|
// Mock SDK functions
|
|
vi.mock("@modelcontextprotocol/client", () => ({
|
|
discoverAuthorizationServerMetadata: vi.fn(),
|
|
}));
|
|
|
|
describe("OAuth Scope Discovery", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should return scopes from resource metadata when available", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: "http://localhost:3000",
|
|
authorization_servers: ["http://localhost:3000"],
|
|
scopes_supported: ["read", "write", "admin"],
|
|
};
|
|
|
|
const scopes = await discoverScopes(
|
|
"http://localhost:3000",
|
|
resourceMetadata,
|
|
);
|
|
|
|
expect(scopes).toBe("read write admin");
|
|
});
|
|
|
|
it("should fall back to OAuth metadata scopes when resource metadata has no scopes", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: "http://localhost:3000",
|
|
authorization_servers: ["http://localhost:3000"],
|
|
scopes_supported: [],
|
|
};
|
|
|
|
const scopes = await discoverScopes(
|
|
"http://localhost:3000",
|
|
resourceMetadata,
|
|
);
|
|
|
|
expect(scopes).toBe("read write");
|
|
});
|
|
|
|
it("should fall back to OAuth metadata scopes when resource metadata is not provided", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const scopes = await discoverScopes("http://localhost:3000");
|
|
|
|
expect(scopes).toBe("read write");
|
|
});
|
|
|
|
it("should return undefined when no scopes are available", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: [],
|
|
});
|
|
|
|
const scopes = await discoverScopes("http://localhost:3000");
|
|
|
|
expect(scopes).toBeUndefined();
|
|
});
|
|
|
|
it("should return undefined when discovery fails", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockRejectedValue(
|
|
new Error("Discovery failed"),
|
|
);
|
|
|
|
const scopes = await discoverScopes("http://localhost:3000");
|
|
|
|
expect(scopes).toBeUndefined();
|
|
});
|
|
|
|
it("should return undefined when metadata is undefined", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(undefined);
|
|
|
|
const scopes = await discoverScopes("http://localhost:3000");
|
|
|
|
expect(scopes).toBeUndefined();
|
|
});
|
|
|
|
it("should use OAuth metadata scopes when resource has scopes_supported undefined", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: "http://localhost:3000",
|
|
authorization_servers: ["http://localhost:3000"],
|
|
scopes_supported: undefined as unknown as string[],
|
|
};
|
|
|
|
const scopes = await discoverScopes(
|
|
"http://localhost:3000",
|
|
resourceMetadata,
|
|
);
|
|
|
|
expect(scopes).toBe("read write");
|
|
});
|
|
|
|
it("should return single scope when only one scope is supported", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["openid"],
|
|
});
|
|
|
|
const scopes = await discoverScopes("http://localhost:3000");
|
|
|
|
expect(scopes).toBe("openid");
|
|
});
|
|
|
|
it("should pass fetchFn to discoverAuthorizationServerMetadata when provided", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
const mockFetchFn = vi.fn();
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "http://localhost:3000",
|
|
authorization_endpoint: "http://localhost:3000/authorize",
|
|
token_endpoint: "http://localhost:3000/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
await discoverScopes("http://localhost:3000", undefined, mockFetchFn);
|
|
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL("/", "http://localhost:3000"),
|
|
{ fetchFn: mockFetchFn },
|
|
);
|
|
});
|
|
|
|
it("should use authorization_servers URL from resource metadata for discovery (different domain)", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "https://auth-server.com",
|
|
authorization_endpoint: "https://auth-server.com/authorize",
|
|
token_endpoint: "https://auth-server.com/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: "https://mcp-server.com",
|
|
authorization_servers: ["https://auth-server.com/"],
|
|
scopes_supported: ["read", "write"],
|
|
};
|
|
|
|
const scopes = await discoverScopes(
|
|
"https://mcp-server.com",
|
|
resourceMetadata,
|
|
);
|
|
|
|
expect(scopes).toBe("read write");
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL("https://auth-server.com/"),
|
|
{ fetchFn: undefined },
|
|
);
|
|
});
|
|
|
|
it("should preserve full path in authorization_servers URL", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "https://auth-server.com/realms/my-realm",
|
|
authorization_endpoint:
|
|
"https://auth-server.com/realms/my-realm/authorize",
|
|
token_endpoint: "https://auth-server.com/realms/my-realm/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: "https://mcp-server.com",
|
|
authorization_servers: ["https://auth-server.com/realms/my-realm/"],
|
|
scopes_supported: ["read", "write"],
|
|
};
|
|
|
|
const scopes = await discoverScopes(
|
|
"https://mcp-server.com",
|
|
resourceMetadata,
|
|
);
|
|
|
|
expect(scopes).toBe("read write");
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL("https://auth-server.com/realms/my-realm/"),
|
|
{ fetchFn: undefined },
|
|
);
|
|
});
|
|
|
|
it("should fall back to serverUrl when authorization_servers is empty", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: "https://mcp-server.com",
|
|
authorization_endpoint: "https://mcp-server.com/authorize",
|
|
token_endpoint: "https://mcp-server.com/token",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: "https://mcp-server.com",
|
|
authorization_servers: [],
|
|
scopes_supported: ["read", "write"],
|
|
};
|
|
|
|
const scopes = await discoverScopes(
|
|
"https://mcp-server.com",
|
|
resourceMetadata,
|
|
);
|
|
|
|
expect(scopes).toBe("read write");
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL("/", "https://mcp-server.com"),
|
|
{ fetchFn: undefined },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getAuthorizationServerUrl", () => {
|
|
const serverUrl = "https://mcp.example.com";
|
|
|
|
it("returns server URL when resourceMetadata is null", () => {
|
|
expect(getAuthorizationServerUrl(serverUrl, null)).toEqual(
|
|
new URL("/", serverUrl),
|
|
);
|
|
});
|
|
|
|
it("returns server URL when resourceMetadata is undefined", () => {
|
|
expect(getAuthorizationServerUrl(serverUrl)).toEqual(
|
|
new URL("/", serverUrl),
|
|
);
|
|
});
|
|
|
|
it("returns server URL when authorization_servers is empty array", () => {
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: serverUrl,
|
|
authorization_servers: [],
|
|
};
|
|
expect(getAuthorizationServerUrl(serverUrl, resourceMetadata)).toEqual(
|
|
new URL("/", serverUrl),
|
|
);
|
|
});
|
|
|
|
it("falls back to server URL when authorization_servers[0] is empty string", () => {
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: serverUrl,
|
|
authorization_servers: [""],
|
|
};
|
|
expect(getAuthorizationServerUrl(serverUrl, resourceMetadata)).toEqual(
|
|
new URL("/", serverUrl),
|
|
);
|
|
});
|
|
|
|
it("returns authorization_servers[0] when present and truthy", () => {
|
|
const authUrl = "https://auth.example.com/";
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: serverUrl,
|
|
authorization_servers: [authUrl],
|
|
};
|
|
expect(getAuthorizationServerUrl(serverUrl, resourceMetadata)).toEqual(
|
|
new URL(authUrl),
|
|
);
|
|
});
|
|
|
|
it("throws a descriptive error when the MCP server URL is invalid", () => {
|
|
// "not a url" cannot be parsed by `new URL("/", serverUrl)`, exercising the
|
|
// catch block that wraps the underlying parse failure (discovery.ts 21-22).
|
|
expect(() => getAuthorizationServerUrl("not a url")).toThrow(
|
|
/Invalid MCP server URL: "not a url"/,
|
|
);
|
|
});
|
|
|
|
it("includes the underlying error detail in the thrown message", () => {
|
|
let captured: Error | undefined;
|
|
try {
|
|
getAuthorizationServerUrl("");
|
|
} catch (err) {
|
|
captured = err instanceof Error ? err : undefined;
|
|
}
|
|
expect(captured).toBeInstanceOf(Error);
|
|
// The wrapped message carries the parenthesised detail from the parse failure.
|
|
expect(captured?.message).toMatch(/Invalid MCP server URL: "" \(.+\)/);
|
|
});
|
|
});
|
|
|
|
// #2110: an MCP server hosted under a path (Moodle, a WordPress plugin, any
|
|
// PHP endpoint) publishes its OAuth metadata under that same path. Reducing the
|
|
// fallback authorization-server URL to the origin threw that path away, so
|
|
// discovery could only ever probe the domain root and 404'd.
|
|
describe("path-hosted MCP servers (#2110)", () => {
|
|
const pathServerUrl = "https://misc.example.com/mod/minilesson/mcp.php";
|
|
|
|
describe("getAuthorizationServerUrl", () => {
|
|
it("keeps the server's path in the fallback URL", () => {
|
|
expect(getAuthorizationServerUrl(pathServerUrl)).toEqual(
|
|
new URL(pathServerUrl),
|
|
);
|
|
});
|
|
|
|
it("strips query and fragment, which an RFC 8414 issuer never carries", () => {
|
|
expect(
|
|
getAuthorizationServerUrl(`${pathServerUrl}?session=abc#frag`),
|
|
).toEqual(new URL(pathServerUrl));
|
|
});
|
|
});
|
|
|
|
describe("getAuthorizationServerUrlCandidates", () => {
|
|
it("tries the path-scoped URL before the bare origin", () => {
|
|
expect(getAuthorizationServerUrlCandidates(pathServerUrl)).toEqual([
|
|
new URL(pathServerUrl),
|
|
new URL("https://misc.example.com/"),
|
|
]);
|
|
});
|
|
|
|
it("offers a single candidate for a root-hosted server", () => {
|
|
expect(
|
|
getAuthorizationServerUrlCandidates("https://mcp.example.com"),
|
|
).toEqual([new URL("https://mcp.example.com/")]);
|
|
});
|
|
|
|
it("does not probe past an authorization server named by resource metadata", () => {
|
|
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
|
resource: pathServerUrl,
|
|
authorization_servers: ["https://auth.example.com/realms/r"],
|
|
};
|
|
expect(
|
|
getAuthorizationServerUrlCandidates(pathServerUrl, resourceMetadata),
|
|
).toEqual([new URL("https://auth.example.com/realms/r")]);
|
|
});
|
|
});
|
|
|
|
describe("discoverAuthorizationServerMetadataForServer", () => {
|
|
const metadata = {
|
|
issuer: pathServerUrl,
|
|
authorization_endpoint: "https://misc.example.com/mod/authorize.php",
|
|
token_endpoint: "https://misc.example.com/mod/token.php",
|
|
response_types_supported: ["code"],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns the path-scoped result without probing the origin", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(
|
|
metadata,
|
|
);
|
|
|
|
await expect(
|
|
discoverAuthorizationServerMetadataForServer(pathServerUrl),
|
|
).resolves.toEqual(metadata);
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledTimes(1);
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL(pathServerUrl),
|
|
{ fetchFn: undefined },
|
|
);
|
|
});
|
|
|
|
it("falls back to the origin when the path-scoped candidate has no metadata", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata)
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce(metadata);
|
|
|
|
await expect(
|
|
discoverAuthorizationServerMetadataForServer(pathServerUrl),
|
|
).resolves.toEqual(metadata);
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenNthCalledWith(
|
|
2,
|
|
new URL("https://misc.example.com/"),
|
|
{ fetchFn: undefined },
|
|
);
|
|
});
|
|
|
|
it("survives a throwing candidate while another remains", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata)
|
|
.mockRejectedValueOnce(new Error("issuer mismatch"))
|
|
.mockResolvedValueOnce(metadata);
|
|
|
|
await expect(
|
|
discoverAuthorizationServerMetadataForServer(pathServerUrl),
|
|
).resolves.toEqual(metadata);
|
|
});
|
|
|
|
it("rethrows the first error when every candidate fails", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata)
|
|
.mockRejectedValueOnce(new Error("path-scoped failed"))
|
|
.mockRejectedValueOnce(new Error("origin failed"));
|
|
|
|
await expect(
|
|
discoverAuthorizationServerMetadataForServer(pathServerUrl),
|
|
).rejects.toThrow("path-scoped failed");
|
|
});
|
|
|
|
it("resolves undefined when every candidate answers with no metadata", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(
|
|
undefined,
|
|
);
|
|
|
|
await expect(
|
|
discoverAuthorizationServerMetadataForServer(pathServerUrl),
|
|
).resolves.toBeUndefined();
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("passes the caller's fetch through to every candidate", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(
|
|
undefined,
|
|
);
|
|
const fetchFn = vi.fn<typeof fetch>();
|
|
|
|
await discoverAuthorizationServerMetadataForServer(
|
|
pathServerUrl,
|
|
undefined,
|
|
fetchFn,
|
|
);
|
|
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL(pathServerUrl),
|
|
{ fetchFn },
|
|
);
|
|
});
|
|
});
|
|
|
|
it("discoverScopes reaches a path-hosted authorization server", async () => {
|
|
const { discoverAuthorizationServerMetadata } =
|
|
await import("@modelcontextprotocol/client");
|
|
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
|
issuer: pathServerUrl,
|
|
authorization_endpoint: "https://misc.example.com/mod/authorize.php",
|
|
token_endpoint: "https://misc.example.com/mod/token.php",
|
|
response_types_supported: ["code"],
|
|
scopes_supported: ["read"],
|
|
});
|
|
|
|
await expect(discoverScopes(pathServerUrl)).resolves.toBe("read");
|
|
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
|
new URL(pathServerUrl),
|
|
{ fetchFn: undefined },
|
|
);
|
|
});
|
|
});
|