From c218dcba03a2c0036b5b94f07cacade9f2448ba5 Mon Sep 17 00:00:00 2001 From: Kirigaya <1193466151@qq.com> Date: Mon, 19 May 2025 23:34:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + renderer/src/App.vue | 10 +- renderer/src/components/guide/tour.vue | 4 +- .../src/views/connect/connection-panel.vue | 11 +- renderer/src/views/connect/core.ts | 37 ++-- renderer/src/views/connect/index.vue | 25 ++- renderer/src/views/connect/type.ts | 1 + service/src/hook/util.ts | 0 service/src/main.ts | 60 +++--- service/src/mcp/connect.service.ts | 178 ++++++++++++++---- service/src/server.ts | 61 +++--- 11 files changed, 241 insertions(+), 148 deletions(-) create mode 100644 service/src/hook/util.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8fac9..d758fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [main] 0.1.0 - 新特性:支持同时连入多个 mcp server - 新特性:更新协议内容,支持 streamable http 协议,未来将逐步取代 SSE 的连接方式 +- 对于 uv 创建的 py 项目进行特殊支持:自动初始化项目,并将 mcp 定向到 .venv/bin/mcp 中,不再需要用户全局安装 mcp +- 对于 npm 创建的 js/ts 项目进行特殊支持:自动初始化项目 ## [main] 0.0.9 - 修复 0.0.8 引入的bug:system prompt 返回的是索引而非真实内容 diff --git a/renderer/src/App.vue b/renderer/src/App.vue index feaa9dd..2c37d3a 100644 --- a/renderer/src/App.vue +++ b/renderer/src/App.vue @@ -48,11 +48,11 @@ onMounted(async () => { pinkLog('OpenMCP Client 启动'); // 跳转到首页 - if (route.name !== 'debug') { - const targetRoute = import.meta.env.BASE_URL + 'debug'; - console.log('go to ' + targetRoute); - router.push(targetRoute); - } + // if (route.name !== 'debug') { + // const targetRoute = import.meta.env.BASE_URL + 'debug'; + // console.log('go to ' + targetRoute); + // router.push(targetRoute); + // } // 进行桥接 await bridge.awaitForWebsocket(); diff --git a/renderer/src/components/guide/tour.vue b/renderer/src/components/guide/tour.vue index 71c552b..175e75a 100644 --- a/renderer/src/components/guide/tour.vue +++ b/renderer/src/components/guide/tour.vue @@ -58,7 +58,7 @@
@@ -18,7 +18,7 @@
@@ -51,6 +51,8 @@ const props = defineProps({ const client = mcpClientAdapter.clients[props.index]; console.log(client); +console.log(client.connectionSettingRef); + const { t } = useI18n(); @@ -59,8 +61,9 @@ const isLoading = ref(false); async function connect() { isLoading.value = true; - const plaform = getPlatform(); - const ok = await client.connect(plaform); + const platform = getPlatform(); + const ok = await client.connect(); + if (ok) { mcpClientAdapter.saveLaunchSignature(); } diff --git a/renderer/src/views/connect/core.ts b/renderer/src/views/connect/core.ts index 88a049b..f771308 100644 --- a/renderer/src/views/connect/core.ts +++ b/renderer/src/views/connect/core.ts @@ -1,5 +1,5 @@ import { useMessageBridge } from "@/api/message-bridge"; -import { reactive, ref, type Reactive, type Ref } from "vue"; +import { reactive } from "vue"; import type { IConnectionResult, ConnectionTypeOptionItem, IConnectionArgs, IConnectionEnvironment, McpOptions } from "./type"; import { ElMessage } from "element-plus"; import { loadPanels } from "@/hook/panel"; @@ -23,49 +23,49 @@ export const connectionSelectDataViewOption: ConnectionTypeOptionItem[] = [ export class McpClient { // 连接入参 - public connectionArgs: Reactive; + public connectionArgs: IConnectionArgs; // 连接出参 - public connectionResult: Reactive; + public connectionResult: IConnectionResult; // 预设环境变量,初始化的时候会去获取它们 public presetsEnvironment: string[] = ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; // 环境变量 - public connectionEnvironment: Reactive; + public connectionEnvironment: IConnectionEnvironment; // logger 面板的 ref - public connectionLogRef = ref(null); + public connectionLogRef: any = null; // setting 面板的 ref - public connectionSettingRef = ref(null); + public connectionSettingRef: any = null; constructor( public clientVersion: string = '0.0.1', public clientNamePrefix: string = 'openmcp.connect' ) { // 连接入参 - this.connectionArgs = reactive({ + this.connectionArgs = { type: 'STDIO', commandString: '', cwd: '', url: '', oauth: '' - }); + }; // 连接出参 - this.connectionResult = reactive({ + this.connectionResult = { success: false, status: 'disconnected', clientId: '', name: '', version: '', logString: [] - }); + }; // 环境变量 - this.connectionEnvironment = reactive({ + this.connectionEnvironment = { data: [], newKey: '', newValue: '' - }); + }; } async acquireConnectionSignature(args: IConnectionArgs) { @@ -121,6 +121,7 @@ export class McpClient { const { command, args } = this.commandAndArgs; const env = this.env; const url = this.connectionArgs.url; + const cwd = this.connectionArgs.cwd; const oauth = this.connectionArgs.oauth; const connectionType = this.connectionArgs.type; @@ -132,6 +133,7 @@ export class McpClient { command, args, url, + cwd, oauth, clientName, clientVersion, @@ -145,7 +147,7 @@ export class McpClient { return option; } - public async connect(platform: string) { + public async connect() { const bridge = useMessageBridge(); const { code, msg } = await bridge.commandRequest('connect', this.connectOption); @@ -160,6 +162,11 @@ export class McpClient { ElMessage.error(message); return false; + } else { + this.connectionResult.logString.push({ + type: 'info', + message: msg.info || '' + }) } this.connectionResult.status = msg.status; @@ -284,9 +291,13 @@ class McpClientAdapter { public async launch() { const launchSignature = await this.getLaunchSignature(); + console.log('launchSignature', launchSignature); + let allOk = true; for (const item of launchSignature) { + + // 创建一个新的客户端 const client = new McpClient(); // 同步连接参数 diff --git a/renderer/src/views/connect/index.vue b/renderer/src/views/connect/index.vue index 4220cdd..4377c65 100644 --- a/renderer/src/views/connect/index.vue +++ b/renderer/src/views/connect/index.vue @@ -3,9 +3,15 @@
- Server {{ index + 1 }} - - {{ client.connectionResult.status }} + + + + + + + + Unconnected +
@@ -21,7 +27,7 @@ @@ -45,11 +50,15 @@ function addServer() { } .server-list { - width: 200px; + width: 150px; border-right: 1px solid var(--border-color); padding: 10px; } +.server-name { + font-size: 15px; +} + .server-item { padding: 10px; margin-bottom: 5px; diff --git a/renderer/src/views/connect/type.ts b/renderer/src/views/connect/type.ts index e6c5126..eeb3245 100644 --- a/renderer/src/views/connect/type.ts +++ b/renderer/src/views/connect/type.ts @@ -16,6 +16,7 @@ export interface IConnectionArgs { export interface IConnectionResult { + info?: string; success: boolean; status: string clientId: string diff --git a/service/src/hook/util.ts b/service/src/hook/util.ts new file mode 100644 index 0000000..e69de29 diff --git a/service/src/main.ts b/service/src/main.ts index 6faf992..f5f67b8 100644 --- a/service/src/main.ts +++ b/service/src/main.ts @@ -42,19 +42,20 @@ interface ISSELaunchSignature { export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature; function refreshConnectionOption(envPath: string) { + const serverPath = path.join(__dirname, '..', '..', 'servers'); + const defaultOption = { type:'STDIO', - command: 'mcp', - args: ['run', 'main.py'], - cwd: '../server' + commandString: 'mcp run main.py', + cwd: serverPath }; fs.writeFileSync(envPath, JSON.stringify(defaultOption, null, 4)); - return defaultOption; + return { data: [ defaultOption ] }; } -function getInitConnectionOption() { +function acquireConnectionOption() { const envPath = path.join(__dirname, '..', '.env'); if (!fs.existsSync(envPath)) { @@ -63,6 +64,15 @@ function getInitConnectionOption() { try { const option = JSON.parse(fs.readFileSync(envPath, 'utf-8')); + + if (!option.data) { + return refreshConnectionOption(envPath); + } + + if (option.data && option.data.length === 0) { + return refreshConnectionOption(envPath); + } + return option; } catch (error) { @@ -73,27 +83,11 @@ function getInitConnectionOption() { function updateConnectionOption(data: any) { const envPath = path.join(__dirname, '..', '.env'); - - if (data.connectionType === 'STDIO') { - const connectionItem = { - type: 'STDIO', - command: data.command, - args: data.args, - cwd: data.cwd.replace(/\\/g, '/') - }; - - fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); - } else { - const connectionItem = { - type: 'SSE', - url: data.url, - oauth: data.oauth - }; - - fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); - } + const connection = { data }; + fs.writeFileSync(envPath, JSON.stringify(connection, null, 4)); } + const devHome = path.join(__dirname, '..', '..'); setRunningCWD(devHome); @@ -115,30 +109,18 @@ wss.on('connection', (ws: any) => { } }); - const option = getInitConnectionOption(); + const option = acquireConnectionOption(); // 注册消息接受的管线 webview.onDidReceiveMessage(message => { - logger.info(`command: [${message.command || 'No Command'}]`); + logger.info(`command: [${message.command || 'No Command'}]`); const { command, data } = message; switch (command) { case 'web/launch-signature': - const launchResultMessage: ILaunchSigature = option.type === 'STDIO' ? - { - type: 'STDIO', - commandString: option.command + ' ' + option.args.join(' '), - cwd: option.cwd || '' - } : - { - type: 'SSE', - url: option.url, - oauth: option.oauth || '' - }; - const launchResult = { code: 200, - msg: launchResultMessage + msg: option.data }; webview.postMessage({ diff --git a/service/src/mcp/connect.service.ts b/service/src/mcp/connect.service.ts index ed9461f..fb22a3a 100644 --- a/service/src/mcp/connect.service.ts +++ b/service/src/mcp/connect.service.ts @@ -1,54 +1,157 @@ -import { spawnSync } from 'node:child_process'; +import { execSync, spawnSync } from 'node:child_process'; import { RequestClientType } from '../common'; import { connect } from './client.service'; import { RestfulResponse } from '../common/index.dto'; import { McpOptions } from './client.dto'; import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import fs from 'node:fs'; export const clientMap: Map = new Map(); export function getClient(clientId?: string): RequestClientType | undefined { - return clientMap.get(clientId || ''); + return clientMap.get(clientId || ''); } export function tryGetRunCommandError(command: string, args: string[] = [], cwd?: string): string | null { - try { + try { console.log('current command', command); console.log('current args', args); - - const result = spawnSync(command, args, { - cwd: cwd || process.cwd(), - STDIO: 'pipe', - encoding: 'utf-8' - }); - if (result.error) { - return result.error.message; - } - if (result.status !== 0) { - return result.stderr || `Command failed with code ${result.status}`; - } - return null; - } catch (error) { - return error instanceof Error ? error.message : String(error); - } + const commandString = [command, ...args].join(' '); + + const result = execSync(commandString, { + cwd: cwd || process.cwd() + }).toString('utf-8'); + + return result; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } } +function getCWD(option: McpOptions) { + if (option.cwd) { + return option.cwd; + } + const file = option.args?.at(-1); + if (file) { + return path.dirname(file); + } + return undefined; +} + +function getCommandFileExt(option: McpOptions) { + const file = option.args?.at(-1); + if (file) { + return path.extname(file); + } + return undefined; +} + + +function preprocessCommand(option: McpOptions): [McpOptions, string] { + // 对于特殊表示的路径,进行特殊的支持 + if (option.args) { + option.args = option.args.map(arg => { + if (arg.startsWith('~/')) { + return arg.replace('~', process.env.HOME || ''); + } + return arg; + }); + } + + if (option.connectionType === 'SSE' || option.connectionType === 'STREAMABLE_HTTP') { + return [option, '']; + } + + const cwd = getCWD(option); + if (!cwd) { + return [option, '']; + } + + const ext = getCommandFileExt(option); + if (!ext) { + return [option, '']; + } + + // STDIO 模式下,对不同类型的项目进行额外支持 + // uv:如果没有初始化,则进行 uv sync,将 mcp 设置为虚拟环境的 + // npm:如果没有初始化,则进行 npm init,将 mcp 设置为虚拟环境 + // go:如果没有初始化,则进行 go mod init + + let info: string = ''; + + switch (ext) { + case '.py': + info = initUv(cwd); + break; + case '.js': + case '.ts': + info = initNpm(cwd); + break; + + default: + break; + } + + + return [option, info]; +} + +function initUv(cwd: string) { + let projectDir = cwd; + + while (projectDir!== path.dirname(projectDir)) { + if (fs.readFileSync(projectDir).includes('pyproject.toml')) { + break; + } + projectDir = path.dirname(projectDir); + } + + console.log(projectDir); + + + const venv = path.join(projectDir, '.venv'); + const mcpCli = path.join(venv, 'bin', 'mcp'); + if (fs.existsSync(mcpCli)) { + return ''; + } + + let info = ''; + info += execSync('uv sync', { cwd: projectDir }).toString('utf-8') + '\n'; + info += execSync('uv add mcp "mcp[cli]"', { cwd: projectDir }).toString('utf-8') + '\n'; + + return info; +} + + +function initNpm(cwd: string) { + let projectDir = cwd; + + while (projectDir !== path.dirname(projectDir)) { + if (fs.readFileSync(projectDir).includes('package.json')) { + break; + } + projectDir = path.dirname(projectDir); + } + + const nodeModulesPath = path.join(projectDir, 'node_modules'); + if (fs.existsSync(nodeModulesPath)) { + return ''; + } + + return execSync('npm i', { cwd: projectDir }).toString('utf-8') + '\n'; +} + + export async function connectService( - option: McpOptions + option: McpOptions ): Promise { try { console.log('ready to connect', option); - // 对于特殊表示的路径,进行特殊的支持 - if (option.args) { - option.args = option.args.map(arg => { - if (arg.startsWith('~/')) { - return arg.replace('~', process.env.HOME || ''); - } - return arg; - }); - } - + const info = preprocessCommand(option); + const client = await connect(option); const uuid = randomUUID(); clientMap.set(uuid, client); @@ -61,16 +164,17 @@ export async function connectService( status: 'success', clientId: uuid, name: versionInfo?.name, - version: versionInfo?.version + version: versionInfo?.version, + info } }; - - return connectResult; + + return connectResult; } catch (error) { - + // TODO: 这边获取到的 error 不够精致,如何才能获取到更加精准的错误 // 比如 error: Failed to spawn: `server.py` - // Caused by: No such file or directory (os error 2) + // Caused by: No such file or directory (os error 2) let errorMsg = ''; @@ -79,12 +183,12 @@ export async function connectService( } errorMsg += (error as any).toString(); - + const connectResult = { code: 500, msg: errorMsg }; - return connectResult; + return connectResult; } } diff --git a/service/src/server.ts b/service/src/server.ts index 0d70b38..a2ab3eb 100644 --- a/service/src/server.ts +++ b/service/src/server.ts @@ -43,19 +43,20 @@ interface ISSELaunchSignature { export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature; function refreshConnectionOption(envPath: string) { + const serverPath = path.join(__dirname, '..', '..', 'servers'); + const defaultOption = { - type: 'STDIO', - command: 'mcp', - args: ['run', 'main.py'], - cwd: '../server' + type:'STDIO', + commandString: 'mcp run main.py', + cwd: serverPath }; - fs.writeFileSync(envPath, JSON.stringify(defaultOption, null, 4)); + fs.writeFileSync(envPath, JSON.stringify(defaultOption, null, 4)); - return defaultOption; + return { data: [ defaultOption ] }; } -function getInitConnectionOption() { +function acquireConnectionOption() { const envPath = path.join(__dirname, '..', '.env'); if (!fs.existsSync(envPath)) { @@ -64,6 +65,15 @@ function getInitConnectionOption() { try { const option = JSON.parse(fs.readFileSync(envPath, 'utf-8')); + + if (!option.data) { + return refreshConnectionOption(envPath); + } + + if (option.data && option.data.length === 0) { + return refreshConnectionOption(envPath); + } + return option; } catch (error) { @@ -81,25 +91,8 @@ const authPassword = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env function updateConnectionOption(data: any) { const envPath = path.join(__dirname, '..', '.env'); - - if (data.connectionType === 'STDIO') { - const connectionItem = { - type: 'STDIO', - command: data.command, - args: data.args, - cwd: data.cwd.replace(/\\/g, '/') - }; - - fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); - } else { - const connectionItem = { - type: 'SSE', - url: data.url, - oauth: data.oauth - }; - - fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); - } + const connection = { data }; + fs.writeFileSync(envPath, JSON.stringify(connection, null, 4)); } const devHome = path.join(__dirname, '..', '..'); @@ -146,7 +139,7 @@ wss.on('connection', (ws: any) => { } }); - const option = getInitConnectionOption(); + const option = acquireConnectionOption(); // 注册消息接受的管线 webview.onDidReceiveMessage(message => { @@ -155,21 +148,9 @@ wss.on('connection', (ws: any) => { switch (command) { case 'web/launch-signature': - const launchResultMessage: ILaunchSigature = option.type === 'STDIO' ? - { - type: 'STDIO', - commandString: option.command + ' ' + option.args.join(' '), - cwd: option.cwd || '' - } : - { - type: 'SSE', - url: option.url, - oauth: option.oauth || '' - }; - const launchResult = { code: 200, - msg: launchResultMessage + msg: option.data }; webview.postMessage({