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
19 changes: 15 additions & 4 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "@/utils/metaUtils";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { createProxyFetch } from "./lib/proxyFetch";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import { cleanParams } from "./utils/paramUtils";
import type { JsonSchemaType } from "./utils/jsonUtils";
Expand Down Expand Up @@ -581,9 +582,17 @@ const App = () => {
};

try {
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
const fetchFn =
connectionType === "proxy" && config
? createProxyFetch(config)
: undefined;
const stateMachine = new OAuthStateMachine(
sseUrl,
(updates) => {
currentState = { ...currentState, ...updates };
},
fetchFn,
);

while (
currentState.oauthStep !== "complete" &&
Expand Down Expand Up @@ -621,7 +630,7 @@ const App = () => {
});
}
},
[sseUrl],
[sseUrl, connectionType, config],
);

useEffect(() => {
Expand Down Expand Up @@ -1184,6 +1193,8 @@ const App = () => {
onBack={() => setIsAuthDebuggerVisible(false)}
authState={authState}
updateAuthState={updateAuthState}
config={config}
connectionType={connectionType}
/>
</TabsContent>
);
Expand Down
92 changes: 92 additions & 0 deletions client/src/__tests__/proxyFetchEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Tests for the proxy server's POST /fetch endpoint.
* Spawns the server and hits it like any other HTTP client would.
*/
import { spawn, type ChildProcess } from "child_process";
import { resolve } from "path";

const TEST_PORT = 16321;
const TEST_TOKEN = "test-proxy-token-12345";
const SERVER_PATH = resolve(__dirname, "../../../server/build/index.js");

async function waitForServer(baseUrl: string, maxWaitMs = 5000): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const res = await fetch(`${baseUrl}/health`);
if (res.ok) return;
} catch {
await new Promise((r) => setTimeout(r, 50));
}
}
throw new Error("Server did not become ready");
}

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

beforeAll(async () => {
server = spawn("node", [SERVER_PATH], {
env: {
...process.env,
SERVER_PORT: String(TEST_PORT),
MCP_PROXY_AUTH_TOKEN: TEST_TOKEN,
},
stdio: "ignore",
});
await waitForServer(baseUrl);
}, 10000);

afterAll(() => {
server.kill();
});

it("returns 401 when no auth header", async () => {
const res = await fetch(`${baseUrl}/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://example.com/",
init: { method: "GET" },
}),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});

it("returns 401 when auth token is invalid", async () => {
const res = await fetch(`${baseUrl}/fetch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MCP-Proxy-Auth": "Bearer wrong-token",
},
body: JSON.stringify({
url: "https://example.com/",
init: { method: "GET" },
}),
});
expect(res.status).toBe(401);
});

it("forwards request when auth token is valid", async () => {
const res = await fetch(`${baseUrl}/fetch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
},
body: JSON.stringify({
url: "https://example.com/",
init: { method: "GET" },
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.status).toBe(200);
expect(body.body).toBeDefined();
});
});
29 changes: 21 additions & 8 deletions client/src/components/AuthDebugger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { AlertCircle } from "lucide-react";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { createProxyFetch } from "../lib/proxyFetch";
import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
import type { InspectorConfig } from "../lib/configurationTypes";

export interface AuthDebuggerProps {
serverUrl: string;
onBack: () => void;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
config?: InspectorConfig;
connectionType?: "direct" | "proxy";
}

interface StatusMessageProps {
Expand Down Expand Up @@ -60,6 +64,8 @@ const AuthDebugger = ({
onBack,
authState,
updateAuthState,
config,
connectionType,
}: AuthDebuggerProps) => {
// Check for existing tokens on mount
useEffect(() => {
Expand Down Expand Up @@ -102,9 +108,12 @@ const AuthDebugger = ({
});
}, [serverUrl, updateAuthState]);

const fetchFn =
connectionType === "proxy" && config ? createProxyFetch(config) : undefined;

const stateMachine = useMemo(
() => new OAuthStateMachine(serverUrl, updateAuthState),
[serverUrl, updateAuthState],
() => new OAuthStateMachine(serverUrl, updateAuthState, fetchFn),
[serverUrl, updateAuthState, fetchFn],
);

const proceedToNextStep = useCallback(async () => {
Expand Down Expand Up @@ -150,11 +159,15 @@ const AuthDebugger = ({
latestError: null,
};

const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
// Update our temporary state during the process
currentState = { ...currentState, ...updates };
// But don't call updateAuthState yet
});
const oauthMachine = new OAuthStateMachine(
serverUrl,
(updates) => {
// Update our temporary state during the process
currentState = { ...currentState, ...updates };
// But don't call updateAuthState yet
},
fetchFn,
);

// Manually step through each stage of the OAuth flow
while (currentState.oauthStep !== "complete") {
Expand Down Expand Up @@ -214,7 +227,7 @@ const AuthDebugger = ({
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, updateAuthState, authState]);
}, [serverUrl, updateAuthState, authState, fetchFn]);

const handleClearOAuth = useCallback(() => {
if (serverUrl) {
Expand Down
77 changes: 72 additions & 5 deletions client/src/components/__tests__/AuthDebugger.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from "react";
import {
render,
screen,
Expand All @@ -8,8 +9,8 @@ import {
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SESSION_KEYS } from "@/lib/constants";
import { TooltipProvider } from "../ui/tooltip";
import { SESSION_KEYS, DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants";

const mockOAuthTokens = {
access_token: "test_access_token",
Expand Down Expand Up @@ -55,10 +56,10 @@ import {
discoverOAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
import { EMPTY_DEBUGGER_STATE } from "../../lib/auth-types";

// Mock local auth module
jest.mock("@/lib/auth", () => ({
jest.mock("../../lib/auth", () => ({
DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
clear: jest.fn().mockImplementation(() => {
Expand Down Expand Up @@ -106,7 +107,7 @@ jest.mock("@/lib/auth", () => ({
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
}));

import { discoverScopes } from "@/lib/auth";
import { discoverScopes } from "../../lib/auth";

// Type the mocked functions properly
const mockDiscoverAuthorizationServerMetadata =
Expand Down Expand Up @@ -269,6 +270,7 @@ describe("AuthDebugger", () => {
// Should first discover and save OAuth metadata
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
{ fetchFn: undefined },
);

// Check that updateAuthState was called with the right info message
Expand Down Expand Up @@ -404,6 +406,65 @@ describe("AuthDebugger", () => {
});
});

describe("Proxy Fetch integration", () => {
it("passes fetchFn to SDK when connectionType is proxy", async () => {
const configWithProxy = {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "http://localhost:6277",
},
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
};

await act(async () => {
renderAuthDebugger({
config: configWithProxy,
connectionType: "proxy",
authState: {
...defaultAuthState,
isInitiatingAuth: false,
oauthStep: "metadata_discovery",
},
});
});

await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});

expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
{ fetchFn: expect.any(Function) },
);
});

it("passes undefined fetchFn when connectionType is direct", async () => {
await act(async () => {
renderAuthDebugger({
connectionType: "direct",
authState: {
...defaultAuthState,
isInitiatingAuth: false,
oauthStep: "metadata_discovery",
},
});
});

await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});

expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
{ fetchFn: undefined },
);
});
});

describe("OAuth Flow Steps", () => {
it("should handle OAuth flow step progression", async () => {
const updateAuthState = jest.fn();
Expand All @@ -428,6 +489,7 @@ describe("AuthDebugger", () => {

expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
{ fetchFn: undefined },
);
});

Expand Down Expand Up @@ -725,6 +787,8 @@ describe("AuthDebugger", () => {
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com/mcp",
{},
undefined,
);
});

Expand Down Expand Up @@ -773,6 +837,8 @@ describe("AuthDebugger", () => {
await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com/mcp",
{},
undefined,
);
});

Expand All @@ -791,6 +857,7 @@ describe("AuthDebugger", () => {
// Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
{ fetchFn: undefined },
);
});
});
Expand Down
5 changes: 4 additions & 1 deletion client/src/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ describe("discoverScopes", () => {

expect(result).toBe(expected);
if (expectedCallUrl) {
expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl));
expect(mockDiscoverAuth).toHaveBeenCalledWith(
new URL(expectedCallUrl),
{ fetchFn: undefined },
);
}
},
);
Expand Down
Loading