feat(service-mcp-connect): 实现stdio连接方式的热更新

This commit is contained in:
ZYD045692 2025-06-13 17:40:26 +08:00
parent 0f32993edb
commit 17cc8f7612
7 changed files with 9805 additions and 37 deletions

4713
renderer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import { ref, computed, inject, onMounted } from 'vue';
import { ref, computed, inject, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { type ChatStorage, type EnableToolItem, getToolSchema } from '../chat';
import { markdownToHtml } from '@/components/main-panel/chat/markdown/markdown';
@ -79,7 +79,8 @@ const disableAllTools = () => {
});
};
onMounted(async () => {
//
const updateToolsList = async () => {
// tool tabStorage.settings.enableTools
// enable
const disableToolNames = new Set<string>(
@ -91,7 +92,8 @@ onMounted(async () => {
const newTools: EnableToolItem[] = [];
for (const client of mcpClientAdapter.clients) {
const tools = await client.getTools();
const tools = await client.getTools({ cache: false });
if (tools) {
for (const tool of tools.values()) {
const enabled = !disableToolNames.has(tool.name);
@ -103,8 +105,18 @@ onMounted(async () => {
});
}
}
}
tabStorage.settings.enableTools = newTools;
}
onMounted(async () => {
await updateToolsList();
watch(() => mcpClientAdapter.refreshSignal.value, async () => {
await updateToolsList();
});
});
</script>

View File

@ -455,19 +455,60 @@ export class McpClient {
});
}
}
// 添加资源刷新方法,支持超时控制
public async refreshAllResources(timeoutMs = 30000): Promise<void> {
const controller = new AbortController();
const signal = controller.signal;
// 设置超时
const timeoutId = setTimeout(() => {
controller.abort();
console.error(`[REFRESH TIMEOUT] Client ${this.clientId}`);
}, timeoutMs);
try {
console.log(`[REFRESH START] Client ${this.clientId}`);
// 按顺序刷新资源
await this.getTools({ cache: false });
await this.getPromptTemplates({ cache: false });
await this.getResources({ cache: false });
await this.getResourceTemplates({ cache: false });
console.log(chalk.gray(`[${new Date().toLocaleString()}]`),
chalk.green(`🚀 [${this.name}] REFRESH COMPLETE`));
} catch (error) {
if (signal.aborted) {
throw new Error(`Refresh timed out after ${timeoutMs}ms`);
}
console.error(`[REFRESH ERROR] Client ${this.clientId}:`, error);
console.error(
chalk.gray(`[${new Date().toLocaleString()}]`),
chalk.red(`🚀 [${this.name}] REFRESH FAILED`),
error
);
throw error;
} finally {
clearTimeout(timeoutId);
}
}
}
class McpClientAdapter {
public clients: Reactive<McpClient[]> = [];
public currentClientIndex: number = 0;
public refreshSignal = reactive({ value: 0 });
private defaultClient: McpClient = new McpClient();
public connectLogListenerCancel: (() => void) | null = null;
public connectrefreshListener: (() => void) | null = null;
constructor(
public platform: string
) { }
) {
this.addConnectRefreshListener();
}
/**
* @description
@ -511,6 +552,43 @@ class McpClientAdapter {
});
}
private findClientIndexByUuid(uuid: string): number {
// 检查客户端数组是否存在且不为空
if (!this.clients || this.clients.length === 0) {
return -1;
}
const index = this.clients.findIndex(client => client.clientId === uuid);
return index;
}
public addConnectRefreshListener() {
// 创建对于 connect/refresh 的监听
if (!this.connectrefreshListener) {
const bridge = useMessageBridge();
this.connectrefreshListener = bridge.addCommandListener('connect/refresh', async (message) => {
const { code, msg } = message;
if (code === 200) {
// 查找目标客户端
const clientIndex = this.findClientIndexByUuid(msg.uuid);
if (clientIndex > -1) {
// 刷新该客户端的所有资源
await this.clients[clientIndex].refreshAllResources();
this.refreshSignal.value++;
} else {
console.error(
chalk.gray(`[${new Date().toLocaleString()}]`),
chalk.red(`No client found with ID: ${msg.uuid}`),
);
}
}
}, { once: false });
}
}
public async launch() {
// 创建对于 log/output 的监听
if (!this.connectLogListenerCancel) {

4633
service/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,133 @@
import { McpOptions } from './client.dto.js';
import { SingleFileMonitor, FileMonitorConfig } from './file-monitor.service.js';
import { PostMessageble } from '../hook/adapter.js';
import * as fs from 'fs';
import * as path from 'path';
import { pino } from 'pino';
// 保留现有 logger 配置
const logger = pino({
transport: {
target: 'pino-pretty',
options: {
colorize: true,
levelFirst: true,
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
}
}
});
function getFilePath(options: {
cwd?: string;
args?: string[];
}): string {
const baseDir = options.cwd || process.cwd();
const targetFile = options.args?.length ? options.args[options.args.length - 1] : '';
if (!targetFile || path.isAbsolute(targetFile)) {
return targetFile;
}
return path.resolve(baseDir, targetFile);
}
export class McpServerConnectMonitor {
private Monitor: SingleFileMonitor | undefined;
private Options: McpOptions;
private uuid: string;
private webview: PostMessageble | undefined;
private filePath: string;
constructor(uuid: string, options: McpOptions, onchange: Function, webview?: PostMessageble) {
this.Options = options;
this.webview = webview;
this.uuid = uuid;
this.filePath = getFilePath(options);
// 记录实例创建
logger.info({ uuid, connectionType: options.connectionType }, 'Created new connection monitor instance');
switch (options.connectionType) {
case 'STDIO':
this.setupStdioMonitor(onchange);
break;
case 'SSE':
logger.info({ uuid }, 'SSE connection type configured but not implemented');
break;
case 'STREAMABLE_HTTP':
logger.info({ uuid }, 'STREAMABLE_HTTP connection type configured but not implemented');
break;
}
}
private setupStdioMonitor(onchange: Function) {
const fileConfig: FileMonitorConfig = {
filePath: this.filePath,
debounceTime: 500,
duplicateCheckTime: 500,
onChange: async (curr, prev) => {
// 使用 info 级别记录文件修改
logger.info({
uuid: this.uuid,
size: curr.size,
mtime: new Date(curr.mtime).toLocaleString()
}, 'File modified');
try {
await onchange(this.uuid, this.Options);
this.sendWebviewMessage('connect/refresh', {
code: 200,
msg: {
message: 'refresh connect success',
uuid: this.uuid,
}
});
logger.info({ uuid: this.uuid }, 'Connection refresh successful');
} catch (err) {
this.sendWebviewMessage('connect/refresh', {
code: 500,
msg: {
message: 'refresh connect failed',
uuid: this.uuid,
}
});
// 使用 error 级别记录错误
logger.error({ uuid: this.uuid, error: err }, 'Connection refresh failed');
}
},
onDelete: () => {
// 使用 warn 级别记录文件删除
logger.warn({ uuid: this.uuid }, 'Monitored file has been deleted');
},
onStart: () => {
// 使用 info 级别记录监控开始
logger.info({ uuid: this.uuid, filePath: path.resolve(fileConfig.filePath) }, 'Started monitoring file');
try {
const stats = fs.statSync(fileConfig.filePath);
// 使用 debug 级别记录详细文件信息
logger.debug({
uuid: this.uuid,
size: stats.size,
ctime: new Date(stats.ctime).toLocaleString()
}, 'File information retrieved');
} catch (err) {
// 使用 error 级别记录获取文件信息失败
logger.error({ uuid: this.uuid, error: err }, 'Failed to retrieve file information');
}
},
onError: (error) => {
// 使用 error 级别记录监控错误
logger.error({ uuid: this.uuid, error }, 'Error occurred during monitoring');
}
};
this.Monitor = new SingleFileMonitor(fileConfig);
}
private sendWebviewMessage(command: string, data: any) {
// 发送消息到webview
this.webview?.postMessage({ command, data });
}
}

View File

@ -3,6 +3,7 @@ import { RequestClientType } from '../common/index.dto.js';
import { connect } from './client.service.js';
import { RestfulResponse } from '../common/index.dto.js';
import { McpOptions } from './client.dto.js';
import { McpServerConnectMonitor } from './connect-monitor.service.js';
import * as crypto from 'node:crypto';
import path from 'node:path';
import fs from 'node:fs';
@ -14,7 +15,19 @@ export const clientMap: Map<string, RequestClientType> = new Map();
export function getClient(clientId?: string): RequestClientType | undefined {
return clientMap.get(clientId || '');
}
export const clientMonitorMap: Map<string, McpServerConnectMonitor> = new Map();
export async function updateClientMap(uuid: string, options: McpOptions): Promise<{ res: boolean; error?: any }> {
try {
const client = await connect(options);
clientMap.set(uuid, client);
const tools = await client.listTools();
console.log('[updateClientMap] tools:', tools);
return { res: true };
} catch (error) {
console.error('[updateClientMap] error:', error);
return { res: false, error };
}
}
export function tryGetRunCommandError(command: string, args: string[] = [], cwd?: string): string | null {
try {
const commandString = command + ' ' + args.join(' ');
@ -290,6 +303,7 @@ export async function connectService(
const client = await connect(option);
clientMap.set(uuid, client);
clientMonitorMap.set(uuid, new McpServerConnectMonitor(uuid, option, updateClientMap, webview));
const versionInfo = client.getServerVersion();

View File

@ -0,0 +1,185 @@
import * as fs from 'fs';
import * as path from 'path';
/**
*
*/
interface FileMonitorConfig {
filePath: string;
onChange?: (curr: fs.Stats, prev: fs.Stats) => void;
onDelete?: () => void;
onStart?: () => void;
onError?: (error: Error) => void;
debounceTime?: number; // 防抖时间(毫秒)
duplicateCheckTime?: number; // 去重检查时间阈值(毫秒)
}
/**
*
*/
class SingleFileMonitor {
private filePath: string;
private onChange: (curr: fs.Stats, prev: fs.Stats) => void;
private onDelete: () => void;
private onError: (error: Error) => void;
private watcher: fs.FSWatcher | null = null;
private exists: boolean = false;
private lastModified: number = 0;
private lastSize: number = 0;
private debounceTimer: NodeJS.Timeout | null = null;
private debounceTime: number;
private duplicateCheckTime: number; // 去重检查时间阈值
private lastChangeTime: number = 0;
private lastChangeSize: number = 0;
private isProcessingChange = false;
private config: FileMonitorConfig; // 添加config属性
constructor(config: FileMonitorConfig) {
this.config = config; // 保存配置
this.filePath = config.filePath;
this.onChange = config.onChange || (() => {});
this.onDelete = config.onDelete || (() => {});
this.onError = config.onError || ((error) => console.error('文件监控错误:', error));
this.debounceTime = config.debounceTime || 1000;
// 使用配置中的去重时间默认800ms
this.duplicateCheckTime = config.duplicateCheckTime !== undefined
? config.duplicateCheckTime
: 800;
this.init();
}
private init() {
// 检查文件是否存在
this.checkFileExists()
.then(exists => {
this.exists = exists;
if (exists) {
const stats = fs.statSync(this.filePath);
this.lastModified = stats.mtimeMs;
this.lastSize = stats.size;
}
this.config.onStart?.(); // 使用保存的config属性
this.startWatching();
})
.catch(this.onError);
}
private startWatching() {
try {
this.watcher = fs.watch(this.filePath, (eventType) => {
if (eventType === 'change') {
this.handleFileChange(true);
}
});
console.log(`正在监控文件: ${this.filePath}`);
} catch (error) {
this.onError(error as Error);
}
}
private checkFileExists(): Promise<boolean> {
return new Promise(resolve => {
fs.access(this.filePath, fs.constants.F_OK, (err) => {
resolve(!err);
});
});
}
private handleFileChange(isFromWatch: boolean = false) {
if (this.isProcessingChange) return;
if (!this.exists) {
this.checkFileExists()
.then(exists => {
if (exists) {
this.exists = true;
const stats = fs.statSync(this.filePath);
this.lastModified = stats.mtimeMs;
this.lastSize = stats.size;
}
});
return;
}
let currentStats: fs.Stats;
try {
currentStats = fs.statSync(this.filePath);
} catch (error) {
this.exists = false;
this.onDelete();
return;
}
const currentMtime = currentStats.mtimeMs;
const currentSize = currentStats.size;
if (currentSize === this.lastSize && currentMtime - this.lastModified < 800) {
return;
}
const now = Date.now();
// 使用可配置的去重时间阈值
if (now - this.lastChangeTime < this.duplicateCheckTime && currentSize === this.lastChangeSize) {
return;
}
this.lastChangeTime = now;
this.lastChangeSize = currentSize;
const prevStats = fs.statSync(this.filePath);
this.lastModified = currentMtime;
this.lastSize = currentSize;
this.isProcessingChange = true;
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.checkFileExists()
.then(exists => {
if (exists) {
const currStats = fs.statSync(this.filePath);
this.onChange(currStats, prevStats);
}
})
.catch(this.onError)
.finally(() => {
this.isProcessingChange = false;
this.debounceTimer = null;
});
}, this.debounceTime);
}
private checkFileStatus() {
this.checkFileExists()
.then(exists => {
if (this.exists && !exists) {
this.exists = false;
this.onDelete();
} else if (!this.exists && exists) {
this.exists = true;
const stats = fs.statSync(this.filePath);
this.lastModified = stats.mtimeMs;
this.lastSize = stats.size;
}
})
.catch(this.onError);
}
public close() {
if (this.watcher) {
// 明确指定close方法的类型解决TS2554错误
(this.watcher.close as (callback?: () => void) => void)(() => {
console.log(`已停止监控文件: ${this.filePath}`);
});
this.watcher = null;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
}
}
export { SingleFileMonitor, FileMonitorConfig };