feat(mcp): interactive Tool Execution Playground (schema viewer + JSON args editor + run + history) (#2644)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Aashir Athar
2026-05-28 20:20:14 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 55b1e6a30d
commit f5e4c30ecc
20 changed files with 1434 additions and 16 deletions
@@ -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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={disconnectedStatus}
onUninstalled={() => {}}
/>
);
// 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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={connectedStatus}
onUninstalled={() => {}}
/>
);
// 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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={{ ...connectedStatus, status: 'error', last_error: 'boom' }}
onUninstalled={() => {}}
/>
);
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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={{ ...connectedStatus, status: 'error', last_error: 'boom' }}
onUninstalled={() => {}}
/>
);
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(
<InstalledServerDetail
server={BASE_SERVER}
connStatus={connectedStatus}
onUninstalled={() => {}}
/>
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
@@ -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<Record<string, string> | 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<McpTool | null>(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<void>) => {
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 = ({
</div>
)}
{/* 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. */}
<div className="space-y-1">
<p className="text-xs font-medium text-stone-600 dark:text-neutral-400">
{t('mcp.detail.tools')}
</p>
<McpToolList tools={status === 'connected' ? tools : []} />
<McpToolList
tools={status === 'connected' ? tools : []}
onTryTool={status === 'connected' ? setPlaygroundTool : undefined}
/>
</div>
{/* Config assistant */}
@@ -228,6 +264,22 @@ const InstalledServerDetail = ({
/>
</div>
)}
{/* 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' && (
<McpToolPlayground
serverId={server.server_id}
tool={playgroundTool}
onClose={() => setPlaygroundTool(null)}
/>
)}
</div>
);
};
@@ -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(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={TOOLS} />);
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(<McpToolList tools={TOOLS} onTryTool={() => {}} />);
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(<McpToolList tools={TOOLS} onTryTool={onTryTool} />);
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(<McpToolList tools={[TOOLS[2]]} onTryTool={onTryTool} />);
fireEvent.click(screen.getByRole('button', { name: /tool available/i }));
expect(
screen.getByRole('button', { name: 'Open execution playground for list_dir' })
).toBeInTheDocument();
});
});
@@ -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) => {
<ul className="mt-2 space-y-1 pl-4 border-l-2 border-stone-100 dark:border-neutral-800">
{safeTools.map(tool => (
<li key={tool.name} className="space-y-0.5">
<p className="text-xs font-mono font-medium text-stone-800 dark:text-neutral-100">
{tool.name}
</p>
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-mono font-medium text-stone-800 dark:text-neutral-100 break-words min-w-0">
{tool.name}
</p>
{onTryTool && (
<button
type="button"
onClick={() => onTryTool(tool)}
aria-label={t('mcp.toolList.tryToolAria').replace('{name}', tool.name)}
className="shrink-0 text-[10px] font-medium text-primary-600 dark:text-primary-300 hover:underline">
{t('mcp.toolList.tryTool')}
</button>
)}
</div>
{tool.description && (
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{tool.description}
@@ -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(
<McpToolPlayground
serverId={overrides?.serverId ?? 'srv-1'}
tool={overrides?.tool ?? TOOL}
onClose={overrides?.onClose ?? (() => {})}
/>
);
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 <ul> 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);
});
});
@@ -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<string>(EMPTY_ARGS);
const [parseError, setParseError] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [resultText, setResultText] = useState<string | null>(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<InvocationRecord[]>([]);
const argsTextareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
}
},
[onClose]
);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="mcp-playground-title"
onMouseDown={handleBackdropMouseDown}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-6 overflow-y-auto">
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-xl max-w-2xl w-full p-5 max-h-full overflow-y-auto">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<h2
id="mcp-playground-title"
className="text-base font-semibold text-stone-900 dark:text-neutral-100 font-mono break-words">
{t('mcp.playground.title').replace('{name}', tool.name)}
</h2>
{tool.description && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{tool.description}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label={t('mcp.playground.close')}
className="shrink-0 rounded-lg p-1.5 text-stone-400 hover:text-stone-700 dark:text-neutral-500 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Input schema (collapsible) */}
<div className="mb-3">
<button
type="button"
onClick={() => setShowSchema(prev => !prev)}
aria-expanded={showSchema}
className="flex items-center gap-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:text-stone-900 dark:hover:text-neutral-100">
<span
className={`transition-transform ${showSchema ? 'rotate-90' : ''}`}
aria-hidden="true">
</span>
{t('mcp.playground.inputSchema')}
</button>
{showSchema && (
<pre
data-testid="mcp-playground-schema"
className="mt-1.5 max-h-40 overflow-auto rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-950 p-2 text-[11px] font-mono text-stone-700 dark:text-neutral-200 whitespace-pre-wrap break-words">
{schemaJson}
</pre>
)}
</div>
{/* Args editor */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1.5">
<label
htmlFor="mcp-playground-args"
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('mcp.playground.argsLabel')}
</label>
<div className="flex items-center gap-2">
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('mcp.playground.runShortcut')}
</span>
<button
type="button"
onClick={handleFormat}
aria-label={t('mcp.playground.format')}
className="text-[10px] font-medium text-primary-600 dark:text-primary-300 hover:underline">
{t('mcp.playground.format')}
</button>
</div>
</div>
<textarea
id="mcp-playground-args"
ref={argsTextareaRef}
value={argsJson}
onChange={e => handleArgsChange(e.target.value)}
onKeyDown={handleTextareaKeyDown}
spellCheck={false}
rows={6}
aria-label={t('mcp.playground.argsLabel')}
aria-describedby="mcp-playground-args-help"
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-xs font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-primary-400 resize-y"
/>
<p
id="mcp-playground-args-help"
className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
{t('mcp.playground.argsHelp')}
</p>
{parseError && (
<p role="alert" className="mt-1 text-[11px] text-coral-700 dark:text-coral-300">
{t('mcp.playground.invalidJson')}: {parseError}
</p>
)}
</div>
{/* Run button */}
<div className="flex justify-end gap-2 mb-4">
<button
type="button"
onClick={() => void handleRun()}
disabled={isRunning}
className="rounded-lg bg-primary-500 px-4 py-1.5 text-xs font-semibold text-white hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed">
{isRunning ? t('mcp.playground.running') : t('mcp.playground.run')}
</button>
</div>
{/* Result */}
{resultText !== null && (
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{resultIsError ? t('mcp.playground.resultError') : t('mcp.playground.result')}
</p>
<button
type="button"
onClick={() => void handleCopyResult()}
aria-label={t('mcp.playground.copyResult')}
className="text-[10px] font-medium text-primary-600 dark:text-primary-300 hover:underline">
{copyStatus === 'copied'
? t('mcp.playground.copied')
: t('mcp.playground.copyResult')}
</button>
</div>
<pre
data-testid="mcp-playground-result"
role={resultIsError ? 'alert' : 'status'}
aria-live={resultIsError ? 'assertive' : 'polite'}
className={`max-h-60 overflow-auto rounded-lg border p-2 text-[11px] font-mono whitespace-pre-wrap break-words ${
resultIsError
? 'border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300'
: 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 text-stone-800 dark:text-neutral-100'
}`}>
{resultText}
</pre>
</div>
)}
{/* History */}
<div>
<button
type="button"
onClick={() => setShowHistory(prev => !prev)}
aria-expanded={showHistory}
className="flex items-center gap-1.5 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:text-stone-900 dark:hover:text-neutral-100">
<span
className={`transition-transform ${showHistory ? 'rotate-90' : ''}`}
aria-hidden="true">
</span>
{t('mcp.playground.history')} ({history.length})
</button>
{showHistory && (
<div className="mt-1.5">
{history.length === 0 ? (
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('mcp.playground.historyEmpty')}
</p>
) : (
<ul className="space-y-1">
{history.map((record, idx) => (
<li
key={`${record.timestamp}-${idx}`}
className="flex items-center justify-between gap-2 rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
<div className="min-w-0 flex items-center gap-2">
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
record.isError ? 'bg-coral-500' : 'bg-sage-500'
}`}
aria-hidden="true"
/>
<span className="text-[10px] font-mono text-stone-500 dark:text-neutral-400">
{record.timestamp}
</span>
<span className="text-[10px] text-stone-600 dark:text-neutral-300 truncate">
{record.argsJson}
</span>
</div>
<button
type="button"
onClick={() => handleLoadFromHistory(record)}
aria-label={t('mcp.playground.historyLoad')}
className="shrink-0 text-[10px] font-medium text-primary-600 dark:text-primary-300 hover:underline">
{t('mcp.playground.historyLoad')}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default McpToolPlayground;
+20
View File
@@ -1309,6 +1309,26 @@ const ar1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'فشل الحصول على الرد',
'mcp.toolList.availableSingular': '{count} الأداة المتاحة',
'mcp.toolList.availablePlural': '{count} الأدوات المتاحة',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'تم نشرها',
'mcp.catalog.installCount': '{count} عمليات التثبيت',
'app.update.dismissNotification': 'تجاهل إشعار التحديث',
+20
View File
@@ -1318,6 +1318,26 @@ const bn1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'প্রতিক্রিয়া পেতে ব্যর্থ',
'mcp.toolList.availableSingular': '{count} টুল উপলব্ধ',
'mcp.toolList.availablePlural': '{count} টুল উপলব্ধ',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'স্থাপন করা হয়েছে',
'mcp.catalog.installCount': '{count} ইনস্টল করা হয়েছে',
'app.update.dismissNotification': 'আপডেট বাতিল করা হয়নি',
+20
View File
@@ -1336,6 +1336,26 @@ const de1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Es konnte keine Antwort erhalten werden',
'mcp.toolList.availableSingular': '{count} Tool verfügbar',
'mcp.toolList.availablePlural': '{count} Tools verfügbar',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Bereitgestellt',
'mcp.catalog.installCount': '{count} installiert',
'app.update.dismissNotification': 'Update-Benachrichtigung verwerfen',
+20
View File
@@ -1294,6 +1294,26 @@ const en1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Failed to get response',
'mcp.toolList.availableSingular': '{count} tool available',
'mcp.toolList.availablePlural': '{count} tools available',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Deployed',
'mcp.catalog.installCount': '{count} installs',
'app.update.dismissNotification': 'Dismiss update notification',
+20
View File
@@ -1333,6 +1333,26 @@ const es1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'No se pudo obtener respuesta',
'mcp.toolList.availableSingular': '{count} herramienta disponible',
'mcp.toolList.availablePlural': '{count} herramientas disponibles',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Implementado',
'mcp.catalog.installCount': '{count} instala',
'app.update.dismissNotification': 'Descartar notificación de actualización',
+20
View File
@@ -1338,6 +1338,26 @@ const fr1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Failed to get response',
'mcp.toolList.availableSingular': '{count} outil disponible',
'mcp.toolList.availablePlural': '{count} outils disponibles',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Déployé',
'mcp.catalog.installCount': '{count} installe',
'app.update.dismissNotification': 'Ignorer la notification de mise à jour',
+20
View File
@@ -1317,6 +1317,26 @@ const hi1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'प्रतिक्रिया प्राप्त करने में विफल',
'mcp.toolList.availableSingular': '{count} उपकरण उपलब्ध है',
'mcp.toolList.availablePlural': '{count} उपकरण उपलब्ध हैं',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'तैनात',
'mcp.catalog.installCount': '{count} इंस्टॉल होता है',
'app.update.dismissNotification': 'अद्यतन अधिसूचना ख़ारिज करें',
+20
View File
@@ -1321,6 +1321,26 @@ const id1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Gagal mendapat respons',
'mcp.toolList.availableSingular': '{count} alat tersedia',
'mcp.toolList.availablePlural': '{count} alat tersedia',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Dikerahkan',
'mcp.catalog.installCount': '{count} diinstal',
'app.update.dismissNotification': 'Tutup pemberitahuan pembaruan',
+20
View File
@@ -1326,6 +1326,26 @@ const it1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Impossibile ottenere risposta',
'mcp.toolList.availableSingular': '{count} strumento disponibile',
'mcp.toolList.availablePlural': '{count} strumenti disponibili',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Distribuito',
'mcp.catalog.installCount': '{count} installa',
'app.update.dismissNotification': 'Ignora notifica di aggiornamento',
+20
View File
@@ -1318,6 +1318,26 @@ const ko1: TranslationMap = {
'mcp.configAssistant.failedResponse': '응답을 받지 못했습니다.',
'mcp.toolList.availableSingular': '{count} 도구 사용 가능',
'mcp.toolList.availablePlural': '{count} 도구 사용 가능',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': '배포됨',
'mcp.catalog.installCount': '{count} 설치',
'app.update.dismissNotification': '업데이트 알림 닫기',
+20
View File
@@ -1331,6 +1331,26 @@ const pt1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Falha ao obter resposta',
'mcp.toolList.availableSingular': '{count} ferramenta disponível',
'mcp.toolList.availablePlural': '{count} ferramentas disponíveis',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Implantado',
'mcp.catalog.installCount': '{count} instala',
'app.update.dismissNotification': 'Ignorar notificação de atualização',
+20
View File
@@ -1321,6 +1321,26 @@ const ru1: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Не удалось получить ответ',
'mcp.toolList.availableSingular': 'Доступен инструмент {count}',
'mcp.toolList.availablePlural': 'Доступен инструмент {count}',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Развернуто',
'mcp.catalog.installCount': '{count} устанавливает',
'app.update.dismissNotification': 'Отклонить уведомление об обновлении',
+20
View File
@@ -1302,6 +1302,26 @@ const zhCN1: TranslationMap = {
'mcp.configAssistant.failedResponse': '未能得到回复',
'mcp.toolList.availableSingular': '{count} 工具可用',
'mcp.toolList.availablePlural': '{count} 可用工具',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': '已部署',
'mcp.catalog.installCount': '{count} 安装',
'app.update.dismissNotification': '关闭更新通知',
+20
View File
@@ -907,6 +907,26 @@ const en: TranslationMap = {
'mcp.configAssistant.failedResponse': 'Failed to get response',
'mcp.toolList.availableSingular': '{count} tool available',
'mcp.toolList.availablePlural': '{count} tools available',
'mcp.toolList.tryTool': 'Try',
'mcp.toolList.tryToolAria': 'Open execution playground for {name}',
'mcp.playground.title': 'Run {name}',
'mcp.playground.close': 'Close playground',
'mcp.playground.inputSchema': 'Input schema',
'mcp.playground.argsLabel': 'Arguments (JSON)',
'mcp.playground.argsHelp': 'Type JSON matching the input schema. Empty input is treated as {}.',
'mcp.playground.runShortcut': '⌘/Ctrl + Enter to run',
'mcp.playground.format': 'Format',
'mcp.playground.invalidJson': 'Invalid JSON',
'mcp.playground.run': 'Run tool',
'mcp.playground.running': 'Running…',
'mcp.playground.result': 'Result',
'mcp.playground.resultError': 'Tool returned an error',
'mcp.playground.copyResult': 'Copy result',
'mcp.playground.copied': 'Copied',
'mcp.playground.history': 'History',
'mcp.playground.historyEmpty': 'No invocations yet in this session.',
'mcp.playground.historyLoad': 'Load',
'mcp.playground.unexpectedError': 'Unexpected error invoking tool.',
'mcp.catalog.deployed': 'Deployed',
'mcp.catalog.installCount': '{count} installs',
'app.update.dismissNotification': 'Dismiss update notification',