shell-session-mcp
v0.1.1
Published
MCP server for managing interactive shell/interpreter sessions (bash, gdb, radare2, python, node REPL, ...)
Downloads
299
Readme
shell-session-mcp
An MCP server that exposes interactive shell / interpreter sessions to an
MCP client. Sessions run inside a real PTY, so programs that care about being
on a terminal — bash, gdb, radare2, python REPL, node, psql, vim,
htop — behave exactly as they would for a human user.
The server is designed for the case where a single tool-call-per-line model is a poor fit: long-running interpreters where you want to keep state across many turns, drive a TUI, or watch a process produce output asynchronously.
Features
- PTY-backed processes via
node-pty. Programs seeisatty(stdin) == true, get proper window size, and receive signals likeSIGWINCH. - Numeric session ids —
spawnreturns an id; every other tool addresses sessions by it. Run any number of sessions concurrently. - Server-tracked read mark —
readreturns only what arrived since the previousread/read_to_file; the server remembers the position so the client never has to pass a cursor. The full session output is also retained (unbounded) and accessible viaread({ all: true }). - Push notifications, opt-in per session, delivered as standard MCP
notifications/message(logging) events:{type: "output", id, chunk, cursor}— a chunk of new data. When notifications are enabled, each pushed chunk also advances the server-side read mark, so it won't be repeated by the nextread(no duplication between push and pull).{type: "exit", id, exitCode, signal}— the process ended- Exit notifications are always sent, regardless of the output toggle.
- Context-overflow guard on
read— by default refuses to ship more than 32 KiB inline. The size of the pending output is reported back so the caller can decide whether to bypass withforce: trueor drain to disk. read_to_file— write pending output straight to a file path; only metadata returns over MCP, so the conversation context stays small even for multi-megabyte output.- Window resize, arbitrary signals (
killacceptsSIGTERM,SIGINT,SIGKILL, …), and session listing.
Install
npm installnode-pty builds a native addon — you need a working C++ toolchain and Python
on the host.
Run
The server speaks MCP over stdio:
node index.js
# or once installed
shell-session-mcpConfiguring it as an MCP server
{
"mcpServers": {
"shell": {
"command": "node",
"args": ["/abs/path/to/shell-session-mcp/index.js"]
}
}
}To receive output / exit notifications the client must (a) declare the
logging capability and (b) call logging/setLevel (any level — the server
emits at info).
Tools
All tool responses are JSON encoded inside a single text content item.
spawn
Start a new PTY-backed process.
| field | type | required | notes |
| ------- | ---------------- | -------- | ------------------------------------------------ |
| command | string | yes | executable, e.g. bash, gdb, r2, python3 |
| args | string[] | no | argument vector |
| cwd | string | no | working directory |
| env | record<string> | no | extra env vars, merged onto the server's env |
| cols | int | no | PTY columns, default 120 |
| rows | int | no | PTY rows, default 40 |
| notify | bool | no | enable output notifications immediately |
Returns {id, pid}.
write
Send raw bytes to the PTY's input. You decide the line ending — append
\n for shell-style submission, \r for some REPLs. Control characters pass
through ( = Ctrl-C, = Ctrl-D / EOF).
Args: {id, data} → {id, written}.
read
By default returns output that arrived since the previous read /
read_to_file and advances the server-side read mark. The server tracks the
mark internally — there is no client-side cursor.
Pass all: true to return the full session output instead (everything since
spawn). This still advances the mark to the end, so the next default read
returns only what arrives afterwards.
| field | type | default | notes |
| ---------- | ---- | ------- | -------------------------------------------------- |
| id | int | — | |
| all | bool | false | return full session output instead of only new |
| wait_ms | int | 0 | block up to N ms for new output (ignored if all) |
| max_bytes | int | 32768 | inline-output size guard (see below) |
| force | bool | false | bypass the size guard |
Returns {id, all, data, bytes, cursor, exited, exitInfo} on success, or
{id, oversized: true, all, available, max_bytes, hint, …} if the size
guard tripped.
The guard exists to protect the client's context window: when the result
would exceed max_bytes the data is not embedded in the response, the read
mark is not advanced, and the caller is told how big the result would be so
they can either retry with force: true, raise max_bytes, or drain to disk
via read_to_file.
read_to_file
Write output to a file on disk and return only metadata. The MCP message
stream — and the model's context — never sees the data. Use it for build
logs, gdb dumps, fuzzer output, or anything you only want to grep through
later. Like read, defaults to only new bytes (and advances the mark);
pass all: true for the full session.
Args: {id, path, all?, append?, wait_ms?} → {id, path, all, bytes, cursor,
append, exited, exitInfo}.
set_notifications
Toggle push notifications for new output on a session. Args: {id, enabled}.
When notifications are enabled, chunks emitted by the child are coalesced
over a short debounce window (~50 ms) and then pushed as a single
{type: "output", …} notification. The bytes covered by that notification
are marked consumed, so a subsequent read won't return the same data
again. (Anything buffered before notifications were enabled stays unread and
is still returned by the next read.)
If a coalesced batch exceeds 32 KiB, the notification carries
{oversized: true, available, hint} instead of the data, and the bytes
are not marked consumed — the client must pull them explicitly via
read (force: true or all: true) or read_to_file. This keeps push
notifications from quietly dumping megabytes into the client's context.
Exit notifications fire regardless of this setting.
resize
{id, cols, rows} — propagates SIGWINCH to the child. Useful for full-screen
TUIs.
kill
{id, signal?} — default SIGTERM. The session entry is kept after exit so
you can still read final output and inspect exitInfo. Drop it with
remove.
list
No args. Returns {sessions: [...]} with a summary for every known session
(live and exited).
info
{id} → summary for one session.
remove
{id} — drop an exited session from the registry.
Notification payloads
All notifications are notifications/message (the MCP logging channel) with
logger: "shell-session-mcp" and level: "info". The interesting bit is params.data:
// Coalesced new output (only if the session has notifications enabled).
// PTY chunks are debounced ~50ms and pushed as one notification. The bytes
// are marked consumed, so the next `read` will not return them again.
{ "type": "output", "id": 3, "chunk": "...bytes...", "cursor": 12345 }
// Heads-up that there is a big chunk waiting (size > 32 KiB). The data is
// NOT in the payload and is NOT consumed — call `read` (with `force: true`,
// or `all: true`) or `read_to_file` to fetch it.
{
"type": "output", "id": 3, "oversized": true,
"available": 75052, "cursor": 75144,
"hint": "Pending output is 75052 bytes (>32768). Call read with force:true (or all:true), or read_to_file."
}
// Process exit (always)
{ "type": "exit", "id": 3, "exitCode": 0, "signal": null }Example session
// 1. open a bash
spawn {command: "bash", args: ["--norc", "--noprofile", "-i"], notify: true}
→ {id: 1, pid: 9999}
// 2. drive it
write {id: 1, data: "ls /etc | head\n"}
read {id: 1, wait_ms: 200}
→ {id: 1, data: "...", cursor: 412, ...}
// 3. expect a lot of output? drain to a file
write {id: 1, data: "find / -name '*.so' 2>/dev/null\n"}
read_to_file {id: 1, path: "/tmp/find.log", since: 412, wait_ms: 5000}
→ {id: 1, bytes: 1438201, cursor: 1438613, ...}
// 4. quit
write {id: 1, data: "exit\n"}
// → notification: {type: "exit", id: 1, exitCode: 0, signal: null}
remove {id: 1}Limitations
- Output is held as UTF-8 strings. Binary stdin/stdout is not directly
representable; for hex-clean transport, base64 in your own protocol layer
before
write. - Output is retained for the entire session lifetime (no cap). For very
long-running or chatty processes this can grow unbounded; spool to disk
with
read_to_fileif you don't need it in memory. - One process per session.
node-ptydoes not auto-respawn on crash.
License
MIT
