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:
jeetul.samaiya 2025-08-26 18:51:06 +05:30
commit c5acc63129
3 changed files with 606 additions and 48 deletions

View file

@ -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();

View file

@ -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);

View 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)];
};