Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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);
}, []);
Comment thread
Aaaaash marked this conversation as resolved.

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.ncp.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.",
),
});
Comment thread
Aaaaash marked this conversation as resolved.

@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>>();
Comment thread
Aaaaash marked this conversation as resolved.

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',
},
],
};
}
}
Comment thread
Aaaaash marked this conversation as resolved.
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;
}
Comment thread
Aaaaash marked this conversation as resolved.

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);
}
Comment thread
Aaaaash marked this conversation as resolved.
Outdated
}
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 @@ -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?',
Comment thread
Aaaaash marked this conversation as resolved.
Outdated
'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 @@ -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': '是否允许运行命令?',
Comment thread
Aaaaash marked this conversation as resolved.
Outdated
'ai.native.mcp.terminal.allow': '允许',
'ai.native.mcp.terminal.deny': '拒绝',
},
};