converse-mcp-server
Version:
Converse MCP Server - Converse with other LLMs with chat and consensus tools
417 lines (361 loc) • 14.1 kB
JavaScript
/**
* Chat Tool
*
* Single-provider conversational AI with context and continuation support.
* Handles context processing, provider calls, and state management.
*/
import { createToolResponse, createToolError } from './index.js';
import { processUnifiedContext, createFileContext } from '../utils/contextProcessor.js';
import { generateContinuationId, addMessageToHistory } from '../continuationStore.js';
import { debugLog, debugError } from '../utils/console.js';
import { createLogger } from '../utils/logger.js';
import { CHAT_PROMPT } from '../systemPrompts.js';
import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js';
import { validateAllPaths } from '../utils/fileValidator.js';
const logger = createLogger('chat');
/**
* Chat tool implementation
* @param {object} args - Tool arguments
* @param {object} dependencies - Injected dependencies (config, providers, continuationStore)
* @returns {object} MCP tool response
*/
export async function chatTool(args, dependencies) {
try {
const { config, providers, continuationStore, contextProcessor } = dependencies;
// Validate required arguments
if (!args.prompt || typeof args.prompt !== 'string') {
return createToolError('Prompt is required and must be a string');
}
// Extract and validate arguments
const {
prompt,
model = 'auto',
files = [],
continuation_id,
temperature = 0.5,
use_websearch = false,
images = [],
reasoning_effort = 'medium',
verbosity = 'medium'
} = args;
let conversationHistory = [];
let continuationId = continuation_id;
// Load existing conversation if continuation_id provided
if (continuationId) {
try {
const existingState = await continuationStore.get(continuationId);
if (existingState) {
conversationHistory = existingState.messages || [];
} else {
// Invalid continuation ID - start fresh with new ID
continuationId = generateContinuationId();
}
} catch (error) {
logger.error('Error loading conversation', { error });
// Continue with fresh conversation on error
continuationId = generateContinuationId();
}
} else {
// Generate new continuation ID for new conversation
continuationId = generateContinuationId();
}
// Validate file paths before processing
if (files.length > 0 || images.length > 0) {
const validation = await validateAllPaths({ files, images });
if (!validation.valid) {
logger.error('File validation failed', { errors: validation.errors });
return validation.errorResponse;
}
}
// Process context (files, images, web search)
let contextMessage = null;
if (files.length > 0 || images.length > 0 || use_websearch) {
try {
const contextRequest = {
files: Array.isArray(files) ? files : [],
images: Array.isArray(images) ? images : [],
webSearch: use_websearch ? prompt : null
};
const contextResult = await contextProcessor.processUnifiedContext(contextRequest);
// Create context message from files and images
const allProcessedFiles = [...contextResult.files, ...contextResult.images];
if (allProcessedFiles.length > 0) {
contextMessage = createFileContext(allProcessedFiles, {
includeMetadata: true,
includeErrors: true
});
}
// Add web search results if available (placeholder for now)
if (contextResult.webSearch && !contextResult.webSearch.placeholder) {
// Future implementation: add web search results to context
logger.debug('Web search results available but not yet implemented');
}
} catch (error) {
logger.error('Error processing context', { error });
// Continue without context if processing fails
}
}
// Build message array for provider
const messages = [];
// Add system prompt only if not already in conversation history
if (conversationHistory.length === 0 || conversationHistory[0].role !== 'system') {
messages.push({
role: 'system',
content: CHAT_PROMPT
});
}
// Add conversation history
messages.push(...conversationHistory);
// Add user prompt with context
const userMessage = {
role: 'user',
content: prompt // default to simple string content
};
// If we have context (files/images), create complex content array
if (contextMessage && contextMessage.content) {
// Create complex content array
userMessage.content = [
...contextMessage.content, // Include all file/image parts
{ type: 'text', text: prompt } // Add the user prompt as text
];
}
messages.push(userMessage);
// Select provider
let selectedProvider;
let providerName;
if (model === 'auto') {
// Auto-select first available provider
const availableProviders = Object.keys(providers).filter(name => {
const provider = providers[name];
return provider && provider.isAvailable && provider.isAvailable(config);
});
if (availableProviders.length === 0) {
return createToolError('No providers available. Please configure at least one API key.');
}
providerName = availableProviders[0];
selectedProvider = providers[providerName];
} else {
// Use specified provider/model
// Try to map model to provider
providerName = mapModelToProvider(model, providers);
selectedProvider = providers[providerName];
if (!selectedProvider) {
return createToolError(`Provider not found for model: ${model}`);
}
if (!selectedProvider.isAvailable(config)) {
return createToolError(`Provider ${providerName} is not available. Check API key configuration.`);
}
}
// Resolve model name and prepare provider options
const resolvedModel = resolveAutoModel(model, providerName);
const providerOptions = {
model: resolvedModel,
temperature,
reasoning_effort,
verbosity,
use_websearch,
config
};
// Call provider
let response;
try {
response = await selectedProvider.invoke(messages, providerOptions);
} catch (error) {
logger.error('Provider error', { error, data: { provider: providerName } });
return createToolError(`Provider error: ${error.message}`);
}
// Validate response
if (!response || !response.content) {
return createToolError('Provider returned invalid response');
}
// Add assistant response to conversation history
const assistantMessage = {
role: 'assistant',
content: response.content
};
const updatedMessages = [...messages, assistantMessage];
// Save conversation state
try {
const conversationState = {
messages: updatedMessages,
provider: providerName,
model,
lastUpdated: Date.now()
};
await continuationStore.set(continuationId, conversationState);
} catch (error) {
logger.error('Error saving conversation', { error });
// Continue even if save fails
}
// Create response with continuation
const result = {
content: response.content,
continuation: {
id: continuationId,
provider: providerName,
model,
messageCount: updatedMessages.filter(msg => msg.role !== 'system').length
}
};
// Add metadata if available
if (response.metadata) {
result.metadata = response.metadata;
}
// Apply token limiting to the final response
const tokenLimit = getTokenLimit(config);
const resultStr = JSON.stringify(result, null, 2);
const limitedResult = applyTokenLimit(resultStr, tokenLimit);
// Parse the limited result back to object format to preserve structure
let finalResult;
try {
finalResult = JSON.parse(limitedResult.content);
} catch (e) {
// Fallback if parsing fails - return original result
finalResult = result;
}
return createToolResponse(finalResult);
} catch (error) {
logger.error('Chat tool error', { error });
return createToolError('Chat tool failed', error);
}
}
/**
* Map model name to provider name
* @param {string} model - Model name
* @returns {string} Provider name
*/
/**
* Resolve "auto" model to default model for the provider
*/
function resolveAutoModel(model, providerName) {
if (model.toLowerCase() !== 'auto') {
return model;
}
const defaults = {
'openai': 'o3',
'xai': 'grok-4-0709',
'google': 'gemini-2.5-pro',
'anthropic': 'claude-sonnet-4-20250514',
'mistral': 'magistral-medium-2506',
'deepseek': 'deepseek-reasoner',
'openrouter': 'qwen/qwen3-coder'
};
return defaults[providerName] || 'gpt-4o-mini';
}
function mapModelToProvider(model, providers) {
const modelLower = model.toLowerCase();
// Handle "auto" - default to OpenAI
if (modelLower === 'auto') {
return 'openai';
}
// Check OpenRouter-specific patterns first
if (modelLower === 'openrouter auto' || modelLower === 'auto router' ||
modelLower === 'auto-router' || modelLower === 'openrouter-auto') {
return 'openrouter';
}
// If model contains "/", check if native provider supports it
if (modelLower.includes('/')) {
// Check each provider to see if they have this exact model
for (const [providerName, provider] of Object.entries(providers)) {
if (provider && provider.getModelConfig) {
const modelConfig = provider.getModelConfig(model);
if (modelConfig && !modelConfig.isDynamic && !modelConfig.needsApiUpdate) {
// Model exists in this provider's static list
return providerName;
}
}
}
// No native provider has this model, route to OpenRouter
return 'openrouter';
}
// For non-slash models, use keyword matching as before
// OpenAI models
if (modelLower.includes('gpt') || modelLower.includes('o1') ||
modelLower.includes('o3') || modelLower.includes('o4')) {
return 'openai';
}
// XAI models
if (modelLower.includes('grok')) {
return 'xai';
}
// Google models
if (modelLower.includes('gemini') || modelLower.includes('flash') ||
modelLower.includes('pro') || modelLower === 'google') {
return 'google';
}
// Anthropic models
if (modelLower.includes('claude') || modelLower.includes('opus') ||
modelLower.includes('sonnet') || modelLower.includes('haiku')) {
return 'anthropic';
}
// Mistral models
if (modelLower.includes('mistral') || modelLower.includes('magistral')) {
return 'mistral';
}
// DeepSeek models
if (modelLower.includes('deepseek') || modelLower === 'reasoner' ||
modelLower === 'r1' || modelLower === 'chat') {
return 'deepseek';
}
// OpenRouter models (specific model patterns)
if (modelLower.includes('qwen') || modelLower.includes('kimi') ||
modelLower.includes('moonshot') || modelLower === 'k2') {
return 'openrouter';
}
// Default fallback
return 'openai';
}
// Tool metadata
chatTool.description = 'GENERAL CHAT & COLLABORATIVE THINKING - For development assistance, brainstorming, and code analysis. Supports files, images, and conversation continuation.';
chatTool.inputSchema = {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'Your question or topic with relevant context. More detail enables better responses. Example: "How should I structure the authentication module for this Express.js API?"',
},
model: {
type: 'string',
description: 'AI model to use. Examples: "auto" (recommended), "gpt-5", "gemini-2.5-pro", "grok-4-0709". Defaults to auto-selection.',
},
files: {
type: 'array',
items: { type: 'string' },
description: 'File paths to include as context (absolute or relative paths). Example: ["C:\\Users\\username\\project\\src\\auth.js", "./config.json"]',
},
images: {
type: 'array',
items: { type: 'string' },
description: 'Image paths for visual context (absolute or relative paths, or base64 data). Example: ["C:\\Users\\username\\diagram.png", "./screenshot.jpg", "data:image/jpeg;base64,/9j/4AAQ..."]',
},
continuation_id: {
type: 'string',
description: 'Continuation ID for persistent conversation. Example: "chat_1703123456789_abc123"',
},
temperature: {
type: 'number',
description: 'Response randomness (0.0-1.0). Examples: 0.2 (focused), 0.5 (balanced), 0.8 (creative). Default: 0.5',
minimum: 0.0,
maximum: 1.0,
default: 0.5
},
reasoning_effort: {
type: 'string',
enum: ['minimal', 'low', 'medium', 'high', 'max'],
description: 'Reasoning depth for thinking models. Examples: "minimal" (fastest, few reasoning tokens), "low" (light analysis), "medium" (balanced), "high" (complex analysis). Default: "medium"',
default: 'medium'
},
verbosity: {
type: 'string',
enum: ['low', 'medium', 'high'],
description: 'Output verbosity for GPT-5 models. Examples: "low" (concise answers), "medium" (balanced), "high" (thorough explanations). Default: "medium"',
default: 'medium'
},
use_websearch: {
type: 'boolean',
description: 'Enable web search for current information. Example: true for recent developments or up to date documentation. Default: false',
default: false
},
},
required: ['prompt'],
};