diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 22d522e06c..ec1fd3d4db 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -520,4 +520,77 @@ describe('ToolConfirmationMessage', () => { expect(output).toMatchSnapshot(); unmount(); }); + + it('should show MCP tool details expand hint for MCP confirmations', async () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'mcp', + title: 'Confirm MCP Tool', + serverName: 'test-server', + toolName: 'test-tool', + toolDisplayName: 'Test Tool', + toolArgs: { + url: 'https://www.google.co.jp', + }, + toolDescription: 'Navigates browser to a URL.', + toolParameterSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Destination URL', + }, + }, + required: ['url'], + }, + onConfirm: vi.fn(), + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('MCP Tool Details:'); + expect(output).toContain('(press Ctrl+O to expand MCP tool details)'); + expect(output).not.toContain('https://www.google.co.jp'); + expect(output).not.toContain('Navigates browser to a URL.'); + unmount(); + }); + + it('should omit empty MCP invocation arguments from details', async () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'mcp', + title: 'Confirm MCP Tool', + serverName: 'test-server', + toolName: 'test-tool', + toolDisplayName: 'Test Tool', + toolArgs: {}, + toolDescription: 'No arguments required.', + onConfirm: vi.fn(), + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('MCP Tool Details:'); + expect(output).toContain('(press Ctrl+O to expand MCP tool details)'); + expect(output).not.toContain('Invocation Arguments:'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index c4e73b73f6..9a49e2aa5a 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; @@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; +import { formatCommand } from '../../utils/keybindingUtils.js'; import { REDIRECTION_WARNING_NOTE_LABEL, REDIRECTION_WARNING_NOTE_TEXT, @@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC< terminalWidth, }) => { const { confirm, isDiffingEnabled } = useToolActions(); + const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{ + callId: string; + expanded: boolean; + }>({ + callId, + expanded: false, + }); + const isMcpToolDetailsExpanded = + mcpDetailsExpansionState.callId === callId + ? mcpDetailsExpansionState.expanded + : false; const settings = useSettings(); const allowPermanentApproval = @@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC< [confirm, callId], ); + const mcpToolDetailsText = useMemo(() => { + if (confirmationDetails.type !== 'mcp') { + return null; + } + + const detailsLines: string[] = []; + const hasNonEmptyToolArgs = + confirmationDetails.toolArgs !== undefined && + !( + typeof confirmationDetails.toolArgs === 'object' && + confirmationDetails.toolArgs !== null && + Object.keys(confirmationDetails.toolArgs).length === 0 + ); + if (hasNonEmptyToolArgs) { + let argsText: string; + try { + argsText = stripUnsafeCharacters( + JSON.stringify(confirmationDetails.toolArgs, null, 2), + ); + } catch { + argsText = '[unserializable arguments]'; + } + detailsLines.push('Invocation Arguments:'); + detailsLines.push(argsText); + } + + const description = confirmationDetails.toolDescription?.trim(); + if (description) { + if (detailsLines.length > 0) { + detailsLines.push(''); + } + detailsLines.push('Description:'); + detailsLines.push(stripUnsafeCharacters(description)); + } + + if (confirmationDetails.toolParameterSchema !== undefined) { + let schemaText: string; + try { + schemaText = stripUnsafeCharacters( + JSON.stringify(confirmationDetails.toolParameterSchema, null, 2), + ); + } catch { + schemaText = '[unserializable schema]'; + } + if (detailsLines.length > 0) { + detailsLines.push(''); + } + detailsLines.push('Input Schema:'); + detailsLines.push(schemaText); + } + + if (detailsLines.length === 0) { + return null; + } + + return detailsLines.join('\n'); + }, [confirmationDetails]); + + const hasMcpToolDetails = !!mcpToolDetailsText; + const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES); + useKeypress( (key) => { if (!isFocused) return false; + if ( + confirmationDetails.type === 'mcp' && + hasMcpToolDetails && + keyMatchers[Command.SHOW_MORE_LINES](key) + ) { + setMcpDetailsExpansionState({ + callId, + expanded: !isMcpToolDetailsExpanded, + }); + return true; + } if (keyMatchers[Command.ESCAPE](key)) { handleConfirm(ToolConfirmationOutcome.Cancel); return true; @@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC< } return false; }, - { isActive: isFocused }, + { isActive: isFocused, priority: true }, ); const handleSelect = useCallback( @@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - - MCP Server: {sanitizeForDisplay(mcpProps.serverName)} - - - Tool: {sanitizeForDisplay(mcpProps.toolName)} - + <> + + MCP Server: {sanitizeForDisplay(mcpProps.serverName)} + + + Tool: {sanitizeForDisplay(mcpProps.toolName)} + + + {hasMcpToolDetails && ( + + MCP Tool Details: + {isMcpToolDetailsExpanded ? ( + <> + + (press {expandDetailsHintKey} to collapse MCP tool details) + + {mcpToolDetailsText} + + ) : ( + + (press {expandDetailsHintKey} to expand MCP tool details) + + )} + + )} ); } @@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC< terminalWidth, handleConfirm, deceptiveUrlWarningText, + isMcpToolDetailsExpanded, + hasMcpToolDetails, + mcpToolDetailsText, + expandDetailsHintKey, ]); + const bodyOverflowDirection: 'top' | 'bottom' = + confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded + ? 'bottom' + : 'top'; + if (confirmationDetails.type === 'edit') { if (confirmationDetails.isModifying) { return ( @@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC< {bodyContent} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index b2de83cd8b..763754ec95 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -352,7 +352,6 @@ describe('keyMatchers', () => { createKey('l', { ctrl: true }), ], }, - // Shell commands { command: Command.REVERSE_SEARCH, diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 69aa98832e..e02c773070 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -95,6 +95,9 @@ export type SerializableConfirmationDetails = serverName: string; toolName: string; toolDisplayName: string; + toolArgs?: Record; + toolDescription?: string; + toolParameterSchema?: unknown; } | { type: 'ask_user'; diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 6faa30c673..f80eebe272 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -80,6 +80,8 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation< readonly trust?: boolean, params: ToolParams = {}, private readonly cliConfig?: Config, + private readonly toolDescription?: string, + private readonly toolParameterSchema?: unknown, ) { // Use composite format for policy checks: serverName__toolName // This enables server wildcards (e.g., "google-workspace__*") @@ -123,6 +125,9 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation< serverName: this.serverName, toolName: this.serverToolName, // Display original tool name in confirmation toolDisplayName: this.displayName, // Display global registry name exposed to model and user + toolArgs: this.params, + toolDescription: this.toolDescription, + toolParameterSchema: this.toolParameterSchema, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey); @@ -317,6 +322,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.trust, params, this.cliConfig, + this.description, + this.parameterSchema, ); } } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 608f405029..94188deca0 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -757,6 +757,9 @@ export interface ToolMcpConfirmationDetails { serverName: string; toolName: string; toolDisplayName: string; + toolArgs?: Record; + toolDescription?: string; + toolParameterSchema?: unknown; onConfirm: (outcome: ToolConfirmationOutcome) => Promise; }