#!/usr/bin/env node
/**
* PTY MCP Server for Claude Code
*
* Provides interactive terminal (PTY) sessions for programs that require
* full terminal emulation (vim, ssh, less, top, interactive REPLs, etc.)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import pty from 'node-pty';
import { randomBytes } from 'crypto';
import { existsSync } from 'fs';
import { resolve } from 'path';
// Configuration
const MAX_SESSIONS = 10;
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const MAX_BUFFER_SIZE = 1024 * 1024; // 1MB max buffer per session
const MAX_INPUT_SIZE = 1024 * 1024; // 1MB max input per write
const DEFAULT_COLS = 120;
const DEFAULT_ROWS = 30;
// Validate configuration
if (MAX_SESSIONS <= 0) throw new Error('MAX_SESSIONS must be positive');
if (SESSION_TIMEOUT_MS <= 0) throw new Error('SESSION_TIMEOUT_MS must be positive');
// Session storage
const sessions = new Map();
/**
* Generate a unique session ID (16 bytes for better security)
*/
function generateSessionId() {
return randomBytes(16).toString('hex');
}
/**
* Validate and sanitize a command path
* Only allows executables that exist and have reasonable paths
*/
function sanitizeCommand(command) {
if (!command || typeof command !== 'string') {
return null;
}
// Reject commands with shell metacharacters
if (/[;&|`$(){}[\]<>!#*?]/.test(command)) {
throw new Error('Command contains invalid characters');
}
// Reject commands starting with dash (option injection)
if (command.startsWith('-')) {
throw new Error('Command cannot start with a dash');
}
// Resolve to absolute path and verify it exists
const resolved = resolve(command);
// Allow common shell paths and commands in PATH
const allowedPaths = [
'/bin/', '/usr/bin/', '/usr/local/bin/',
'/sbin/', '/usr/sbin/',
process.env.HOME + '/.local/bin/'
];
// Check if command is in an allowed path or is a simple command name
const isAllowedPath = allowedPaths.some(p => resolved.startsWith(p));
const isSimpleCommand = !command.includes('/');
if (!isAllowedPath && !isSimpleCommand) {
throw new Error('Command path not allowed');
}
return command;
}
/**
* Sanitize command arguments
*/
function sanitizeArgs(args) {
if (!args) return [];
if (!Array.isArray(args)) {
throw new Error('args must be an array');
}
return args.map((arg, i) => {
if (typeof arg !== 'string') {
throw new Error(`Argument ${i} must be a string`);
}
// Limit individual argument length
if (arg.length > 10000) {
throw new Error(`Argument ${i} too long`);
}
return arg;
});
}
/**
* Sanitize environment variables
* Only allows alphanumeric keys with underscores/dots/dashes
*/
function sanitizeEnv(env) {
if (!env || typeof env !== 'object') return {};
const sanitized = {};
for (const [key, value] of Object.entries(env)) {
// Only allow safe key names
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(key)) {
continue; // Skip invalid keys silently
}
// Only allow string values
if (typeof value !== 'string') {
continue;
}
// Limit value length
if (value.length > 10000) {
continue;
}
sanitized[key] = value;
}
return sanitized;
}
/**
* Sanitize working directory path
*/
function sanitizeCwd(cwd) {
if (!cwd || typeof cwd !== 'string') {
return process.cwd();
}
// Resolve to absolute path
const resolved = resolve(cwd);
// Verify directory exists
if (!existsSync(resolved)) {
throw new Error('Working directory does not exist');
}
return resolved;
}
/**
* Process escape sequences in input with validation
*/
function processEscapeSequences(input) {
if (typeof input !== 'string') {
throw new Error('Input must be a string');
}
if (input.length > MAX_INPUT_SIZE) {
throw new Error('Input too large');
}
let processed = input;
processed = processed.replace(/\\r/g, '\r');
processed = processed.replace(/\\n/g, '\n');
processed = processed.replace(/\\t/g, '\t');
processed = processed.replace(/\\\\/g, '\\');
// Safe hex processing with validation
processed = processed.replace(/\\x([0-9a-fA-F]{2})/g, (match, hex) => {
const code = parseInt(hex, 16);
if (isNaN(code) || code < 0 || code > 255) {
return match; // Return original if invalid
}
return String.fromCharCode(code);
});
// Safe unicode processing with validation
processed = processed.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
const code = parseInt(hex, 16);
if (isNaN(code) || code < 0 || code > 0xFFFF) {
return match; // Return original if invalid
}
return String.fromCharCode(code);
});
return processed;
}
/**
* Create a new PTY session
*/
function createSession(command, args, options) {
if (sessions.size >= MAX_SESSIONS) {
throw new Error(`Maximum session limit (${MAX_SESSIONS}) reached. Kill existing sessions first.`);
}
const sessionId = generateSessionId();
const sanitizedCommand = sanitizeCommand(command);
const shell = sanitizedCommand || process.env.SHELL || '/bin/bash';
const shellArgs = sanitizeArgs(args);
const sanitizedEnv = sanitizeEnv(options.env);
const cwd = sanitizeCwd(options.cwd);
// Validate cols/rows
const cols = Math.min(Math.max(parseInt(options.cols) || DEFAULT_COLS, 1), 500);
const rows = Math.min(Math.max(parseInt(options.rows) || DEFAULT_ROWS, 1), 200);
const ptyProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
cols,
rows,
cwd,
env: { ...process.env, ...sanitizedEnv },
});
const session = {
id: sessionId,
pty: ptyProcess,
buffer: '',
exited: false,
exitCode: null,
signal: null,
command: shell,
args: shellArgs,
cols,
rows,
createdAt: new Date(),
lastActivity: new Date(),
};
// Buffer output from PTY
ptyProcess.onData((data) => {
session.lastActivity = new Date();
session.buffer += data;
// Truncate buffer if too large (keep the end)
if (session.buffer.length > MAX_BUFFER_SIZE) {
session.buffer = session.buffer.slice(-MAX_BUFFER_SIZE);
}
});
// Handle process exit
ptyProcess.onExit(({ exitCode, signal }) => {
session.exited = true;
session.exitCode = exitCode;
session.signal = signal;
});
sessions.set(sessionId, session);
return session;
}
/**
* Get a session by ID
*/
function getSession(sessionId) {
if (typeof sessionId !== 'string' || sessionId.length !== 32) {
throw new Error('Invalid session ID format');
}
const session = sessions.get(sessionId);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
return session;
}
/**
* Clean up timed-out sessions
*/
function cleanupTimedOutSessions() {
const now = Date.now();
const sessionsToDelete = [];
for (const [id, session] of sessions) {
if (now - session.lastActivity.getTime() > SESSION_TIMEOUT_MS) {
try {
if (!session.exited) {
session.pty.kill();
}
} catch (e) {
// Ignore kill errors
}
sessionsToDelete.push(id);
}
}
// Delete sessions after iteration to avoid modification during iteration
for (const id of sessionsToDelete) {
sessions.delete(id);
}
}
// Run cleanup every 5 minutes
const cleanupInterval = setInterval(cleanupTimedOutSessions, 5 * 60 * 1000);
// Tool definitions
const tools = [
{
name: 'pty_spawn',
description: 'Spawn a new interactive PTY session. Returns session_id to use with other pty_ tools.',
inputSchema: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'Command to run (default: user shell)'
},
args: {
type: 'array',
items: { type: 'string' },
description: 'Command arguments'
},
cwd: {
type: 'string',
description: 'Working directory'
},
cols: {
type: 'number',
description: `Terminal columns (default: ${DEFAULT_COLS}, max: 500)`
},
rows: {
type: 'number',
description: `Terminal rows (default: ${DEFAULT_ROWS}, max: 200)`
},
env: {
type: 'object',
description: 'Additional environment variables'
}
}
}
},
{
name: 'pty_write',
description: 'Write input to a PTY session. Use \\r for Enter, \\x03 for Ctrl-C, \\x04 for Ctrl-D, \\x1b for Escape.',
inputSchema: {
type: 'object',
properties: {
session_id: {
type: 'string',
description: 'Session ID from pty_spawn'
},
input: {
type: 'string',
description: 'Input to send (use \\r for Enter, \\x03 for Ctrl-C, \\x04 for Ctrl-D)'
}
},
required: ['session_id', 'input']
}
},
{
name: 'pty_read',
description: 'Read buffered output from a PTY session. Returns accumulated output since last read.',
inputSchema: {
type: 'object',
properties: {
session_id: {
type: 'string',
description: 'Session ID from pty_spawn'
},
timeout_ms: {
type: 'number',
description: 'Wait for output up to this many ms (default: 100, max: 5000)'
},
clear_buffer: {
type: 'boolean',
description: 'Clear buffer after reading (default: true)'
}
},
required: ['session_id']
}
},
{
name: 'pty_resize',
description: 'Resize a PTY terminal. Useful when running full-screen apps.',
inputSchema: {
type: 'object',
properties: {
session_id: {
type: 'string',
description: 'Session ID from pty_spawn'
},
cols: {
type: 'number',
description: 'New column count (max: 500)'
},
rows: {
type: 'number',
description: 'New row count (max: 200)'
}
},
required: ['session_id', 'cols', 'rows']
}
},
{
name: 'pty_kill',
description: 'Kill a PTY session and its process.',
inputSchema: {
type: 'object',
properties: {
session_id: {
type: 'string',
description: 'Session ID from pty_spawn'
},
signal: {
type: 'string',
description: 'Signal to send: SIGHUP, SIGTERM, SIGKILL (default: SIGHUP)'
}
},
required: ['session_id']
}
},
{
name: 'pty_list',
description: 'List all active PTY sessions.',
inputSchema: {
type: 'object',
properties: {}
}
}
];
// Tool handlers
const toolHandlers = {
pty_spawn: async (args) => {
const session = createSession(args.command, args.args, {
cwd: args.cwd,
cols: args.cols,
rows: args.rows,
env: args.env
});
// Wait a moment for initial output (prompt, etc.)
await new Promise(resolve => setTimeout(resolve, 100));
return {
session_id: session.id,
pid: session.pty.pid,
command: session.command,
cols: session.cols,
rows: session.rows
};
},
pty_write: async (args) => {
const session = getSession(args.session_id);
if (session.exited) {
return {
error: 'Session has exited',
exit_code: session.exitCode,
signal: session.signal
};
}
// Process escape sequences with validation
const input = processEscapeSequences(args.input);
session.pty.write(input);
session.lastActivity = new Date();
return { written: input.length };
},
pty_read: async (args) => {
const session = getSession(args.session_id);
const timeoutMs = Math.min(Math.max(parseInt(args.timeout_ms) || 100, 0), 5000);
const clearBuffer = args.clear_buffer !== false;
// Wait for output if buffer is empty and session is still running
if (session.buffer.length === 0 && !session.exited) {
await new Promise(resolve => setTimeout(resolve, timeoutMs));
}
const output = session.buffer;
if (clearBuffer) {
session.buffer = '';
}
return {
output,
exited: session.exited,
exit_code: session.exitCode,
signal: session.signal
};
},
pty_resize: async (args) => {
const session = getSession(args.session_id);
if (session.exited) {
return { error: 'Session has exited' };
}
// Validate and clamp cols/rows
const cols = Math.min(Math.max(parseInt(args.cols) || DEFAULT_COLS, 1), 500);
const rows = Math.min(Math.max(parseInt(args.rows) || DEFAULT_ROWS, 1), 200);
session.pty.resize(cols, rows);
session.cols = cols;
session.rows = rows;
return { cols, rows };
},
pty_kill: async (args) => {
const session = getSession(args.session_id);
if (!session.exited) {
// Validate signal
const allowedSignals = ['SIGHUP', 'SIGTERM', 'SIGKILL', 'SIGINT'];
const signal = allowedSignals.includes(args.signal) ? args.signal : 'SIGHUP';
session.pty.kill(signal);
// Wait briefly for exit
await new Promise(resolve => setTimeout(resolve, 100));
}
sessions.delete(args.session_id);
return {
killed: true,
exit_code: session.exitCode,
signal: session.signal
};
},
pty_list: async () => {
const list = [];
for (const [id, session] of sessions) {
list.push({
session_id: id,
pid: session.pty.pid,
command: session.command,
args: session.args,
cols: session.cols,
rows: session.rows,
exited: session.exited,
exit_code: session.exitCode,
created_at: session.createdAt.toISOString(),
last_activity: session.lastActivity.toISOString()
});
}
return { sessions: list };
}
};
// Create and configure MCP server
const server = new Server(
{
name: 'pty-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const handler = toolHandlers[name];
if (!handler) {
throw new Error(`Unknown tool: ${name}`);
}
try {
const result = await handler(args || {});
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: JSON.stringify({ error: error.message }, null, 2)
}
],
isError: true
};
}
});
// Cleanup on exit
process.on('SIGINT', () => {
clearInterval(cleanupInterval);
for (const [, session] of sessions) {
try {
if (!session.exited) {
session.pty.kill();
}
} catch (e) {
// Ignore
}
}
process.exit(0);
});
process.on('SIGTERM', () => {
clearInterval(cleanupInterval);
for (const [, session] of sessions) {
try {
if (!session.exited) {
session.pty.kill();
}
} catch (e) {
// Ignore
}
}
process.exit(0);
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('PTY MCP server running');
}
main().catch((error) => {
console.error('Failed to start PTY MCP server:', error);
process.exit(1);
});