Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.4] - 2026-04-12

### Added

- **`ssh-mcp healthcheck` CLI subcommand** — built-in liveness probe that auto-detects transport (stdio vs streamable-http), respects auth env vars (`SSH_MCP_HTTP_TOKEN`, `SSH_MCP_HTTP_TOKEN_FILE`, `SSH_MCP_HTTP_AUTH=none`), and performs a real MCP `initialize` handshake in HTTP mode. Uses Python stdlib only, 3-second timeout, exits 0 healthy / 1 unhealthy.
- New module `src/ssh_mcp/healthcheck.py` with unit tests in `tests/test_healthcheck.py`.

### Changed

- **Dockerfile `HEALTHCHECK`** now uses the built-in `ssh-mcp healthcheck` subcommand instead of the old `python -c "import ssh_mcp"` liveness-only check. The new check performs a real MCP protocol handshake in HTTP mode, so a container marked "healthy" actually means the MCP tools respond correctly — not just that the Python package is importable.
- **`compose.yaml`** — removed the ~40-line inline Python healthcheck block from the commented `ssh-mcp-http` service template. Operators no longer need to embed credential-handling Python in their compose files. The Dockerfile's baked-in HEALTHCHECK handles both stdio and HTTP modes automatically.

### Fixed

- Eliminates the pain point where upgrading the healthcheck required editing every operator's compose.yaml.

## [0.5.3] - 2026-04-12

### Security
Expand Down Expand Up @@ -271,7 +287,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tilde expansion for config file paths
- Packaged for distribution via PyPI; installable with `uvx ssh-mcp`

[Unreleased]: https://github.com/blackaxgit/ssh-mcp/compare/v0.5.3...HEAD
[Unreleased]: https://github.com/blackaxgit/ssh-mcp/compare/v0.5.4...HEAD
[0.5.4]: https://github.com/blackaxgit/ssh-mcp/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/blackaxgit/ssh-mcp/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/blackaxgit/ssh-mcp/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/blackaxgit/ssh-mcp/compare/v0.5.0...v0.5.1
Expand Down
17 changes: 10 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ USER sshmcp
# Override via SSH_MCP_HTTP_PORT env var and republish with -p.
EXPOSE 8000

# HEALTHCHECK: Python-based import check (slim image has no `ps`)
# Verifies the ssh_mcp package is importable — signals the runtime is healthy.
# For HTTP transport, operators may prefer a curl-based probe against
# http://127.0.0.1:${SSH_MCP_HTTP_PORT:-8000}/mcp but curl is not in the slim
# image, so the import check is the portable default.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import ssh_mcp" || exit 1
# HEALTHCHECK: use the built-in ``ssh-mcp healthcheck`` subcommand which
# auto-detects transport (stdio vs streamable-http) and performs a real
# MCP initialize handshake in HTTP mode. Reads the same env vars as the
# server itself (SSH_MCP_TRANSPORT, SSH_MCP_HTTP_PORT, SSH_MCP_HTTP_TOKEN,
# SSH_MCP_HTTP_TOKEN_FILE, SSH_MCP_HTTP_AUTH).
#
# start_period=10s covers startup for HTTP transport (FastMCP session
# manager init + uvicorn bind). interval=30s is standard.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ssh-mcp healthcheck

# Entry point: invoke the console script installed by uv
ENTRYPOINT ["ssh-mcp"]
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,43 @@ Host: ssh-mcp.internal

For stateful sessions (default), FastMCP maintains per-client context across requests. For stateless deployments behind a load balancer, set `SSH_MCP_HTTP_STATELESS=true` — each request is handled independently with no server-side session.

### Healthcheck

The Docker image includes a built-in `ssh-mcp healthcheck` CLI subcommand that
Docker's `HEALTHCHECK` directive invokes automatically. No inline Python, no
`curl`, no manual compose surgery required. The subcommand:

- Auto-detects the transport via `SSH_MCP_TRANSPORT`:
- **stdio mode**: verifies the package imports and `servers.toml` parses
- **http mode**: sends a real MCP `initialize` JSON-RPC POST and checks for any non-5xx response
- Reads the same auth env vars as the server (`SSH_MCP_HTTP_TOKEN`, `SSH_MCP_HTTP_TOKEN_FILE`, `SSH_MCP_HTTP_AUTH`) — never logs the token
- Exits 0 if healthy, 1 otherwise
- Uses Python stdlib only (no `curl`/`wget` dependency)
- 3-second hard timeout per probe

Run manually for debugging:

```bash
docker exec ssh-mcp ssh-mcp healthcheck && echo "healthy"
```

Check current status:

```bash
docker inspect ssh-mcp --format '{{.State.Health.Status}}'
```

To override the baked-in settings in your compose file:

```yaml
healthcheck:
test: ["CMD", "ssh-mcp", "healthcheck"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
```

### Reverse proxy deployment (auth at the edge)

If your reverse proxy (Caddy, nginx, Traefik, Envoy, Cloudflare Access, etc.) already authenticates requests before they reach ssh-mcp, you can disable the built-in bearer middleware with `SSH_MCP_HTTP_AUTH=none`. This mode is deliberately hard to enable on a public bind — you must also set a verbose acknowledgement env var:
Expand Down
62 changes: 11 additions & 51 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,55 +109,15 @@ services:
# env_file:
# - .env.ssh-mcp
# restart: unless-stopped
# # Healthcheck: a proper MCP ``initialize`` POST request.
# # Healthcheck: inherited from the Dockerfile ``HEALTHCHECK`` directive
# # which runs ``ssh-mcp healthcheck``. The subcommand auto-detects the
# # transport from SSH_MCP_TRANSPORT and performs a real MCP initialize
# # handshake in HTTP mode — no inline Python or curl needed.
# #
# # We send the minimum valid JSON-RPC ``initialize`` payload with the
# # required Accept header and a valid bearer token. The healthcheck
# # passes on any 2xx/4xx response (the MCP server is processing
# # requests) and fails only on 5xx, timeout, or connection refused.
# #
# # Why we can't just use GET:
# # * MCP streamable HTTP rejects GET with 406 Not Acceptable unless
# # the client sends ``Accept: application/json, text/event-stream``.
# # * 406 is still a sign the server is alive, so technically any
# # non-5xx is "healthy" — but a real ``initialize`` POST gives
# # a much stronger signal that the MCP protocol is working.
# healthcheck:
# test:
# - CMD-SHELL
# - |
# python3 -c "
# import json, os, sys, urllib.request
# port = os.environ.get('SSH_MCP_HTTP_PORT', '8000')
# token = os.environ.get('SSH_MCP_HTTP_TOKEN', '')
# payload = json.dumps({
# 'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
# 'params': {
# 'protocolVersion': '2025-03-26',
# 'capabilities': {},
# 'clientInfo': {'name': 'healthcheck', 'version': '1'},
# },
# }).encode()
# req = urllib.request.Request(
# f'http://127.0.0.1:{port}/mcp',
# data=payload,
# method='POST',
# headers={
# 'Authorization': f'Bearer {token}',
# 'Content-Type': 'application/json',
# 'Accept': 'application/json, text/event-stream',
# },
# )
# try:
# urllib.request.urlopen(req, timeout=3).read()
# sys.exit(0)
# except urllib.error.HTTPError as e:
# # Any non-5xx means the MCP app processed the request
# sys.exit(0 if e.code < 500 else 1)
# except Exception:
# sys.exit(1)
# "
# interval: 30s
# timeout: 5s
# retries: 3
# start_period: 10s
# # To override (e.g. shorter interval), uncomment:
# # healthcheck:
# # test: ["CMD", "ssh-mcp", "healthcheck"]
# # interval: 15s
# # timeout: 5s
# # retries: 3
# # start_period: 10s
2 changes: 1 addition & 1 deletion src/ssh_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""SSH MCP Server - Manage infrastructure via Claude Code."""

__version__ = "0.5.3"
__version__ = "0.5.4"
126 changes: 126 additions & 0 deletions src/ssh_mcp/healthcheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Liveness healthcheck for ssh-mcp Docker container.

Invoked as ``ssh-mcp healthcheck`` from the Dockerfile HEALTHCHECK
directive. Exits 0 if the server is healthy, 1 otherwise. Prints a
single diagnostic line to stderr on failure (never logs the token).

Auto-detects transport via ``SSH_MCP_TRANSPORT`` env var:
* ``stdio`` (default): import check + config file parse
* ``http`` / ``streamable-http``: MCP initialize POST handshake
"""

from __future__ import annotations

import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import NoReturn

HEALTHCHECK_TIMEOUT = 3 # seconds


def _load_token() -> str | None:
"""Read bearer token from env or token file. Returns None if neither set."""
raw = os.environ.get("SSH_MCP_HTTP_TOKEN", "").strip()
if raw:
return raw
token_file = os.environ.get("SSH_MCP_HTTP_TOKEN_FILE", "").strip()
if token_file:
try:
return Path(token_file).read_text().strip() or None
except OSError:
return None
return None


def _check_stdio() -> tuple[bool, str]:
"""Verify the package imports and the config file parses.

Returns (ok, diagnostic).
"""
try:
import ssh_mcp # noqa: F401
except ImportError as e:
return False, f"import failed: {e}"
# Try to resolve and parse config if present
config_path = os.environ.get("SSH_MCP_CONFIG", "")
if config_path and Path(config_path).exists():
try:
from ssh_mcp.config import ServerRegistry

ServerRegistry(config_path)
except Exception as e:
return False, f"config parse failed: {type(e).__name__}"
return True, "stdio healthy"


def _check_http() -> tuple[bool, str]:
"""Send MCP initialize POST and verify the server responds.

Returns (ok, diagnostic). Any non-5xx status is considered healthy
(including 401 if auth is misconfigured — the server is clearly alive).
"""
port = os.environ.get("SSH_MCP_HTTP_PORT", "8000")
auth_mode = os.environ.get("SSH_MCP_HTTP_AUTH", "bearer").strip().lower()
token = _load_token() if auth_mode != "none" else None

payload = json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "ssh-mcp-healthcheck", "version": "1"},
},
}
).encode()

headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if token:
headers["Authorization"] = f"Bearer {token}"

url = f"http://127.0.0.1:{port}/mcp"
req = urllib.request.Request(url, data=payload, method="POST", headers=headers) # noqa: S310

try:
# URL is hardcoded http://127.0.0.1:<port>/mcp — not user-controlled
# and not a file:// scheme. Port comes from the validated
# SSH_MCP_HTTP_PORT env var, so B310 (permitted-schemes) doesn't apply.
with urllib.request.urlopen(req, timeout=HEALTHCHECK_TIMEOUT) as resp: # nosec B310 # noqa: S310
return True, f"http {resp.status}"
except urllib.error.HTTPError as e:
# Any 4xx means the server is alive but the request was rejected
# (wrong auth, wrong protocol version, etc.) — still healthy.
if e.code < 500:
return True, f"http {e.code}"
return False, f"http {e.code}"
except urllib.error.URLError as e:
return False, f"connect failed: {type(e.reason).__name__}"
except Exception as e:
return False, f"unexpected: {type(e).__name__}"


def run() -> NoReturn:
"""Entry point invoked by ``ssh-mcp healthcheck`` CLI."""
transport = os.environ.get("SSH_MCP_TRANSPORT", "stdio").strip().lower()
if transport in ("http", "streamable-http"):
ok, diag = _check_http()
else:
ok, diag = _check_stdio()

if not ok:
print(f"ssh-mcp healthcheck: UNHEALTHY ({diag})", file=sys.stderr)
sys.exit(1)
sys.exit(0)


if __name__ == "__main__":
run()
8 changes: 8 additions & 0 deletions src/ssh_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,14 @@ def main() -> None:
a TCP port. Requires ``SSH_MCP_HTTP_TOKEN`` for non-localhost binds.
See ``_run_http`` for the full list of env vars.
"""
# Dispatch subcommands BEFORE any expensive setup.
# The ``healthcheck`` subcommand must NOT touch ``mcp.run`` or open sockets.
if len(sys.argv) >= 2 and sys.argv[1] == "healthcheck":
from ssh_mcp.healthcheck import run as run_healthcheck

run_healthcheck() # exits 0 or 1
return # unreachable but keeps mypy happy

from ssh_mcp import __version__

transport = os.environ.get("SSH_MCP_TRANSPORT", "stdio").strip().lower()
Expand Down
Loading
Loading