first commit
This commit is contained in:
483
web/src/services/MCPClientService.ts
Normal file
483
web/src/services/MCPClientService.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import type { MCPServerConfig, ServerCapabilities, Tool, Resource, Prompt } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SSETransport } from './SSETransport';
|
||||
|
||||
/**
|
||||
* 纯前端 MCP 客户端服务
|
||||
* 直接在浏览器中连接 MCP 服务器,无需后端中间层
|
||||
*/
|
||||
export class MCPClientService {
|
||||
private clients = new Map<string, any>();
|
||||
private listeners = new Map<string, Array<(event: string, data: any) => void>>();
|
||||
|
||||
/**
|
||||
* 添加并连接到 MCP 服务器
|
||||
*/
|
||||
async addServer(config: MCPServerConfig): Promise<ServerCapabilities> {
|
||||
try {
|
||||
console.log(`🔗 正在连接到 MCP 服务器: ${config.name} (${config.url})`);
|
||||
|
||||
let client;
|
||||
|
||||
if (config.type === 'http') {
|
||||
// HTTP 连接
|
||||
client = await this.createHttpClient(config);
|
||||
} else if (config.type === 'sse') {
|
||||
// SSE 连接
|
||||
client = await this.createSSEClient(config);
|
||||
} else {
|
||||
throw new Error(`不支持的连接类型: ${config.type}`);
|
||||
}
|
||||
|
||||
// 获取服务器能力
|
||||
const capabilities = await this.getServerCapabilities(client);
|
||||
|
||||
this.clients.set(config.id, { client, config, capabilities });
|
||||
|
||||
console.log(`✅ 成功连接到 MCP 服务器: ${config.name}`);
|
||||
console.log('服务器能力:', capabilities);
|
||||
|
||||
this.emit(config.id, 'connected', capabilities);
|
||||
|
||||
return capabilities;
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接 MCP 服务器失败: ${config.name}`);
|
||||
console.error('错误详情:', error);
|
||||
|
||||
// 检查是否是 CORS 错误
|
||||
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
|
||||
const corsError = new Error(`CORS 错误: 无法连接到 ${config.url}。请确保 MCP 服务器启用了 CORS 支持。`);
|
||||
this.emit(config.id, 'error', corsError);
|
||||
throw corsError;
|
||||
}
|
||||
|
||||
this.emit(config.id, 'error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 客户端
|
||||
*/
|
||||
private async createHttpClient(config: MCPServerConfig) {
|
||||
// 将 0.0.0.0 替换为 localhost(浏览器无法访问 0.0.0.0)
|
||||
let baseUrl = config.url.replace(/\/$/, '');
|
||||
baseUrl = baseUrl.replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
|
||||
|
||||
// 确保URL包含 /mcp 路径
|
||||
if (!baseUrl.includes('/mcp')) {
|
||||
baseUrl = baseUrl + '/mcp';
|
||||
}
|
||||
|
||||
console.log(`🔄 HTTP原始URL: ${config.url}`);
|
||||
console.log(`🔄 HTTP转换后URL: ${baseUrl}`);
|
||||
|
||||
// 先测试MCP端点是否可访问
|
||||
try {
|
||||
console.log(`🔍 测试MCP端点可达性: ${baseUrl}`);
|
||||
const testResponse = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'test-' + Date.now(),
|
||||
method: 'ping' // 随便发一个方法测试连通性
|
||||
})
|
||||
});
|
||||
|
||||
console.log(`MCP端点响应状态: ${testResponse.status}`);
|
||||
|
||||
// 如果完全无法连接,fetch会抛出错误
|
||||
// 如果能连接但返回错误状态码,我们也认为连接有问题
|
||||
if (!testResponse.ok && testResponse.status >= 500) {
|
||||
throw new Error(`服务器错误: HTTP ${testResponse.status}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ MCP端点连接失败:`, error);
|
||||
|
||||
// 检查是否是网络错误
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error(`网络连接失败: 无法访问 ${baseUrl}。请检查服务器是否运行以及网络连接。`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'http',
|
||||
baseUrl,
|
||||
async call(method: string, params: any) {
|
||||
const response = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: uuidv4(),
|
||||
method,
|
||||
params
|
||||
})
|
||||
});
|
||||
|
||||
console.log(`MCP 请求 (${method}):`, response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`MCP 响应 (${method}):`, result);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || '请求错误');
|
||||
}
|
||||
|
||||
return result.result;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建SSE客户端
|
||||
*/
|
||||
private async createSSEClient(config: MCPServerConfig): Promise<any> {
|
||||
// 将 0.0.0.0 替换为 localhost(浏览器无法访问 0.0.0.0)
|
||||
let url = config.url;
|
||||
url = url.replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
|
||||
|
||||
console.log(`🔄 SSE 原始URL: ${config.url}`);
|
||||
console.log(`🔄 SSE 转换后URL: ${url}`);
|
||||
|
||||
const transport = new SSETransport(url);
|
||||
|
||||
// 连接SSE
|
||||
await transport.connect();
|
||||
|
||||
console.log(`✓ SSE 连接已建立: ${url}`);
|
||||
|
||||
return {
|
||||
type: 'sse',
|
||||
transport,
|
||||
async call(method: string, params: any) {
|
||||
try {
|
||||
const result = await transport.sendRequest(method, params);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`SSE 请求失败 (${method}):`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async disconnect() {
|
||||
await transport.disconnect();
|
||||
},
|
||||
get connected() {
|
||||
return transport.isConnected;
|
||||
},
|
||||
// 事件监听
|
||||
on(event: string, callback: Function) {
|
||||
transport.on(event, callback);
|
||||
},
|
||||
off(event: string, callback: Function) {
|
||||
transport.off(event, callback);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器能力
|
||||
*/
|
||||
private async getServerCapabilities(client: any): Promise<ServerCapabilities> {
|
||||
try {
|
||||
console.log('🔄 正在初始化MCP服务器...');
|
||||
|
||||
// 初始化请求 - 这是必须成功的
|
||||
const initResult = await client.call('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
roots: {
|
||||
listChanged: true
|
||||
},
|
||||
sampling: {}
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'MCP-Vue-Client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ MCP服务器初始化成功:', initResult);
|
||||
|
||||
// 获取工具列表
|
||||
let tools: Tool[] = [];
|
||||
try {
|
||||
const toolsResult = await client.call('tools/list', {});
|
||||
tools = toolsResult.tools || [];
|
||||
console.log(`📋 发现 ${tools.length} 个工具`);
|
||||
} catch (error) {
|
||||
console.warn('获取工具列表失败:', error);
|
||||
}
|
||||
|
||||
// 获取资源列表
|
||||
let resources: Resource[] = [];
|
||||
try {
|
||||
const resourcesResult = await client.call('resources/list', {});
|
||||
resources = resourcesResult.resources || [];
|
||||
console.log(`📁 发现 ${resources.length} 个资源`);
|
||||
} catch (error) {
|
||||
console.warn('获取资源列表失败:', error);
|
||||
}
|
||||
|
||||
// 获取提示列表
|
||||
let prompts: Prompt[] = [];
|
||||
try {
|
||||
const promptsResult = await client.call('prompts/list', {});
|
||||
prompts = promptsResult.prompts || [];
|
||||
console.log(`💡 发现 ${prompts.length} 个提示`);
|
||||
} catch (error) {
|
||||
console.warn('获取提示列表失败:', error);
|
||||
}
|
||||
|
||||
return { tools, resources, prompts };
|
||||
} catch (error) {
|
||||
console.error('❌ MCP服务器初始化失败:', error);
|
||||
// 初始化失败应该抛出错误,而不是返回空能力
|
||||
throw new Error(`MCP服务器初始化失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用工具
|
||||
*/
|
||||
async callTool(serverId: string, toolName: string, parameters: Record<string, any>): Promise<any> {
|
||||
const serverInfo = this.clients.get(serverId);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`服务器 ${serverId} 未连接`);
|
||||
}
|
||||
|
||||
const { client } = serverInfo;
|
||||
|
||||
try {
|
||||
console.log(`🔧 调用工具: ${toolName}`, parameters);
|
||||
|
||||
const result = await client.call('tools/call', {
|
||||
name: toolName,
|
||||
arguments: parameters
|
||||
});
|
||||
|
||||
console.log(`✅ 工具调用成功: ${toolName}`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 工具调用失败: ${toolName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取资源
|
||||
*/
|
||||
async readResource(serverId: string, uri: string): Promise<any> {
|
||||
const serverInfo = this.clients.get(serverId);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`服务器 ${serverId} 未连接`);
|
||||
}
|
||||
|
||||
const { client } = serverInfo;
|
||||
|
||||
try {
|
||||
console.log(`📖 读取资源: ${uri}`);
|
||||
|
||||
const result = await client.call('resources/read', { uri });
|
||||
|
||||
console.log(`✅ 资源读取成功: ${uri}`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 资源读取失败: ${uri}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示
|
||||
*/
|
||||
async getPrompt(serverId: string, name: string, args?: Record<string, any>): Promise<any> {
|
||||
const serverInfo = this.clients.get(serverId);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`服务器 ${serverId} 未连接`);
|
||||
}
|
||||
|
||||
const { client } = serverInfo;
|
||||
|
||||
try {
|
||||
console.log(`💭 获取提示: ${name}`, args);
|
||||
|
||||
const result = await client.call('prompts/get', {
|
||||
name,
|
||||
arguments: args || {}
|
||||
});
|
||||
|
||||
console.log(`✅ 提示获取成功: ${name}`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 提示获取失败: ${name}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开服务器连接
|
||||
*/
|
||||
async removeServer(serverId: string): Promise<void> {
|
||||
const serverInfo = this.clients.get(serverId);
|
||||
if (serverInfo) {
|
||||
const { client } = serverInfo;
|
||||
|
||||
try {
|
||||
if (client.type === 'sse' && client.disconnect) {
|
||||
await client.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('关闭连接时出错:', error);
|
||||
}
|
||||
|
||||
this.clients.delete(serverId);
|
||||
}
|
||||
this.listeners.delete(serverId);
|
||||
console.log(`🔌 服务器 ${serverId} 已断开连接`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试服务器连接
|
||||
*/
|
||||
async testConnection(serverId: string): Promise<boolean> {
|
||||
const serverInfo = this.clients.get(serverId);
|
||||
if (!serverInfo) {
|
||||
console.log(`❌ 服务器 ${serverId} 未找到客户端实例`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { client, config } = serverInfo;
|
||||
|
||||
try {
|
||||
if (client.type === 'sse') {
|
||||
return client.connected;
|
||||
} else if (client.type === 'http') {
|
||||
// HTTP 连接测试 - 发送真实的MCP初始化请求
|
||||
console.log(`🔍 测试HTTP MCP连接: ${client.baseUrl}`);
|
||||
|
||||
const response = await fetch(client.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'test-' + Date.now(),
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'MCP-Test-Client', version: '1.0.0' }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ HTTP响应失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
console.log(`❌ MCP协议错误:`, data.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ MCP连接测试成功`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log(`❌ 连接测试异常:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有连接的服务器
|
||||
*/
|
||||
getConnectedServers(): string[] {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器信息
|
||||
*/
|
||||
getServerInfo(serverId: string) {
|
||||
return this.clients.get(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件监听
|
||||
*/
|
||||
on(serverId: string, callback: (event: string, data: any) => void): void {
|
||||
if (!this.listeners.has(serverId)) {
|
||||
this.listeners.set(serverId, []);
|
||||
}
|
||||
this.listeners.get(serverId)!.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听
|
||||
*/
|
||||
off(serverId: string, callback?: (event: string, data: any) => void): void {
|
||||
if (!callback) {
|
||||
this.listeners.delete(serverId);
|
||||
} else {
|
||||
const callbacks = this.listeners.get(serverId) || [];
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit(serverId: string, event: string, data: any): void {
|
||||
const callbacks = this.listeners.get(serverId) || [];
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('事件回调执行失败:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有连接
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
const serverIds = Array.from(this.clients.keys());
|
||||
await Promise.all(serverIds.map(id => this.removeServer(id)));
|
||||
console.log('🧹 所有连接已清理');
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
export const mcpClientService = new MCPClientService();
|
||||
|
||||
// 在页面卸载时清理连接
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
mcpClientService.cleanup();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user