Skip to content

Commit 5cdbeaa

Browse files
authored
Merge pull request #1047 from BobDickinson/web-client-oauth-proxy-fetch
Inspector client: Added proxy fetch for use by auth (to avoid CORS issues)
2 parents 443288f + 446a9bc commit 5cdbeaa

16 files changed

Lines changed: 1188 additions & 70 deletions

client/src/App.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
3434
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
3535
import { OAuthStateMachine } from "./lib/oauth-state-machine";
36+
import { createProxyFetch } from "./lib/proxyFetch";
3637
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
3738
import { cleanParams } from "./utils/paramUtils";
3839
import type { JsonSchemaType } from "./utils/jsonUtils";
@@ -622,9 +623,17 @@ const App = () => {
622623
};
623624

624625
try {
625-
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
626-
currentState = { ...currentState, ...updates };
627-
});
626+
const fetchFn =
627+
connectionType === "proxy" && config
628+
? createProxyFetch(config)
629+
: undefined;
630+
const stateMachine = new OAuthStateMachine(
631+
sseUrl,
632+
(updates) => {
633+
currentState = { ...currentState, ...updates };
634+
},
635+
fetchFn,
636+
);
628637

629638
while (
630639
currentState.oauthStep !== "complete" &&
@@ -662,7 +671,7 @@ const App = () => {
662671
});
663672
}
664673
},
665-
[sseUrl],
674+
[sseUrl, connectionType, config],
666675
);
667676

668677
useEffect(() => {
@@ -1264,6 +1273,8 @@ const App = () => {
12641273
onBack={() => setIsAuthDebuggerVisible(false)}
12651274
authState={authState}
12661275
updateAuthState={updateAuthState}
1276+
config={config}
1277+
connectionType={connectionType}
12671278
/>
12681279
</TabsContent>
12691280
);
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* Tests for the proxy server's POST /fetch endpoint.
3+
* Spawns the server and hits it like any other HTTP client would.
4+
*/
5+
import { spawn, type ChildProcess } from "child_process";
6+
import {
7+
createServer,
8+
type IncomingMessage,
9+
type Server,
10+
type ServerResponse,
11+
} from "http";
12+
import { resolve } from "path";
13+
14+
const TEST_PORT = 16321;
15+
const TEST_TOKEN = "test-proxy-token-12345";
16+
const SERVER_PATH = resolve(__dirname, "../../../server/build/index.js");
17+
18+
/** Placeholder URL for tests where auth fails before the proxy fetches (no network). */
19+
const UNUSED_UPSTREAM_URL = "http://127.0.0.1:1/unused";
20+
21+
async function waitForServer(baseUrl: string, maxWaitMs = 5000): Promise<void> {
22+
const start = Date.now();
23+
while (Date.now() - start < maxWaitMs) {
24+
try {
25+
const res = await fetch(`${baseUrl}/health`);
26+
if (res.ok) return;
27+
} catch {
28+
await new Promise((r) => setTimeout(r, 50));
29+
}
30+
}
31+
throw new Error("Server did not become ready");
32+
}
33+
34+
/**
35+
* Runs `fn` with a local HTTP server on 127.0.0.1:ephemeral-port.
36+
* `origin` is `http://127.0.0.1:<port>` (no trailing path).
37+
*/
38+
async function withLocalUpstream(
39+
onRequest: (req: IncomingMessage, res: ServerResponse) => void,
40+
fn: (origin: string) => Promise<void>,
41+
): Promise<void> {
42+
const upstream: Server = createServer(onRequest);
43+
44+
await new Promise<void>((resolve, reject) => {
45+
upstream.once("error", reject);
46+
upstream.listen(0, "127.0.0.1", () => resolve());
47+
});
48+
49+
const addr = upstream.address();
50+
if (!addr || typeof addr === "string") {
51+
upstream.close();
52+
throw new Error("Expected TCP listen address");
53+
}
54+
55+
const origin = `http://127.0.0.1:${addr.port}`;
56+
57+
try {
58+
await fn(origin);
59+
} finally {
60+
await new Promise<void>((r) => upstream.close(() => r()));
61+
}
62+
}
63+
64+
describe("POST /fetch endpoint", () => {
65+
let server: ChildProcess;
66+
const baseUrl = `http://localhost:${TEST_PORT}`;
67+
68+
beforeAll(async () => {
69+
server = spawn("node", [SERVER_PATH], {
70+
env: {
71+
...process.env,
72+
SERVER_PORT: String(TEST_PORT),
73+
MCP_PROXY_AUTH_TOKEN: TEST_TOKEN,
74+
},
75+
stdio: "ignore",
76+
});
77+
await waitForServer(baseUrl);
78+
}, 10000);
79+
80+
afterAll(() => {
81+
server.kill();
82+
});
83+
84+
it("returns 401 when no auth header", async () => {
85+
const res = await fetch(`${baseUrl}/fetch`, {
86+
method: "POST",
87+
headers: { "Content-Type": "application/json" },
88+
body: JSON.stringify({
89+
url: UNUSED_UPSTREAM_URL,
90+
init: { method: "GET" },
91+
}),
92+
});
93+
expect(res.status).toBe(401);
94+
const body = await res.json();
95+
expect(body.error).toBe("Unauthorized");
96+
});
97+
98+
it("returns 401 when auth token is invalid", async () => {
99+
const res = await fetch(`${baseUrl}/fetch`, {
100+
method: "POST",
101+
headers: {
102+
"Content-Type": "application/json",
103+
"X-MCP-Proxy-Auth": "Bearer wrong-token",
104+
},
105+
body: JSON.stringify({
106+
url: UNUSED_UPSTREAM_URL,
107+
init: { method: "GET" },
108+
}),
109+
});
110+
expect(res.status).toBe(401);
111+
});
112+
113+
it("returns 400 for non-http(s) URL when auth token is valid", async () => {
114+
const res = await fetch(`${baseUrl}/fetch`, {
115+
method: "POST",
116+
headers: {
117+
"Content-Type": "application/json",
118+
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
119+
},
120+
body: JSON.stringify({
121+
url: "file:///etc/passwd",
122+
init: { method: "GET" },
123+
}),
124+
});
125+
expect(res.status).toBe(400);
126+
const body = (await res.json()) as { error: string };
127+
expect(body.error).toBe("Only http/https URLs are allowed");
128+
});
129+
130+
it("returns 400 for invalid URL string when auth token is valid", async () => {
131+
const res = await fetch(`${baseUrl}/fetch`, {
132+
method: "POST",
133+
headers: {
134+
"Content-Type": "application/json",
135+
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
136+
},
137+
body: JSON.stringify({
138+
url: "not a valid url",
139+
init: { method: "GET" },
140+
}),
141+
});
142+
expect(res.status).toBe(400);
143+
const body = (await res.json()) as { error: string };
144+
expect(body.error).toBe("Invalid URL");
145+
});
146+
147+
it("returns 400 when url is missing when auth token is valid", async () => {
148+
const res = await fetch(`${baseUrl}/fetch`, {
149+
method: "POST",
150+
headers: {
151+
"Content-Type": "application/json",
152+
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
153+
},
154+
body: JSON.stringify({ init: { method: "GET" } }),
155+
});
156+
expect(res.status).toBe(400);
157+
const body = (await res.json()) as { error: string };
158+
expect(body.error).toBe("Missing or invalid url");
159+
});
160+
161+
it("forwards request when auth token is valid", async () => {
162+
const upstreamPayload = JSON.stringify({ hello: "proxy-fetch-test" });
163+
164+
await withLocalUpstream(
165+
(req, res) => {
166+
res.writeHead(200, { "Content-Type": "application/json" });
167+
res.end(upstreamPayload);
168+
},
169+
async (origin) => {
170+
const upstreamUrl = `${origin}/ok`;
171+
172+
const res = await fetch(`${baseUrl}/fetch`, {
173+
method: "POST",
174+
headers: {
175+
"Content-Type": "application/json",
176+
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
177+
},
178+
body: JSON.stringify({
179+
url: upstreamUrl,
180+
init: { method: "GET" },
181+
}),
182+
});
183+
184+
expect(res.status).toBe(200);
185+
const body = (await res.json()) as {
186+
ok: boolean;
187+
status: number;
188+
statusText: string;
189+
body: string;
190+
headers: Record<string, string>;
191+
};
192+
expect(body.ok).toBe(true);
193+
expect(body.status).toBe(200);
194+
expect(body.statusText).toBe("OK");
195+
expect(body.body).toBe(upstreamPayload);
196+
expect(body.headers["content-type"]).toMatch(/application\/json/i);
197+
},
198+
);
199+
});
200+
201+
it("mirrors upstream 404 (non-2xx) when auth token is valid", async () => {
202+
await withLocalUpstream(
203+
(req, res) => {
204+
res.writeHead(404, { "Content-Type": "application/json" });
205+
res.end('{"error":"not_found"}');
206+
},
207+
async (origin) => {
208+
const upstreamUrl = `${origin}/missing`;
209+
210+
const res = await fetch(`${baseUrl}/fetch`, {
211+
method: "POST",
212+
headers: {
213+
"Content-Type": "application/json",
214+
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
215+
},
216+
body: JSON.stringify({
217+
url: upstreamUrl,
218+
init: { method: "GET" },
219+
}),
220+
});
221+
222+
expect(res.status).toBe(404);
223+
const body = (await res.json()) as {
224+
ok: boolean;
225+
status: number;
226+
body: string;
227+
};
228+
expect(body.ok).toBe(false);
229+
expect(body.status).toBe(404);
230+
expect(JSON.parse(body.body)).toEqual({ error: "not_found" });
231+
},
232+
);
233+
});
234+
});

client/src/components/AuthDebugger.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import { AlertCircle } from "lucide-react";
55
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
66
import { OAuthFlowProgress } from "./OAuthFlowProgress";
77
import { OAuthStateMachine } from "../lib/oauth-state-machine";
8+
import { createProxyFetch } from "../lib/proxyFetch";
89
import { SESSION_KEYS } from "../lib/constants";
910
import { validateRedirectUrl } from "@/utils/urlValidation";
11+
import type { InspectorConfig } from "../lib/configurationTypes";
1012

1113
export interface AuthDebuggerProps {
1214
serverUrl: string;
1315
onBack: () => void;
1416
authState: AuthDebuggerState;
1517
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
18+
config?: InspectorConfig;
19+
connectionType?: "direct" | "proxy";
1620
}
1721

1822
interface StatusMessageProps {
@@ -60,6 +64,8 @@ const AuthDebugger = ({
6064
onBack,
6165
authState,
6266
updateAuthState,
67+
config,
68+
connectionType,
6369
}: AuthDebuggerProps) => {
6470
// Check for existing tokens on mount
6571
useEffect(() => {
@@ -102,9 +108,17 @@ const AuthDebugger = ({
102108
});
103109
}, [serverUrl, updateAuthState]);
104110

111+
const fetchFn = useMemo(
112+
() =>
113+
connectionType === "proxy" && config
114+
? createProxyFetch(config)
115+
: undefined,
116+
[connectionType, config],
117+
);
118+
105119
const stateMachine = useMemo(
106-
() => new OAuthStateMachine(serverUrl, updateAuthState),
107-
[serverUrl, updateAuthState],
120+
() => new OAuthStateMachine(serverUrl, updateAuthState, fetchFn),
121+
[serverUrl, updateAuthState, fetchFn],
108122
);
109123

110124
const proceedToNextStep = useCallback(async () => {
@@ -150,11 +164,15 @@ const AuthDebugger = ({
150164
latestError: null,
151165
};
152166

153-
const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
154-
// Update our temporary state during the process
155-
currentState = { ...currentState, ...updates };
156-
// But don't call updateAuthState yet
157-
});
167+
const oauthMachine = new OAuthStateMachine(
168+
serverUrl,
169+
(updates) => {
170+
// Update our temporary state during the process
171+
currentState = { ...currentState, ...updates };
172+
// But don't call updateAuthState yet
173+
},
174+
fetchFn,
175+
);
158176

159177
// Manually step through each stage of the OAuth flow
160178
while (currentState.oauthStep !== "complete") {
@@ -214,7 +232,7 @@ const AuthDebugger = ({
214232
} finally {
215233
updateAuthState({ isInitiatingAuth: false });
216234
}
217-
}, [serverUrl, updateAuthState, authState]);
235+
}, [serverUrl, updateAuthState, authState, fetchFn]);
218236

219237
const handleClearOAuth = useCallback(() => {
220238
if (serverUrl) {

0 commit comments

Comments
 (0)