diff --git a/.vscode/tabs.example-servers_puppeteer.json b/.vscode/tabs.example-servers_puppeteer.json index e8d179d..48644ce 100644 --- a/.vscode/tabs.example-servers_puppeteer.json +++ b/.vscode/tabs.example-servers_puppeteer.json @@ -331,6 +331,34 @@ "state": "success", "serverName": "Huoshan DeepSeek" } + }, + { + "role": "user", + "content": "请将下面的更新日志翻译成 GitHub release 风格的英文说明,请只返回翻译后的结果,不要出现任何多余的前缀: ", + "extraInfo": { + "created": 1747598248965, + "state": "success", + "serverName": "deepseek" + } + }, + { + "role": "assistant", + "content": "Here’s the translation in GitHub release style:\n\n---\n\n### **New Features**\n- Added support for real-time data synchronization.\n- Introduced a new dashboard for analytics.\n\n### **Improvements**\n- Optimized query performance for large datasets.\n- Enhanced error handling for API requests.\n\n### **Bug Fixes**\n- Fixed an issue where the system crashed on invalid input.\n- Resolved a memory leak in the background service.\n\n### **Breaking Changes**\n- Removed deprecated endpoints (`/v1/old`).\n- Updated the authentication mechanism to use OAuth 2.0.\n\n### **Documentation**\n- Added detailed guides for API integration.\n- Improved inline code comments.", + "extraInfo": { + "created": 1747598260799, + "state": "success", + "serverName": "deepseek", + "usage": { + "prompt_tokens": 7946, + "completion_tokens": 136, + "total_tokens": 8082, + "prompt_tokens_details": { + "cached_tokens": 896 + }, + "prompt_cache_hit_tokens": 896, + "prompt_cache_miss_tokens": 7050 + } + } } ], "settings": { diff --git a/CHANGELOG.md b/CHANGELOG.md index f44f954..d758fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [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/package.json b/package.json index 9915952..7f48db8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openmcp", "displayName": "OpenMCP", "description": "An all in one MCP Client/TestTool", - "version": "0.0.9", + "version": "0.1.0", "publisher": "kirigaya", "author": { "name": "kirigaya", diff --git a/renderer/.env.development b/renderer/.env.development index 254e7de..7d53432 100644 --- a/renderer/.env.development +++ b/renderer/.env.development @@ -1 +1,2 @@ +VITE_USE_AUTH=false VITE_WEBSOCKET_URL=ws://localhost:8282 \ No newline at end of file diff --git a/renderer/.env.production b/renderer/.env.production index 254e7de..7d53432 100644 --- a/renderer/.env.production +++ b/renderer/.env.production @@ -1 +1,2 @@ +VITE_USE_AUTH=false VITE_WEBSOCKET_URL=ws://localhost:8282 \ No newline at end of file diff --git a/renderer/README.md b/renderer/README.md index 7493165..2b9f1f2 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -1,33 +1,18 @@ -# test-vite +## dev -This template should help get you started developing with Vue 3 in Vite. +如果想要部署到公网中,想要通过密码认证才能进入,进行如下步骤: -## Recommended IDE Setup - -[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). - -## Type Support for `.vue` Imports in TS - -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. - -## Customize configuration - -See [Vite Configuration Reference](https://vite.dev/config/). - -## Project Setup - -```sh -npm install +```bash +touch .env.website.local ``` -### Compile and Hot-Reload for Development +写入: -```sh -npm run dev +```toml +VITE_USE_AUTH=true +VITE_WEBSOCKET_URL=wss:///<路径> ``` -### Type-Check, Compile and Minify for Production +使用 `npm run serve:website` 进行测试(服务端使用 ts-node src/server.ts) -```sh -npm run build -``` +使用 `npm run build:website` 进行打包 \ No newline at end of file diff --git a/renderer/public/CascadiaCode.woff2 b/renderer/public/CascadiaCode.woff2 new file mode 100644 index 0000000..ed0335c Binary files /dev/null and b/renderer/public/CascadiaCode.woff2 differ diff --git a/renderer/src/App.vue b/renderer/src/App.vue index 622c1aa..2c37d3a 100644 --- a/renderer/src/App.vue +++ b/renderer/src/App.vue @@ -4,7 +4,7 @@ - + @@ -18,8 +18,7 @@ import MainPanel from '@/components/main-panel/index.vue'; import { setDefaultCss } from './hook/css'; import { greenLog, pinkLog } from './views/setting/util'; import { useMessageBridge } from './api/message-bridge'; -import { initialise } from './views/connect/connection'; -import { getPlatform } from './api/platform'; +import { initialise } from './views/connect'; import Tour from '@/components/guide/tour.vue'; import { userHasReadGuide } from './components/guide/tour'; @@ -37,8 +36,10 @@ bridge.addCommandListener('hello', data => { const route = useRoute(); const router = useRouter(); -const password = Boolean(import.meta.env.VITE_USE_PASSWORD); -privilegeStatus.allow = !Boolean(password); +const useAuth = Boolean(import.meta.env.VITE_USE_AUTH !== "false"); +console.log(import.meta.env.VITE_USE_AUTH, useAuth); + +privilegeStatus.allow = !Boolean(useAuth); onMounted(async () => { // 初始化 css @@ -47,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/api/message-bridge.ts b/renderer/src/api/message-bridge.ts index 70ecbed..f652321 100644 --- a/renderer/src/api/message-bridge.ts +++ b/renderer/src/api/message-bridge.ts @@ -7,9 +7,9 @@ export interface VSCodeMessage { callbackId?: string; } -export interface RestFulResponse { +export interface RestFulResponse { code: number; - msg: any; + msg: T; } export type MessageHandler = (message: VSCodeMessage) => void; @@ -19,6 +19,11 @@ interface AddCommandListenerOption { once: boolean // 只调用一次就销毁 } +export interface ICommandRequestData { + clientId?: string; + [key: string]: any; +} + export class MessageBridge { private ws: WebSocket | null = null; private handlers = new Map>(); @@ -206,7 +211,7 @@ export class MessageBridge { * @param data * @returns */ - public commandRequest(command: string, data?: any) { + public commandRequest(command: string, data?: ICommandRequestData): Promise> { return new Promise((resolve, reject) => { this.addCommandListener(command, (data) => { resolve(data as RestFulResponse); diff --git a/renderer/src/components/guide/tour.vue b/renderer/src/components/guide/tour.vue index 122ab47..175e75a 100644 --- a/renderer/src/components/guide/tour.vue +++ b/renderer/src/components/guide/tour.vue @@ -58,7 +58,7 @@ - + @@ -32,18 +32,19 @@ import { defineComponent, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { Connection } from './sidebar'; -import { connectionResult } from '@/views/connect/connection'; +import { mcpClientAdapter } from '@/views/connect/core'; defineComponent({ name: 'connected' }); const { t } = useI18n(); +const client = mcpClientAdapter.masterNode; const fullDisplayServerName = computed(() => { - return connectionResult.serverInfo.name + '/' + connectionResult.serverInfo.version; + return client.connectionResult.name + '/' + client.connectionResult.version; }); const displayServerName = computed(() => { - const name = connectionResult.serverInfo.name; + const name = client.connectionResult.name; if (name.length <= 3) return name; // 处理中文混合名称 diff --git a/renderer/src/hook/panel.ts b/renderer/src/hook/panel.ts index eb5504a..7c5b2ea 100644 --- a/renderer/src/hook/panel.ts +++ b/renderer/src/hook/panel.ts @@ -1,8 +1,9 @@ import { useMessageBridge } from "@/api/message-bridge"; import { pinkLog } from "@/views/setting/util"; import { debugModes, tabs } from "@/components/main-panel/panel"; -import { markRaw, ref, nextTick } from "vue"; +import { markRaw, ref } from "vue"; import { v4 as uuidv4 } from 'uuid'; +import type { McpClient } from "@/views/connect/core"; interface SaveTabItem { name: string; @@ -12,64 +13,55 @@ interface SaveTabItem { storage: Record; } -interface SaveTab { +export interface SaveTab { tabs: SaveTabItem[] currentIndex: number } export const panelLoaded = ref(false); -export function loadPanels() { - - return new Promise((resolve, reject) => { - const bridge = useMessageBridge(); - - bridge.addCommandListener('panel/load', data => { - if (data.code !== 200) { - pinkLog('tabs 加载失败'); - console.log(data.msg); - - } else { - const persistTab = data.msg as SaveTab; - - pinkLog('tabs 加载成功'); - - if (persistTab.tabs.length === 0) { - // 空的,直接返回不需要管 - panelLoaded.value = true; - resolve(void 0); - return; - } - - tabs.activeIndex = 0; - tabs.content = []; - - for (const tab of persistTab.tabs || []) { - - const component = tab.componentIndex >= 0? markRaw(debugModes[tab.componentIndex]) : undefined; - - tabs.content.push({ - id: uuidv4(), - name: tab.name, - icon: tab.icon, - type: tab.type, - componentIndex: tab.componentIndex, - component: component, - storage: tab.storage - }); - } - - tabs.activeIndex = persistTab.currentIndex; - } - - panelLoaded.value = true; - resolve(void 0); - }, { once: true }); - - bridge.postMessage({ - command: 'panel/load' - }); +export async function loadPanels(client: McpClient) { + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest('panel/load', { + clientId: client.clientId }); + if (code !== 200) { + pinkLog('tabs 加载失败'); + console.log(msg); + + } else { + const persistTab = msg; + + pinkLog('tabs 加载成功'); + + if (persistTab.tabs.length === 0) { + // 空的,直接返回不需要管 + panelLoaded.value = true; + return; + } + + tabs.activeIndex = 0; + tabs.content = []; + + for (const tab of persistTab.tabs || []) { + + const component = tab.componentIndex >= 0? markRaw(debugModes[tab.componentIndex]) : undefined; + + tabs.content.push({ + id: uuidv4(), + name: tab.name, + icon: tab.icon, + type: tab.type, + componentIndex: tab.componentIndex, + component: component, + storage: tab.storage + }); + } + + tabs.activeIndex = persistTab.currentIndex; + } + + panelLoaded.value = true; } let debounceHandler: number; diff --git a/renderer/src/hook/type.ts b/renderer/src/hook/type.ts index 1967de9..728404f 100644 --- a/renderer/src/hook/type.ts +++ b/renderer/src/hook/type.ts @@ -149,7 +149,7 @@ export type APIRequest = | ToolCallRequest; export interface IStdioConnectionItem { - type: 'stdio'; + type: 'STDIO'; name: string; command: string; args: string[]; @@ -159,7 +159,7 @@ export interface IStdioConnectionItem { } export interface ISSEConnectionItem { - type: 'sse'; + type: 'SSE'; name: string; url: string; oauth?: string; @@ -169,13 +169,13 @@ export interface ISSEConnectionItem { export interface IStdioLaunchSignature { - type: 'stdio'; + type: 'STDIO'; commandString: string; cwd: string; } export interface ISSELaunchSignature { - type:'sse'; + type:'SSE'; url: string; oauth: string; } diff --git a/renderer/src/router/index.ts b/renderer/src/router/index.ts index 06ea588..1b2a4b4 100644 --- a/renderer/src/router/index.ts +++ b/renderer/src/router/index.ts @@ -6,28 +6,28 @@ const routes: Array = [ { name : "default", path : "/", - redirect : baseURL + "/debug" + redirect : baseURL + "debug" }, { - path: baseURL + "/debug", + path: baseURL + "debug", name: "debug", component: () => import( /* webpackMode: "eager" */ "@/views/debug/index.vue"), meta: { title: "Debug" } }, { - path: baseURL + "/connect", + path: baseURL + "connect", name: "connect", component: () => import( /* webpackMode: "eager" */ "@/views/connect/index.vue"), meta: { title: "Connect" } }, { - path: baseURL + "/setting", + path: baseURL + "setting", name: "setting", component: () => import( /* webpackMode: "eager" */ "@/views/setting/index.vue"), meta: { title: "Setting" } }, { - path: baseURL + "/about", + path: baseURL + "about", name: "about", component: () => import( /* webpackMode: "eager" */ "@/views/about/index.vue"), meta: { title: "Tools" } diff --git a/renderer/src/views/connect/connection-args.vue b/renderer/src/views/connect/connection-args.vue index 1632b0e..e264b18 100644 --- a/renderer/src/views/connect/connection-args.vue +++ b/renderer/src/views/connect/connection-args.vue @@ -1,19 +1,19 @@ diff --git a/renderer/src/views/connect/connection-log.vue b/renderer/src/views/connect/connection-log.vue index 0dceaf3..68fdbde 100644 --- a/renderer/src/views/connect/connection-log.vue +++ b/renderer/src/views/connect/connection-log.vue @@ -3,7 +3,7 @@ {{ t('log') }}
-
+
{{ log.message }}
@@ -14,9 +14,17 @@ + + \ No newline at end of file diff --git a/renderer/src/views/connect/connection.ts b/renderer/src/views/connect/connection.ts deleted file mode 100644 index 82d23a2..0000000 --- a/renderer/src/views/connect/connection.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { useMessageBridge } from '@/api/message-bridge'; -import { reactive, ref } from 'vue'; -import { pinkLog } from '../setting/util'; -import { ElLoading, ElMessage } from 'element-plus'; -import { getPlatform, type OpenMcpSupportPlatform } from '@/api/platform'; -import { getTour, loadSetting } from '@/hook/setting'; -import { loadPanels } from '@/hook/panel'; - -export const connectionMethods = reactive({ - current: 'STDIO', - data: [ - { - value: 'STDIO', - label: 'STDIO' - }, - { - value: 'SSE', - label: 'SSE' - } - ] -}); - -export const connectionSettingRef = ref(null); -export const connectionLogRef = ref(null); - -export const connectionArgs = reactive({ - commandString: '', - cwd: '', - oauth: '', - urlString: '' -}); - -export interface EnvItem { - key: string - value: string -} - -export interface IConnectionEnv { - data: EnvItem[] - newKey: string - newValue: string -} - -export const connectionEnv = reactive({ - data: [], - newKey: '', - newValue: '' -}); - -export function makeEnv() { - const env = {} as Record; - connectionEnv.data.forEach(item => { - env[item.key] = item.value; - }); - return env; -} - - -// 定义连接类型 -type ConnectionType = 'STDIO' | 'SSE'; - -// 定义命令行参数接口 -export interface McpOptions { - connectionType: ConnectionType; - // STDIO 特定选项 - command?: string; - args?: string[]; - cwd?: string; - env?: Record; - // SSE 特定选项 - url?: string; - // 通用客户端选项 - clientName?: string; - clientVersion?: string; -} - -export async function doConnect( - option: { - namespace: OpenMcpSupportPlatform - updateCommandString?: boolean - } -) { - const { - // updateCommandString 为 true 代表是初始化阶段 - namespace, - updateCommandString = true - } = option; - - if (updateCommandString) { - pinkLog('请求启动参数'); - const connectionItem = await getLaunchSignature(namespace + '/launch-signature'); - - if (connectionItem.type ==='stdio') { - connectionMethods.current = 'STDIO'; - connectionArgs.commandString = connectionItem.commandString; - connectionArgs.cwd = connectionItem.cwd; - - if (connectionArgs.commandString.length === 0) { - return; - } - } else { - connectionMethods.current = 'SSE'; - connectionArgs.urlString = connectionItem.url || ''; - - if (connectionArgs.urlString.length === 0) { - return; - } - } - } - - if (connectionMethods.current === 'STDIO') { - await launchStdio(namespace); - } else { - await launchSSE(namespace); - } -} - -async function launchStdio(namespace: string) { - const bridge = useMessageBridge(); - const env = makeEnv(); - - const commandComponents = connectionArgs.commandString.split(/\s+/g); - const command = commandComponents[0]; - commandComponents.shift(); - - const connectOption = { - connectionType: 'STDIO', - command: command, - args: commandComponents, - cwd: connectionArgs.cwd, - clientName: 'openmcp.connect.stdio', - clientVersion: '0.0.1', - env - }; - - const { code, msg } = await bridge.commandRequest('connect', connectOption); - - connectionResult.success = (code === 200); - - if (code === 200) { - connectionResult.logString.push({ - type: 'info', - message: msg - }); - - const res = await getServerVersion() as { name: string, version: string }; - connectionResult.serverInfo.name = res.name || ''; - connectionResult.serverInfo.version = res.version || ''; - - // 同步信息到 vscode - const commandComponents = connectionArgs.commandString.split(/\s+/g); - const command = commandComponents[0]; - commandComponents.shift(); - - const clientStdioConnectionItem = { - serverInfo: connectionResult.serverInfo, - connectionType: 'STDIO', - name: 'openmcp.connect.stdio', - command: command, - args: commandComponents, - cwd: connectionArgs.cwd, - env - }; - - bridge.postMessage({ - command: namespace + '/update-connection-sigature', - data: JSON.parse(JSON.stringify(clientStdioConnectionItem)) - }); - - } else { - connectionResult.logString.push({ - type: 'error', - message: msg - }); - - ElMessage.error(msg); - } -} - -async function launchSSE(namespace: string) { - const bridge = useMessageBridge(); - const env = makeEnv(); - - const connectOption: McpOptions = { - connectionType: 'SSE', - url: connectionArgs.urlString, - clientName: 'openmcp.connect.sse', - clientVersion: '0.0.1', - env - }; - - const { code, msg } = await bridge.commandRequest('connect', connectOption); - - connectionResult.success = (code === 200); - - if (code === 200) { - connectionResult.logString.push({ - type: 'info', - message: msg - }); - - const res = await getServerVersion() as { name: string, version: string }; - connectionResult.serverInfo.name = res.name || ''; - connectionResult.serverInfo.version = res.version || ''; - - // 同步信息到 vscode - const clientSseConnectionItem = { - serverInfo: connectionResult.serverInfo, - connectionType: 'SSE', - name: 'openmcp.connect.sse', - url: connectionArgs.urlString, - oauth: connectionArgs.oauth, - env: env - }; - - bridge.postMessage({ - command: namespace + '/update-connection-sigature', - data: JSON.parse(JSON.stringify(clientSseConnectionItem)) - }); - - } else { - connectionResult.logString.push({ - type: 'error', - message: msg - }); - - ElMessage.error(msg); - } -} - - -async function getLaunchSignature(signatureName: string) { - const bridge = useMessageBridge(); - const { code, msg } = await bridge.commandRequest(signatureName); - - return msg; -} - -export function doReconnect() { - // TODO: finish this - console.log(); -} - -export const connectionResult = reactive<{ - success: boolean, - logString: { type: 'info' | 'error' | 'warning', message: string }[], - serverInfo: { - name: string, - version: string - } -}>({ - success: false, - logString: [], - serverInfo: { - name: '', - version: '' - } -}); - -export function getServerVersion() { - return new Promise((resolve, reject) => { - const bridge = useMessageBridge(); - bridge.addCommandListener('server/version', data => { - if (data.code === 200) { - resolve(data.msg); - } else { - reject(data.msg); - } - }, { once: true }); - - bridge.postMessage({ - command: 'server/version', - }); - }); -} - -export const envVarStatus = { - launched: false -}; - -function lookupEnvVar(varNames: string[]) { - const bridge = useMessageBridge(); - - return new Promise((resolve, reject) => { - bridge.addCommandListener('lookup-env-var', data => { - const { code, msg } = data; - - if (code === 200) { - connectionResult.logString.push({ - type: 'info', - message: '预设环境变量同步完成' - }); - - resolve(msg); - } else { - connectionResult.logString.push({ - type: 'error', - message: '预设环境变量同步失败: ' + msg - }); - - resolve(undefined); - } - }, { once: true }); - - console.log(varNames); - - - bridge.postMessage({ - command: 'lookup-env-var', - data: { - keys: varNames - } - }) - }); -} - - -export async function handleEnvSwitch(enabled: boolean) { - const presetVars = ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; - - if (enabled) { - const values = await lookupEnvVar(presetVars); - - if (values) { - // 将 key values 合并进 connectionEnv.data 中 - // 若已有相同的 key, 则替换 value - for (let i = 0; i < presetVars.length; i++) { - const key = presetVars[i]; - const value = values[i]; - const sameNameItems = connectionEnv.data.filter(item => item.key === key); - if (sameNameItems.length > 0) { - const conflictItem = sameNameItems[0]; - conflictItem.value = value; - } else { - connectionEnv.data.push({ - key: key, value: value - }); - } - } - } - } else { - // 清空 connectionEnv.data 中所有 key 为 presetVars 的项 - const reserveItems = connectionEnv.data.filter(item => !presetVars.includes(item.key)); - connectionEnv.data = reserveItems; - } -} - -export async function loadEnvVar() { - return await handleEnvSwitch(true); -} - -export async function initialise() { - - pinkLog('准备请求设置'); - - const loading = ElLoading.service({ - fullscreen: true, - lock: true, - text: 'Loading', - background: 'rgba(0, 0, 0, 0.7)' - }); - const platform = getPlatform(); - - // 加载全局设置 - loadSetting(); - - // 设置环境变量 - await loadEnvVar(); - - // 获取引导状态 - await getTour(); - - // 尝试进行初始化连接 - await doConnect({ - namespace: platform, - updateCommandString: true - }); - - // loading panels - await loadPanels(); - - loading.close(); -} \ No newline at end of file diff --git a/renderer/src/views/connect/core.ts b/renderer/src/views/connect/core.ts new file mode 100644 index 0000000..f771308 --- /dev/null +++ b/renderer/src/views/connect/core.ts @@ -0,0 +1,331 @@ +import { useMessageBridge } from "@/api/message-bridge"; +import { reactive } from "vue"; +import type { IConnectionResult, ConnectionTypeOptionItem, IConnectionArgs, IConnectionEnvironment, McpOptions } from "./type"; +import { ElMessage } from "element-plus"; +import { loadPanels } from "@/hook/panel"; +import { getPlatform } from "@/api/platform"; + +export const connectionSelectDataViewOption: ConnectionTypeOptionItem[] = [ + { + value: 'STDIO', + label: 'STDIO' + }, + { + value: 'SSE', + label: 'SSE' + }, + { + value: 'STREAMABLE_HTTP', + label: 'STREAMABLE_HTTP' + } +] + + +export class McpClient { + // 连接入参 + public connectionArgs: IConnectionArgs; + // 连接出参 + public connectionResult: IConnectionResult; + + // 预设环境变量,初始化的时候会去获取它们 + public presetsEnvironment: string[] = ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; + // 环境变量 + public connectionEnvironment: IConnectionEnvironment; + + // logger 面板的 ref + public connectionLogRef: any = null; + // setting 面板的 ref + public connectionSettingRef: any = null; + + constructor( + public clientVersion: string = '0.0.1', + public clientNamePrefix: string = 'openmcp.connect' + ) { + // 连接入参 + this.connectionArgs = { + type: 'STDIO', + commandString: '', + cwd: '', + url: '', + oauth: '' + }; + + // 连接出参 + this.connectionResult = { + success: false, + status: 'disconnected', + clientId: '', + name: '', + version: '', + logString: [] + }; + + // 环境变量 + this.connectionEnvironment = { + data: [], + newKey: '', + newValue: '' + }; + } + + async acquireConnectionSignature(args: IConnectionArgs) { + this.connectionArgs.type = args.type; + this.connectionArgs.commandString = args.commandString || ''; + this.connectionArgs.cwd = args.cwd || ''; + this.connectionArgs.url = args.url || ''; + this.connectionArgs.oauth = args.oauth || ''; + } + + get clientId() { + return this.connectionResult.clientId; + } + + get name() { + return this.connectionResult.name; + } + + get version() { + return this.connectionResult.version; + } + + get status() { + return this.connectionResult.status; + } + + get connected() { + return this.connectionResult.success; + } + + get env() { + const env = {} as Record; + this.connectionEnvironment.data.forEach(item => { + env[item.key] = item.value; + }); + return env; + } + + private get commandAndArgs() { + const commandString = this.connectionArgs.commandString; + + if (!commandString) { + return { command: '', args: [] }; + } + + const args = commandString.split(' '); + const command = args.shift() || ''; + + return { command, args }; + } + + get connectOption() { + 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; + + const clientName = this.clientNamePrefix + '.' + this.connectionArgs.type; + const clientVersion = this.clientVersion; + + const option: McpOptions = { + connectionType, + command, + args, + url, + cwd, + oauth, + clientName, + clientVersion, + env, + serverInfo: { + name: this.connectionResult.name, + version: this.connectionResult.version + } + }; + + return option; + } + + public async connect() { + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest('connect', this.connectOption); + + this.connectionResult.success = (code === 200); + + if (code !== 200) { + const message = msg.toString(); + this.connectionResult.logString.push({ + type: 'error', + message + }); + + ElMessage.error(message); + return false; + } else { + this.connectionResult.logString.push({ + type: 'info', + message: msg.info || '' + }) + } + + this.connectionResult.status = msg.status; + this.connectionResult.clientId = msg.clientId; + this.connectionResult.name = msg.name; + this.connectionResult.version = msg.version; + return true; + } + + /** + * @description 处理环境变量开关 + * - 开启时,刷新预设环境变量的数值 + * - 关闭时,清空预设环境变量的数值 + * @param enabled + */ + public async handleEnvSwitch(enabled: boolean) { + const presetVars = this.presetsEnvironment; + if (enabled) { + const values = await this.lookupEnvVar(presetVars); + + if (values) { + // 将 key values 合并进 connectionEnv.data 中 + // 若已有相同的 key, 则替换 value + for (let i = 0; i < presetVars.length; i++) { + const key = presetVars[i]; + const value = values[i]; + const sameNameItems = this.connectionEnvironment.data.filter(item => item.key === key); + if (sameNameItems.length > 0) { + const conflictItem = sameNameItems[0]; + conflictItem.value = value; + } else { + this.connectionEnvironment.data.push({ + key: key, value: value + }); + } + } + } + } else { + // 清空 connectionEnv.data 中所有 key 为 presetVars 的项 + const reserveItems = this.connectionEnvironment.data.filter(item => !presetVars.includes(item.key)); + this.connectionEnvironment.data = reserveItems; + } + } + + + /** + * @description 查询环境变量 + * @param varNames + * @returns + */ + public async lookupEnvVar(varNames: string[]) { + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest('lookup-env-var', { keys: varNames }); + + if (code === 200) { + this.connectionResult.logString.push({ + type: 'info', + message: '预设环境变量同步完成' + }); + + return msg; + } else { + this.connectionResult.logString.push({ + type: 'error', + message: '预设环境变量同步失败: ' + msg + }); + } + } +} + + +class McpClientAdapter { + public clients: McpClient[] = []; + public currentClientIndex: number = 0; + + private defaultClient: McpClient = new McpClient(); + + constructor( + public platform: string + ) { } + + /** + * @description 获取连接参数签名 + * @returns + */ + public async getLaunchSignature(): Promise { + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest(this.platform + '/launch-signature'); + + if (code !== 200) { + const message = msg.toString(); + ElMessage.error(message); + return []; + } + + // 判断一下版本,新版本的 msg 应该是数组,老版本是对象 + // 返回的数组的第一个为主节点,其余为从节点 + if (Array.isArray(msg)) { + return msg; + } + return [msg]; + } + + get masterNode() { + if (this.clients.length === 0) { + return this.defaultClient; + } + return this.clients[0]; + } + + public async saveLaunchSignature() { + const bridge = useMessageBridge(); + const options: McpOptions[] = this.clients.map(client => client.connectOption); + + // 同步成功的连接参数到后端,更新 vscode treeview 中的列表 + const deserializeOption = JSON.parse(JSON.stringify(options)); + bridge.postMessage({ + command: platform + '/update-connection-signature', + data: deserializeOption + }); + } + + public async launch() { + const launchSignature = await this.getLaunchSignature(); + console.log('launchSignature', launchSignature); + + let allOk = true; + + for (const item of launchSignature) { + + // 创建一个新的客户端 + const client = new McpClient(); + + // 同步连接参数 + await client.acquireConnectionSignature(item); + + // 同步环境变量 + await client.handleEnvSwitch(true); + + // 连接 + const ok = await client.connect(this.platform); + allOk &&= ok; + + this.clients.push(client); + } + + // 如果全部成功,保存连接参数 + if (allOk) { + this.saveLaunchSignature(); + } + } + + public async loadPanels() { + const masterNode = this.clients[0]; + await loadPanels(masterNode); + } +} + +const platform = getPlatform(); +export const mcpClientAdapter = reactive( + new McpClientAdapter(platform) +); \ No newline at end of file diff --git a/renderer/src/views/connect/index.ts b/renderer/src/views/connect/index.ts new file mode 100644 index 0000000..68a4d45 --- /dev/null +++ b/renderer/src/views/connect/index.ts @@ -0,0 +1,30 @@ +import { getTour, loadSetting } from "@/hook/setting"; +import { ElLoading } from "element-plus"; +import { pinkLog } from "../setting/util"; +import { mcpClientAdapter } from "./core"; + +export async function initialise() { + + pinkLog('准备请求设置'); + + const loading = ElLoading.service({ + fullscreen: true, + lock: true, + text: 'Loading', + background: 'rgba(0, 0, 0, 0.7)' + }); + + // 加载全局设置 + loadSetting(); + + // 获取引导状态 + await getTour(); + + // 尝试进行初始化连接 + await mcpClientAdapter.launch(); + + // loading panels + await mcpClientAdapter.loadPanels(); + + loading.close(); +} \ No newline at end of file diff --git a/renderer/src/views/connect/index.vue b/renderer/src/views/connect/index.vue index c2e67a7..4377c65 100644 --- a/renderer/src/views/connect/index.vue +++ b/renderer/src/views/connect/index.vue @@ -1,128 +1,105 @@ \ No newline at end of file diff --git a/renderer/src/views/connect/type.ts b/renderer/src/views/connect/type.ts new file mode 100644 index 0000000..eeb3245 --- /dev/null +++ b/renderer/src/views/connect/type.ts @@ -0,0 +1,81 @@ + +export type ConnectionType = 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; + +export interface ConnectionTypeOptionItem { + value: ConnectionType; + label: string; +} + +export interface IConnectionArgs { + type: ConnectionType; + commandString?: string; + cwd?: string; + url?: string; + oauth?: string; +} + + +export interface IConnectionResult { + info?: string; + success: boolean; + status: string + clientId: string + name: string + version: string + logString: { + type: 'info' | 'error' | 'warning', + message: string + }[] +} + + + +export interface McpOptions { + connectionType: ConnectionType; + command?: string; + + // STDIO 特定选项 + args?: string[]; + cwd?: string; + env?: Record; + // SSE 特定选项 + url?: string; + oauth?: any; + + // 通用客户端选项 + clientName?: string; + clientVersion?: string; + serverInfo: { + name: string + version: string + } +} + +export interface EnvItem { + key: string + value: string +} + + +export interface IConnectionEnvironment { + data: EnvItem[] + newKey: string + newValue: string +} + +export interface IConnectionArgs { + type: ConnectionType; + commandString?: string; + cwd?: string; + url?: string; + oauth?: string; +} + + +export interface ConnectionResult { + status: string + clientId: string + name: string + version: string +} + diff --git a/renderer/src/views/debug/welcome.vue b/renderer/src/views/debug/welcome.vue index a342f6b..6af7184 100644 --- a/renderer/src/views/debug/welcome.vue +++ b/renderer/src/views/debug/welcome.vue @@ -7,7 +7,7 @@ = ( - client: RequestClientType, - data: T, + data: T & RequestData, webview: PostMessageble ) => Promise; diff --git a/service/src/common/router.ts b/service/src/common/router.ts index 297ebd1..5ff9ab9 100644 --- a/service/src/common/router.ts +++ b/service/src/common/router.ts @@ -3,7 +3,6 @@ import type { PostMessageble } from "../hook/adapter"; import { LlmController } from "../llm/llm.controller"; import { ClientController } from "../mcp/client.controller"; import { ConnectController } from "../mcp/connect.controller"; -import { client } from "../mcp/connect.service"; import { OcrController } from "../mcp/ocr.controller"; import { PanelController } from "../panel/panel.controller"; import { SettingController } from "../setting/setting.controller"; @@ -24,7 +23,7 @@ export async function routeMessage(command: string, data: any, webview: PostMess try { // TODO: select client based on something - const res = await handler(client, data, webview); + const res = await handler(data, webview); // res.code = -1 代表当前请求不需要返回发送 if (res.code >= 0) { diff --git a/service/src/hook/adapter.ts b/service/src/hook/adapter.ts index b037468..825625e 100644 --- a/service/src/hook/adapter.ts +++ b/service/src/hook/adapter.ts @@ -2,7 +2,7 @@ import { WebSocket } from 'ws'; import { EventEmitter } from 'events'; import { routeMessage } from '../common/router'; import { McpOptions } from '../mcp/client.dto'; -import { client, connectService } from '../mcp/connect.service'; +import { clientMap, connectService } from '../mcp/connect.service'; // WebSocket 消息格式 export interface WebSocketMessage { @@ -114,9 +114,12 @@ export class TaskLoopAdapter { * @param mcpOption */ public async connectMcpServer(mcpOption: McpOptions) { - const res = await connectService(undefined, mcpOption); + const res = await connectService(mcpOption); if (res.code === 200) { console.log('✅ 成功连接 mcp 服务器: ' + res.msg); + + const uuid = res.msg.uuid; + const client = clientMap.get(uuid); const version = client?.getServerVersion(); console.log(version); } else { @@ -129,14 +132,19 @@ export class TaskLoopAdapter { * @returns */ public async listTools() { - const tools = await client?.listTools(); - if (tools?.tools) { - return tools.tools.map((tool) => { - const enabledTools = { ...tool, enabled: true }; - return enabledTools; - }); + const tools = []; + for (const client of clientMap.values()) { + const clientTools = await client?.listTools(); + if (clientTools?.tools) { + const enabledTools = clientTools.tools.map((tool) => { + const enabledTools = {...tool, enabled: true }; + return enabledTools; + }); + tools.push(...enabledTools); + } } - return []; + + return tools; } } 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/llm/llm.controller.ts b/service/src/llm/llm.controller.ts index 4252c51..08804bf 100644 --- a/service/src/llm/llm.controller.ts +++ b/service/src/llm/llm.controller.ts @@ -1,13 +1,17 @@ import { Controller, RequestClientType } from "../common"; +import { RequestData } from "../common/index.dto"; import { PostMessageble } from "../hook/adapter"; +import { getClient } from "../mcp/connect.service"; import { abortMessageService, streamingChatCompletion } from "./llm.service"; export class LlmController { @Controller('llm/chat/completions') - async chatCompletion(client: RequestClientType, data: any, webview: PostMessageble) { + async chatCompletion(data: RequestData, webview: PostMessageble) { let { tools = [] } = data; + const client = getClient(data.clientId); + if (tools.length > 0 && !client) { return { code: 501, @@ -37,7 +41,7 @@ export class LlmController { } @Controller('llm/chat/completions/abort') - async abortChatCompletion(client: RequestClientType, data: any, webview: PostMessageble) { + async abortChatCompletion(data: RequestData, webview: PostMessageble) { return abortMessageService(data, webview); } diff --git a/service/src/main.ts b/service/src/main.ts index 6fdde5a..f5f67b8 100644 --- a/service/src/main.ts +++ b/service/src/main.ts @@ -28,13 +28,13 @@ const logger = pino({ export type MessageHandler = (message: VSCodeMessage) => void; interface IStdioLaunchSignature { - type: 'stdio'; + type: 'STDIO'; commandString: string; cwd: string; } interface ISSELaunchSignature { - type:'sse'; + type:'SSE'; url: string; oauth: string; } @@ -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' + type:'STDIO', + 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({ @@ -148,7 +130,7 @@ wss.on('connection', (ws: any) => { break; - case 'web/update-connection-sigature': + case 'web/update-connection-signature': updateConnectionOption(data); break; diff --git a/service/src/mcp/client.controller.ts b/service/src/mcp/client.controller.ts index 0238fb6..19efaf7 100644 --- a/service/src/mcp/client.controller.ts +++ b/service/src/mcp/client.controller.ts @@ -1,11 +1,14 @@ -import { Controller, RequestClientType } from "../common"; +import { Controller } from "../common"; +import { RequestData } from "../common/index.dto"; import { PostMessageble } from "../hook/adapter"; import { postProcessMcpToolcallResponse } from "./client.service"; +import { getClient } from "./connect.service"; export class ClientController { @Controller('server/version') - async getServerVersion(client: RequestClientType, data: any, webview: PostMessageble) { + async getServerVersion(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -21,7 +24,8 @@ export class ClientController { } @Controller('prompts/list') - async listPrompts(client: RequestClientType, data: any, webview: PostMessageble) { + async listPrompts(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { const connectResult = { code: 501, @@ -39,7 +43,8 @@ export class ClientController { } @Controller('prompts/get') - async getPrompt(client: RequestClientType, option: any, webview: PostMessageble) { + async getPrompt(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -47,7 +52,7 @@ export class ClientController { }; } - const prompt = await client.getPrompt(option.promptId, option.args || {}); + const prompt = await client.getPrompt(data.promptId, data.args || {}); return { code: 200, msg: prompt @@ -55,7 +60,8 @@ export class ClientController { } @Controller('resources/list') - async listResources(client: RequestClientType, data: any, webview: PostMessageble) { + async listResources(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -71,8 +77,8 @@ export class ClientController { } @Controller('resources/templates/list') - async listResourceTemplates(client: RequestClientType, data: any, webview: PostMessageble) { - + async listResourceTemplates(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -88,7 +94,8 @@ export class ClientController { } @Controller('resources/read') - async readResource(client: RequestClientType, option: any, webview: PostMessageble) { + async readResource(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -96,7 +103,7 @@ export class ClientController { }; } - const resource = await client.readResource(option.resourceUri); + const resource = await client.readResource(data.resourceUri); console.log(resource); return { @@ -106,7 +113,8 @@ export class ClientController { } @Controller('tools/list') - async listTools(client: RequestClientType, data: any, webview: PostMessageble) { + async listTools(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -122,7 +130,8 @@ export class ClientController { } @Controller('tools/call') - async callTool(client: RequestClientType, option: any, webview: PostMessageble) { + async callTool(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { return { code: 501, @@ -131,18 +140,13 @@ export class ClientController { } const toolResult = await client.callTool({ - name: option.toolName, - arguments: option.toolArgs, - callToolOption: option.callToolOption + name: data.toolName, + arguments: data.toolArgs, + callToolOption: data.callToolOption }); - // console.log(JSON.stringify(toolResult, null, 2)); - postProcessMcpToolcallResponse(toolResult, webview); - // console.log(JSON.stringify(toolResult, null, 2)); - - return { code: 200, msg: toolResult diff --git a/service/src/mcp/client.dto.ts b/service/src/mcp/client.dto.ts index cd30ce0..84f1ce6 100644 --- a/service/src/mcp/client.dto.ts +++ b/service/src/mcp/client.dto.ts @@ -1,5 +1,5 @@ -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/STDIO.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/SSE.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Implementation } from "@modelcontextprotocol/sdk/types"; diff --git a/service/src/mcp/client.service.ts b/service/src/mcp/client.service.ts index 29ebe99..2106012 100644 --- a/service/src/mcp/client.service.ts +++ b/service/src/mcp/client.service.ts @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/STDIO.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/SSE.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { McpOptions, McpTransport, IServerVersion, ToolCallResponse, ToolCallContent } from './client.dto'; import { PostMessageble } from "../hook/adapter"; diff --git a/service/src/mcp/connect.controller.ts b/service/src/mcp/connect.controller.ts index 6f979bc..92c13fc 100644 --- a/service/src/mcp/connect.controller.ts +++ b/service/src/mcp/connect.controller.ts @@ -1,17 +1,19 @@ -import { Controller, RequestClientType } from '../common'; +import { Controller } from '../common'; import { PostMessageble } from '../hook/adapter'; -import { connectService } from './connect.service'; +import { RequestData } from '../common/index.dto'; +import { connectService, getClient } from './connect.service'; export class ConnectController { @Controller('connect') - async connect(client: RequestClientType, data: any, webview: PostMessageble) { - const res = await connectService(client, data); + async connect(data: any, webview: PostMessageble) { + const res = await connectService(data); return res; } @Controller('lookup-env-var') - async lookupEnvVar(client: RequestClientType, data: any, webview: PostMessageble) { + async lookupEnvVar(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const { keys } = data; const values = keys.map((key: string) => process.env[key] || ''); @@ -22,7 +24,8 @@ export class ConnectController { } @Controller('ping') - async ping(client: RequestClientType, data: any, webview: PostMessageble) { + async ping(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); if (!client) { const connectResult = { code: 501, diff --git a/service/src/mcp/connect.service.ts b/service/src/mcp/connect.service.ts index 83f2cef..fb22a3a 100644 --- a/service/src/mcp/connect.service.ts +++ b/service/src/mcp/connect.service.ts @@ -1,69 +1,180 @@ -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'; - -// TODO: 更多的 client -export let client: RequestClientType = undefined; - -export function tryGetRunCommandError(command: string, args: string[] = [], cwd?: string): string | null { - 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); - } +export const clientMap: Map = new Map(); +export function getClient(clientId?: string): RequestClientType | undefined { + return clientMap.get(clientId || ''); } +export function tryGetRunCommandError(command: string, args: string[] = [], cwd?: string): string | null { + try { + console.log('current command', command); + console.log('current args', args); + + 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( - _client: RequestClientType, - 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; - }); - } - - client = await connect(option); + const info = preprocessCommand(option); + + const client = await connect(option); + const uuid = randomUUID(); + clientMap.set(uuid, client); + + const versionInfo = client.getServerVersion(); + const connectResult = { code: 200, - msg: 'Connect to OpenMCP successfully\nWelcome back, Kirigaya' + msg: { + status: 'success', + clientId: uuid, + name: versionInfo?.name, + version: versionInfo?.version, + info + } }; - - return connectResult; - } catch (error) { - console.log('meet error'); - console.log(error); - + 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 = ''; @@ -72,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/panel/panel.controller.ts b/service/src/panel/panel.controller.ts index f7239a1..92332ef 100644 --- a/service/src/panel/panel.controller.ts +++ b/service/src/panel/panel.controller.ts @@ -1,13 +1,16 @@ -import { Controller, RequestClientType } from "../common"; +import { Controller } from "../common"; import { PostMessageble } from "../hook/adapter"; +import { RequestData } from "../common/index.dto"; +import { getClient } from "../mcp/connect.service"; import { systemPromptDB } from "../hook/db"; import { loadTabSaveConfig, saveTabSaveConfig } from "./panel.service"; export class PanelController { @Controller('panel/save') - async savePanel(client: RequestClientType, data: any, webview: PostMessageble) { + async savePanel(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const serverInfo = client?.getServerVersion(); - saveTabSaveConfig(serverInfo, data); + saveTabSaveConfig(serverInfo, data); return { code: 200, @@ -15,11 +18,11 @@ export class PanelController { }; } - @Controller('panel/load') - async loadPanel(client: RequestClientType, data: any, webview: PostMessageble) { + async loadPanel(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const serverInfo = client?.getServerVersion(); - const config = loadTabSaveConfig(serverInfo); + const config = loadTabSaveConfig(serverInfo); return { code: 200, @@ -28,7 +31,8 @@ export class PanelController { } @Controller('system-prompts/set') - async setSystemPrompt(client: RequestClientType, data: any, webview: PostMessageble) { + async setSystemPrompt(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const { name, content } = data; await systemPromptDB.insert({ @@ -44,7 +48,8 @@ export class PanelController { } @Controller('system-prompts/delete') - async deleteSystemPrompt(client: RequestClientType, data: any, webview: PostMessageble) { + async deleteSystemPrompt(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const { name } = data; await systemPromptDB.delete(name); return { @@ -54,7 +59,8 @@ export class PanelController { } @Controller('system-prompts/save') - async saveSystemPrompts(client: RequestClientType, data: any, webview: PostMessageble) { + async saveSystemPrompts(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const { prompts } = data; await Promise.all(prompts.map((prompt: any) => { @@ -72,8 +78,8 @@ export class PanelController { } @Controller('system-prompts/load') - async loadSystemPrompts(client: RequestClientType, data: any, webview: PostMessageble) { - + async loadSystemPrompts(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const queryPrompts = await systemPromptDB.findAll(); const prompts = []; for (const prompt of queryPrompts) { diff --git a/service/src/server.ts b/service/src/server.ts index d5dfb4b..a2ab3eb 100644 --- a/service/src/server.ts +++ b/service/src/server.ts @@ -29,13 +29,13 @@ const logger = pino({ export type MessageHandler = (message: VSCodeMessage) => void; interface IStdioLaunchSignature { - type: 'stdio'; + type: 'STDIO'; commandString: string; cwd: string; } interface ISSELaunchSignature { - type: 'sse'; + type: 'SSE'; url: string; oauth: string; } @@ -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({ @@ -179,7 +160,7 @@ wss.on('connection', (ws: any) => { break; - case 'web/update-connection-sigature': + case 'web/update-connection-signature': updateConnectionOption(data); break; diff --git a/service/src/setting/setting.controller.ts b/service/src/setting/setting.controller.ts index 12ddc3b..3a0a6b2 100644 --- a/service/src/setting/setting.controller.ts +++ b/service/src/setting/setting.controller.ts @@ -1,11 +1,14 @@ -import { Controller, RequestClientType } from "../common"; +import { Controller } from "../common"; import { PostMessageble } from "../hook/adapter"; +import { RequestData } from "../common/index.dto"; +import { getClient } from "../mcp/connect.service"; import { getTour, loadSetting, saveSetting, setTour } from "./setting.service"; export class SettingController { @Controller('setting/save') - async saveSetting(client: RequestClientType, data: any, webview: PostMessageble) { + async saveSetting(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); saveSetting(data); console.log('Settings saved successfully'); @@ -16,8 +19,8 @@ export class SettingController { } @Controller('setting/load') - async loadSetting(client: RequestClientType, data: any, webview: PostMessageble) { - + async loadSetting(data: RequestData, webview: PostMessageble) { + const client = getClient(data.clientId); const config = loadSetting(); return { code: 200, @@ -26,10 +29,8 @@ export class SettingController { } @Controller('setting/set-tour') - async setTourController(client: RequestClientType, data: any, webview: PostMessageble) { - + async setTourController(data: any, webview: PostMessageble) { const { userHasReadGuide } = data; - setTour(userHasReadGuide); return { @@ -39,7 +40,7 @@ export class SettingController { } @Controller('setting/get-tour') - async getTourController(client: RequestClientType, data: any, webview: PostMessageble) { + async getTourController(data: any, webview: PostMessageble) { const { userHasReadGuide } = getTour(); diff --git a/software/src/main.ts b/software/src/main.ts index bbbdc87..1e9867d 100644 --- a/software/src/main.ts +++ b/software/src/main.ts @@ -40,14 +40,14 @@ function createWindow(): void { switch (command) { case 'electron/launch-signature': - const launchResultMessage: ILaunchSigature = option.type === 'stdio' ? + const launchResultMessage: ILaunchSigature = option.type === 'STDIO' ? { - type: 'stdio', + type: 'STDIO', commandString: option.command + ' ' + option.args.join(' '), cwd: option.cwd || '' } : { - type: 'sse', + type: 'SSE', url: option.url, oauth: option.oauth || '' }; @@ -64,7 +64,7 @@ function createWindow(): void { break; - case 'electron/update-connection-sigature': + case 'electron/update-connection-signature': updateConnectionOption(data); break; diff --git a/software/src/util.ts b/software/src/util.ts index 21fa7d2..43584f6 100644 --- a/software/src/util.ts +++ b/software/src/util.ts @@ -24,13 +24,13 @@ export class ElectronIPCLike { interface IStdioLaunchSignature { - type: 'stdio'; + type: 'STDIO'; commandString: string; cwd: string; } interface ISSELaunchSignature { - type:'sse'; + type:'SSE'; url: string; oauth: string; } @@ -39,7 +39,7 @@ export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature; export function refreshConnectionOption(envPath: string) { const defaultOption = { - type:'stdio', + type:'STDIO', command: 'mcp', args: ['run', 'main.py'], cwd: '../server' @@ -80,7 +80,7 @@ export function updateConnectionOption(data: any) { if (data.connectionType === 'STDIO') { const connectionItem = { - type: 'stdio', + type: 'STDIO', command: data.command, args: data.args, cwd: data.cwd.replace(/\\/g, '/') @@ -89,7 +89,7 @@ export function updateConnectionOption(data: any) { fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); } else { const connectionItem = { - type: 'sse', + type: 'SSE', url: data.url, oauth: data.oauth }; diff --git a/src/global.ts b/src/global.ts index 504733f..9cef060 100644 --- a/src/global.ts +++ b/src/global.ts @@ -7,7 +7,7 @@ export type FsPath = string; export const panels = new Map(); export interface IStdioConnectionItem { - type: 'stdio'; + type: 'STDIO'; name: string; version?: string; command: string; @@ -18,7 +18,7 @@ export interface IStdioConnectionItem { } export interface ISSEConnectionItem { - type: 'sse'; + type: 'SSE'; name: string; version: string; url: string; @@ -29,13 +29,13 @@ export interface ISSEConnectionItem { interface IStdioLaunchSignature { - type: 'stdio'; + type: 'STDIO'; commandString: string; cwd: string; } interface ISSELaunchSignature { - type:'sse'; + type:'SSE'; url: string; oauth: string; } @@ -123,7 +123,7 @@ export function getWorkspaceConnectionConfig() { if (item.filePath && item.filePath.startsWith('{workspace}')) { item.filePath = item.filePath.replace('{workspace}', workspacePath).replace(/\\/g, '/'); } - if (item.type === 'stdio' && item.cwd && item.cwd.startsWith('{workspace}')) { + if (item.type === 'STDIO' && item.cwd && item.cwd.startsWith('{workspace}')) { item.cwd = item.cwd.replace('{workspace}', workspacePath).replace(/\\/g, '/'); } } @@ -169,7 +169,7 @@ export function saveWorkspaceConnectionConfig(workspace: string) { if (item.filePath && item.filePath.replace(/\\/g, '/').startsWith(workspacePath)) { item.filePath = item.filePath.replace(workspacePath, '{workspace}').replace(/\\/g, '/'); } - if (item.type ==='stdio' && item.cwd && item.cwd.replace(/\\/g, '/').startsWith(workspacePath)) { + if (item.type ==='STDIO' && item.cwd && item.cwd.replace(/\\/g, '/').startsWith(workspacePath)) { item.cwd = item.cwd.replace(workspacePath, '{workspace}').replace(/\\/g, '/'); } } @@ -213,7 +213,7 @@ export function updateWorkspaceConnectionConfig( if (data.connectionType === 'STDIO') { const connectionItem: IStdioConnectionItem = { - type: 'stdio', + type: 'STDIO', name: data.serverInfo.name, version: data.serverInfo.version, command: data.command, @@ -234,7 +234,7 @@ export function updateWorkspaceConnectionConfig( } else { const connectionItem: ISSEConnectionItem = { - type: 'sse', + type: 'SSE', name: data.serverInfo.name, version: data.serverInfo.version, url: data.url, @@ -267,7 +267,7 @@ export function updateInstalledConnectionConfig( if (data.connectionType === 'STDIO') { const connectionItem: IStdioConnectionItem = { - type: 'stdio', + type: 'STDIO', name: data.serverInfo.name, version: data.serverInfo.version, command: data.command, @@ -287,7 +287,7 @@ export function updateInstalledConnectionConfig( } else { const connectionItem: ISSEConnectionItem = { - type: 'sse', + type: 'SSE', name: data.serverInfo.name, version: data.serverInfo.version, url: data.url, diff --git a/src/sidebar/installed.service.ts b/src/sidebar/installed.service.ts index 42b2720..542ebc3 100644 --- a/src/sidebar/installed.service.ts +++ b/src/sidebar/installed.service.ts @@ -52,7 +52,7 @@ export async function validateAndGetCommandPath(commandString: string, cwd?: str export async function acquireInstalledConnection(): Promise { // 让用户选择连接类型 - const connectionType = await vscode.window.showQuickPick(['stdio', 'sse'], { + const connectionType = await vscode.window.showQuickPick(['STDIO', 'SSE'], { placeHolder: '请选择连接类型', canPickMany: false }); @@ -61,7 +61,7 @@ export async function acquireInstalledConnection(): Promise { // 让用户选择连接类型 - const connectionType = await vscode.window.showQuickPick(['stdio', 'sse'], { + const connectionType = await vscode.window.showQuickPick(['STDIO', 'SSE'], { placeHolder: '请选择连接类型' }); @@ -14,7 +14,7 @@ export async function acquireUserCustomConnection(): Promise