blob: cb8e11d93203c743bb824143bf0c92bd445172f1 [file] [log] [blame]
import readline from 'readline/promises';
import { MCPClient } from '../lib/mcp-client';
import { readSettings } from '../lib/settings';
import dotenv from 'dotenv';
import { GoogleGenerativeAI, FinishReason, Tool, FunctionCall, Part } from '@google/generative-ai';
dotenv.config();
const MODEL_NAME = 'gemini-2.5-pro-preview-06-05';
// Module-scoped variables for cleanup
let rl: readline.Interface | null = null;
const mcpClients: MCPClient[] = [];
let isCleaningUp = false;
/**
* Performs cleanup of resources and exits the process.
* @param source - The source of the cleanup trigger (e.g., 'SIGINT', 'quit').
* @param exitCode - The exit code for the process.
*/
async function performCleanupAndExit(source: string, exitCode: number = 0) {
if (isCleaningUp) {
return; // Cleanup already in progress
}
isCleaningUp = true;
console.log(`\n[${source}] Initiating graceful shutdown...`);
if (rl) {
console.log('Closing readline interface...');
rl.close(); // This will cause rl.question() to stop waiting for input
}
console.log('Cleaning up MCP clients...');
const cleanupPromises = mcpClients.map((client) =>
client.cleanup().catch((e) => {
console.error(`Error during cleanup for client ${client.getServerName()}:`, e);
})
);
await Promise.allSettled(cleanupPromises);
console.log(`[${source}] Hades CLI exited. Exit code: ${exitCode}`);
process.exit(exitCode);
}
// Register signal handlers
process.on('SIGINT', () => {
performCleanupAndExit('SIGINT', 130); // Standard exit code for SIGINT
});
process.on('SIGTERM', () => {
performCleanupAndExit('SIGTERM', 143); // Standard exit code for SIGTERM
});
async function main() {
rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const settings = await readSettings();
const serverNames = Object.keys(settings.mcpServers);
if (serverNames.length === 0) {
console.log('No servers configured. Please add servers to your settings file.');
await performCleanupAndExit('no_servers_config', 1);
return; // Should be unreachable
}
console.log('Connecting to MCP servers...');
for (const serverName of serverNames) {
const client = new MCPClient();
try {
await client.connect(serverName, settings.mcpServers[serverName]);
mcpClients.push(client); // Populate module-scoped array
} catch (e) {
console.error(
`Failed to connect to server ${serverName}:`,
e instanceof Error ? e.message : String(e)
);
}
}
if (mcpClients.length === 0) {
console.log('No MCP servers could be connected. Exiting.');
await performCleanupAndExit('no_servers_connected', 1);
return; // Should be unreachable
}
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY as string);
const allToolsForModel: Tool[] = mcpClients.flatMap((client) => client.getToolsForModel());
const hasDeclarableTools = allToolsForModel.some(
(tool) =>
'functionDeclarations' in tool &&
tool.functionDeclarations &&
tool.functionDeclarations.length > 0
);
if (!hasDeclarableTools) {
console.warn(
'No tools with function declarations available from any connected MCP server. The CLI will operate without tool functionality.'
);
}
const model = genAI.getGenerativeModel({
model: MODEL_NAME,
tools: hasDeclarableTools ? allToolsForModel : undefined,
});
console.log('\nHades CLI Started!');
console.log("Type your queries or 'quit' to exit.");
const chat = model.startChat();
while (true) {
if (isCleaningUp) {
// Check if cleanup has been initiated by a signal
break;
}
let message = '';
try {
message = await rl.question('\nQuery: ');
} catch (err: any) {
// This catch handles errors from rl.question(), e.g., if rl is closed during await
if (isCleaningUp) {
// If cleanup is in progress (e.g., due to SIGINT), this is expected
// console.log('Readline question interrupted by shutdown.');
break; // Exit loop to allow cleanup to proceed
}
// If not cleaning up, this is an unexpected error with readline
console.error('Error reading input:', err.message);
await performCleanupAndExit('readline_error', 1);
return; // Should be unreachable
}
if (message.toLowerCase() === 'quit') {
break; // Normal exit from loop, finally block will handle cleanup
}
const result = await chat.sendMessage(message);
const response = result.response;
if (
response.promptFeedback?.blockReason ||
(response.candidates &&
response.candidates[0]?.finishReason !== FinishReason.STOP &&
String(response.candidates[0]?.finishReason) !== 'TOOL_CALLS')
) {
console.log(
'Response was blocked or did not finish as expected. Reason:',
response.promptFeedback?.blockReason || response.candidates?.[0]?.finishReason
);
continue;
}
const functionCalls = response.functionCalls();
if (functionCalls && functionCalls.length > 0) {
console.log(`[Calling tools: ${functionCalls.map((fc) => fc.name).join(', ')}]`);
const toolResultsPromises = functionCalls.map(async (functionCall: FunctionCall) => {
const modelFacingToolName = functionCall.name;
const serverNamePrefix = modelFacingToolName.substring(
0,
modelFacingToolName.indexOf('_')
);
const clientForTool = mcpClients.find((c) => c.getServerName() === serverNamePrefix);
if (!clientForTool) {
console.error(
`Could not find a client for server prefix "${serverNamePrefix}" from tool "${modelFacingToolName}"`
);
return {
functionResponse: {
name: modelFacingToolName,
response: {
name: modelFacingToolName,
content: JSON.stringify({
error: `Client not found for server prefix ${serverNamePrefix}`,
}),
},
},
};
}
const toolCallResult = await clientForTool.callTool(functionCall);
if (toolCallResult && toolCallResult.error) {
console.error(
`Error from tool ${modelFacingToolName} on server ${clientForTool.getServerName()}:`,
toolCallResult.error
);
return {
functionResponse: {
name: modelFacingToolName,
response: {
name: modelFacingToolName,
content: JSON.stringify({
error: toolCallResult.error.message || 'Tool execution failed',
}),
},
},
};
}
return {
functionResponse: {
name: modelFacingToolName,
response: {
name: modelFacingToolName,
content:
toolCallResult && toolCallResult.content !== undefined
? toolCallResult.content
: JSON.stringify({}),
},
},
};
});
const toolResponses = await Promise.all(toolResultsPromises);
const finalResult = await chat.sendMessage(toolResponses as Part[]);
console.log('\n' + finalResult.response.text());
} else if (response.text) {
console.log('\n' + response.text());
} else {
console.log(
'No text response and no tool calls. Raw response:',
JSON.stringify(response, null, 2)
);
}
} // End of while loop
} catch (e) {
console.error(
'An unexpected error occurred in main execution: ',
e instanceof Error ? e.message : String(e),
e
);
await performCleanupAndExit('main_catch_block', 1); // Ensures cleanup and exit
return; // Should be unreachable
} finally {
// This finally block handles the 'quit' case or other normal loop terminations.
// If a signal or an error already triggered cleanup and exit, isCleaningUp will be true.
if (!isCleaningUp) {
await performCleanupAndExit('main_finally_normal_exit', 0);
}
}
}
main().catch((e) => {
// This is a top-level catch for unhandled promise rejections from main() itself.
console.error('Critical unhandled error launching main:', e);
if (!isCleaningUp) {
performCleanupAndExit('toplevel_main_catch', 1);
}
});