完成兼容

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ export interface IConnectionArgs {
export interface IConnectionResult {
info?: string;
success: boolean;
status: 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;
function refreshConnectionOption(envPath: string) {
const serverPath = path.join(__dirname, '..', '..', 'servers');
const defaultOption = {
type:'STDIO',
command: 'mcp',
args: ['run', 'main.py'],
cwd: '../server'
commandString: 'mcp run main.py',
cwd: serverPath
};
fs.writeFileSync(envPath, JSON.stringify(defaultOption, null, 4));
return defaultOption;
return { data: [ defaultOption ] };
}
function getInitConnectionOption() {
function acquireConnectionOption() {
const envPath = path.join(__dirname, '..', '.env');
if (!fs.existsSync(envPath)) {
@ -63,6 +64,15 @@ function getInitConnectionOption() {
try {
const option = JSON.parse(fs.readFileSync(envPath, 'utf-8'));
if (!option.data) {
return refreshConnectionOption(envPath);
}
if (option.data && option.data.length === 0) {
return refreshConnectionOption(envPath);
}
return option;
} catch (error) {
@ -73,27 +83,11 @@ function getInitConnectionOption() {
function updateConnectionOption(data: any) {
const envPath = path.join(__dirname, '..', '.env');
if (data.connectionType === 'STDIO') {
const connectionItem = {
type: 'STDIO',
command: data.command,
args: data.args,
cwd: data.cwd.replace(/\\/g, '/')
};
fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4));
} else {
const connectionItem = {
type: 'SSE',
url: data.url,
oauth: data.oauth
};
fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4));
}
const connection = { data };
fs.writeFileSync(envPath, JSON.stringify(connection, null, 4));
}
const devHome = path.join(__dirname, '..', '..');
setRunningCWD(devHome);
@ -115,7 +109,7 @@ wss.on('connection', (ws: any) => {
}
});
const option = getInitConnectionOption();
const option = acquireConnectionOption();
// 注册消息接受的管线
webview.onDidReceiveMessage(message => {
@ -124,21 +118,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({

View File

@ -1,9 +1,11 @@
import { spawnSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
import { RequestClientType } from '../common';
import { connect } from './client.service';
import { RestfulResponse } from '../common/index.dto';
import { McpOptions } from './client.dto';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import fs from 'node:fs';
export const clientMap: Map<string, RequestClientType> = new Map();
export function getClient(clientId?: string): RequestClientType | undefined {
@ -15,30 +17,39 @@ export function tryGetRunCommandError(command: string, args: string[] = [], cwd?
console.log('current command', command);
console.log('current args', args);
const result = spawnSync(command, args, {
cwd: cwd || process.cwd(),
STDIO: 'pipe',
encoding: 'utf-8'
});
const commandString = [command, ...args].join(' ');
if (result.error) {
return result.error.message;
}
if (result.status !== 0) {
return result.stderr || `Command failed with code ${result.status}`;
}
return null;
const result = execSync(commandString, {
cwd: cwd || process.cwd()
}).toString('utf-8');
return result;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
export async function connectService(
option: McpOptions
): Promise<RestfulResponse> {
try {
console.log('ready to connect', option);
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 => {
@ -49,6 +60,98 @@ export async function connectService(
});
}
if (option.connectionType === 'SSE' || option.connectionType === 'STREAMABLE_HTTP') {
return [option, ''];
}
const cwd = getCWD(option);
if (!cwd) {
return [option, ''];
}
const ext = getCommandFileExt(option);
if (!ext) {
return [option, ''];
}
// STDIO 模式下,对不同类型的项目进行额外支持
// uv如果没有初始化则进行 uv sync将 mcp 设置为虚拟环境的
// npm如果没有初始化则进行 npm init将 mcp 设置为虚拟环境
// go如果没有初始化则进行 go mod init
let info: string = '';
switch (ext) {
case '.py':
info = initUv(cwd);
break;
case '.js':
case '.ts':
info = initNpm(cwd);
break;
default:
break;
}
return [option, info];
}
function initUv(cwd: string) {
let projectDir = cwd;
while (projectDir!== path.dirname(projectDir)) {
if (fs.readFileSync(projectDir).includes('pyproject.toml')) {
break;
}
projectDir = path.dirname(projectDir);
}
console.log(projectDir);
const venv = path.join(projectDir, '.venv');
const mcpCli = path.join(venv, 'bin', 'mcp');
if (fs.existsSync(mcpCli)) {
return '';
}
let info = '';
info += execSync('uv sync', { cwd: projectDir }).toString('utf-8') + '\n';
info += execSync('uv add mcp "mcp[cli]"', { cwd: projectDir }).toString('utf-8') + '\n';
return info;
}
function initNpm(cwd: string) {
let projectDir = cwd;
while (projectDir !== path.dirname(projectDir)) {
if (fs.readFileSync(projectDir).includes('package.json')) {
break;
}
projectDir = path.dirname(projectDir);
}
const nodeModulesPath = path.join(projectDir, 'node_modules');
if (fs.existsSync(nodeModulesPath)) {
return '';
}
return execSync('npm i', { cwd: projectDir }).toString('utf-8') + '\n';
}
export async function connectService(
option: McpOptions
): Promise<RestfulResponse> {
try {
console.log('ready to connect', option);
const info = preprocessCommand(option);
const client = await connect(option);
const uuid = randomUUID();
clientMap.set(uuid, client);
@ -61,7 +164,8 @@ export async function connectService(
status: 'success',
clientId: uuid,
name: versionInfo?.name,
version: versionInfo?.version
version: versionInfo?.version,
info
}
};

View File

@ -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));
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({