diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml new file mode 100644 index 00000000..8bd3bb8e --- /dev/null +++ b/.github/workflows/cli_tests.yml @@ -0,0 +1,41 @@ +name: CLI Tests + +on: + push: + paths: + - "cli/**" + pull_request: + paths: + - "cli/**" + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./cli + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: npm + + - name: Install dependencies + run: | + cd .. + npm ci --ignore-scripts + + - name: Build CLI + run: npm run build + + - name: Explicitly pre-install test dependencies + run: npx -y @modelcontextprotocol/server-everything --help || true + + - name: Run tests + run: npm test + env: + NPM_CONFIG_YES: true + CI: true diff --git a/README.md b/README.md index c342b8b2..6a671f1c 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,12 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER DANGEROUSLY_OMIT_AUTH=true npm start ``` +You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: + +```bash +MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start +``` + #### Local-only Binding By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: @@ -309,7 +315,7 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/l npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com # Connect to a remote MCP server (with Streamable HTTP transport) -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list # Call a tool on a remote server npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value diff --git a/cli/package.json b/cli/package.json index f12600d2..bf344733 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.1", + "version": "0.16.2", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.17.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 51bda553..68ce3885 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -12,7 +12,7 @@ const colors = { import fs from "fs"; import path from "path"; -import { execSync, spawn } from "child_process"; +import { spawn } from "child_process"; import os from "os"; import { fileURLToPath } from "url"; @@ -680,7 +680,7 @@ async function runTests() { // Test 26: HTTP transport with explicit --transport http flag await runBasicTest( "http_transport_with_explicit_flag", - "http://127.0.0.1:3001", + "http://127.0.0.1:3001/mcp", "--transport", "http", "--cli", @@ -710,6 +710,26 @@ async function runTests() { "tools/list", ); + // Test 29: HTTP transport without URL (should fail) + await runErrorTest( + "http_transport_without_url", + "--transport", + "http", + "--cli", + "--method", + "tools/list", + ); + + // Test 30: SSE transport without URL (should fail) + await runErrorTest( + "sse_transport_without_url", + "--transport", + "sse", + "--cli", + "--method", + "tools/list", + ); + // Kill HTTP server try { process.kill(-httpServer.pid); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index de0153b0..5ff1f111 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -66,8 +66,21 @@ async function runWebClient(args: Args): Promise { abort.abort(); }); + // Build arguments to pass to start.js + const startArgs: string[] = []; + + // Pass environment variables + for (const [key, value] of Object.entries(args.envArgs)) { + startArgs.push("-e", `${key}=${value}`); + } + + // Pass command and args (using -- to separate them) + if (args.command) { + startArgs.push("--", args.command, ...args.args); + } + try { - await spawnPromise("node", [inspectorClientPath], { + await spawnPromise("node", [inspectorClientPath, ...startArgs], { signal: abort.signal, echoOutput: true, }); @@ -233,7 +246,7 @@ async function main(): Promise { const args = parseArgs(); if (args.cli) { - runCli(args); + await runCli(args); } else { await runWebClient(args); } diff --git a/cli/src/index.ts b/cli/src/index.ts index 5d5dcf8b..2b0c4f53 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -287,6 +287,8 @@ async function main(): Promise { try { const args = parseArgs(); await callMethod(args); + // Explicitly exit to ensure process terminates in CI + process.exit(0); } catch (error) { handleError(error); } diff --git a/cli/src/transport.ts b/cli/src/transport.ts index e0d67b4e..b6276356 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -14,24 +14,6 @@ export type TransportOptions = { url?: string; }; -function createSSETransport(options: TransportOptions): Transport { - const baseUrl = new URL(options.url ?? ""); - const sseUrl = baseUrl.pathname.endsWith("/sse") - ? baseUrl - : new URL("/sse", baseUrl); - - return new SSEClientTransport(sseUrl); -} - -function createHTTPTransport(options: TransportOptions): Transport { - const baseUrl = new URL(options.url ?? ""); - const mcpUrl = baseUrl.pathname.endsWith("/mcp") - ? baseUrl - : new URL("/mcp", baseUrl); - - return new StreamableHTTPClientTransport(mcpUrl); -} - function createStdioTransport(options: TransportOptions): Transport { let args: string[] = []; @@ -75,12 +57,18 @@ export function createTransport(options: TransportOptions): Transport { return createStdioTransport(options); } + // If not STDIO, then it must be either SSE or HTTP. + if (!options.url) { + throw new Error("URL must be provided for SSE or HTTP transport types."); + } + const url = new URL(options.url); + if (transportType === "sse") { - return createSSETransport(options); + return new SSEClientTransport(url); } if (transportType === "http") { - return createHTTPTransport(options); + return new StreamableHTTPClientTransport(url); } throw new Error(`Unsupported transport type: ${transportType}`); diff --git a/client/bin/start.js b/client/bin/start.js index 70ca046e..ae6e9259 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -40,7 +40,7 @@ async function startDevServer(serverOptions) { ...process.env, SERVER_PORT, CLIENT_PORT, - MCP_PROXY_TOKEN: sessionToken, + MCP_PROXY_AUTH_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), }, signal: abort.signal, @@ -91,15 +91,17 @@ async function startProdServer(serverOptions) { "node", [ inspectorServerPath, - ...(command ? [`--env`, command] : []), - ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ...(command ? [`--command`, command] : []), + ...(mcpServerArgs && mcpServerArgs.length > 0 + ? [`--args`, mcpServerArgs.join(" ")] + : []), ], { env: { ...process.env, SERVER_PORT, CLIENT_PORT, - MCP_PROXY_TOKEN: sessionToken, + MCP_PROXY_AUTH_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), }, signal: abort.signal, @@ -247,8 +249,9 @@ async function main() { : "Starting MCP inspector...", ); - // Generate session token for authentication - const sessionToken = randomBytes(32).toString("hex"); + // Use provided token from environment or generate a new one + const sessionToken = + process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; const abort = new AbortController(); diff --git a/client/package.json b/client/package.json index ee3f7c30..2e725a60 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.1", + "version": "0.16.2", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -25,9 +25,8 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.17.0", "@radix-ui/react-checkbox": "^1.1.4", - "ajv": "^6.12.6", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", @@ -37,6 +36,7 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "ajv": "^6.12.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", diff --git a/client/src/App.tsx b/client/src/App.tsx index c80cfd35..12e78fa8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -71,6 +71,10 @@ import { initializeInspectorConfig, saveInspectorConfig, } from "./utils/configUtils"; +import ElicitationTab, { + PendingElicitationRequest, + ElicitationResponse, +} from "./components/ElicitationTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -119,6 +123,14 @@ const App = () => { return localStorage.getItem("lastHeaderName") || ""; }); + const [oauthClientId, setOauthClientId] = useState(() => { + return localStorage.getItem("lastOauthClientId") || ""; + }); + + const [oauthScope, setOauthScope] = useState(() => { + return localStorage.getItem("lastOauthScope") || ""; + }); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -127,13 +139,19 @@ const App = () => { } > >([]); + const [pendingElicitationRequests, setPendingElicitationRequests] = useState< + Array< + PendingElicitationRequest & { + resolve: (response: ElicitationResponse) => void; + decline: (error: Error) => void; + } + > + >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); - // Auth debugger state const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); - // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { setAuthState((prev) => ({ ...prev, ...updates })); }; @@ -161,6 +179,19 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); + const [activeTab, setActiveTab] = useState(() => { + const hash = window.location.hash.slice(1); + const initialTab = hash || "resources"; + return initialTab; + }); + + const currentTabRef = useRef(activeTab); + const lastToolCallOriginTabRef = useRef(activeTab); + + useEffect(() => { + currentTabRef.current = activeTab; + }, [activeTab]); + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); const { width: sidebarWidth, @@ -187,6 +218,8 @@ const App = () => { env, bearerToken, headerName, + oauthClientId, + oauthScope, config, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -203,10 +236,65 @@ const App = () => { { id: nextRequestId.current++, request, resolve, reject }, ]); }, + onElicitationRequest: (request, resolve) => { + const currentTab = lastToolCallOriginTabRef.current; + + setPendingElicitationRequests((prev) => [ + ...prev, + { + id: nextRequestId.current++, + request: { + id: nextRequestId.current, + message: request.params.message, + requestedSchema: request.params.requestedSchema, + }, + originatingTab: currentTab, + resolve, + decline: (error: Error) => { + console.error("Elicitation request rejected:", error); + }, + }, + ]); + + setActiveTab("elicitations"); + window.location.hash = "elicitations"; + }, getRoots: () => rootsRef.current, defaultLoggingLevel: logLevel, }); + useEffect(() => { + if (serverCapabilities) { + const hash = window.location.hash.slice(1); + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ]; + + const isValidTab = validTabs.includes(hash); + + if (!isValidTab) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + + setActiveTab(defaultTab); + window.location.hash = defaultTab; + } + } + }, [serverCapabilities]); + useEffect(() => { localStorage.setItem("lastCommand", command); }, [command]); @@ -231,11 +319,18 @@ const App = () => { localStorage.setItem("lastHeaderName", headerName); }, [headerName]); + useEffect(() => { + localStorage.setItem("lastOauthClientId", oauthClientId); + }, [oauthClientId]); + + useEffect(() => { + localStorage.setItem("lastOauthScope", oauthScope); + }, [oauthScope]); + useEffect(() => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); - // Auto-connect to previously saved serverURL after OAuth callback const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); @@ -245,7 +340,6 @@ const App = () => { [connectMcpServer], ); - // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( async ({ authorizationCode, @@ -266,7 +360,6 @@ const App = () => { } if (restoredState && authorizationCode) { - // Restore the previous auth state and continue the OAuth flow let currentState: AuthDebuggerState = { ...restoredState, authorizationCode, @@ -277,12 +370,10 @@ const App = () => { }; try { - // Create a new state machine instance to continue the flow const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { currentState = { ...currentState, ...updates }; }); - // Continue stepping through the OAuth flow from where we left off while ( currentState.oauthStep !== "complete" && currentState.oauthStep !== "authorization_code" @@ -291,7 +382,6 @@ const App = () => { } if (currentState.oauthStep === "complete") { - // After the flow completes or reaches a user-input step, update the app state updateAuthState({ ...currentState, statusMessage: { @@ -314,7 +404,6 @@ const App = () => { }); } } else if (authorizationCode) { - // Fallback to the original behavior if no state was restored updateAuthState({ authorizationCode, oauthStep: "token_request", @@ -324,7 +413,6 @@ const App = () => { [sseUrl], ); - // Load OAuth tokens when sseUrl changes useEffect(() => { const loadOAuthTokens = async () => { try { @@ -383,6 +471,18 @@ const App = () => { } }, []); + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.slice(1); + if (hash && hash !== activeTab) { + setActiveTab(hash); + } + }; + + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, [activeTab]); + const handleApproveSampling = (id: number, result: CreateMessageResult) => { setPendingSampleRequests((prev) => { const request = prev.find((r) => r.id === id); @@ -399,6 +499,44 @@ const App = () => { }); }; + const handleResolveElicitation = ( + id: number, + response: ElicitationResponse, + ) => { + setPendingElicitationRequests((prev) => { + const request = prev.find((r) => r.id === id); + if (request) { + request.resolve(response); + + if (request.originatingTab) { + const originatingTab = request.originatingTab; + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ]; + + if (validTabs.includes(originatingTab)) { + setActiveTab(originatingTab); + window.location.hash = originatingTab; + + setTimeout(() => { + setActiveTab(originatingTab); + window.location.hash = originatingTab; + }, 100); + } + } + } + return prev.filter((r) => r.id !== id); + }); + }; + const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; @@ -456,7 +594,23 @@ const App = () => { setNextResourceTemplateCursor(response.nextCursor); }; + const getPrompt = async (name: string, args: Record = {}) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + + const response = await sendMCPRequest( + { + method: "prompts/get" as const, + params: { name, arguments: args }, + }, + GetPromptResultSchema, + "prompts", + ); + setPromptContent(JSON.stringify(response, null, 2)); + }; + const readResource = async (uri: string) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + const response = await sendMCPRequest( { method: "resources/read" as const, @@ -518,18 +672,6 @@ const App = () => { setNextPromptCursor(response.nextCursor); }; - const getPrompt = async (name: string, args: Record = {}) => { - const response = await sendMCPRequest( - { - method: "prompts/get" as const, - params: { name, arguments: args }, - }, - GetPromptResultSchema, - "prompts", - ); - setPromptContent(JSON.stringify(response, null, 2)); - }; - const listTools = async () => { const response = await sendMCPRequest( { @@ -541,11 +683,12 @@ const App = () => { ); setTools(response.tools); setNextToolCursor(response.nextCursor); - // Cache output schemas for validation cacheToolOutputSchemas(response.tools); }; const callTool = async (name: string, params: Record) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + try { const response = await sendMCPRequest( { @@ -561,6 +704,7 @@ const App = () => { CompatibilityCallToolResultSchema, "tools", ); + setToolResult(response); } catch (e) { const toolResult: CompatibilityCallToolResult = { @@ -595,7 +739,6 @@ const App = () => { setStdErrNotifications([]); }; - // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( { ); - // Helper function to render OAuth callback components if (window.location.pathname === "/oauth/callback") { const OAuthCallback = React.lazy( () => import("./components/OAuthCallback"), @@ -659,6 +801,10 @@ const App = () => { setBearerToken={setBearerToken} headerName={headerName} setHeaderName={setHeaderName} + oauthClientId={oauthClientId} + setOauthClientId={setOauthClientId} + oauthScope={oauthScope} + setOauthScope={setOauthScope} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} stdErrNotifications={stdErrNotifications} @@ -667,7 +813,6 @@ const App = () => { loggingSupported={!!serverCapabilities?.logging || false} clearStdErrNotifications={clearStdErrNotifications} /> - {/* Drag handle for resizing sidebar */}
{
{mcpClient ? ( (window.location.hash = value)} + onValueChange={(value) => { + setActiveTab(value); + window.location.hash = value; + }} > { )} + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + Roots @@ -855,7 +1000,6 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); - // Clear cached output schemas cacheToolOutputSchemas([]); }} callTool={async (name, params) => { @@ -894,6 +1038,10 @@ const App = () => { onApprove={handleApproveSampling} onReject={handleRejectSampling} /> + { const supportedTypes = ["string", "number", "integer", "boolean", "null"]; - if (supportedTypes.includes(schema.type)) return true; + if (schema.type && supportedTypes.includes(schema.type)) return true; if (schema.type === "object") { - return Object.values(schema.properties ?? {}).every((prop) => - supportedTypes.includes(prop.type), + return Object.values(schema.properties ?? {}).every( + (prop) => prop.type && supportedTypes.includes(prop.type), ); } if (schema.type === "array") { @@ -182,10 +182,89 @@ const DynamicJsonForm = ({ parentSchema?.required?.includes(propertyName || "") ?? false; switch (propSchema.type) { - case "string": + case "string": { + if ( + propSchema.oneOf && + propSchema.oneOf.every( + (option) => + typeof option.const === "string" && + typeof option.title === "string", + ) + ) { + return ( + + ); + } + + if (propSchema.enum) { + return ( + + ); + } + + let inputType = "text"; + switch (propSchema.format) { + case "email": + inputType = "email"; + break; + case "uri": + inputType = "url"; + break; + case "date": + inputType = "date"; + break; + case "date-time": + inputType = "datetime-local"; + break; + default: + inputType = "text"; + break; + } + return ( { const val = e.target.value; @@ -194,8 +273,13 @@ const DynamicJsonForm = ({ }} placeholder={propSchema.description} required={isRequired} + minLength={propSchema.minLength} + maxLength={propSchema.maxLength} + pattern={propSchema.pattern} /> ); + } + case "number": return ( ); + case "integer": return ( ); + case "boolean": return ( void; +}; + +const ElicitationRequest = ({ + request, + onResolve, +}: ElicitationRequestProps) => { + const [formData, setFormData] = useState({}); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + const defaultValue = generateDefaultValue(request.request.requestedSchema); + setFormData(defaultValue); + setValidationError(null); + }, [request.request.requestedSchema]); + + const validateEmailFormat = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const validateFormData = ( + data: JsonValue, + schema: JsonSchemaType, + ): boolean => { + if ( + schema.type === "object" && + schema.properties && + typeof data === "object" && + data !== null + ) { + const dataObj = data as Record; + + if (Array.isArray(schema.required)) { + for (const field of schema.required) { + const value = dataObj[field]; + if (value === undefined || value === null || value === "") { + setValidationError(`Required field missing: ${field}`); + return false; + } + } + } + + for (const [fieldName, fieldValue] of Object.entries(dataObj)) { + const fieldSchema = schema.properties[fieldName]; + if ( + fieldSchema && + fieldSchema.format === "email" && + typeof fieldValue === "string" + ) { + if (!validateEmailFormat(fieldValue)) { + setValidationError(`Invalid email format: ${fieldName}`); + return false; + } + } + } + } + + return true; + }; + + const handleAccept = () => { + try { + if (!validateFormData(formData, request.request.requestedSchema)) { + return; + } + + const ajv = new Ajv(); + const validate = ajv.compile(request.request.requestedSchema); + const isValid = validate(formData); + + if (!isValid) { + const errorMessage = ajv.errorsText(validate.errors); + setValidationError(errorMessage); + return; + } + + onResolve(request.id, { + action: "accept", + content: formData as Record, + }); + } catch (error) { + setValidationError( + error instanceof Error ? error.message : "Validation failed", + ); + } + }; + + const handleDecline = () => { + onResolve(request.id, { action: "decline" }); + }; + + const handleCancel = () => { + onResolve(request.id, { action: "cancel" }); + }; + + const schemaTitle = + request.request.requestedSchema.title || "Information Request"; + const schemaDescription = request.request.requestedSchema.description; + + return ( +
+
+
+

{schemaTitle}

+

{request.request.message}

+ {schemaDescription && ( +

{schemaDescription}

+ )} +
+
Request Schema:
+ +
+
+
+ +
+
+

Response Form

+ { + setFormData(newValue); + setValidationError(null); + }} + /> + + {validationError && ( +
+
+ Validation Error: {validationError} +
+
+ )} +
+ +
+ + + +
+
+
+ ); +}; + +export default ElicitationRequest; diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx new file mode 100644 index 00000000..cf3be2b5 --- /dev/null +++ b/client/src/components/ElicitationTab.tsx @@ -0,0 +1,56 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { TabsContent } from "@/components/ui/tabs"; +import { JsonSchemaType } from "@/utils/jsonUtils"; +import ElicitationRequest from "./ElicitationRequest"; + +export interface ElicitationRequestData { + id: number; + message: string; + requestedSchema: JsonSchemaType; +} + +export interface ElicitationResponse { + action: "accept" | "decline" | "cancel"; + content?: Record; +} + +export type PendingElicitationRequest = { + id: number; + request: ElicitationRequestData; + originatingTab?: string; +}; + +export type Props = { + pendingRequests: PendingElicitationRequest[]; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +const ElicitationTab = ({ pendingRequests, onResolve }: Props) => { + return ( + +
+ + + When the server requests information from the user, requests will + appear here for response. + + +
+

Recent Requests

+ {pendingRequests.map((request) => ( + + ))} + {pendingRequests.length === 0 && ( +

No pending requests

+ )} +
+
+
+ ); +}; + +export default ElicitationTab; diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 938e5b5a..a41e4bfc 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -56,6 +56,10 @@ interface SidebarProps { setBearerToken: (token: string) => void; headerName?: string; setHeaderName?: (name: string) => void; + oauthClientId: string; + setOauthClientId: (id: string) => void; + oauthScope: string; + setOauthScope: (scope: string) => void; onConnect: () => void; onDisconnect: () => void; stdErrNotifications: StdErrNotification[]; @@ -83,6 +87,10 @@ const Sidebar = ({ setBearerToken, headerName, setHeaderName, + oauthClientId, + setOauthClientId, + oauthScope, + setOauthScope, onConnect, onDisconnect, stdErrNotifications, @@ -95,7 +103,7 @@ const Sidebar = ({ }: SidebarProps) => { const [theme, setTheme] = useTheme(); const [showEnvVars, setShowEnvVars] = useState(false); - const [showBearerToken, setShowBearerToken] = useState(false); + const [showAuthConfig, setShowAuthConfig] = useState(false); const [showConfig, setShowConfig] = useState(false); const [shownEnvVars, setShownEnvVars] = useState>(new Set()); const [copiedServerEntry, setCopiedServerEntry] = useState(false); @@ -308,51 +316,6 @@ const Sidebar = ({ /> )}
-
- - {showBearerToken && ( -
- - - setHeaderName && setHeaderName(e.target.value) - } - data-testid="header-input" - className="font-mono" - value={headerName} - /> - - setBearerToken(e.target.value)} - data-testid="bearer-token-input" - className="font-mono" - type="password" - /> -
- )} -
)} @@ -521,6 +484,94 @@ const Sidebar = ({
+
+ + {showAuthConfig && ( + <> + {/* Bearer Token Section */} +
+

+ API Token Authentication +

+
+ + + setHeaderName && setHeaderName(e.target.value) + } + data-testid="header-input" + className="font-mono" + value={headerName} + /> + + setBearerToken(e.target.value)} + data-testid="bearer-token-input" + className="font-mono" + type="password" + /> +
+
+ {transportType !== "stdio" && ( + // OAuth Configuration +
+

+ OAuth 2.0 Flow +

+
+ + setOauthClientId(e.target.value)} + value={oauthClientId} + data-testid="oauth-client-id-input" + className="font-mono" + /> + + + + setOauthScope(e.target.value)} + value={oauthScope} + data-testid="oauth-scope-input" + className="font-mono" + /> +
+
+ )} + + )} +
{/* Configuration */}