feat(service-mcp-connect): 实现stdio连接方式的热更新
This commit is contained in:
parent
0f32993edb
commit
17cc8f7612
4713
renderer/package-lock.json
generated
Normal file
4713
renderer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,32 +79,44 @@ const disableAllTools = () => {
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// 更新工具列表的方法
|
||||
const updateToolsList = async () => {
|
||||
// 将新的 tool 和并进入 tabStorage.settings.enableTools 中
|
||||
// 只需要保证 enable 信息同步即可,其余工具默认开启
|
||||
const disableToolNames = new Set<string>(
|
||||
tabStorage.settings.enableTools
|
||||
.filter(tool => !tool.enabled)
|
||||
.map(tool => tool.name)
|
||||
.filter(tool => !tool.enabled)
|
||||
.map(tool => tool.name)
|
||||
);
|
||||
|
||||
const newTools: EnableToolItem[] = [];
|
||||
|
||||
for (const client of mcpClientAdapter.clients) {
|
||||
const tools = await client.getTools();
|
||||
for (const tool of tools.values()) {
|
||||
const enabled = !disableToolNames.has(tool.name);
|
||||
const tools = await client.getTools({ cache: false });
|
||||
if (tools) {
|
||||
for (const tool of tools.values()) {
|
||||
const enabled = !disableToolNames.has(tool.name);
|
||||
|
||||
newTools.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
enabled
|
||||
});
|
||||
newTools.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
enabled
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tabStorage.settings.enableTools = newTools;
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await updateToolsList();
|
||||
watch(() => mcpClientAdapter.refreshSignal.value, async () => {
|
||||
await updateToolsList();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
@ -29,7 +29,7 @@ export const connectionSelectDataViewOption: ConnectionTypeOptionItem[] = [
|
||||
function prettifyMapKeys(keys: MapIterator<string>) {
|
||||
const result: string[] = [];
|
||||
for (const key of keys) {
|
||||
result.push('+ ' +key);
|
||||
result.push('+ ' + key);
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
@ -197,7 +197,7 @@ export class McpClient {
|
||||
const bridge = useMessageBridge();
|
||||
|
||||
const { code, msg } = await bridge.commandRequest<ToolsListResponse>('tools/list', { clientId: this.clientId });
|
||||
if (code!== 200) {
|
||||
if (code !== 200) {
|
||||
return new Map<string, ToolItem>();
|
||||
}
|
||||
|
||||
@ -227,7 +227,7 @@ export class McpClient {
|
||||
|
||||
const { code, msg } = await bridge.commandRequest<PromptsListResponse>('prompts/list', { clientId: this.clientId });
|
||||
|
||||
if (code!== 200) {
|
||||
if (code !== 200) {
|
||||
return new Map<string, PromptTemplate>();
|
||||
}
|
||||
|
||||
@ -252,7 +252,7 @@ export class McpClient {
|
||||
const bridge = useMessageBridge();
|
||||
|
||||
const { code, msg } = await bridge.commandRequest<ResourcesListResponse>('resources/list', { clientId: this.clientId });
|
||||
if (code!== 200) {
|
||||
if (code !== 200) {
|
||||
return new Map<string, Resources>();
|
||||
}
|
||||
|
||||
@ -276,7 +276,7 @@ export class McpClient {
|
||||
const bridge = useMessageBridge();
|
||||
|
||||
const { code, msg } = await bridge.commandRequest<ResourceTemplatesListResponse>('resources/templates/list', { clientId: this.clientId });
|
||||
if (code!== 200) {
|
||||
if (code !== 200) {
|
||||
return new Map();
|
||||
}
|
||||
this.resourceTemplates = new Map<string, ResourceTemplate>();
|
||||
@ -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) {
|
||||
@ -525,7 +603,7 @@ class McpClientAdapter {
|
||||
}
|
||||
|
||||
client.connectionResult.logString.push({
|
||||
type: code === 200 ? 'info': 'error',
|
||||
type: code === 200 ? 'info' : 'error',
|
||||
title: msg.title,
|
||||
message: msg.message
|
||||
});
|
||||
|
4633
service/package-lock.json
generated
Normal file
4633
service/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
133
service/src/mcp/connect-monitor.service.ts
Normal file
133
service/src/mcp/connect-monitor.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
||||
|
185
service/src/mcp/file-monitor.service.ts
Normal file
185
service/src/mcp/file-monitor.service.ts
Normal 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 };
|
Loading…
x
Reference in New Issue
Block a user