fix: mcp stdio should run in workspace & support mcp tool enable/disable (#4605)

* fix: mcp stdio should run in workspace

* feat: implement mcp tool enable/disable

* fix: test
This commit is contained in:
LouisLv 2025-07-21 14:00:20 +08:00 committed by GitHub
parent be6af3732e
commit 94433253d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 134 additions and 16 deletions

View File

@ -24,7 +24,14 @@ describe('StdioMCPServer', () => {
beforeEach(() => {
jest.clearAllMocks();
server = new StdioMCPServer('test-server', 'test-command', ['arg1', 'arg2'], { ENV: 'test' }, mockLogger);
server = new StdioMCPServer(
'test-server',
'test-command',
['arg1', 'arg2'],
{ ENV: 'test' },
undefined,
mockLogger,
);
});
describe('constructor', () => {

View File

@ -102,7 +102,7 @@ export class ApplyService extends BaseApplyService {
Provide the complete updated code.
`,
{
...this.chatProxyService.getRequestOptions(),
...(await this.chatProxyService.getRequestOptions()),
trimTexts: ['<updated-code>', '</updated-code>'],
system:
'You are a coding assistant that helps merge code updates, ensuring every modification is fully integrated.',

View File

@ -11,6 +11,7 @@ import {
IAIReporter,
IApplicationService,
IChatProgress,
MCPConfigServiceToken,
} from '@opensumi/ide-core-common';
import { AINativeSettingSectionsId } from '@opensumi/ide-core-common/lib/settings/ai-native';
import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service';
@ -27,6 +28,7 @@ import {
} from '../../common';
import { DEFAULT_SYSTEM_PROMPT } from '../../common/prompts/system-prompt';
import { ChatToolRender } from '../components/ChatToolRender';
import { MCPConfigService } from '../mcp/config/mcp-config.service';
import { IChatAgentViewService } from '../types';
import { ChatFeatureRegistry } from './chat.feature.registry';
@ -66,9 +68,12 @@ export class ChatProxyService extends Disposable {
@Autowired(IMessageService)
private readonly messageService: IMessageService;
@Autowired(MCPConfigServiceToken)
private readonly mcpConfigService: MCPConfigService;
private chatDeferred: Deferred<void> = new Deferred<void>();
public getRequestOptions() {
public async getRequestOptions() {
const model = this.preferenceService.get<string>(AINativeSettingSectionsId.LLMModelSelection);
const modelId = this.preferenceService.get<string>(AINativeSettingSectionsId.ModelID);
let apiKey: string = '';
@ -86,6 +91,7 @@ export class ChatProxyService extends Disposable {
}
const maxTokens = this.preferenceService.get<number>(AINativeSettingSectionsId.MaxTokens);
const agent = this.chatAgentService.getAgent(ChatProxyService.AGENT_ID);
const disabledTools = await this.mcpConfigService.getDisabledTools();
return {
clientId: this.applicationService.clientId,
model,
@ -94,6 +100,7 @@ export class ChatProxyService extends Disposable {
baseURL,
maxTokens,
system: agent?.metadata.systemPrompt,
disabledTools,
};
}
@ -139,7 +146,7 @@ export class ChatProxyService extends Disposable {
sessionId: request.sessionId,
history,
images: request.images,
...this.getRequestOptions(),
...(await this.getRequestOptions()),
},
token,
);

View File

@ -222,4 +222,16 @@
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
&.disabledTool {
opacity: 0.5;
background-color: var(--notification-warning-background);
color: var(--notification-warning-foreground);
text-decoration: line-through;
}
&:hover {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}

View File

@ -20,14 +20,21 @@ export const MCPConfigView: React.FC = () => {
const [editingServer, setEditingServer] = React.useState<MCPServerDescription | undefined>();
const [loadingServer, setLoadingServer] = React.useState<string | undefined>();
const [isReady, setIsReady] = React.useState(mcpConfigService.isInitialized);
const [disabledTools, setDisabledTools] = React.useState<string[]>([]);
const loadServers = useCallback(async () => {
const allServers = await mcpConfigService.getServers();
setServers(allServers);
}, [mcpConfigService]);
const loadDisabledTools = useCallback(async () => {
const disabled = await mcpConfigService.getDisabledTools();
setDisabledTools(disabled);
}, [mcpConfigService]);
React.useEffect(() => {
loadServers();
loadDisabledTools();
const disposer = mcpConfigService.onMCPServersChange((isReady) => {
if (isReady) {
setIsReady(true);
@ -38,7 +45,7 @@ export const MCPConfigView: React.FC = () => {
return () => {
disposer.dispose();
};
}, [loadServers]);
}, [loadServers, loadDisabledTools]);
const handleServerControl = useCallback(
async (serverName: string, start: boolean) => {
@ -102,6 +109,22 @@ export const MCPConfigView: React.FC = () => {
[mcpConfigService, loadServers],
);
const handleToggleTool = useCallback(
async (toolName: string) => {
await mcpConfigService.toggleToolEnabled(toolName);
// 直接更新本地状态,避免重新从 service 加载
setDisabledTools((prev) => {
const isCurrentlyDisabled = prev.includes(toolName);
if (isCurrentlyDisabled) {
return prev.filter((name) => name !== toolName);
} else {
return [...prev, toolName];
}
});
},
[mcpConfigService],
);
return (
<div className={styles.container}>
<div className={styles.header}>
@ -196,11 +219,21 @@ export const MCPConfigView: React.FC = () => {
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Tools:</span>
<span className={styles.detailContent}>
{server.tools.map((tool, index) => (
<Badge key={index} className={styles.toolTag} title={tool.description}>
{tool.name}
</Badge>
))}
{server.tools.map((tool, index) => {
const isDisabled = disabledTools.includes(tool.name);
return (
<Badge
key={index}
className={cls(styles.toolTag, isDisabled && styles.disabledTool)}
title={`${tool.description}${isDisabled ? ' (已禁用)' : ''}`}
onClick={() => handleToggleTool(tool.name)}
style={{ cursor: 'pointer' }}
>
{tool.name}
{isDisabled && <span style={{ marginLeft: '4px', opacity: 0.6 }}></span>}
</Badge>
);
})}
</span>
</div>
</div>

View File

@ -1,5 +1,5 @@
import { Autowired, Injectable } from '@opensumi/di';
import { ILogger } from '@opensumi/ide-core-browser';
import { AppConfig, ILogger } from '@opensumi/ide-core-browser';
import { PreferenceService } from '@opensumi/ide-core-browser/lib/preferences';
import {
Deferred,
@ -46,13 +46,19 @@ export class MCPConfigService extends Disposable {
@Autowired(WorkbenchEditorService)
private readonly workbenchEditorService: WorkbenchEditorService;
@Autowired(AppConfig)
private readonly appConfig: AppConfig;
@Autowired(ILogger)
private readonly logger: ILogger;
private chatStorage: IStorage;
private mcpConfigStorage: IStorage;
private whenReadyDeferred = new Deferred<void>();
private _isInitialized = false;
private disabledToolsCache: Set<string> = new Set();
private disabledToolsCacheInitialized = false;
private readonly mcpServersChangeEventEmitter = new Emitter<boolean>();
@ -75,6 +81,8 @@ export class MCPConfigService extends Disposable {
private async init() {
this.chatStorage = await this.storageProvider(STORAGE_NAMESPACE.CHAT);
this.mcpConfigStorage = await this.storageProvider(STORAGE_NAMESPACE.MCP);
await this.loadDisabledToolsCache();
this.whenReadyDeferred.resolve();
}
@ -259,7 +267,7 @@ export class MCPConfigService extends Disposable {
type: MCP_SERVER_TYPE.STDIO,
command: server.command,
args: server.args,
env: server.env,
env: Object.assign({ cwd: this.appConfig.workspaceDir }, server.env),
enabled: !disabledMCPServers.includes(serverName),
};
}
@ -280,6 +288,37 @@ export class MCPConfigService extends Disposable {
}
}
async getDisabledTools(): Promise<string[]> {
await this.whenReady;
if (!this.disabledToolsCacheInitialized) {
await this.loadDisabledToolsCache();
}
return Array.from(this.disabledToolsCache);
}
async toggleToolEnabled(toolName: string): Promise<void> {
await this.whenReady;
if (!this.disabledToolsCacheInitialized) {
await this.loadDisabledToolsCache();
}
if (this.disabledToolsCache.has(toolName)) {
this.disabledToolsCache.delete(toolName);
} else {
this.disabledToolsCache.add(toolName);
}
await this.mcpConfigStorage.set('disabledMCPTools', Array.from(this.disabledToolsCache));
}
async isToolEnabled(toolName: string): Promise<boolean> {
await this.whenReady;
if (!this.disabledToolsCacheInitialized) {
await this.loadDisabledToolsCache();
}
return !this.disabledToolsCache.has(toolName);
}
async openConfigFile(): Promise<void> {
let config = this.preferenceService.resolve<{ mcpServers: Record<string, any> }>(
'mcp',
@ -301,4 +340,10 @@ export class MCPConfigService extends Disposable {
});
}
}
private async loadDisabledToolsCache(): Promise<void> {
const disabledTools = this.mcpConfigStorage.get<string[]>('disabledMCPTools', []);
this.disabledToolsCache = new Set(disabledTools);
this.disabledToolsCacheInitialized = true;
}
}

View File

@ -38,6 +38,12 @@ export abstract class BaseLanguageModel {
if (clientId) {
const registry = this.toolInvocationRegistryManager.getRegistry(clientId);
allFunctions = options.noTool ? [] : registry.getAllFunctions();
// 过滤禁用的工具
if (options.disabledTools && options.disabledTools.length > 0) {
const disabledToolsSet = new Set(options.disabledTools);
allFunctions = allFunctions.filter((tool) => !disabledToolsSet.has(tool.name));
}
}
return this.handleStreamingRequest(

View File

@ -161,14 +161,15 @@ export class MCPServerManagerImpl implements MCPServerManager {
const existingServer = this.servers.get(description.name);
if (description.type === MCP_SERVER_TYPE.STDIO) {
const { name, command, args, env } = description;
const envs = {
const envs: any = {
...env,
PATH: this.shellPath || process.env.PATH || '',
};
if (existingServer) {
existingServer.update(command, args, envs);
} else {
const newServer = new StdioMCPServer(name, command, args, envs, this.logger);
// cwd 默认从前端透传过来
const newServer = new StdioMCPServer(name, command, args, envs, envs.cwd, this.logger);
this.servers.set(name, newServer);
}
} else if (description.type === MCP_SERVER_TYPE.SSE) {

View File

@ -14,6 +14,7 @@ export class StdioMCPServer implements IMCPServer {
public args?: string[];
private client: Client;
private env?: { [key: string]: string };
private cwd?: string;
private started: boolean = false;
private toolNameMap: Map<string, string> = new Map(); // Map sanitized tool names to original names
@ -22,12 +23,14 @@ export class StdioMCPServer implements IMCPServer {
command: string,
args?: string[],
env?: Record<string, string>,
cwd?: string,
private readonly logger?: ILogger,
) {
this.name = name;
this.command = command === 'node' ? process.env.NODE_BINARY_PATH || 'node' : command;
this.args = args;
this.env = env;
this.cwd = cwd;
}
isStarted(): boolean {
@ -49,7 +52,7 @@ export class StdioMCPServer implements IMCPServer {
this.logger?.log(
`Starting server "${this.name}" with command: ${this.command} and args: ${this.args?.join(
' ',
)} and env: ${JSON.stringify(this.env)}`,
)} and env: ${JSON.stringify(this.env)} and cwd: ${this.cwd}`,
);
// Filter process.env to exclude undefined values
const sanitizedEnv: Record<string, string> = Object.fromEntries(
@ -60,10 +63,12 @@ export class StdioMCPServer implements IMCPServer {
...sanitizedEnv,
...(this.env || {}),
};
delete mergedEnv.cwd;
const transport = new StdioClientTransport({
command: this.command,
args: this.args,
env: mergedEnv,
cwd: this.cwd,
});
transport.onerror = (error) => {
this.logger?.error('Transport Error:', error);

View File

@ -54,6 +54,7 @@ export const STORAGE_NAMESPACE = {
DEBUG: new URI('debug').withScheme(STORAGE_SCHEMA.SCOPE),
OUTLINE: new URI('outline').withScheme(STORAGE_SCHEMA.SCOPE),
CHAT: new URI('chat').withScheme(STORAGE_SCHEMA.SCOPE),
MCP: new URI('mcp').withScheme(STORAGE_SCHEMA.SCOPE),
// global database
GLOBAL_LAYOUT: new URI('layout-global').withScheme(STORAGE_SCHEMA.GLOBAL),
GLOBAL_EXTENSIONS: new URI('extensions').withScheme(STORAGE_SCHEMA.GLOBAL),

View File

@ -187,6 +187,7 @@ export interface IAIBackServiceOption {
noTool?: boolean;
/** 响应首尾是否有需要trim的内容 */
trimTexts?: [string, string];
disabledTools?: string[];
}
/**
@ -440,7 +441,7 @@ export const CoreMessgaeRoleMap = {
export interface IHistoryChatMessage extends IChatMessage {
id: string;
order: number;
isSummarized?: boolean; // 添加这个属性,表示消息是否已被总结
isSummarized?: boolean; // 添加这个属性,表示消息是否已被总结
type?: 'string' | 'component';
images?: string[];