Merge pull request #996 from asoorm/fix/oauth-cors-proxy-support

Fix: OAuth CORS errors when Connection Type is 'Via Proxy'
This commit is contained in:
Cliff Hall 2026-04-09 10:53:33 -04:00 • committed by GitHub
commit efbae5f927
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1098 additions and 44 deletions

View file

@ -622,9 +622,14 @@ const App = () => {
};
try {
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
const stateMachine = new OAuthStateMachine(
sseUrl,
(updates) => {
currentState = { ...currentState, ...updates };
},
connectionType,
config,
);
while (
currentState.oauthStep !== "complete" &&
@ -1264,6 +1269,8 @@ const App = () => {
onBack={() => setIsAuthDebuggerVisible(false)}
authState={authState}
updateAuthState={updateAuthState}
connectionType={connectionType}
config={config}
/>
</TabsContent>
);

View file

@ -7,12 +7,15 @@ import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
import { InspectorConfig } from "../lib/configurationTypes";
export interface AuthDebuggerProps {
serverUrl: string;
onBack: () => void;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
connectionType: "direct" | "proxy";
config: InspectorConfig;
}
interface StatusMessageProps {
@ -60,6 +63,8 @@ const AuthDebugger = ({
onBack,
authState,
updateAuthState,
connectionType,
config,
}: AuthDebuggerProps) => {
// Check for existing tokens on mount
useEffect(() => {
@ -103,8 +108,9 @@ const AuthDebugger = ({
}, [serverUrl, updateAuthState]);
const stateMachine = useMemo(
() => new OAuthStateMachine(serverUrl, updateAuthState),
[serverUrl, updateAuthState],
() =>
new OAuthStateMachine(serverUrl, updateAuthState, connectionType, config),
[serverUrl, updateAuthState, connectionType, config],
);
const proceedToNextStep = useCallback(async () => {
@ -150,11 +156,16 @@ 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
},
connectionType,
config,
);
// Manually step through each stage of the OAuth flow
while (currentState.oauthStep !== "complete") {

View file

@ -1,5 +1,6 @@
import { discoverScopes } from "../auth";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import { InspectorConfig } from "../configurationTypes";
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverAuthorizationServerMetadata: jest.fn(),
@ -10,6 +11,39 @@ const mockDiscoverAuth =
typeof discoverAuthorizationServerMetadata
>;
const mockConfig: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for MCP requests",
value: 30000,
is_session_item: false,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
is_session_item: false,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Max Total Timeout",
description: "Maximum total timeout",
value: 300000,
is_session_item: false,
},
MCP_PROXY_FULL_ADDRESS: {
label: "Proxy Address",
description: "Full address of the MCP proxy",
value: "http://localhost:6277",
is_session_item: false,
},
MCP_PROXY_AUTH_TOKEN: {
label: "Proxy Auth Token",
description: "Authentication token for the proxy",
value: "",
is_session_item: false,
},
};
const baseMetadata = {
issuer: "https://test.com",
authorization_endpoint: "https://test.com/authorize",
@ -129,7 +163,12 @@ describe("discoverScopes", () => {
}) => {
mockDiscoverAuth.mockResolvedValue(mockResolves);
const result = await discoverScopes(serverUrl, resourceMetadata);
const result = await discoverScopes(
serverUrl,
"direct",
mockConfig,
resourceMetadata,
);
expect(result).toBe(expected);
if (expectedCallUrl) {
@ -147,7 +186,12 @@ describe("discoverScopes", () => {
mockDiscoverAuth.mockResolvedValue(mockResolves);
}
const result = await discoverScopes(serverUrl, resourceMetadata);
const result = await discoverScopes(
serverUrl,
"direct",
mockConfig,
resourceMetadata,
);
expect(result).toBeUndefined();
},

View file

@ -0,0 +1,424 @@
/**
* Tests for OAuth Proxy Utilities
*/
import {
discoverAuthorizationServerMetadataViaProxy,
discoverOAuthProtectedResourceMetadataViaProxy,
registerClientViaProxy,
exchangeAuthorizationViaProxy,
} from "../oauth-proxy";
import { InspectorConfig } from "../configurationTypes";
// Mock the config utils
jest.mock("@/utils/configUtils", () => ({
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn(() => ({
token: "test-token",
header: "x-mcp-proxy-auth",
})),
}));
// Mock global fetch
global.fetch = jest.fn();
const mockConfig: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for MCP requests",
value: 30000,
is_session_item: false,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
is_session_item: false,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Max Total Timeout",
description: "Maximum total timeout",
value: 300000,
is_session_item: false,
},
MCP_PROXY_FULL_ADDRESS: {
label: "Proxy Address",
description: "Full address of the MCP proxy",
value: "http://localhost:6277",
is_session_item: false,
},
MCP_PROXY_AUTH_TOKEN: {
label: "Proxy Auth Token",
description: "Authentication token for the proxy",
value: "test-token",
is_session_item: false,
},
};
describe("OAuth Proxy Utilities", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("discoverAuthorizationServerMetadataViaProxy", () => {
it("should successfully fetch metadata through proxy", async () => {
const mockMetadata = {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
};
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata,
});
const result = await discoverAuthorizationServerMetadataViaProxy(
new URL("https://auth.example.com"),
mockConfig,
);
expect(result).toEqual(mockMetadata);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/oauth/metadata?url=https%3A%2F%2Fauth.example.com%2F.well-known%2Foauth-authorization-server",
{
method: "GET",
headers: {
"Content-Type": "application/json",
"x-mcp-proxy-auth": "Bearer test-token",
},
},
);
});
it("should handle network errors", async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error("Network error"),
);
await expect(
discoverAuthorizationServerMetadataViaProxy(
new URL("https://auth.example.com"),
mockConfig,
),
).rejects.toThrow("Network error");
});
it("should handle non-ok responses", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
json: async () => ({ error: "Metadata not found" }),
});
await expect(
discoverAuthorizationServerMetadataViaProxy(
new URL("https://auth.example.com"),
mockConfig,
),
).rejects.toThrow(
"Not found: Metadata not found. The OAuth endpoint may not exist or be misconfigured.",
);
});
it("should fall back to root well-known URL for non-root paths", async () => {
const mockMetadata = {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
};
// First call (path-aware) fails with 404
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
json: async () => ({ error: "Not found" }),
});
// Second call (root fallback) succeeds
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata,
});
const result = await discoverAuthorizationServerMetadataViaProxy(
new URL("https://auth.example.com/tenant"),
mockConfig,
);
expect(result).toEqual(mockMetadata);
expect(global.fetch).toHaveBeenCalledTimes(2);
// First attempt: path-aware URL
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"http://localhost:6277/oauth/metadata?url=https%3A%2F%2Fauth.example.com%2F.well-known%2Foauth-authorization-server%2Ftenant",
expect.any(Object),
);
// Second attempt: root fallback URL
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"http://localhost:6277/oauth/metadata?url=https%3A%2F%2Fauth.example.com%2F.well-known%2Foauth-authorization-server",
expect.any(Object),
);
});
it("should handle responses without error details", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
json: async () => {
throw new Error("Invalid JSON");
},
});
await expect(
discoverAuthorizationServerMetadataViaProxy(
new URL("https://auth.example.com"),
mockConfig,
),
).rejects.toThrow("Server error (500): Internal Server Error");
});
});
describe("discoverOAuthProtectedResourceMetadataViaProxy", () => {
it("should successfully fetch resource metadata through proxy", async () => {
const mockMetadata = {
resource: "https://api.example.com",
authorization_servers: ["https://auth.example.com"],
scopes_supported: ["read", "write"],
};
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata,
});
const result = await discoverOAuthProtectedResourceMetadataViaProxy(
"https://api.example.com",
mockConfig,
);
expect(result).toEqual(mockMetadata);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/oauth/resource-metadata?url=https%3A%2F%2Fapi.example.com%2F.well-known%2Foauth-protected-resource",
{
method: "GET",
headers: {
"Content-Type": "application/json",
"x-mcp-proxy-auth": "Bearer test-token",
},
},
);
});
it("should handle errors", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
json: async () => ({ error: "Resource metadata not found" }),
});
await expect(
discoverOAuthProtectedResourceMetadataViaProxy(
"https://api.example.com",
mockConfig,
),
).rejects.toThrow(
"Not found: Resource metadata not found. The OAuth endpoint may not exist or be misconfigured.",
);
});
it("should fall back to root well-known URL for non-root paths", async () => {
const mockMetadata = {
resource: "https://api.example.com/mcp",
authorization_servers: ["https://auth.example.com"],
scopes_supported: ["read", "write"],
};
// First call (path-aware) fails with 404
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
json: async () => ({ error: "Not found" }),
});
// Second call (root fallback) succeeds
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockMetadata,
});
const result = await discoverOAuthProtectedResourceMetadataViaProxy(
"https://api.example.com/mcp",
mockConfig,
);
expect(result).toEqual(mockMetadata);
expect(global.fetch).toHaveBeenCalledTimes(2);
// First attempt: path-aware URL
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"http://localhost:6277/oauth/resource-metadata?url=https%3A%2F%2Fapi.example.com%2F.well-known%2Foauth-protected-resource%2Fmcp",
expect.any(Object),
);
// Second attempt: root fallback URL
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"http://localhost:6277/oauth/resource-metadata?url=https%3A%2F%2Fapi.example.com%2F.well-known%2Foauth-protected-resource",
expect.any(Object),
);
});
});
describe("registerClientViaProxy", () => {
it("should successfully register client through proxy", async () => {
const clientMetadata = {
client_name: "Test Client",
redirect_uris: ["http://localhost:6274/oauth/callback"],
grant_types: ["authorization_code"],
};
const mockClientInformation = {
client_id: "test-client-id",
client_secret: "test-client-secret",
...clientMetadata,
};
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockClientInformation,
});
const result = await registerClientViaProxy(
"https://auth.example.com/register",
clientMetadata,
mockConfig,
);
expect(result).toEqual(mockClientInformation);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/oauth/register",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-mcp-proxy-auth": "Bearer test-token",
},
body: JSON.stringify({
registrationEndpoint: "https://auth.example.com/register",
clientMetadata,
}),
},
);
});
it("should handle registration errors", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 400,
statusText: "Bad Request",
json: async () => ({ error: "Invalid client metadata" }),
});
await expect(
registerClientViaProxy(
"https://auth.example.com/register",
{
client_name: "Test",
redirect_uris: ["http://localhost:6274/oauth/callback"],
},
mockConfig,
),
).rejects.toThrow(
"Failed to register client: Bad Request: Invalid client metadata",
);
});
});
describe("exchangeAuthorizationViaProxy", () => {
it("should successfully exchange authorization code through proxy", async () => {
const params = {
grant_type: "authorization_code",
code: "test-auth-code",
redirect_uri: "http://localhost:6274/oauth/callback",
code_verifier: "test-verifier",
client_id: "test-client-id",
};
const mockTokens = {
access_token: "test-access-token",
token_type: "Bearer",
expires_in: 3600,
};
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockTokens,
});
const result = await exchangeAuthorizationViaProxy(
"https://auth.example.com/token",
params,
mockConfig,
);
expect(result).toEqual(mockTokens);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:6277/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-mcp-proxy-auth": "Bearer test-token",
},
body: JSON.stringify({
tokenEndpoint: "https://auth.example.com/token",
params,
}),
},
);
});
it("should handle token exchange errors", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
json: async () => ({ error: "invalid_grant" }),
});
await expect(
exchangeAuthorizationViaProxy(
"https://auth.example.com/token",
{ grant_type: "authorization_code", code: "invalid" },
mockConfig,
),
).rejects.toThrow(
"Failed to exchange authorization code: Authentication failed: invalid_grant",
);
});
it("should handle network failures", async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error("Connection refused"),
);
await expect(
exchangeAuthorizationViaProxy(
"https://auth.example.com/token",
{ grant_type: "authorization_code", code: "test" },
mockConfig,
),
).rejects.toThrow("Connection refused");
});
});
});

View file

@ -12,21 +12,31 @@ import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/c
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
import { generateOAuthState } from "@/utils/oauthUtils";
import { validateRedirectUrl } from "@/utils/urlValidation";
import { discoverAuthorizationServerMetadataViaProxy } from "./oauth-proxy";
import { InspectorConfig } from "./configurationTypes";
/**
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
* @param serverUrl - The MCP server URL
* @param connectionType - Whether to use proxy or direct connection
* @param config - Inspector configuration (needed for proxy mode)
* @param resourceMetadata - Optional resource metadata containing preferred scopes
* @returns Promise resolving to space-separated scope string or undefined
*/
export const discoverScopes = async (
serverUrl: string,
connectionType: "direct" | "proxy",
config: InspectorConfig,
resourceMetadata?: OAuthProtectedResourceMetadata,
): Promise<string | undefined> => {
try {
const metadata = await discoverAuthorizationServerMetadata(
new URL("/", serverUrl),
);
const metadata =
connectionType === "proxy"
? await discoverAuthorizationServerMetadataViaProxy(
new URL(serverUrl),
config,
)
: await discoverAuthorizationServerMetadata(new URL("/", serverUrl));
// Prefer resource metadata scopes, but fall back to OAuth metadata if empty
const resourceScopes = resourceMetadata?.scopes_supported;

View file

@ -1513,6 +1513,8 @@ describe("useConnection", () => {
if (expectScopeCall) {
expect(mockDiscoverScopes).toHaveBeenCalledWith(
defaultProps.sseUrl,
"proxy",
defaultProps.config,
undefined,
);
} else {
@ -1537,6 +1539,8 @@ describe("useConnection", () => {
expect(mockDiscoverScopes).toHaveBeenCalledWith(
defaultProps.sseUrl,
"proxy",
defaultProps.config,
undefined,
);
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {

View file

@ -64,6 +64,7 @@ import {
clearScopeFromSessionStorage,
discoverScopes,
} from "../auth";
import { discoverOAuthProtectedResourceMetadataViaProxy } from "../oauth-proxy";
import {
getMCPProxyAddress,
getMCPTaskTtl,
@ -404,13 +405,26 @@ export function useConnection({
// Only discover resource metadata when we need to discover scopes
let resourceMetadata;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
new URL("/", sseUrl),
);
if (connectionType === "proxy") {
resourceMetadata =
await discoverOAuthProtectedResourceMetadataViaProxy(
sseUrl,
config,
);
} else {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
new URL("/", sseUrl),
);
}
} catch {
// Resource metadata is optional, continue without it
}
scope = await discoverScopes(sseUrl, resourceMetadata);
scope = await discoverScopes(
sseUrl,
connectionType,
config,
resourceMetadata,
);
}
saveScopeToSessionStorage(sseUrl, scope);

View file

@ -0,0 +1,262 @@
/**
* OAuth Proxy Utilities
*
* These functions route OAuth requests through the MCP Inspector proxy server
* to avoid CORS issues when connectionType is "proxy".
*/
import {
OAuthMetadata,
OAuthProtectedResourceMetadata,
OAuthClientInformation,
OAuthTokens,
OAuthClientMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { getMCPProxyAddress, getMCPProxyAuthToken } from "@/utils/configUtils";
import { InspectorConfig } from "./configurationTypes";
/**
* Get proxy headers for authentication
* @param config - Inspector configuration containing proxy authentication settings
* @returns Headers object with Content-Type and optional Bearer token
*/
function getProxyHeaders(config: InspectorConfig): Record<string, string> {
const { token, header } = getMCPProxyAuthToken(config);
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers[header] = `Bearer ${token}`;
}
return headers;
}
/**
* Common helper for proxying fetch requests
* @param endpoint - The API endpoint path (e.g., "/oauth/metadata")
* @param method - HTTP method (GET or POST)
* @param config - Inspector configuration containing proxy settings
* @param body - Optional request body object
* @param queryParams - Optional query parameters to append to URL
* @returns Promise resolving to the typed response
* @throws Error if the request fails or returns non-ok status
*/
async function proxyFetch<T>(
endpoint: string,
method: "GET" | "POST",
config: InspectorConfig,
body?: object,
queryParams?: Record<string, string>,
): Promise<T> {
const proxyAddress = getMCPProxyAddress(config);
const url = new URL(endpoint, proxyAddress);
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
let response: Response;
try {
response = await fetch(url.toString(), {
method,
headers: getProxyHeaders(config),
...(body && { body: JSON.stringify(body) }),
});
} catch (error) {
// Network errors (connection refused, timeout, etc.)
throw new Error(
`Network error: ${error instanceof Error ? error.message : "Failed to connect to proxy server"}`,
);
}
if (!response.ok) {
// Try to get detailed error from response body
const errorData = await response
.json()
.catch(() => ({ error: response.statusText }));
const errorMessage = errorData.error || response.statusText;
const errorDetails = errorData.details ? ` - ${errorData.details}` : "";
// Provide specific error messages based on status code
switch (response.status) {
case 400:
throw new Error(`Bad Request: ${errorMessage}${errorDetails}`);
case 401:
throw new Error(
`Authentication failed: ${errorMessage}. Check your proxy authentication token.`,
);
case 403:
throw new Error(
`Access forbidden: ${errorMessage}. You may not have permission to access this resource.`,
);
case 404:
throw new Error(
`Not found: ${errorMessage}. The OAuth endpoint may not exist or be misconfigured.`,
);
case 500:
case 502:
case 503:
case 504:
throw new Error(
`Server error (${response.status}): ${errorMessage}${errorDetails}`,
);
default:
throw new Error(
`Request failed (${response.status}): ${errorMessage}${errorDetails}`,
);
}
}
return await response.json();
}
/**
* Discover OAuth Authorization Server Metadata via proxy
* @param authServerUrl - The OAuth authorization server URL
* @param config - Inspector configuration containing proxy settings
* @returns Promise resolving to OAuth metadata
* @throws Error if metadata discovery fails
*/
export async function discoverAuthorizationServerMetadataViaProxy(
authServerUrl: URL,
config: InspectorConfig,
): Promise<OAuthMetadata> {
// Construct the path-aware well-known URL per RFC 8414
const pathname = authServerUrl.pathname.endsWith("/")
? authServerUrl.pathname.slice(0, -1)
: authServerUrl.pathname;
const wellKnownUrl = new URL(
`/.well-known/oauth-authorization-server${pathname}`,
authServerUrl,
);
try {
return await proxyFetch<OAuthMetadata>(
"/oauth/metadata",
"GET",
config,
undefined,
{ url: wellKnownUrl.toString() },
);
} catch (error) {
// If path-aware discovery fails and we have a non-root path, fall back to root discovery
if (pathname && pathname !== "/") {
const rootWellKnownUrl = new URL(
`/.well-known/oauth-authorization-server`,
authServerUrl,
);
return await proxyFetch<OAuthMetadata>(
"/oauth/metadata",
"GET",
config,
undefined,
{ url: rootWellKnownUrl.toString() },
);
}
throw error;
}
}
/**
* Discover OAuth Protected Resource Metadata via proxy
* @param serverUrl - The MCP server URL
* @param config - Inspector configuration containing proxy settings
* @returns Promise resolving to OAuth protected resource metadata
* @throws Error if resource metadata discovery fails
*/
export async function discoverOAuthProtectedResourceMetadataViaProxy(
serverUrl: string,
config: InspectorConfig,
): Promise<OAuthProtectedResourceMetadata> {
// Construct the well-known URL per RFC 9728
const url = new URL(serverUrl);
const pathname = url.pathname.endsWith("/")
? url.pathname.slice(0, -1)
: url.pathname;
const wellKnownUrl = new URL(
`/.well-known/oauth-protected-resource${pathname}`,
url,
);
try {
return await proxyFetch<OAuthProtectedResourceMetadata>(
"/oauth/resource-metadata",
"GET",
config,
undefined,
{ url: wellKnownUrl.toString() },
);
} catch (error) {
// If path-aware discovery fails and we have a non-root path, fall back to root discovery
if (pathname && pathname !== "/") {
const rootWellKnownUrl = new URL(
`/.well-known/oauth-protected-resource`,
url,
);
return await proxyFetch<OAuthProtectedResourceMetadata>(
"/oauth/resource-metadata",
"GET",
config,
undefined,
{ url: rootWellKnownUrl.toString() },
);
}
throw error;
}
}
/**
* Register OAuth client via proxy (Dynamic Client Registration)
* @param registrationEndpoint - The OAuth client registration endpoint URL
* @param clientMetadata - OAuth client metadata for registration
* @param config - Inspector configuration containing proxy settings
* @returns Promise resolving to OAuth client information
* @throws Error if client registration fails
*/
export async function registerClientViaProxy(
registrationEndpoint: string,
clientMetadata: OAuthClientMetadata,
config: InspectorConfig,
): Promise<OAuthClientInformation> {
try {
return await proxyFetch<OAuthClientInformation>(
"/oauth/register",
"POST",
config,
{ registrationEndpoint, clientMetadata },
);
} catch (error) {
throw new Error(
`Failed to register client: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Exchange authorization code for tokens via proxy
* @param tokenEndpoint - The OAuth token endpoint URL
* @param params - Token exchange parameters (code, client_id, etc.)
* @param config - Inspector configuration containing proxy settings
* @returns Promise resolving to OAuth tokens
* @throws Error if token exchange fails
*/
export async function exchangeAuthorizationViaProxy(
tokenEndpoint: string,
params: Record<string, string>,
config: InspectorConfig,
): Promise<OAuthTokens> {
try {
return await proxyFetch<OAuthTokens>("/oauth/token", "POST", config, {
tokenEndpoint,
params,
});
} catch (error) {
throw new Error(
`Failed to exchange authorization code: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View file

@ -13,12 +13,21 @@ import {
OAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { generateOAuthState } from "@/utils/oauthUtils";
import { InspectorConfig } from "./configurationTypes";
import {
discoverAuthorizationServerMetadataViaProxy,
discoverOAuthProtectedResourceMetadataViaProxy,
registerClientViaProxy,
exchangeAuthorizationViaProxy,
} from "./oauth-proxy";
export interface StateMachineContext {
state: AuthDebuggerState;
serverUrl: string;
provider: DebugInspectorOAuthClientProvider;
updateState: (updates: Partial<AuthDebuggerState>) => void;
connectionType: "direct" | "proxy";
config: InspectorConfig;
}
export interface StateTransition {
@ -36,9 +45,18 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
let resourceMetadata: OAuthProtectedResourceMetadata | null = null;
let resourceMetadataError: Error | null = null;
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
context.serverUrl,
);
// Use proxy if connectionType is "proxy"
if (context.connectionType === "proxy") {
resourceMetadata =
await discoverOAuthProtectedResourceMetadataViaProxy(
context.serverUrl,
context.config,
);
} else {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
context.serverUrl,
);
}
if (resourceMetadata?.authorization_servers?.length) {
authServerUrl = new URL(resourceMetadata.authorization_servers[0]);
}
@ -57,7 +75,15 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
resourceMetadata ?? undefined,
);
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
// Use proxy if connectionType is "proxy"
const metadata =
context.connectionType === "proxy"
? await discoverAuthorizationServerMetadataViaProxy(
authServerUrl,
context.config,
)
: await discoverAuthorizationServerMetadata(authServerUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
@ -86,8 +112,8 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
const scopesSupported =
context.state.resourceMetadata?.scopes_supported ||
metadata.scopes_supported;
// Add all supported scopes to client registration
if (scopesSupported) {
// Add all supported scopes to client registration (only if non-empty)
if (scopesSupported && scopesSupported.length > 0) {
clientMetadata.scope = scopesSupported.join(" ");
}
}
@ -95,10 +121,24 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
// Try Static client first, with DCR as fallback
let fullInformation = await context.provider.clientInformation();
if (!fullInformation) {
fullInformation = await registerClient(context.serverUrl, {
metadata,
clientMetadata,
});
// Use proxy if connectionType is "proxy"
if (context.connectionType === "proxy") {
if (!metadata.registration_endpoint) {
throw new Error(
"No registration endpoint available for dynamic client registration",
);
}
fullInformation = await registerClientViaProxy(
metadata.registration_endpoint,
clientMetadata,
context.config,
);
} else {
fullInformation = await registerClient(context.serverUrl, {
metadata,
clientMetadata,
});
}
context.provider.saveClientInformation(fullInformation);
}
@ -121,6 +161,8 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
if (!scope || scope.trim() === "") {
scope = await discoverScopes(
context.serverUrl,
context.connectionType,
context.config,
context.state.resourceMetadata ?? undefined,
);
}
@ -178,18 +220,50 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
const metadata = context.provider.getServerMetadata()!;
const clientInformation = (await context.provider.clientInformation())!;
const tokens = await exchangeAuthorization(context.serverUrl, {
metadata,
clientInformation,
authorizationCode: context.state.authorizationCode,
codeVerifier,
redirectUri: context.provider.redirectUrl,
resource: context.state.resource
? context.state.resource instanceof URL
? context.state.resource
: new URL(context.state.resource)
: undefined,
});
let tokens;
// Use proxy if connectionType is "proxy"
if (context.connectionType === "proxy") {
// Build the token request parameters
const params: Record<string, string> = {
grant_type: "authorization_code",
code: context.state.authorizationCode,
redirect_uri: context.provider.redirectUrl,
code_verifier: codeVerifier,
client_id: clientInformation.client_id,
};
if (clientInformation.client_secret) {
params.client_secret = clientInformation.client_secret;
}
if (context.state.resource) {
const resourceUrl =
context.state.resource instanceof URL
? context.state.resource.toString()
: context.state.resource;
params.resource = resourceUrl;
}
tokens = await exchangeAuthorizationViaProxy(
metadata.token_endpoint,
params,
context.config,
);
} else {
tokens = await exchangeAuthorization(context.serverUrl, {
metadata,
clientInformation,
authorizationCode: context.state.authorizationCode,
codeVerifier,
redirectUri: context.provider.redirectUrl,
resource: context.state.resource
? context.state.resource instanceof URL
? context.state.resource
: new URL(context.state.resource)
: undefined,
});
}
context.provider.saveTokens(tokens);
context.updateState({
@ -211,6 +285,8 @@ export class OAuthStateMachine {
constructor(
private serverUrl: string,
private updateState: (updates: Partial<AuthDebuggerState>) => void,
private connectionType: "direct" | "proxy",
private config: InspectorConfig,
) {}
async executeStep(state: AuthDebuggerState): Promise<void> {
@ -220,6 +296,8 @@ export class OAuthStateMachine {
serverUrl: this.serverUrl,
provider,
updateState: this.updateState,
connectionType: this.connectionType,
config: this.config,
};
const transition = oauthTransitions[state.oauthStep];

2
package-lock.json generated
View file

@ -38,7 +38,7 @@
"jest-fixed-jsdom": "^0.0.9",
"lint-staged": "^16.1.5",
"playwright": "^1.56.1",
"prettier": "^3.7.1",
"prettier": "^3.7.4",
"rimraf": "^6.0.1",
"typescript": "^5.4.2"
},

View file

@ -69,7 +69,7 @@
"jest-fixed-jsdom": "^0.0.9",
"lint-staged": "^16.1.5",
"playwright": "^1.56.1",
"prettier": "^3.7.1",
"prettier": "^3.7.4",
"rimraf": "^6.0.1",
"typescript": "^5.4.2"
},

View file

@ -174,6 +174,7 @@ const updateHeadersInPlace = (
const app = express();
app.use(cors());
app.use(express.json()); // Enable JSON body parsing globally
app.use((req, res, next) => {
res.header("Access-Control-Expose-Headers", "mcp-session-id");
next();
@ -457,7 +458,7 @@ app.get(
res.status(404).end("Session not found");
return;
} else {
await transport.handleRequest(req, res);
await transport.handleRequest(req, res, req.body);
}
} catch (error) {
console.error("Error in /mcp route:", error);
@ -473,6 +474,16 @@ app.post(
async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
// Diagnostic logging for debugging "Parse error: Invalid JSON" issues
// See: https://github.com/modelcontextprotocol/inspector/pull/996
console.log(
`[/mcp POST] sessionId=${sessionId ?? "(none)"} ` +
`content-type=${req.headers["content-type"] ?? "(missing)"} ` +
`body-type=${typeof req.body} ` +
`body-defined=${req.body !== undefined} ` +
`body=${typeof req.body === "object" ? JSON.stringify(req.body) : String(req.body)}`,
);
if (sessionId) {
console.log(`Received POST message for sessionId ${sessionId}`);
const headerHolder = sessionHeaderHolders.get(sessionId);
@ -493,6 +504,7 @@ app.post(
await (transport as StreamableHTTPServerTransport).handleRequest(
req,
res,
req.body,
);
}
} catch (error) {
@ -816,6 +828,194 @@ app.get(
},
);
// OAuth Proxy Endpoints - for routing OAuth requests through the proxy to avoid CORS issues
/**
* Proxy endpoint for OAuth Authorization Server Metadata Discovery
* GET /oauth/metadata?url=<well-known-url>
*/
app.get(
"/oauth/metadata",
originValidationMiddleware,
authMiddleware,
async (req, res) => {
try {
const metadataUrl = req.query.url as string;
if (!metadataUrl) {
res.status(400).json({ error: "url query parameter is required" });
return;
}
console.log(`OAuth metadata discovery: ${metadataUrl}`);
const response = await fetch(metadataUrl);
if (!response.ok) {
const errorText = await response.text();
res.status(response.status).json({
error: `Failed to fetch OAuth metadata: ${response.statusText}`,
details: errorText,
});
return;
}
const metadata = await response.json();
res.json(metadata);
} catch (error) {
console.error("Error in /oauth/metadata route:", error);
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
/**
* Proxy endpoint for OAuth Protected Resource Metadata Discovery
* GET /oauth/resource-metadata?url=<well-known-url>
*/
app.get(
"/oauth/resource-metadata",
originValidationMiddleware,
authMiddleware,
async (req, res) => {
try {
const metadataUrl = req.query.url as string;
if (!metadataUrl) {
res.status(400).json({ error: "url query parameter is required" });
return;
}
console.log(`OAuth resource metadata discovery: ${metadataUrl}`);
const response = await fetch(metadataUrl);
if (!response.ok) {
const errorText = await response.text();
res.status(response.status).json({
error: `Failed to fetch resource metadata: ${response.statusText}`,
details: errorText,
});
return;
}
const metadata = await response.json();
res.json(metadata);
} catch (error) {
console.error("Error in /oauth/resource-metadata route:", error);
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
/**
* Proxy endpoint for OAuth Dynamic Client Registration (DCR)
* POST /oauth/register
* Body: { registrationEndpoint: string, clientMetadata: object }
*/
app.post(
"/oauth/register",
originValidationMiddleware,
authMiddleware,
async (req, res) => {
try {
const { registrationEndpoint, clientMetadata } = req.body;
if (!registrationEndpoint || !clientMetadata) {
res.status(400).json({
error:
"registrationEndpoint and clientMetadata are required in request body",
});
return;
}
console.log(`OAuth client registration at: ${registrationEndpoint}`);
const response = await fetch(registrationEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(clientMetadata),
});
if (!response.ok) {
const errorText = await response.text();
res.status(response.status).json({
error: `Failed to register client: ${response.statusText}`,
details: errorText,
});
return;
}
const clientInformation = await response.json();
res.json(clientInformation);
} catch (error) {
console.error("Error in /oauth/register route:", error);
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
/**
* Proxy endpoint for OAuth Token Exchange
* POST /oauth/token
* Body: { tokenEndpoint: string, params: object }
*/
app.post(
"/oauth/token",
originValidationMiddleware,
authMiddleware,
async (req, res) => {
try {
const { tokenEndpoint, params } = req.body;
if (!tokenEndpoint || !params) {
res.status(400).json({
error: "tokenEndpoint and params are required in request body",
});
return;
}
console.log(`OAuth token exchange at: ${tokenEndpoint}`);
// Convert params object to URLSearchParams for form encoding
const formBody = new URLSearchParams(params as Record<string, string>);
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: formBody.toString(),
});
if (!response.ok) {
const errorText = await response.text();
res.status(response.status).json({
error: `Failed to exchange token: ${response.statusText}`,
details: errorText,
});
return;
}
const tokens = await response.json();
res.json(tokens);
} catch (error) {
console.error("Error in /oauth/token route:", error);
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
},
);
const PORT = parseInt(
process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT,
10,