diff --git a/.gitignore b/.gitignore index 59ecaa4fc..d7b755424 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ lib/ !05-blueprints/**/lib/ !02-use-cases/visa-b2b-account-payable-agent/infrastructure/lib/ !04-infrastructure-as-code/cdk/typescript/knowledge-base-rag-agent/infrastructure/lib/ +!01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/ !06-workshops/01-AgentCore-runtime/01-hosting-agent/05-java-agents/01-springai-with-bedrock-model/infra/lib/ !06-workshops/01-AgentCore-runtime/01-hosting-agent/05-java-agents/02-embabel-with-bedrock-model/infra/lib/ lib64/ diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/.gitignore b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/.gitignore index 448724a92..ff125113a 100644 --- a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/.gitignore +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/.gitignore @@ -14,8 +14,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/components/GatewaySelector.tsx b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/components/GatewaySelector.tsx index 70748b4d0..b793bd1d6 100644 --- a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/components/GatewaySelector.tsx +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/components/GatewaySelector.tsx @@ -70,7 +70,10 @@ const GatewaySelector = ({ ); const data = await response.json(); if (data.gatewayUrl) { - onSelect(gw.gatewayId, data.gatewayUrl); + const url = data.gatewayUrl.endsWith("/mcp") + ? data.gatewayUrl + : `${data.gatewayUrl.replace(/\/$/, "")}/mcp`; + onSelect(gw.gatewayId, url); } else { setError(data.error || `Could not resolve URL for ${gw.name}`); } diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/auth-types.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/auth-types.ts new file mode 100644 index 000000000..aaa834286 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/auth-types.ts @@ -0,0 +1,59 @@ +import { + OAuthMetadata, + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// OAuth flow steps +export type OAuthStep = + | "metadata_discovery" + | "client_registration" + | "authorization_redirect" + | "authorization_code" + | "token_request" + | "complete"; + +// Message types for inline feedback +export type MessageType = "success" | "error" | "info"; + +export interface StatusMessage { + type: MessageType; + message: string; +} + +// Single state interface for OAuth state +export interface AuthDebuggerState { + isInitiatingAuth: boolean; + oauthTokens: OAuthTokens | null; + oauthStep: OAuthStep; + resourceMetadata: OAuthProtectedResourceMetadata | null; + resourceMetadataError: Error | null; + resource: URL | null; + authServerUrl: URL | null; + oauthMetadata: OAuthMetadata | null; + oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; + authorizationUrl: URL | null; + authorizationCode: string; + latestError: Error | null; + statusMessage: StatusMessage | null; + validationError: string | null; +} + +export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { + isInitiatingAuth: false, + oauthTokens: null, + oauthStep: "metadata_discovery", + oauthMetadata: null, + resourceMetadata: null, + resourceMetadataError: null, + resource: null, + authServerUrl: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, +}; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/auth.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/auth.ts new file mode 100644 index 000000000..879936104 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/auth.ts @@ -0,0 +1,296 @@ +import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + OAuthClientInformationSchema, + OAuthClientInformation, + OAuthTokens, + OAuthTokensSchema, + OAuthClientMetadata, + OAuthMetadata, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "./constants"; +import { generateOAuthState } from "@/utils/oauthUtils"; +import { validateRedirectUrl } from "@/utils/urlValidation"; + +/** + * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes + * @param serverUrl - The MCP server URL + * @param resourceMetadata - Optional resource metadata containing preferred scopes + * @returns Promise resolving to space-separated scope string or undefined + */ +export const discoverScopes = async ( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, +): Promise => { + try { + const metadata = await discoverAuthorizationServerMetadata( + new URL("/", serverUrl), + ); + + // Prefer resource metadata scopes, but fall back to OAuth metadata if empty + const resourceScopes = resourceMetadata?.scopes_supported; + const oauthScopes = metadata?.scopes_supported; + + const scopesSupported = + resourceScopes && resourceScopes.length > 0 + ? resourceScopes + : oauthScopes; + + return scopesSupported && scopesSupported.length > 0 + ? scopesSupported.join(" ") + : undefined; + } catch (error) { + console.debug("OAuth scope discovery failed:", error); + return undefined; + } +}; + +export const getClientInformationFromSessionStorage = async ({ + serverUrl, + isPreregistered, +}: { + serverUrl: string; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + + const value = sessionStorage.getItem(key); + if (!value) { + return undefined; + } + + return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); +}; + +export const saveClientInformationToSessionStorage = ({ + serverUrl, + clientInformation, + isPreregistered, +}: { + serverUrl: string; + clientInformation: OAuthClientInformation; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(clientInformation)); +}; + +export const clearClientInformationFromSessionStorage = ({ + serverUrl, + isPreregistered, +}: { + serverUrl: string; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + sessionStorage.removeItem(key); +}; + +export const getScopeFromSessionStorage = ( + serverUrl: string, +): string | undefined => { + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); + const value = sessionStorage.getItem(key); + return value || undefined; +}; + +export const saveScopeToSessionStorage = ( + serverUrl: string, + scope: string | undefined, +) => { + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); + if (scope) { + sessionStorage.setItem(key, scope); + } else { + sessionStorage.removeItem(key); + } +}; + +export const clearScopeFromSessionStorage = (serverUrl: string) => { + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); + sessionStorage.removeItem(key); +}; + +export class InspectorOAuthClientProvider implements OAuthClientProvider { + constructor(protected serverUrl: string) { + // Save the server URL to session storage + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); + } + + get scope(): string | undefined { + return getScopeFromSessionStorage(this.serverUrl); + } + + get redirectUrl() { + return window.location.origin + "/oauth/callback"; + } + + get debugRedirectUrl() { + return window.location.origin + "/oauth/callback/debug"; + } + + get redirect_uris() { + // Normally register both redirect URIs to support both normal and debug flows + // In debug subclass, redirectUrl may be the same as debugRedirectUrl, so remove duplicates + // See: https://github.com/modelcontextprotocol/inspector/issues/825 + return [...new Set([this.redirectUrl, this.debugRedirectUrl])]; + } + + get clientMetadata(): OAuthClientMetadata { + const metadata: OAuthClientMetadata = { + redirect_uris: this.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector", + client_uri: "https://github.com/modelcontextprotocol/inspector", + }; + + // Only include scope if it's defined and non-empty + // Per OAuth spec, omit the scope field entirely if no scopes are requested + if (this.scope) { + metadata.scope = this.scope; + } + + return metadata; + } + + state(): string | Promise { + return generateOAuthState(); + } + + async clientInformation() { + // Try to get the preregistered client information from session storage first + const preregisteredClientInformation = + await getClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: true, + }); + + // If no preregistered client information is found, get the dynamically registered client information + return ( + preregisteredClientInformation ?? + (await getClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: false, + })) + ); + } + + saveClientInformation(clientInformation: OAuthClientInformation) { + // Save the dynamically registered client information to session storage + saveClientInformationToSessionStorage({ + serverUrl: this.serverUrl, + clientInformation, + isPreregistered: false, + }); + } + + async tokens() { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); + const tokens = sessionStorage.getItem(key); + if (!tokens) { + return undefined; + } + + return await OAuthTokensSchema.parseAsync(JSON.parse(tokens)); + } + + saveTokens(tokens: OAuthTokens) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); + sessionStorage.setItem(key, JSON.stringify(tokens)); + } + + redirectToAuthorization(authorizationUrl: URL) { + // Validate the URL using the shared utility + validateRedirectUrl(authorizationUrl.href); + window.location.href = authorizationUrl.href; + } + + saveCodeVerifier(codeVerifier: string) { + const key = getServerSpecificKey( + SESSION_KEYS.CODE_VERIFIER, + this.serverUrl, + ); + sessionStorage.setItem(key, codeVerifier); + } + + codeVerifier() { + const key = getServerSpecificKey( + SESSION_KEYS.CODE_VERIFIER, + this.serverUrl, + ); + const verifier = sessionStorage.getItem(key); + if (!verifier) { + throw new Error("No code verifier saved for session"); + } + + return verifier; + } + + clear() { + clearClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: false, + }); + sessionStorage.removeItem( + getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), + ); + sessionStorage.removeItem( + getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl), + ); + } +} + +// 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 { + // 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) { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(metadata)); + } + + getServerMetadata(): OAuthMetadata | null { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + const metadata = sessionStorage.getItem(key); + if (!metadata) { + return null; + } + return JSON.parse(metadata); + } + + clear() { + super.clear(); + sessionStorage.removeItem( + getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl), + ); + } +} diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/configurationTypes.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/configurationTypes.ts new file mode 100644 index 000000000..60a993564 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/configurationTypes.ts @@ -0,0 +1,48 @@ +export type ConfigItem = { + label: string; + description: string; + value: string | number | boolean; + is_session_item: boolean; +}; + +/** + * Configuration interface for the MCP Inspector, including settings for the MCP Client, + * Proxy Server, and Inspector UI/UX. + * + * Note: Configuration related to which MCP Server to use or any other MCP Server + * specific settings are outside the scope of this interface as of now. + */ +export type InspectorConfig = { + /** + * Client-side timeout in milliseconds. The Inspector will cancel the request if no response + * is received within this time. Note: This is independent of any server-side timeouts. + */ + MCP_SERVER_REQUEST_TIMEOUT: ConfigItem; + + /** + * Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates. + * Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow + */ + MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem; + + /** + * Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS. + * Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow + */ + MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem; + + /** + * The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577 + */ + MCP_PROXY_FULL_ADDRESS: ConfigItem; + + /** + * Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup. + */ + MCP_PROXY_AUTH_TOKEN: ConfigItem; + + /** + * Default Time-to-Live (TTL) in milliseconds for newly created tasks. + */ + MCP_TASK_TTL: ConfigItem; +}; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/constants.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/constants.ts new file mode 100644 index 000000000..6cb1a02cc --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/constants.ts @@ -0,0 +1,86 @@ +import { InspectorConfig } from "./configurationTypes"; +import packageJson from "../../package.json"; + +// Client identity for MCP connections +export const CLIENT_IDENTITY = (() => { + const [, name = packageJson.name] = packageJson.name.split("/"); + const version = packageJson.version; + return { name, version }; +})(); + +// OAuth-related session storage keys +export const SESSION_KEYS = { + CODE_VERIFIER: "mcp_code_verifier", + SERVER_URL: "mcp_server_url", + TOKENS: "mcp_tokens", + CLIENT_INFORMATION: "mcp_client_information", + PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", + SERVER_METADATA: "mcp_server_metadata", + AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", + SCOPE: "mcp_scope", +} as const; + +// Generate server-specific session storage keys +export const getServerSpecificKey = ( + baseKey: string, + serverUrl?: string, +): string => { + if (!serverUrl) return baseKey; + return `[${serverUrl}] ${baseKey}`; +}; + +export type ConnectionStatus = + | "disconnected" + | "connected" + | "error" + | "error-connecting-to-proxy"; + +export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; + +/** + * Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser. + * Future plans: Provide json config file + Browser local_storage to override default values + **/ +export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = { + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: + "Client-side timeout (ms) - Inspector will cancel requests after this time", + value: 300000, // 5 minutes - increased to support elicitation and other long-running tools + 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: "Maximum Total Timeout", + description: + "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", + value: 60000, + is_session_item: false, + }, + MCP_PROXY_FULL_ADDRESS: { + label: "Inspector Proxy Address", + description: + "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", + value: "", + is_session_item: false, + }, + MCP_PROXY_AUTH_TOKEN: { + label: "Proxy Session Token", + description: + "Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)", + value: "", + is_session_item: true, + }, + MCP_TASK_TTL: { + label: "Task TTL", + description: + "Default Time-to-Live (TTL) in milliseconds for newly created tasks", + value: 60000, + is_session_item: false, + }, +} as const; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useCompletionState.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useCompletionState.ts new file mode 100644 index 000000000..26b69a276 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useCompletionState.ts @@ -0,0 +1,137 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import { + ResourceReference, + PromptReference, +} from "@modelcontextprotocol/sdk/types.js"; + +interface CompletionState { + completions: Record; + loading: Record; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function debounce PromiseLike>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeout: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + void func(...args); + }, wait); + }; +} + +export function useCompletionState( + handleCompletion: ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + context?: Record, + signal?: AbortSignal, + ) => Promise, + completionsSupported: boolean = true, + debounceMs: number = 300, +) { + const [state, setState] = useState({ + completions: {}, + loading: {}, + }); + + const abortControllerRef = useRef(null); + + const cleanup = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return cleanup; + }, [cleanup]); + + const clearCompletions = useCallback(() => { + cleanup(); + setState({ + completions: {}, + loading: {}, + }); + }, [cleanup]); + + const requestCompletions = useMemo(() => { + return debounce( + async ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + context?: Record, + ) => { + if (!completionsSupported) { + return; + } + + cleanup(); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: true }, + })); + + try { + if (context !== undefined) { + delete context[argName]; + } + + const values = await handleCompletion( + ref, + argName, + value, + context, + abortController.signal, + ); + + if (!abortController.signal.aborted) { + setState((prev) => ({ + ...prev, + completions: { ...prev.completions, [argName]: values }, + loading: { ...prev.loading, [argName]: false }, + })); + } + } catch { + console.error("completion failed"); + if (!abortController.signal.aborted) { + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: false }, + })); + } + } finally { + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + } + }, + debounceMs, + ); + }, [handleCompletion, completionsSupported, cleanup, debounceMs]); + + // Clear completions when support status changes + useEffect(() => { + if (!completionsSupported) { + clearCompletions(); + } + }, [completionsSupported, clearCompletions]); + + return { + ...state, + clearCompletions, + requestCompletions, + completionsSupported, + }; +} diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useConnection.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useConnection.ts new file mode 100644 index 000000000..d1fd2fd7f --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useConnection.ts @@ -0,0 +1,1233 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + SSEClientTransport, + SseError, + SSEClientTransportOptions, +} from "@modelcontextprotocol/sdk/client/sse.js"; +import { + StreamableHTTPClientTransport, + StreamableHTTPClientTransportOptions, + StreamableHTTPError, +} from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + ClientNotification, + ClientRequest, + ClientResult, + CreateMessageRequestSchema, + ListRootsRequestSchema, + ResourceUpdatedNotificationSchema, + LoggingMessageNotificationSchema, + Request, + Result, + ServerCapabilities, + PromptReference, + ResourceReference, + McpError, + CompleteResultSchema, + ErrorCode, + CancelledNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + Progress, + LoggingLevel, + ElicitRequestSchema, + Implementation, + Task, + CreateTaskResultSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema, + ListTasksResultSchema, + CancelTaskResultSchema, + TaskStatusNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + AnySchema, + SchemaOutput, +} from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { useEffect, useRef, useState } from "react"; +import { useToast } from "@/lib/hooks/useToast"; +import { ConnectionStatus, CLIENT_IDENTITY } from "../constants"; +import { Notification } from "../notificationTypes"; +import { + auth, + discoverOAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { + clearClientInformationFromSessionStorage, + InspectorOAuthClientProvider, + saveClientInformationToSessionStorage, + saveScopeToSessionStorage, + clearScopeFromSessionStorage, + discoverScopes, +} from "../auth"; +import { + getMCPProxyAddress, + getMCPTaskTtl, + getMCPServerRequestMaxTotalTimeout, + resetRequestTimeoutOnProgress, + getMCPProxyAuthToken, +} from "@/utils/configUtils"; +import { getMCPServerRequestTimeout } from "@/utils/configUtils"; +import { InspectorConfig } from "../configurationTypes"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { CustomHeaders } from "../types/customHeaders"; +import { resolveRefsInMessage } from "@/utils/schemaUtils"; + +interface UseConnectionOptions { + transportType: "stdio" | "sse" | "streamable-http"; + command: string; + args: string; + sseUrl: string; + env: Record; + // Custom headers support + customHeaders?: CustomHeaders; + oauthClientId?: string; + oauthClientSecret?: string; + oauthScope?: string; + config: InspectorConfig; + connectionType?: "direct" | "proxy"; + authMode?: string; + 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; + serverImplementation?: Implementation; + metadata?: Record; +} + +export function useConnection({ + transportType, + command, + args, + sseUrl, + env, + customHeaders, + oauthClientId, + oauthClientSecret, + oauthScope, + config, + connectionType = "proxy", + authMode, + onNotification, + onPendingRequest, + onElicitationRequest, + getRoots, + defaultLoggingLevel, + metadata = {}, +}: UseConnectionOptions) { + const [connectionStatus, setConnectionStatus] = + useState("disconnected"); + const { toast } = useToast(); + const [serverCapabilities, setServerCapabilities] = + useState(null); + const [mcpClient, setMcpClient] = useState(null); + const [clientTransport, setClientTransport] = useState( + null, + ); + const [requestHistory, setRequestHistory] = useState< + { request: string; response?: string }[] + >([]); + const [completionsSupported, setCompletionsSupported] = useState(false); + const [mcpSessionId, setMcpSessionId] = useState(null); + const [mcpProtocolVersion, setMcpProtocolVersion] = useState( + null, + ); + const [serverImplementation, setServerImplementation] = + useState(null); + + type ReceiverTaskRecord = { + task: Task; + payloadPromise: Promise; + resolvePayload: (payload: ClientResult) => void; + rejectPayload: (reason?: unknown) => void; + cleanupTimeoutId?: ReturnType; + }; + + // Tasks created locally in response to *incoming* task-augmented requests + // (e.g. `sampling/createMessage` and `elicitation/create` with `params.task`). + const receiverTasksRef = useRef>(new Map()); + + useEffect(() => { + if (!oauthClientId) { + clearClientInformationFromSessionStorage({ + serverUrl: sseUrl, + isPreregistered: true, + }); + return; + } + + const clientInformation: { client_id: string; client_secret?: string } = { + client_id: oauthClientId, + }; + + if (oauthClientSecret) { + clientInformation.client_secret = oauthClientSecret; + } + + saveClientInformationToSessionStorage({ + serverUrl: sseUrl, + clientInformation, + isPreregistered: true, + }); + }, [oauthClientId, oauthClientSecret, sseUrl]); + + useEffect(() => { + if (!oauthScope) { + clearScopeFromSessionStorage(sseUrl); + return; + } + + saveScopeToSessionStorage(sseUrl, oauthScope); + }, [oauthScope, sseUrl]); + + const pushHistory = (request: object, response?: object) => { + setRequestHistory((prev) => [ + ...prev, + { + request: JSON.stringify(request), + response: response !== undefined ? JSON.stringify(response) : undefined, + }, + ]); + }; + + const makeRequest = async ( + request: ClientRequest, + schema: T, + options?: RequestOptions & { suppressToast?: boolean }, + ): Promise> => { + if (!mcpClient) { + throw new Error("MCP client not connected"); + } + try { + const abortController = new AbortController(); + + // Add metadata to the request if available, but skip for tool calls + // as they handle metadata merging separately + const shouldAddGeneralMetadata = + request.method !== "tools/call" && Object.keys(metadata).length > 0; + const requestWithMetadata = shouldAddGeneralMetadata + ? { + ...request, + params: { + ...request.params, + _meta: metadata, + }, + } + : request; + + // 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: "notifications/progress", + params, + }); + } + }; + } + + let response; + try { + response = await mcpClient.request( + requestWithMetadata, + schema, + mcpRequestOptions, + ); + + pushHistory(requestWithMetadata, response); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + pushHistory(requestWithMetadata, { 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, + signal?: AbortSignal, + ): Promise => { + 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 StreamableHTTPError && error.code === 401) || + (error instanceof Error && error.message.includes("401")) || + (error instanceof Error && error.message.includes("Unauthorized")) || + (error instanceof Error && + error.message.includes("Missing Authorization header")) + ); + }; + + 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)) { + 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); + } + + saveScopeToSessionStorage(sseUrl, scope); + const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + + try { + const result = await auth(serverAuthProvider, { + serverUrl: sseUrl, + scope, + }); + return result === "AUTHORIZED"; + } catch (authError) { + // Show user-friendly error message for OAuth failures + toast({ + title: "OAuth Authentication Failed", + description: + authError instanceof Error ? authError.message : String(authError), + variant: "destructive", + }); + return false; + } + } + + 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: { + sampling: {}, + elicitation: { form: {}, url: {} }, + roots: { + listChanged: true, + }, + tasks: { + list: {}, + cancel: {}, + ...(onPendingRequest || onElicitationRequest + ? { + requests: { + ...(onPendingRequest + ? { sampling: { createMessage: {} } } + : undefined), + ...(onElicitationRequest + ? { elicitation: { create: {} } } + : undefined), + }, + } + : undefined), + }, + }, + }; + + const client = new Client( + CLIENT_IDENTITY, + clientCapabilities, + ); + + // Only check proxy health for proxy connections + if (connectionType === "proxy") { + 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 custom headers (migration is handled in App.tsx) + let finalHeaders: CustomHeaders = customHeaders || []; + + const isEmptyAuthHeader = (header: CustomHeaders[number]) => + header.name.trim().toLowerCase() === "authorization" && + header.value.trim().toLowerCase() === "bearer"; + + // IAM mode: strip Authorization headers — SigV4 signing is handled server-side + if (authMode === "iam") { + finalHeaders = finalHeaders.filter( + (header) => header.name.trim().toLowerCase() !== "authorization", + ); + } else { + // Check for empty Authorization headers and show validation error + const hasEmptyAuthHeader = finalHeaders.some( + (header) => header.enabled && isEmptyAuthHeader(header), + ); + + if (hasEmptyAuthHeader) { + toast({ + title: "Invalid Authorization Header", + description: + "Authorization header is enabled but empty. Please add a token or disable the header.", + variant: "destructive", + }); + } + + const needsOAuthToken = !finalHeaders.some( + (header) => + header.enabled && + header.name.trim().toLowerCase() === "authorization", + ); + + if (needsOAuthToken) { + const oauthToken = (await serverAuthProvider.tokens())?.access_token; + if (oauthToken) { + // Add the OAuth token + finalHeaders = [ + // Remove any existing Authorization headers with empty tokens + ...finalHeaders.filter((header) => !isEmptyAuthHeader(header)), + { + 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); + } + + // Create appropriate transport + let transportOptions: + | StreamableHTTPClientTransportOptions + | SSEClientTransportOptions; + + let serverUrl: URL; + + // 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); + + 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 = { + authProvider: serverAuthProvider, + fetch: async ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => { + const response = await fetch(url, { + ...init, + headers: requestHeaders, + }); + + // Capture protocol-related headers from response + captureResponseHeaders(response); + return response; + }, + requestInit: { + headers: requestHeaders, + }, + }; + break; + + case "streamable-http": + transportOptions = { + authProvider: serverAuthProvider, + 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 = { + authProvider: serverAuthProvider, + 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 = { + authProvider: serverAuthProvider, + 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); + if (authMode) { + serverUrl.searchParams.append("authMode", authMode); + } + } + + if (onNotification) { + [ + CancelledNotificationSchema, + LoggingMessageNotificationSchema, + ResourceUpdatedNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + TaskStatusNotificationSchema, + ].forEach((notificationSchema) => { + client.setNotificationHandler(notificationSchema, onNotification); + }); + + client.fallbackNotificationHandler = ( + notification: Notification, + ): Promise => { + onNotification(notification); + return Promise.resolve(); + }; + } + + let capabilities; + try { + const transport = + transportType === "streamable-http" + ? new StreamableHTTPClientTransport(serverUrl, { + sessionId: undefined, + ...transportOptions, + }) + : new SSEClientTransport(serverUrl, transportOptions); + + await client.connect(transport as Transport); + + const protocolOnMessage = transport.onmessage; + if (protocolOnMessage) { + transport.onmessage = (message) => { + const resolvedMessage = resolveRefsInMessage(message); + protocolOnMessage(resolvedMessage); + }; + } + + setClientTransport(transport); + + capabilities = client.getServerCapabilities(); + const serverInfo = client.getServerVersion(); + setServerImplementation(serverInfo || null); + const initializeRequest = { + method: "initialize", + }; + pushHistory(initializeRequest, { + capabilities, + serverInfo: client.getServerVersion(), + instructions: client.getInstructions(), + }); + } catch (error) { + console.error( + connectionType === "direct" + ? `Failed to connect directly to MCP Server at: ${serverUrl}:` + : `Failed to connect to MCP Server via the MCP Inspector Proxy: ${serverUrl}:`, + 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); + + const nowIso = () => new Date().toISOString(); + + const makeTaskId = () => { + // Prefer UUID when available; otherwise fall back to a reasonably unique id. + const cryptoAny = globalThis.crypto as unknown as + | { randomUUID?: () => string } + | undefined; + return ( + cryptoAny?.randomUUID?.() ?? + `task_${Date.now()}_${Math.random().toString(16).slice(2)}` + ); + }; + + const emitTaskStatus = async (task: Task) => { + // Best-effort; task status notifications are optional. + try { + const notification: ClientNotification = { + method: "notifications/tasks/status", + params: task, + } as unknown as ClientNotification; + await client.notification(notification); + pushHistory(notification); + } catch (e) { + console.warn("Failed to send notifications/tasks/status", e); + } + }; + + const upsertReceiverTask = async (task: Task) => { + // Update task record and emit status notification. + const record = receiverTasksRef.current.get(task.taskId); + if (record) { + receiverTasksRef.current.set(task.taskId, { ...record, task }); + } + await emitTaskStatus(task); + }; + + const createReceiverTask = (opts: { + ttl?: number; + initialStatus: Task["status"]; + statusMessage?: string; + pollInterval?: number; + }): ReceiverTaskRecord => { + const taskId = makeTaskId(); + const createdAt = nowIso(); + const ttl = opts.ttl ?? getMCPTaskTtl(config); + + let resolvePayload: (payload: ClientResult) => void = () => undefined; + let rejectPayload: (reason?: unknown) => void = () => undefined; + const payloadPromise = new Promise((resolve, reject) => { + resolvePayload = resolve; + rejectPayload = reject; + }); + + const task: Task = { + taskId, + status: opts.initialStatus, + ttl, + createdAt, + lastUpdatedAt: createdAt, + ...(opts.pollInterval !== undefined + ? { pollInterval: opts.pollInterval } + : undefined), + ...(opts.statusMessage ? { statusMessage: opts.statusMessage } : {}), + }; + + const record: ReceiverTaskRecord = { + task, + payloadPromise, + resolvePayload, + rejectPayload, + }; + + // Cleanup after TTL (best-effort). + if (ttl !== null && ttl > 0) { + record.cleanupTimeoutId = setTimeout(() => { + receiverTasksRef.current.delete(taskId); + }, ttl); + } + + receiverTasksRef.current.set(taskId, record); + void emitTaskStatus(task); + return record; + }; + + // Server -> client Tasks handlers (receiver side) + client.setRequestHandler(ListTasksRequestSchema, async () => { + return { + tasks: Array.from(receiverTasksRef.current.values()).map( + (r) => r.task, + ), + }; + }); + + client.setRequestHandler(GetTaskRequestSchema, async (request) => { + const record = receiverTasksRef.current.get(request.params.taskId); + if (!record) { + throw new McpError( + ErrorCode.InvalidParams, + `Unknown taskId: ${request.params.taskId}`, + ); + } + return record.task; + }); + + client.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => { + const record = receiverTasksRef.current.get(request.params.taskId); + if (!record) { + throw new McpError( + ErrorCode.InvalidParams, + `Unknown taskId: ${request.params.taskId}`, + ); + } + + // Block until the task payload is ready. + return await record.payloadPromise; + }); + + client.setRequestHandler(CancelTaskRequestSchema, async (request) => { + const record = receiverTasksRef.current.get(request.params.taskId); + if (!record) { + throw new McpError( + ErrorCode.InvalidParams, + `Unknown taskId: ${request.params.taskId}`, + ); + } + + const terminalStatuses: Task["status"][] = [ + "completed", + "failed", + "cancelled", + ]; + + if (!terminalStatuses.includes(record.task.status)) { + const updated: Task = { + ...record.task, + status: "cancelled", + lastUpdatedAt: nowIso(), + statusMessage: "Cancelled", + }; + receiverTasksRef.current.set(request.params.taskId, { + ...record, + task: updated, + }); + + // Unblock any pending `tasks/result`. + record.rejectPayload( + new McpError(ErrorCode.InternalError, "Task was cancelled"), + ); + + await emitTaskStatus(updated); + } + + return receiverTasksRef.current.get(request.params.taskId)!.task; + }); + + if (onPendingRequest) { + client.setRequestHandler(CreateMessageRequestSchema, (request) => { + const taskSpec = (request as { params?: { task?: { ttl?: number } } }) + .params?.task; + + if (!taskSpec) { + return new Promise((resolve, reject) => { + onPendingRequest(request, resolve, reject); + }); + } + + // Task-augmented sampling request: return a task immediately and + // allow the server to poll via `tasks/get` and `tasks/result`. + const record = createReceiverTask({ + ttl: taskSpec.ttl, + initialStatus: "input_required", + statusMessage: "Awaiting user input", + }); + + // Background runner to complete and resolve this specific task record. + void (async () => { + try { + const payload = await new Promise((resolve, reject) => { + onPendingRequest(request, resolve, reject); + }); + record.resolvePayload(payload as ClientResult); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: nowIso(), + }; + receiverTasksRef.current.set(record.task.taskId, { + ...record, + task: updated, + }); + await upsertReceiverTask(updated); + } catch (e) { + record.rejectPayload(e); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: nowIso(), + statusMessage: e instanceof Error ? e.message : "Task failed", + }; + receiverTasksRef.current.set(record.task.taskId, { + ...record, + task: updated, + }); + await upsertReceiverTask(updated); + } + })(); + + const createTaskResult: SchemaOutput = + { + task: record.task, + }; + return createTaskResult; + }); + } + + if (getRoots) { + client.setRequestHandler(ListRootsRequestSchema, async () => { + return { roots: getRoots() }; + }); + } + + if (onElicitationRequest) { + client.setRequestHandler(ElicitRequestSchema, (request) => { + const taskSpec = (request as { params?: { task?: { ttl?: number } } }) + .params?.task; + + if (!taskSpec) { + return new Promise((resolve) => { + onElicitationRequest(request, resolve); + }); + } + + const record = createReceiverTask({ + ttl: taskSpec.ttl, + initialStatus: "input_required", + statusMessage: "Awaiting user input", + }); + + // Run elicitation flow and resolve the task payload. + void (async () => { + try { + const payload = await new Promise((resolve) => { + onElicitationRequest(request, resolve); + }); + record.resolvePayload(payload as ClientResult); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: nowIso(), + }; + receiverTasksRef.current.set(record.task.taskId, { + ...record, + task: updated, + }); + await upsertReceiverTask(updated); + } catch (e) { + record.rejectPayload(e); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: nowIso(), + statusMessage: e instanceof Error ? e.message : "Task failed", + }; + receiverTasksRef.current.set(record.task.taskId, { + ...record, + task: updated, + }); + await upsertReceiverTask(updated); + } + })(); + + const createTaskResult: SchemaOutput = + { + task: record.task, + }; + return createTaskResult; + }); + } + + if (capabilities?.logging && defaultLoggingLevel) { + lastRequest = "logging/setLevel"; + await client.setLoggingLevel(defaultLoggingLevel); + pushHistory( + { + method: "logging/setLevel", + params: { + level: defaultLoggingLevel, + }, + }, + {}, + ); + lastRequest = ""; + } + + 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", + }); + } else { + toast({ + title: "Connection error", + description: `Connection failed: "${e}"`, + variant: "destructive", + }); + } + console.error(e); + setConnectionStatus("error"); + } + }; + + const cancelTask = async (taskId: string) => { + return makeRequest( + { + method: "tasks/cancel", + params: { taskId }, + }, + CancelTaskResultSchema, + ); + }; + + const listTasks = async (cursor?: string) => { + return makeRequest( + { + method: "tasks/list", + params: { cursor }, + }, + ListTasksResultSchema, + ); + }; + + const disconnect = async () => { + // Clear any receiver-side tasks + cleanup timers + receiverTasksRef.current.forEach((record) => { + if (record.cleanupTimeoutId) { + clearTimeout(record.cleanupTimeoutId); + } + }); + receiverTasksRef.current.clear(); + + 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); + setMcpSessionId(null); + setMcpProtocolVersion(null); + }; + + const clearRequestHistory = () => { + setRequestHistory([]); + setServerImplementation(null); + }; + + return { + connectionStatus, + serverCapabilities, + serverImplementation, + mcpClient, + requestHistory, + clearRequestHistory, + makeRequest, + cancelTask, + listTasks, + sendNotification, + handleCompletion, + completionsSupported, + connect, + disconnect, + }; +} diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useCopy.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useCopy.ts new file mode 100644 index 000000000..148d603da --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useCopy.ts @@ -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, timeout]); + + return { copied, setCopied }; +} + +export default useCopy; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useDraggablePane.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useDraggablePane.ts new file mode 100644 index 000000000..4ee5af541 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useDraggablePane.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useDraggablePane(initialHeight: number) { + const [height, setHeight] = useState(initialHeight); + const [isDragging, setIsDragging] = useState(false); + const dragStartY = useRef(0); + const dragStartHeight = useRef(0); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + setIsDragging(true); + dragStartY.current = e.clientY; + dragStartHeight.current = height; + document.body.style.userSelect = "none"; + }, + [height], + ); + + const handleDragMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + const deltaY = dragStartY.current - e.clientY; + const newHeight = Math.max( + 100, + Math.min(800, dragStartHeight.current + deltaY), + ); + setHeight(newHeight); + }, + [isDragging], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + return () => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + }; + } + }, [isDragging, handleDragMove, handleDragEnd]); + + return { + height, + isDragging, + handleDragStart, + }; +} + +export function useDraggableSidebar(initialWidth: number) { + const [width, setWidth] = useState(initialWidth); + const [isDragging, setIsDragging] = useState(false); + const dragStartX = useRef(0); + const dragStartWidth = useRef(0); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + setIsDragging(true); + dragStartX.current = e.clientX; + dragStartWidth.current = width; + document.body.style.userSelect = "none"; + }, + [width], + ); + + const handleDragMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + const deltaX = e.clientX - dragStartX.current; + const newWidth = Math.max( + 200, + Math.min(600, dragStartWidth.current + deltaX), + ); + setWidth(newWidth); + }, + [isDragging], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + return () => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + }; + } + }, [isDragging, handleDragMove, handleDragEnd]); + + return { + width, + isDragging, + handleDragStart, + }; +} diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useTheme.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useTheme.ts new file mode 100644 index 000000000..bd0d11296 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useTheme.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +type Theme = "light" | "dark" | "system"; + +const useTheme = (): [Theme, (mode: Theme) => void] => { + const [theme, setTheme] = useState(() => { + const savedTheme = localStorage.getItem("theme") as Theme; + return savedTheme || "system"; + }); + + useEffect(() => { + const darkModeMediaQuery = window.matchMedia( + "(prefers-color-scheme: dark)", + ); + const handleDarkModeChange = (e: MediaQueryListEvent) => { + if (theme === "system") { + updateDocumentTheme(e.matches ? "dark" : "light"); + } + }; + + const updateDocumentTheme = (newTheme: "light" | "dark") => { + document.documentElement.classList.toggle("dark", newTheme === "dark"); + }; + + // Set initial theme based on current mode + if (theme === "system") { + updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light"); + } else { + updateDocumentTheme(theme); + } + + darkModeMediaQuery.addEventListener("change", handleDarkModeChange); + + return () => { + darkModeMediaQuery.removeEventListener("change", handleDarkModeChange); + }; + }, [theme]); + + const setThemeWithSideEffect = useCallback((newTheme: Theme) => { + setTheme(newTheme); + localStorage.setItem("theme", newTheme); + if (newTheme !== "system") { + document.documentElement.classList.toggle("dark", newTheme === "dark"); + } + }, []); + return useMemo( + () => [theme, setThemeWithSideEffect], + [theme, setThemeWithSideEffect], + ); +}; + +export default useTheme; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useToast.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useToast.ts new file mode 100644 index 000000000..b70565545 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/hooks/useToast.ts @@ -0,0 +1,191 @@ +"use client"; + +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +type ToasterToast = ToastProps & { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +let count = 0; + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +} + +const enum ActionType { + ADD_TOAST = "ADD_TOAST", + UPDATE_TOAST = "UPDATE_TOAST", + DISMISS_TOAST = "DISMISS_TOAST", + REMOVE_TOAST = "REMOVE_TOAST", +} + +type Action = + | { + type: ActionType.ADD_TOAST; + toast: ToasterToast; + } + | { + type: ActionType.UPDATE_TOAST; + toast: Partial; + } + | { + type: ActionType.DISMISS_TOAST; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType.REMOVE_TOAST; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: ActionType.REMOVE_TOAST, + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case ActionType.ADD_TOAST: + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case ActionType.UPDATE_TOAST: + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t, + ), + }; + + case ActionType.DISMISS_TOAST: { + const { toastId } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case ActionType.REMOVE_TOAST: + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: ActionType.UPDATE_TOAST, + toast: { ...props, id }, + }); + const dismiss = () => + dispatch({ type: ActionType.DISMISS_TOAST, toastId: id }); + + dispatch({ + type: ActionType.ADD_TOAST, + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => + dispatch({ type: ActionType.DISMISS_TOAST, toastId }), + }; +} + +export { useToast, toast }; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/notificationTypes.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/notificationTypes.ts new file mode 100644 index 000000000..e7cc1cb56 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/notificationTypes.ts @@ -0,0 +1,12 @@ +import { + NotificationSchema as BaseNotificationSchema, + ClientNotificationSchema, + ServerNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import type { SchemaOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js"; + +export const NotificationSchema = ClientNotificationSchema.or( + ServerNotificationSchema, +).or(BaseNotificationSchema); + +export type Notification = SchemaOutput; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/oauth-state-machine.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/oauth-state-machine.ts new file mode 100644 index 000000000..8dc9da8f9 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/oauth-state-machine.ts @@ -0,0 +1,232 @@ +import { OAuthStep, AuthDebuggerState } from "./auth-types"; +import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; +import { + discoverAuthorizationServerMetadata, + registerClient, + startAuthorization, + exchangeAuthorization, + discoverOAuthProtectedResourceMetadata, + selectResourceURL, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { + OAuthMetadataSchema, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { generateOAuthState } from "@/utils/oauthUtils"; + +export interface StateMachineContext { + state: AuthDebuggerState; + serverUrl: string; + provider: DebugInspectorOAuthClientProvider; + updateState: (updates: Partial) => void; +} + +export interface StateTransition { + canTransition: (context: StateMachineContext) => Promise; + execute: (context: StateMachineContext) => Promise; +} + +// State machine transitions +export const oauthTransitions: Record = { + metadata_discovery: { + canTransition: async () => true, + execute: async (context) => { + // Default to discovering from the server's URL + let authServerUrl = new URL("/", context.serverUrl); + let resourceMetadata: OAuthProtectedResourceMetadata | null = null; + let resourceMetadataError: Error | null = null; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + context.serverUrl, + ); + if (resourceMetadata?.authorization_servers?.length) { + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); + } + } catch (e) { + if (e instanceof Error) { + resourceMetadataError = e; + } else { + resourceMetadataError = new Error(String(e)); + } + } + + const resource: URL | undefined = await selectResourceURL( + context.serverUrl, + context.provider, + // we default to null, so swap it for undefined if not set + resourceMetadata ?? undefined, + ); + + const metadata = await discoverAuthorizationServerMetadata(authServerUrl); + if (!metadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + context.provider.saveServerMetadata(parsedMetadata); + context.updateState({ + resourceMetadata, + resource, + resourceMetadataError, + authServerUrl, + oauthMetadata: parsedMetadata, + oauthStep: "client_registration", + }); + }, + }, + + client_registration: { + canTransition: async (context) => !!context.state.oauthMetadata, + execute: async (context) => { + const metadata = context.state.oauthMetadata!; + const clientMetadata = context.provider.clientMetadata; + + // Priority: user-provided scope > discovered scopes + if (!context.provider.scope || context.provider.scope.trim() === "") { + // Prefer scopes from resource metadata if available + const scopesSupported = + context.state.resourceMetadata?.scopes_supported || + metadata.scopes_supported; + // Add all supported scopes to client registration + if (scopesSupported) { + clientMetadata.scope = scopesSupported.join(" "); + } + } + + // Try Static client first, with DCR as fallback + let fullInformation = await context.provider.clientInformation(); + if (!fullInformation) { + fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + context.provider.saveClientInformation(fullInformation); + } + + context.updateState({ + oauthClientInfo: fullInformation, + oauthStep: "authorization_redirect", + }); + }, + }, + + authorization_redirect: { + canTransition: async (context) => + !!context.state.oauthMetadata && !!context.state.oauthClientInfo, + execute: async (context) => { + const metadata = context.state.oauthMetadata!; + const clientInformation = context.state.oauthClientInfo!; + + // Priority: user-provided scope > discovered scopes + let scope = context.provider.scope; + if (!scope || scope.trim() === "") { + scope = await discoverScopes( + context.serverUrl, + context.state.resourceMetadata ?? undefined, + ); + } + + const { authorizationUrl, codeVerifier } = await startAuthorization( + context.serverUrl, + { + metadata, + clientInformation, + redirectUrl: context.provider.redirectUrl, + scope, + state: generateOAuthState(), + resource: context.state.resource ?? undefined, + }, + ); + + context.provider.saveCodeVerifier(codeVerifier); + context.updateState({ + authorizationUrl: authorizationUrl, + oauthStep: "authorization_code", + }); + }, + }, + + authorization_code: { + canTransition: async () => true, + execute: async (context) => { + if ( + !context.state.authorizationCode || + context.state.authorizationCode.trim() === "" + ) { + context.updateState({ + validationError: "You need to provide an authorization code", + }); + // Don't advance if no code + throw new Error("Authorization code required"); + } + context.updateState({ + validationError: null, + oauthStep: "token_request", + }); + }, + }, + + token_request: { + canTransition: async (context) => { + return ( + !!context.state.authorizationCode && + !!context.provider.getServerMetadata() && + !!(await context.provider.clientInformation()) + ); + }, + execute: async (context) => { + const codeVerifier = context.provider.codeVerifier(); + 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, + }); + + context.provider.saveTokens(tokens); + context.updateState({ + oauthTokens: tokens, + oauthStep: "complete", + }); + }, + }, + + complete: { + canTransition: async () => false, + execute: async () => { + // No-op for complete state + }, + }, +}; + +export class OAuthStateMachine { + constructor( + private serverUrl: string, + private updateState: (updates: Partial) => void, + ) {} + + async executeStep(state: AuthDebuggerState): Promise { + const provider = new DebugInspectorOAuthClientProvider(this.serverUrl); + const context: StateMachineContext = { + state, + serverUrl: this.serverUrl, + provider, + updateState: this.updateState, + }; + + const transition = oauthTransitions[state.oauthStep]; + if (!(await transition.canTransition(context))) { + throw new Error(`Cannot transition from ${state.oauthStep}`); + } + + await transition.execute(context); + } +} diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/types/customHeaders.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/types/customHeaders.ts new file mode 100644 index 000000000..46e7237d0 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/types/customHeaders.ts @@ -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 => { + const enabledHeaders = getEnabledHeaders(headers); + const record: Record = {}; + + enabledHeaders.forEach((header) => { + record[header.name.trim()] = header.value.trim(); + }); + + return record; +}; + +export const recordToHeaders = ( + record: Record, +): 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)] + : []; +}; diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/utils.ts b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/05-community/gateway-mcp-inspector/client/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/README.md b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/README.md index a082582b6..fc1c331c9 100644 --- a/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/README.md +++ b/01-features/07-centralize-and-govern-your-ai-infrastructure/01-gateway/README.md @@ -20,15 +20,15 @@ Enterprise platform and security teams are adopting AgentCore policy and AgentCo What makes AgentCore gateway compelling for these teams is the consolidation of several enterprise-critical capabilities into a single layer: --**Central observability**: AgentCore gateway provides a unified view of all agent-to-tool interactions across the organization, giving platform teams full visibility into what agents are calling, how often, and with what outcomes. This is essential for audit, debugging, and usage analysis at scale. +- **Central observability**: AgentCore gateway provides a unified view of all agent-to-tool interactions across the organization, giving platform teams full visibility into what agents are calling, how often, and with what outcomes. This is essential for audit, debugging, and usage analysis at scale. --**Central Credential Management**: Rather than distributing secrets and API credentials across individual agents or teams, AgentCore gateway manages authentication centrally. This eliminates credential sprawl and gives security teams a single point of control for access management. +- **Central Credential Management**: Rather than distributing secrets and API credentials across individual agents or teams, AgentCore gateway manages authentication centrally. This eliminates credential sprawl and gives security teams a single point of control for access management. --**VPC Access to Private Data**: Enterprises need agents to reach internal APIs and data sources that live within private VPCs — not just public endpoints. AgentCore gateway enables secure egress into customer VPCs, ensuring agents can access proprietary data without exposing it to the public internet. +- **VPC Access to Private Data**: Enterprises need agents to reach internal APIs and data sources that live within private VPCs — not just public endpoints. AgentCore gateway enables secure egress into customer VPCs, ensuring agents can access proprietary data without exposing it to the public internet. --**Multi-Tenancy**: Organizations building internal platforms need to serve multiple teams, business units, or even external customers from a shared AgentCore gateway infrastructure while maintaining strict isolation between tenants. +- **Multi-Tenancy**: Organizations building internal platforms need to serve multiple teams, business units, or even external customers from a shared AgentCore gateway infrastructure while maintaining strict isolation between tenants. --**Deterministic policy Enforcement**: This is where AgentCore policy becomes essential. AgentCore policy allows central platform teams to define and enforce deterministic guardrails on tool calls. This gives governance teams a hard boundary around agent behavior — not probabilistic model-level guardrails, but deterministic, auditable controls that ensure agents only do what they're explicitly permitted to do. +- **Deterministic policy Enforcement**: This is where AgentCore policy becomes essential. AgentCore policy allows central platform teams to define and enforce deterministic guardrails on tool calls. This gives governance teams a hard boundary around agent behavior — not probabilistic model-level guardrails, but deterministic, auditable controls that ensure agents only do what they're explicitly permitted to do. ## Tutorials @@ -49,55 +49,6 @@ What makes AgentCore gateway compelling for these teams is the consolidation of - AWS CLI configured with credentials - `boto3` installed (`pip install boto3`) -## AgentCore CLI - -Add a gateway to an existing runtime project with the AgentCore CLI: - -```bash -npm install -g @aws/agentcore - -# Add a gateway with CUSTOM_JWT inbound auth (interactive mode) -agentcore add gateway - -# Or non-interactive with JWT auth -agentcore add gateway \ - --name mygateway \ - --authorizer-type CUSTOM_JWT \ - --discovery-url $DISCOVERY_URL \ - --allowed-clients $CLIENT_ID \ - --client-id $CLIENT_ID \ - --client-secret $CLIENT_SECRET - -# Add a Lambda function as a gateway target -agentcore add gateway-target \ - --name my-tools \ - --type lambda-function-arn \ - --lambda-arn $LAMBDA_ARN \ - --tool-schema-file tool-schemas/tools.json \ - --gateway mygateway - -# Add an MCP server as a gateway target -agentcore add gateway-target \ - --name exa-search \ - --type mcp-server \ - --endpoint https://mcp.exa.ai/mcp \ - --gateway mygateway - -# Add an OpenAPI spec as a gateway target -agentcore add gateway-target \ - --name my-api \ - --type open-api-schema \ - --tool-schema-file openapi-specs/my-api.yaml \ - --gateway mygateway - -# Deploy all resources -agentcore deploy - -# Check deployment status -agentcore status -``` - -For direct control of gateway resources (create, query, delete) without a runtime project, use the **AWS CLI** — see the [AgentCore gateway Developer Guide](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html) for the full walkthrough using `aws bedrock-agentcore-control create-gateway`. ## Documentation