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/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 0c5a6f2d..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"; 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/client/bin/start.js b/client/bin/start.js index aef386d0..ae6e9259 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -91,8 +91,10 @@ async function startProdServer(serverOptions) { "node", [ inspectorServerPath, - ...(command ? [`--env`, command] : []), - ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ...(command ? [`--command`, command] : []), + ...(mcpServerArgs && mcpServerArgs.length > 0 + ? [`--args`, mcpServerArgs.join(" ")] + : []), ], { env: { 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 cff51b4f..c4c89564 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,9 +236,64 @@ 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, }); + 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]); @@ -230,11 +318,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); @@ -244,7 +339,6 @@ const App = () => { [connectMcpServer], ); - // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( async ({ authorizationCode, @@ -265,7 +359,6 @@ const App = () => { } if (restoredState && authorizationCode) { - // Restore the previous auth state and continue the OAuth flow let currentState: AuthDebuggerState = { ...restoredState, authorizationCode, @@ -276,12 +369,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" @@ -290,7 +381,6 @@ const App = () => { } if (currentState.oauthStep === "complete") { - // After the flow completes or reaches a user-input step, update the app state updateAuthState({ ...currentState, statusMessage: { @@ -313,7 +403,6 @@ const App = () => { }); } } else if (authorizationCode) { - // Fallback to the original behavior if no state was restored updateAuthState({ authorizationCode, oauthStep: "token_request", @@ -323,7 +412,6 @@ const App = () => { [sseUrl], ); - // Load OAuth tokens when sseUrl changes useEffect(() => { const loadOAuthTokens = async () => { try { @@ -382,6 +470,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); @@ -398,6 +498,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 })); }; @@ -455,7 +593,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, @@ -517,18 +671,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( { @@ -540,11 +682,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( { @@ -560,6 +703,7 @@ const App = () => { CompatibilityCallToolResultSchema, "tools", ); + setToolResult(response); } catch (e) { const toolResult: CompatibilityCallToolResult = { @@ -594,7 +738,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"), @@ -658,6 +800,10 @@ const App = () => { setBearerToken={setBearerToken} headerName={headerName} setHeaderName={setHeaderName} + oauthClientId={oauthClientId} + setOauthClientId={setOauthClientId} + oauthScope={oauthScope} + setOauthScope={setOauthScope} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} stdErrNotifications={stdErrNotifications} @@ -666,7 +812,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 @@ -854,7 +999,6 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); - // Clear cached output schemas cacheToolOutputSchemas([]); }} callTool={async (name, params) => { @@ -893,6 +1037,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 */}