From ada2905ee09c0898c5893f1338c7cd7f190a5ced Mon Sep 17 00:00:00 2001 From: "xubing.bxb" Date: Fri, 21 Feb 2025 19:35:26 +0800 Subject: [PATCH 1/5] feat: add terminal command execution tool with user approval --- .../browser/mcp/tools/components/Terminal.tsx | 82 ++++++++++++++ .../mcp/tools/components/index.module.less | 30 +++++ .../browser/mcp/tools/handlers/RunCommand.ts | 107 ++++++++++++++++++ .../src/browser/mcp/tools/runTerminalCmd.ts | 75 ++---------- 4 files changed, 226 insertions(+), 68 deletions(-) create mode 100644 packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx create mode 100644 packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts diff --git a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx new file mode 100644 index 0000000000..d95d805bcb --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx @@ -0,0 +1,82 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; + +import { useInjectable } from '@opensumi/ide-core-browser'; +import { Button, Icon } from '@opensumi/ide-core-browser/lib/components'; + +import { IMCPServerToolComponentProps } from '../../../types'; +import { RunCommandHandler } from '../handlers/RunCommand'; + +import styles from './index.module.less'; + +function getResult(raw: string) { + const result: { + isError?: boolean; + text?: string; + } = {}; + + try { + const data = JSON.parse(raw); + if (data.isError) { + result.isError = data.isError; + } + + if (data.content) { + result.text = data.content; + } + + return result; + } catch { + return null; + } +} + +export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) => { + const { args, toolCallId } = props; + const handler = useInjectable(RunCommandHandler); + const [disabled, toggleDisabled] = useState(false); + + const handleClick = useCallback((approval: boolean) => { + handler.handleApproval(toolCallId!, approval); + toggleDisabled(true); + }, []); + + const output = useMemo(() => { + if (props.result) { + return getResult(props.result); + } + return null; + }, [props]); + + return ( +
+ {props.state === 'result' && ( +
+
+ + 输出 +
+ {output ?
{output.text}
: ''} +
+ )} + + {props.state === 'complete' && args?.require_user_approval && ( +
+
+ + 是否允许运行命令? +
+

{args.command}

+

{args.explanation}

+
+ + +
+
+ )} +
+ ); +}); diff --git a/packages/ai-native/src/browser/mcp/tools/components/index.module.less b/packages/ai-native/src/browser/mcp/tools/components/index.module.less index 5b0b5e5513..13ca611e71 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/index.module.less +++ b/packages/ai-native/src/browser/mcp/tools/components/index.module.less @@ -130,3 +130,33 @@ flex-basis: 0px; flex-grow: 1; } + +.run_cmd_tool { + .command_title { + display: flex; + align-items: center; + span { + margin-left: 5px; + } + } + + .command_content { + padding: 4px; + font-size: 12px; + color: var(--design-text-foreground); + margin: 0px; + background-color: var(--terminal-background); + margin: 10px 0px; + border-radius: 4px; + } + + .comand_description { + font-size: 11px; + color: var(--descriptionForeground); + } + + .cmmand_footer { + display: flex; + justify-content: flex-end; + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts b/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts new file mode 100644 index 0000000000..44c764af0e --- /dev/null +++ b/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts @@ -0,0 +1,107 @@ +import z from 'zod'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { ITerminalController, ITerminalGroupViewService } from '@opensumi/ide-terminal-next'; +import { Deferred } from '@opensumi/ide-utils/lib/promises'; + +import { MCPLogger } from '../../../types'; + +const color = { + italic: '\x1b[3m', + reset: '\x1b[0m', +}; + +export const inputSchema = z.object({ + command: z.string().describe('The terminal command to execute'), + is_background: z.boolean().describe('Whether the command should be run in the background'), + explanation: z + .string() + .describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'), + require_user_approval: z + .boolean() + .describe( + "Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.", + ), +}); + +@Injectable() +export class RunCommandHandler { + @Autowired(ITerminalController) + protected readonly terminalController: ITerminalController; + + @Autowired(AppConfig) + protected readonly appConfig: AppConfig; + + @Autowired(ITerminalGroupViewService) + protected readonly terminalView: ITerminalGroupViewService; + + private approvalDeferredMap = new Map>(); + + private terminalId = 0; + + getShellLaunchConfig(command: string) { + return { + name: `MCP:Terminal_${this.terminalId++}`, + cwd: this.appConfig.workspaceDir, + args: ['-c', command], + }; + } + + async handler(args: z.infer & { toolCallId: string }, logger: MCPLogger) { + if (args.require_user_approval) { + const def = new Deferred(); + this.approvalDeferredMap.set(args.toolCallId, def); + const approval = await def.promise; + if (!approval) { + return { + isError: false, + content: 'User rejection', + }; + } + } + const terminalClient = await this.terminalController.createTerminalWithWidget({ + config: this.getShellLaunchConfig(args.command), + closeWhenExited: false, + }); + + this.terminalController.showTerminalPanel(); + + const result: string[] = []; + const def = new Deferred<{ isError?: boolean; content: string[] }>(); + + terminalClient.onOutput((e) => { + result.push(e.data.toString()); + }); + + terminalClient.onExit((e) => { + const isError = e.code !== 0; + def.resolve({ + isError, + content: result, + }); + + terminalClient.term.writeln( + `\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${ + 3000 / 1000 + } seconds.${color.reset}\n`, + ); + + setTimeout(() => { + terminalClient.dispose(); + this.terminalView.removeWidget(terminalClient.id); + }, 3000); + }); + + return def.promise; + } + + handleApproval(callId: string, approval: boolean) { + if (!this.approvalDeferredMap.has(callId)) { + return; + } + + const def = this.approvalDeferredMap.get(callId); + def?.resolve(approval); + } +} diff --git a/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts b/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts index 2e4e9cf377..534fae497d 100644 --- a/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts +++ b/packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts @@ -7,23 +7,8 @@ import { ITerminalController, ITerminalGroupViewService } from '@opensumi/ide-te import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types'; -const color = { - italic: '\x1b[3m', - reset: '\x1b[0m', -}; - -const inputSchema = z.object({ - command: z.string().describe('The terminal command to execute'), - is_background: z.boolean().describe('Whether the command should be run in the background'), - explanation: z - .string() - .describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'), - require_user_approval: z - .boolean() - .describe( - "Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.", - ), -}); +import { TerminalToolComponent } from './components/Terminal'; +import { RunCommandHandler, inputSchema } from './handlers/RunCommand'; @Domain(MCPServerContribution) export class RunTerminalCommandTool implements MCPServerContribution { @@ -36,10 +21,12 @@ export class RunTerminalCommandTool implements MCPServerContribution { @Autowired(ITerminalGroupViewService) protected readonly terminalView: ITerminalGroupViewService; - private terminalId = 0; + @Autowired(RunCommandHandler) + private readonly runCommandHandler: RunCommandHandler; registerMCPServer(registry: IMCPServerRegistry): void { registry.registerMCPTool(this.getToolDefinition()); + registry.registerToolComponent('run_terminal_cmd', TerminalToolComponent); } getToolDefinition(): MCPToolDefinition { @@ -52,55 +39,7 @@ export class RunTerminalCommandTool implements MCPServerContribution { }; } - getShellLaunchConfig(command: string) { - return { - name: `MCP:Terminal_${this.terminalId++}`, - cwd: this.appConfig.workspaceDir, - args: ['-c', command], - }; - } - - private async handler(args: z.infer, logger: MCPLogger) { - if (args.require_user_approval) { - // FIXME: support approval - } - - const terminalClient = await this.terminalController.createTerminalWithWidget({ - config: this.getShellLaunchConfig(args.command), - closeWhenExited: false, - }); - - this.terminalController.showTerminalPanel(); - - const result: { type: string; text: string }[] = []; - const def = new Deferred<{ isError?: boolean; content: { type: string; text: string }[] }>(); - - terminalClient.onOutput((e) => { - result.push({ - type: 'output', - text: e.data.toString(), - }); - }); - - terminalClient.onExit((e) => { - const isError = e.code !== 0; - def.resolve({ - isError, - content: result, - }); - - terminalClient.term.writeln( - `\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${ - 3000 / 1000 - } seconds.${color.reset}\n`, - ); - - setTimeout(() => { - terminalClient.dispose(); - this.terminalView.removeWidget(terminalClient.id); - }, 3000); - }); - - return def.promise; + private async handler(args: z.infer & { toolCallId: string }, logger: MCPLogger) { + return this.runCommandHandler.handler(args, logger); } } From 4d98c5edd06dce2e88e17853507849b1be34ae1d Mon Sep 17 00:00:00 2001 From: liuqian Date: Mon, 24 Feb 2025 09:28:11 +0800 Subject: [PATCH 2/5] fix: types --- .../browser/mcp/tools/components/Terminal.tsx | 12 +++++++++--- .../src/browser/mcp/tools/handlers/RunCommand.ts | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx index d95d805bcb..a6a6d0481f 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx +++ b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx @@ -15,13 +15,16 @@ function getResult(raw: string) { } = {}; try { - const data = JSON.parse(raw); + const data: { + content: { type: string; text: string }[]; + isError?: boolean; + } = JSON.parse(raw); if (data.isError) { result.isError = data.isError; } if (data.content) { - result.text = data.content; + result.text = data.content.map((item) => item.text).join('\n'); } return result; @@ -36,7 +39,10 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) const [disabled, toggleDisabled] = useState(false); const handleClick = useCallback((approval: boolean) => { - handler.handleApproval(toolCallId!, approval); + if (!toolCallId) { + return; + } + handler.handleApproval(toolCallId, approval); toggleDisabled(true); }, []); diff --git a/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts b/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts index 44c764af0e..7efaab6c4a 100644 --- a/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts +++ b/packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts @@ -56,7 +56,12 @@ export class RunCommandHandler { if (!approval) { return { isError: false, - content: 'User rejection', + content: [ + { + type: 'text', + text: 'User rejection', + }, + ], }; } } @@ -67,11 +72,14 @@ export class RunCommandHandler { this.terminalController.showTerminalPanel(); - const result: string[] = []; - const def = new Deferred<{ isError?: boolean; content: string[] }>(); + const result: { type: string; text: string }[] = []; + const def = new Deferred<{ isError?: boolean; content: { type: string; text: string }[] }>(); terminalClient.onOutput((e) => { - result.push(e.data.toString()); + result.push({ + type: 'text', + text: e.data.toString(), + }); }); terminalClient.onExit((e) => { From c66eb529c96d2abfd3b6a17ea6f90d9bc9065f04 Mon Sep 17 00:00:00 2001 From: liuqian Date: Mon, 24 Feb 2025 09:41:56 +0800 Subject: [PATCH 3/5] chore: improve style --- .../src/browser/mcp/tools/components/Terminal.tsx | 12 ++++++++++-- .../browser/mcp/tools/components/index.module.less | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx index a6a6d0481f..c4ffc8a88f 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx +++ b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx @@ -61,7 +61,13 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) 输出 - {output ?
{output.text}
: ''} + {output ? ( +
+ {output.text} +
+ ) : ( + '' + )} )} @@ -71,7 +77,9 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) 是否允许运行命令? -

{args.command}

+

+ $ {args.command} +

{args.explanation}

diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 88927ac762..1c024102f4 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -1560,5 +1560,11 @@ export const localizationBundle = { 'preference.ai.native.mcp.servers.command.description': 'Command to start the MCP server', 'preference.ai.native.mcp.servers.args.description': 'Command line arguments for the MCP server', 'preference.ai.native.mcp.servers.env.description': 'Environment variables for the MCP server', + + // MCP Terminal Tool + 'ai.native.mcp.terminal.output': 'Output', + 'ai.native.ncp.terminal.allow-question': 'Allow the terminal to run the command?', + 'ai.native.mcp.terminal.allow': 'Allow', + 'ai.native.mcp.terminal.deny': 'Reject', }, }; diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index d1a60c1802..118210e67f 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -1322,5 +1322,11 @@ export const localizationBundle = { 'preference.ai.native.mcp.servers.command.description': '启动 MCP 服务器的命令', 'preference.ai.native.mcp.servers.args.description': 'MCP 服务器的命令行参数', 'preference.ai.native.mcp.servers.env.description': 'MCP 服务器的环境变量', + + // MCP Terminal Tool + 'ai.native.mcp.terminal.output': '输出', + 'ai.native.ncp.terminal.allow-question': '是否允许运行命令?', + 'ai.native.mcp.terminal.allow': '允许', + 'ai.native.mcp.terminal.deny': '拒绝', }, }; From 7e7e3aa5b2c9139fb8c0d2a6721fdfa27083d184 Mon Sep 17 00:00:00 2001 From: liuqian Date: Mon, 24 Feb 2025 10:03:03 +0800 Subject: [PATCH 5/5] chore: i18n --- .../ai-native/src/browser/mcp/tools/components/Terminal.tsx | 2 +- packages/i18n/src/common/en-US.lang.ts | 2 +- packages/i18n/src/common/zh-CN.lang.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx index aafa1ad44b..2ccf022759 100644 --- a/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx +++ b/packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx @@ -76,7 +76,7 @@ export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps)
- {localize('ai.native.ncp.terminal.allow-question')} + {localize('ai.native.mcp.terminal.allow-question')}

$ {args.command} diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 1c024102f4..bb912fd2c6 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -1563,7 +1563,7 @@ export const localizationBundle = { // MCP Terminal Tool 'ai.native.mcp.terminal.output': 'Output', - 'ai.native.ncp.terminal.allow-question': 'Allow the terminal to run the command?', + 'ai.native.mcp.terminal.allow-question': 'Allow the terminal to run the command?', 'ai.native.mcp.terminal.allow': 'Allow', 'ai.native.mcp.terminal.deny': 'Reject', }, diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 118210e67f..0f97196821 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -1325,7 +1325,7 @@ export const localizationBundle = { // MCP Terminal Tool 'ai.native.mcp.terminal.output': '输出', - 'ai.native.ncp.terminal.allow-question': '是否允许运行命令?', + 'ai.native.mcp.terminal.allow-question': '是否允许运行命令?', 'ai.native.mcp.terminal.allow': '允许', 'ai.native.mcp.terminal.deny': '拒绝', },