Modified proxy fetch to return all response payloads (not just 200), which should fix the failures reported with proxy auth. I have not yet verified that locally (pending repro details).

This commit is contained in:
Bob Dickinson 2026-04-06 12:03:13 -07:00
commit c344ffb87c
3 changed files with 217 additions and 9 deletions

View file

@ -3,6 +3,7 @@
* Spawns the server and hits it like any other HTTP client would.
*/
import { spawn, type ChildProcess } from "child_process";
import { createServer, type Server } from "http";
import { resolve } from "path";
const TEST_PORT = 16321;
@ -89,4 +90,49 @@ describe("POST /fetch endpoint", () => {
expect(body.status).toBe(200);
expect(body.body).toBeDefined();
});
it("mirrors upstream 404 (non-2xx) when auth token is valid", async () => {
const upstream: Server = createServer((req, res) => {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"not_found"}');
});
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, "127.0.0.1", () => resolve());
});
const addr = upstream.address();
if (!addr || typeof addr === "string") {
upstream.close();
throw new Error("Expected TCP listen address");
}
const upstreamUrl = `http://127.0.0.1:${addr.port}/missing`;
try {
const res = await fetch(`${baseUrl}/fetch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MCP-Proxy-Auth": `Bearer ${TEST_TOKEN}`,
},
body: JSON.stringify({
url: upstreamUrl,
init: { method: "GET" },
}),
});
expect(res.status).toBe(404);
const body = (await res.json()) as {
ok: boolean;
status: number;
body: string;
};
expect(body.ok).toBe(false);
expect(body.status).toBe(404);
expect(JSON.parse(body.body)).toEqual({ error: "not_found" });
} finally {
await new Promise<void>((r) => upstream.close(() => r()));
}
});
});

View file

@ -112,15 +112,93 @@ describe("createProxyFetch", () => {
expect(body).toBe('{"issuer":"https://example.com"}');
});
it("throws when proxy returns non-ok response", async () => {
it("returns non-ok Response when upstream status is not 2xx (mirrored by proxy)", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
json: () =>
Promise.resolve({
ok: false,
status: 404,
statusText: "Not Found",
headers: { "content-type": "application/json" },
body: '{"error":"not_found"}',
}),
});
const fetchFn = createProxyFetch(configWithProxy);
const response = await fetchFn("https://example.com/.well-known/missing");
expect(response.ok).toBe(false);
expect(response.status).toBe(404);
expect(response.statusText).toBe("Not Found");
expect(response.headers.get("content-type")).toBe("application/json");
expect(await response.text()).toBe('{"error":"not_found"}');
});
it("returns non-ok Response when upstream returns 400 with token-endpoint error JSON (RFC 6749)", async () => {
const tokenErrorBody =
'{"error":"invalid_grant","error_description":"code expired"}';
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: "Bad Request",
json: () =>
Promise.resolve({
ok: false,
status: 400,
statusText: "Bad Request",
headers: { "content-type": "application/json" },
body: tokenErrorBody,
}),
});
const fetchFn = createProxyFetch(configWithProxy);
const response = await fetchFn("https://example.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=authorization_code&code=x",
});
expect(response.ok).toBe(false);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: "invalid_grant",
error_description: "code expired",
});
});
it("throws when proxy POST returns JSON error envelope (e.g. 401 invalid session)", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
json: () =>
Promise.resolve({
error: "Unauthorized",
message:
"Authentication required. Use the session token shown in the console when starting the server.",
}),
});
const fetchFn = createProxyFetch(configWithProxy);
await expect(fetchFn("https://example.com/")).rejects.toThrow(
"Proxy fetch failed: Unauthorized",
"Authentication required. Use the session token shown in the console when starting the server.",
);
});
it("throws when proxy response is not valid JSON", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 502,
statusText: "Bad Gateway",
json: () => Promise.reject(new SyntaxError("Unexpected token")),
});
const fetchFn = createProxyFetch(configWithProxy);
await expect(fetchFn("https://example.com/")).rejects.toThrow(
"Proxy fetch failed: 502 Bad Gateway",
);
});

View file

@ -9,6 +9,77 @@ interface ProxyFetchResponse {
body: string;
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* JSON body from POST /fetch when the proxy itself failed (auth, TLS, etc.),
* not a mirrored upstream HTTP response.
*/
function messageFromProxyInfrastructureError(data: unknown): string | null {
if (!isJsonObject(data)) {
return null;
}
const rec = data;
if (!("error" in rec) || "status" in rec) {
return null;
}
if (typeof rec.message === "string") {
return rec.message;
}
if (typeof rec.error === "string") {
return rec.error;
}
return "Proxy fetch failed";
}
/**
* Validates the JSON shape the proxy returns when it successfully forwarded
* a request and is mirroring the upstream response.
*/
function parseMirroredUpstreamJson(data: unknown): ProxyFetchResponse | null {
if (!isJsonObject(data)) {
return null;
}
const rec = data;
if (typeof rec.status !== "number") {
return null;
}
if (typeof rec.body !== "string") {
return null;
}
if (typeof rec.statusText !== "string") {
return null;
}
if (typeof rec.ok !== "boolean") {
return null;
}
if (rec.headers === null || typeof rec.headers !== "object") {
return null;
}
if (Array.isArray(rec.headers)) {
return null;
}
const headers: Record<string, string> = {};
for (const [key, val] of Object.entries(rec.headers)) {
if (typeof val !== "string") {
return null;
}
headers[key] = val;
}
return {
ok: rec.ok,
status: rec.status,
statusText: rec.statusText,
headers,
body: rec.body,
};
}
/**
* Creates a fetch function that routes requests through the proxy server
* to avoid CORS restrictions on OAuth discovery and token endpoints.
@ -54,16 +125,29 @@ export function createProxyFetch(config: InspectorConfig): typeof fetch {
}),
});
if (!proxyResponse.ok) {
throw new Error(`Proxy fetch failed: ${proxyResponse.statusText}`);
let data: unknown;
try {
data = await proxyResponse.json();
} catch {
throw new Error(
`Proxy fetch failed: ${proxyResponse.status} ${proxyResponse.statusText}`,
);
}
const data: ProxyFetchResponse = await proxyResponse.json();
const infraMessage = messageFromProxyInfrastructureError(data);
if (infraMessage !== null) {
throw new Error(infraMessage);
}
return new Response(data.body, {
status: data.status,
statusText: data.statusText,
headers: new Headers(data.headers),
const mirrored = parseMirroredUpstreamJson(data);
if (mirrored === null) {
throw new Error("Proxy fetch failed: unexpected response shape");
}
return new Response(mirrored.body, {
status: mirrored.status,
statusText: mirrored.statusText,
headers: new Headers(mirrored.headers),
});
};
}