mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Opus 4.7
parent
55b1e6a30d
commit
f5e4c30ecc
@@ -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;
|
||||
@@ -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': 'تجاهل إشعار التحديث',
|
||||
|
||||
@@ -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': 'আপডেট বাতিল করা হয়নি',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'अद्यतन अधिसूचना ख़ारिज करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '업데이트 알림 닫기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Отклонить уведомление об обновлении',
|
||||
|
||||
@@ -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': '关闭更新通知',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user