diff --git a/README.md b/README.md index 2c33ca1..dc80bf8 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ | `ext` | 支持基本的 MCP 项目管理 | `迭代版本` | 100% | `P0` | | `service` | 支持自定义支持 openai 接口协议的大模型接入 | `完整版本` | 100% | `Done` | | `service` | 支持自定义接口协议的大模型接入 | `MVP` | 0% | `P1` | -| `all` | 支持同时调试多个 MCP Server | `MVP` | 0% | `P1` | +| `all` | 支持同时调试多个 MCP Server | `MVP` | 80% | `P0` | | `all` | 支持通过大模型进行在线验证 | `迭代版本` | 100% | `Done` | | `all` | 支持对用户对应服务器的调试工作内容进行保存 | `迭代版本` | 100% | `Done` | | `render` | 高危操作权限确认 | `MVP` | 0% | `P1` | diff --git a/renderer/src/App.vue b/renderer/src/App.vue index 54dfa83..edde560 100644 --- a/renderer/src/App.vue +++ b/renderer/src/App.vue @@ -54,13 +54,8 @@ onMounted(async () => { // router.push(targetRoute); // } - // 进行桥接 - console.log('enter'); - - await bridge.awaitForWebsocket(); - - console.log('enter2'); - + // 进行桥接 + await bridge.awaitForWebsocket(); // 根据是否需要密码进行后续的选择 if (!privilegeStatus.allow) { diff --git a/renderer/src/components/log-block/index.vue b/renderer/src/components/log-block/index.vue new file mode 100644 index 0000000..ce1a4f4 --- /dev/null +++ b/renderer/src/components/log-block/index.vue @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/renderer/src/components/main-panel/chat/core/task-loop.ts b/renderer/src/components/main-panel/chat/core/task-loop.ts index 9591578..c1e9b05 100644 --- a/renderer/src/components/main-panel/chat/core/task-loop.ts +++ b/renderer/src/components/main-panel/chat/core/task-loop.ts @@ -8,7 +8,7 @@ import { pinkLog, redLog } from "@/views/setting/util"; import { ElMessage } from "element-plus"; import { handleToolCalls, type ToolCallResult } from "./handle-tool-calls"; import { getPlatform } from "@/api/platform"; -import { getSystemPrompt, systemPrompts } from "../chat-box/options/system-prompt"; +import { getSystemPrompt } from "../chat-box/options/system-prompt"; export type ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk; export type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string }; diff --git a/renderer/src/components/main-panel/chat/message/user.vue b/renderer/src/components/main-panel/chat/message/user.vue index c7dea37..fecc2e0 100644 --- a/renderer/src/components/main-panel/chat/message/user.vue +++ b/renderer/src/components/main-panel/chat/message/user.vue @@ -83,10 +83,20 @@ const handleKeydown = (event: KeyboardEvent) => { const copy = async () => { try { - await navigator.clipboard.writeText(userInput.value); + if (navigator.clipboard) { + await navigator.clipboard.writeText(userInput.value); + } else { + const textarea = document.createElement('textarea'); + textarea.value = userInput.value; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } ElMessage.success('内容已复制到剪贴板'); } catch (err) { console.error('无法复制内容: ', err); + ElMessage.error('复制失败,请手动复制'); } }; diff --git a/renderer/src/components/main-panel/prompt/prompt-reader.vue b/renderer/src/components/main-panel/prompt/prompt-reader.vue index 6924648..979d6fa 100644 --- a/renderer/src/components/main-panel/prompt/prompt-reader.vue +++ b/renderer/src/components/main-panel/prompt/prompt-reader.vue @@ -40,6 +40,7 @@ import { promptsManager, type PromptStorage } from './prompts'; import type { PromptsGetResponse } from '@/hook/type'; import { useMessageBridge } from '@/api/message-bridge'; import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp'; +import { mcpClientAdapter } from '@/views/connect/core'; defineComponent({ name: 'prompt-reader' }); @@ -131,16 +132,14 @@ const resetForm = () => { } async function handleSubmit() { - const bridge = useMessageBridge(); - const { code, msg } = await bridge.commandRequest('prompts/get', { - promptId: currentPrompt.value.name, - args: JSON.parse(JSON.stringify(tabStorage.formData)) - }); + const res = await mcpClientAdapter.readPromptTemplate( + currentPrompt.value.name, + JSON.parse(JSON.stringify(tabStorage.formData)) + ); - tabStorage.lastPromptGetResponse = msg; - - emits('prompt-get-response', msg); + tabStorage.lastPromptGetResponse = res; + emits('prompt-get-response', res); } if (props.tabId >= 0) { diff --git a/renderer/src/components/main-panel/resource/resouce-reader.vue b/renderer/src/components/main-panel/resource/resouce-reader.vue index 77234b0..7e60aac 100644 --- a/renderer/src/components/main-panel/resource/resouce-reader.vue +++ b/renderer/src/components/main-panel/resource/resouce-reader.vue @@ -42,6 +42,7 @@ import { parseResourceTemplate, resourcesManager, type ResourceStorage } from '. import type{ ResourcesReadResponse } from '@/hook/type'; import { useMessageBridge } from '@/api/message-bridge'; import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp'; +import { mcpClientAdapter } from '@/views/connect/core'; defineComponent({ name: 'resource-reader' }); @@ -154,11 +155,10 @@ function getUri() { // 提交表单 async function handleSubmit() { const uri = getUri(); + const res = await mcpClientAdapter.readResource(uri); - const bridge = useMessageBridge(); - const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri }); - tabStorage.lastResourceReadResponse = msg; - emits('resource-get-response', msg); + tabStorage.lastResourceReadResponse = res; + emits('resource-get-response', res); } if (props.tabId >= 0) { diff --git a/renderer/src/components/main-panel/tool/tool-executor.vue b/renderer/src/components/main-panel/tool/tool-executor.vue index 138fe83..a0e7343 100644 --- a/renderer/src/components/main-panel/tool/tool-executor.vue +++ b/renderer/src/components/main-panel/tool/tool-executor.vue @@ -60,10 +60,11 @@ import { defineComponent, defineProps, watch, ref, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import type { FormInstance, FormRules } from 'element-plus'; import { tabs } from '../panel'; -import { callTool, toolsManager, type ToolStorage } from './tools'; +import type { ToolStorage } from './tools'; import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp'; import KInputObject from '@/components/k-input-object/index.vue'; +import { mcpClientAdapter } from '@/views/connect/core'; defineComponent({ name: 'tool-executor' }); @@ -90,7 +91,10 @@ const formRef = ref(); const loading = ref(false); const currentTool = computed(() => { - return toolsManager.tools.find(tool => tool.name === tabStorage.currentToolName); + for (const client of mcpClientAdapter.clients) { + const tool = client.tools?.get(tabStorage.currentToolName); + if (tool) return tool; + } }); @@ -146,7 +150,7 @@ async function handleExecute() { loading.value = true; try { tabStorage.lastToolCallResponse = undefined; - const toolResponse = await callTool(tabStorage.currentToolName, tabStorage.formData); + const toolResponse = await mcpClientAdapter.callTool(tabStorage.currentToolName, tabStorage.formData); tabStorage.lastToolCallResponse = toolResponse; } finally { loading.value = false; diff --git a/renderer/src/components/main-panel/tool/tool-list.vue b/renderer/src/components/main-panel/tool/tool-list.vue index 1c936f2..f3497ae 100644 --- a/renderer/src/components/main-panel/tool/tool-list.vue +++ b/renderer/src/components/main-panel/tool/tool-list.vue @@ -1,42 +1,42 @@ \ No newline at end of file diff --git a/renderer/src/views/connect/core.ts b/renderer/src/views/connect/core.ts index 277121b..6bb004a 100644 --- a/renderer/src/views/connect/core.ts +++ b/renderer/src/views/connect/core.ts @@ -1,9 +1,11 @@ import { useMessageBridge } from "@/api/message-bridge"; -import { reactive } from "vue"; +import { reactive, type 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"; +import type { PromptsGetResponse, PromptsListResponse, PromptTemplate, Resources, ResourcesListResponse, ResourcesReadResponse, ResourceTemplate, ResourceTemplatesListResponse, ToolCallResponse, ToolItem, ToolsListResponse } from "@/hook/type"; +import { mcpSetting } from "@/hook/mcp"; export const connectionSelectDataViewOption: ConnectionTypeOptionItem[] = [ { @@ -37,6 +39,11 @@ export class McpClient { // setting 面板的 ref public connectionSettingRef: any = null; + public tools: Map | null = null; + public promptTemplates: Map | null = null; + public resources: Map | null = null; + public resourceTemplates: Map | null = null; + constructor( public clientVersion: string = '0.0.1', public clientNamePrefix: string = 'openmcp.connect' @@ -104,6 +111,83 @@ export class McpClient { return env; } + public async getTools() { + if (this.tools) { + return this.tools; + } + + const bridge = useMessageBridge(); + + const { code, msg } = await bridge.commandRequest('tools/list', { clientId: this.clientId }); + if (code!== 200) { + return new Map(); + } + + this.tools = new Map(); + msg.tools.forEach(tool => { + this.tools!.set(tool.name, tool); + }); + + return this.tools; + } + + public async getPromptTemplates() { + if (this.promptTemplates) { + return this.promptTemplates; + } + + const bridge = useMessageBridge(); + + const { code, msg } = await bridge.commandRequest('prompts/list', { clientId: this.clientId }); + if (code!== 200) { + return new Map(); + } + + this.promptTemplates = new Map(); + msg.prompts.forEach(template => { + this.promptTemplates!.set(template.name, template); + }); + + return this.promptTemplates; + } + + public async getResources() { + if (this.resources) { + return this.resources; + } + + const bridge = useMessageBridge(); + + const { code, msg } = await bridge.commandRequest('resources/list', { clientId: this.clientId }); + if (code!== 200) { + return new Map(); + } + + this.resources = new Map(); + msg.resources.forEach(resource => { + this.resources!.set(resource.name, resource); + }); + return this.resources; + } + + public async getResourceTemplates() { + if (this.resourceTemplates) { + return this.resourceTemplates; + } + + const bridge = useMessageBridge(); + + const { code, msg } = await bridge.commandRequest('resources/templates/list', { clientId: this.clientId }); + if (code!== 200) { + return new Map(); + } + this.resourceTemplates = new Map(); + msg.resourceTemplates.forEach(template => { + this.resourceTemplates!.set(template.name, template); + }); + return this.resourceTemplates; + } + private get commandAndArgs() { const commandString = this.connectionArgs.commandString; @@ -163,14 +247,6 @@ export class McpClient { ElMessage.error(message); return false; } else { - const info = msg.info || ''; - if (info) { - this.connectionResult.logString.push({ - type: 'info', - message: msg.info || '' - }); - } - this.connectionResult.logString.push({ type: 'info', message: msg.name + ' ' + msg.version + ' 连接成功' @@ -247,10 +323,11 @@ export class McpClient { class McpClientAdapter { - public clients: McpClient[] = []; + public clients: Reactive = []; public currentClientIndex: number = 0; private defaultClient: McpClient = new McpClient(); + public connectLogListenerCancel: (() => void) | null = null; constructor( public platform: string @@ -298,6 +375,28 @@ class McpClientAdapter { } public async launch() { + // 创建对于 log/output 的监听 + if (!this.connectLogListenerCancel) { + const bridge = useMessageBridge(); + this.connectLogListenerCancel = bridge.addCommandListener('connect/log', (message) => { + const { code, msg } = message; + + console.log(code, msg); + const client = this.clients.at(-1); + console.log(client); + + if (!client) { + return; + } + + client.connectionResult.logString.push({ + type: code === 200 ? 'info': 'error', + message: msg + }); + + }, { once: false }); + } + const launchSignature = await this.getLaunchSignature(); console.log('launchSignature', launchSignature); @@ -306,7 +405,7 @@ class McpClientAdapter { for (const item of launchSignature) { // 创建一个新的客户端 - const client = new McpClient(); + const client = reactive(new McpClient()); // 同步连接参数 await client.acquireConnectionSignature(item); @@ -314,11 +413,11 @@ class McpClientAdapter { // 同步环境变量 await client.handleEnvSwitch(true); + this.clients.push(client); + // 连接 const ok = await client.connect(); allOk &&= ok; - - this.clients.push(client); } // 如果全部成功,保存连接参数 @@ -327,6 +426,76 @@ class McpClientAdapter { } } + public async readResource(resourceUri?: string) { + if (!resourceUri) { + return undefined; + } + + // TODO: 如果遇到不同服务器的同名 tool,请拓展解决方案 + // 目前只找到第一个匹配 toolName 的工具进行调用 + let clientId = this.clients[0].clientId; + + for (const client of this.clients) { + const resources = await client.getResources(); + const resource = resources.get(resourceUri); + if (resource) { + clientId = client.clientId; + break; + } + } + + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest('resources/read', { clientId, resourceUri }); + + return msg; + } + + public async readPromptTemplate(promptId: string, args: Record) { + // TODO: 如果遇到不同服务器的同名 tool,请拓展解决方案 + // 目前只找到第一个匹配 toolName 的工具进行调用 + let clientId = this.clients[0].clientId; + + for (const client of this.clients) { + const promptTemplates = await client.getPromptTemplates(); + const promptTemplate = promptTemplates.get(promptId); + if (promptTemplate) { + clientId = client.clientId; + break; + } + } + + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest('prompts/get', { clientId, promptId, args }); + return msg; + } + + public async callTool(toolName: string, toolArgs: Record) { + // TODO: 如果遇到不同服务器的同名 tool,请拓展解决方案 + // 目前只找到第一个匹配 toolName 的工具进行调用 + let clientId = this.clients[0].clientId; + + for (const client of this.clients) { + const tools = await client.getTools(); + const tool = tools.get(toolName); + if (tool) { + clientId = client.clientId; + break; + } + } + + const bridge = useMessageBridge(); + const { msg } = await bridge.commandRequest('tools/call', { + clientId, + toolName, + toolArgs: JSON.parse(JSON.stringify(toolArgs)), + callToolOption: { + timeout: mcpSetting.timeout * 1000 + } + }); + + return msg; + } + public async loadPanels() { const masterNode = this.clients[0]; await loadPanels(masterNode); @@ -336,4 +505,13 @@ class McpClientAdapter { const platform = getPlatform(); export const mcpClientAdapter = reactive( new McpClientAdapter(platform) -); \ No newline at end of file +); + +export interface ISegmentViewItem { + value: any; + label: string; + client: McpClient; + index: number; +} + +export const segmentsView = reactive([]); \ No newline at end of file diff --git a/renderer/src/views/connect/index.ts b/renderer/src/views/connect/index.ts index 68a4d45..50dfbb0 100644 --- a/renderer/src/views/connect/index.ts +++ b/renderer/src/views/connect/index.ts @@ -20,11 +20,11 @@ export async function initialise() { // 获取引导状态 await getTour(); + loading.close(); + // 尝试进行初始化连接 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 c1c4ca4..075c62a 100644 --- a/renderer/src/views/connect/index.vue +++ b/renderer/src/views/connect/index.vue @@ -24,7 +24,9 @@ - + @@ -41,7 +43,7 @@ @@ -89,6 +93,7 @@ function deleteServer(index: number) { display: flex; align-items: center; width: 150px; + height: 50px; border-right: 1px solid var(--border-color); padding: 15px 25px; } diff --git a/service/src/mcp/connect.controller.ts b/service/src/mcp/connect.controller.ts index 92c13fc..32c1368 100644 --- a/service/src/mcp/connect.controller.ts +++ b/service/src/mcp/connect.controller.ts @@ -7,7 +7,7 @@ export class ConnectController { @Controller('connect') async connect(data: any, webview: PostMessageble) { - const res = await connectService(data); + const res = await connectService(data, webview); return res; } diff --git a/service/src/mcp/connect.service.ts b/service/src/mcp/connect.service.ts index c560436..02173c7 100644 --- a/service/src/mcp/connect.service.ts +++ b/service/src/mcp/connect.service.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import fs from 'node:fs'; import * as os from 'os'; +import { PostMessageble } from '../hook/adapter'; export const clientMap: Map = new Map(); export function getClient(clientId?: string): RequestClientType | undefined { @@ -14,15 +15,11 @@ export function getClient(clientId?: string): 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 commandString = command + ' ' + args.join(' '); - + try { + const commandString = command + ' ' + args.join(' '); const result = spawnSync(commandString, { cwd: cwd || process.cwd(), - STDIO: 'pipe', + stdio: 'pipe', encoding: 'utf-8' }); @@ -33,10 +30,9 @@ export function tryGetRunCommandError(command: string, args: string[] = [], cwd? return result.stderr || `Command failed with code ${result.status}`; } return null; - - } catch (error) { - return error instanceof Error ? error.message : String(error); - } + } catch (error) { + return error instanceof Error ? error.message : String(error); + } } function getCWD(option: McpOptions) { @@ -61,12 +57,15 @@ function getCommandFileExt(option: McpOptions) { function collectAllOutputExec(command: string, cwd: string) { return new Promise((resolve, reject) => { exec(command, { cwd }, (error, stdout, stderr) => { - resolve(error + stdout + stderr); + const errorString = error || ''; + const stdoutString = stdout || ''; + const stderrString = stderr || ''; + resolve(errorString + stdoutString + stderrString); }); }); } -async function preprocessCommand(option: McpOptions): Promise<[McpOptions, string]> { +async function preprocessCommand(option: McpOptions, webview?: PostMessageble) { // 对于特殊表示的路径,进行特殊的支持 if (option.args) { option.args = option.args.map(arg => { @@ -78,17 +77,17 @@ async function preprocessCommand(option: McpOptions): Promise<[McpOptions, strin } if (option.connectionType === 'SSE' || option.connectionType === 'STREAMABLE_HTTP') { - return [option, '']; + return; } const cwd = getCWD(option); if (!cwd) { - return [option, '']; + return; } const ext = getCommandFileExt(option); if (!ext) { - return [option, '']; + return; } // STDIO 模式下,对不同类型的项目进行额外支持 @@ -96,25 +95,21 @@ async function preprocessCommand(option: McpOptions): Promise<[McpOptions, strin // npm:如果没有初始化,则进行 npm init,将 mcp 设置为虚拟环境 // go:如果没有初始化,则进行 go mod init - let info: string = ''; - switch (ext) { case '.py': - info = await initUv(option, cwd); + await initUv(option, cwd, webview); break; case '.js': case '.ts': - info = await initNpm(option, cwd); + await initNpm(option, cwd, webview); break; default: break; - } - - return [option, '']; + } } -async function initUv(option: McpOptions, cwd: string) { +async function initUv(option: McpOptions, cwd: string, webview?: PostMessageble) { let projectDir = cwd; while (projectDir!== path.dirname(projectDir)) { @@ -140,16 +135,27 @@ async function initUv(option: McpOptions, cwd: string) { return ''; } - let info = ''; + const syncOutput = await collectAllOutputExec('uv sync', projectDir); + webview?.postMessage({ + command: 'connect/log', + data: { + code: syncOutput.toLowerCase().startsWith('error') ? 501: 200, + msg: syncOutput + } + }); - info += await collectAllOutputExec('uv sync', projectDir) + '\n'; - info += await collectAllOutputExec('uv add mcp "mcp[cli]"', projectDir) + '\n'; - - return info; + const addOutput = await collectAllOutputExec('uv add mcp "mcp[cli]"', projectDir); + webview?.postMessage({ + command: 'connect/log', + data: { + code: addOutput.toLowerCase().startsWith('error') ? 501: 200, + msg: addOutput + } + }); } -async function initNpm(option: McpOptions, cwd: string) { +async function initNpm(option: McpOptions, cwd: string, webview?: PostMessageble) { let projectDir = cwd; while (projectDir !== path.dirname(projectDir)) { @@ -164,18 +170,26 @@ async function initNpm(option: McpOptions, cwd: string) { return ''; } - return execSync('npm i', { cwd: projectDir }).toString('utf-8') + '\n'; + const installOutput = execSync('npm i', { cwd: projectDir }).toString('utf-8') + '\n'; + webview?.postMessage({ + command: 'connect/log', + data: { + code: installOutput.toLowerCase().startsWith('error')? 200: 501, + msg: installOutput + } + }) } export async function connectService( - option: McpOptions + option: McpOptions, + webview?: PostMessageble ): Promise { try { const { env, ...others } = option; console.log('ready to connect', others); - const [_, info] = await preprocessCommand(option); + await preprocessCommand(option, webview); const client = await connect(option); const uuid = randomUUID(); @@ -189,8 +203,7 @@ export async function connectService( status: 'success', clientId: uuid, name: versionInfo?.name, - version: versionInfo?.version, - info + version: versionInfo?.version } };