Bash MCP (Master Control Program)
by yannbam
Verified
import * as pty from 'node-pty';
import { v4 as uuidv4 } from 'uuid';
import { MCPConfig, Session, ExecutionResult } from '../types';
import { logger } from '../utils/logger';
import { isDirectoryAllowed } from '../utils/validator';
export class SessionManager {
private sessions: Map<string, Session> = new Map();
private config: MCPConfig;
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(config: MCPConfig) {
this.config = config;
this.startCleanupInterval();
}
/**
* Create a new session with a PTY process
*/
public createSession(cwd: string): Session | null {
// Validate the directory
if (!isDirectoryAllowed(cwd, this.config)) {
logger.error(`Cannot create session: directory ${cwd} is not allowed`);
return null;
}
// Check if we've reached the maximum number of sessions
if (this.sessions.size >= this.config.session.maxActiveSessions) {
logger.error('Cannot create session: maximum number of sessions reached');
return null;
}
try {
// Create a unique ID for the session
const sessionId = uuidv4();
// Create a PTY process
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
// Convert process.env to the required format (string values only, no undefined)
const envVars: { [key: string]: string } = {};
Object.entries(process.env).forEach(([key, value]) => {
if (value !== undefined) {
envVars[key] = value;
}
});
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd,
env: envVars,
});
// Create the session object
const session: Session = {
id: sessionId,
createdAt: new Date(),
lastActivity: new Date(),
process: ptyProcess,
cwd,
isInteractive: true,
};
// Add the session to our map
this.sessions.set(sessionId, session);
logger.info(`Created new session: ${sessionId}`);
return session;
} catch (error) {
logger.error(
`Failed to create session: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
/**
* Get a session by ID
*/
public getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
/**
* Execute a command in an existing session
*/
public executeInSession(sessionId: string, command: string): Promise<ExecutionResult> {
return new Promise((resolve) => {
const session = this.sessions.get(sessionId);
if (!session) {
resolve({
success: false,
output: '',
error: `Session ${sessionId} not found`,
command,
});
return;
}
// Update last activity
session.lastActivity = new Date();
// Set up output collection
let output = '';
// Add data listener and get the disposable
const dataDisposable = session.process.onData((data: string) => {
output += data;
});
// Write the command to the PTY
session.process.write(`${command}\n`);
// For simplicity, we'll just collect output for a short time and then resolve
// In a real implementation, you'd need a more sophisticated approach to detect when
// the command has completed or is waiting for input
setTimeout(() => {
// Dispose the data listener
dataDisposable.dispose();
resolve({
success: true,
output,
sessionId,
command,
isInteractive: true,
waitingForInput: this.isWaitingForInput(output), // This would need a proper implementation
});
}, 1000); // This timeout would need adjustment or a better approach
});
}
/**
* Send input to an interactive session
*/
public sendInput(sessionId: string, input: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
logger.error(`Cannot send input: Session ${sessionId} not found`);
return false;
}
// Update last activity
session.lastActivity = new Date();
// Write the input to the PTY
session.process.write(`${input}\n`);
logger.debug(`Sent input to session ${sessionId}`);
return true;
}
/**
* Close a session
*/
public closeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
logger.warn(`Cannot close session: Session ${sessionId} not found`);
return false;
}
try {
// Kill the PTY process
session.process.kill();
// Remove the session from our map
this.sessions.delete(sessionId);
logger.info(`Closed session: ${sessionId}`);
return true;
} catch (error) {
logger.error(
`Error closing session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
return false;
}
}
/**
* List all active sessions
*/
public listSessions(): { id: string; createdAt: Date; lastActivity: Date; cwd: string }[] {
return Array.from(this.sessions.values()).map((session) => ({
id: session.id,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
cwd: session.cwd,
}));
}
/**
* Start the cleanup interval to remove expired sessions
*/
private startCleanupInterval(): void {
// Clear any existing interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
// Set up a new interval
this.cleanupInterval = setInterval(() => {
this.cleanupExpiredSessions();
}, 60000); // Check every minute
}
/**
* Clean up expired sessions
*/
private cleanupExpiredSessions(): void {
const now = new Date();
const sessionTimeout = this.config.session.timeout * 1000; // Convert to milliseconds
for (const [sessionId, session] of this.sessions.entries()) {
const timeSinceLastActivity = now.getTime() - session.lastActivity.getTime();
if (timeSinceLastActivity > sessionTimeout) {
logger.info(`Session ${sessionId} has expired and will be closed`);
this.closeSession(sessionId);
}
}
}
/**
* Simple heuristic to determine if a process is waiting for input
* This would need a more sophisticated implementation in production
*/
private isWaitingForInput(output: string): boolean {
// Look for common shell prompts
const promptPatterns = [
/[$>] *$/m, // Standard shell prompts
/Password: *$/m, // Password prompts
/\(y\/n\) *$/m, // Yes/no prompts
/Continue\? *$/m, // Continue prompts
/Press Enter/i, // Press Enter prompts
];
return promptPatterns.some((pattern) => pattern.test(output));
}
/**
* Clean up resources when shutting down
*/
public shutdown(): void {
// Clear the cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Close all sessions
for (const sessionId of this.sessions.keys()) {
this.closeSession(sessionId);
}
logger.info('Session manager shut down');
}
}