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:
parent
f2a34b67de
commit
c344ffb87c
3 changed files with 217 additions and 9 deletions
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue