bg-server-mcp-shell
Version:
MCP server for shell - PTY with live streaming for long-running processes (dev servers, watch modes, docker-compose, etc.)
458 lines (421 loc) • 12.7 kB
JavaScript
#!/usr/bin/env node
import os from "os";
import pty from "node-pty";
import { randomUUID } from "crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const sessions = new Map();
const server = new McpServer({
name: "bg-server-mcp-shell",
version: "1.0.4",
icons: [
{
kind: "url",
src: "https://raw.githubusercontent.com/bgbruno/bg-server-mcp-shell/main/cover-square.jpg",
mimeType: "image/jpeg",
sizes: ["256x256"]
}
]
});
// Helper function to spawn PTY process
function spawnPtyProcess({ cmd, args = [], cwd = process.cwd(), env = {}, cols = 120, rows = 30, shellOnWindows = false }) {
const isWin = os.platform() === "win32";
const exe = isWin && shellOnWindows ? "powershell.exe" : cmd;
const argv = isWin && shellOnWindows
? ["-NoLogo", "-Command", `${cmd} ${args.join(" ")}`]
: args;
// COLOR env: "true" = xterm-color (with ANSI), "false" or undefined = dumb (plain text)
const useColor = process.env.COLOR === "true";
const termType = useColor ? "xterm-color" : "dumb";
// Build environment variables
const spawnEnv = { ...process.env, ...env };
if (!useColor) {
// Disable colors for most CLI tools
spawnEnv.NO_COLOR = "1";
spawnEnv.FORCE_COLOR = "0";
spawnEnv.TERM = "dumb";
}
const p = pty.spawn(exe, argv, {
name: termType,
cols,
rows,
cwd,
env: spawnEnv
});
return { pty: p, exe, argv };
}
// Tool: Run command and wait for completion
server.registerTool(
"startProcessAndWait",
{
title: "Start Process And Wait",
description: "Run a command and wait for it to complete, returning the full output. Use for quick commands (npm --version, ls, git status). For long-running processes use startProcessBackground instead.",
inputSchema: {
cmd: z.string(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
cols: z.number().optional(),
rows: z.number().optional(),
shellOnWindows: z.boolean().optional(),
timeoutMs: z.number().optional().describe("Timeout in milliseconds. Default 30000 (30s)")
},
outputSchema: {
ok: z.boolean(),
sessionId: z.string().optional(),
pid: z.number().optional(),
output: z.array(z.any()).optional(),
exitCode: z.number().nullable().optional(),
exitSignal: z.number().nullable().optional(),
error: z.string().optional()
}
},
async ({ cmd, args = [], cwd = process.cwd(), env = {}, cols = 120, rows = 30, shellOnWindows = false, timeoutMs = 30000 }) => {
const { pty: p, exe, argv } = spawnPtyProcess({ cmd, args, cwd, env, cols, rows, shellOnWindows });
const sessionId = randomUUID();
const outputBuffer = [];
return new Promise((resolve) => {
let timeoutHandle;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
};
p.onData((data) => {
outputBuffer.push({ type: 'stdout', data, timestamp: new Date().toISOString() });
console.error(`[${sessionId}] ${data}`);
});
p.onExit(({ exitCode, signal }) => {
cleanup();
outputBuffer.push({
type: 'exit',
exitCode,
signal,
timestamp: new Date().toISOString()
});
console.error(`[${sessionId}] Process exited: code=${exitCode}, signal=${signal}`);
const output = {
ok: true,
sessionId,
pid: p.pid,
output: outputBuffer,
exitCode,
exitSignal: signal
};
resolve({
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
});
});
// Timeout handler
timeoutHandle = setTimeout(() => {
console.error(`[${sessionId}] Timeout after ${timeoutMs}ms, killing process`);
try { p.kill(); } catch {}
const output = {
ok: false,
sessionId,
pid: p.pid,
output: outputBuffer,
error: `Process timeout after ${timeoutMs}ms`
};
resolve({
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
});
}, timeoutMs);
});
}
);
// Tool: Start background process
server.registerTool(
"startProcessBackground",
{
title: "Start Background Process",
description: "Start a long-running process in a PTY (servers, watch modes, daemons). Returns immediately with sessionId. Use getSessionOutput to read output. For quick commands use startProcessAndWait instead.",
inputSchema: {
cmd: z.string(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
cols: z.number().optional(),
rows: z.number().optional(),
shellOnWindows: z.boolean().optional()
},
outputSchema: {
ok: z.boolean(),
sessionId: z.string().optional(),
pid: z.number().optional(),
error: z.string().optional()
}
},
async ({ cmd, args = [], cwd = process.cwd(), env = {}, cols = 120, rows = 30, shellOnWindows = false }) => {
const { pty: p, exe, argv } = spawnPtyProcess({ cmd, args, cwd, env, cols, rows, shellOnWindows });
const sessionId = randomUUID();
const outputBuffer = [];
const maxBufferSize = 10000; // Max lines to keep
sessions.set(sessionId, {
pid: p.pid,
pty: p,
cwd,
cmd: exe,
args: argv,
cols,
rows,
startedAt: new Date().toISOString(),
output: outputBuffer,
exitCode: null,
exitSignal: null,
isRunning: true
});
p.onData((data) => {
// Buffer output for reading
outputBuffer.push({ type: 'stdout', data, timestamp: new Date().toISOString() });
if (outputBuffer.length > maxBufferSize) {
outputBuffer.shift(); // Remove oldest
}
console.error(`[${sessionId}] ${data}`);
});
p.onExit(({ exitCode, signal }) => {
const session = sessions.get(sessionId);
if (session) {
session.isRunning = false;
session.exitCode = exitCode;
session.exitSignal = signal;
session.output.push({
type: 'exit',
exitCode,
signal,
timestamp: new Date().toISOString()
});
}
console.error(`[${sessionId}] Process exited: code=${exitCode}, signal=${signal}`);
});
const output = {
ok: true,
sessionId,
pid: p.pid
};
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
);
// Tool: Write input to PTY session
server.registerTool(
"writeInput",
{
title: "Write Input",
description: "Write input to a running PTY session",
inputSchema: {
sessionId: z.string(),
data: z.string()
},
outputSchema: {
ok: z.boolean(),
error: z.string().optional()
}
},
async ({ sessionId, data }) => {
const s = sessions.get(sessionId);
if (!s) {
const output = { ok: false, error: "Session not found" };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
s.pty.write(data);
const output = { ok: true };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
);
// Tool: Get session output
server.registerTool(
"getSessionOutput",
{
title: "Get Session Output",
description: "Get the buffered output from a running or completed PTY session",
inputSchema: {
sessionId: z.string(),
fromIndex: z.number().optional()
},
outputSchema: {
ok: z.boolean(),
sessionId: z.string().optional(),
isRunning: z.boolean().optional(),
exitCode: z.number().nullable().optional(),
exitSignal: z.number().nullable().optional(),
output: z.array(z.any()).optional(),
totalLines: z.number().optional(),
error: z.string().optional()
}
},
async ({ sessionId, fromIndex = 0 }) => {
const s = sessions.get(sessionId);
if (!s) {
const output = { ok: false, error: "Session not found" };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
const outputSlice = s.output.slice(fromIndex);
const output = {
ok: true,
sessionId,
isRunning: s.isRunning,
exitCode: s.exitCode,
exitSignal: s.exitSignal,
output: outputSlice,
totalLines: s.output.length
};
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
);
// Tool: List all sessions
server.registerTool(
"listSessions",
{
title: "List Sessions",
description: "List all active PTY sessions",
inputSchema: {},
outputSchema: {
ok: z.boolean(),
sessions: z.array(z.any()).optional()
}
},
async () => {
const sessionList = Array.from(sessions.entries()).map(([id, s]) => ({
sessionId: id,
pid: s.pid,
cmd: s.cmd,
args: s.args,
cwd: s.cwd,
isRunning: s.isRunning,
exitCode: s.exitCode,
startedAt: s.startedAt,
outputLines: s.output.length
}));
const output = {
ok: true,
sessions: sessionList
};
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
);
// Tool: Stop a PTY session
server.registerTool(
"stopProcess",
{
title: "Stop Process",
description: "Stop a running PTY session",
inputSchema: {
sessionId: z.string()
},
outputSchema: {
ok: z.boolean(),
killed: z.boolean().optional(),
error: z.string().optional()
}
},
async ({ sessionId }) => {
const s = sessions.get(sessionId);
if (!s) {
const output = { ok: false, error: "Session not found" };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
try {
s.pty.kill();
s.isRunning = false;
// Don't delete session immediately - keep it for output reading
// Sessions will be cleaned up on server restart or manually
const output = { ok: true, killed: true };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
} catch (e) {
const output = { ok: false, error: String(e) };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
}
);
// Tool: Clean up finished sessions
server.registerTool(
"cleanupSessions",
{
title: "Cleanup Sessions",
description: "Remove finished (non-running) sessions from memory",
inputSchema: {
sessionId: z.string().optional()
},
outputSchema: {
ok: z.boolean(),
cleaned: z.number().optional(),
error: z.string().optional()
}
},
async ({ sessionId }) => {
if (sessionId) {
// Clean specific session
const s = sessions.get(sessionId);
if (!s) {
const output = { ok: false, error: "Session not found" };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
if (s.isRunning) {
const output = { ok: false, error: "Session is still running" };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
sessions.delete(sessionId);
const output = { ok: true, cleaned: 1 };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
} else {
// Clean all finished sessions
let cleaned = 0;
for (const [id, s] of sessions.entries()) {
if (!s.isRunning) {
sessions.delete(id);
cleaned++;
}
}
const output = { ok: true, cleaned };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
}
);
process.on("SIGINT", () => {
for (const s of sessions.values()) {
try { s.pty.kill(); } catch {}
}
process.exit(0);
});
const transport = new StdioServerTransport();
await server.connect(transport);