diff --git a/app/src/components/channels/mcp/InstalledServerDetail.test.tsx b/app/src/components/channels/mcp/InstalledServerDetail.test.tsx index 6730863fe..dd464c97f 100644 --- a/app/src/components/channels/mcp/InstalledServerDetail.test.tsx +++ b/app/src/components/channels/mcp/InstalledServerDetail.test.tsx @@ -200,4 +200,111 @@ describe('InstalledServerDetail', () => { // last_error shown in the error banner expect(screen.getByText('Timed out')).toBeInTheDocument(); }); + + // ---------------------------------------------------------------------- + // Tool Execution Playground gating (PR review fix) + // ---------------------------------------------------------------------- + + /** + * Disconnected → connect → connected re-render flow. Returns the + * rerender function so the caller can flip status further. By the + * time this resolves the playground modal is open against the + * `read_file` tool from the mocked connect result. + */ + const setupOpenPlayground = async () => { + mockConnect.mockResolvedValue({ + server_id: 'srv-1', + status: 'connected', + tools: [{ name: 'read_file', description: 'reads', input_schema: {} }], + }); + const disconnectedStatus = { + server_id: 'srv-1', + qualified_name: 'acme/test-server', + display_name: 'Test Server', + status: 'disconnected' as const, + tool_count: 0, + }; + const connectedStatus = { ...disconnectedStatus, status: 'connected' as const, tool_count: 1 }; + const { rerender } = render( + {}} + /> + ); + // Click Connect — fills the local `tools` state via the mocked RPC. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + }); + // Parent would now flip status to connected (driven by its poll + // loop after install/connect succeeds); simulate that here. + rerender( + {}} + /> + ); + // Expand the tool list to reveal the Try button, then click Try. + fireEvent.click(screen.getByRole('button', { name: /tool available/i })); + fireEvent.click( + screen.getByRole('button', { name: 'Open execution playground for read_file' }) + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + return { rerender, connectedStatus }; + }; + + it('clears the playground when Disconnect is clicked (handler path)', async () => { + mockDisconnect.mockResolvedValue({ server_id: 'srv-1', status: 'disconnected' }); + await setupOpenPlayground(); + // Click Disconnect — handler calls setPlaygroundTool(null). + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + }); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('hides the playground via the render gate when status flips externally', async () => { + const { rerender, connectedStatus } = await setupOpenPlayground(); + // External status flip (e.g. driven by the parent's poll loop). + // The gate `status === "connected"` must hide the modal even + // though no handler ran inside the detail component. + rerender( + {}} + /> + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('clears playground STATE on external status flip, so it does not reappear on reconnect', async () => { + const { rerender, connectedStatus } = await setupOpenPlayground(); + // Poll-driven flip away from connected. The render gate hides the modal, + // and the status-watching effect must additionally clear playgroundTool. + rerender( + {}} + /> + ); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + // Reconnect. If the effect only relied on the render gate (state still + // set), the modal would spring back open here. With the state cleared it + // must stay closed until the user explicitly clicks Try again. + rerender( + {}} + /> + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/components/channels/mcp/InstalledServerDetail.tsx b/app/src/components/channels/mcp/InstalledServerDetail.tsx index 0c1bcb98e..d4641e4ee 100644 --- a/app/src/components/channels/mcp/InstalledServerDetail.tsx +++ b/app/src/components/channels/mcp/InstalledServerDetail.tsx @@ -10,6 +10,7 @@ import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; import ConfigAssistantPanel from './ConfigAssistantPanel'; import McpStatusBadge from './McpStatusBadge'; import McpToolList from './McpToolList'; +import McpToolPlayground from './McpToolPlayground'; import type { ConnStatus, InstalledServer, McpTool, ServerStatus } from './types'; const log = debug('mcp-clients:detail'); @@ -33,6 +34,27 @@ const InstalledServerDetail = ({ const [confirmUninstall, setConfirmUninstall] = useState(false); const [showAssistant, setShowAssistant] = useState(false); const [suggestedEnv, setSuggestedEnv] = useState | null>(null); + // When non-null, the Tool Execution Playground modal is rendered for + // this tool. Cleared on close. Only meaningful while the server is + // connected (the gate is enforced at the McpToolList rendering site). + const [playgroundTool, setPlaygroundTool] = useState(null); + + // Poll-driven safety net: if the server leaves `connected` by ANY path — + // background status poll, parent prop change, auth expiry — not just the + // explicit disconnect/uninstall handlers, drop the staged playground so its + // now-unreachable tool can't be run AND doesn't spring back open when the + // server reconnects. Implemented via React's "adjust state while rendering" + // pattern (store the previous status, reset on change) rather than an + // effect — same result without the extra render pass or the + // set-state-in-effect lint. The render gate below is the belt-and-suspenders + // guard for the single render before this runs. + const [prevStatus, setPrevStatus] = useState(status); + if (status !== prevStatus) { + setPrevStatus(status); + if (status !== 'connected' && playgroundTool) { + setPlaygroundTool(null); + } + } const runBusy = useCallback(async (task: () => Promise) => { setBusy(true); @@ -63,6 +85,11 @@ const InstalledServerDetail = ({ await mcpClientsApi.disconnect(server.server_id); // Clear stale tool list so it doesn't show after disconnection. setTools([]); + // Drop any open Tool Execution Playground — its tool is no longer + // reachable on this server. The render gate below ALSO enforces + // this, but clearing the state here releases any in-flight async + // work the modal was holding (history, copy timer, etc.). + setPlaygroundTool(null); log('disconnected'); }); }, [server.server_id, runBusy]); @@ -71,6 +98,10 @@ const InstalledServerDetail = ({ void runBusy(async () => { log('uninstalling server_id=%s', server.server_id); await mcpClientsApi.uninstall(server.server_id); + // The detail view is about to unmount via onUninstalled, but + // clear explicitly so there's no window during which the modal + // points at a now-removed server. + setPlaygroundTool(null); log('uninstalled'); onUninstalled(server.server_id); }); @@ -211,12 +242,17 @@ const InstalledServerDetail = ({ )} - {/* Tool list — only show when connected so stale tools don't linger */} + {/* Tool list — only show when connected so stale tools don't linger. + When connected, each tool gets a "Try" button via `onTryTool` + that opens the Tool Execution Playground modal below. */}

{t('mcp.detail.tools')}

- +
{/* Config assistant */} @@ -228,6 +264,22 @@ const InstalledServerDetail = ({ /> )} + + {/* Tool Execution Playground modal. Gated on BOTH a selected tool + AND a live connection — a disconnected server's tool list is + stale by definition, and the upstream RPC will reject calls + anyway. The handlers above also clear `playgroundTool` on + explicit disconnect / uninstall; this gate is the safety net + for any state path that flips `status` without going through + those handlers (poll-driven status change, parent forcing a + reconnect, etc.). */} + {playgroundTool && status === 'connected' && ( + setPlaygroundTool(null)} + /> + )} ); }; diff --git a/app/src/components/channels/mcp/McpToolList.test.tsx b/app/src/components/channels/mcp/McpToolList.test.tsx index a98dddbc0..cfd0bc8eb 100644 --- a/app/src/components/channels/mcp/McpToolList.test.tsx +++ b/app/src/components/channels/mcp/McpToolList.test.tsx @@ -1,8 +1,8 @@ /** - * Tests for McpToolList — collapsible tool list. + * Tests for McpToolList — collapsible tool list with optional Try button. */ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; import McpToolList from './McpToolList'; import type { McpTool } from './types'; @@ -49,14 +49,21 @@ describe('McpToolList', () => { it('does not render description paragraph when description is undefined', () => { render(); fireEvent.click(screen.getByRole('button', { name: /tools available/i })); - // list_dir has no description — only 2 description paragraphs should exist - const descriptions = screen - .getAllByRole('listitem') - .filter(item => item.querySelector('p + p')); - // We expect 2 of the 3 items to have a description paragraph - expect(screen.getByText('Reads a file from disk')).toBeInTheDocument(); + // Each described tool's description text is rendered exactly where + // expected — inside its own list-item row. + const readFileItem = screen.getByText('read_file').closest('li')!; + const writeFileItem = screen.getByText('write_file').closest('li')!; + expect(within(readFileItem).getByText('Reads a file from disk')).toBeInTheDocument(); + expect(within(writeFileItem).getByText('Writes data to a file')).toBeInTheDocument(); + // Behaviour-level assertion for the description-less tool: its row + // contains only the tool name (no Try button is rendered because + // `onTryTool` isn't passed in this test), so the row's full visible + // text is exactly the name with no description content. + const listDirItem = screen.getByText('list_dir').closest('li')!; + expect(listDirItem.textContent?.trim()).toBe('list_dir'); + // And the literal string 'undefined' must never appear (would + // indicate the conditional `{tool.description && …}` was bypassed). expect(screen.queryByText('undefined')).not.toBeInTheDocument(); - expect(descriptions).toHaveLength(2); }); it('collapses again when toggle button is clicked twice', () => { @@ -83,4 +90,49 @@ describe('McpToolList', () => { fireEvent.click(screen.getByRole('button', { name: /tools available/i })); expect(arrow.className).toMatch(/rotate-90/); }); + + // --------------------------------------------------------------------- + // Try-button (the optional onTryTool integration with the playground) + // --------------------------------------------------------------------- + + it('does NOT render any "Try" button when onTryTool is omitted', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /tools available/i })); + expect(screen.queryByRole('button', { name: /Try/i })).not.toBeInTheDocument(); + }); + + it('renders a "Try" button per tool when onTryTool is provided', () => { + render( {}} />); + fireEvent.click(screen.getByRole('button', { name: /tools available/i })); + // One per tool, accessible name = "Open execution playground for {name}" + expect( + screen.getByRole('button', { name: 'Open execution playground for read_file' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Open execution playground for write_file' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Open execution playground for list_dir' }) + ).toBeInTheDocument(); + }); + + it('clicking "Try" invokes onTryTool with the corresponding tool object', () => { + const onTryTool = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /tools available/i })); + fireEvent.click( + screen.getByRole('button', { name: 'Open execution playground for write_file' }) + ); + expect(onTryTool).toHaveBeenCalledTimes(1); + expect(onTryTool).toHaveBeenCalledWith(TOOLS[1]); // write_file + }); + + it('Try button is shown for tools without a description as well', () => { + const onTryTool = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /tool available/i })); + expect( + screen.getByRole('button', { name: 'Open execution playground for list_dir' }) + ).toBeInTheDocument(); + }); }); diff --git a/app/src/components/channels/mcp/McpToolList.tsx b/app/src/components/channels/mcp/McpToolList.tsx index 40425d424..33eb44140 100644 --- a/app/src/components/channels/mcp/McpToolList.tsx +++ b/app/src/components/channels/mcp/McpToolList.tsx @@ -1,5 +1,11 @@ /** * Collapsible list of MCP tools with name and description. + * + * Optionally renders a per-tool "Try" button when `onTryTool` is + * provided — clicking it hands the selected tool back to the parent so + * it can open the Tool Execution Playground. When the prop is absent + * the list stays purely informational (preserving the original API for + * any other call site). */ import { useState } from 'react'; @@ -8,9 +14,11 @@ import type { McpTool } from './types'; interface McpToolListProps { tools: McpTool[]; + /** When provided, each tool gets a "Try" button that calls this with that tool. */ + onTryTool?: (tool: McpTool) => void; } -const McpToolList = ({ tools }: McpToolListProps) => { +const McpToolList = ({ tools, onTryTool }: McpToolListProps) => { const { t } = useT(); const [expanded, setExpanded] = useState(false); // Guard against undefined/null passed at runtime (TypeScript can't always prevent this). @@ -40,9 +48,20 @@ const McpToolList = ({ tools }: McpToolListProps) => {
    {safeTools.map(tool => (
  • -

    - {tool.name} -

    +
    +

    + {tool.name} +

    + {onTryTool && ( + + )} +
    {tool.description && (

    {tool.description} diff --git a/app/src/components/channels/mcp/McpToolPlayground.test.tsx b/app/src/components/channels/mcp/McpToolPlayground.test.tsx new file mode 100644 index 000000000..5fd0a7201 --- /dev/null +++ b/app/src/components/channels/mcp/McpToolPlayground.test.tsx @@ -0,0 +1,458 @@ +/** + * Tests for McpToolPlayground — interactive tool execution modal. + * + * Covers: schema viewer, JSON args validation, Run flow, success / error + * result rendering, Cmd-Enter shortcut, Esc to close, in-session history + * with one-click "load", copy-to-clipboard, and the a11y attribute + * contract on dialog + result regions. + */ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import McpToolPlayground, { parseToolArgs } from './McpToolPlayground'; +import type { McpTool } from './types'; + +const TOOL: McpTool = { + name: 'read_file', + description: 'Reads a file from disk and returns its contents.', + input_schema: { + type: 'object', + properties: { path: { type: 'string', description: 'Absolute path.' } }, + required: ['path'], + }, +}; + +const mockToolCall = vi.fn(); +vi.mock('../../../services/api/mcpClientsApi', () => ({ + mcpClientsApi: { toolCall: (...args: unknown[]) => mockToolCall(...args) }, +})); + +beforeEach(() => { + mockToolCall.mockReset(); +}); + +const renderPlayground = (overrides?: { + tool?: McpTool; + serverId?: string; + onClose?: () => void; +}) => + render( + {})} + /> + ); + +describe('McpToolPlayground', () => { + // ---------------------------------------------------------------------- + // Layout + a11y + // ---------------------------------------------------------------------- + + it('renders an accessible modal dialog with the tool name in the title', () => { + renderPlayground(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'mcp-playground-title'); + expect(screen.getByText('Run read_file')).toBeInTheDocument(); + }); + + it('renders the tool description when present', () => { + renderPlayground(); + expect( + screen.getByText('Reads a file from disk and returns its contents.') + ).toBeInTheDocument(); + }); + + it('does not render a description block when the tool has none', () => { + renderPlayground({ tool: { ...TOOL, description: undefined } }); + expect( + screen.queryByText('Reads a file from disk and returns its contents.') + ).not.toBeInTheDocument(); + }); + + it('exposes a close button with an accessible label', () => { + renderPlayground(); + expect(screen.getByRole('button', { name: 'Close playground' })).toBeInTheDocument(); + }); + + it('focuses the args textarea on mount', () => { + renderPlayground(); + expect(screen.getByLabelText('Arguments (JSON)')).toHaveFocus(); + }); + + // ---------------------------------------------------------------------- + // Schema viewer (collapsible) + // ---------------------------------------------------------------------- + + it('does not render the schema body until the toggle is clicked', () => { + renderPlayground(); + expect(screen.queryByTestId('mcp-playground-schema')).not.toBeInTheDocument(); + }); + + it('renders the input schema as formatted JSON when expanded', () => { + renderPlayground(); + fireEvent.click(screen.getByRole('button', { name: /Input schema/i })); + const schemaPre = screen.getByTestId('mcp-playground-schema'); + expect(schemaPre.textContent).toContain('"type": "object"'); + expect(schemaPre.textContent).toContain('"required"'); + }); + + // ---------------------------------------------------------------------- + // Close behaviours: Esc, button, backdrop click + // ---------------------------------------------------------------------- + + it('calls onClose when Escape is pressed', () => { + const onClose = vi.fn(); + renderPlayground({ onClose }); + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the close button is clicked', () => { + const onClose = vi.fn(); + renderPlayground({ onClose }); + fireEvent.click(screen.getByRole('button', { name: 'Close playground' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the backdrop is mousedown-ed but NOT when the dialog card is', () => { + const onClose = vi.fn(); + renderPlayground({ onClose }); + const dialog = screen.getByRole('dialog'); + // Mousedown on the backdrop itself (the dialog div) — target === currentTarget + fireEvent.mouseDown(dialog); + expect(onClose).toHaveBeenCalledTimes(1); + // Mousedown on a descendant (the title) should NOT close + fireEvent.mouseDown(screen.getByText('Run read_file')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // ---------------------------------------------------------------------- + // Args validation + Format + // ---------------------------------------------------------------------- + + it('starts with empty-object args ({}) in the textarea', () => { + renderPlayground(); + expect(screen.getByLabelText('Arguments (JSON)')).toHaveValue('{}'); + }); + + it('refuses to call the RPC when the args JSON is malformed and shows an alert', async () => { + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: '{not valid json' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + expect(mockToolCall).not.toHaveBeenCalled(); + const alert = screen.getByRole('alert'); + expect(alert.textContent).toMatch(/Invalid JSON/); + }); + + it('treats an empty / whitespace-only args field as {}', async () => { + mockToolCall.mockResolvedValue({ result: { ok: true }, is_error: false }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: ' ' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + expect(mockToolCall).toHaveBeenCalledWith({ + server_id: 'srv-1', + tool_name: 'read_file', + arguments: {}, + }); + }); + + it('reformats invalid JSON gracefully (Format leaves it untouched)', () => { + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: '{not valid' } }); + fireEvent.click(screen.getByRole('button', { name: 'Format' })); + // No throw, original preserved + expect(textarea).toHaveValue('{not valid'); + }); + + it('pretty-prints valid JSON on Format', () => { + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: '{"path":"/etc/hosts","limit":10}' } }); + fireEvent.click(screen.getByRole('button', { name: 'Format' })); + expect(textarea).toHaveValue('{\n "path": "/etc/hosts",\n "limit": 10\n}'); + }); + + // ---------------------------------------------------------------------- + // Run flow — success, tool error, RPC exception + // ---------------------------------------------------------------------- + + it('calls mcpClientsApi.toolCall with parsed args and renders success result', async () => { + mockToolCall.mockResolvedValue({ + result: { contents: 'hello world', bytes: 11 }, + is_error: false, + }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: '{"path":"/etc/hosts"}' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + expect(mockToolCall).toHaveBeenCalledWith({ + server_id: 'srv-1', + tool_name: 'read_file', + arguments: { path: '/etc/hosts' }, + }); + const result = screen.getByTestId('mcp-playground-result'); + expect(result).toHaveAttribute('role', 'status'); + expect(result).toHaveAttribute('aria-live', 'polite'); + expect(result.textContent).toContain('"contents": "hello world"'); + expect(result.textContent).toContain('"bytes": 11'); + }); + + it('flags is_error=true returns with role=alert and the error label', async () => { + mockToolCall.mockResolvedValue({ result: { message: 'denied' }, is_error: true }); + renderPlayground(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + expect(screen.getByText('Tool returned an error')).toBeInTheDocument(); + const result = screen.getByTestId('mcp-playground-result'); + expect(result).toHaveAttribute('role', 'alert'); + expect(result).toHaveAttribute('aria-live', 'assertive'); + expect(result.textContent).toContain('"message": "denied"'); + }); + + it('renders thrown RPC exceptions as an error result', async () => { + mockToolCall.mockRejectedValue(new Error('socket closed')); + renderPlayground(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + const result = screen.getByTestId('mcp-playground-result'); + expect(result).toHaveAttribute('role', 'alert'); + expect(result.textContent).toContain('socket closed'); + }); + + it('falls back to a generic message when the rejection value is not an Error', async () => { + mockToolCall.mockRejectedValue('plain-string'); + renderPlayground(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + const result = screen.getByTestId('mcp-playground-result'); + expect(result.textContent).toContain('Unexpected error invoking tool.'); + }); + + it('disables the Run button while the call is in flight', async () => { + let resolve: ((v: unknown) => void) | undefined; + mockToolCall.mockImplementation( + () => + new Promise(res => { + resolve = res; + }) + ); + renderPlayground(); + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + const runBtn = screen.getByRole('button', { name: /Running…|Run tool/ }); + expect(runBtn).toBeDisabled(); + expect(runBtn).toHaveTextContent('Running…'); + await act(async () => { + resolve?.({ result: 'ok', is_error: false }); + }); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Run tool' })).not.toBeDisabled(); + }); + }); + + // ---------------------------------------------------------------------- + // Cmd/Ctrl+Enter shortcut + // ---------------------------------------------------------------------- + + it('runs on Cmd+Enter from the args textarea', async () => { + mockToolCall.mockResolvedValue({ result: 'ok', is_error: false }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }); + }); + expect(mockToolCall).toHaveBeenCalledTimes(1); + }); + + it('runs on Ctrl+Enter from the args textarea', async () => { + mockToolCall.mockResolvedValue({ result: 'ok', is_error: false }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + }); + expect(mockToolCall).toHaveBeenCalledTimes(1); + }); + + it('does not run on Enter without a modifier key', () => { + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.keyDown(textarea, { key: 'Enter' }); + expect(mockToolCall).not.toHaveBeenCalled(); + }); + + // ---------------------------------------------------------------------- + // History + // ---------------------------------------------------------------------- + + it('records successful invocations in history and exposes a Load button per entry', async () => { + mockToolCall.mockResolvedValue({ result: 'ok-1', is_error: false }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: '{"path":"/a"}' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + fireEvent.click(screen.getByRole('button', { name: /History/i })); + // Scope to the history list — the args also appear in the textarea + // (its `value`), and testing-library matches textarea value as text + // content. The history list is the only

      in the dialog. + const historyList = screen.getByRole('list'); + expect(within(historyList).getByText('{"path":"/a"}')).toBeInTheDocument(); + }); + + it('Load from history restores the prior args into the textarea', async () => { + mockToolCall.mockResolvedValue({ result: 'ok', is_error: false }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + fireEvent.change(textarea, { target: { value: '{"path":"/orig"}' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + // Change args to something else + fireEvent.change(textarea, { target: { value: '{"path":"/changed"}' } }); + expect(textarea).toHaveValue('{"path":"/changed"}'); + // Expand history and click Load on the saved invocation. The Load + // button's aria-label is just 'Load'; there's exactly one Load + // button per history entry. + fireEvent.click(screen.getByRole('button', { name: /History/i })); + fireEvent.click(screen.getByRole('button', { name: 'Load' })); + expect(textarea).toHaveValue('{"path":"/orig"}'); + }); + + it('caps history at 10 entries (oldest entries fall off)', async () => { + mockToolCall.mockResolvedValue({ result: 'ok', is_error: false }); + renderPlayground(); + const textarea = screen.getByLabelText('Arguments (JSON)'); + for (let i = 0; i < 12; i += 1) { + fireEvent.change(textarea, { target: { value: `{"i":${i}}` } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + } + fireEvent.click(screen.getByRole('button', { name: /History/i })); + const historyList = screen.getByRole('list'); + // Most-recent first: i=11..2 should be present (10 entries); i=0 + // and i=1 should be gone. Scope to the history list so the + // textarea's current value (which testing-library treats as text + // content for a textarea) doesn't double-match. + expect(within(historyList).getByText('{"i":11}')).toBeInTheDocument(); + expect(within(historyList).getByText('{"i":2}')).toBeInTheDocument(); + expect(within(historyList).queryByText('{"i":0}')).not.toBeInTheDocument(); + expect(within(historyList).queryByText('{"i":1}')).not.toBeInTheDocument(); + // Total entries + expect(within(historyList).getAllByRole('listitem')).toHaveLength(10); + }); + + it('records error invocations in history (with the error styling marker)', async () => { + mockToolCall.mockRejectedValue(new Error('boom')); + renderPlayground(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + fireEvent.click(screen.getByRole('button', { name: /History/i })); + // Empty placeholder must NOT be visible — we have one history entry. + expect(screen.queryByText('No invocations yet in this session.')).not.toBeInTheDocument(); + }); + + it('shows the empty-history placeholder before any run', () => { + renderPlayground(); + fireEvent.click(screen.getByRole('button', { name: /History/i })); + expect(screen.getByText('No invocations yet in this session.')).toBeInTheDocument(); + }); + + // ---------------------------------------------------------------------- + // Copy-feedback state reset (PR review fix) + // ---------------------------------------------------------------------- + + it('resets the "Copied" copy-feedback label when a new run starts', async () => { + // Mock navigator.clipboard.writeText so the copy path actually runs + // in jsdom (which doesn't ship a clipboard by default). + const writeText = vi.fn().mockResolvedValue(undefined); + const originalClipboard = (navigator as { clipboard?: unknown }).clipboard; + Object.defineProperty(navigator, 'clipboard', { + writable: true, + configurable: true, + value: { writeText }, + }); + try { + mockToolCall.mockResolvedValue({ result: 'first', is_error: false }); + renderPlayground(); + // 1st run — produces a result so the Copy button appears. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + // Click Copy — copyStatus flips to 'copied' and the label changes. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy result' })); + }); + expect(screen.getByRole('button', { name: 'Copy result' })).toHaveTextContent('Copied'); + expect(writeText).toHaveBeenCalledWith('"first"'); + // 2nd run — handleRun resets copyStatus to 'idle' so the label + // returns to 'Copy result' immediately (without waiting for the + // 1.5s timeout). + mockToolCall.mockResolvedValue({ result: 'second', is_error: false }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + }); + expect(screen.getByRole('button', { name: 'Copy result' })).toHaveTextContent('Copy result'); + } finally { + // Restore (or remove) the clipboard property so other tests in + // the suite don't see this stub. + if (originalClipboard === undefined) { + delete (navigator as { clipboard?: unknown }).clipboard; + } else { + Object.defineProperty(navigator, 'clipboard', { + writable: true, + configurable: true, + value: originalClipboard, + }); + } + } + }); +}); + +describe('parseToolArgs', () => { + it('treats empty / whitespace-only input as an empty object', () => { + expect(parseToolArgs('', 'fallback')).toEqual({ ok: true, value: {} }); + expect(parseToolArgs(' \n\t', 'fallback')).toEqual({ ok: true, value: {} }); + }); + + it('parses valid JSON into its value', () => { + expect(parseToolArgs('{"path":"/tmp/x"}', 'fallback')).toEqual({ + ok: true, + value: { path: '/tmp/x' }, + }); + }); + + it('returns ok:false with the parser message on invalid JSON', () => { + const result = parseToolArgs('{not valid', 'fallback message'); + expect(result.ok).toBe(false); + if (!result.ok) { + // The real parser message is surfaced (not the fallback) when available. + expect(result.error.length).toBeGreaterThan(0); + } + }); + + it('falls back to the provided message when the error is not an Error', () => { + // JSON.parse throws SyntaxError (an Error), so the fallback path is hard to + // hit naturally; this pins the contract that a non-empty error is returned. + const result = parseToolArgs('nope', 'fallback message'); + expect(result.ok).toBe(false); + }); +}); diff --git a/app/src/components/channels/mcp/McpToolPlayground.tsx b/app/src/components/channels/mcp/McpToolPlayground.tsx new file mode 100644 index 000000000..eda5fde10 --- /dev/null +++ b/app/src/components/channels/mcp/McpToolPlayground.tsx @@ -0,0 +1,450 @@ +/** + * Tool Execution Playground — modal for interactively invoking a single + * MCP tool against a connected server. + * + * Lives next to `McpToolList`: clicking the "Try" button on a tool opens + * this modal. The parent (`InstalledServerDetail`) holds the `serverId` + * and the currently-targeted `tool`; this component renders the modal + * UI and orchestrates the round-trip through `mcpClientsApi.toolCall`. + * + * Features: + * - JSON args editor with validate + format buttons; Cmd/Ctrl+Enter to + * run; Esc to close (does NOT trigger run). + * - Result/error display with copy-to-clipboard. + * - In-session invocation history (last 10) with one-click "load" to + * restore an earlier set of args. + * - Collapsible input-schema viewer so callers can see the JSON-schema + * contract before composing args. + * + * Intentional non-features: + * - No JSON-schema-driven form generation. Args are typed as raw JSON; + * keeps the surface predictable and avoids re-implementing JSON-schema + * coercion semantics (the upstream tool can validate stricter). + * - No persistence across modal closes. History is session-only; this + * is a debug/exploration surface, not a saved workspace. + * - No global keyboard shortcut for opening the modal (would clash with + * the app-wide CommandProvider). + */ +import { + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { mcpClientsApi } from '../../../services/api/mcpClientsApi'; +import type { McpTool } from './types'; + +interface McpToolPlaygroundProps { + serverId: string; + tool: McpTool; + onClose: () => void; +} + +interface InvocationRecord { + /** Local-timezone HH:MM:SS string captured at submit. */ + timestamp: string; + /** Raw args string the user submitted. */ + argsJson: string; + /** JSON-stringified result if the tool returned successfully. */ + resultText: string; + /** True if the tool itself reported is_error OR an exception was thrown. */ + isError: boolean; +} + +const HISTORY_LIMIT = 10; +const EMPTY_ARGS = '{}'; + +/** + * Try to pretty-print whatever value the user typed. Returns the + * original string unchanged if it isn't valid JSON — never throws. + */ +const formatArgs = (raw: string): string => { + const trimmed = raw.trim(); + if (!trimmed) return EMPTY_ARGS; + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return raw; + } +}; + +/** + * Parse the args textarea into a value for the tool call. Empty input is + * treated as `{}`. Returns a discriminated result rather than throwing so the + * caller can keep JSON-parse failures (user input) cleanly separate from RPC + * failures (the actual tool call) — they surface to the user differently. + */ +export type ParsedToolArgs = { ok: true; value: unknown } | { ok: false; error: string }; + +export const parseToolArgs = (argsJson: string, fallbackMessage: string): ParsedToolArgs => { + if (argsJson.trim() === '') return { ok: true, value: {} }; + try { + return { ok: true, value: JSON.parse(argsJson) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : fallbackMessage }; + } +}; + +const stringifyResult = (value: unknown): string => { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const formatTimestamp = (date: Date): string => + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +const McpToolPlayground = ({ serverId, tool, onClose }: McpToolPlaygroundProps) => { + const { t } = useT(); + const [argsJson, setArgsJson] = useState(EMPTY_ARGS); + const [parseError, setParseError] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [resultText, setResultText] = useState(null); + const [resultIsError, setResultIsError] = useState(false); + const [showSchema, setShowSchema] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle'); + const [history, setHistory] = useState([]); + const argsTextareaRef = useRef(null); + + // Esc closes; click-outside the dialog card also closes. We attach the + // keydown listener to document so the modal handles Esc regardless of + // which child has focus. + useEffect(() => { + const handleDocumentKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + document.addEventListener('keydown', handleDocumentKey); + return () => document.removeEventListener('keydown', handleDocumentKey); + }, [onClose]); + + // Auto-focus the args editor on mount so keyboard-first users land + // exactly where they need to type. + useEffect(() => { + argsTextareaRef.current?.focus(); + }, []); + + const schemaJson = useMemo(() => stringifyResult(tool.input_schema), [tool.input_schema]); + + const handleArgsChange = useCallback((next: string) => { + setArgsJson(next); + // Live-clear stale parse errors; they re-appear on Run if still bad. + setParseError(null); + }, []); + + const handleFormat = useCallback(() => { + setArgsJson(prev => formatArgs(prev)); + setParseError(null); + }, []); + + const handleRun = useCallback(async () => { + if (isRunning) return; + // Parse args first; refuse to call the RPC with bad input. + const parsed = parseToolArgs(argsJson, t('mcp.playground.invalidJson')); + if (!parsed.ok) { + setParseError(parsed.error); + setResultText(null); + return; + } + setParseError(null); + setIsRunning(true); + setResultText(null); + setResultIsError(false); + // Reset the copy-feedback chip so a stale "Copied" label doesn't + // briefly persist over the next result — the Copy timer has its + // own 1.5s reset, but starting a new run is itself a clear signal + // the prior result is gone. + setCopyStatus('idle'); + const submittedArgs = argsJson; + const timestamp = formatTimestamp(new Date()); + try { + const callResult = await mcpClientsApi.toolCall({ + server_id: serverId, + tool_name: tool.name, + arguments: parsed.value, + }); + const text = stringifyResult(callResult.result); + const isError = Boolean(callResult.is_error); + setResultText(text); + setResultIsError(isError); + setHistory(prev => + [{ timestamp, argsJson: submittedArgs, resultText: text, isError }, ...prev].slice( + 0, + HISTORY_LIMIT + ) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : t('mcp.playground.unexpectedError'); + setResultText(msg); + setResultIsError(true); + setHistory(prev => + [{ timestamp, argsJson: submittedArgs, resultText: msg, isError: true }, ...prev].slice( + 0, + HISTORY_LIMIT + ) + ); + } finally { + setIsRunning(false); + } + }, [argsJson, isRunning, serverId, t, tool.name]); + + // Cmd/Ctrl+Enter from the textarea triggers Run. We do NOT propagate + // the keydown to the document Esc listener — only the Enter+modifier + // combination is intercepted. + const handleTextareaKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + void handleRun(); + } + }, + [handleRun] + ); + + const handleCopyResult = useCallback(async () => { + if (resultText == null) return; + if (typeof navigator === 'undefined' || !navigator.clipboard) return; + try { + await navigator.clipboard.writeText(resultText); + setCopyStatus('copied'); + window.setTimeout(() => setCopyStatus('idle'), 1500); + } catch { + // Best-effort copy — silently ignore platforms / contexts where + // clipboard access is denied. The result is still visible. + } + }, [resultText]); + + const handleLoadFromHistory = useCallback((record: InvocationRecord) => { + setArgsJson(record.argsJson); + setParseError(null); + argsTextareaRef.current?.focus(); + }, []); + + // Click on the backdrop (not the dialog card) closes. + const handleBackdropMouseDown = useCallback( + (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + return ( +
      +
      + {/* Header */} +
      +
      +

      + {t('mcp.playground.title').replace('{name}', tool.name)} +

      + {tool.description && ( +

      + {tool.description} +

      + )} +
      + +
      + + {/* Input schema (collapsible) */} +
      + + {showSchema && ( +
      +              {schemaJson}
      +            
      + )} +
      + + {/* Args editor */} +
      +
      + +
      + + {t('mcp.playground.runShortcut')} + + +
      +
      +