Added proxy fetch for use by auth (to avoid CORS and other issues when using browser fetch)
This commit is contained in:
parent
9f3b4ff0a9
commit
3b2dec0063
7 changed files with 520 additions and 33 deletions
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
146
client/src/lib/__tests__/proxyFetch.test.ts
Normal file
146
client/src/lib/__tests__/proxyFetch.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { createProxyFetch } from "../proxyFetch";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "../constants";
|
||||
import type { InspectorConfig } from "../configurationTypes";
|
||||
|
||||
describe("createProxyFetch", () => {
|
||||
const mockFetch = jest.fn();
|
||||
const proxyAddress = "http://localhost:6277";
|
||||
|
||||
const configWithProxy: InspectorConfig = {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: proxyAddress,
|
||||
},
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
it("returns a function", () => {
|
||||
const fetchFn = createProxyFetch(configWithProxy);
|
||||
expect(typeof fetchFn).toBe("function");
|
||||
});
|
||||
|
||||
it("sends POST to proxy /fetch endpoint with correct headers", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
body: "response body",
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchFn = createProxyFetch(configWithProxy);
|
||||
await fetchFn("https://example.com/.well-known/oauth-authorization-server");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(`${proxyAddress}/fetch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-MCP-Proxy-Auth": "Bearer test-proxy-token",
|
||||
},
|
||||
body: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("includes target url and init in request body", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"issuer":"https://example.com"}',
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchFn = createProxyFetch(configWithProxy);
|
||||
await fetchFn("https://example.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "grant_type=authorization_code&code=abc",
|
||||
});
|
||||
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody).toEqual({
|
||||
url: "https://example.com/oauth/token",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: "grant_type=authorization_code&code=abc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reconstructs Response from proxy response", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"issuer":"https://example.com"}',
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchFn = createProxyFetch(configWithProxy);
|
||||
const response = await fetchFn(
|
||||
"https://example.com/.well-known/oauth-authorization-server",
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.statusText).toBe("OK");
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
const body = await response.text();
|
||||
expect(body).toBe('{"issuer":"https://example.com"}');
|
||||
});
|
||||
|
||||
it("throws when proxy returns non-ok response", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: "Unauthorized",
|
||||
});
|
||||
|
||||
const fetchFn = createProxyFetch(configWithProxy);
|
||||
await expect(fetchFn("https://example.com/")).rejects.toThrow(
|
||||
"Proxy fetch failed: Unauthorized",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses URL object as input", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
body: "",
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchFn = createProxyFetch(configWithProxy);
|
||||
await fetchFn(new URL("https://example.com/discovery"));
|
||||
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody.url).toBe("https://example.com/discovery");
|
||||
});
|
||||
});
|
||||
|
|
@ -22,10 +22,12 @@ import { validateRedirectUrl } from "@/utils/urlValidation";
|
|||
export const discoverScopes = async (
|
||||
serverUrl: string,
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata,
|
||||
fetchFn?: typeof fetch,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
new URL("/", serverUrl),
|
||||
{ fetchFn },
|
||||
);
|
||||
|
||||
// Prefer resource metadata scopes, but fall back to OAuth metadata if empty
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ describe("useConnection", () => {
|
|||
const [, samplingHandler] = samplingHandlerCall;
|
||||
|
||||
// Invoke handler; should return a CreateTaskResult immediately
|
||||
let createTaskResult: SchemaOutput<typeof CreateTaskResultSchema>;
|
||||
let createTaskResult!: SchemaOutput<typeof CreateTaskResultSchema>;
|
||||
await act(async () => {
|
||||
createTaskResult = await samplingHandler(samplingRequest);
|
||||
});
|
||||
|
|
@ -449,7 +449,7 @@ describe("useConnection", () => {
|
|||
});
|
||||
|
||||
expect(elicitRequestHandlerCall).toBeDefined();
|
||||
const [, handler] = elicitRequestHandlerCall;
|
||||
const [, handler] = elicitRequestHandlerCall!;
|
||||
|
||||
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
|
||||
resolve({ action: "accept", content: { name: "test" } });
|
||||
|
|
@ -640,7 +640,7 @@ describe("useConnection", () => {
|
|||
});
|
||||
|
||||
expect(elicitRequestHandlerCall).toBeDefined();
|
||||
const [, handler] = elicitRequestHandlerCall;
|
||||
const [, handler] = elicitRequestHandlerCall!;
|
||||
|
||||
const mockElicitationRequest: ElicitRequest = {
|
||||
method: "elicitation/create",
|
||||
|
|
@ -707,7 +707,8 @@ describe("useConnection", () => {
|
|||
}
|
||||
});
|
||||
|
||||
const [, handler] = elicitRequestHandlerCall;
|
||||
expect(elicitRequestHandlerCall).toBeDefined();
|
||||
const [, handler] = elicitRequestHandlerCall!;
|
||||
|
||||
const mockElicitationRequest: ElicitRequest = {
|
||||
method: "elicitation/create",
|
||||
|
|
@ -732,7 +733,7 @@ describe("useConnection", () => {
|
|||
resolve(mockResponse);
|
||||
});
|
||||
|
||||
let handlerResult;
|
||||
let handlerResult!: ElicitResult;
|
||||
await act(async () => {
|
||||
handlerResult = await handler(mockElicitationRequest);
|
||||
});
|
||||
|
|
@ -1514,15 +1515,19 @@ describe("useConnection", () => {
|
|||
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||
defaultProps.sseUrl,
|
||||
undefined,
|
||||
expect.any(Function), // fetchFn when connectionType is proxy
|
||||
);
|
||||
} else {
|
||||
expect(mockDiscoverScopes).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
|
||||
serverUrl: defaultProps.sseUrl,
|
||||
scope: expectedAuthScope,
|
||||
});
|
||||
expect(mockAuth).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
serverUrl: defaultProps.sseUrl,
|
||||
scope: expectedAuthScope,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1538,11 +1543,36 @@ describe("useConnection", () => {
|
|||
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||
defaultProps.sseUrl,
|
||||
undefined,
|
||||
expect.any(Function), // fetchFn when connectionType is proxy
|
||||
);
|
||||
expect(mockAuth).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
serverUrl: defaultProps.sseUrl,
|
||||
scope: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes undefined fetchFn when connectionType is direct", async () => {
|
||||
mockDiscoverScopes.mockResolvedValue("read write");
|
||||
setup401Error();
|
||||
|
||||
const directProps = {
|
||||
...defaultProps,
|
||||
connectionType: "direct" as const,
|
||||
};
|
||||
await attemptConnection(directProps);
|
||||
|
||||
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||
defaultProps.sseUrl,
|
||||
undefined,
|
||||
undefined, // fetchFn is undefined for direct
|
||||
);
|
||||
expect(mockAuth).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.not.objectContaining({ fetchFn: expect.anything() }),
|
||||
);
|
||||
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
|
||||
serverUrl: defaultProps.sseUrl,
|
||||
scope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
clearScopeFromSessionStorage,
|
||||
discoverScopes,
|
||||
} from "../auth";
|
||||
import { createProxyFetch } from "../proxyFetch";
|
||||
import {
|
||||
getMCPProxyAddress,
|
||||
getMCPTaskTtl,
|
||||
|
|
@ -400,17 +401,22 @@ export function useConnection({
|
|||
const handleAuthError = async (error: unknown) => {
|
||||
if (is401Error(error)) {
|
||||
let scope = oauthScope?.trim();
|
||||
const fetchFn =
|
||||
connectionType === "proxy" ? createProxyFetch(config) : undefined;
|
||||
|
||||
if (!scope) {
|
||||
// Only discover resource metadata when we need to discover scopes
|
||||
let resourceMetadata;
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
new URL("/", sseUrl),
|
||||
{},
|
||||
fetchFn,
|
||||
);
|
||||
} catch {
|
||||
// Resource metadata is optional, continue without it
|
||||
}
|
||||
scope = await discoverScopes(sseUrl, resourceMetadata);
|
||||
scope = await discoverScopes(sseUrl, resourceMetadata, fetchFn);
|
||||
}
|
||||
|
||||
saveScopeToSessionStorage(sseUrl, scope);
|
||||
|
|
@ -420,6 +426,7 @@ export function useConnection({
|
|||
const result = await auth(serverAuthProvider, {
|
||||
serverUrl: sseUrl,
|
||||
scope,
|
||||
...(fetchFn && { fetchFn }),
|
||||
});
|
||||
return result === "AUTHORIZED";
|
||||
} catch (authError) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface StateMachineContext {
|
|||
serverUrl: string;
|
||||
provider: DebugInspectorOAuthClientProvider;
|
||||
updateState: (updates: Partial<AuthDebuggerState>) => void;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface StateTransition {
|
||||
|
|
@ -38,6 +39,8 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
context.serverUrl,
|
||||
{},
|
||||
context.fetchFn,
|
||||
);
|
||||
if (resourceMetadata?.authorization_servers?.length) {
|
||||
authServerUrl = new URL(resourceMetadata.authorization_servers[0]);
|
||||
|
|
@ -57,7 +60,10 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
resourceMetadata ?? undefined,
|
||||
);
|
||||
|
||||
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
authServerUrl,
|
||||
{ fetchFn: context.fetchFn },
|
||||
);
|
||||
if (!metadata) {
|
||||
throw new Error("Failed to discover OAuth metadata");
|
||||
}
|
||||
|
|
@ -98,6 +104,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
fullInformation = await registerClient(context.serverUrl, {
|
||||
metadata,
|
||||
clientMetadata,
|
||||
fetchFn: context.fetchFn,
|
||||
});
|
||||
context.provider.saveClientInformation(fullInformation);
|
||||
}
|
||||
|
|
@ -122,6 +129,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
scope = await discoverScopes(
|
||||
context.serverUrl,
|
||||
context.state.resourceMetadata ?? undefined,
|
||||
context.fetchFn,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +197,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
? context.state.resource
|
||||
: new URL(context.state.resource)
|
||||
: undefined,
|
||||
fetchFn: context.fetchFn,
|
||||
});
|
||||
|
||||
context.provider.saveTokens(tokens);
|
||||
|
|
@ -211,6 +220,7 @@ export class OAuthStateMachine {
|
|||
constructor(
|
||||
private serverUrl: string,
|
||||
private updateState: (updates: Partial<AuthDebuggerState>) => void,
|
||||
private fetchFn?: typeof fetch,
|
||||
) {}
|
||||
|
||||
async executeStep(state: AuthDebuggerState): Promise<void> {
|
||||
|
|
@ -220,6 +230,7 @@ export class OAuthStateMachine {
|
|||
serverUrl: this.serverUrl,
|
||||
provider,
|
||||
updateState: this.updateState,
|
||||
fetchFn: this.fetchFn,
|
||||
};
|
||||
|
||||
const transition = oauthTransitions[state.oauthStep];
|
||||
|
|
|
|||
69
client/src/lib/proxyFetch.ts
Normal file
69
client/src/lib/proxyFetch.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { getMCPProxyAddress, getMCPProxyAuthToken } from "@/utils/configUtils";
|
||||
import type { InspectorConfig } from "./configurationTypes";
|
||||
|
||||
interface ProxyFetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fetch function that routes requests through the proxy server
|
||||
* to avoid CORS restrictions on OAuth discovery and token endpoints.
|
||||
*/
|
||||
export function createProxyFetch(config: InspectorConfig): typeof fetch {
|
||||
const proxyAddress = getMCPProxyAddress(config);
|
||||
const { token, header } = getMCPProxyAuthToken(config);
|
||||
|
||||
return async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
// Serialize body for JSON transport. URLSearchParams and similar don't
|
||||
// JSON-serialize (they become {}), so we must convert to string first.
|
||||
let serializedBody: string | undefined;
|
||||
if (init?.body != null) {
|
||||
if (typeof init.body === "string") {
|
||||
serializedBody = init.body;
|
||||
} else if (init.body instanceof URLSearchParams) {
|
||||
serializedBody = init.body.toString();
|
||||
} else {
|
||||
serializedBody = String(init.body);
|
||||
}
|
||||
}
|
||||
|
||||
const proxyResponse = await fetch(`${proxyAddress}/fetch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[header]: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
init: {
|
||||
method: init?.method,
|
||||
headers: init?.headers
|
||||
? Object.fromEntries(new Headers(init.headers))
|
||||
: undefined,
|
||||
body: serializedBody,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!proxyResponse.ok) {
|
||||
throw new Error(`Proxy fetch failed: ${proxyResponse.statusText}`);
|
||||
}
|
||||
|
||||
const data: ProxyFetchResponse = await proxyResponse.json();
|
||||
|
||||
return new Response(data.body, {
|
||||
status: data.status,
|
||||
statusText: data.statusText,
|
||||
headers: new Headers(data.headers),
|
||||
});
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue