- Add shared discoverScopes function in auth.ts with resource metadata preference - Update automatic flow (handleAuthError) to use dynamic scope discovery - Update manual flow (OAuth state machine) to use shared scope discovery logic - Add comprehensive test suite for discoverScopes function (11 test cases) - Fix empty array handling to return undefined instead of empty string - Maintain backward compatibility with existing oauthScope parameter - All quality gates pass: TypeScript, linting, formatting, and tests Resolves OAuth scope discovery inconsistency where automatic flow used static scopes while manual flow used metadata scopes. Both flows now use identical shared logic with proper resource metadata preference.
645 lines
19 KiB
TypeScript
645 lines
19 KiB
TypeScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import {
|
|
SSEClientTransport,
|
|
SseError,
|
|
SSEClientTransportOptions,
|
|
} from "@modelcontextprotocol/sdk/client/sse.js";
|
|
import {
|
|
StreamableHTTPClientTransport,
|
|
StreamableHTTPClientTransportOptions,
|
|
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import {
|
|
ClientNotification,
|
|
ClientRequest,
|
|
CreateMessageRequestSchema,
|
|
ListRootsRequestSchema,
|
|
ResourceUpdatedNotificationSchema,
|
|
LoggingMessageNotificationSchema,
|
|
Request,
|
|
Result,
|
|
ServerCapabilities,
|
|
PromptReference,
|
|
ResourceReference,
|
|
McpError,
|
|
CompleteResultSchema,
|
|
ErrorCode,
|
|
CancelledNotificationSchema,
|
|
ResourceListChangedNotificationSchema,
|
|
ToolListChangedNotificationSchema,
|
|
PromptListChangedNotificationSchema,
|
|
Progress,
|
|
LoggingLevel,
|
|
ElicitRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
import { useEffect, useState } from "react";
|
|
import { useToast } from "@/lib/hooks/useToast";
|
|
import { z } from "zod";
|
|
import { ConnectionStatus } from "../constants";
|
|
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
|
import {
|
|
auth,
|
|
discoverOAuthProtectedResourceMetadata,
|
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
import {
|
|
clearClientInformationFromSessionStorage,
|
|
InspectorOAuthClientProvider,
|
|
saveClientInformationToSessionStorage,
|
|
discoverScopes,
|
|
} from "../auth";
|
|
import packageJson from "../../../package.json";
|
|
import {
|
|
getMCPProxyAddress,
|
|
getMCPServerRequestMaxTotalTimeout,
|
|
resetRequestTimeoutOnProgress,
|
|
getMCPProxyAuthToken,
|
|
} from "@/utils/configUtils";
|
|
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
|
import { InspectorConfig } from "../configurationTypes";
|
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
|
|
interface UseConnectionOptions {
|
|
transportType: "stdio" | "sse" | "streamable-http";
|
|
command: string;
|
|
args: string;
|
|
sseUrl: string;
|
|
env: Record<string, string>;
|
|
bearerToken?: string;
|
|
headerName?: string;
|
|
oauthClientId?: string;
|
|
oauthScope?: string;
|
|
config: InspectorConfig;
|
|
onNotification?: (notification: Notification) => void;
|
|
onStdErrNotification?: (notification: Notification) => void;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
onElicitationRequest?: (request: any, resolve: any) => void;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
getRoots?: () => any[];
|
|
defaultLoggingLevel?: LoggingLevel;
|
|
}
|
|
|
|
export function useConnection({
|
|
transportType,
|
|
command,
|
|
args,
|
|
sseUrl,
|
|
env,
|
|
bearerToken,
|
|
headerName,
|
|
oauthClientId,
|
|
oauthScope,
|
|
config,
|
|
onNotification,
|
|
onStdErrNotification,
|
|
onPendingRequest,
|
|
onElicitationRequest,
|
|
getRoots,
|
|
defaultLoggingLevel,
|
|
}: UseConnectionOptions) {
|
|
const [connectionStatus, setConnectionStatus] =
|
|
useState<ConnectionStatus>("disconnected");
|
|
const { toast } = useToast();
|
|
const [serverCapabilities, setServerCapabilities] =
|
|
useState<ServerCapabilities | null>(null);
|
|
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
|
const [clientTransport, setClientTransport] = useState<Transport | null>(
|
|
null,
|
|
);
|
|
const [requestHistory, setRequestHistory] = useState<
|
|
{ request: string; response?: string }[]
|
|
>([]);
|
|
const [completionsSupported, setCompletionsSupported] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!oauthClientId) {
|
|
clearClientInformationFromSessionStorage({
|
|
serverUrl: sseUrl,
|
|
isPreregistered: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
saveClientInformationToSessionStorage({
|
|
serverUrl: sseUrl,
|
|
clientInformation: { client_id: oauthClientId },
|
|
isPreregistered: true,
|
|
});
|
|
}, [oauthClientId, sseUrl]);
|
|
|
|
const pushHistory = (request: object, response?: object) => {
|
|
setRequestHistory((prev) => [
|
|
...prev,
|
|
{
|
|
request: JSON.stringify(request),
|
|
response: response !== undefined ? JSON.stringify(response) : undefined,
|
|
},
|
|
]);
|
|
};
|
|
|
|
const makeRequest = async <T extends z.ZodType>(
|
|
request: ClientRequest,
|
|
schema: T,
|
|
options?: RequestOptions & { suppressToast?: boolean },
|
|
): Promise<z.output<T>> => {
|
|
if (!mcpClient) {
|
|
throw new Error("MCP client not connected");
|
|
}
|
|
try {
|
|
const abortController = new AbortController();
|
|
|
|
// prepare MCP Client request options
|
|
const mcpRequestOptions: RequestOptions = {
|
|
signal: options?.signal ?? abortController.signal,
|
|
resetTimeoutOnProgress:
|
|
options?.resetTimeoutOnProgress ??
|
|
resetRequestTimeoutOnProgress(config),
|
|
timeout: options?.timeout ?? getMCPServerRequestTimeout(config),
|
|
maxTotalTimeout:
|
|
options?.maxTotalTimeout ??
|
|
getMCPServerRequestMaxTotalTimeout(config),
|
|
};
|
|
|
|
// If progress notifications are enabled, add an onprogress hook to the MCP Client request options
|
|
// This is required by SDK to reset the timeout on progress notifications
|
|
if (mcpRequestOptions.resetTimeoutOnProgress) {
|
|
mcpRequestOptions.onprogress = (params: Progress) => {
|
|
// Add progress notification to `Server Notification` window in the UI
|
|
if (onNotification) {
|
|
onNotification({
|
|
method: "notification/progress",
|
|
params,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await mcpClient.request(request, schema, mcpRequestOptions);
|
|
|
|
pushHistory(request, response);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
pushHistory(request, { error: errorMessage });
|
|
throw error;
|
|
}
|
|
|
|
return response;
|
|
} catch (e: unknown) {
|
|
if (!options?.suppressToast) {
|
|
const errorString = (e as Error).message ?? String(e);
|
|
toast({
|
|
title: "Error",
|
|
description: errorString,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const handleCompletion = async (
|
|
ref: ResourceReference | PromptReference,
|
|
argName: string,
|
|
value: string,
|
|
context?: Record<string, string>,
|
|
signal?: AbortSignal,
|
|
): Promise<string[]> => {
|
|
if (!mcpClient || !completionsSupported) {
|
|
return [];
|
|
}
|
|
|
|
const request: ClientRequest = {
|
|
method: "completion/complete",
|
|
params: {
|
|
argument: {
|
|
name: argName,
|
|
value,
|
|
},
|
|
ref,
|
|
},
|
|
};
|
|
|
|
if (context) {
|
|
request["params"]["context"] = {
|
|
arguments: context,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const response = await makeRequest(request, CompleteResultSchema, {
|
|
signal,
|
|
suppressToast: true,
|
|
});
|
|
return response?.completion.values || [];
|
|
} catch (e: unknown) {
|
|
// Disable completions silently if the server doesn't support them.
|
|
// See https://github.com/modelcontextprotocol/specification/discussions/122
|
|
if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
|
|
setCompletionsSupported(false);
|
|
return [];
|
|
}
|
|
|
|
// Unexpected errors - show toast and rethrow
|
|
toast({
|
|
title: "Error",
|
|
description: e instanceof Error ? e.message : String(e),
|
|
variant: "destructive",
|
|
});
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const sendNotification = async (notification: ClientNotification) => {
|
|
if (!mcpClient) {
|
|
const error = new Error("MCP client not connected");
|
|
toast({
|
|
title: "Error",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
await mcpClient.notification(notification);
|
|
// Log successful notifications
|
|
pushHistory(notification);
|
|
} catch (e: unknown) {
|
|
if (e instanceof McpError) {
|
|
// Log MCP protocol errors
|
|
pushHistory(notification, { error: e.message });
|
|
}
|
|
toast({
|
|
title: "Error",
|
|
description: e instanceof Error ? e.message : String(e),
|
|
variant: "destructive",
|
|
});
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const checkProxyHealth = async () => {
|
|
try {
|
|
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
|
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
|
getMCPProxyAuthToken(config);
|
|
const headers: HeadersInit = {};
|
|
if (proxyAuthToken) {
|
|
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
|
}
|
|
const proxyHealthResponse = await fetch(proxyHealthUrl, { headers });
|
|
const proxyHealth = await proxyHealthResponse.json();
|
|
if (proxyHealth?.status !== "ok") {
|
|
throw new Error("MCP Proxy Server is not healthy");
|
|
}
|
|
} catch (e) {
|
|
console.error("Couldn't connect to MCP Proxy Server", e);
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const is401Error = (error: unknown): boolean => {
|
|
return (
|
|
(error instanceof SseError && error.code === 401) ||
|
|
(error instanceof Error && error.message.includes("401")) ||
|
|
(error instanceof Error && error.message.includes("Unauthorized"))
|
|
);
|
|
};
|
|
|
|
const isProxyAuthError = (error: unknown): boolean => {
|
|
return (
|
|
error instanceof Error &&
|
|
error.message.includes("Authentication required. Use the session token")
|
|
);
|
|
};
|
|
|
|
const handleAuthError = async (error: unknown) => {
|
|
if (is401Error(error)) {
|
|
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
|
|
|
let scope = oauthScope?.trim();
|
|
if (!scope) {
|
|
// Only discover resource metadata when we need to discover scopes
|
|
let resourceMetadata;
|
|
try {
|
|
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
|
new URL("/", sseUrl),
|
|
);
|
|
} catch {
|
|
// Resource metadata is optional, continue without it
|
|
}
|
|
scope = await discoverScopes(sseUrl, resourceMetadata);
|
|
}
|
|
|
|
const result = await auth(serverAuthProvider, {
|
|
serverUrl: sseUrl,
|
|
scope,
|
|
});
|
|
return result === "AUTHORIZED";
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const connect = async (_e?: unknown, retryCount: number = 0) => {
|
|
const client = new Client<Request, Notification, Result>(
|
|
{
|
|
name: "mcp-inspector",
|
|
version: packageJson.version,
|
|
},
|
|
{
|
|
capabilities: {
|
|
sampling: {},
|
|
elicitation: {},
|
|
roots: {
|
|
listChanged: true,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
try {
|
|
await checkProxyHealth();
|
|
} catch {
|
|
setConnectionStatus("error-connecting-to-proxy");
|
|
return;
|
|
}
|
|
|
|
let lastRequest = "";
|
|
try {
|
|
// Inject auth manually instead of using SSEClientTransport, because we're
|
|
// proxying through the inspector server first.
|
|
const headers: HeadersInit = {};
|
|
|
|
// 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";
|
|
|
|
// 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 proxy authentication
|
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
|
getMCPProxyAuthToken(config);
|
|
const proxyHeaders: HeadersInit = {};
|
|
if (proxyAuthToken) {
|
|
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
|
}
|
|
|
|
// Create appropriate transport
|
|
let transportOptions:
|
|
| 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;
|
|
|
|
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;
|
|
|
|
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;
|
|
}
|
|
(mcpProxyServerUrl as URL).searchParams.append(
|
|
"transportType",
|
|
transportType,
|
|
);
|
|
|
|
if (onNotification) {
|
|
[
|
|
CancelledNotificationSchema,
|
|
LoggingMessageNotificationSchema,
|
|
ResourceUpdatedNotificationSchema,
|
|
ResourceListChangedNotificationSchema,
|
|
ToolListChangedNotificationSchema,
|
|
PromptListChangedNotificationSchema,
|
|
].forEach((notificationSchema) => {
|
|
client.setNotificationHandler(notificationSchema, onNotification);
|
|
});
|
|
|
|
client.fallbackNotificationHandler = (
|
|
notification: Notification,
|
|
): Promise<void> => {
|
|
onNotification(notification);
|
|
return Promise.resolve();
|
|
};
|
|
}
|
|
|
|
if (onStdErrNotification) {
|
|
client.setNotificationHandler(
|
|
StdErrNotificationSchema,
|
|
onStdErrNotification,
|
|
);
|
|
}
|
|
|
|
let capabilities;
|
|
try {
|
|
const transport =
|
|
transportType === "streamable-http"
|
|
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
|
sessionId: undefined,
|
|
...transportOptions,
|
|
})
|
|
: new SSEClientTransport(
|
|
mcpProxyServerUrl as URL,
|
|
transportOptions,
|
|
);
|
|
|
|
await client.connect(transport as Transport);
|
|
|
|
setClientTransport(transport);
|
|
|
|
capabilities = client.getServerCapabilities();
|
|
const initializeRequest = {
|
|
method: "initialize",
|
|
};
|
|
pushHistory(initializeRequest, {
|
|
capabilities,
|
|
serverInfo: client.getServerVersion(),
|
|
instructions: client.getInstructions(),
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
|
|
error,
|
|
);
|
|
|
|
// Check if it's a proxy auth error
|
|
if (isProxyAuthError(error)) {
|
|
toast({
|
|
title: "Proxy Authentication Required",
|
|
description:
|
|
"Please enter the session token from the proxy server console in the Configuration settings.",
|
|
variant: "destructive",
|
|
});
|
|
setConnectionStatus("error");
|
|
return;
|
|
}
|
|
|
|
const shouldRetry = await handleAuthError(error);
|
|
if (shouldRetry) {
|
|
return connect(undefined, retryCount + 1);
|
|
}
|
|
if (is401Error(error)) {
|
|
// Don't set error state if we're about to redirect for auth
|
|
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
setServerCapabilities(capabilities ?? null);
|
|
setCompletionsSupported(capabilities?.completions !== undefined);
|
|
|
|
if (onPendingRequest) {
|
|
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
|
|
return new Promise((resolve, reject) => {
|
|
onPendingRequest(request, resolve, reject);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (getRoots) {
|
|
client.setRequestHandler(ListRootsRequestSchema, async () => {
|
|
return { roots: getRoots() };
|
|
});
|
|
}
|
|
|
|
if (capabilities?.logging && defaultLoggingLevel) {
|
|
lastRequest = "logging/setLevel";
|
|
await client.setLoggingLevel(defaultLoggingLevel);
|
|
lastRequest = "";
|
|
}
|
|
|
|
if (onElicitationRequest) {
|
|
client.setRequestHandler(ElicitRequestSchema, async (request) => {
|
|
return new Promise((resolve) => {
|
|
onElicitationRequest(request, resolve);
|
|
});
|
|
});
|
|
}
|
|
|
|
setMcpClient(client);
|
|
setConnectionStatus("connected");
|
|
} catch (e) {
|
|
if (
|
|
lastRequest === "logging/setLevel" &&
|
|
e instanceof McpError &&
|
|
e.code === ErrorCode.MethodNotFound
|
|
) {
|
|
toast({
|
|
title: "Error",
|
|
description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
console.error(e);
|
|
setConnectionStatus("error");
|
|
}
|
|
};
|
|
|
|
const disconnect = async () => {
|
|
if (transportType === "streamable-http")
|
|
await (
|
|
clientTransport as StreamableHTTPClientTransport
|
|
).terminateSession();
|
|
await mcpClient?.close();
|
|
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
|
authProvider.clear();
|
|
setMcpClient(null);
|
|
setClientTransport(null);
|
|
setConnectionStatus("disconnected");
|
|
setCompletionsSupported(false);
|
|
setServerCapabilities(null);
|
|
};
|
|
|
|
return {
|
|
connectionStatus,
|
|
serverCapabilities,
|
|
mcpClient,
|
|
requestHistory,
|
|
makeRequest,
|
|
sendNotification,
|
|
handleCompletion,
|
|
completionsSupported,
|
|
connect,
|
|
disconnect,
|
|
};
|
|
}
|