Add support for custom headers and migrate from legacy auth
- Introduced CustomHeaders component for managing custom headers. - Updated Sidebar to include CustomHeaders component. - Enhanced useConnection hook to handle custom headers and legacy auth migration. - Added tests for custom headers functionality. - Updated package.json and package-lock.json for new dependencies.
This commit is contained in:
parent
b476ece67e
commit
c5acc63129
3 changed files with 606 additions and 48 deletions
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { discoverScopes } from "../../auth";
|
||||
import { CustomHeaders } from "../../types/customHeaders";
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
|
|
@ -740,6 +741,135 @@ describe("useConnection", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Custom Headers", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the mock transport objects
|
||||
mockSSETransport.url = undefined;
|
||||
mockSSETransport.options = undefined;
|
||||
mockStreamableHTTPTransport.url = undefined;
|
||||
mockStreamableHTTPTransport.options = undefined;
|
||||
});
|
||||
|
||||
test("sends multiple custom headers correctly", async () => {
|
||||
const customHeaders: CustomHeaders = [
|
||||
{ name: "Authorization", value: "Bearer token123", enabled: true },
|
||||
{ name: "X-Tenant-ID", value: "acme-inc", enabled: true },
|
||||
{ name: "X-Environment", value: "staging", enabled: true },
|
||||
];
|
||||
|
||||
const propsWithCustomHeaders = {
|
||||
...defaultProps,
|
||||
customHeaders,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithCustomHeaders),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the transport was created with the correct headers
|
||||
expect(mockSSETransport.options).toBeDefined();
|
||||
expect(mockSSETransport.options?.requestInit?.headers).toBeDefined();
|
||||
|
||||
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||
expect(headers).toHaveProperty("Authorization", "Bearer token123");
|
||||
expect(headers).toHaveProperty("X-Tenant-ID", "acme-inc");
|
||||
expect(headers).toHaveProperty("X-Environment", "staging");
|
||||
expect(headers).toHaveProperty("x-custom-auth-headers", JSON.stringify(["X-Tenant-ID", "X-Environment"]));
|
||||
});
|
||||
|
||||
test("ignores disabled custom headers", async () => {
|
||||
const customHeaders: CustomHeaders = [
|
||||
{ name: "Authorization", value: "Bearer token123", enabled: true },
|
||||
{ name: "X-Disabled", value: "should-not-appear", enabled: false },
|
||||
{ name: "X-Enabled", value: "should-appear", enabled: true },
|
||||
];
|
||||
|
||||
const propsWithCustomHeaders = {
|
||||
...defaultProps,
|
||||
customHeaders,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithCustomHeaders),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||
expect(headers).toHaveProperty("Authorization", "Bearer token123");
|
||||
expect(headers).toHaveProperty("X-Enabled", "should-appear");
|
||||
expect(headers).not.toHaveProperty("X-Disabled");
|
||||
});
|
||||
|
||||
test("migrates from legacy bearerToken and headerName", async () => {
|
||||
const propsWithLegacyAuth = {
|
||||
...defaultProps,
|
||||
bearerToken: "legacy-token",
|
||||
headerName: "X-Custom-Auth",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithLegacyAuth),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||
expect(headers).toHaveProperty("X-Custom-Auth", "legacy-token");
|
||||
expect(headers).toHaveProperty("x-custom-auth-headers", JSON.stringify(["X-Custom-Auth"]));
|
||||
});
|
||||
|
||||
test("uses OAuth token when no custom headers or legacy auth provided", async () => {
|
||||
const propsWithoutAuth = {
|
||||
...defaultProps,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithoutAuth),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||
expect(headers).toHaveProperty("Authorization", "Bearer mock-token");
|
||||
});
|
||||
|
||||
test("prioritizes custom headers over legacy auth", async () => {
|
||||
const customHeaders: CustomHeaders = [
|
||||
{ name: "Authorization", value: "Bearer custom-token", enabled: true },
|
||||
];
|
||||
|
||||
const propsWithBothAuth = {
|
||||
...defaultProps,
|
||||
customHeaders,
|
||||
bearerToken: "legacy-token",
|
||||
headerName: "Authorization",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithBothAuth),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||
expect(headers).toHaveProperty("Authorization", "Bearer custom-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Error Handling with Scope Discovery", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import {
|
|||
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||
import { InspectorConfig } from "../configurationTypes";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { CustomHeaders, migrateFromLegacyAuth } from "../types/customHeaders";
|
||||
|
||||
interface UseConnectionOptions {
|
||||
transportType: "stdio" | "sse" | "streamable-http";
|
||||
|
|
@ -64,8 +65,11 @@ interface UseConnectionOptions {
|
|||
args: string;
|
||||
sseUrl: string;
|
||||
env: Record<string, string>;
|
||||
// Legacy auth support (for backward compatibility)
|
||||
bearerToken?: string;
|
||||
headerName?: string;
|
||||
// New custom headers support
|
||||
customHeaders?: CustomHeaders;
|
||||
oauthClientId?: string;
|
||||
oauthScope?: string;
|
||||
config: InspectorConfig;
|
||||
|
|
@ -88,6 +92,7 @@ export function useConnection({
|
|||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
customHeaders,
|
||||
oauthClientId,
|
||||
oauthScope,
|
||||
config,
|
||||
|
|
@ -379,21 +384,52 @@ export function useConnection({
|
|||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
|
||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
||||
const token =
|
||||
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
||||
if (token) {
|
||||
const authHeaderName = headerName || "Authorization";
|
||||
// Handle custom headers (new approach) or legacy auth (backward compatibility)
|
||||
let finalHeaders: CustomHeaders = [];
|
||||
|
||||
// Add custom header name as a special request header to let the server know which header to pass through
|
||||
if (authHeaderName.toLowerCase() !== "authorization") {
|
||||
headers[authHeaderName] = token;
|
||||
headers["x-custom-auth-header"] = authHeaderName;
|
||||
} else {
|
||||
headers[authHeaderName] = `Bearer ${token}`;
|
||||
if (customHeaders && customHeaders.length > 0) {
|
||||
// Use new custom headers approach
|
||||
finalHeaders = customHeaders;
|
||||
} else if (bearerToken || headerName) {
|
||||
// Migrate from legacy auth approach
|
||||
finalHeaders = migrateFromLegacyAuth(bearerToken, headerName);
|
||||
}
|
||||
|
||||
// Add OAuth token if available and no custom headers are set
|
||||
if (finalHeaders.length === 0) {
|
||||
const oauthToken = (await serverAuthProvider.tokens())?.access_token;
|
||||
if (oauthToken) {
|
||||
finalHeaders = [
|
||||
{
|
||||
name: "Authorization",
|
||||
value: `Bearer ${oauthToken}`,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Process all enabled custom headers
|
||||
const customHeaderNames: string[] = [];
|
||||
finalHeaders.forEach((header) => {
|
||||
if (header.enabled && header.name.trim() && header.value.trim()) {
|
||||
const headerName = header.name.trim();
|
||||
const headerValue = header.value.trim();
|
||||
|
||||
headers[headerName] = headerValue;
|
||||
|
||||
// Track custom header names for server processing
|
||||
if (headerName.toLowerCase() !== "authorization") {
|
||||
customHeaderNames.push(headerName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add custom header names as a special request header for server processing
|
||||
if (customHeaderNames.length > 0) {
|
||||
headers["x-custom-auth-headers"] = JSON.stringify(customHeaderNames);
|
||||
}
|
||||
|
||||
// Add proxy authentication
|
||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||
getMCPProxyAuthToken(config);
|
||||
|
|
|
|||
59
client/src/lib/types/customHeaders.ts
Normal file
59
client/src/lib/types/customHeaders.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export interface CustomHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type CustomHeaders = CustomHeader[];
|
||||
|
||||
export const createEmptyHeader = (): CustomHeader => ({
|
||||
name: "",
|
||||
value: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export const createHeaderFromBearerToken = (
|
||||
bearerToken: string,
|
||||
headerName?: string,
|
||||
): CustomHeader => ({
|
||||
name: headerName || "Authorization",
|
||||
value: headerName?.toLowerCase() === "authorization" || !headerName
|
||||
? `Bearer ${bearerToken}`
|
||||
: bearerToken,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export const getEnabledHeaders = (headers: CustomHeaders): CustomHeaders => {
|
||||
return headers.filter(header => header.enabled && header.name.trim() && header.value.trim());
|
||||
};
|
||||
|
||||
export const headersToRecord = (headers: CustomHeaders): Record<string, string> => {
|
||||
const enabledHeaders = getEnabledHeaders(headers);
|
||||
const record: Record<string, string> = {};
|
||||
|
||||
enabledHeaders.forEach(header => {
|
||||
record[header.name.trim()] = header.value.trim();
|
||||
});
|
||||
|
||||
return record;
|
||||
};
|
||||
|
||||
export const recordToHeaders = (record: Record<string, string>): CustomHeaders => {
|
||||
return Object.entries(record).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
enabled: true,
|
||||
}));
|
||||
};
|
||||
|
||||
// Migration helper for backward compatibility
|
||||
export const migrateFromLegacyAuth = (
|
||||
bearerToken?: string,
|
||||
headerName?: string,
|
||||
): CustomHeaders => {
|
||||
if (!bearerToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [createHeaderFromBearerToken(bearerToken, headerName)];
|
||||
};
|
||||
Loading…
Reference in a new issue