nREPL MCP Server
by JohanCodinha
import * as net from 'net';
import { BencodeDecoder, BencodeEncoder, BencodeValue } from './bencode.js';
export interface NReplMessage {
op: string;
id?: string;
session?: string;
code?: string;
[key: string]: BencodeValue | undefined;
}
export interface NReplResponse {
id: string;
session?: string;
value?: string;
'new-session'?: string;
status?: string[];
ex?: string;
'root-ex'?: string;
out?: string;
err?: string;
}
export class NReplClient {
private socket: net.Socket;
private buffer = '';
private messageCallbacks = new Map<
string,
(response: NReplResponse) => void
>();
public sessionId: string | null = null;
public lastError: string | null = null;
private port: number;
private connected: boolean = false;
constructor(port: number) {
this.port = port;
this.socket = new net.Socket();
this.setupSocketHandlers();
this.connect();
}
private setupSocketHandlers() {
this.socket.on('data', (data) => this.handleData(data));
this.socket.on('error', (error) => {
console.error('Socket error:', error);
this.connected = false;
this.lastError = error.message;
});
this.socket.on('close', () => {
console.error('Socket closed');
this.connected = false;
if (!this.lastError) {
this.lastError = 'Connection closed';
}
});
}
private connect() {
this.socket.connect(this.port, '127.0.0.1', () => {
console.error('Connected to nREPL server');
this.connected = true;
});
}
private async ensureConnected() {
if (!this.connected) {
this.socket.destroy();
this.socket = new net.Socket();
this.setupSocketHandlers();
this.connect();
// Wait for connection
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
const error = new Error('Connection timeout');
this.lastError = error.message;
reject(error);
}, 5000);
this.socket.once('connect', () => {
clearTimeout(timeout);
resolve();
});
});
// Re-clone session after reconnect
await this.clone();
}
}
private handleData(data: Buffer) {
// Explicitly use UTF-8 encoding for all string operations
this.buffer += data.toString('utf8');
console.error('Received data:', data.toString('utf8'));
// Try to process complete messages from the buffer
let processed = 0;
while (processed < this.buffer.length) {
try {
const decoder = new BencodeDecoder(this.buffer.slice(processed));
const response = decoder.decode() as unknown as NReplResponse;
console.error('Decoded response:', response);
if (response.id) {
const callback = this.messageCallbacks.get(response.id);
if (callback) {
callback(response);
if (response.status?.includes('done')) {
this.messageCallbacks.delete(response.id);
}
}
}
// Update processed count based on what was actually decoded
processed += decoder.getProcessedLength();
} catch (error) {
// If we can't decode, assume we need more data
break;
}
}
// Remove processed data from buffer
if (processed > 0) {
this.buffer = this.buffer.slice(processed);
}
}
private send(message: NReplMessage): Promise<NReplResponse[]> {
return new Promise((resolve, reject) => {
const responses: NReplResponse[] = [];
const id = Math.random().toString(36).slice(2);
console.error('Sending message:', message);
const timeout = setTimeout(() => {
this.messageCallbacks.delete(id);
const error = new Error(`nREPL request timed out. Message: ${JSON.stringify(message)}`);
this.lastError = error.message;
reject(error);
}, 30000); // Increase timeout to 30 seconds
this.messageCallbacks.set(id, (response) => {
responses.push(response);
if (response.status?.includes('done')) {
clearTimeout(timeout);
this.messageCallbacks.delete(id);
resolve(responses);
}
});
try {
const encoded = BencodeEncoder.encode({ ...message, id });
console.error('Encoded message:', encoded);
this.socket.write(encoded);
} catch (error) {
clearTimeout(timeout);
this.messageCallbacks.delete(id);
reject(error);
}
});
}
async clone(): Promise<string> {
const responses = await this.send({ op: 'clone' });
const newSession = responses[0]['new-session'];
if (!newSession) {
throw new Error('Failed to create new session');
}
this.sessionId = newSession;
return newSession;
}
// Helper function to strip ANSI color codes from strings
private stripAnsiColorCodes(str: string): string {
// This regex matches ANSI escape sequences used for terminal colors
return str.replace(/\x1B\[\d+m/g, '');
}
async eval(code: string): Promise<string> {
await this.ensureConnected();
if (!this.sessionId) {
throw new Error('No active session');
}
const responses = await this.send({
op: 'eval',
code,
session: this.sessionId,
});
// Collect any errors
const errors = responses
.map((r) => r.ex || r['root-ex'] || r.err)
.filter(Boolean)
.map(err => this.stripAnsiColorCodes(err as string));
if (errors.length > 0) {
const errorMsg = errors.join('\n');
this.lastError = errorMsg;
throw new Error(errorMsg);
}
// Collect any output and strip ANSI color codes
const output = responses
.map((r) => {
const text = r.value || r.out;
return text ? this.stripAnsiColorCodes(text) : null;
})
.filter(Boolean);
return output.join('\n');
}
async close() {
return new Promise<void>((resolve) => {
this.socket.end(() => resolve());
});
}
}