Skip to content

Commit

Permalink
feat: add terminal command execution tool with user approval (#4398)
Browse files Browse the repository at this point in the history
* feat: add terminal command execution tool with user approval

* fix: types

* chore: improve style

* chore: i18n

* chore: i18n
  • Loading branch information
Aaaaash authored Feb 24, 2025
1 parent 23e4557 commit aca7ff8
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 68 deletions.
97 changes: 97 additions & 0 deletions packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 { localize } from '@opensumi/ide-core-common';

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: {
content: { type: string; text: string }[];
isError?: boolean;
} = JSON.parse(raw);
if (data.isError) {
result.isError = data.isError;
}

if (data.content) {
result.text = data.content.map((item) => item.text).join('\n');
}

return result;
} catch {
return null;
}
}

export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) => {
const { args, toolCallId } = props;
const handler = useInjectable<RunCommandHandler>(RunCommandHandler);
const [disabled, toggleDisabled] = useState(false);

const handleClick = useCallback((approval: boolean) => {
if (!toolCallId) {
return;
}
handler.handleApproval(toolCallId, approval);
toggleDisabled(true);
}, []);

const output = useMemo(() => {
if (props.result) {
return getResult(props.result);
}
return null;
}, [props]);

return (
<div className={styles.run_cmd_tool}>
{props.state === 'result' && (
<div>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>{localize('ai.native.mcp.terminal.output')}</span>
</div>
{output ? (
<div className={styles.command_content}>
<code>{output.text}</code>
</div>
) : (
''
)}
</div>
)}

{props.state === 'complete' && args?.require_user_approval && (
<div>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>{localize('ai.native.mcp.terminal.allow-question')}</span>
</div>
<p className={styles.command_content}>
<code>$ {args.command}</code>
</p>
<p className={styles.comand_description}>{args.explanation}</p>
<div className={styles.cmmand_footer}>
<Button type='link' size='small' disabled={disabled} onClick={() => handleClick(true)}>
{localize('ai.native.mcp.terminal.allow')}
</Button>
<Button type='link' size='small' disabled={disabled} onClick={() => handleClick(false)}>
{localize('ai.native.mcp.terminal.deny')}
</Button>
</div>
</div>
)}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,39 @@
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;
overflow: auto;

code {
font-size: 12px;
white-space: pre;
}
}

.comand_description {
font-size: 11px;
color: var(--descriptionForeground);
}

.cmmand_footer {
display: flex;
justify-content: flex-end;
}
}
115 changes: 115 additions & 0 deletions packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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<string, Deferred<boolean>>();

private terminalId = 0;

getShellLaunchConfig(command: string) {
return {
name: `MCP:Terminal_${this.terminalId++}`,
cwd: this.appConfig.workspaceDir,
args: ['-c', command],
};
}

async handler(args: z.infer<typeof inputSchema> & { toolCallId: string }, logger: MCPLogger) {
if (args.require_user_approval) {
const def = new Deferred<boolean>();
this.approvalDeferredMap.set(args.toolCallId, def);
const approval = await def.promise;
if (!approval) {
return {
isError: false,
content: [
{
type: 'text',
text: 'User rejection',
},
],
};
}
}
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: 'text',
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;
}

handleApproval(callId: string, approval: boolean) {
if (!this.approvalDeferredMap.has(callId)) {
return;
}

const def = this.approvalDeferredMap.get(callId);
def?.resolve(approval);
}
}
75 changes: 7 additions & 68 deletions packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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<typeof inputSchema>, 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<typeof inputSchema> & { toolCallId: string }, logger: MCPLogger) {
return this.runCommandHandler.handler(args, logger);
}
}
6 changes: 6 additions & 0 deletions packages/i18n/src/common/en-US.lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1565,5 +1565,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.mcp.terminal.allow-question': 'Allow the terminal to run the command?',
'ai.native.mcp.terminal.allow': 'Allow',
'ai.native.mcp.terminal.deny': 'Reject',
},
};
6 changes: 6 additions & 0 deletions packages/i18n/src/common/zh-CN.lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1327,5 +1327,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.mcp.terminal.allow-question': '是否允许运行命令?',
'ai.native.mcp.terminal.allow': '允许',
'ai.native.mcp.terminal.deny': '拒绝',
},
};

0 comments on commit aca7ff8

Please sign in to comment.