converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
300 lines (264 loc) • 8.41 kB
JavaScript
/**
* 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;
}