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:
commit
efbae5f927
12 changed files with 1098 additions and 44 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
424
client/src/lib/__tests__/oauth-proxy.test.ts
Normal file
424
client/src/lib/__tests__/oauth-proxy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
262
client/src/lib/oauth-proxy.ts
Normal file
262
client/src/lib/oauth-proxy.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
2
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue