重构完成基础设施

This commit is contained in:
锦恢 2025-05-20 20:41:00 +08:00
parent 4d459464d3
commit 468ce23b66
18 changed files with 421 additions and 191 deletions

View File

@ -54,7 +54,7 @@
| `ext` | 支持基本的 MCP 项目管理 | `迭代版本` | 100% | `P0` | | `ext` | 支持基本的 MCP 项目管理 | `迭代版本` | 100% | `P0` |
| `service` | 支持自定义支持 openai 接口协议的大模型接入 | `完整版本` | 100% | `Done` | | `service` | 支持自定义支持 openai 接口协议的大模型接入 | `完整版本` | 100% | `Done` |
| `service` | 支持自定义接口协议的大模型接入 | `MVP` | 0% | `P1` | | `service` | 支持自定义接口协议的大模型接入 | `MVP` | 0% | `P1` |
| `all` | 支持同时调试多个 MCP Server | `MVP` | 0% | `P1` | | `all` | 支持同时调试多个 MCP Server | `MVP` | 80% | `P0` |
| `all` | 支持通过大模型进行在线验证 | `迭代版本` | 100% | `Done` | | `all` | 支持通过大模型进行在线验证 | `迭代版本` | 100% | `Done` |
| `all` | 支持对用户对应服务器的调试工作内容进行保存 | `迭代版本` | 100% | `Done` | | `all` | 支持对用户对应服务器的调试工作内容进行保存 | `迭代版本` | 100% | `Done` |
| `render` | 高危操作权限确认 | `MVP` | 0% | `P1` | | `render` | 高危操作权限确认 | `MVP` | 0% | `P1` |

View File

@ -55,13 +55,8 @@ onMounted(async () => {
// } // }
// //
console.log('enter');
await bridge.awaitForWebsocket(); await bridge.awaitForWebsocket();
console.log('enter2');
// //
if (!privilegeStatus.allow) { if (!privilegeStatus.allow) {
return; return;

View File

@ -0,0 +1,10 @@
<template>
</template>
<script setup lang="ts">
</script>
<style>
</style>

View File

@ -8,7 +8,7 @@ import { pinkLog, redLog } from "@/views/setting/util";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { handleToolCalls, type ToolCallResult } from "./handle-tool-calls"; import { handleToolCalls, type ToolCallResult } from "./handle-tool-calls";
import { getPlatform } from "@/api/platform"; 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 ChatCompletionChunk = OpenAI.Chat.Completions.ChatCompletionChunk;
export type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string }; export type ChatCompletionCreateParamsBase = OpenAI.Chat.Completions.ChatCompletionCreateParams & { id?: string };

View File

@ -83,10 +83,20 @@ const handleKeydown = (event: KeyboardEvent) => {
const copy = async () => { const copy = async () => {
try { try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(userInput.value); 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('内容已复制到剪贴板'); ElMessage.success('内容已复制到剪贴板');
} catch (err) { } catch (err) {
console.error('无法复制内容: ', err); console.error('无法复制内容: ', err);
ElMessage.error('复制失败,请手动复制');
} }
}; };

View File

@ -40,6 +40,7 @@ import { promptsManager, type PromptStorage } from './prompts';
import type { PromptsGetResponse } from '@/hook/type'; import type { PromptsGetResponse } from '@/hook/type';
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp'; import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp';
import { mcpClientAdapter } from '@/views/connect/core';
defineComponent({ name: 'prompt-reader' }); defineComponent({ name: 'prompt-reader' });
@ -131,16 +132,14 @@ const resetForm = () => {
} }
async function handleSubmit() { async function handleSubmit() {
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest('prompts/get', { const res = await mcpClientAdapter.readPromptTemplate(
promptId: currentPrompt.value.name, currentPrompt.value.name,
args: JSON.parse(JSON.stringify(tabStorage.formData)) JSON.parse(JSON.stringify(tabStorage.formData))
}); );
tabStorage.lastPromptGetResponse = msg; tabStorage.lastPromptGetResponse = res;
emits('prompt-get-response', res);
emits('prompt-get-response', msg);
} }
if (props.tabId >= 0) { if (props.tabId >= 0) {

View File

@ -42,6 +42,7 @@ import { parseResourceTemplate, resourcesManager, type ResourceStorage } from '.
import type{ ResourcesReadResponse } from '@/hook/type'; import type{ ResourcesReadResponse } from '@/hook/type';
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp'; import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp';
import { mcpClientAdapter } from '@/views/connect/core';
defineComponent({ name: 'resource-reader' }); defineComponent({ name: 'resource-reader' });
@ -154,11 +155,10 @@ function getUri() {
// //
async function handleSubmit() { async function handleSubmit() {
const uri = getUri(); const uri = getUri();
const res = await mcpClientAdapter.readResource(uri);
const bridge = useMessageBridge(); tabStorage.lastResourceReadResponse = res;
const { code, msg } = await bridge.commandRequest('resources/read', { resourceUri: uri }); emits('resource-get-response', res);
tabStorage.lastResourceReadResponse = msg;
emits('resource-get-response', msg);
} }
if (props.tabId >= 0) { if (props.tabId >= 0) {

View File

@ -60,10 +60,11 @@ import { defineComponent, defineProps, watch, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
import { tabs } from '../panel'; import { tabs } from '../panel';
import { callTool, toolsManager, type ToolStorage } from './tools'; import type { ToolStorage } from './tools';
import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp'; import { getDefaultValue, normaliseJavascriptType } from '@/hook/mcp';
import KInputObject from '@/components/k-input-object/index.vue'; import KInputObject from '@/components/k-input-object/index.vue';
import { mcpClientAdapter } from '@/views/connect/core';
defineComponent({ name: 'tool-executor' }); defineComponent({ name: 'tool-executor' });
@ -90,7 +91,10 @@ const formRef = ref<FormInstance>();
const loading = ref(false); const loading = ref(false);
const currentTool = computed(() => { 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; loading.value = true;
try { try {
tabStorage.lastToolCallResponse = undefined; tabStorage.lastToolCallResponse = undefined;
const toolResponse = await callTool(tabStorage.currentToolName, tabStorage.formData); const toolResponse = await mcpClientAdapter.callTool(tabStorage.currentToolName, tabStorage.formData);
tabStorage.lastToolCallResponse = toolResponse; tabStorage.lastToolCallResponse = toolResponse;
} finally { } finally {
loading.value = false; loading.value = false;

View File

@ -1,42 +1,42 @@
<template> <template>
<el-collapse :expand-icon-position="'left'" v-model="activeNames">
<el-collapse-item v-for="(client, index) in mcpClientAdapter.clients" :name="index" :class="[]">
<!-- header -->
<template #title>
<h3 class="resource-template"> <h3 class="resource-template">
<code>tools/list</code> <code>tools/list</code>
<span <span class="iconfont icon-restart" @click="reloadTools({ first: false })"></span>
class="iconfont icon-restart"
@click="reloadTools({ first: false })"
></span>
</h3> </h3>
</template>
<!-- body -->
<div class="tool-list-container-scrollbar"> <div class="tool-list-container-scrollbar">
<el-scrollbar height="500px"> <el-scrollbar height="500px">
<div class="tool-list-container"> <div class="tool-list-container">
<div <div class="item" :class="{ 'active': tabStorage.currentToolName === tool.name }"
class="item" v-for="tool of client.tools?.values()" :key="tool.name" @click="handleClick(tool)">
:class="{ 'active': tabStorage.currentToolName === tool.name }"
v-for="tool of toolsManager.tools"
:key="tool.name"
@click="handleClick(tool)"
>
<span>{{ tool.name }}</span> <span>{{ tool.name }}</span>
<span>{{ tool.description || '' }}</span> <span>{{ tool.description || '' }}</span>
</div> </div>
</div> </div>
</el-scrollbar> </el-scrollbar>
</div> </div>
</el-collapse-item>
<div> </el-collapse>
<!-- resources/list -->
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
import type { CasualRestAPI, ToolsListResponse } from '@/hook/type'; import type { CasualRestAPI, ToolsListResponse } from '@/hook/type';
import { onMounted, onUnmounted, defineProps } from 'vue'; import { onMounted, onUnmounted, defineProps, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { toolsManager, type ToolStorage } from './tools'; import type { ToolStorage } from './tools';
import { tabs } from '../panel'; import { tabs } from '../panel';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { mcpClientAdapter } from '@/views/connect/core';
const bridge = useMessageBridge(); const bridge = useMessageBridge();
const { t } = useI18n(); const { t } = useI18n();
@ -51,6 +51,8 @@ const props = defineProps({
const tab = tabs.content[props.tabId]; const tab = tabs.content[props.tabId];
const tabStorage = tab.storage as ToolStorage; const tabStorage = tab.storage as ToolStorage;
const activeNames = ref<any[]>([0]);
function reloadTools(option: { first: boolean }) { function reloadTools(option: { first: boolean }) {
bridge.postMessage({ bridge.postMessage({
command: 'tools/list' command: 'tools/list'
@ -71,30 +73,12 @@ function handleClick(tool: { name: string }) {
tabStorage.lastToolCallResponse = undefined; tabStorage.lastToolCallResponse = undefined;
} }
let commandCancel: (() => void); onMounted(async () => {
for (const client of mcpClientAdapter.clients) {
onMounted(() => { await client.getTools();
commandCancel = bridge.addCommandListener('tools/list', (data: CasualRestAPI<ToolsListResponse>) => { }
toolsManager.tools = data.msg.tools || [];
const targetTool = toolsManager.tools.find((tool) => {
return tool.name === tabStorage.currentToolName;
}); });
if (targetTool === undefined) {
tabStorage.currentToolName = toolsManager.tools[0].name;
tabStorage.lastToolCallResponse = undefined;
}
}, { once: false });
reloadTools({ first: true });
});
onUnmounted(() => {
if (commandCancel){
commandCancel();
}
})
</script> </script>
<style> <style>

View File

@ -1,14 +1,7 @@
import { useMessageBridge } from '@/api/message-bridge'; import { useMessageBridge } from '@/api/message-bridge';
import { mcpSetting } from '@/hook/mcp'; import { mcpSetting } from '@/hook/mcp';
import type { ToolsListResponse, ToolCallResponse, CasualRestAPI } from '@/hook/type'; import type { ToolsListResponse, ToolCallResponse, CasualRestAPI } from '@/hook/type';
import { pinkLog } from '@/views/setting/util'; import { mcpClientAdapter } from '@/views/connect/core';
import { reactive } from 'vue';
export const toolsManager = reactive<{
tools: ToolsListResponse['tools']
}>({
tools: []
});
export interface ToolStorage { export interface ToolStorage {
currentToolName: string; currentToolName: string;
@ -16,29 +9,24 @@ export interface ToolStorage {
formData: Record<string, any>; formData: Record<string, any>;
} }
/**
* @description
* @param toolName
* @param toolArgs
* @returns
*/
export async function callTool(toolName: string, toolArgs: Record<string, any>) {
mcpClientAdapter
export function callTool(toolName: string, toolArgs: Record<string, any>) {
const bridge = useMessageBridge(); const bridge = useMessageBridge();
return new Promise<ToolCallResponse>((resolve, reject) => { const { msg } = await bridge.commandRequest<ToolCallResponse>('tools/call', {
bridge.addCommandListener('tools/call', (data: CasualRestAPI<ToolCallResponse>) => {
console.log(data.msg);
if (data.code !== 200) {
resolve(data.msg);
} else {
resolve(data.msg);
}
}, { once: true });
bridge.postMessage({
command: 'tools/call',
data: {
toolName, toolName,
toolArgs: JSON.parse(JSON.stringify(toolArgs)), toolArgs: JSON.parse(JSON.stringify(toolArgs)),
callToolOption: { callToolOption: {
timeout: mcpSetting.timeout * 1000 timeout: mcpSetting.timeout * 1000
} }
}
});
}); });
return msg;
} }

View File

@ -1,7 +1,7 @@
import { useMessageBridge } from "@/api/message-bridge"; import { useMessageBridge } from "@/api/message-bridge";
import { pinkLog } from "@/views/setting/util"; import { pinkLog } from "@/views/setting/util";
import { debugModes, tabs } from "@/components/main-panel/panel"; import { debugModes, tabs } from "@/components/main-panel/panel";
import { markRaw, ref } from "vue"; import { markRaw, ref, type Reactive } from "vue";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { McpClient } from "@/views/connect/core"; import type { McpClient } from "@/views/connect/core";
@ -20,7 +20,7 @@ export interface SaveTab {
export const panelLoaded = ref(false); export const panelLoaded = ref(false);
export async function loadPanels(client: McpClient) { export async function loadPanels(client: McpClient | Reactive<McpClient>) {
const bridge = useMessageBridge(); const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest<SaveTab>('panel/load', { const { code, msg } = await bridge.commandRequest<SaveTab>('panel/load', {
clientId: client.clientId clientId: client.clientId

View File

@ -6,7 +6,7 @@
</span> </span>
<p> <p>
OpenMCP Client 0.0.9 OpenMCP@<a href="https://www.zhihu.com/people/can-meng-zhong-de-che-xian">锦恢</a> 开发 OpenMCP Client 0.1.0 OpenMCP@<a href="https://www.zhihu.com/people/can-meng-zhong-de-che-xian">锦恢</a> 开发
</p> </p>
<p> <p>

View File

@ -1,12 +1,27 @@
<template> <template>
<div class="connection-option"> <div class="connection-option">
<div class="header">
<span>{{ t('log') }}</span> <span>{{ t('log') }}</span>
<span class="iconfont icon-delete" @click="clearLogs"></span>
</div>
<el-scrollbar height="90%"> <el-scrollbar height="90%">
<div class="output-content"> <div class="output-content">
<div v-for="(log, index) in client.connectionResult.logString" :key="index" :class="log.type"> <el-collapse :expand-icon-position="'left'">
<span class="log-message">{{ log.message }}</span> <el-collapse-item v-for="(log, index) in logString" :name="index" :class="['item', log.type]">
<template #title>
<div class="tool-calls">
<div class="tool-call-header">
<span>{{ log.message.split('\n')[0] }}</span>
</div> </div>
</div> </div>
</template>
<div class="logger-inner">
{{ log.message }}
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-scrollbar> </el-scrollbar>
</div> </div>
</template> </template>
@ -24,10 +39,15 @@ const props = defineProps({
} }
}); });
const client = computed(() => mcpClientAdapter.clients[props.index]); const logString = computed(() => {
return mcpClientAdapter.clients[props.index].connectionResult.logString;
});
const { t } = useI18n(); const { t } = useI18n();
function clearLogs() {
mcpClientAdapter.clients[props.index].connectionResult.logString = [];
}
</script> </script>
<style> <style>
@ -55,28 +75,22 @@ const { t } = useI18n();
height: 95%; height: 95%;
} }
.output-content .item {
margin-bottom: 12px;
padding: 0px 9px;
border-radius: .5em;
}
.output-content .info { .output-content .info {
background-color: rgba(103, 194, 58, 0.5); background-color: rgba(103, 194, 58, 0.5);
margin: 8px 0;
margin-bottom: 12px;
padding: 5px 9px;
border-radius: .5em;
} }
.output-content .error { .output-content .error {
background-color: rgba(245, 108, 108, 0.5); background-color: rgba(245, 108, 108, 0.5);
margin: 8px 0;
margin-bottom: 12px;
padding: 5px 9px;
border-radius: .5em;
} }
.output-content .warning { .output-content .warning {
background-color: rgba(230, 162, 60, 0.5); background-color: rgba(230, 162, 60, 0.5);
margin: 8px 0;
margin-bottom: 12px;
padding: 5px 9px;
border-radius: .5em;
} }
.log-icon { .log-icon {
@ -104,4 +118,34 @@ const { t } = useI18n();
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
} }
.output-content .el-collapse-item__header,
.output-content .el-collapse-item__wrap {
background-color: unset !important;
border-bottom: unset !important;
}
.output-content .el-collapse-item__content {
padding-bottom: unset;
}
.logger-inner {
padding: 10px;
}
.header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.header .icon-delete {
margin-left: 10px;
cursor: pointer;
}
.header .icon-delete:hover {
color: var(--el-color-error);
}
</style> </style>

View File

@ -1,9 +1,11 @@
import { useMessageBridge } from "@/api/message-bridge"; 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 type { IConnectionResult, ConnectionTypeOptionItem, IConnectionArgs, IConnectionEnvironment, McpOptions } from "./type";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { loadPanels } from "@/hook/panel"; import { loadPanels } from "@/hook/panel";
import { getPlatform } from "@/api/platform"; 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[] = [ export const connectionSelectDataViewOption: ConnectionTypeOptionItem[] = [
{ {
@ -37,6 +39,11 @@ export class McpClient {
// setting 面板的 ref // setting 面板的 ref
public connectionSettingRef: any = null; public connectionSettingRef: any = null;
public tools: Map<string, ToolItem> | null = null;
public promptTemplates: Map<string, PromptTemplate> | null = null;
public resources: Map<string, Resources> | null = null;
public resourceTemplates: Map<string, ResourceTemplate> | null = null;
constructor( constructor(
public clientVersion: string = '0.0.1', public clientVersion: string = '0.0.1',
public clientNamePrefix: string = 'openmcp.connect' public clientNamePrefix: string = 'openmcp.connect'
@ -104,6 +111,83 @@ export class McpClient {
return env; return env;
} }
public async getTools() {
if (this.tools) {
return this.tools;
}
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest<ToolsListResponse>('tools/list', { clientId: this.clientId });
if (code!== 200) {
return new Map<string, ToolItem>();
}
this.tools = new Map<string, ToolItem>();
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<PromptsListResponse>('prompts/list', { clientId: this.clientId });
if (code!== 200) {
return new Map<string, PromptTemplate>();
}
this.promptTemplates = new Map<string, PromptTemplate>();
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<ResourcesListResponse>('resources/list', { clientId: this.clientId });
if (code!== 200) {
return new Map<string, Resources>();
}
this.resources = new Map<string, Resources>();
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<ResourceTemplatesListResponse>('resources/templates/list', { clientId: this.clientId });
if (code!== 200) {
return new Map();
}
this.resourceTemplates = new Map<string, ResourceTemplate>();
msg.resourceTemplates.forEach(template => {
this.resourceTemplates!.set(template.name, template);
});
return this.resourceTemplates;
}
private get commandAndArgs() { private get commandAndArgs() {
const commandString = this.connectionArgs.commandString; const commandString = this.connectionArgs.commandString;
@ -163,14 +247,6 @@ export class McpClient {
ElMessage.error(message); ElMessage.error(message);
return false; return false;
} else { } else {
const info = msg.info || '';
if (info) {
this.connectionResult.logString.push({
type: 'info',
message: msg.info || ''
});
}
this.connectionResult.logString.push({ this.connectionResult.logString.push({
type: 'info', type: 'info',
message: msg.name + ' ' + msg.version + ' 连接成功' message: msg.name + ' ' + msg.version + ' 连接成功'
@ -247,10 +323,11 @@ export class McpClient {
class McpClientAdapter { class McpClientAdapter {
public clients: McpClient[] = []; public clients: Reactive<McpClient[]> = [];
public currentClientIndex: number = 0; public currentClientIndex: number = 0;
private defaultClient: McpClient = new McpClient(); private defaultClient: McpClient = new McpClient();
public connectLogListenerCancel: (() => void) | null = null;
constructor( constructor(
public platform: string public platform: string
@ -298,6 +375,28 @@ class McpClientAdapter {
} }
public async launch() { 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(); const launchSignature = await this.getLaunchSignature();
console.log('launchSignature', launchSignature); console.log('launchSignature', launchSignature);
@ -306,7 +405,7 @@ class McpClientAdapter {
for (const item of launchSignature) { for (const item of launchSignature) {
// 创建一个新的客户端 // 创建一个新的客户端
const client = new McpClient(); const client = reactive(new McpClient());
// 同步连接参数 // 同步连接参数
await client.acquireConnectionSignature(item); await client.acquireConnectionSignature(item);
@ -314,11 +413,11 @@ class McpClientAdapter {
// 同步环境变量 // 同步环境变量
await client.handleEnvSwitch(true); await client.handleEnvSwitch(true);
this.clients.push(client);
// 连接 // 连接
const ok = await client.connect(); const ok = await client.connect();
allOk &&= ok; 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<ResourcesReadResponse>('resources/read', { clientId, resourceUri });
return msg;
}
public async readPromptTemplate(promptId: string, args: Record<string, any>) {
// 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<PromptsGetResponse>('prompts/get', { clientId, promptId, args });
return msg;
}
public async callTool(toolName: string, toolArgs: Record<string, any>) {
// 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<ToolCallResponse>('tools/call', {
clientId,
toolName,
toolArgs: JSON.parse(JSON.stringify(toolArgs)),
callToolOption: {
timeout: mcpSetting.timeout * 1000
}
});
return msg;
}
public async loadPanels() { public async loadPanels() {
const masterNode = this.clients[0]; const masterNode = this.clients[0];
await loadPanels(masterNode); await loadPanels(masterNode);
@ -337,3 +506,12 @@ const platform = getPlatform();
export const mcpClientAdapter = reactive( export const mcpClientAdapter = reactive(
new McpClientAdapter(platform) new McpClientAdapter(platform)
); );
export interface ISegmentViewItem {
value: any;
label: string;
client: McpClient;
index: number;
}
export const segmentsView = reactive<ISegmentViewItem[]>([]);

View File

@ -20,11 +20,11 @@ export async function initialise() {
// 获取引导状态 // 获取引导状态
await getTour(); await getTour();
loading.close();
// 尝试进行初始化连接 // 尝试进行初始化连接
await mcpClientAdapter.launch(); await mcpClientAdapter.launch();
// loading panels // loading panels
await mcpClientAdapter.loadPanels(); await mcpClientAdapter.loadPanels();
loading.close();
} }

View File

@ -24,7 +24,9 @@
<span class="iconfont icon-cuo"></span> <span class="iconfont icon-cuo"></span>
</span> </span>
</span> </span>
<span class="delete-btn" @click.stop="deleteServer(scope.item.index)"> <span
v-if="scope.item.index > 0"
class="delete-btn" @click.stop="deleteServer(scope.item.index)">
<span class="iconfont icon-delete"></span> <span class="iconfont icon-delete"></span>
</span> </span>
</div> </div>
@ -41,7 +43,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent, computed } from 'vue'; import { defineComponent, computed, watch, toRef } from 'vue';
import ConnectionPanel from './connection-panel.vue'; import ConnectionPanel from './connection-panel.vue';
import { McpClient, mcpClientAdapter } from './core'; import { McpClient, mcpClientAdapter } from './core';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
@ -66,6 +68,7 @@ const serverOptions = computed(() => {
})); }));
}); });
function deleteServer(index: number) { function deleteServer(index: number) {
if (mcpClientAdapter.clients.length <= 1) { if (mcpClientAdapter.clients.length <= 1) {
ElMessage.warning('至少需要保留一个服务器连接'); ElMessage.warning('至少需要保留一个服务器连接');
@ -75,6 +78,7 @@ function deleteServer(index: number) {
if (mcpClientAdapter.currentClientIndex >= mcpClientAdapter.clients.length) { if (mcpClientAdapter.currentClientIndex >= mcpClientAdapter.clients.length) {
mcpClientAdapter.currentClientIndex = mcpClientAdapter.clients.length - 1; mcpClientAdapter.currentClientIndex = mcpClientAdapter.clients.length - 1;
} }
mcpClientAdapter.saveLaunchSignature();
} }
</script> </script>
@ -89,6 +93,7 @@ function deleteServer(index: number) {
display: flex; display: flex;
align-items: center; align-items: center;
width: 150px; width: 150px;
height: 50px;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
padding: 15px 25px; padding: 15px 25px;
} }

View File

@ -7,7 +7,7 @@ export class ConnectController {
@Controller('connect') @Controller('connect')
async connect(data: any, webview: PostMessageble) { async connect(data: any, webview: PostMessageble) {
const res = await connectService(data); const res = await connectService(data, webview);
return res; return res;
} }

View File

@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import * as os from 'os'; import * as os from 'os';
import { PostMessageble } from '../hook/adapter';
export const clientMap: Map<string, RequestClientType> = new Map(); export const clientMap: Map<string, RequestClientType> = new Map();
export function getClient(clientId?: string): RequestClientType | undefined { export function getClient(clientId?: string): RequestClientType | undefined {
@ -15,14 +16,10 @@ export function getClient(clientId?: string): RequestClientType | undefined {
export function tryGetRunCommandError(command: string, args: string[] = [], cwd?: string): string | null { export function tryGetRunCommandError(command: string, args: string[] = [], cwd?: string): string | null {
try { try {
console.log('current command', command);
console.log('current args', args);
const commandString = command + ' ' + args.join(' '); const commandString = command + ' ' + args.join(' ');
const result = spawnSync(commandString, { const result = spawnSync(commandString, {
cwd: cwd || process.cwd(), cwd: cwd || process.cwd(),
STDIO: 'pipe', stdio: 'pipe',
encoding: 'utf-8' encoding: 'utf-8'
}); });
@ -33,7 +30,6 @@ export function tryGetRunCommandError(command: string, args: string[] = [], cwd?
return result.stderr || `Command failed with code ${result.status}`; return result.stderr || `Command failed with code ${result.status}`;
} }
return null; return null;
} catch (error) { } catch (error) {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
@ -61,12 +57,15 @@ function getCommandFileExt(option: McpOptions) {
function collectAllOutputExec(command: string, cwd: string) { function collectAllOutputExec(command: string, cwd: string) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
exec(command, { cwd }, (error, stdout, stderr) => { 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) { if (option.args) {
option.args = option.args.map(arg => { 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') { if (option.connectionType === 'SSE' || option.connectionType === 'STREAMABLE_HTTP') {
return [option, '']; return;
} }
const cwd = getCWD(option); const cwd = getCWD(option);
if (!cwd) { if (!cwd) {
return [option, '']; return;
} }
const ext = getCommandFileExt(option); const ext = getCommandFileExt(option);
if (!ext) { if (!ext) {
return [option, '']; return;
} }
// STDIO 模式下,对不同类型的项目进行额外支持 // STDIO 模式下,对不同类型的项目进行额外支持
@ -96,25 +95,21 @@ async function preprocessCommand(option: McpOptions): Promise<[McpOptions, strin
// npm如果没有初始化则进行 npm init将 mcp 设置为虚拟环境 // npm如果没有初始化则进行 npm init将 mcp 设置为虚拟环境
// go如果没有初始化则进行 go mod init // go如果没有初始化则进行 go mod init
let info: string = '';
switch (ext) { switch (ext) {
case '.py': case '.py':
info = await initUv(option, cwd); await initUv(option, cwd, webview);
break; break;
case '.js': case '.js':
case '.ts': case '.ts':
info = await initNpm(option, cwd); await initNpm(option, cwd, webview);
break; break;
default: default:
break; break;
} }
return [option, ''];
} }
async function initUv(option: McpOptions, cwd: string) { async function initUv(option: McpOptions, cwd: string, webview?: PostMessageble) {
let projectDir = cwd; let projectDir = cwd;
while (projectDir!== path.dirname(projectDir)) { while (projectDir!== path.dirname(projectDir)) {
@ -140,16 +135,27 @@ async function initUv(option: McpOptions, cwd: string) {
return ''; 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'; const addOutput = await collectAllOutputExec('uv add mcp "mcp[cli]"', projectDir);
info += await collectAllOutputExec('uv add mcp "mcp[cli]"', projectDir) + '\n'; webview?.postMessage({
command: 'connect/log',
return info; 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; let projectDir = cwd;
while (projectDir !== path.dirname(projectDir)) { while (projectDir !== path.dirname(projectDir)) {
@ -164,18 +170,26 @@ async function initNpm(option: McpOptions, cwd: string) {
return ''; 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( export async function connectService(
option: McpOptions option: McpOptions,
webview?: PostMessageble
): Promise<RestfulResponse> { ): Promise<RestfulResponse> {
try { try {
const { env, ...others } = option; const { env, ...others } = option;
console.log('ready to connect', others); console.log('ready to connect', others);
const [_, info] = await preprocessCommand(option); await preprocessCommand(option, webview);
const client = await connect(option); const client = await connect(option);
const uuid = randomUUID(); const uuid = randomUUID();
@ -189,8 +203,7 @@ export async function connectService(
status: 'success', status: 'success',
clientId: uuid, clientId: uuid,
name: versionInfo?.name, name: versionInfo?.name,
version: versionInfo?.version, version: versionInfo?.version
info
} }
}; };