@probelabs/probe
Version:
Node.js wrapper for the probe code search tool
406 lines (351 loc) • 11.5 kB
JavaScript
/**
* Enhanced MCP Client with support for all transport types
* Compatible with Claude's MCP configuration format
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import { loadMCPConfiguration, parseEnabledServers } from './config.js';
/**
* Create transport based on configuration
* @param {Object} serverConfig - Server configuration
* @returns {Object} Transport instance
*/
export function createTransport(serverConfig) {
const { transport, command, args, url, env } = serverConfig;
switch (transport) {
case 'stdio':
return new StdioClientTransport({
command,
args: args || [],
env: env ? { ...process.env, ...env } : undefined
});
case 'sse':
if (!url) {
throw new Error('SSE transport requires a URL');
}
return new SSEClientTransport(new URL(url));
case 'websocket':
case 'ws':
if (!url) {
throw new Error('WebSocket transport requires a URL');
}
try {
return new WebSocketClientTransport(new URL(url));
} catch (error) {
throw new Error(`Invalid WebSocket URL: ${url}`);
}
case 'http':
case 'streamable':
// For HTTP, we'll use a custom implementation since the SDK
// doesn't provide a direct HTTP transport yet
if (!url) {
throw new Error('HTTP transport requires a URL');
}
// Return a custom HTTP transport wrapper
return createHttpTransport(url);
default:
throw new Error(`Unknown transport type: ${transport}`);
}
}
/**
* Create a custom HTTP transport wrapper
* This simulates MCP over HTTP REST endpoints
*/
function createHttpTransport(url) {
// This is a simplified HTTP transport
// In practice, you'd implement the full MCP protocol over HTTP
return {
async start() {
// Initialize HTTP connection
const response = await fetch(`${url}/initialize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
protocolVersion: '2024-11-05',
capabilities: {}
})
});
if (!response.ok) {
throw new Error(`HTTP initialization failed: ${response.statusText}`);
}
return response.json();
},
async send(message) {
const response = await fetch(`${url}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
if (!response.ok) {
throw new Error(`HTTP request failed: ${response.statusText}`);
}
return response.json();
},
async close() {
// Close HTTP connection
await fetch(`${url}/close`, {
method: 'POST'
}).catch(() => {
// Ignore close errors
});
}
};
}
/**
* MCP Client Manager - manages multiple MCP server connections
*/
export class MCPClientManager {
constructor(options = {}) {
this.clients = new Map();
this.tools = new Map();
this.debug = options.debug || process.env.DEBUG_MCP === '1';
this.config = null;
}
/**
* Initialize MCP clients from configuration
* @param {Object} config - Optional configuration override
*/
async initialize(config = null) {
// Load configuration
this.config = config || loadMCPConfiguration();
const servers = parseEnabledServers(this.config);
// Always log the number of servers found
console.error(`[MCP INFO] Found ${servers.length} enabled MCP server${servers.length !== 1 ? 's' : ''}`);
if (servers.length === 0) {
console.error('[MCP INFO] No MCP servers configured or enabled');
console.error('[MCP INFO] 0 MCP tools available');
return {
connected: 0,
total: 0,
tools: []
};
}
if (this.debug) {
console.error('[MCP DEBUG] Server details:');
servers.forEach(server => {
console.error(`[MCP DEBUG] - ${server.name} (${server.transport})`);
});
}
// Connect to each enabled server
const connectionPromises = servers.map(server =>
this.connectToServer(server).catch(error => {
console.error(`[MCP ERROR] Failed to connect to ${server.name}:`, error.message);
return null;
})
);
const results = await Promise.all(connectionPromises);
const connectedCount = results.filter(Boolean).length;
// Always log connection results
if (connectedCount === 0) {
console.error(`[MCP ERROR] Failed to connect to all ${servers.length} server${servers.length !== 1 ? 's' : ''}`);
console.error('[MCP INFO] 0 MCP tools available');
} else if (connectedCount < servers.length) {
console.error(`[MCP INFO] Successfully connected to ${connectedCount}/${servers.length} servers`);
console.error(`[MCP INFO] ${this.tools.size} MCP tool${this.tools.size !== 1 ? 's' : ''} available`);
} else {
console.error(`[MCP INFO] Successfully connected to all ${connectedCount} server${connectedCount !== 1 ? 's' : ''}`);
console.error(`[MCP INFO] ${this.tools.size} MCP tool${this.tools.size !== 1 ? 's' : ''} available`);
}
if (this.debug && this.tools.size > 0) {
console.error('[MCP DEBUG] Available tools:');
Array.from(this.tools.keys()).forEach(toolName => {
console.error(`[MCP DEBUG] - ${toolName}`);
});
}
return {
connected: connectedCount,
total: servers.length,
tools: Array.from(this.tools.keys())
};
}
/**
* Connect to a single MCP server
* @param {Object} serverConfig - Server configuration
*/
async connectToServer(serverConfig) {
const { name } = serverConfig;
try {
if (this.debug) {
console.error(`[MCP DEBUG] Connecting to ${name} via ${serverConfig.transport}...`);
}
// Create transport
const transport = createTransport(serverConfig);
// Create client
const client = new Client(
{
name: `probe-client-${name}`,
version: '1.0.0'
},
{
capabilities: {}
}
);
// Connect
await client.connect(transport);
// Store client
this.clients.set(name, {
client,
transport,
config: serverConfig
});
// Fetch and register tools
const toolsResponse = await client.listTools();
const toolCount = toolsResponse?.tools?.length || 0;
if (toolsResponse && toolsResponse.tools) {
for (const tool of toolsResponse.tools) {
// Add server prefix to avoid conflicts
const qualifiedName = `${name}_${tool.name}`;
this.tools.set(qualifiedName, {
...tool,
serverName: name,
originalName: tool.name
});
if (this.debug) {
console.error(`[MCP DEBUG] Registered tool: ${qualifiedName}`);
}
}
}
console.error(`[MCP INFO] Connected to ${name}: ${toolCount} tool${toolCount !== 1 ? 's' : ''} loaded`);
return true;
} catch (error) {
console.error(`[MCP ERROR] Error connecting to ${name}:`, error.message);
if (this.debug) {
console.error(`[MCP DEBUG] Full error details:`, error);
}
return false;
}
}
/**
* Call a tool on its respective server
* @param {string} toolName - Qualified tool name (server_tool)
* @param {Object} args - Tool arguments
*/
async callTool(toolName, args) {
const tool = this.tools.get(toolName);
if (!tool) {
throw new Error(`Unknown tool: ${toolName}`);
}
const clientInfo = this.clients.get(tool.serverName);
if (!clientInfo) {
throw new Error(`Server ${tool.serverName} not connected`);
}
try {
if (this.debug) {
console.error(`[MCP DEBUG] Calling ${toolName} with args:`, JSON.stringify(args, null, 2));
}
// Get timeout from config (default 30 seconds)
const timeout = this.config?.settings?.timeout || 30000;
// Create a timeout promise
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`MCP tool call timeout after ${timeout}ms`));
}, timeout);
});
// Race between the actual call and timeout
const result = await Promise.race([
clientInfo.client.callTool({
name: tool.originalName,
arguments: args
}),
timeoutPromise
]);
if (this.debug) {
console.error(`[MCP DEBUG] Tool ${toolName} executed successfully`);
}
return result;
} catch (error) {
console.error(`[MCP ERROR] Error calling tool ${toolName}:`, error.message);
if (this.debug) {
console.error(`[MCP DEBUG] Full error details:`, error);
}
throw error;
}
}
/**
* Get all available tools with their schemas
* @returns {Object} Map of tool name to tool definition
*/
getTools() {
const tools = {};
for (const [name, tool] of this.tools.entries()) {
tools[name] = {
description: tool.description,
inputSchema: tool.inputSchema,
serverName: tool.serverName
};
}
return tools;
}
/**
* Get tools formatted for Vercel AI SDK
* @returns {Object} Tools in Vercel AI SDK format
*/
getVercelTools() {
const tools = {};
for (const [name, tool] of this.tools.entries()) {
// Create a wrapper that calls the MCP tool
tools[name] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (args) => {
const result = await this.callTool(name, args);
// Extract text content from MCP response
if (result.content && result.content[0]) {
return result.content[0].text;
}
return JSON.stringify(result);
}
};
}
return tools;
}
/**
* Disconnect all clients
*/
async disconnect() {
const disconnectPromises = [];
if (this.clients.size === 0) {
if (this.debug) {
console.error('[MCP DEBUG] No MCP clients to disconnect');
}
return;
}
if (this.debug) {
console.error(`[MCP DEBUG] Disconnecting from ${this.clients.size} MCP server${this.clients.size !== 1 ? 's' : ''}...`);
}
for (const [name, clientInfo] of this.clients.entries()) {
disconnectPromises.push(
clientInfo.client.close()
.then(() => {
if (this.debug) {
console.error(`[MCP DEBUG] Disconnected from ${name}`);
}
})
.catch(error => {
console.error(`[MCP ERROR] Error disconnecting from ${name}:`, error.message);
})
);
}
await Promise.all(disconnectPromises);
this.clients.clear();
this.tools.clear();
if (this.debug) {
console.error('[MCP DEBUG] All MCP connections closed');
}
}
}
/**
* Create and initialize MCP client manager with default configuration
*/
export async function createMCPManager(options = {}) {
const manager = new MCPClientManager(options);
await manager.initialize();
return manager;
}
export default {
MCPClientManager,
createMCPManager,
createTransport
};