hypnosis/ssh-mcp-server
Remote server work over the OpenSSH client already on your machine: run commands, move files with checksum verification, search logs and audit hosts, with destructive commands blocked before they reach the shell.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[2.3.3] - 2026-08-24
Added — a directory can be updated without wiping what the source does not carry
ssh_uploadtakesmerge: truefor directories. What the target holds and the new tree does not — uploads,.env, logs — stays where it is; files that share a name are taken from the source. Without the flag a directory is still replaced whole, as before, and "update the build in a live directory" no longer has to go around the tool throughssh_execandtar, outside every check.- The tree still arrives whole. The new files cross the network first, into a temporary path of their own; only then is the target's own content copied beside it, the two are composed there, and the result takes the target's place by a single rename. A connection that breaks leaves the live directory exactly as it was, and there is no moment when it is half old and half new.
- The composition is two plain copies, the same ones on BusyBox and on coreutils. The shorter spelling with a no-clobber flag was measured on both and rejected: BusyBox skips the whole tree and still reports success, which would turn merging back into a silent full replacement.
modeandownerdescribe everything that ends up at the target, the kept files included.- Merging together with
overwrite: falseis refused before the first command: one asks to write into the target, the other forbids touching it. - The answer says
merged: trueonly when something was really kept — asking to merge onto an empty path is an ordinary install, and it is not dressed up as anything more.
Changed — a note about an owner that was not applied cannot be lost on the way out
- The step that sets permissions and ownership now hands its notes back instead of writing
them into a variable kept outside it. The wording in
ssh_uploadandssh_file_writeis the same as before; what changed is that there is no longer a path on which the note is prepared and then dropped.
Changed — the tool list reads as one table
- All 18 tools stand in a single table under a
## Toolsheading instead of five tables split by topic, and the install section carries the plainmcpServersblock most clients share. Catalogues read a README literally: they look for that heading and that block, and a listing without them shows neither the tools nor a way to install the server.
[2.3.2] - 2026-08-24
Added — a command that does not name what it stops is shown the target first
docker kill $(docker ps -q --filter …),kill $(pgrep -f …),pkill -f …and the like are no longer sent. The server expands the target itself and answers with what stands behind it: name, image and status for a container; number, command line, age, listening ports and open connections for a process. The caller wrote a way of finding a target and could not know what the way would find — now it can, before anything is stopped.- Named targets go through as they always did:
docker kill web-1,kill 4871,systemctl stop appcost nothing extra and ask the machine nothing. - To go ahead, name what is being stopped:
# CONFIRMED-KILL: edge, api. The names are checked against what the command actually reaches, so a mask that quietly drifted onto something else is refused instead of confirmed. Repeating the same unnamed command is not agreement, and it stops again. - A pattern over command lines is not confirmed but rewritten: it matches the very command
that carries it, so the shell running it dies before the target and the reply breaks off
mid-stream. The refusal offers both ways out — by number, or with one character written as
a class (
[r]elay) so the pattern stops matching itself. - Three outcomes stay apart: targets found, the expansion reached nothing, and nothing to ask with — no engine on the machine, a clipped answer, a connection that failed. The last two are refusals as well: not knowing is not a reason to proceed.
Removed
- The warning
docker rm all containers detected: the same form is now refused before it reaches the machine, and the warning could no longer be triggered.
[2.3.1] - 2026-08-24
Changed — a refusal says where the way through is
- A tool that cannot do something now ends its refusal by naming
ssh_exec, which runs commands on the machine directly. The cases are not worth enumerating — a driver nobody supports, a utility the machine does not have, an engine this server does not speak — and they share one exit. An agent that hits a wall goes to the shell anyway; the question is whether it goes blind. Which command to run there is the caller's business: the answer names the tool, not the line. - Three kinds of refusal say nothing of the sort, because there the shell is the wrong
answer: a rule of the profile (walking around it is not a fix), a malformed call (the fix
is in the call), and a refusal from
ssh_execitself or from a detached job.
Added — a container's log is read by name
ssh_log_tailandssh_log_searchtakecontainerinstead ofpath: docker is asked where that container writes, and the file it names is read by the same machinery as any other log — the window,since, the match cap, the truncation mark and the honest "could not be read". Until now the only way in wasssh_execwithdocker logs, which returns text, and an empty text reads as "no errors".- The answer says where the lines came from: the engine, the driver and the file. Line numbers are the file's own, and the text is what the container printed — the driver's record around it is unwrapped, and a record that will not parse is shown as it lies rather than dropped.
- Only the
json-filedriver leaves a file behind. Everything this cannot read — another driver, an engine that is not docker, a name nothing answers to, a socket closed by permissions — comes back named rather than empty. Permissions additionally say to repeat withsudo: true.
Fixed — a checksum mismatch is now a word the answer says
- Verification tells three outcomes apart inside and named only two of them outside:
verified,unavailable,skipped. A mismatch had no word at all, so an agent reading the schema before the call could not tell what one would look like — and readwritten: falseas the mismatch, which means something else entirely. mismatchedis now in the answer schema ofssh_upload,ssh_downloadandssh_file_write, and in the legend beside it. The call still fails — a copy that did not survive the trip is never reported as delivered — but the failure now carries the structure with it: which path, that nothing was replaced, and that the path still holds what it held before.ssh_file_writeused to report a mismatch asskipped, the word for "no comparison ran".- The error text no longer shows the temporary name a file travelled under; a directory names the files that differ by their place inside it, not by their staging path.
Fixed — cancelling a call now stops the command on the server
- A cancelled call used to drop the local ssh client and leave the command running on the machine. The stop is now sent: a second call over the same connection finds the command by the marker in its arguments and signals its process group.
- A command running under
sudois stopped undersudotoo — a signal from the login user never reaches a process owned by root. The password travels on stdin, never in the arguments. - Servers without
timeoutare covered, and so are servers without/proc, where the command is found throughps. FreeBSD is not verified: correct behaviour there is not guaranteed. - File transfers and
ssh_snapshotstill do not take cancellation, deliberately.
Added — ssh_file_list answers in fields
- A directory comes back as entries with
name,type,size,mode,owner,group,mtimeand, for a symlink, its target — plus the schema that declares them. Sizes are exact bytes, the time is a number, and a name holding a newline stays one name. Directories the walk was refused at are named inunreadable: the signal to repeat the call withsudo: true. patternis matched byfindon the machine instead of the server's shell, so a pattern that matches nothing is an empty list rather than a failed call.
Added — ssh_file_write can set an owner
- A file written as root takes an
owner, per file, worded as onssh_upload. It is applied before the rename, so the live path never shows the file under the wrong owner. Withoutsudothe file still lands and the answer says the owner was not applied.
Changed — the values an answer uses are explained before the call
- Every enum field in an answer schema names all of its values and what each one means:
jobs[].state, the outcome of stopping a job or reading a service, both firewall screens, and the verification outcome of a write or transfer. Schema and legend come from one dictionary, so they cannot drift apart. ssh_audit_baselineandssh_service_statuscarry a legend at all now.files[].writtensays that permissions and owner are a separate matter, named inreason;jobs[].started_atsays its unit;owneronssh_uploadsays it walks the whole tree withrecursive.
Fixed — an installed firewall no longer reads as one that is not there
- Over SSH a regular user has no
/usr/sbinor/sbinon the PATH, so an installedufwwas reported as missing — with a warning that incoming traffic was unfiltered on a machine whose firewall was on. The audit now looks in sbin too. include_sudo_sectionscovers every section that needs root, the firewall included.- A refusal by permissions says what removes it. The INPUT chain policy and the docker nat rules used to say nothing when unreadable, leaving "docker publishes nothing past the firewall" standing as a fact.
Fixed — a silent answer to the hash check no longer reads as a mismatch
- A server that hashed, complained about nothing and named no hash was judged as a mismatch — and a mismatch makes the installer tear down a copy that had arrived intact. Silence now means there was nothing to verify with and the copy stays; a complaint about a missing file still means the file is not there.
Changed — a written file is verified unless you say otherwise
verifyonssh_file_writedefaults totrue, as it already did onssh_uploadandssh_download. Passverify: falsefor the old behaviour. Each write costs one more round trip, and answers carry the outcome where they used to say nothing.files[].verifiedsays which question it answers: how the check ended, not whether the data landed.
Changed — every fact on the surface is said once, where it is read
- Tool descriptions follow one shape: what it does, what the answer promises where it could be read wrongly, and which neighbour to use instead. What a parameter governs is written in that parameter and nowhere else.
- The server instructions tell the paired tools apart, and say that the password
sudoasks for lives in the profile like every other secret.
[2.3.0] - 2026-08-21
Added — a profile can answer sudo without a login password
- New profile field
sudoPassword: whatsudois answered with on that machine. Until now the only answer available was the login password, so a profile that logs in by key — the common shape for an unprivileged deploy user — had nothing to hand over, and every call withsudo: truecame back with sudo's words about a missing terminal. It is read from the profile and from the secrets file alike, masked in logs, and travels on standard input rather than inargv, exactly as the login password does. - Where a machine keeps the two secrets apart,
sudoPasswordwins; left out,passwordis used, so existing profiles behave as before. - When there is nothing to answer with, both
ssh_execand a detached job now say the same thing — give the profile asudoPassword, allow the command insudoers, or dropsudo— in place of sudo's own advice about-Sand askpass helpers, neither of which a caller can reach. Sudo's own output is still shown; the note is added beside it.
Added — a background job can run as root
detachandsudoused to be refused together, and the refusal offered two ways out that a long job under root has neither of: dropsudoand the command fails, dropdetachand the call dies before the work ends. Now the job starts as root — a package upgrade, an image rebuild, a migration finally has a way to run.- The whole protocol follows it there. A job's id says it belongs to root, so
ssh_job_status,ssh_job_output,ssh_job_killandssh_job_listelevate by themselves and nothing extra is passed on later calls. Without that they would report a live job aslostand fail to stop it: someone else's process is not theirs to signal. - What
sudoneeds is something to answer with — a password in the profile, or asudoersline that asks for none. A key-only profile whosesudodemands a password gets a refusal saying exactly that, in place of sudo's own words about a missing terminal, and no job is started.
Changed — the legend says what its keys look like
- The
legendin a structured answer now carries a description: a key names the field before the value (state=limited,jobs[].state=lost), and only values the answer actually used are listed. The rule lived in the code; the eight tools that declare a legend now say it in the surface itself.
Changed — a missing profiles file no longer stops the server
SSH_PROFILES_FILEleft unset, or pointing at a file that will not load, used to end the process at startup. The server now starts, answerstools/list, and puts the diagnosis in the first tool call instead: what is missing and where the file format is written down (ssh://profiles/example). A catalogue that reads a tool list by starting the server finally gets one, and a person who forgot the variable reads the reason in their client rather than watching the connection die.- The
mcpbbundle marks the profiles file optional and the Docker image no longer carries a placeholder profile: neither needed a stand-in once the server starts on its own.
Added — more ways to install the same server
mcpbbundle:npm run build:mcpbproducesreleases/ssh-mcp-server-<version>.mcpb, a self-contained bundle carrying the built server and its runtime dependencies. Clients that install in one click read it, and so does Smithery. The release workflow builds it after the tag-version check and attaches it to the GitHub release, so the number inside it cannot disagree with the tag.plugin.jsonandmcp.jsonat the repository root, conforming to the Agent Plugins 1.0 schemas. Cursor, VS Code, Copilot, Codex and Kiro all read this pair, so the server can be installed from a repository URL rather than a hand-written config block..claude-plugin/plugin.json,.claude-plugin/marketplace.jsonand.mcp.json: Claude Code installs the repository as a plugin —/plugin marketplace add hypnosis/ssh-mcp-server, then/plugin install ssh-mcp-server@ssh-mcp-server. The plugin reads~/.claude/ssh-profiles.jsonunlessSSH_PROFILES_FILEpoints elsewhere.Dockerfileand.dockerignore: catalogues that read a server's tool list by starting it can now do so. The image carries a placeholder profile file, because the server refuses to start without one, and it reaches nothing until a call names a profile.npm run bumpnow moves the version in all three plugin manifests as well; the release workflow refuses a tag that disagrees withplugin.json.
Changed — badges say who vouches for what
- The README badge row is split in two: what others vouch for (MCP registry entry with its live version, Glama quality score, monthly downloads) above what the project reports about itself (npm version, tests, Node, MCP SDK, license). The TypeScript badge is gone — it showed the version of a dev dependency, which tells a reader nothing.
- The README header no longer sits in a table: the project mark floats beside the opening paragraph, so the block reflows on a phone instead of holding its columns.
[2.2.1] - 2026-08-21
Added — the server has a face in the catalogues
- The registry entry carries an icon now: a rack with a key, drawn for light and dark themes in three sizes each. Listings that show icons no longer fall back to a blank square. The registry only picks up an entry with a new version, so the icon travels with this release.
Added — one command moves the version everywhere
npm run bump -- patch|minor|major|X.Y.Zraises the version in every place at once:package.json, both fields inpackage-lock.json, both fields inserver.json, and the[Unreleased]section of this file. It refuses before writing anything if the places have drifted apart, and re-reads the files afterwards to confirm the new number arrived everywhere. Commit and tag stay manual.
[2.2.0] - 2026-08-21
Fixed — the answer reaches the model, not just the transcript
- A client that gets a tool answer with a declared output schema shows the model the fields
alone and drops the text block. Four tools kept their substance in the text, so it reached
nobody:
ssh_exechad no output fields at all,ssh_log_searchreported a count without the lines,ssh_monitor action:listanswered with empty fields instead of profile names, andssh_snapshotgave eight numbers without the sections behind them. - Command output now travels in the fields:
stdout,stderrandclipped_bytesper command. An empty string means the command ran and said nothing; a command that never ran carries no such field, so silence and absence stay distinguishable. - Matched log lines travel as
lines—{file, line, text, context}— wherecontextmarks a neighbour brought in by the context option rather than a match.ssh_monitor action:listreturnsprofilesandbroken;ssh_snapshotreturnslistening,services,containers_runninganderror_lines, eachnullexactly where its counter isnull. - Output over 128 KB per command keeps both ends with a seam between them naming the amount
cut. The cut is made on bytes and stepped back to a character boundary, so a clipped
answer never carries a replacement mark.
clipped_bytesis separate fromtruncated: one means the field was too small, the other means the transport buffer filled up.
Fixed — sudo without a terminal
sudoasks a terminal for the password, and there is no terminal on a one-shot command. When the profile has a password, it is now handed tosudoon standard input. A command that reads its own standard input is left alone — mixing the password into the data would be worse than the refusal it avoids. A profile authenticating by key still has nothing to offersudo; that half is written down as TD-28.
[2.1.1] - 2026-08-19
Changed — the release signs itself, with nothing left to steal
- Releases now publish over OIDC: the workflow run proves its own identity to npm
against a trusted publisher configured for this repository and this workflow file.
The stored
NPM_TOKENis gone from every step — there is no long-lived credential left to leak, expire, or rotate, and provenance is still signed against the run that built the package. - The server itself is unchanged from 2.1.0: same tools, same descriptions, same behaviour. Only the way the package reaches the registry is different.
[2.1.0] - 2026-08-19
Added — the surface explains itself to an agent that has never seen it
- Every tool description now has one shape: what it does, the parameters that matter with their values, and the antipattern. Three sentences, up to 150 characters. The server instructions became an index of question to tool instead of a retelling of what the descriptions already say.
- The surface a client loads once per session went from 44 007 to 30 514 characters (≈11 000 → 7 628 tokens, −31%): tool descriptions −76%, parameter descriptions −35%, instructions −59%. Input and output schemas are 23 557 of what remains and are not compressed — they are the validator's contract, not prose.
- The measurements and the method behind them are written down in
docs/decisions/009-how-an-agent-reads-the-surface.md.
Added — sudo where it was missing, and one rule for when to take it
ssh_disk_breakdownandssh_service_statustakesudo. Without it the cache section read the wrong home directory and the journal came back trimmed to what the profile user may see, with nothing saying so.- The rule for when to take root is written once and shared by every tool that offers it: straight away for places a plain user cannot read, otherwise on retry when the answer names what it could not read.
Fixed — a directory closed by permissions is named instead of vanishing
- A search over a tree silently dropped directories the profile user cannot enter: the
complaint went to
/dev/null, so files nobody looked at could not be told from files with nothing in them. They arrive infiles_unreadablenow, in the fields and in the text. - A file whose modification time could not be read no longer counts as "not modified" and
no longer disappears into
files_skipped. - The disk breakdown hid closed directories the same way and reported a list of the largest
directories that was short by exactly the one filling the disk. Those directories are
named in the new
unreadablefield. - A pattern that matched nothing is not a refusal. Sections are asked for by pattern, and the shell hands the pattern through untouched when nothing answers to it; only the complaint tells that apart from a closed directory, so only "no such file" is dropped.
Added — every tool says what it does before it is called
- All 18 tools now carry the standard MCP annotations:
readOnlyHint,destructiveHint,idempotentHintandopenWorldHint. Reading a log and wiping a directory used to look identical to a client, which left the decision to ask the person to guesswork. Twelve tools are read-only, five change something, andssh_execis the one that promises nothing about a repeat, because it runs whatever it is handed. ssh_monitoris the only tool withopenWorldHintfalse: it touches the connection this process holds, not a remote machine.
Added — the server hands the model a map of its tools
- On connect the server now sends
instructions, and the client puts them in the model's system prompt. Eighteen tools were reachable and two were used:ssh_execruns anything, so it was always the shortest path, and everything the specific tools exist for — batching a list of files into one round trip, parsing the answer, verifying a write against sha256, saying "could not check" instead of returning a blank — was lost without anyone noticing. The map says exactly that, tool by tool, and every one of the eighteen is named in it.
Added — a disk breakdown and a service check answer in fields
ssh_disk_breakdownandssh_service_statusnow returnstructuredContentalongside their text, the wayssh_audit_baselineandssh_tls_checkalready did. Half the audit family answered in fields and half in prose, and a client could not tell which it would get until the answer arrived.- A service is reported as measured only when systemd actually answered.
outcomeischecked,no_systemdorno_unit, and the last two leave every field empty: asked about a unit that does not exist,systemctl showstill printsActiveState=inactive, and passing that on would report a stopped service on a machine that never had one. - A disk section that came back with nothing is named in
unavailableinstead of arriving empty.duis equally silent about a directory that is empty and one it was not allowed to read, and silence was being read as "no problems here". - A machine that answers none of the four sections is reported as unmeasured rather than checked. A router's own CLI replies neither the way systemd does nor the way a missing binary does, so a service on it used to come back as "found, properties unknown" — on a box that has no services in that sense at all.
Fixed — a device that answers nothing is no longer reported as a healthy server
- Measured on a consumer router, whose login shell is the vendor's own CLI: it runs none of the probe commands and replies neither the way systemd does nor the way a missing binary does. Every section came back empty, and empty was being read as fact.
ssh_audit_baselineno longer reports0 running services, none failedthere: a system or services section that produced no output is named inunavailableinstead.ssh_snapshotno longer printsEstablished connections: 0when nothing counted them. A count that is not a number, and a listener probe that answered nothing at all, are both reported as not checked.- Terminal drawing no longer travels into answers. A device CLI emits erase and colour
sequences with no terminal attached, and they arrived inside values —
load: [K[K. They are stripped where an answer is read; file contents keep their bytes, because an escape sequence inside a file is data. - The advice for a
limitedconnection names the tools that do not apply there instead of saying "audit tools", which told the reader nothing about which call to avoid.
Added — stopping a job answers in a field
ssh_job_killnow declares an output schema and names its outcome:signalled,gone,nopid,missingorno-answer, beside the signal that was sent and what the server said. Five outcomes lead to five different next steps and used to differ by wording alone;no-answeris the one that must not be read as success — the server said nothing, so whether the job still runs is unknown.
Known limitations
Unchanged from 2.0.0 and recorded in docs/tech-debt/ with measurements. Neither is tied
to a release: both are lifted by work, not by a version.
- Cancelling a call does not stop the work on the server. Cancellation drops the local
sshclient at once, but a command already started on the machine runs to its end. Anything that has to be stoppable from outside is started withdetach: trueand stopped byssh_job_kill, which signals the process group. File transfers and the system snapshot do not take cancellation at all, deliberately (TD-08). - The mount-point check needs a
statthat speaks the GNU or BusyBox syntax. On a server whosestatdiffers (BSD, macOS) the check does not run and says so; the install proceeds, and the rename stays the real guard (TD-09).
[2.0.3] - 2026-08-18
Changed — the one-line pitch says what the server is for
- The description on npm, on GitHub and in the registry now reads the same, and it leads with what a reader decides on: SSH that reaches both a cloud host and a BusyBox router, and that refuses commands which destroy a machine. The client names stay in the second sentence, where they still answer "will it work with mine?" without crowding the first.
[2.0.2] - 2026-08-18
Added — the server is listed in the official MCP Registry
server.jsondescribes the server for the registry atregistry.modelcontextprotocol.io, andmcpNameinpackage.jsonis what proves the npm package and the listing are the same thing. Clients that read the registry can now find and install the server without being handed a config by hand.- The release workflow publishes the listing right after npm, signing in with the same
short-lived GitHub token that already signs the provenance — no secret is stored for it.
The version is checked against
server.jsonbefore anything is published, because the registry refuses a listing whose version does not match the package it points at.
[2.0.1] - 2026-08-18
Changed — the profile is always named (breaking)
- There is no default profile any more, and the
defaultfield in the profiles file is ignored. Every call names the server it talks to; a call without a name is refused and the refusal lists the names to choose from. Picking one silently meant that the order of entries in the file decided where a command went — and a command sent to the wrong machine cannot be taken back. - Fields and profiles the server does not recognise are left alone instead of failing the load, so a profiles file shared with other tools keeps working.
Changed — a connection test says what state it found
ssh_monitoractiontestreports one of four states as its first word:ready,limited,no-route,rejected. A server that answers with a non-POSIX shell (routers, appliances) islimited— a usable connection, not a failure, and no longer reported as silence. Onlyno-routeandrejectedare errors, and each says which side to look at: the network or the credentials.
Added — passwords live outside the profiles file
secretsFile, at the top level of the profiles file or per profile, points at a separate JSON keyed by profile name, holdingpasswordandpassphrase. The file must bechmod 600or it is refused, the waysshrefuses a private key. A relative path is resolved from the profiles file, not from the working directory. Secrets written inline still work but log a warning. Seesecrets.json.example.
[2.0.0] - 2026-08-17
Transport moved from the in-process ssh2 pool to the system OpenSSH client with
ControlMaster multiplexing. Sprints CORE_08 → CORE_11; full record with measurements in
docs/sprints/planned/. The bundled ssh2 backend is gone, and with it the connection
pool, so SSH_MCP_BACKEND no longer selects anything.
New requirement on your machine (breaking). There is no bundled SSH any more: a system
ssh client has to be on PATH. Any OpenSSH works, but three features have a floor —
5.6 for the shared multiplexed connection, 8.4 for password and passphrase profiles, 9.0
for scp over SFTP. Below a floor the feature says so instead of failing quietly. If that
does not suit the machines you run on, stay on 1.x.
Changed — one transport, and connections that outlive the server
- The system
sshclient is the only way commands are delivered.SSH_MCP_BACKEND=ssh2is no longer honoured (breaking), and the pool variablesSSH_MCP_POOL_IDLE_TIMEOUTandSSH_MCP_POOL_KEEPALIVE_INTERVALare gone with the pool itself. - Connections are no longer closed on exit: the control socket is shared with other windows on the machine. The server reports what it leaves behind instead — which sockets are in the control directory and whether their master is alive.
SSH_MCP_CONTROL_PERSISTsets how long a connection stays alive after the last command (whole seconds,0closes immediately, default600). The remaining idle time is deliberately not reported: a socket's timestamp marks when the master came up, not the last command. Seedocs/decisions/006-leftover-control-sockets.md.ssh_monitorgained the actionclose: it closes the shared connection of one profile right away instead of waiting for the idle window, and says what is still left on the machine. Nothing to close is an answer, not a failure — the connection may have already idled out.- Servers without an sftp subsystem (routers, embedded devices, dropbear) can receive and
send files again. On client 9.0+
scprides SFTP, which such a server refuses; the transfer now falls back to the classic scp protocol once and remembers that destination. Nothing to configure. A remote path containing a newline is refused on that path — the classic protocol cannot carry it safely.
Added — commands that outlive the call (4 new tools)
ssh_exectakesdetach: true: the command is started as a job on the server and the answer comes back with its id in under a second. Before this, a command longer than the timeout could not be run at all — the client was killed and the work was neither finished nor reachable. The timeout does not apply to a job.ssh_job_status,ssh_job_output,ssh_job_listandssh_job_killfollow it. State lives on the server (~/.ssh-mcp/jobs/<id>/: the command, pid, start time, output and exit code), so restarting this server or watching from another window changes nothing.- Three outcomes are kept apart:
running,finishedwith its exit code, andlost— no exit code and no process, which means it was signalled or the machine restarted.lostis not dressed up as success or as failure, because it is neither. ssh_job_outputreads from a byte offset and answers with the offset to continue from, so repeated reads never overlap and never skip. The offset counts what was actually read: an answer cut off at the transport buffer does not jump over the middle of the output.ssh_job_killsignals the whole process group, so the children of a job stop with it. A job that is already gone is reported, not refused — it may have finished between two calls.ssh_job_listremoves the directories of jobs that are no longer running and started more than seven days ago; running jobs are never touched, and removal never leaves~/.ssh-mcp.- Limits: one command per job (an array is refused), and
detachcannot be combined withsudo— a background job has nowhere to take a password from. The refusal happens before anything is sent. The guard against destructive deletes runs on this path too, before the job directory is created.
Changed — a failure now looks like a failure, and says what it managed to do
- A command killed by its
timeoutno longer loses what it printed. The answer carries the output collected until the kill, under a note saying it is only that much. Before, the whole answer was a single line naming the timeout — a command that printed progress for an hour and then hit its limit left nothing behind. The same applies to a cancelled command. - Every tool marks a failed call with
isError(breaking for clients that parsed the text to guess). Before, onlyssh_monitordid, so a client could not tell "the file is not there" from the contents of a file. The text of the answer is unchanged — only the flag is new. - Three outcomes stay apart, and the flag draws the line. The tool could not do the work
(no such profile, wrong argument shape, command killed by its timeout, server refused) —
flagged. The work was done — not flagged, even when the command exited non-zero: the
command ran and its output is the answer. There was nothing to check with (
ssh_tls_checkansweringUNKNOWN,ssh_service_statusansweringNOT CHECKED) — not flagged either, that is a success with a note.
Changed — MCP SDK 0.6.1 → 1.30
- The server answers each client with the protocol revision that client asked for
(
2024-11-05through2025-11-25). On0.6.1every client was answered2024-11-05, whatever it asked for. Older clients keep working — measured. - The upgrade itself changed no behaviour: the whole set of 14 tools was called through a real client before and after it, on both lab containers, and the answers matched character for character. Tool names and input schemas stay as they were; what the two entries below add to the answer came after the upgrade, deliberately.
- The dependency is heavier: 14 packages / 7.5 MB → 91 packages / 24 MB, because the SDK
now ships its HTTP transports (express, cors, hono, ajv) even for a stdio server. The
Node requirement stays
>=18. - A cancelled call is now acted upon instead of being ignored. The client's cancellation
reaches the tool and the transport, and the
sshclient waiting on the answer is dropped at once rather than sitting out the command's timeout. What it does not do is stop the command on the server — see Known limitations. ssh_audit_baselineandssh_tls_checkhand the client their result already parsed (structuredContent), and describe its shape in the tool listing (outputSchema). Before, the only way to get the data was to cut the text at--- raw JSON ---and parse what followed. That text is unchanged, so a client doing exactly that keeps working. A client on SDK 1.x checks the answer against the declared shape and refuses it on a mismatch, so the two are tested against each other on live servers.
Fixed — data loss and false corruption reports
~inremote_pathofssh_upload/ssh_download. Download used to bring the file and then delete it (the checksum was asked for a file literally named~); upload created a directory called~next to the real home.- File names containing a backslash, newline or carriage return were reported as corrupted after a correct transfer — and the installer removed the tree it had just delivered.
- A transfer killed by the remote timeout watchdog was read as a checksum mismatch (exit code 124 on coreutils, 143 on BusyBox), with the same removal as above.
- Trees of ~1000 files failed to upload with
verify: true: every file was opened at once and the process ran out of descriptors. Hashing now runs through a pool of 16 readers. - Broken symlinks and symlink loops inside a tree are refused before the transfer starts instead of leaving half a tree on the server.
Changed — behaviour
pathSecurityfrom the profile is now actually enforced, and also coversssh_upload/ssh_download. It never worked before: the field was lost between the profiles file and the tool. Profiles that carry this block will start rejecting paths.- No default 300-second ceiling on transfers.
ssh_upload/ssh_downloadaccept an optionaltimeout(ms) which also covers checksum verification,chmod -Rand cleanup. ~user/pathis rejected instead of being written to a guessed location; undersudo: true,~means the login user's home and the answer says so.ssh_file_readrefuses truncated output instead of returning a partial file.ssh_uploadwithoverwrite: falserefuses when it cannot tell whether the target exists, instead of taking a failed check for "the file is not there" and writing over it. Passoverwrite: trueif that is what you mean.
Security
- Tool argument values (
mode,owner,pattern,lines,context,top_n,log_lines) are validated before reaching the command line — eight injection points in four files. Working forms (644,u+x,*.log, names with spaces) are unchanged. - Passwords and passphrases are stripped from everything the server logs itself, at any length. Server output is left untouched — masking it corrupted file contents.
Fixed — found by acceptance testing (251 real MCP calls on four servers)
ssh_snapshotno longer hangs on a connection that is not up yet. Its ten parallel reads and the server passport deadlocked behind the first-command gate, and the hang took the whole server down with it. The passport now comes from the transport, whose probe bypasses the gate; a cold snapshot answers in 200–300 ms.- A profile with no key and no password could run commands as
root(security). The shared socket was named after host, port and user only, so such a profile rode the connection another profile had opened — and closed that connection on its way out. The socket name now includes a fingerprint of the credentials: the profile is refused by authentication and the neighbouring window keeps its channel. Profiles that share credentials still share one connection.
Fixed — the warning now matches the danger
Found on a fresh production server: the audit stayed silent where the machine stood unprotected, and a warning fired where a file was being read. Both halves were harmful — silence reads as "checked, clean", and noise on routine work teaches you to skip warnings.
ssh_audit_baseline:PasswordAuthentication yesis critical on any port. The flag used to require port 22 as well, so a server that allows password login on a non-standard port — that is, one somebody had already configured by hand — got no critical flag at all.ssh_audit_baseline: the port fromsshd -Tis compared with the sockets sshd actually listens on, and a mismatch is its own warning. On Ubuntu 22.04+ the port is set byssh.socketand the config disagrees silently; the dangerous direction is a config with a non-standard port while 22 is what is open. No listeners to compare with means the config port is reported as unconfirmed rather than as fact.ssh_audit_baseline: the firewall section takes part in the classification. It was collected and printed but produced no flag whatsoever, so a machine with ufw off, anACCEPTpolicy and no rules got an empty findings list. Presence of filtering is judged by theINPUTchain policy, not by the number of rule lines.ssh_audit_baseline: ports published by docker are checked in thenattable. Those rules run before the ufw rules, soufw statuscould honestly saydeny (incoming)while a container port was open to the internet — and the report said the firewall was on.ssh_exec: warnings read the command position instead of searching the text.rebootas the command is a call;rebootinside a path, an argument or a quoted string is not — sotest -f /var/run/reboot-requiredno longer prints "reboot detected", a file that any audit reads, this server's own included. SQL warnings fire only when a database client is the command being run. Wrappers (sudo,timeout 5,nice -n 10,env VAR=1) are seen through, and a separator inside quotes no longer starts a new command.
Changed — refusal where the loss is final (breaking)
A warning printed after the command has already gone stops nothing. Commands whose damage
cannot be undone are now refused before anything is sent, on two checks: the whole
container stops existing, or an object is read after the same call destroyed it. Content
inside a surviving container still only warns — a table restores from a dump of the
database, the database restores from nothing. Reasoning in
docs/decisions/007-refusal-threshold.md.
- Refused instead of warned:
reboot,shutdown,halt,poweroff;docker compose down -v,docker volume rm,docker system prune --volumes;DROP DATABASE,dropdb,FLUSHALL/FLUSHDB;crontab -r;mkfs,wipefs -a,lvremove/vgremove/pvremove,zfs destroy,btrfs subvolume delete;dd of=aimed at a device. Automation that calls any of these through MCP needs the marker now. - Broken order inside one call is refused too:
rm -rf A && cp -r A A.bakreads what is already gone. The correct order — copy, move, delete — keeps the destruction last and passes untouched, as dorm -rf A && ls Aand recreating a path or an archive. # CONFIRMED-DESTRUCTIVEafter the command sends it as written. Rebooting a router guarded by therouter-no-reboot.shhook needs two markers in one command: that hook has its own,# CONFIRMED-REBOOT, and they belong to different systems.- What the guard does not see is written down in the README rather than left implied:
destruction and reading split across two calls; a single argument taken for the
destination (
pg_restore dump.sql); the long form of an archiver key (tar --file X); a utility whose destination sits in the middle without a flag.
Fixed — answers that no longer claim more than was checked
Every tool below used to report a check it had not performed. The three outcomes — done, failed, nothing to check with — are now distinct in the answer itself.
ssh_audit_baseline: sections that were not requested are no longer printed as facts ("firewall inactive", "0 updates"); an unknown name inincludeis refused with the list of valid sections instead of yielding an empty report;ufwandiptablesreportnot installed/NOT CHECKED/ their real state separately; unreadablesshd -Tsays so and points atinclude_sudo_sectionsinstead of printing empty fields.ssh_tls_check: the Let's Encrypt renewal hook has four distinct answers — not readable (retry withsudo), Let's Encrypt not installed, installed without a hook, hook configured. The tool acceptssudofor this check.ssh_snapshot: memory percentage is computed after converting units, so a server reporting506Miof3.8Gino longer shows13316% used; CPU usage is derived from the idle share and parses both procps and BusyBoxtop; missingsystemctland missingss/netstatare reported asNOT CHECKEDrather than "no services, no ports". Thess || netstatfallback never actually ran (the trailingsortreturned 0), so BusyBox servers now list their listening ports for the first time.ssh_file_write: the answer says whether the sha256 was verified, could not be verified (nosha256sum, noopenssl), or was not requested.ssh_upload:ownerwithoutsudo: trueis reported as not applied — for single files and for directories, wherechownwas not even attempted.ssh_snapshotno longer loses readings on a server that cuts channels opened in a burst (dropbear does). Exit code 255 with no output at all is now recognised as a closed channel rather than a value, and retried immediately for reads that declared themselves safe to repeat —ssh_execrunningexit 255still answersExit code: 255. Reads run four at a time, a lost reading yields an empty metric instead of an empty report, and an unread core count or load average saysNOT CHECKEDinstead of showing0 cores. Measured: twelve consecutive router snapshots, twelve complete answers, unchanged timing elsewhere.ssh_file_readrefuses a file that is not valid UTF-8 instead of returning it as damaged text, and points atbinary: true. A 4096-byte random file used to come back with 1736 replacement characters and no warning; written back, it was a different file. In a batch only the damaged file is refused. Text with Cyrillic or emoji is unaffected.ssh_snapshotsays why the error log is silent instead of leaving the section out: no/var/log/syslog, not readable, or the read did not go through. A read that fails undersudois retried without it, so a server that has nosudoat all still gets a precise answer. A service whose status could not be read is printed as? NOT CHECKEDrather than vanishing from the list.ssh_audit_baselineasks about the sshd config always. It used to appear only withinclude_sudo_sections: true, so a full audit under root stayed silent about password login; that flag now selects how the config is read, not whether the section exists.- A timeout is answered in the time it names.
ssh_execwithtimeout: 3000used to answer after ~8 s: the ssh client dies on SIGTERM within milliseconds, but its streams are held open by the shared master process, which only lets go when the remote command ends — so the answer waited for the server-side guard instead. A process we killed is now awaited by its own exit, with 200 ms for the tail of its output. Measured: 8123 ms → 3231 ms (debian), 8117 → 3218 (alpine), 8071 → 3220 (router); a 200 000-line output still arrives whole. ssh_log_searchtakesmaxMatches(default 200 per log file). The limit is set by grep itself (-m), not by a trailingheadthat would return 0 for a missing file and make "no matches" indistinguishable from a failed read, and the answer says when it was cut. A real journal used to come back as 3736 lines in one answer. Context lines do not count against the limit.- Answers no longer show the kitchen. An error names the path you asked for instead of the
staging name the data travelled through (
/etc/nginx.conf, not/etc/.upload-7952b8939bc0.nginx.conf) — the address of a leftover backup copy is still printed, because that one is really on the server. Where a message names both paths — the rename that puts the prepared copy in place — the temp one is marked(staging copy): without it the two collapsed into one and the refusal read asmv -T -- '/etc/hosts' '/etc/hosts', a file renamed onto itself. A missing local file is refused withlocal_path does not exist: …instead of a rawENOENTstack line.ssh_disk_breakdownprints titled sections instead of its__SSH_MCP_DISK_SEP__markers, and saysnot installedwhere it used to echoNO_DOCKER. ssh_service_statustells apart three outcomes. A raw systemctl message no longer stands in theenabledfield next to?in the others: with no systemd on the server all three fields sayNOT CHECKED, an unknown unit saysno unit by that name, and a running service reads as before. The restart pause is asked for by the name systemd actually prints (RestartUSec), so the field is a value —on-failure (after 100ms)— instead of a permanent(?s).- Two installs into the same path: the loser now says what happened. The refusal used to be
the utility's own line (
mv: cannot stat …), which says nothing about a parallel install or about the data being intact. The cause is asked of the server rather than parsed out of the message: a target that vanished between the survey and the replacement was moved away by someone else (nothing was changed here), a target that could not be taken was claimed by someone else (the prepared copy stayed where it was, and its address is in the warnings). The utility's line is kept afterDetails:. - The search for leftovers next to the target no longer passes a cut-off listing off as the whole picture. The transport reports when output hit the 10 MiB limit; that flag now travels with the list, and the answer says the search was incomplete — including when nothing was found, since an empty list out of a cut-off answer proves nothing. Measured on a directory of 40 001 files whose listing is 10 680 045 bytes: before, debian answered with no warning at all.
- A glob pattern in
ssh_log_searchandssh_log_tailnames files again. The pattern is expanded on the server byfindmatching the file name, not by the shell, so the quoting that protects a name with a space,$(…)or a newline stays in place — measured on all three lab servers with such names present. The pattern is supported in the file name only (/var/*/app.logis refused by name), a path that exists under its own name is read as itself, and at most 50 matching files are read, with a note in the answer when there were more. A pattern that matches nothing is refused asno files match "…"instead of the utility's message. - The mount-point check tells "not a mount point" from "there was nothing to check with".
The device numbers are asked for with
stat -c, a GNU and BusyBox option; a server whosestatspeaks another syntax (BSD, macOS) answered nothing, and the silence was read as a clean result. Such a server now installs as before — the check never blocked anything by itself — but the answer carries a note that the check did not run. Measured on debian and alpine withstatreplaced by a BSD-like one: the note appears, and disappears again once the realstatis back. The rename stays the real guard:mv -Tonto a mount point is refused by both coreutils (Device or resource busy) and BusyBox (Resource busy), measured on a live bind mount, with the target left intact. - Disk, memory and listener readings are no longer taken by column position. The disk
overview of
ssh_snapshotpicked its rows by device name (^/dev/), so the root filesystem was missing on every container — where it sits on overlay — and the list showed the files bind-mounted over it instead. It now readsdf -hT, drops kernel pseudo filesystems by type and shows one row per device. A filesystem name too long for its column, whichdfwraps onto a second line, keeps its name; a row neither tool can read is printed asNOT CHECKEDinstead of disappearing.availablememory is read by column name, sofreefrom procps older than 2014 — which has no such column — reportsn/arather than its cache size. A listening port is taken after the last colon of the address, so an IPv6-only listener ([::]:2222) is no longer skipped. - A working directory the server cannot enter now stops the call.
cwdused to be joined ascd <dir> && <command>, and&&binds only up to the first;: everything after it ran in the home directory and the call ended with code 0.cwd: "/opt/app"with a typo andrm -rf ./cache; systemctl restart appdeleted files where nobody asked and reported success. The join is nowcd <dir> || exit 1; <command>— the command text is left untouched — and a failedcdends every call shape with a non-zero code: single command,;-chain, batch,sudo, and a detached job (whoseexit_codeis non-zero as well). - A refused destructive command is marked as a failure (
isError). The wording of the refusal is unchanged, but a client that reads the flag rather than the text no longer takes a blockedrm -rffor a completed one. - A batch of files that failed completely is a failed call.
ssh_file_readandssh_file_writeused to answerRead 2 files:over two errors, without the failure flag, because the header counted what was attempted. It now counts what succeeded —Read 0/2 files:— and zero successes marks the call as failed, exactly as the single-file form already did. A partial result stays a success with ✓/✗ per file. ssh_job_outputandssh_job_killtell a missing job from an empty one. A job id from another server (or a typo) used to answer0 bytes totalandnever recorded a pid; both now sayno such job on the server, the same answerssh_job_statusalready gave.ssh_audit_baselineno longer counts services and updates it could not count.0 running servicesand0 upgradable packagesappeared on servers with nosystemctl, noapt, or a systemd that does not answer. Those sections are now left out and listed inunavailablewith the reason; a real zero from a workingaptis still reported as zero.ssh_snapshottells a silent systemd from a server without services. Withsystemctlinstalled but no systemd running, every probe answered with an error that|| echo inactiveturned into "stopped", and the report printedNo active services detected. The snapshot now asks the bus itself first and saysNOT CHECKED: systemd did not answer on this server; servers withoutsystemctlkeep their previous wording.
Known limitations
These ship with the release. Each is recorded in docs/tech-debt/ with measurements.
- Cancelling a call does not stop the work on the server. Cancellation now reaches the
transport and drops the local
sshclient immediately, but a command already started on the machine runs to its end — closing the channel does not kill what is behind it (measured). A long transfer still runs until itstimeout, set before it starts. File transfers and the system snapshot do not take cancellation at all, deliberately: one has a window where stopping would leave the target empty, the other would come back with blanks instead of a refusal (TD-08). Work that has to be stoppable from outside can be started withdetach: trueand stopped byssh_job_kill— that path knows the pid. - The mount-point check needs a
statthat speaks the GNU or BusyBox syntax. On a server whosestatdiffers (BSD, macOS), the check does not run and says so; the install proceeds, and the rename stays the real guard (TD-09).
[1.3.2] - 2026-06-20
Fixed
ssh_upload/ssh_execwithsudo: trueno longer fail on shell constructs. Previouslysudo <command>was prepended literally, so any command containing a subshell(...),if/elif/fi, or a pipe (e.g. the sha256 verify command) producedbash: syntax error near unexpected token '('and the upload's integrity check was silently skipped. Sudo commands are now wrapped assudo bash -c '<command>', so the remote shell interprets the construct under root. Non-sudo commands are unchanged — they already run through the remote login shell viaclient.exec.- File:
src/managers/ssh-executor.ts(execute)
- File:
1.3.1 - 2026-05-03
Documentation
- README: expanded v1.3.0 tool documentation
ssh_audit_baseline— added flags table (include,include_sudo_sections,compact), output format description, subset-include examplessh_disk_breakdown— added sections table (df,du_path, docker, journald,var_log, cache) and defaultsssh_service_status— added sections table (status,is_enabled, show, log) and unit name validation note- Transfer section — added file-size guidance (when to use
ssh_file_writevsssh_upload) ssh_upload/ssh_download— replaced inline parameter lists with full tables- Caveat extended for
recursive + sudoworkflow and symlink behavior on recursive uploads - Installation: added
npxvariant and npm link
- LICENSE / package.json / README: removed author full name (kept GitHub handle
@hypnosis)
1.3.0 - 2026-05-03
Added — Transfer & Audit Sprint
SFTP Transfer Tools (Sprint 6) 📦
ssh_upload— binary-safe file/directory upload through SFTP with sha256 verify and atomic renamessh_download— binary-safe download with sha256 verify- Native ssh2 SFTP channel piggy-backed on the existing connection pool — no extra deps
- Concurrent fastPut chunks (default concurrency=4)
- Atomic semantics: temp file next to target + mv (avoids EXDEV across FS borders)
- sudo path: stage in /tmp under user →
sudo install -m mode -o owner src dst - sha256 fallback: tries sha256sum, falls back to openssl dgst -sha256
Audit Tools (Sprint 6) 🔍
ssh_audit_baseline— single-batch baseline: hostname, disk, mem, net listeners, sshd config, services, docker, firewall, updates with auto-classification CRITICAL/WARNING/OKssh_tls_check— TLS expiry + SAN match + issuer chain + Let's Encryptrenew_hookdetectionssh_disk_breakdown— top-N largest dirs + docker df + journald + cache breakdownssh_service_status— combined systemctl status + journalctl tail in one call
ssh_file_write extensions (back-compat)
- New per-file flags:
verify(sha256 after write),atomic(.tmp + rename),binary(content as base64; uploaded via SFTP) - Routing: any of verify/atomic/binary OR size > 256KB → SFTP path; otherwise legacy heredoc fast path
- sudo write: stage in /tmp +
sudo install(avoids sftp under root)
ssh_file_read extensions (back-compat)
- New
binary: true— reads via SFTP, returns base64 (binary-safe; legacycatover PTY corrupts binaries)
Changed
- Connection pool: added
getSftp()helper aroundclient.sftp()for tool reuse - SSHManager.uploadFile/downloadFile: implemented (were
throw new Error('not implemented yet')) - Total tool count: 8 → 14
Technical Details
- New file:
src/tools/transfer-tool.ts— TransferTool withssh_upload,ssh_download - New file:
src/tools/audit-tool.ts— AuditTool with 4 audit primitives - New file:
src/utils/sha256.ts— local + remote hashing helpers - New file:
src/utils/tmp-name.ts— atomic temp/staging path generators - New tests:
tests/unit/sha256.test.ts(8 tests),tests/unit/tmp-name.test.ts(9 tests) - 77 total tests passing
Documentation
- New: docs/transfer.md (SFTP transfer guide)
- New: docs/audit.md (audit tools guide)
- README.md: updated tool count and added Transfer/Audit sections
1.2.2 - 2026-01-12
Note on version jump (1.0.1 → 1.2.2): This release combines changes from multiple development sprints that were documented in CHANGELOG as versions 1.1.0, 1.1.1, 1.2.0, and 1.2.1, but were never released as separate git tags or npm packages. To maintain consistency with the Keep a Changelog standard (every version in CHANGELOG must have a corresponding git tag), all these changes have been consolidated into version 1.2.2.
Why not 2.0.0? This is not a breaking change release. All changes are backward compatible - existing functionality continues to work, and new features are additive. According to Semantic Versioning, breaking changes would require a major version bump (2.0.0). The jump from 1.0.1 to 1.2.2 reflects the accumulation of multiple minor feature releases (Sprints 2-5) that were developed but not individually released.
Added - Major Features Update (Sprints 2-5) 🚀
Sprint 2: Connection Pool & Performance 🚀
- Connection Pool (Singleton) - Reuse SSH connections for all commands
- Keep-alive mechanism - Automatic pings every 10 seconds to maintain connection
- Auto-reconnect - Automatic reconnection on connection loss
- Idle cleanup - Automatic closure of unused connections after 30 seconds
- Graceful shutdown - Proper closure of all connections on server stop
- Pool metrics - Pool usage statistics (cache hit/miss, reconnects, total commands)
- ⚡ 6-10× faster for sequential commands (16s → 2.5s for 10 commands)
- ⚡ 6-10× faster for batch operations
- ⚡ Cache hit rate >80% for repeated commands
- ⚡ One connection per profile instead of N connections for N commands
Sprint 3: Path Security & Tilde Expansion 🛡️
- Tilde Expansion:
- Automatic tilde expansion -
~/fileautomatically expands to$HOME/fileon remote server - Support for ~user paths -
~username/fileproperly handled by shell - Works in all file/log tools -
ssh_file_read,ssh_file_write,ssh_file_list,ssh_log_tail,ssh_log_search
- Automatic tilde expansion -
- Path Security (Optional):
- PathValidator utility - New optional security layer for path validation
- Whitelist support -
allowedPathsto restrict access to specific directories - Blacklist support -
deniedPathsto block access to sensitive paths - Path traversal protection -
allowTraversal: falseprevents../attacks - Path length limits -
maxPathLengthto prevent extremely long paths - Per-profile configuration - Add
pathSecurityto any SSH profile
- Security Improvements:
- Dual quoting strategy - Single quotes (default) for safety, double quotes only for
$HOME - Comprehensive escaping - Prevents variable expansion, command substitution, history expansion
- Injection protection - Safe handling of special characters in paths (
;,$,`,!)
- Dual quoting strategy - Single quotes (default) for safety, double quotes only for
Sprint 4: Timeout & Error Handling 🔧
- Retry Mechanism:
- Automatic retry - Connection failures automatically retry up to 3 times
- Exponential backoff - Delays increase progressively (1s, 2s, 4s)
- Smart retry logic - Retries only temporary errors (ECONNREFUSED, ETIMEDOUT, ENOTFOUND)
- No retry for auth errors - Authentication failures fail immediately (no wasted attempts)
- Enhanced Error Messages:
- ECONNREFUSED - "Check if SSH server is running and port is correct"
- ETIMEDOUT - "Check firewall rules and network connectivity"
- ENOTFOUND - "Check hostname/IP address in profile configuration"
- Authentication failed - "Check username, SSH key path, and passphrase"
- Invalid SSH key - "Check file exists and has correct permissions (600)"
- Timeout after retries - "Check network connectivity and SSH server availability"
Sprint 5: Profiles Reload & Monitoring 📊
- Profile Reload:
- Automatic reload - SSH profiles reload automatically when
SSH_PROFILES_FILEchanges - File watcher - Monitors profile file for changes (can be disabled with
SSH_MCP_PROFILES_WATCH=false) - Cache with TTL - Profiles cached for 60 seconds (configurable with
SSH_MCP_PROFILES_CACHE_TTL) - Manual reload -
ssh_monitor(action="reload")to force reload profiles - Config change detection - ConnectionPool detects profile config changes and reconnects automatically
- Automatic reload - SSH profiles reload automatically when
- Monitoring Tool (
ssh_monitor):- stats - Get connection pool statistics (cache hit rate, active connections, metrics)
- reload - Reload SSH profiles without server restart
- test - Test connection to profile with timing metrics
- list - List all available profiles with default marked
- Enhanced Logging:
- Context logger -
logger.context('ModuleName')for scoped logging - Performance timer -
logger.time('label')for measuring operation duration - Configurable logging -
SSH_MCP_LOG_LEVEL,SSH_MCP_LOG_TIMESTAMP,SSH_MCP_LOG_COLORS
- Context logger -
Environment Variables:
SSH_MCP_LOG_LEVEL- Log level: debug, info, warn, error (default: info)SSH_MCP_LOG_TIMESTAMP- Show timestamps: true, false (default: true)SSH_MCP_LOG_COLORS- Enable colors: true, false (default: false)SSH_MCP_POOL_IDLE_TIMEOUT- Connection idle timeout in ms (default: 30000)SSH_MCP_POOL_KEEPALIVE_INTERVAL- Keep-alive interval in ms (default: 10000)SSH_MCP_PROFILES_CACHE_TTL- Profile cache TTL in ms (default: 60000)SSH_MCP_PROFILES_WATCH- Watch profiles file: true, false (default: true)
Changed - Performance & Developer Experience
- SSHManager - Uses ConnectionPool instead of creating new connection for each command
- SSHExecutor - Delegates connection management to SSHManager with pool
- executeBatch - Optimized to use single connection for all commands
- All Tools - Pass profileName for connection identification in pool
- profile-resolver.ts - Profiles now cached with TTL and auto-reload support
- connection-pool.ts - Detects profile config changes and reconnects automatically
- logger.ts - Enhanced with context logger and performance timer
- index.ts - Added MonitoringTool registration (8 tools total now)
- file-tools.ts - Added
expandRemoteTilde(),escapeForSingleQuotes(),escapeForDoubleQuotes(),buildSafeCommand() - log-tools.ts - Added
expandRemoteTilde(),escapeForSingleQuotes(),escapeForDoubleQuotes(),buildSafePath() - All file/log operations - Now use secure quoting strategy with proper escaping
- ConnectionPool - Integrated retry mechanism with exponential backoff
- createConnection() - Wrapped in
retryWithTimeout()for automatic retry - connectClient() - Separated single connection attempt for retry logic
- Error messages - Enhanced with specific troubleshooting hints
Fixed
- Connection Pool metrics - Fixed negative
activeConnectionsvalue (now calculated dynamically from pool size) - Session metrics auto-reset - Metrics automatically reset when all connections close (session-based metrics, prevents memory leak)
- Race condition - Fixed race condition in timeout handler (resolveOnce/rejectOnce in ssh-manager.ts)
- ISSUE-001 - Tilde (
~) not expanding in file paths (now works correctly) - ARCH-004 - Incomplete path escaping (now escapes all special characters)
- ARCH-005 - No path validation (now optional PathValidator available)
- ARCH-007 - Profiles singleton without reload (now auto-reload with file watcher)
- Temporary network failures - Now automatically retry instead of failing immediately
- Authentication errors - No longer retry (fail fast with helpful message)
- Timeout errors - Clear error messages with context about what failed
- No monitoring - Added
ssh_monitortool for diagnostics and stats - No profile reload - Profiles reload automatically or manually without server restart
Technical Details
- New file:
src/managers/connection-pool.ts- ConnectionPool Singleton for connection management - New file:
src/tools/monitoring-tool.ts- MonitoringTool with 4 actions - New file:
src/utils/path-validator.ts- PathValidator class with security rules - New tests:
tests/unit/path-security.test.ts- Comprehensive tests for tilde expansion and path validation - File watcher using Node.js
fs.watch()for instant profile reload - Profile cache with TTL fallback if file watcher fails
Map<profileName, PooledConnection>for storing active connections- Automatic cleanup of idle connections every 10 seconds
- Thread-safe access to pool via async locks
- Logging of all pool operations (cache hit/miss, reconnects, cleanup)
- retry.ts - Integrated into ConnectionPool
- createSSHRetryPredicate() - Fixed to check auth errors first (case-insensitive)
- retryWithTimeout() - 3 attempts, 10s timeout per attempt, exponential backoff
- Error context - All errors include host, port, username for debugging
- 22 new tests - Comprehensive error handling test suite
- 60 total tests - All passing ✅
Documentation
- Added Environment Variables section to README.md
- Added
ssh_monitorexamples to README.md - Updated tool count from 7 to 8 commands
- Documented profile reload behavior
- Added Path Security section to README.md with configuration examples
- Updated BUGLIST.md - closed ISSUE-001, ARCH-004, ARCH-005
- Added Security section to README.md explaining quoting strategy
- Documented tilde expansion in usage examples
- Error handling improvements documented
- Retry mechanism explained
1.0.1 - 2026-01-12
Added
- Input validation for array parameters in all tools (
ssh_exec,ssh_file_read,ssh_log_tail,ssh_log_search) - Centralized array validator utility (
array-validator.ts) for reusable validation logic - Clear error messages when using single quotes instead of double quotes in arrays
- Improved description in
ssh_exectool schema with examples of correct syntax - Documentation about array syntax requirements in README.md
- Array validator documentation (
docs/ARRAY_VALIDATOR.md)
Fixed
- Fixed issue where array commands with single quotes (
['cmd1', 'cmd2']) caused parsing errors - Added validation to prevent execution of malformed command arrays
Changed
- Refactored array validation to centralized utility (DRY principle)
- Applied validation to all tools with array parameters for consistency
Documentation
- Added array syntax guidelines to README.md usage examples
- Updated
DEBUG_BATCH_EXEC.mdwith problem solution and explanation - Added
ARRAY_VALIDATOR.mdwith validator API documentation - Improved tool descriptions for AI assistants with explicit examples
1.0.0 - 2026-01-XX
Added
- Initial release
- 7 core SSH tools (exec, file operations, logs, snapshot)
- SSH profile support from JSON file
- Retry logic for network errors
- Security warnings for dangerous commands
- sudo support for all commands