Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add terminal command execution tool with user approval #4398

Merged
merged 6 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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': '拒绝',
},
};
Loading