Skip to content

ADR 0001: Runtime, SDK, and SSH library

  • Status: Accepted
  • Date: 2026-05-19

Context

relay-shell must speak the Model Context Protocol reliably, run arbitrary local commands and interactive PTYs, and perform full SSH operations (exec, PTY, SFTP, port forwarding, jump hosts). The choice affects protocol correctness, type safety, and how much transport/auth code we own versus delegate.

Decision

  • Python 3.12+ and the official mcp SDK (FastMCP), initially pinned to mcp==1.27.1 (validated by the test suite), with the surrounding stack pinned in requirements.txt. FastMCP provides the protocol, stdio and streamable-HTTP transports, and an OAuth 2.1 hook. The pin moves on a deliberate, tested bump; the current pins are the ones in requirements.txt (tested) and pyproject.toml (supported range), and each bump is listed under Consequences below, not on this line.
  • asyncssh for all SSH. It is pure-Python, asyncio-native, and covers the entire required surface (exec, interactive PTY, SFTP, local/remote/ dynamic forwarding, agent, ProxyJump/tunnels, known_hosts, keepalive) with one dependency and no system ssh shelling.
  • Local execution uses asyncio subprocesses and a non-blocking PTY master driven by the event loop (executor fallback, removed in 0.4.0 as dead code, A14-X3) - stdlib only.

Consequences

  • One protocol dependency, one SSH dependency; both typed. ssh_config semantics (including ProxyJump) come free via asyncssh's config support.
  • Pinning a known-good SDK version trades newest features for reliability; upgrades are deliberate and tested.
  • No reliance on a system ssh binary or its version quirks; behaviour is reproducible across hosts.
  • Pin movement (one line per validated bump; the runbook §8.9 trigger):
  • mcp 1.27.1 → 1.27.2 (PR #66, 2026-06-04). Validated by the full test suite plus the ADR 0005 step-3 upstream-symbol check (FastMCP/Context kwargs and the nine OAuth provider methods resolve unchanged); recorded in the ADR 0005 2026-06-12 outcome.
  • mcp 1.27.2 → 1.28.1 and asyncssh 2.23.1 → 2.24.0 (shipped in v0.3.0, 2026-07-15; pyproject.toml / requirements.txt). The living docs (README status line and compatibility matrix, docs/architecture.md) were reconciled to the same pins (CHANGELOG DOC-1); the release pipeline runs the full test suite as its build gate.
  • mcp 1.28.1 → the v2 line, moving off the exact pin to mcp>=2,<3 (PR #164/#165, 2026-08-12). mcp.server.fastmcp.FastMCP is now mcp.server.mcpserver.MCPServer; host/port/stateless_http/ json_response moved from the constructor to per-transport run() kwargs (http_transport_kwargs() in server.py), and call_tool now returns a CallToolResult instead of a (content, structured) tuple. No change to policy, redaction, audit, patterns, broker, or the Relay.run body; the trust boundary is untouched. Validated by the full test suite (450 passed), ruff check/ruff format --check, and mypy --strict across 20 source files; the living docs (README status line and compatibility matrix, docs/architecture.md) were reconciled to the same range.
  • Tested pins mcp 2.2.0 → 2.3.0 and asyncssh 2.24.0 → 2.24.1 (shipped in v0.4.0, 2026-10-15; requirements.txt, the dev extra and the pre-commit hook in lockstep). The supported ranges stay mcp>=2,<3 and asyncssh>=2.23.0,<3.

Rejected

  • TypeScript MCP SDK: diverges from the Python execution/SSH ecosystem here.
  • Shelling out to system ssh/scp: brittle parsing, version-dependent behaviour, weaker error semantics than asyncssh.
  • paramiko: synchronous core; bridging to async for PTY/forwarding is more code and more failure modes than asyncssh.