mcp-client.js•11 kB
/**
* MCP Client for Memory Hook Integration
* Provides MCP protocol communication for memory operations
*/
const { spawn } = require('child_process');
const { EventEmitter } = require('events');
const fs = require('fs');
const path = require('path');
class MCPClient extends EventEmitter {
constructor(serverCommand, options = {}) {
super();
this.serverCommand = serverCommand;
this.serverWorkingDir = options.workingDir || process.cwd();
this.connectionTimeout = options.connectionTimeout || 5000;
this.toolCallTimeout = options.toolCallTimeout || 10000;
this.serverProcess = null;
this.messageId = 0;
this.pendingRequests = new Map();
this.connected = false;
this.buffer = '';
// Load environment variables from .env file
this.loadEnvironment();
}
/**
* Load environment variables from .env file
*/
loadEnvironment() {
const envPath = path.join(this.serverWorkingDir, '.env');
try {
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
const lines = envContent.split('\n');
for (const line of lines) {
const match = line.match(/^([^#\s][^=]*?)=(.*)$/);
if (match) {
const [, key, value] = match;
if (!process.env[key]) {
process.env[key] = value.replace(/^["']|["']$/g, '');
}
}
}
}
} catch (error) {
// Ignore .env loading errors
}
}
/**
* Start MCP server and establish connection
*/
async connect() {
return new Promise((resolve, reject) => {
try {
// Start MCP server process
this.serverProcess = spawn(this.serverCommand[0], this.serverCommand.slice(1), {
cwd: this.serverWorkingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }
});
// Handle server output
this.serverProcess.stdout.on('data', (data) => {
this.buffer += data.toString();
this.processMessages();
});
this.serverProcess.stderr.on('data', (data) => {
const error = data.toString();
// Only emit critical errors, ignore warnings and debug info
if (error.includes('FATAL') || error.includes('ExceptionGroup')) {
this.emit('error', new Error(`Server error: ${error.substring(0, 200)}...`));
}
});
this.serverProcess.on('error', (error) => {
if (!this.connected) {
reject(new Error(`Server process error: ${error.message}`));
}
});
this.serverProcess.on('exit', (code) => {
this.connected = false;
if (code !== 0 && !this.connected) {
reject(new Error(`Server failed to start (exit code ${code})`));
}
});
// Initialize MCP connection
this.sendInitialize()
.then(() => {
this.connected = true;
resolve();
})
.catch(reject);
// Connection timeout
setTimeout(() => {
if (!this.connected) {
reject(new Error('Connection timeout'));
}
}, this.connectionTimeout);
} catch (error) {
reject(error);
}
});
}
/**
* Process incoming messages from server
*/
processMessages() {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const message = JSON.parse(line);
this.handleMessage(message);
} catch (error) {
// Ignore malformed messages
}
}
}
}
/**
* Handle incoming MCP messages
*/
handleMessage(message) {
if (message.id && this.pendingRequests.has(message.id)) {
const { resolve, reject } = this.pendingRequests.get(message.id);
this.pendingRequests.delete(message.id);
if (message.error) {
reject(new Error(message.error.message || 'MCP Error'));
} else {
resolve(message.result);
}
}
}
/**
* Send MCP initialize request
*/
async sendInitialize() {
const message = {
jsonrpc: '2.0',
id: ++this.messageId,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
clientInfo: {
name: 'claude-hooks-mcp-client',
version: '1.0.0'
}
}
};
return this.sendMessage(message);
}
/**
* Send message to MCP server
*/
async sendMessage(message) {
return new Promise((resolve, reject) => {
if (!this.serverProcess || this.serverProcess.exitCode !== null) {
reject(new Error('Server not running'));
return;
}
if (message.id) {
this.pendingRequests.set(message.id, { resolve, reject });
// Timeout for this specific request
setTimeout(() => {
if (this.pendingRequests.has(message.id)) {
this.pendingRequests.delete(message.id);
reject(new Error('Request timeout'));
}
}, this.toolCallTimeout);
}
try {
const messageStr = JSON.stringify(message) + '\n';
this.serverProcess.stdin.write(messageStr);
if (!message.id) {
resolve(); // No response expected
}
} catch (error) {
if (message.id && this.pendingRequests.has(message.id)) {
this.pendingRequests.delete(message.id);
}
reject(error);
}
});
}
/**
* Call a tool via MCP
*/
async callTool(toolName, args = {}) {
const message = {
jsonrpc: '2.0',
id: ++this.messageId,
method: 'tools/call',
params: {
name: toolName,
arguments: args
}
};
return this.sendMessage(message);
}
/**
* Get memory service health status
*/
async getHealthStatus() {
try {
const result = await this.callTool('check_database_health');
return {
success: true,
data: result.content ? this.parseToolResponse(result.content) : result
};
} catch (error) {
return {
success: false,
error: error.message,
fallback: true
};
}
}
/**
* Query memories using semantic search
*/
async queryMemories(query, limit = 10) {
try {
const result = await this.callTool('retrieve_memory', {
query: query,
n_results: limit
});
return this.parseToolResponse(result.content);
} catch (error) {
console.warn('[MCP Client] Memory query error:', error.message);
return [];
}
}
/**
* Query memories by time range
*/
async queryMemoriesByTime(timeQuery, limit = 10) {
try {
const result = await this.callTool('recall_memory', {
query: timeQuery,
n_results: limit
});
return this.parseToolResponse(result.content);
} catch (error) {
console.warn('[MCP Client] Time-based memory query error:', error.message);
return [];
}
}
/**
* Parse tool response content
*/
parseToolResponse(content) {
if (!content) return [];
// Handle array of content objects
if (Array.isArray(content)) {
const textContent = content.find(c => c.type === 'text')?.text || '';
return this.parseMemoryResults(textContent);
}
// Handle direct text content
if (typeof content === 'string') {
return this.parseMemoryResults(content);
}
// Handle object with text property
if (content.text) {
return this.parseMemoryResults(content.text);
}
return [];
}
/**
* Parse memory results from text response
*/
parseMemoryResults(textData) {
try {
// Handle Python dict format conversion to JSON
let cleanText = textData
.replace(/'/g, '"')
.replace(/True/g, 'true')
.replace(/False/g, 'false')
.replace(/None/g, 'null');
const parsed = JSON.parse(cleanText);
return parsed.results || parsed.memories || parsed || [];
} catch (error) {
// Try to extract JSON from text
const jsonMatch = textData.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const extracted = JSON.parse(jsonMatch[0]);
return extracted.results || extracted.memories || [extracted];
} catch {}
}
console.warn('[MCP Client] Could not parse memory results:', error.message);
return [];
}
}
/**
* Disconnect and cleanup
*/
async disconnect() {
this.connected = false;
// Clear pending requests
for (const [id, { reject }] of this.pendingRequests) {
reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
// Terminate server process
if (this.serverProcess && this.serverProcess.exitCode === null) {
this.serverProcess.kill('SIGTERM');
// Force kill if doesn't exit gracefully
setTimeout(() => {
if (this.serverProcess && this.serverProcess.exitCode === null) {
this.serverProcess.kill('SIGKILL');
}
}, 2000);
}
}
}
module.exports = { MCPClient };