UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

300 lines (264 loc) 8.41 kB
/** * Tool Registry * * Central registry for all MCP tools following functional architecture. * Each tool receives dependencies via injection and returns MCP-compatible responses. */ // Import individual tools import { chatTool } from './chat.js'; import { checkStatusTool } from './checkStatus.js'; import { cancelJobTool } from './cancelJob.js'; /** * Tool registry map * Each tool must implement: async function(args, dependencies) => mcpResponse * Tools also have metadata: description, inputSchema */ const tools = { chat: chatTool, check_status: checkStatusTool, cancel_job: cancelJobTool, }; /** * Get all available tools, optionally filtered based on configuration * @param {object} config - Configuration object (optional) * @returns {object} Map of tool name to tool implementation */ export function getTools(config = null) { // If no config provided or async tools not disabled, return all tools if (!config || !config.async?.disableAsyncTools) { return tools; } // Filter out async-only tools when async is disabled const filteredTools = {}; for (const [name, tool] of Object.entries(tools)) { // Exclude check_status and cancel_job tools if (name === 'check_status' || name === 'cancel_job') { continue; } // Clone the tool to avoid mutating the original const clonedTool = tool; // For the chat tool, remove the 'async' parameter from its schema if (name === 'chat') { // Create a modified inputSchema without the async parameter const modifiedSchema = { ...tool.inputSchema, properties: { ...tool.inputSchema.properties }, }; delete modifiedSchema.properties.async; // Create a wrapper function with modified metadata const wrappedTool = async function (...args) { return await tool(...args); }; wrappedTool.description = tool.description; wrappedTool.inputSchema = modifiedSchema; filteredTools[name] = wrappedTool; } else { filteredTools[name] = clonedTool; } } return filteredTools; } /** * Get a specific tool by name * @param {string} name - Tool name * @returns {object|null} Tool implementation or null if not found */ export function getTool(name) { return tools[name] || null; } /** * Register a new tool * @param {string} name - Tool name * @param {function} toolHandler - Tool implementation function * @param {object} metadata - Tool metadata (description, inputSchema) */ export function registerTool(name, toolHandler, metadata = {}) { // Validate tool interface if (typeof toolHandler !== 'function') { throw new Error(`Tool ${name} must be a function`); } // Add metadata to tool function toolHandler.description = metadata.description || `${name} tool`; toolHandler.inputSchema = metadata.inputSchema || { type: 'object', properties: {}, }; tools[name] = toolHandler; } /** * Get list of available tool names * @returns {string[]} Array of tool names */ export function getAvailableTools() { return Object.keys(tools); } /** * Format metadata for display in tool responses * @param {object} metadata - Metadata to format * @param {string} toolName - Name of the tool * @param {number} executionTime - Execution time in seconds * @param {boolean} enableDisplay - Whether to enable metadata display * @returns {string} Formatted metadata string */ export function formatMetadataDisplay( metadata = {}, toolName = '', executionTime = null, enableDisplay = true, ) { // Return empty string if display is disabled (useful for testing) if (!enableDisplay) { return ''; } const parts = []; // Use elapsed_seconds from job if available, otherwise use executionTime const timeToShow = metadata.elapsed_seconds !== undefined ? metadata.elapsed_seconds : executionTime; if (timeToShow !== null) { // Format time appropriately based on duration let timeDisplay; if (timeToShow >= 60) { // Show minutes and seconds for long requests const minutes = Math.floor(timeToShow / 60); const seconds = Math.round(timeToShow % 60); timeDisplay = `${minutes}m${seconds}s`; } else if (timeToShow >= 1) { // Show seconds with 1 decimal place for requests over 1 second timeDisplay = `${timeToShow.toFixed(1)}s`; } else { // Show seconds with 2 decimal places for sub-second requests timeDisplay = `${timeToShow.toFixed(2)}s`; } parts.push(`⏱️ ${timeDisplay}`); } if (metadata.successful_models !== undefined) { parts.push( `✅ ${metadata.successful_models}/${metadata.total_models} models`, ); } if (metadata.continuation_id) { parts.push(`🔗 ${metadata.continuation_id}`); } if (metadata.models_list) { // For consensus tool with multiple models parts.push(`🤖 ${metadata.models_list}`); } else if (metadata.provider) { parts.push(`🤖 ${metadata.provider}`); } if (metadata.model && !metadata.models_list) { parts.push(`📱 ${metadata.model}`); } return parts.length > 0 ? `[${parts.join(' | ')}]` : ''; } /** * Format failure details for display in tool responses * @param {array} failureDetails - Array of failure detail strings * @returns {string} Formatted failure details string */ export function formatFailureDetails(failureDetails = []) { if (!failureDetails || failureDetails.length === 0) { return ''; } const failureList = failureDetails.map((detail) => `• ${detail}`).join('\n'); return `\nModel failures:\n${failureList}`; } /** * Create MCP-compatible tool response * @param {string|object} content - Response content (string) or full response object * @param {boolean} isError - Whether this is an error response * @param {object} additionalFields - Additional fields to include in response * @returns {object} MCP tool response */ export function createToolResponse( content, isError = false, additionalFields = {}, ) { // If content is already a structured response object, use it directly if ( typeof content === 'object' && content !== null && !Array.isArray(content) ) { // If it's a complete response object with content array, return it directly if (content.content && Array.isArray(content.content)) { return { ...content, isError: isError || content.isError || false, ...additionalFields, }; } // If it's a tool result object (has continuation, metadata, etc.) convert to MCP format if ( content.continuation || content.metadata || content.content || content.metadata_display ) { // Prepare the text content, potentially prefixing with metadata display let textContent = content.content || JSON.stringify(content, null, 2); if (content.metadata_display) { textContent = `${content.metadata_display}\n\n${textContent}`; } const mcpResponse = { content: [ { type: 'text', text: textContent, }, ], isError: isError || content.isError || false, ...additionalFields, }; // Preserve continuation and metadata at top level if (content.continuation) { mcpResponse.continuation = content.continuation; } if (content.metadata) { mcpResponse.metadata = content.metadata; } return mcpResponse; } // If it's any other object, stringify it return { content: [ { type: 'text', text: JSON.stringify(content, null, 2), }, ], isError, ...additionalFields, }; } // Handle string content return { content: [ { type: 'text', text: content, }, ], isError, ...additionalFields, }; } /** * Create MCP-compatible tool error response * @param {string} message - Error message * @param {Error} error - Original error object * @returns {object} MCP error response */ export function createToolError(message, error = null) { const errorText = error ? `${message}: ${error.message}` : message; const response = createToolResponse(errorText, true); // Add error object for test compatibility response.error = { message: errorText, type: 'ToolError', timestamp: new Date().toISOString(), }; return response; }