496 lines
14 KiB
TypeScript
496 lines
14 KiB
TypeScript
import type { MCPServerConfig } from '../types';
|
||
import type { ServerCapabilities, Tool, Resource, Prompt } from '../types/index';
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取服务器的工具列表
|
||
*/
|
||
getTools(serverId: string): Tool[] {
|
||
const serverInfo = this.clients.get(serverId);
|
||
if (!serverInfo) {
|
||
console.warn(`服务器 ${serverId} 未连接`);
|
||
return [];
|
||
}
|
||
return serverInfo.capabilities?.tools || [];
|
||
}
|
||
|
||
/**
|
||
* 获取提示
|
||
*/
|
||
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 } = 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();
|
||
});
|
||
} |