Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 38 additions & 2 deletions client/src/__tests__/proxyFetchEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Tests for the proxy server's POST /fetch endpoint.
* Tests for the proxy server's HTTP endpoints.
* Spawns the server and hits it like any other HTTP client would.
*/
import { spawn, type ChildProcess } from "child_process";
Expand Down Expand Up @@ -61,7 +61,7 @@ async function withLocalUpstream(
}
}

describe("POST /fetch endpoint", () => {
describe("Inspector proxy endpoints", () => {
let server: ChildProcess;
const baseUrl = `http://localhost:${TEST_PORT}`;

Expand Down Expand Up @@ -231,4 +231,40 @@ describe("POST /fetch endpoint", () => {
},
);
});

it("forwards upstream SSE 401 challenge details to the browser", async () => {
const challenge =
'Bearer resource_metadata="http://127.0.0.1/.well-known/oauth-protected-resource/mcp"';
const upstreamPayload = JSON.stringify({
error: "unauthorized",
error_description: "Authentication required",
});

await withLocalUpstream(
(req, res) => {
res.writeHead(401, {
"Content-Type": "application/json",
"WWW-Authenticate": challenge,
});
res.end(upstreamPayload);
},
async (origin) => {
const proxyUrl = new URL(`${baseUrl}/sse`);
proxyUrl.searchParams.set("transportType", "sse");
proxyUrl.searchParams.set("url", `${origin}/events`);

const res = await fetch(proxyUrl, {
headers: {
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
},
});

const body = await res.text();
expect(res.status).toBe(401);
expect(res.headers.get("www-authenticate")).toBe(challenge);
expect(res.headers.get("content-type")).toMatch(/application\/json/i);
expect(body).toBe(upstreamPayload);
},
);
});
});
3 changes: 3 additions & 0 deletions client/src/components/OAuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
import { getResourceMetadataUrlFromSessionStorage } from "@/lib/oauth-resource-metadata";

interface OAuthCallbackProps {
onConnect: (serverUrl: string) => void;
Expand Down Expand Up @@ -49,6 +50,8 @@ const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: params.code,
resourceMetadataUrl:
getResourceMetadataUrlFromSessionStorage(serverUrl),
});
} catch (error) {
console.error("OAuth callback error:", error);
Expand Down
22 changes: 19 additions & 3 deletions client/src/components/__tests__/AuthDebugger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
exchangeAuthorization: jest.fn(),
discoverOAuthProtectedResourceMetadata: jest.fn(),
selectResourceURL: jest.fn(),
extractWWWAuthenticateParams: jest.fn(() => ({})),
}));

// Import the functions to get their types
Expand All @@ -64,10 +65,17 @@ jest.mock("../../lib/auth", () => ({
tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
clear: jest.fn().mockImplementation(() => {
// Mock the real clear() behavior which removes items from sessionStorage
sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens");
sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info");
sessionStorage.removeItem(
"[https://example.com/mcp] mcp_server_metadata",
`[https://example.com/mcp] ${SESSION_KEYS.CLIENT_INFORMATION}`,
);
sessionStorage.removeItem(
`[https://example.com/mcp] ${SESSION_KEYS.TOKENS}`,
);
sessionStorage.removeItem(
`[https://example.com/mcp] ${SESSION_KEYS.CODE_VERIFIER}`,
);
sessionStorage.removeItem(
`[https://example.com/mcp] ${SESSION_KEYS.RESOURCE_METADATA_URL}`,
);
}),
redirectUrl: "http://localhost:3000/oauth/callback/debug",
Expand Down Expand Up @@ -155,6 +163,11 @@ describe("AuthDebugger", () => {
beforeEach(() => {
jest.clearAllMocks();
sessionStorageMock.getItem.mockReturnValue(null);
global.fetch = jest.fn().mockResolvedValue(
new Response("", {
status: 404,
}),
);

// Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {});
Expand Down Expand Up @@ -403,6 +416,9 @@ describe("AuthDebugger", () => {

// Verify session storage was cleared
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith(
`[https://example.com/mcp] ${SESSION_KEYS.RESOURCE_METADATA_URL}`,
);
});
});

Expand Down
87 changes: 87 additions & 0 deletions client/src/components/__tests__/OAuthCallback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { render, waitFor } from "@testing-library/react";
import OAuthCallback from "../OAuthCallback";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { getServerSpecificKey, SESSION_KEYS } from "../../lib/constants";

jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
extractWWWAuthenticateParams: jest.fn(() => ({})),
}));

jest.mock("../../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({})),
}));

jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}));

const mockAuth = auth as jest.MockedFunction<typeof auth>;

describe("OAuthCallback", () => {
beforeEach(() => {
jest.clearAllMocks();
mockAuth.mockResolvedValue("AUTHORIZED");

sessionStorage.clear();
sessionStorage.setItem(
SESSION_KEYS.SERVER_URL,
"http://localhost:8080/jenkins/mcp-server/mcp",
);
sessionStorage.setItem(
getServerSpecificKey(
SESSION_KEYS.RESOURCE_METADATA_URL,
"http://localhost:8080/jenkins/mcp-server/mcp",
),
"http://localhost:8080/jenkins/.well-known/oauth-protected-resource/mcp-server/mcp",
);

window.history.pushState({}, "", "/oauth/callback?code=test-code");
jest.spyOn(window.history, "replaceState").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

it("passes persisted resource metadata URL when exchanging authorization code", async () => {
render(<OAuthCallback onConnect={jest.fn()} />);

await waitFor(() => {
expect(mockAuth).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
serverUrl: "http://localhost:8080/jenkins/mcp-server/mcp",
authorizationCode: "test-code",
resourceMetadataUrl: new URL(
"http://localhost:8080/jenkins/.well-known/oauth-protected-resource/mcp-server/mcp",
),
}),
);
});
});

it("continues without resource metadata URL when none was persisted", async () => {
sessionStorage.removeItem(
getServerSpecificKey(
SESSION_KEYS.RESOURCE_METADATA_URL,
"http://localhost:8080/jenkins/mcp-server/mcp",
),
);

render(<OAuthCallback onConnect={jest.fn()} />);

await waitFor(() => {
expect(mockAuth).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
serverUrl: "http://localhost:8080/jenkins/mcp-server/mcp",
authorizationCode: "test-code",
resourceMetadataUrl: undefined,
}),
);
});
});
});
25 changes: 24 additions & 1 deletion client/src/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { discoverScopes } from "../auth";
import { discoverScopes, InspectorOAuthClientProvider } from "../auth";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import { getServerSpecificKey, SESSION_KEYS } from "../constants";

jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverAuthorizationServerMetadata: jest.fn(),
Expand Down Expand Up @@ -156,3 +157,25 @@ describe("discoverScopes", () => {
},
);
});

describe("InspectorOAuthClientProvider", () => {
beforeEach(() => {
sessionStorage.clear();
});

it("clears the server-specific resource metadata URL", () => {
const serverUrl = "https://example.com/mcp";
const resourceMetadataKey = getServerSpecificKey(
SESSION_KEYS.RESOURCE_METADATA_URL,
serverUrl,
);
sessionStorage.setItem(
resourceMetadataKey,
"https://example.com/.well-known/oauth-protected-resource/mcp",
);

new InspectorOAuthClientProvider(serverUrl).clear();

expect(sessionStorage.getItem(resourceMetadataKey)).toBeNull();
});
});
Loading