diff --git a/client/bin/start.js b/client/bin/start.js index 7e7e013af..f5a0f69a3 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -36,9 +36,15 @@ async function startDevServer(serverOptions) { abort, transport, serverUrl, + command, + mcpServerArgs, } = serverOptions; const serverCommand = "npx"; const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; + // Forward the MCP server command and arguments to the proxy server in dev mode + if (command) serverArgs.push(`--command=${command}`); + if (mcpServerArgs.length > 0) + serverArgs.push(`--args=${mcpServerArgs.join(" ")}`); const isWindows = process.platform === "win32"; const spawnOptions = { @@ -268,9 +274,9 @@ async function main() { } else { envVars[envVar] = ""; } - } else if (!command && !isDev) { + } else if (!command) { command = arg; - } else if (!isDev) { + } else { mcpServerArgs.push(arg); } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 39fc2812a..360acbed5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -75,6 +75,7 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; import TasksTab from "./components/TasksTab"; +import { Resizer } from "./components/Resizer"; import { InspectorConfig } from "./lib/configurationTypes"; import { getMCPProxyAddress, @@ -326,11 +327,17 @@ const App = () => { }, 100); }; - const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + const { + height: historyPaneHeight, + isDragging: isHistoryDragging, + handleDragStart, + toggleCollapse: toggleHistory, + } = useDraggablePane(300); const { width: sidebarWidth, isDragging: isSidebarDragging, handleDragStart: handleSidebarDragStart, + toggleCollapse: toggleSidebar, } = useDraggableSidebar(320); const selectedTaskRef = useRef(null); @@ -1215,57 +1222,50 @@ const App = () => {
- -
+ +
+
@@ -1477,6 +1477,7 @@ const App = () => { clearError("resources"); readResource(uri); }} + config={config} /> { className="relative border-t border-border" style={{ height: `${historyPaneHeight}px`, + transition: isHistoryDragging ? "none" : "height 0.15s", }} > -
-
-
+ onDoubleClick={toggleHistory} + className="absolute top-[-8px] left-0" + />
{ isValid: boolean; error: string | null }; - hasJsonError: () => boolean; } const isTypeSupported = ( @@ -130,10 +129,6 @@ const DynamicJsonForm = forwardRef( // on every keystroke which would be inefficient and error-prone const timeoutRef = useRef>(); - const hasJsonError = () => { - return !!jsonError; - }; - // Debounce JSON parsing and parent updates to handle typing gracefully const debouncedUpdateParent = useCallback( (jsonString: string) => { @@ -258,7 +253,6 @@ const DynamicJsonForm = forwardRef( useImperativeHandle(ref, () => ({ validateJson, - hasJsonError, })); const renderFormFields = ( diff --git a/client/src/components/HistoryAndNotifications.tsx b/client/src/components/HistoryAndNotifications.tsx index 1c6060712..fcfcc618c 100644 --- a/client/src/components/HistoryAndNotifications.tsx +++ b/client/src/components/HistoryAndNotifications.tsx @@ -2,6 +2,8 @@ import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { useState } from "react"; import JsonView from "./JsonView"; import { Button } from "@/components/ui/button"; +import { useResizable } from "../lib/hooks/useDraggablePane"; +import { Resizer } from "./Resizer"; const HistoryAndNotifications = ({ requestHistory, @@ -21,6 +23,19 @@ const HistoryAndNotifications = ({ [key: number]: boolean; }>({}); + const { + size: notificationsWidth, + isDragging, + handleDragStart, + toggleCollapse, + } = useResizable({ + initialSize: window.innerWidth / 2, + axis: "x", + reverse: true, + minSize: 0, + maxSize: window.innerWidth - 400, + }); + const toggleRequestExpansion = (index: number) => { setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] })); }; @@ -31,7 +46,7 @@ const HistoryAndNotifications = ({ return (
-
+

History

-
-
-

Server Notifications

- -
- {serverNotifications.length === 0 ? ( -

- No notifications yet -

- ) : ( -
    - {serverNotifications - .slice() - .reverse() - .map((notification, index) => ( -
  • -
    - toggleNotificationExpansion( - serverNotifications.length - 1 - index, - ) - } + +
    + + +
    +
    +

    Server Notifications

    + +
    + {serverNotifications.length === 0 ? ( +

    + No notifications yet +

    + ) : ( +
      + {serverNotifications + .slice() + .reverse() + .map((notification, index) => ( +
    • - - {serverNotifications.length - index}.{" "} - {notification.method} - - - {expandedNotifications[ - serverNotifications.length - 1 - index - ] - ? "▼" - : "▶"} - -
    - {expandedNotifications[ - serverNotifications.length - 1 - index - ] && ( -
    -
    - - Details: - -
    - +
    + toggleNotificationExpansion( + serverNotifications.length - 1 - index, + ) + } + > + + {serverNotifications.length - index}.{" "} + {notification.method} + + + {expandedNotifications[ + serverNotifications.length - 1 - index + ] + ? "▼" + : "▶"} +
    - )} -
  • - ))} -
- )} + {expandedNotifications[ + serverNotifications.length - 1 - index + ] && ( +
+
+ + Details: + +
+ +
+ )} + + ))} + + )} +
); diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 8cc2c1fe7..048ff2e3a 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -7,6 +7,7 @@ type ListPaneProps = { items: T[]; listItems: () => void; clearItems: () => void; + selectedItem?: T | null; setSelectedItem: (item: T) => void; renderItem: (item: T) => React.ReactNode; title: string; @@ -18,6 +19,7 @@ const ListPane = ({ items, listItems, clearItems, + selectedItem, setSelectedItem, renderItem, title, @@ -57,8 +59,8 @@ const ListPane = ({ return (
-
-
+
+

{title}

@@ -93,27 +95,33 @@ const ListPane = ({
- - +
+ + +
{filteredItems.map((item, index) => (
setSelectedItem(item)} > {renderItem(item)} diff --git a/client/src/components/Resizer.tsx b/client/src/components/Resizer.tsx new file mode 100644 index 000000000..f3500a30c --- /dev/null +++ b/client/src/components/Resizer.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; + +type ResizerProps = { + onMouseDown: (e: React.MouseEvent) => void; + onDoubleClick: () => void; + axis: "x" | "y"; + className?: string; +}; + +export const Resizer = ({ + onMouseDown, + onDoubleClick, + axis, + className, +}: ResizerProps) => { + const isX = axis === "x"; + + return ( +
+
+
+ ); +}; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 9e1b5d56b..bcdfd86b0 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -39,6 +39,7 @@ import { useEffect, useState, useRef } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; import ToolResults from "./ToolResults"; +import { Resizer } from "./Resizer"; import { useToast } from "@/lib/hooks/useToast"; import useCopy from "@/lib/hooks/useCopy"; import IconDisplay, { WithIcons } from "./IconDisplay"; @@ -52,6 +53,9 @@ import { isReservedMetaKey, } from "@/utils/metaUtils"; +import { useResizable } from "../lib/hooks/useDraggablePane"; +import { InspectorConfig } from "@/lib/configurationTypes"; + // Type guard to safely detect the optional _meta field without using `any` const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } => typeof (tool as { _meta?: unknown })._meta !== "undefined"; @@ -69,6 +73,7 @@ const ToolsTab = ({ error, resourceContent, onReadResource, + config, }: { tools: Tool[]; listTools: () => void; @@ -87,6 +92,7 @@ const ToolsTab = ({ error: string | null; resourceContent: Record; onReadResource?: (uri: string) => void; + config: InspectorConfig; }) => { const [params, setParams] = useState>({}); const [runAsTask, setRunAsTask] = useState(false); @@ -101,17 +107,72 @@ const ToolsTab = ({ const { toast } = useToast(); const { copied, setCopied } = useCopy(); + const { + size: listWidth, + isDragging, + handleDragStart, + toggleCollapse, + } = useResizable({ + initialSize: 350, + axis: "x", + minSize: 200, + maxSize: 600, + }); + + const enableHistory = config.MCP_ENABLE_BROWSER_HISTORY?.value !== false; + // Function to check if any form has validation errors - const checkValidationErrors = (validateChildren: boolean = false) => { + const checkValidationErrors = () => { const errors = Object.values(formRefs.current).some( - (ref) => - ref && - (validateChildren ? !ref.validateJson().isValid : ref.hasJsonError()), + (ref) => ref && !ref.validateJson().isValid, ); setHasValidationErrors(errors); return errors; }; + const handleSubmit = async (e: React.FormEvent) => { + const enableHistory = config.MCP_ENABLE_BROWSER_HISTORY?.value !== false; + + // Note: If history is enabled, we do NOT preventDefault here to allow the + // browser to see a successful submission to the hidden iframe. + if (!enableHistory) { + e.preventDefault(); + } + + // Validate JSON inputs before calling tool + if (checkValidationErrors()) { + e.preventDefault(); // Always stop submission if there are validation errors + return; + } + + try { + setIsToolRunning(true); + const metadata = metadataEntries.reduce>( + (acc, { key, value }) => { + const trimmedKey = key.trim(); + if ( + trimmedKey !== "" && + hasValidMetaPrefix(trimmedKey) && + !isReservedMetaKey(trimmedKey) && + hasValidMetaName(trimmedKey) + ) { + acc[trimmedKey] = value; + } + return acc; + }, + {}, + ); + await callTool( + selectedTool!.name, + params, + Object.keys(metadata).length ? metadata : undefined, + runAsTask, + ); + } finally { + setIsToolRunning(false); + } + }; + useEffect(() => { const params = Object.entries( selectedTool?.inputSchema.properties ?? [], @@ -157,37 +218,54 @@ const ToolsTab = ({ return ( -
- { - clearTools(); - setSelectedTool(null); - setRunAsTask(false); +
+
( -
-
- -
-
- {tool.name} - - {tool.description} - -
- -
- )} - title="Tools" - buttonText={nextCursor ? "List More Tools" : "List Tools"} - isButtonDisabled={!nextCursor && tools.length > 0} - /> + > +
+ { + clearTools(); + setSelectedTool(null); + setRunAsTask(false); + }} + selectedItem={selectedTool} + setSelectedItem={setSelectedTool} + renderItem={(tool) => ( +
+
+ +
+
+ {tool.name} + + {tool.description} + +
+ +
+ )} + title="Tools" + buttonText={nextCursor ? "List More Tools" : "List Tools"} + isButtonDisabled={!nextCursor && tools.length > 0} + /> +
+ +
-
-
+
+
{selectedTool && ( {selectedTool.description}

- {Object.entries(selectedTool.inputSchema.properties ?? []).map( - ([key, value]) => { + {enableHistory && ( + /* + * Hidden iframe hack: Browsers only save autocomplete history for standard form + * submissions. By targeting a hidden iframe and allowing the event to bubble, + * we trick the browser into recording the input values without a page reload. + */ +