Merge branch 'main' into accurate-cli-version
This commit is contained in:
commit
32ea112051
7 changed files with 3542 additions and 561 deletions
|
|
@ -34,7 +34,7 @@ export interface AuthDebuggerState {
|
|||
authServerUrl: URL | null;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
authorizationUrl: string | null;
|
||||
authorizationUrl: URL | null;
|
||||
authorizationCode: string;
|
||||
latestError: Error | null;
|
||||
statusMessage: StatusMessage | null;
|
||||
|
|
|
|||
|
|
@ -117,9 +117,14 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
return window.location.origin + "/oauth/callback";
|
||||
}
|
||||
|
||||
get debugRedirectUrl() {
|
||||
return window.location.origin + "/oauth/callback/debug";
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
// Register both redirect URIs to support both normal and debug flows
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
redirect_uris: [this.redirectUrl, this.debugRedirectUrl],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
|
|
@ -223,11 +228,13 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
}
|
||||
|
||||
// Overrides debug URL and allows saving server OAuth metadata to
|
||||
// Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to
|
||||
// display in debug UI.
|
||||
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
|
||||
get redirectUrl(): string {
|
||||
return `${window.location.origin}/oauth/callback/debug`;
|
||||
// We can use the debug redirect URL here because it was already registered
|
||||
// in the parent class's clientMetadata along with the normal redirect URL
|
||||
return this.debugRedirectUrl;
|
||||
}
|
||||
|
||||
saveServerMetadata(metadata: OAuthMetadata) {
|
||||
|
|
|
|||
|
|
@ -13,10 +13,14 @@ 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({
|
||||
json: () => Promise.resolve({ status: "ok" }),
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
// Mock the SDK dependencies
|
||||
|
|
@ -573,9 +577,10 @@ describe("useConnection", () => {
|
|||
mockStreamableHTTPTransport.options = undefined;
|
||||
});
|
||||
|
||||
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => {
|
||||
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured for proxy connectionType", async () => {
|
||||
const propsWithProxyAuth = {
|
||||
...defaultProps,
|
||||
connectionType: "proxy" as const,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
|
|
@ -625,6 +630,56 @@ describe("useConnection", () => {
|
|||
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
||||
});
|
||||
|
||||
test("does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for direct connectionType", async () => {
|
||||
const propsWithProxyAuth = {
|
||||
...defaultProps,
|
||||
connectionType: "direct" as const,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
|
||||
value: "test-proxy-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
|
||||
|
||||
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).toBeDefined();
|
||||
|
||||
// Verify that X-MCP-Proxy-Auth header is NOT present for direct connections
|
||||
expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty(
|
||||
"X-MCP-Proxy-Auth",
|
||||
);
|
||||
expect(mockSSETransport?.options?.fetch).toBeDefined();
|
||||
|
||||
// Verify the fetch function does NOT include the proxy auth header
|
||||
const mockFetch = mockSSETransport.options?.fetch;
|
||||
const testUrl = "http://test.com";
|
||||
await mockFetch?.(testUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
cache: "no-store",
|
||||
mode: "cors",
|
||||
signal: new AbortController().signal,
|
||||
redirect: "follow",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(testUrl);
|
||||
expect(
|
||||
(global.fetch as jest.Mock).mock.calls[0][1].headers,
|
||||
).not.toHaveProperty("X-MCP-Proxy-Auth");
|
||||
});
|
||||
|
||||
test("does NOT send Authorization header for proxy auth", async () => {
|
||||
const propsWithProxyAuth = {
|
||||
...defaultProps,
|
||||
|
|
@ -648,9 +703,17 @@ describe("useConnection", () => {
|
|||
});
|
||||
|
||||
test("preserves server Authorization header when proxy auth is configured", async () => {
|
||||
const customHeaders: CustomHeaders = [
|
||||
{
|
||||
name: "Authorization",
|
||||
value: "Bearer server-auth-token",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const propsWithBothAuth = {
|
||||
...defaultProps,
|
||||
bearerToken: "server-auth-token",
|
||||
customHeaders,
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_AUTH_TOKEN: {
|
||||
|
|
@ -740,6 +803,190 @@ 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("handles migrated legacy auth via custom headers", async () => {
|
||||
// Simulate what App.tsx would do - migrate legacy auth to custom headers
|
||||
const customHeaders: CustomHeaders = [
|
||||
{ name: "X-Custom-Auth", value: "legacy-token", enabled: true },
|
||||
];
|
||||
|
||||
const propsWithMigratedAuth = {
|
||||
...defaultProps,
|
||||
customHeaders,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithMigratedAuth));
|
||||
|
||||
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("Connection URL Verification", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the mock transport objects
|
||||
mockSSETransport.url = undefined;
|
||||
mockSSETransport.options = undefined;
|
||||
mockStreamableHTTPTransport.url = undefined;
|
||||
mockStreamableHTTPTransport.options = undefined;
|
||||
});
|
||||
|
||||
test("uses server URL directly when connectionType is 'direct'", async () => {
|
||||
const directProps = {
|
||||
...defaultProps,
|
||||
connectionType: "direct" as const,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(directProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Verify the transport was created with the direct server URL
|
||||
expect(mockSSETransport.url).toBeDefined();
|
||||
expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/");
|
||||
});
|
||||
|
||||
test("uses proxy server URL when connectionType is 'proxy'", async () => {
|
||||
const proxyProps = {
|
||||
...defaultProps,
|
||||
connectionType: "proxy" as const,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(proxyProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Verify the transport was created with a proxy server URL
|
||||
expect(mockSSETransport.url).toBeDefined();
|
||||
expect(mockSSETransport.url?.pathname).toBe("/sse");
|
||||
expect(mockSSETransport.url?.searchParams.get("url")).toBe(
|
||||
"http://localhost:8080",
|
||||
);
|
||||
expect(mockSSETransport.url?.searchParams.get("transportType")).toBe(
|
||||
"sse",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Error Handling with Scope Discovery", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -877,4 +1124,129 @@ describe("useConnection", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_PROXY_FULL_ADDRESS Configuration", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the mock transport objects
|
||||
mockSSETransport.url = undefined;
|
||||
mockSSETransport.options = undefined;
|
||||
mockStreamableHTTPTransport.url = undefined;
|
||||
mockStreamableHTTPTransport.options = undefined;
|
||||
});
|
||||
|
||||
test("sends proxyFullAddress query parameter for stdio transport when configured", async () => {
|
||||
const propsWithProxyFullAddress = {
|
||||
...defaultProps,
|
||||
transportType: "stdio" as const,
|
||||
command: "test-command",
|
||||
args: "test-args",
|
||||
env: {},
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "https://example.com/inspector/mcp_proxy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithProxyFullAddress),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the URL contains the proxyFullAddress parameter
|
||||
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
|
||||
"https://example.com/inspector/mcp_proxy",
|
||||
);
|
||||
});
|
||||
|
||||
test("sends proxyFullAddress query parameter for sse transport when configured", async () => {
|
||||
const propsWithProxyFullAddress = {
|
||||
...defaultProps,
|
||||
transportType: "sse" as const,
|
||||
sseUrl: "http://localhost:8080",
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "https://example.com/inspector/mcp_proxy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithProxyFullAddress),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the URL contains the proxyFullAddress parameter
|
||||
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
|
||||
"https://example.com/inspector/mcp_proxy",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => {
|
||||
const propsWithEmptyProxy = {
|
||||
...defaultProps,
|
||||
transportType: "stdio" as const,
|
||||
command: "test-command",
|
||||
args: "test-args",
|
||||
env: {},
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithEmptyProxy));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the URL does not contain the proxyFullAddress parameter
|
||||
expect(
|
||||
mockSSETransport.url?.searchParams.get("proxyFullAddress"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("does not send proxyFullAddress parameter for streamable-http transport", async () => {
|
||||
const propsWithStreamableHttp = {
|
||||
...defaultProps,
|
||||
transportType: "streamable-http" as const,
|
||||
sseUrl: "http://localhost:8080",
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "https://example.com/inspector/mcp_proxy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithStreamableHttp),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that streamable-http transport doesn't get proxyFullAddress parameter
|
||||
expect(
|
||||
mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||
import { InspectorConfig } from "../configurationTypes";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { CustomHeaders } from "../types/customHeaders";
|
||||
|
||||
interface UseConnectionOptions {
|
||||
transportType: "stdio" | "sse" | "streamable-http";
|
||||
|
|
@ -63,11 +64,12 @@ interface UseConnectionOptions {
|
|||
args: string;
|
||||
sseUrl: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken?: string;
|
||||
headerName?: string;
|
||||
// Custom headers support
|
||||
customHeaders?: CustomHeaders;
|
||||
oauthClientId?: string;
|
||||
oauthScope?: string;
|
||||
config: InspectorConfig;
|
||||
connectionType?: "direct" | "proxy";
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onStdErrNotification?: (notification: Notification) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -85,11 +87,11 @@ export function useConnection({
|
|||
args,
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
customHeaders,
|
||||
oauthClientId,
|
||||
oauthScope,
|
||||
config,
|
||||
connectionType = "proxy",
|
||||
onNotification,
|
||||
onPendingRequest,
|
||||
onElicitationRequest,
|
||||
|
|
@ -109,6 +111,10 @@ export function useConnection({
|
|||
{ request: string; response?: string }[]
|
||||
>([]);
|
||||
const [completionsSupported, setCompletionsSupported] = useState(false);
|
||||
const [mcpSessionId, setMcpSessionId] = useState<string | null>(null);
|
||||
const [mcpProtocolVersion, setMcpProtocolVersion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthClientId) {
|
||||
|
|
@ -166,7 +172,7 @@ export function useConnection({
|
|||
// Add progress notification to `Server Notification` window in the UI
|
||||
if (onNotification) {
|
||||
onNotification({
|
||||
method: "notification/progress",
|
||||
method: "notifications/progress",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
|
@ -345,6 +351,17 @@ export function useConnection({
|
|||
return false;
|
||||
};
|
||||
|
||||
const captureResponseHeaders = (response: Response): void => {
|
||||
const sessionId = response.headers.get("mcp-session-id");
|
||||
const protocolVersion = response.headers.get("mcp-protocol-version");
|
||||
if (sessionId && sessionId !== mcpSessionId) {
|
||||
setMcpSessionId(sessionId);
|
||||
}
|
||||
if (protocolVersion && protocolVersion !== mcpProtocolVersion) {
|
||||
setMcpProtocolVersion(protocolVersion);
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async (_e?: unknown, retryCount: number = 0) => {
|
||||
const clientCapabilities = {
|
||||
capabilities: {
|
||||
|
|
@ -361,11 +378,14 @@ export function useConnection({
|
|||
clientCapabilities,
|
||||
);
|
||||
|
||||
try {
|
||||
await checkProxyHealth();
|
||||
} catch {
|
||||
setConnectionStatus("error-connecting-to-proxy");
|
||||
return;
|
||||
// Only check proxy health for proxy connections
|
||||
if (connectionType === "proxy") {
|
||||
try {
|
||||
await checkProxyHealth();
|
||||
} catch {
|
||||
setConnectionStatus("error-connecting-to-proxy");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let lastRequest = "";
|
||||
|
|
@ -377,27 +397,42 @@ 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";
|
||||
// Use custom headers (migration is handled in App.tsx)
|
||||
let finalHeaders: CustomHeaders = 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}`;
|
||||
// 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add proxy authentication
|
||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||
getMCPProxyAuthToken(config);
|
||||
const proxyHeaders: HeadersInit = {};
|
||||
if (proxyAuthToken) {
|
||||
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Create appropriate transport
|
||||
|
|
@ -405,82 +440,178 @@ export function useConnection({
|
|||
| StreamableHTTPClientTransportOptions
|
||||
| SSEClientTransportOptions;
|
||||
|
||||
let mcpProxyServerUrl;
|
||||
switch (transportType) {
|
||||
case "stdio":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
|
||||
mcpProxyServerUrl.searchParams.append("command", command);
|
||||
mcpProxyServerUrl.searchParams.append("args", args);
|
||||
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||
transportOptions = {
|
||||
authProvider: serverAuthProvider,
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
};
|
||||
break;
|
||||
let serverUrl: URL;
|
||||
|
||||
case "sse":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
transportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
};
|
||||
break;
|
||||
// Determine connection URL based on the connection type
|
||||
if (connectionType === "direct" && transportType !== "stdio") {
|
||||
// Direct connection - use the provided URL directly (not available for STDIO)
|
||||
serverUrl = new URL(sseUrl);
|
||||
|
||||
case "streamable-http":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
transportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
const requestHeaders = { ...headers };
|
||||
if (mcpSessionId) {
|
||||
requestHeaders["mcp-session-id"] = mcpSessionId;
|
||||
}
|
||||
switch (transportType) {
|
||||
case "sse":
|
||||
requestHeaders["Accept"] = "text/event-stream";
|
||||
requestHeaders["content-type"] = "application/json";
|
||||
transportOptions = {
|
||||
fetch: async (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
) => {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
// TODO these should be configurable...
|
||||
reconnectionOptions: {
|
||||
maxReconnectionDelay: 30000,
|
||||
initialReconnectionDelay: 1000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 2,
|
||||
},
|
||||
};
|
||||
break;
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
// Capture protocol-related headers from response
|
||||
captureResponseHeaders(response);
|
||||
return response;
|
||||
},
|
||||
requestInit: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
case "streamable-http":
|
||||
transportOptions = {
|
||||
fetch: async (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
requestHeaders["Accept"] =
|
||||
"text/event-stream, application/json";
|
||||
requestHeaders["Content-Type"] = "application/json";
|
||||
const response = await fetch(url, {
|
||||
headers: requestHeaders,
|
||||
...init,
|
||||
});
|
||||
|
||||
// Capture protocol-related headers from response
|
||||
captureResponseHeaders(response);
|
||||
|
||||
return response;
|
||||
},
|
||||
requestInit: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
// TODO these should be configurable...
|
||||
reconnectionOptions: {
|
||||
maxReconnectionDelay: 30000,
|
||||
initialReconnectionDelay: 1000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 2,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Proxy connection (default behavior)
|
||||
// Add proxy authentication headers for proxy connections only
|
||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||
getMCPProxyAuthToken(config);
|
||||
const proxyHeaders: HeadersInit = {};
|
||||
if (proxyAuthToken) {
|
||||
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
||||
}
|
||||
|
||||
let mcpProxyServerUrl;
|
||||
switch (transportType) {
|
||||
case "stdio": {
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
|
||||
mcpProxyServerUrl.searchParams.append("command", command);
|
||||
mcpProxyServerUrl.searchParams.append("args", args);
|
||||
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||
|
||||
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS
|
||||
.value as string;
|
||||
if (proxyFullAddress) {
|
||||
mcpProxyServerUrl.searchParams.append(
|
||||
"proxyFullAddress",
|
||||
proxyFullAddress,
|
||||
);
|
||||
}
|
||||
transportOptions = {
|
||||
authProvider: serverAuthProvider,
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "sse": {
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
|
||||
const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS
|
||||
.value as string;
|
||||
if (proxyFullAddressSSE) {
|
||||
mcpProxyServerUrl.searchParams.append(
|
||||
"proxyFullAddress",
|
||||
proxyFullAddressSSE,
|
||||
);
|
||||
}
|
||||
transportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "streamable-http":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
transportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
url: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
}),
|
||||
},
|
||||
requestInit: {
|
||||
headers: { ...headers, ...proxyHeaders },
|
||||
},
|
||||
// TODO these should be configurable...
|
||||
reconnectionOptions: {
|
||||
maxReconnectionDelay: 30000,
|
||||
initialReconnectionDelay: 1000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 2,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
serverUrl = mcpProxyServerUrl as URL;
|
||||
serverUrl.searchParams.append("transportType", transportType);
|
||||
}
|
||||
(mcpProxyServerUrl as URL).searchParams.append(
|
||||
"transportType",
|
||||
transportType,
|
||||
);
|
||||
|
||||
if (onNotification) {
|
||||
[
|
||||
|
|
@ -506,14 +637,11 @@ export function useConnection({
|
|||
try {
|
||||
const transport =
|
||||
transportType === "streamable-http"
|
||||
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
||||
? new StreamableHTTPClientTransport(serverUrl, {
|
||||
sessionId: undefined,
|
||||
...transportOptions,
|
||||
})
|
||||
: new SSEClientTransport(
|
||||
mcpProxyServerUrl as URL,
|
||||
transportOptions,
|
||||
);
|
||||
: new SSEClientTransport(serverUrl, transportOptions);
|
||||
|
||||
await client.connect(transport as Transport);
|
||||
|
||||
|
|
@ -530,7 +658,9 @@ export function useConnection({
|
|||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
||||
connectionType === "direct"
|
||||
? `Failed to connect directly to MCP Server at: ${serverUrl}:`
|
||||
: `Failed to connect to MCP Server via the MCP Inspector Proxy: ${serverUrl}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
|
|
@ -577,6 +707,15 @@ export function useConnection({
|
|||
if (capabilities?.logging && defaultLoggingLevel) {
|
||||
lastRequest = "logging/setLevel";
|
||||
await client.setLoggingLevel(defaultLoggingLevel);
|
||||
pushHistory(
|
||||
{
|
||||
method: "logging/setLevel",
|
||||
params: {
|
||||
level: defaultLoggingLevel,
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
lastRequest = "";
|
||||
}
|
||||
|
||||
|
|
@ -620,6 +759,12 @@ export function useConnection({
|
|||
setConnectionStatus("disconnected");
|
||||
setCompletionsSupported(false);
|
||||
setServerCapabilities(null);
|
||||
setMcpSessionId(null);
|
||||
setMcpProtocolVersion(null);
|
||||
};
|
||||
|
||||
const clearRequestHistory = () => {
|
||||
setRequestHistory([]);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -627,6 +772,7 @@ export function useConnection({
|
|||
serverCapabilities,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
clearRequestHistory,
|
||||
makeRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
|
|
|
|||
29
client/src/lib/hooks/useCopy.ts
Normal file
29
client/src/lib/hooks/useCopy.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type UseCopyProps = {
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
function useCopy({ timeout = 500 }: UseCopyProps = {}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (copied) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, timeout);
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [copied]);
|
||||
|
||||
return { copied, setCopied };
|
||||
}
|
||||
|
||||
export default useCopy;
|
||||
|
|
@ -132,7 +132,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
|
||||
context.provider.saveCodeVerifier(codeVerifier);
|
||||
context.updateState({
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
authorizationUrl: authorizationUrl,
|
||||
oauthStep: "authorization_code",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
64
client/src/lib/types/customHeaders.ts
Normal file
64
client/src/lib/types/customHeaders.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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 => {
|
||||
return bearerToken
|
||||
? [createHeaderFromBearerToken(bearerToken, headerName)]
|
||||
: [];
|
||||
};
|
||||
Loading…
Reference in a new issue