mirror of https://github.com/opensumi/core
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:
parent
be6af3732e
commit
94433253d7
|
@ -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', () => {
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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[];
|
||||
|
|
Loading…
Reference in New Issue