mcpretentious
Version:
MCPretentious - Universal Terminal MCP. High-performance terminal automation for iTerm2 (WebSocket) and tmux (control mode). Cross-platform support with cursor position, colors, and layered screenshots.
710 lines (623 loc) • 26.6 kB
JavaScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { parseArgs } from "util";
import { backendManager } from "./lib/backend-manager.js";
import { TerminalBackend } from "./lib/terminal-backend.js";
import { TmuxBackend } from "./lib/tmux-backend.js";
import { getCurrentFocus, restoreFocus } from "./lib/focus-manager.js";
import { ITERM_DEFAULTS } from "./lib/constants.js";
import {
successResponse,
errorResponse,
safeExecute
} from "./lib/terminal-utils.js";
// No longer need session mappings - using UUID directly as terminal ID
// Terminal key mappings
const TERMINAL_KEY_MAP = {
'tab': '\t',
'shift-tab': '\x1b[Z',
'enter': '\r',
'return': '\r',
'escape': '\x1b',
'esc': '\x1b',
'backspace': '\x08',
'delete': '\x7f',
'up': '\x1b[A',
'down': '\x1b[B',
'right': '\x1b[C',
'left': '\x1b[D',
'home': '\x1b[H',
'end': '\x1b[F',
'pageup': '\x1b[5~',
'pagedown': '\x1b[6~',
// Ctrl keys
...Object.fromEntries(
Array.from({ length: 26 }, (_, i) =>
[`ctrl-${String.fromCharCode(97 + i)}`, String.fromCharCode(i + 1)]
)
),
// Function keys
'f1': '\x1bOP',
'f2': '\x1bOQ',
'f3': '\x1bOR',
'f4': '\x1bOS',
'f5': '\x1b[15~',
'f6': '\x1b[17~',
'f7': '\x1b[18~',
'f8': '\x1b[19~',
'f9': '\x1b[20~',
'f10': '\x1b[21~',
'f11': '\x1b[23~',
'f12': '\x1b[24~',
// Alt keys (ESC followed by character)
...Object.fromEntries(
Array.from({ length: 26 }, (_, i) =>
[`alt-${String.fromCharCode(97 + i)}`, `\x1b${String.fromCharCode(97 + i)}`]
)
),
// Alt keys (uppercase)
...Object.fromEntries(
Array.from({ length: 26 }, (_, i) =>
[`alt-shift-${String.fromCharCode(97 + i)}`, `\x1b${String.fromCharCode(65 + i)}`]
)
),
// Alt with numbers
...Object.fromEntries(
Array.from({ length: 10 }, (_, i) =>
[`alt-${i}`, `\x1b${i}`]
)
),
// Alt with special characters
'alt-tab': '\x1b\t',
'alt-enter': '\x1b\r',
'alt-space': '\x1b ',
'alt-backspace': '\x1b\x7f',
'alt-left': '\x1b[1;3D',
'alt-right': '\x1b[1;3C',
'alt-up': '\x1b[1;3A',
'alt-down': '\x1b[1;3B',
'alt-home': '\x1b[1;3H',
'alt-end': '\x1b[1;3F',
'alt-pageup': '\x1b[5;3~',
'alt-pagedown': '\x1b[6;3~',
// Alt with function keys
'alt-f1': '\x1b[1;3P',
'alt-f2': '\x1b[1;3Q',
'alt-f3': '\x1b[1;3R',
'alt-f4': '\x1b[1;3S',
'alt-f5': '\x1b[15;3~',
'alt-f6': '\x1b[17;3~',
'alt-f7': '\x1b[18;3~',
'alt-f8': '\x1b[19;3~',
'alt-f9': '\x1b[20;3~',
'alt-f10': '\x1b[21;3~',
'alt-f11': '\x1b[23;3~',
'alt-f12': '\x1b[24;3~',
};
const SUPPORTED_KEYS = Object.keys(TERMINAL_KEY_MAP).join(', ');
// Create server instance
const server = new McpServer({
name: "mcpretentious",
version: "1.2.0",
});
// Helper function to handle terminal operations with session
async function withTerminalSession(terminalId, operation) {
// Check if any backends are available first
if (!backendManager.hasBackends()) {
return errorResponse(backendManager.getNoBackendError());
}
if (!terminalId) {
return errorResponse("Terminal ID is required");
}
// Check if session exists
const sessionExists = await backendManager.sessionExists(terminalId);
if (!sessionExists) {
return errorResponse(`Terminal not found: ${terminalId}`);
}
return operation(terminalId);
}
// === Tool Handlers ===
// Function to register tools after backend initialization
function registerTools() {
// Build schema for mcpretentious-open based on current mode
const openToolSchema = {
columns: z.number().min(ITERM_DEFAULTS.MIN_COLUMNS).max(ITERM_DEFAULTS.MAX_COLUMNS).optional().describe(`Initial width in columns (default: ${ITERM_DEFAULTS.COLUMNS})`),
rows: z.number().min(ITERM_DEFAULTS.MIN_ROWS).max(ITERM_DEFAULTS.MAX_ROWS).optional().describe(`Initial height in rows (default: ${ITERM_DEFAULTS.ROWS})`)
};
// Only add backend parameter if in API mode with multiple backends available
if (backendManager.isApiMode()) {
const availableBackends = backendManager.getAvailableBackends();
if (availableBackends.length > 1) {
// Only show backend option if there's actually a choice
openToolSchema.backend = z.enum(availableBackends)
.optional()
.describe(`Backend to use for this session. Options: ${availableBackends.map(b => `'${b}'`).join(', ')}. Default: ${availableBackends[0]}`);
}
}
server.tool(
"mcpretentious-open",
"Opens a new terminal window and creates a tracked terminal session. Returns a terminal ID that can be used with other commands.",
openToolSchema,
async ({ columns, rows, backend }) => {
return safeExecute(async () => {
// Check if any backends are available
if (!backendManager.hasBackends()) {
throw new Error(backendManager.getNoBackendError());
}
// Store focus before creating terminal (iTerm2 only)
const originalFocus = await getCurrentFocus();
// Create terminal using backend manager
const sessionOptions = { columns, rows };
// Add backend selection in API mode
if (backendManager.isApiMode()) {
if (backend) {
// Validate backend is available
const available = backendManager.getAvailableBackends();
if (!available.includes(backend)) {
throw new Error(`Backend '${backend}' is not available. Available backends: ${available.join(', ')}`);
}
sessionOptions.backend = backend;
} else {
// Use first available backend if not specified
const available = backendManager.getAvailableBackends();
if (available.length > 0) {
sessionOptions.backend = available[0];
}
}
} else if (backend) {
// Backend parameter provided but not in API mode
throw new Error('Backend selection is only available when server is started with --backend=api');
}
const terminalId = await backendManager.createSession(sessionOptions);
if (!terminalId) {
throw new Error("Failed to create new terminal session");
}
// Restore focus (iTerm2 only)
if (originalFocus) {
setTimeout(() => restoreFocus(originalFocus), 100);
}
const terminalBackend = backendManager.getBackendForTerminal(terminalId);
const backendName = terminalBackend.getName();
const sizeInfo = (columns || rows)
? ` (${columns || ITERM_DEFAULTS.COLUMNS}×${rows || ITERM_DEFAULTS.ROWS})`
: '';
// Include available backends info in API mode
if (backendManager.isApiMode()) {
const availableList = backendManager.getAvailableBackends().join(', ');
return successResponse(`${backendName} terminal opened with ID: ${terminalId}${sizeInfo}\nAvailable backends: ${availableList}`);
}
return successResponse(`${backendName} terminal opened with ID: ${terminalId}${sizeInfo}`);
}, "Failed to open terminal");
}
);
server.tool(
"mcpretentious-read",
"Reads text output from a terminal session. Returns the current screen contents. Use mcpretentious-screenshot for rich terminal info including colors, cursor position, and styles.",
{
terminalId: z.string().describe("The terminal ID to read from"),
lines: z.number().optional().describe("Number of lines to read from the bottom"),
},
async ({ terminalId, lines }) => {
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const screen = await backendManager.getScreenContents(sessionId, false);
let content = screen?.text || "";
if (lines && content) {
const allLines = content.split('\n');
let lastNonEmpty = allLines.length - 1;
while (lastNonEmpty >= 0 && allLines[lastNonEmpty].trim() === '') {
lastNonEmpty--;
}
const startIdx = Math.max(0, lastNonEmpty - lines + 1);
content = allLines.slice(startIdx, lastNonEmpty + 1).join('\n');
}
return successResponse(content || "No output available");
}, "Failed to read output");
});
}
);
server.tool(
"mcpretentious-screenshot",
"Takes a token-optimized screenshot of the terminal screen using a layered format that reduces token usage by 85-98%. Returns only the data layers you need (text, cursor, colors, styles). Supports viewport limiting to show just a region or area around cursor. Essential for inspecting TUI applications without hitting token limits.",
{
terminalId: z.string().describe("The terminal ID to read from"),
layers: z.array(z.enum([
"text", "cursor", "fgColors", "bgColors",
"styles", "bold", "italic", "underline"
])).optional().default(["text", "cursor"])
.describe("Data layers to include. 'text' for content, 'cursor' for position, 'styles' for combined formatting, individual style layers, or color layers with palette. Default: ['text', 'cursor'] for minimal token usage"),
region: z.object({
left: z.number().describe("Starting column from left (0-based)"),
top: z.number().describe("Starting row from top (0-based)"),
width: z.number().describe("Width in columns"),
height: z.number().describe("Height in rows")
}).optional().describe("Limit to specific viewport rectangle to reduce tokens"),
aroundCursor: z.number().optional()
.describe("Show N lines around cursor (e.g., 5 shows 11 lines total). Great for reducing tokens when debugging at cursor position"),
compact: z.boolean().optional().default(false)
.describe("Skip empty lines to further reduce token usage")
},
async ({ terminalId, layers = ["text", "cursor"], region, aroundCursor, compact = false }) => {
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const backend = backendManager.getBackendForTerminal(sessionId);
// Check if backend is TMux - it has dedicated screenshot method
if (backend instanceof TmuxBackend) {
const screenshot = await backend.getScreenshot(sessionId, {
layers, region, aroundCursor, compact
});
return successResponse(JSON.stringify(screenshot, null, 2));
}
// iTerm2 backend - use existing logic
const {
calculateViewport,
extractTextLayer,
extractColorLayers,
extractStyleLayers,
applyCompactMode
} = await import('./lib/screenshot-layers.js');
const screen = await backendManager.getScreenContents(sessionId, true);
// Normalize cursor values
const cursor = {
x: typeof screen.cursor?.x === 'object' ? (screen.cursor.x.low || screen.cursor.x) : (screen.cursor?.x || 0),
y: typeof screen.cursor?.y === 'object' ? (screen.cursor.y.low || screen.cursor.y) : (screen.cursor?.y || 0)
};
// Get terminal dimensions
// iTerm2's grid_size is typically 1 less than actual content dimensions
const gridSize = await backendManager.getProperty(sessionId, 'grid_size');
const terminal = {
width: gridSize.width + 1,
height: gridSize.height + 1
};
// Calculate viewport
const viewport = calculateViewport(terminal, cursor, { region, aroundCursor });
// Build response structure
let response = {
terminal,
viewport
};
// Add cursor info if requested
if (layers.includes("cursor")) {
response.cursor = {
left: cursor.x,
top: cursor.y,
relLeft: cursor.x - viewport.left,
relTop: cursor.y - viewport.top
};
}
// Add text layer if requested
if (layers.includes("text")) {
response.text = extractTextLayer(screen.lines, viewport, false);
}
// Add color layers if requested
const colorLayers = layers.filter(l => l === "fgColors" || l === "bgColors");
if (colorLayers.length > 0) {
const colors = extractColorLayers(screen.lines, viewport, colorLayers);
Object.assign(response, colors);
}
// Add style layers if requested
const styleLayers = layers.filter(l => ["styles", "bold", "italic", "underline"].includes(l));
if (styleLayers.length > 0) {
const styles = extractStyleLayers(screen.lines, viewport, styleLayers);
Object.assign(response, styles);
}
// Apply compact mode if requested
if (compact && response.text) {
response = applyCompactMode(response);
}
return successResponse(JSON.stringify(response, null, 2));
}, "Failed to get screen info");
});
}
);
server.tool(
"mcpretentious-close",
"Closes the terminal window associated with the specified terminal ID.",
{
terminalId: z.string().describe("The terminal ID to close"),
},
async ({ terminalId }) => {
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const success = await backendManager.closeSession(sessionId);
if (success) {
return successResponse(`Terminal ${terminalId} closed`);
}
throw new Error(`Failed to close terminal ${terminalId}`);
}, "Failed to close terminal");
});
}
);
server.tool(
"mcpretentious-list",
"Lists all currently open terminal sessions with their IDs.",
{},
async () => {
return safeExecute(async () => {
// Check if any backends are available
if (!backendManager.hasBackends()) {
throw new Error(backendManager.getNoBackendError());
}
const sessions = await backendManager.listSessions();
// Return JSON array of session objects
return successResponse(JSON.stringify(sessions));
}, "Could not list terminal sessions");
}
);
server.tool(
"mcpretentious-type",
`Send text and keystrokes to a terminal. Always pass as array.
Examples: ["ls -la"], ["cd /path", {"key": "enter"}], ["Hello", 32, "World"], [{"key": "ctrl-c"}]
Supported keys: ${SUPPORTED_KEYS}`,
{
terminalId: z.string().describe("ID of the terminal to send input to"),
input: z.array(z.union([
z.string().describe("Text to type"),
z.number().int().min(0).max(255).describe("ASCII code"),
z.object({ key: z.string() }).describe("Special key")
])).describe("Array of text / key / ascii to send"),
},
async ({ terminalId, input }) => {
// Validate input first
if (!input || input.length === 0) {
return errorResponse("Empty sequence provided");
}
// Build text to send with validation
let textToSend = "";
for (const item of input) {
if (typeof item === 'number') {
if (item < 0 || item > 255) {
return errorResponse(`Invalid ASCII code: ${item}. Must be between 0 and 255.`);
}
textToSend += String.fromCharCode(item);
} else if (typeof item === 'string') {
textToSend += item;
} else if (typeof item === 'object' && item.key) {
const keySeq = TERMINAL_KEY_MAP[item.key.toLowerCase()];
if (keySeq === undefined) {
return errorResponse(`Unknown key: ${item.key}. Available keys: ${SUPPORTED_KEYS}`);
}
textToSend += keySeq;
} else {
return errorResponse(`Invalid input type: ${typeof item}. Use string, number (0-255), or {key: 'name'}`);
}
}
// Now execute with terminal session
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const success = await backendManager.sendText(sessionId, textToSend);
if (success) {
return successResponse(`Sent sequence of ${input.length} items to ${terminalId}`);
}
throw new Error(`Failed to send sequence to ${terminalId}`);
}, "Failed to send sequence");
});
}
);
server.tool(
"mcpretentious-info",
"Gets terminal metadata including dimensions (columns × rows) and session information.",
{
terminalId: z.string().describe("The terminal ID to get info for"),
},
async ({ terminalId }) => {
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const info = await backendManager.getSessionInfo(sessionId);
const backend = backendManager.getBackendForTerminal(sessionId);
// iTerm2's grid_size is typically 1 less than actual content dimensions
// TMux already returns correct dimensions
const adjustDimensions = backend.getType() === 'iterm';
return successResponse(JSON.stringify({
terminalId,
backend: backend.getName(),
sessionId: info.sessionId,
windowId: info.windowId,
dimensions: {
columns: adjustDimensions ? info.dimensions.columns + 1 : info.dimensions.columns,
rows: adjustDimensions ? info.dimensions.rows + 1 : info.dimensions.rows
}
}, null, 2));
}, "Failed to get terminal info");
});
}
);
server.tool(
"mcpretentious-resize",
"Resizes a terminal to the specified dimensions in columns × rows.",
{
terminalId: z.string().describe("The terminal ID to resize"),
columns: z.number().min(ITERM_DEFAULTS.MIN_COLUMNS).max(ITERM_DEFAULTS.MAX_COLUMNS).describe("Number of columns (width in characters)"),
rows: z.number().min(ITERM_DEFAULTS.MIN_ROWS).max(ITERM_DEFAULTS.MAX_ROWS).describe("Number of rows (height in lines)"),
},
async ({ terminalId, columns, rows }) => {
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const success = await backendManager.setSessionSize(sessionId, columns, rows);
if (success) {
return successResponse(`Terminal ${terminalId} resized to ${columns}×${rows}`);
}
throw new Error(`Failed to resize terminal ${terminalId}`);
}, "Failed to resize terminal");
});
}
);
server.tool(
"mcpretentious-mouse",
`Send mouse events to a terminal using SGR mouse protocol.
This tool provides direct control over mouse events following the terminal's SGR (Select Graphic Rendition) mouse protocol.
EVENTS:
- 'press': Mouse button press event
- 'release': Mouse button release event
- 'drag': Mouse movement with button held down
BUTTONS (use names or button-N format):
Named buttons:
- 'left': Left mouse button
- 'middle': Middle mouse button
- 'right': Right mouse button
- 'scrollUp': Scroll wheel up
- 'scrollDown': Scroll wheel down
Direct button codes:
- 'button-0' through 'button-127': Direct SGR button codes
- Common codes: 0=left, 1=middle, 2=right, 64=scrollUp, 65=scrollDown
MODIFIERS (optional, default to false):
- shift: Hold shift key during event
- alt: Hold alt/option key during event
- ctrl: Hold control key during event
COORDINATES:
- x and y are 0-based character positions (column, row)
- The protocol will convert to 1-based coordinates internally
EXAMPLES:
- Left click at (10,5): event='press', button='left', x=10, y=5, then event='release' with same coordinates
- Drag from (5,5) to (15,10): event='press' at (5,5), event='drag' at (15,10), event='release' at (15,10)
- Scroll up at (20,8): event='press', button='scrollUp', x=20, y=8 (no release needed for scroll)`,
{
terminalId: z.string().describe("The terminal ID to send mouse events to"),
event: z.enum(['press', 'release', 'drag']).describe("Mouse event type: 'press' for button down, 'release' for button up, 'drag' for movement with button held"),
x: z.number().min(0).describe("X coordinate (column position, 0-based)"),
y: z.number().min(0).describe("Y coordinate (row position, 0-based)"),
button: z.union([
z.enum(['left', 'middle', 'right', 'scrollUp', 'scrollDown']),
z.string().regex(/^button-\d{1,3}$/)
]).describe("Mouse button: named ('left', 'middle', 'right', 'scrollUp', 'scrollDown') or direct code ('button-0' through 'button-127')"),
shift: z.boolean().optional().default(false).describe("Shift key modifier"),
alt: z.boolean().optional().default(false).describe("Alt/Option key modifier"),
ctrl: z.boolean().optional().default(false).describe("Control key modifier")
},
async ({ terminalId, event, x, y, button, shift = false, alt = false, ctrl = false }) => {
return withTerminalSession(terminalId, async (sessionId) => {
return safeExecute(async () => {
const backend = backendManager.getBackendForTerminal(sessionId);
// Check if backend supports mouse operations
if (backend.getType() !== 'tmux' && backend.getType() !== 'iterm') {
throw new Error(`Mouse support is currently only available for TMux and iTerm2 backends. Current backend: ${backend.getName()}`);
}
// Send the mouse event with modifiers
await backend.sendMouseEvent(sessionId, event, x, y, button, { shift, alt, ctrl });
// Format response message
const buttonName = button.startsWith('button-') ? `button ${button.slice(7)}` : button;
const modifierStr = [
shift && 'Shift',
alt && 'Alt',
ctrl && 'Ctrl'
].filter(Boolean).join('+');
const eventDescription = event === 'drag' ? 'drag' :
event === 'press' ? 'press' : 'release';
return successResponse(
`Mouse ${eventDescription}: ${buttonName}${modifierStr ? ` with ${modifierStr}` : ''} at (${x}, ${y})`
);
}, "Failed to send mouse event");
});
}
);
}
// Parse command-line arguments
function parseCommandLineArgs() {
const options = {
backend: {
type: 'string',
short: 'b',
default: process.env.MCP_TERMINAL_BACKEND || 'auto'
},
help: {
type: 'boolean',
short: 'h',
default: false
},
verbose: {
type: 'boolean',
short: 'v',
default: process.env.VERBOSE === 'true'
}
};
try {
const { values } = parseArgs({
options,
allowPositionals: false
});
return values;
} catch (error) {
console.error(`Error: ${error.message}`);
console.error('\nUsage: mcpretentious [options]');
console.error('\nOptions:');
console.error(' -b, --backend <type> Backend to use: auto, iterm, tmux, api (default: auto)');
console.error(' -v, --verbose Enable verbose output');
console.error(' -h, --help Show this help message');
process.exit(1);
}
}
// Store backend mode globally for use in tools
let BACKEND_MODE = 'auto';
// Main entry point
async function main() {
const args = parseCommandLineArgs();
if (args.help) {
console.log('MCPretentious - Universal Terminal MCP');
console.log('\nUsage: mcpretentious [options]');
console.log('\nOptions:');
console.log(' -b, --backend <type> Backend to use: auto, iterm, tmux, api (default: auto)');
console.log(' - auto: Automatically detect best backend');
console.log(' - iterm: Use iTerm2 backend (macOS only)');
console.log(' - tmux: Use tmux backend (cross-platform)');
console.log(' - api: Let LLM choose backend per session');
console.log(' -v, --verbose Enable verbose output');
console.log(' -h, --help Show this help message');
console.log('\nEnvironment variables:');
console.log(' MCP_TERMINAL_BACKEND Default backend (overridden by --backend)');
console.log(' VERBOSE Enable verbose output (overridden by --verbose)');
process.exit(0);
}
// Validate backend option
const validBackends = ['auto', 'iterm', 'tmux', 'api'];
if (!validBackends.includes(args.backend)) {
console.error(`Error: Invalid backend '${args.backend}'. Must be one of: ${validBackends.join(', ')}`);
process.exit(1);
}
// Store backend mode
BACKEND_MODE = args.backend;
// Initialize backend manager
if (BACKEND_MODE === 'api') {
// In API mode, initialize with available backends but don't select default
await backendManager.initApiMode();
if (args.verbose) {
if (backendManager.hasBackends()) {
const available = backendManager.getAvailableBackends();
console.error(`MCPretentious: Running in API mode with backends: ${available.join(', ')}`);
} else {
console.error('MCPretentious: No backends available - tools will return errors');
}
}
} else {
// Normal mode - initialize with specific backend
await backendManager.init(BACKEND_MODE);
if (args.verbose) {
if (backendManager.hasBackends()) {
const backend = backendManager.getDefaultBackend();
console.error(`MCPretentious: Using ${backend.getName()} backend`);
} else {
console.error('MCPretentious: No backends available - tools will return errors');
}
}
}
// Warn if no backends available but continue running
if (!backendManager.hasBackends()) {
console.error('\nWarning: No terminal backend available.');
console.error('The server will continue running, but all terminal operations will fail.');
console.error('\nTo enable terminal features, please install either:');
console.error(' - iTerm2 with Python API enabled (macOS)');
console.error(' - tmux (any platform)');
}
// Register tools after backend initialization
registerTools();
const transport = new StdioServerTransport();
await server.connect(transport);
if (args.verbose) {
console.error('MCPretentious MCP Server running on stdio');
}
}
main().catch((error) => {
console.error("Fatal error:", error.message);
process.exit(1);
});