inspector/client/src/App.tsx
Cliff Hall 2af3410b8e
Add MCP Apps support to Inspector (#1044)
* Fix MCP Apps rendering issue and add comprehensive logging

- Fixed AppRenderer useEffect dependency array to include resourceContent
  This ensures the component re-evaluates when resource content arrives
- Added detailed console logging throughout the app lifecycle:
  * Resource fetch and response tracking in App.tsx
  * Setup conditions and AppBridge creation in AppRenderer.tsx
  * HTML parsing and iframe rendering steps
  * PostMessageTransport and AppBridge connection status
  * App tool filtering and selection in AppsTab.tsx
- Refactored AppsTab selectedTool rendering for better tracking

The issue was that resourceContent prop updates weren't triggering
the AppRenderer setup effect. Now the effect properly responds to
both resourceUri and resourceContent changes.

Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>

* Fix MCP Apps iframe rendering issue

The app was getting stuck on 'Loading MCP App...' because the iframe was
hidden (display: none) until the oninitialized event fired. However, the
PostMessage handshake requires the iframe to be visible to complete.

This fix:
- Sets loading to false immediately after writing HTML to the iframe
- Makes the iframe visible before establishing PostMessage transport
- Allows the AppBridge initialization handshake to complete successfully
- Removes redundant setLoading(false) from oninitialized callback

The iframe is now visible and ready for PostMessage communication before
the AppBridge connect() call, enabling proper initialization.

Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>

* Fix MCP Apps HTML extraction to match spec

The AppRenderer was incorrectly checking for a 'type' field in resource
contents, but TextResourceContents objects only have uri, mimeType, and
text fields according to the MCP specification.

Fixed by checking for the presence of the 'text' field directly instead
of checking a non-existent 'type' field. This allows the HTML content
to be properly extracted and rendered in the iframe.

Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>

* Add comprehensive tests for MCP Apps support

- Add tests for AppsTab component (13 tests)
- Add tests for AppRenderer component (17 tests)
- Update jest.config.cjs to handle ES modules from @modelcontextprotocol/ext-apps
- All 478 tests pass successfully

Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>

* Add MCP Apps support to Inspector

- Add AppsTab component for detecting and listing MCP apps
- Add AppRenderer component with full AppBridge integration
- Implement UI resource fetching and sandboxed iframe rendering
- Add PostMessage transport for bidirectional JSON-RPC communication
- Include comprehensive test coverage (30 new tests)
- Auto-populate apps when tab becomes active
- Support theme awareness and configurable permissions

Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>

* Prefer structuredContent over content field in tool responses

Some MCP servers return different data in structuredContent vs content fields.
This change ensures that when structuredContent is present, it is used
exclusively for display instead of showing both fields.

Changes:
- Modified ToolResults.tsx to only show content when structuredContent is absent
- Removed unused checkContentCompatibility function
- Updated test cases to reflect new behavior

Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>

* Revert "Prefer structuredContent over content field in tool responses"

This reverts commit 3b054b4da08517cce53ef23e60c07bb32b0fa8f5.

Claude did not do the right thing.

* feat: integrate @mcp-ui/client for MCP application rendering

This commit implements the integration of `@mcp-ui/client` to handle the
rendering of Model Context Protocol (MCP) applications within the inspector.
It includes refactoring the rendering logic, improving resource fetching
in the main application state, and setting up a secure sandbox environment.

Changes per file:

- client/package.json:
  - Added `@mcp-ui/client` as a dependency.

- client/src/components/AppRenderer.tsx:
  - Refactored to use `McpUiAppRenderer` from `@mcp-ui/client`.
  - Implemented HTML parsing logic for MCP resource responses.
  - Configured host context (theme) and sandbox URL for the renderer.
  - Replaced iframe-based manual rendering with the official component.

- client/src/App.tsx:
  - Added `fetchingResources` state to prevent duplicate concurrent resource requests.
  - Enhanced `readResource` with better error handling and state tracking.
  - Optimized resource content mapping to better support application state.

- client/src/components/AppsTab.tsx:
  - Updated to use `getToolUiResourceUri` utility from `@modelcontextprotocol/ext-apps`
    instead of manual metadata property access.
  - Simplified tool filtering and selection logic.

- client/public/sandbox_proxy.html:
  - Added a sandbox proxy page to facilitate secure communication between
    the inspector and the rendered MCP applications.

- client/bin/client.js:
  - Added server rewrites to ensure `sandbox_proxy.html` is served correctly.
  - Implemented specific `Cache-Control` headers for `sandbox_proxy.html` to
    prevent stale cached versions.

- client/vite.config.ts:
  - Explicitly configured `publicDir: "public"` to ensure the sandbox proxy
    is included in the build output.

- client/src/components/__tests__/AppRenderer.test.tsx:
  - Updated tests to mock the new `@mcp-ui/client` component.
  - Adjusted assertions to verify correct props are passed to the renderer.
  - Enhanced mock MCP client to include required methods like `getServerCapabilities`.

* Potential fix for code scanning alert no. 37: Client-side cross-site scripting

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update package-lock.json
 - npm install
 - npm audit fix

* Potential fix for code scanning alert no. 38: Client-side cross-site scripting

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 39: Client-side cross-site scripting

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* prettier

* Update client/src/components/AppsTab.tsx

Co-authored-by: Ola Hungerford <olahungerford@gmail.com>

* prettier

* In AppsTab.test.tsx, fetch button by aria-label

* relative imports

* Remove sanitization logic from sandbox_proxy.html. It's breaking the app rendering. Will sort out what best practices for this HTML is later

* In sandbox_proxy.html,formatting
In AppsTab.tsx
  - Add app window
    - when an app is selected,
      - show input form for apps that have an input schema
      - button shows app
      - if app is shown, show maximize button
        - if maximized,
          - show minimize button
          - hide app list
* In AppsTab.test.tsx
  - test new layout and controls
* In AppRenderer.tsx
    - accept toolInput
    - pass to McpUiAppRenderer

* In AppsTab.tsx, if an app has no inputSchema, just show it, otherwise show the form and the "back to form" button when showing the app.

* In AppsTab.test.tsx
  - test new behavior

* In ListPane.tsx
  - remove the Clear button

* In ListPane.test.tsx
  - remove test for clear button

* In AppRenderer.tsx,
  - remove onReadResource and resourceContent, not needed since mcpClient is being passed

* In App.tsx and AppsTab.tsx,
  - remove the passing of these variables

* In AppRenderer.test.tsx, and AppsTab.test.tsx
  - remove tests that fail after having removed onReadResource and resourceContent

* Serving the sandbox_proxy.html from a separate port, using the proxy instead of the webclient server.

* In App.tsx
  - pass the path to the sandbox endpoint on the proxy to the AppsTab

* In AppsTab.tsx
  - accept the sandboxPath property and pass it to the AppRenderer

* In AppRenderer.tsx
  - accept the sandboxPath property and pass it to the McpUiAppRenderer

* In client.js
  - remove the rewrite and header config for sandbox_proxy.html

* In server/src/index.ts
  - add a /sandbox endpoint that reads and returns the sandbox_proxy.html file with no-cache header

* In server/package.json
  - add shx for cross-platform copy function
  - in build script, copy static folder to build folder

* Moved sandbox_proxy.html from client/public to server/static

* In client.js
  - return to original state

* In vite.config.ts
  - return to original state

* Potential fix for code scanning alert no. 41: Missing rate limiting

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* In AppRenderer.tsx
  - add open link handler that handles link requests from the UI. Makes sure that the URL starts with http or https

* In sandbox_proxy.html
  - refactor to match [basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example
  - creates an nested iframe for security.
  - in this implementation, for simplicity the sandbox.ts is inlined in sandbox_proxy.html as javascript

* prettier

* In AppRenderer.tsx
  - add support for handling McpUiMessageRequest messages from the UI
  - show message in a toast

* Add logging message handler to AppRenderer

In App.tsx
  - pass an onNotification function to the AppsTab that adds a notification to the notification array

In AppsTab.tsx
  - Accept the onNotification function and pass it to the AppRenderer

In AppRenderer.tsx
  - Accept the onNotification function
  - added an handleLoggingMessage function that takes a LoggingMessageNotificationParams object, wraps it with a method and params prop to make it a ServerNotification shape, and pass it to the onNotification function
   - pass handleLoggingMessage to McpUiAppRenderer

* In ResourcesTab.test.tsx
  - fix @ import that wasn't resolving

* In AppRenderer.tsx
  - remove fetch and evaluation of resourceUri, as the mcpClient handles that (review request)

* In AppRenderer.test.tsx
  - remove test for no resourceUri found

* Merge branch 'main' into full-apps-support-final

Resolved Conflicts:
- package-lock.json

* Fixed mismatch with sanboxRateLimiter and the express version

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
2026-02-05 20:02:20 -07:00

1649 lines
52 KiB
TypeScript

import {
ClientRequest,
CompatibilityCallToolResult,
CompatibilityCallToolResultSchema,
CreateMessageResult,
EmptyResultSchema,
GetPromptResultSchema,
ListPromptsResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema,
Resource,
ResourceTemplate,
Root,
ServerNotification,
Tool,
LoggingLevel,
Task,
GetTaskResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
AnySchema,
SchemaOutput,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import {
hasValidMetaName,
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import { cleanParams } from "./utils/paramUtils";
import type { JsonSchemaType } from "./utils/jsonUtils";
import React, {
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useConnection } from "./lib/hooks/useConnection";
import {
useDraggablePane,
useDraggableSidebar,
} from "./lib/hooks/useDraggablePane";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
AppWindow,
Bell,
Files,
FolderTree,
Hammer,
Hash,
Key,
ListTodo,
MessageSquare,
Settings,
} from "lucide-react";
import { z } from "zod";
import "./App.css";
import AuthDebugger from "./components/AuthDebugger";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/HistoryAndNotifications";
import PingTab from "./components/PingTab";
import PromptsTab, { Prompt } from "./components/PromptsTab";
import ResourcesTab from "./components/ResourcesTab";
import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import TasksTab from "./components/TasksTab";
import AppsTab from "./components/AppsTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
getMCPProxyAuthToken,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
saveInspectorConfig,
getMCPTaskTtl,
} from "./utils/configUtils";
import ElicitationTab, {
PendingElicitationRequest,
ElicitationResponse,
} from "./components/ElicitationTab";
import {
CustomHeaders,
migrateFromLegacyAuth,
} from "./lib/types/customHeaders";
import MetadataTab from "./components/MetadataTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const filterReservedMetadata = (
metadata: Record<string, string>,
): Record<string, string> => {
return Object.entries(metadata).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (
!isReservedMetaKey(key) &&
hasValidMetaPrefix(key) &&
hasValidMetaName(key)
) {
acc[key] = value;
}
return acc;
},
{},
);
};
const App = () => {
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
>([]);
const [resourceContent, setResourceContent] = useState<string>("");
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
new Set(),
);
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [toolResult, setToolResult] =
useState<CompatibilityCallToolResult | null>(null);
const [errors, setErrors] = useState<Record<string, string | null>>({
resources: null,
prompts: null,
tools: null,
tasks: null,
});
const [command, setCommand] = useState<string>(getInitialCommand);
const [args, setArgs] = useState<string>(getInitialArgs);
const [sseUrl, setSseUrl] = useState<string>(getInitialSseUrl);
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(getInitialTransportType);
const [connectionType, setConnectionType] = useState<"direct" | "proxy">(
() => {
return (
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
"proxy"
);
},
);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
const [config, setConfig] = useState<InspectorConfig>(() =>
initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY),
);
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
const [headerName, setHeaderName] = useState<string>(() => {
return localStorage.getItem("lastHeaderName") || "";
});
const [oauthClientId, setOauthClientId] = useState<string>(() => {
return localStorage.getItem("lastOauthClientId") || "";
});
const [oauthScope, setOauthScope] = useState<string>(() => {
return localStorage.getItem("lastOauthScope") || "";
});
const [oauthClientSecret, setOauthClientSecret] = useState<string>(() => {
return localStorage.getItem("lastOauthClientSecret") || "";
});
// Custom headers state with migration from legacy auth
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
const savedHeaders = localStorage.getItem("lastCustomHeaders");
if (savedHeaders) {
try {
return JSON.parse(savedHeaders);
} catch (error) {
console.warn(
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
error,
);
// Fall back to migration if JSON parsing fails
}
}
// Migrate from legacy auth if available
const legacyToken = localStorage.getItem("lastBearerToken") || "";
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
if (legacyToken) {
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
}
// Default to empty array
return [
{
name: "Authorization",
value: "Bearer ",
enabled: false,
},
];
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
resolve: (result: CreateMessageResult) => void;
reject: (error: Error) => void;
}
>
>([]);
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
Array<
PendingElicitationRequest & {
resolve: (response: ElicitationResponse) => void;
decline: (error: Error) => void;
}
>
>([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// Metadata state - persisted in localStorage
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
const savedMetadata = localStorage.getItem("lastMetadata");
if (savedMetadata) {
try {
const parsed = JSON.parse(savedMetadata);
if (parsed && typeof parsed === "object") {
return filterReservedMetadata(parsed);
}
} catch (error) {
console.warn("Failed to parse saved metadata:", error);
}
}
return {};
});
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
const handleMetadataChange = (newMetadata: Record<string, string>) => {
const sanitizedMetadata = filterReservedMetadata(newMetadata);
setMetadata(sanitizedMetadata);
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
};
const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]);
const [selectedResource, setSelectedResource] = useState<Resource | null>(
null,
);
const [resourceSubscriptions, setResourceSubscriptions] = useState<
Set<string>
>(new Set<string>());
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [isPollingTask, setIsPollingTask] = useState(false);
const [nextResourceCursor, setNextResourceCursor] = useState<
string | undefined
>();
const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState<
string | undefined
>();
const [nextPromptCursor, setNextPromptCursor] = useState<
string | undefined
>();
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
const hash = window.location.hash.slice(1);
const initialTab = hash || "resources";
return initialTab;
});
const currentTabRef = useRef<string>(activeTab);
const lastToolCallOriginTabRef = useRef<string>(activeTab);
useEffect(() => {
currentTabRef.current = activeTab;
}, [activeTab]);
const navigateToOriginatingTab = (originatingTab?: string) => {
if (!originatingTab) return;
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping",
"sampling",
"elicitations",
"roots",
"auth",
"metadata",
];
if (!validTabs.includes(originatingTab)) return;
setActiveTab(originatingTab);
window.location.hash = originatingTab;
setTimeout(() => {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
}, 100);
};
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
width: sidebarWidth,
isDragging: isSidebarDragging,
handleDragStart: handleSidebarDragStart,
} = useDraggableSidebar(320);
const selectedTaskRef = useRef<Task | null>(null);
useEffect(() => {
selectedTaskRef.current = selectedTask;
}, [selectedTask]);
const {
connectionStatus,
serverCapabilities,
serverImplementation,
mcpClient,
requestHistory,
clearRequestHistory,
makeRequest,
cancelTask: cancelMcpTask,
listTasks: listMcpTasks,
sendNotification,
handleCompletion,
completionsSupported,
connect: connectMcpServer,
disconnect: disconnectMcpServer,
} = useConnection({
transportType,
command,
args,
sseUrl,
env,
customHeaders,
oauthClientId,
oauthClientSecret,
oauthScope,
config,
connectionType,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
if (notification.method === "notifications/tasks/list_changed") {
void listTasks();
}
if (notification.method === "notifications/tasks/status") {
const task = notification.params as unknown as Task;
setTasks((prev) => {
const exists = prev.some((t) => t.taskId === task.taskId);
if (exists) {
return prev.map((t) => (t.taskId === task.taskId ? task : t));
} else {
return [task, ...prev];
}
});
if (selectedTaskRef.current?.taskId === task.taskId) {
setSelectedTask(task);
}
}
},
onPendingRequest: (request, resolve, reject) => {
const currentTab = lastToolCallOriginTabRef.current;
setPendingSampleRequests((prev) => [
...prev,
{
id: nextRequestId.current++,
request,
originatingTab: currentTab,
resolve,
reject,
},
]);
setActiveTab("sampling");
window.location.hash = "sampling";
},
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,
metadata,
});
useEffect(() => {
if (serverCapabilities) {
const hash = window.location.hash.slice(1);
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping",
"sampling",
"elicitations",
"roots",
"auth",
"metadata",
];
const isValidTab = validTabs.includes(hash);
if (!isValidTab) {
const defaultTab = serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: serverCapabilities?.tasks
? "tasks"
: "ping";
setActiveTab(defaultTab);
window.location.hash = defaultTab;
}
}
}, [serverCapabilities]);
useEffect(() => {
if (mcpClient && activeTab === "tasks") {
void listTasks();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab]);
useEffect(() => {
if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) {
void listTools();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab, serverCapabilities?.tools]);
useEffect(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
useEffect(() => {
localStorage.setItem("lastArgs", args);
}, [args]);
useEffect(() => {
localStorage.setItem("lastSseUrl", sseUrl);
}, [sseUrl]);
useEffect(() => {
localStorage.setItem("lastTransportType", transportType);
}, [transportType]);
useEffect(() => {
localStorage.setItem("lastConnectionType", connectionType);
}, [connectionType]);
useEffect(() => {
if (bearerToken) {
localStorage.setItem("lastBearerToken", bearerToken);
} else {
localStorage.removeItem("lastBearerToken");
}
}, [bearerToken]);
useEffect(() => {
if (headerName) {
localStorage.setItem("lastHeaderName", headerName);
} else {
localStorage.removeItem("lastHeaderName");
}
}, [headerName]);
useEffect(() => {
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
}, [customHeaders]);
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
useEffect(() => {
if (customHeaders.length === 0 && (bearerToken || headerName)) {
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
if (migratedHeaders.length > 0) {
setCustomHeaders(migratedHeaders);
// Clear legacy auth after migration
setBearerToken("");
setHeaderName("");
}
}
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
useEffect(() => {
localStorage.setItem("lastOauthClientId", oauthClientId);
}, [oauthClientId]);
useEffect(() => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);
useEffect(() => {
localStorage.setItem("lastOauthClientSecret", oauthClientSecret);
}, [oauthClientSecret]);
useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
const onOAuthConnect = useCallback(
(serverUrl: string) => {
setSseUrl(serverUrl);
setIsAuthDebuggerVisible(false);
void connectMcpServer();
},
[connectMcpServer],
);
const onOAuthDebugConnect = useCallback(
async ({
authorizationCode,
errorMsg,
restoredState,
}: {
authorizationCode?: string;
errorMsg?: string;
restoredState?: AuthDebuggerState;
}) => {
setIsAuthDebuggerVisible(true);
if (errorMsg) {
updateAuthState({
latestError: new Error(errorMsg),
});
return;
}
if (restoredState && authorizationCode) {
let currentState: AuthDebuggerState = {
...restoredState,
authorizationCode,
oauthStep: "token_request",
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
};
try {
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
while (
currentState.oauthStep !== "complete" &&
currentState.oauthStep !== "authorization_code"
) {
await stateMachine.executeStep(currentState);
}
if (currentState.oauthStep === "complete") {
updateAuthState({
...currentState,
statusMessage: {
type: "success",
message: "Authentication completed successfully",
},
isInitiatingAuth: false,
});
}
} catch (error) {
console.error("OAuth continuation error:", error);
updateAuthState({
latestError:
error instanceof Error ? error : new Error(String(error)),
statusMessage: {
type: "error",
message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
},
isInitiatingAuth: false,
});
}
} else if (authorizationCode) {
updateAuthState({
authorizationCode,
oauthStep: "token_request",
});
}
},
[sseUrl],
);
useEffect(() => {
const loadOAuthTokens = async () => {
try {
if (sseUrl) {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl);
const tokens = sessionStorage.getItem(key);
if (tokens) {
const parsedTokens = await OAuthTokensSchema.parseAsync(
JSON.parse(tokens),
);
updateAuthState({
oauthTokens: parsedTokens,
oauthStep: "complete",
});
}
}
} catch (error) {
console.error("Error loading OAuth tokens:", error);
}
};
loadOAuthTokens();
}, [sseUrl]);
useEffect(() => {
const headers: HeadersInit = {};
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
if (proxyAuthToken) {
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
.then((response) => response.json())
.then((data) => {
setEnv(data.defaultEnvironment);
if (data.defaultCommand) {
setCommand(data.defaultCommand);
}
if (data.defaultArgs) {
setArgs(data.defaultArgs);
}
if (data.defaultTransport) {
setTransportType(
data.defaultTransport as "stdio" | "sse" | "streamable-http",
);
}
if (data.defaultServerUrl) {
setSseUrl(data.defaultServerUrl);
}
})
.catch((error) =>
console.error("Error fetching default environment:", error),
);
}, [config]);
useEffect(() => {
rootsRef.current = roots;
}, [roots]);
useEffect(() => {
if (mcpClient && !window.location.hash) {
const defaultTab = serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: serverCapabilities?.tasks
? "tasks"
: "ping";
window.location.hash = defaultTab;
} else if (!mcpClient && window.location.hash) {
// Clear hash when disconnected - completely remove the fragment
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
}, [mcpClient, serverCapabilities]);
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);
request?.resolve(result);
navigateToOriginatingTab(request?.originatingTab);
return prev.filter((r) => r.id !== id);
});
};
const handleRejectSampling = (id: number) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
navigateToOriginatingTab(request?.originatingTab);
return prev.filter((r) => r.id !== id);
});
};
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"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping",
"sampling",
"elicitations",
"roots",
"auth",
"metadata",
];
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 }));
};
const sendMCPRequest = async <T extends AnySchema>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
): Promise<SchemaOutput<T>> => {
try {
const response = await makeRequest(request, schema);
if (tabKey !== undefined) {
clearError(tabKey);
}
return response;
} catch (e) {
const errorString = (e as Error).message ?? String(e);
if (tabKey !== undefined) {
setErrors((prev) => ({
...prev,
[tabKey]: errorString,
}));
}
throw e;
}
};
const listResources = async () => {
const response = await sendMCPRequest(
{
method: "resources/list" as const,
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
},
ListResourcesResultSchema,
"resources",
);
setResources(resources.concat(response.resources ?? []));
setNextResourceCursor(response.nextCursor);
};
const listResourceTemplates = async () => {
const response = await sendMCPRequest(
{
method: "resources/templates/list" as const,
params: nextResourceTemplateCursor
? { cursor: nextResourceTemplateCursor }
: {},
},
ListResourceTemplatesResultSchema,
"resources",
);
setResourceTemplates(
resourceTemplates.concat(response.resourceTemplates ?? []),
);
setNextResourceTemplateCursor(response.nextCursor);
};
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
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) => {
if (fetchingResources.has(uri) || resourceContentMap[uri]) {
return;
}
console.log("[App] Reading resource:", uri);
setFetchingResources((prev) => new Set(prev).add(uri));
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
const response = await sendMCPRequest(
{
method: "resources/read" as const,
params: { uri },
},
ReadResourceResultSchema,
"resources",
);
console.log("[App] Resource read response:", {
uri,
responseLength: JSON.stringify(response).length,
hasContents: !!(response as { contents?: unknown[] }).contents,
});
const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
} catch (error) {
console.error(`[App] Failed to read resource ${uri}:`, error);
const errorString = (error as Error).message ?? String(error);
setResourceContentMap((prev) => ({
...prev,
[uri]: JSON.stringify({ error: errorString }),
}));
} finally {
setFetchingResources((prev) => {
const next = new Set(prev);
next.delete(uri);
return next;
});
}
};
const subscribeToResource = async (uri: string) => {
if (!resourceSubscriptions.has(uri)) {
await sendMCPRequest(
{
method: "resources/subscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.add(uri);
setResourceSubscriptions(clone);
}
};
const unsubscribeFromResource = async (uri: string) => {
if (resourceSubscriptions.has(uri)) {
await sendMCPRequest(
{
method: "resources/unsubscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.delete(uri);
setResourceSubscriptions(clone);
}
};
const listPrompts = async () => {
const response = await sendMCPRequest(
{
method: "prompts/list" as const,
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
},
ListPromptsResultSchema,
"prompts",
);
setPrompts(response.prompts);
setNextPromptCursor(response.nextCursor);
};
const listTools = async () => {
const response = await sendMCPRequest(
{
method: "tools/list" as const,
params: nextToolCursor ? { cursor: nextToolCursor } : {},
},
ListToolsResultSchema,
"tools",
);
setTools(response.tools);
setNextToolCursor(response.nextCursor);
cacheToolOutputSchemas(response.tools);
};
const callTool = async (
name: string,
params: Record<string, unknown>,
toolMetadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
// Find the tool schema to clean parameters properly
const tool = tools.find((t) => t.name === name);
const cleanedParams = tool?.inputSchema
? cleanParams(params, tool.inputSchema as JsonSchemaType)
: params;
// Merge general metadata with tool-specific metadata
// Tool-specific metadata takes precedence over general metadata
const mergedMetadata = {
...metadata, // General metadata
progressToken: progressTokenRef.current++,
...toolMetadata, // Tool-specific metadata
};
const request: ClientRequest = {
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: mergedMetadata,
},
};
if (runAsTask) {
request.params = {
...request.params,
task: {
ttl: getMCPTaskTtl(config),
},
};
}
const response = await sendMCPRequest(
request,
CompatibilityCallToolResultSchema,
"tools",
);
// Check if this was a task-augmented request that returned a task reference
// The server returns { task: { taskId, status, ... } } when a task is created
const isTaskResult = (
res: unknown,
): res is {
task: { taskId: string; status: string; pollInterval: number };
} =>
!!res &&
typeof res === "object" &&
"task" in res &&
!!res.task &&
typeof res.task === "object" &&
"taskId" in res.task;
if (runAsTask && isTaskResult(response)) {
const taskId = response.task.taskId;
const pollInterval = response.task.pollInterval;
// Set polling state BEFORE setting tool result for proper UI update
setIsPollingTask(true);
// Safely extract any _meta from the original response (if present)
const initialResponseMeta =
response &&
typeof response === "object" &&
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
: undefined;
setToolResult({
content: [
{
type: "text",
text: `Task created: ${taskId}. Polling for status...`,
},
],
_meta: {
...(initialResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
// Polling loop
let taskCompleted = false;
while (!taskCompleted) {
try {
// Wait for 1 second before polling
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const taskStatus = await sendMCPRequest(
{
method: "tasks/get",
params: { taskId },
},
GetTaskResultSchema,
);
if (
taskStatus.status === "completed" ||
taskStatus.status === "failed" ||
taskStatus.status === "cancelled"
) {
taskCompleted = true;
console.log(
`Polling complete for task ${taskId}: ${taskStatus.status}`,
);
if (taskStatus.status === "completed") {
console.log(`Fetching result for task ${taskId}`);
const result = await sendMCPRequest(
{
method: "tasks/result",
params: { taskId },
},
CompatibilityCallToolResultSchema,
);
console.log(`Result received for task ${taskId}:`, result);
setToolResult(result as CompatibilityCallToolResult);
// Refresh tasks list to show completed state
void listTasks();
} else {
setToolResult({
content: [
{
type: "text",
text: `Task ${taskStatus.status}: ${taskStatus.statusMessage || "No additional information"}`,
},
],
isError: true,
});
// Refresh tasks list to show failed/cancelled state
void listTasks();
}
} else {
// Update status message while polling
// Safely extract any _meta from the original response (if present)
const pollingResponseMeta =
response &&
typeof response === "object" &&
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ??
{})
: undefined;
setToolResult({
content: [
{
type: "text",
text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`,
},
],
_meta: {
...(pollingResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
// Refresh tasks list to show progress
void listTasks();
}
} catch (pollingError) {
console.error("Error polling task status:", pollingError);
setToolResult({
content: [
{
type: "text",
text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`,
},
],
isError: true,
});
taskCompleted = true;
}
}
setIsPollingTask(false);
} else {
setToolResult(response as CompatibilityCallToolResult);
}
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
{
type: "text",
text: (e as Error).message ?? String(e),
},
],
isError: true,
};
setToolResult(toolResult);
// Clear validation errors - tool execution errors are shown in ToolResults
setErrors((prev) => ({ ...prev, tools: null }));
}
};
const listTasks = useCallback(async () => {
try {
const response = await listMcpTasks(nextTaskCursor);
setTasks(response.tasks);
setNextTaskCursor(response.nextCursor);
// Inline error clear to avoid extra dependency on clearError
setErrors((prev) => ({ ...prev, tasks: null }));
} catch (e) {
setErrors((prev) => ({
...prev,
tasks: (e as Error).message ?? String(e),
}));
}
}, [listMcpTasks, nextTaskCursor]);
const cancelTask = async (taskId: string) => {
try {
const response = await cancelMcpTask(taskId);
setTasks((prev) => prev.map((t) => (t.taskId === taskId ? response : t)));
if (selectedTask?.taskId === taskId) {
setSelectedTask(response);
}
clearError("tasks");
} catch (e) {
setErrors((prev) => ({
...prev,
tasks: (e as Error).message ?? String(e),
}));
}
};
const handleRootsChange = async () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
const handleClearNotifications = () => {
setNotifications([]);
};
const sendLogLevelRequest = async (level: LoggingLevel) => {
await sendMCPRequest(
{
method: "logging/setLevel" as const,
params: { level },
},
z.object({}),
);
setLogLevel(level);
};
const AuthDebuggerWrapper = () => (
<TabsContent value="auth">
<AuthDebugger
serverUrl={sseUrl}
onBack={() => setIsAuthDebuggerVisible(false)}
authState={authState}
updateAuthState={updateAuthState}
/>
</TabsContent>
);
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback onConnect={onOAuthConnect} />
</Suspense>
);
}
if (window.location.pathname === "/oauth/callback/debug") {
const OAuthDebugCallback = React.lazy(
() => import("./components/OAuthDebugCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthDebugCallback onConnect={onOAuthDebugConnect} />
</Suspense>
);
}
return (
<div className="flex h-screen bg-background">
<div
style={{
width: sidebarWidth,
minWidth: 200,
maxWidth: 600,
transition: isSidebarDragging ? "none" : "width 0.15s",
}}
className="bg-card border-r border-border flex flex-col h-full relative"
>
<Sidebar
connectionStatus={connectionStatus}
transportType={transportType}
setTransportType={setTransportType}
command={command}
setCommand={setCommand}
args={args}
setArgs={setArgs}
sseUrl={sseUrl}
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthClientSecret={oauthClientSecret}
setOauthClientSecret={setOauthClientSecret}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
serverImplementation={serverImplementation}
/>
<div
onMouseDown={handleSidebarDragStart}
style={{
cursor: "col-resize",
position: "absolute",
top: 0,
right: 0,
width: 6,
height: "100%",
zIndex: 10,
background: isSidebarDragging ? "rgba(0,0,0,0.08)" : "transparent",
}}
aria-label="Resize sidebar"
data-testid="sidebar-drag-handle"
/>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs
value={activeTab}
className="w-full p-4"
onValueChange={(value) => {
setActiveTab(value);
window.location.hash = value;
}}
>
<TabsList className="mb-4 py-0">
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
<TabsTrigger
value="tasks"
disabled={!serverCapabilities?.tasks}
>
<ListTodo className="w-4 h-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="apps">
<AppWindow className="w-4 h-4 mr-2" />
Apps
</TabsTrigger>
<TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" />
Ping
</TabsTrigger>
<TabsTrigger value="sampling" className="relative">
<Hash className="w-4 h-4 mr-2" />
Sampling
{pendingSampleRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingSampleRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="elicitations" className="relative">
<MessageSquare className="w-4 h-4 mr-2" />
Elicitations
{pendingElicitationRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingElicitationRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="roots">
<FolderTree className="w-4 h-4 mr-2" />
Roots
</TabsTrigger>
<TabsTrigger value="auth">
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
<TabsTrigger value="metadata">
<Settings className="w-4 h-4 mr-2" />
Metadata
</TabsTrigger>
</TabsList>
<div className="w-full">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
The connected server does not support any MCP
capabilities
</p>
</div>
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
</>
) : (
<>
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
listResources={() => {
clearError("resources");
listResources();
}}
clearResources={() => {
setResources([]);
setNextResourceCursor(undefined);
}}
listResourceTemplates={() => {
clearError("resources");
listResourceTemplates();
}}
clearResourceTemplates={() => {
setResourceTemplates([]);
setNextResourceTemplateCursor(undefined);
}}
readResource={(uri) => {
clearError("resources");
readResource(uri);
}}
selectedResource={selectedResource}
setSelectedResource={(resource) => {
clearError("resources");
setSelectedResource(resource);
}}
resourceSubscriptionsSupported={
serverCapabilities?.resources?.subscribe || false
}
resourceSubscriptions={resourceSubscriptions}
subscribeToResource={(uri) => {
clearError("resources");
subscribeToResource(uri);
}}
unsubscribeFromResource={(uri) => {
clearError("resources");
unsubscribeFromResource(uri);
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
resourceContent={resourceContent}
nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor}
error={errors.resources}
/>
<PromptsTab
prompts={prompts}
listPrompts={() => {
clearError("prompts");
listPrompts();
}}
clearPrompts={() => {
setPrompts([]);
setNextPromptCursor(undefined);
}}
getPrompt={(name, args) => {
clearError("prompts");
getPrompt(name, args);
}}
selectedPrompt={selectedPrompt}
setSelectedPrompt={(prompt) => {
clearError("prompts");
setSelectedPrompt(prompt);
setPromptContent("");
}}
handleCompletion={handleCompletion}
completionsSupported={completionsSupported}
promptContent={promptContent}
nextCursor={nextPromptCursor}
error={errors.prompts}
/>
<ToolsTab
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
cacheToolOutputSchemas([]);
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params, metadata, runAsTask);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
clearError("tools");
setSelectedTool(tool);
setToolResult(null);
}}
toolResult={toolResult}
isPollingTask={isPollingTask}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
}}
/>
<TasksTab
tasks={tasks}
listTasks={() => {
clearError("tasks");
listTasks();
}}
clearTasks={() => {
setTasks([]);
setNextTaskCursor(undefined);
}}
cancelTask={cancelTask}
selectedTask={selectedTask}
setSelectedTask={(task) => {
clearError("tasks");
setSelectedTask(task);
}}
error={errors.tasks}
nextCursor={nextTaskCursor}
/>
<AppsTab
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
error={errors.tools}
mcpClient={mcpClient}
onNotification={(notification) => {
setNotifications((prev) => [...prev, notification]);
}}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
<SamplingTab
pendingRequests={pendingSampleRequests}
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<ElicitationTab
pendingRequests={pendingElicitationRequests}
onResolve={handleResolveElicitation}
/>
<RootsTab
roots={roots}
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
<MetadataTab
metadata={metadata}
onMetadataChange={handleMetadataChange}
/>
</>
)}
</div>
</Tabs>
) : isAuthDebuggerVisible ? (
<Tabs
defaultValue={"auth"}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<AuthDebuggerWrapper />
</Tabs>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
Connect to an MCP server to start inspecting
</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
Need to configure authentication?
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsAuthDebuggerVisible(true)}
>
Open Auth Settings
</Button>
</div>
</div>
)}
</div>
<div
className="relative border-t border-border"
style={{
height: `${historyPaneHeight}px`,
}}
>
<div
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
onMouseDown={handleDragStart}
>
<div className="w-8 h-1 rounded-full bg-border" />
</div>
<div className="h-full overflow-auto">
<HistoryAndNotifications
requestHistory={requestHistory}
serverNotifications={notifications}
onClearHistory={clearRequestHistory}
onClearNotifications={handleClearNotifications}
/>
</div>
</div>
</div>
</div>
);
};
export default App;