完成兼容

This commit is contained in:
锦恢 2025-05-19 23:34:10 +08:00
parent 355b25b9b7
commit c218dcba03
11 changed files with 241 additions and 148 deletions

View File

@ -3,6 +3,8 @@
## [main] 0.1.0 ## [main] 0.1.0
- 新特性:支持同时连入多个 mcp server - 新特性:支持同时连入多个 mcp server
- 新特性:更新协议内容,支持 streamable http 协议,未来将逐步取代 SSE 的连接方式 - 新特性:更新协议内容,支持 streamable http 协议,未来将逐步取代 SSE 的连接方式
- 对于 uv 创建的 py 项目进行特殊支持:自动初始化项目,并将 mcp 定向到 .venv/bin/mcp 中,不再需要用户全局安装 mcp
- 对于 npm 创建的 js/ts 项目进行特殊支持:自动初始化项目
## [main] 0.0.9 ## [main] 0.0.9
- 修复 0.0.8 引入的bugsystem prompt 返回的是索引而非真实内容 - 修复 0.0.8 引入的bugsystem prompt 返回的是索引而非真实内容

View File

@ -48,11 +48,11 @@ onMounted(async () => {
pinkLog('OpenMCP Client 启动'); pinkLog('OpenMCP Client 启动');
// //
if (route.name !== 'debug') { // if (route.name !== 'debug') {
const targetRoute = import.meta.env.BASE_URL + 'debug'; // const targetRoute = import.meta.env.BASE_URL + 'debug';
console.log('go to ' + targetRoute); // console.log('go to ' + targetRoute);
router.push(targetRoute); // router.push(targetRoute);
} // }
// //
await bridge.awaitForWebsocket(); await bridge.awaitForWebsocket();

View File

@ -58,7 +58,7 @@
</el-tour-step> </el-tour-step>
<el-tour-step <el-tour-step
:target="client.connectionSettingRef.value" :target="client.connectionSettingRef"
:prev-button-props="{ children: '上一步' }" :prev-button-props="{ children: '上一步' }"
:next-button-props="{ children: '下一步' }" :next-button-props="{ children: '下一步' }"
:show-close="false" :show-close="false"
@ -78,7 +78,7 @@
</el-tour-step> </el-tour-step>
<el-tour-step <el-tour-step
:target="client.connectionLogRef.value" :target="client.connectionLogRef"
:prev-button-props="{ children: '上一步' }" :prev-button-props="{ children: '上一步' }"
:next-button-props="{ children: '下一步' }" :next-button-props="{ children: '下一步' }"
:show-close="false" :show-close="false"

View File

@ -2,7 +2,7 @@
<el-scrollbar> <el-scrollbar>
<div class="connection-container"> <div class="connection-container">
<div class="connect-panel-container" <div class="connect-panel-container"
:ref="el => client.connectionSettingRef.value = el" :ref="el => client.connectionSettingRef = el"
> >
<ConnectionMethod :index="props.index" /> <ConnectionMethod :index="props.index" />
<ConnectionArgs :index="props.index" /> <ConnectionArgs :index="props.index" />
@ -18,7 +18,7 @@
</div> </div>
<div class="connect-panel-container" <div class="connect-panel-container"
:ref="el => client.connectionLogRef.value = el" :ref="el => client.connectionLogRef = el"
> >
<ConnectionLog :index="props.index" /> <ConnectionLog :index="props.index" />
</div> </div>
@ -51,6 +51,8 @@ const props = defineProps({
const client = mcpClientAdapter.clients[props.index]; const client = mcpClientAdapter.clients[props.index];
console.log(client); console.log(client);
console.log(client.connectionSettingRef);
const { t } = useI18n(); const { t } = useI18n();
@ -59,8 +61,9 @@ const isLoading = ref(false);
async function connect() { async function connect() {
isLoading.value = true; isLoading.value = true;
const plaform = getPlatform(); const platform = getPlatform();
const ok = await client.connect(plaform); const ok = await client.connect();
if (ok) { if (ok) {
mcpClientAdapter.saveLaunchSignature(); mcpClientAdapter.saveLaunchSignature();
} }

View File

@ -1,5 +1,5 @@
import { useMessageBridge } from "@/api/message-bridge"; import { useMessageBridge } from "@/api/message-bridge";
import { reactive, ref, type Reactive, type Ref } from "vue"; import { reactive } from "vue";
import type { IConnectionResult, ConnectionTypeOptionItem, IConnectionArgs, IConnectionEnvironment, McpOptions } from "./type"; import 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";
@ -23,49 +23,49 @@ export const connectionSelectDataViewOption: ConnectionTypeOptionItem[] = [
export class McpClient { export class McpClient {
// 连接入参 // 连接入参
public connectionArgs: Reactive<IConnectionArgs>; public connectionArgs: IConnectionArgs;
// 连接出参 // 连接出参
public connectionResult: Reactive<IConnectionResult>; public connectionResult: IConnectionResult;
// 预设环境变量,初始化的时候会去获取它们 // 预设环境变量,初始化的时候会去获取它们
public presetsEnvironment: string[] = ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; public presetsEnvironment: string[] = ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
// 环境变量 // 环境变量
public connectionEnvironment: Reactive<IConnectionEnvironment>; public connectionEnvironment: IConnectionEnvironment;
// logger 面板的 ref // logger 面板的 ref
public connectionLogRef = ref<any>(null); public connectionLogRef: any = null;
// setting 面板的 ref // setting 面板的 ref
public connectionSettingRef = ref<any>(null); public connectionSettingRef: any = 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'
) { ) {
// 连接入参 // 连接入参
this.connectionArgs = reactive({ this.connectionArgs = {
type: 'STDIO', type: 'STDIO',
commandString: '', commandString: '',
cwd: '', cwd: '',
url: '', url: '',
oauth: '' oauth: ''
}); };
// 连接出参 // 连接出参
this.connectionResult = reactive({ this.connectionResult = {
success: false, success: false,
status: 'disconnected', status: 'disconnected',
clientId: '', clientId: '',
name: '', name: '',
version: '', version: '',
logString: [] logString: []
}); };
// 环境变量 // 环境变量
this.connectionEnvironment = reactive({ this.connectionEnvironment = {
data: [], data: [],
newKey: '', newKey: '',
newValue: '' newValue: ''
}); };
} }
async acquireConnectionSignature(args: IConnectionArgs) { async acquireConnectionSignature(args: IConnectionArgs) {
@ -121,6 +121,7 @@ export class McpClient {
const { command, args } = this.commandAndArgs; const { command, args } = this.commandAndArgs;
const env = this.env; const env = this.env;
const url = this.connectionArgs.url; const url = this.connectionArgs.url;
const cwd = this.connectionArgs.cwd;
const oauth = this.connectionArgs.oauth; const oauth = this.connectionArgs.oauth;
const connectionType = this.connectionArgs.type; const connectionType = this.connectionArgs.type;
@ -132,6 +133,7 @@ export class McpClient {
command, command,
args, args,
url, url,
cwd,
oauth, oauth,
clientName, clientName,
clientVersion, clientVersion,
@ -145,7 +147,7 @@ export class McpClient {
return option; return option;
} }
public async connect(platform: string) { public async connect() {
const bridge = useMessageBridge(); const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest<IConnectionResult>('connect', this.connectOption); const { code, msg } = await bridge.commandRequest<IConnectionResult>('connect', this.connectOption);
@ -160,6 +162,11 @@ export class McpClient {
ElMessage.error(message); ElMessage.error(message);
return false; return false;
} else {
this.connectionResult.logString.push({
type: 'info',
message: msg.info || ''
})
} }
this.connectionResult.status = msg.status; this.connectionResult.status = msg.status;
@ -284,9 +291,13 @@ class McpClientAdapter {
public async launch() { public async launch() {
const launchSignature = await this.getLaunchSignature(); const launchSignature = await this.getLaunchSignature();
console.log('launchSignature', launchSignature);
let allOk = true; let allOk = true;
for (const item of launchSignature) { for (const item of launchSignature) {
// 创建一个新的客户端
const client = new McpClient(); const client = new McpClient();
// 同步连接参数 // 同步连接参数

View File

@ -3,9 +3,15 @@
<div class="server-list"> <div class="server-list">
<div v-for="(client, index) in mcpClientAdapter.clients" :key="index" class="server-item" <div v-for="(client, index) in mcpClientAdapter.clients" :key="index" class="server-item"
:class="{ 'active': mcpClientAdapter.currentClientIndex === index }" @click="selectServer(index)"> :class="{ 'active': mcpClientAdapter.currentClientIndex === index }" @click="selectServer(index)">
<span class="server-name">Server {{ index + 1 }}</span> <span class="connect-status">
<span class="server-status" :class="client.connectionResult.status"> <span v-if="client.connectionResult.success">
{{ client.connectionResult.status }} <span class="iconfont icon-connect"></span>
<span class="iconfont icon-dui"></span>
</span>
<span v-else>
<span class="iconfont icon-connect"></span>
<span class="server-name"> Unconnected </span>
</span>
</span> </span>
</div> </div>
<div class="add-server" @click="addServer"> <div class="add-server" @click="addServer">
@ -21,7 +27,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import ConnectionPanel from './connection-panel.vue'; import ConnectionPanel from './connection-panel.vue';
import { mcpClientAdapter } from './core'; import { McpClient, mcpClientAdapter } from './core';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
defineComponent({ name: 'connection' }); defineComponent({ name: 'connection' });
@ -32,9 +38,8 @@ function selectServer(index: number) {
function addServer() { function addServer() {
ElMessage.info('Add server is not implemented yet'); ElMessage.info('Add server is not implemented yet');
mcpClientAdapter.clients.push(new McpClient());
// mcpClientAdapter.clients.push(new McpClient()); mcpClientAdapter.currentClientIndex = mcpClientAdapter.clients.length - 1;
// mcpClientAdapter.currentClientIndex = mcpClientAdapter.clients.length - 1;
} }
</script> </script>
@ -45,11 +50,15 @@ function addServer() {
} }
.server-list { .server-list {
width: 200px; width: 150px;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
padding: 10px; padding: 10px;
} }
.server-name {
font-size: 15px;
}
.server-item { .server-item {
padding: 10px; padding: 10px;
margin-bottom: 5px; margin-bottom: 5px;

View File

@ -16,6 +16,7 @@ export interface IConnectionArgs {
export interface IConnectionResult { export interface IConnectionResult {
info?: string;
success: boolean; success: boolean;
status: string status: string
clientId: string clientId: string

0
service/src/hook/util.ts Normal file
View File

View File

@ -42,19 +42,20 @@ interface ISSELaunchSignature {
export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature; export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature;
function refreshConnectionOption(envPath: string) { function refreshConnectionOption(envPath: string) {
const serverPath = path.join(__dirname, '..', '..', 'servers');
const defaultOption = { const defaultOption = {
type:'STDIO', type:'STDIO',
command: 'mcp', commandString: 'mcp run main.py',
args: ['run', 'main.py'], cwd: serverPath
cwd: '../server'
}; };
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'); const envPath = path.join(__dirname, '..', '.env');
if (!fs.existsSync(envPath)) { if (!fs.existsSync(envPath)) {
@ -63,6 +64,15 @@ function getInitConnectionOption() {
try { try {
const option = JSON.parse(fs.readFileSync(envPath, 'utf-8')); 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; return option;
} catch (error) { } catch (error) {
@ -73,27 +83,11 @@ function getInitConnectionOption() {
function updateConnectionOption(data: any) { function updateConnectionOption(data: any) {
const envPath = path.join(__dirname, '..', '.env'); const envPath = path.join(__dirname, '..', '.env');
const connection = { data };
if (data.connectionType === 'STDIO') { fs.writeFileSync(envPath, JSON.stringify(connection, null, 4));
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 devHome = path.join(__dirname, '..', '..'); const devHome = path.join(__dirname, '..', '..');
setRunningCWD(devHome); setRunningCWD(devHome);
@ -115,30 +109,18 @@ wss.on('connection', (ws: any) => {
} }
}); });
const option = getInitConnectionOption(); const option = acquireConnectionOption();
// 注册消息接受的管线 // 注册消息接受的管线
webview.onDidReceiveMessage(message => { webview.onDidReceiveMessage(message => {
logger.info(`command: [${message.command || 'No Command'}]`); logger.info(`command: [${message.command || 'No Command'}]`);
const { command, data } = message; const { command, data } = message;
switch (command) { switch (command) {
case 'web/launch-signature': 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 = { const launchResult = {
code: 200, code: 200,
msg: launchResultMessage msg: option.data
}; };
webview.postMessage({ webview.postMessage({

View File

@ -1,54 +1,157 @@
import { spawnSync } from 'node:child_process'; import { execSync, spawnSync } from 'node:child_process';
import { RequestClientType } from '../common'; import { RequestClientType } from '../common';
import { connect } from './client.service'; import { connect } from './client.service';
import { RestfulResponse } from '../common/index.dto'; import { RestfulResponse } from '../common/index.dto';
import { McpOptions } from './client.dto'; import { McpOptions } from './client.dto';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import path from 'node:path';
import fs from 'node:fs';
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 {
return clientMap.get(clientId || ''); return clientMap.get(clientId || '');
} }
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 command', command);
console.log('current args', args); console.log('current args', args);
const result = spawnSync(command, args, {
cwd: cwd || process.cwd(),
STDIO: 'pipe',
encoding: 'utf-8'
});
if (result.error) { const commandString = [command, ...args].join(' ');
return result.error.message;
} const result = execSync(commandString, {
if (result.status !== 0) { cwd: cwd || process.cwd()
return result.stderr || `Command failed with code ${result.status}`; }).toString('utf-8');
}
return null; return result;
} catch (error) { } catch (error) {
return error instanceof Error ? error.message : String(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( export async function connectService(
option: McpOptions option: McpOptions
): Promise<RestfulResponse> { ): Promise<RestfulResponse> {
try { try {
console.log('ready to connect', option); console.log('ready to connect', option);
// 对于特殊表示的路径,进行特殊的支持 const info = preprocessCommand(option);
if (option.args) {
option.args = option.args.map(arg => {
if (arg.startsWith('~/')) {
return arg.replace('~', process.env.HOME || '');
}
return arg;
});
}
const client = await connect(option); const client = await connect(option);
const uuid = randomUUID(); const uuid = randomUUID();
clientMap.set(uuid, client); clientMap.set(uuid, client);
@ -61,16 +164,17 @@ 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
} }
}; };
return connectResult; return connectResult;
} catch (error) { } catch (error) {
// TODO: 这边获取到的 error 不够精致,如何才能获取到更加精准的错误 // TODO: 这边获取到的 error 不够精致,如何才能获取到更加精准的错误
// 比如 error: Failed to spawn: `server.py` // 比如 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 = ''; let errorMsg = '';
@ -79,12 +183,12 @@ export async function connectService(
} }
errorMsg += (error as any).toString(); errorMsg += (error as any).toString();
const connectResult = { const connectResult = {
code: 500, code: 500,
msg: errorMsg msg: errorMsg
}; };
return connectResult; return connectResult;
} }
} }

View File

@ -43,19 +43,20 @@ interface ISSELaunchSignature {
export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature; export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature;
function refreshConnectionOption(envPath: string) { function refreshConnectionOption(envPath: string) {
const serverPath = path.join(__dirname, '..', '..', 'servers');
const defaultOption = { const defaultOption = {
type: 'STDIO', type:'STDIO',
command: 'mcp', commandString: 'mcp run main.py',
args: ['run', 'main.py'], cwd: serverPath
cwd: '../server'
}; };
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'); const envPath = path.join(__dirname, '..', '.env');
if (!fs.existsSync(envPath)) { if (!fs.existsSync(envPath)) {
@ -64,6 +65,15 @@ function getInitConnectionOption() {
try { try {
const option = JSON.parse(fs.readFileSync(envPath, 'utf-8')); 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; return option;
} catch (error) { } catch (error) {
@ -81,25 +91,8 @@ const authPassword = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env
function updateConnectionOption(data: any) { function updateConnectionOption(data: any) {
const envPath = path.join(__dirname, '..', '.env'); const envPath = path.join(__dirname, '..', '.env');
const connection = { data };
if (data.connectionType === 'STDIO') { fs.writeFileSync(envPath, JSON.stringify(connection, null, 4));
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 devHome = path.join(__dirname, '..', '..'); const devHome = path.join(__dirname, '..', '..');
@ -146,7 +139,7 @@ wss.on('connection', (ws: any) => {
} }
}); });
const option = getInitConnectionOption(); const option = acquireConnectionOption();
// 注册消息接受的管线 // 注册消息接受的管线
webview.onDidReceiveMessage(message => { webview.onDidReceiveMessage(message => {
@ -155,21 +148,9 @@ wss.on('connection', (ws: any) => {
switch (command) { switch (command) {
case 'web/launch-signature': 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 = { const launchResult = {
code: 200, code: 200,
msg: launchResultMessage msg: option.data
}; };
webview.postMessage({ webview.postMessage({