multi-host-mcp
v0.2.3
Published
MCP server connecting to multiple remote hosts via SSH or WSL
Readme
multi-host-mcp
An MCP server that connects to multiple remote hosts via SSH or WSL through a single server instance. Instead of maintaining a separate MCP server configuration for each machine, you define all your hosts in one config file and get a full set of per-host tools automatically. Each tool is prefixed with the host name (prod_exec, prod_readTextFile, etc.) so the LLM always knows which machine it is targeting.
Installation
npm install -g multi-host-mcpOr run without installing:
npx multi-host-mcpQuick start
Create .multi-host.json in your project directory:
{
"$schema": "https://unpkg.com/multi-host-mcp/schema/config.schema.json",
"hosts": {
"prod": {
"type": "ssh",
"host": "prod.example.com",
"user": "deploy",
"key": "~/.ssh/id_ed25519"
},
"wsl": {
"type": "wsl",
"distro": "Ubuntu"
}
}
}Then start the server:
multi-host-mcpThe server discovers .multi-host.json in the current working directory automatically. No flag is needed.
Config file
The config file format is defined by schema/config.schema.json (published with the package). Add the $schema key to get editor validation and autocompletion.
Structure
{
"$schema": "https://unpkg.com/multi-host-mcp/schema/config.schema.json",
"defaults": {
"timeout": 30000,
"ssh": {
"user": "deploy",
"port": 22
},
"wsl": {
"distro": "Ubuntu"
}
},
"hosts": {
"prod": {
"type": "ssh",
"host": "prod.example.com",
"key": "~/.ssh/id_ed25519"
},
"staging": {
"type": "ssh",
"host": "staging.example.com"
},
"local": {
"type": "wsl"
}
}
}Field resolution order
Each field resolves through four tiers, highest priority first:
- Host-level value (e.g.,
hosts.prod.timeout) - Type-scoped default (e.g.,
defaults.ssh.timeout) - Top-level default (e.g.,
defaults.timeout) - Hardcoded default (see table below)
| Field | Hardcoded default |
|-------|-------------------|
| enabledTools.* | true (all 7 tools enabled) |
| autoConnect | false |
| timeout | 60000 (ms) |
| maxChars | 1000 |
| toolList | 'dynamic' |
| port (SSH) | 22 |
| user (SSH) | current OS username |
| connectTimeout (SSH) | 30 (seconds) |
toolList
Controls which per-host SSH tools are visible, and when.
| Value | Behavior |
|-------|----------|
| 'dynamic' (default) | Only {name}_connect is listed while a host is disconnected; {name}_disconnect and the operational tools replace it once connected, and the surface reverts if the connection drops. This is the current, unchanged behavior. |
| 'full' | {name}_connect, {name}_disconnect, and the enabled operational tools are all registered once at server startup and the list never changes afterward. Operational tools auto-connect on first use if the host is not yet connected. |
toolList is a shared field and resolves through the standard 4-tier order: per-host toolList, the type-scoped default (defaults.ssh.toolList or defaults.wsl.toolList), the top-level defaults.toolList, then the hardcoded default 'dynamic'. Setting it under defaults applies it to every host at once.
Motivation: some MCP clients (for example, Claude Cowork) do not honor notifications/tools/list_changed and only ever see the tool list captured at the initial connection. Under toolList: 'dynamic', those clients never discover a host's operational tools unless it happened to already be connected when the client fetched its first tool list. Set toolList: 'full' (per host, or defaults.toolList for the whole server) so every tool is visible from that first call, with no dependency on connection timing.
WSL hosts accept toolList for schema consistency but treat it as a no-op — the WSL tool surface is always the full operational set and never changes.
{
"defaults": {
"toolList": "full"
},
"hosts": {
"prod": {
"type": "ssh",
"host": "prod.example.com",
"user": "deploy",
"key": "~/.ssh/id_ed25519"
}
}
}Host name format
Host keys must match ^[a-z][a-z0-9_-]*$ (lowercase letter, then lowercase letters, digits, underscores, or hyphens) and must be between 1 and 64 characters.
Environment variable substitution
${ENV_VAR} references inside config file contents are substituted at load time — after JSON parsing, before schema validation — on all three config sources (--config, --config-url, --config-json). This is distinct from shell or path expansion: bare $VAR (without braces) is never expanded and stays literal.
Syntax
| Form | Behavior |
|------|----------|
| ${VAR} | Value of VAR if set (even if empty), otherwise empty string. |
| ${VAR:-default} | Value of VAR if set and non-empty, otherwise default. |
| ${VAR-default} | Value of VAR if set (even if empty), otherwise default. |
The load-bearing distinction between :- and - is the "set but empty" case: ${VAR:-default} falls through to default when VAR="", while ${VAR-default} uses the empty value.
Rules
- Braces mandatory. Bare
$VARis left as-is (prices, shell variables in command strings, etc.). - Variable name format:
[A-Za-z_][A-Za-z0-9_]*. A reference like${1BAD}does not match and stays literal. - String values only. Substitution applies to string values at any depth; object keys are never substituted. Non-string values (numbers, booleans,
null) pass through untouched. - Partial and multiple references per string.
"prefix-${VAR}-suffix"and"${A} and ${B}"both work. - Missing vars silently become empty string. An empty value that fails schema validation produces the usual
INVALID_CONFIG_SHAPEorMISSING_SSH_HOSTerror. - No type coercion. Wrapping a numeric field in
${...}(e.g.,"port": "${SSH_PORT}") produces a string. The schema rejects it withINVALID_CONFIG_SHAPE. Use numeric literals forportand similar fields. - Env values are never logged. Error messages reference JSON paths only, not substituted content.
Example: credentials via MCP client env block
MCP clients let you pass an env block to the server process. Use it to inject credentials without storing them in the config file.
MCP client configuration (e.g., Claude Desktop claude_desktop_config.json):
{
"mcpServers": {
"multi-host-mcp": {
"command": "npx",
"args": ["multi-host-mcp"],
"cwd": "/path/to/your/project",
"env": {
"MY_SSH_USERNAME": "deploy",
"MY_SSH_KEY_PATH": "/home/deploy/.ssh/id_ed25519"
}
}
}
}.multi-host.json referencing those variables:
{
"$schema": "https://unpkg.com/multi-host-mcp/schema/config.schema.json",
"hosts": {
"prod": {
"type": "ssh",
"host": "prod.example.com",
"user": "${MY_SSH_USERNAME}",
"key": "${MY_SSH_KEY_PATH:-~/.ssh/id_ed25519}"
}
}
}At load time ${MY_SSH_USERNAME} resolves to "deploy" and ${MY_SSH_KEY_PATH:-~/.ssh/id_ed25519} resolves to "/home/deploy/.ssh/id_ed25519" (or falls back to "~/.ssh/id_ed25519" if the variable is absent or empty). The resolved values are passed to the schema validator; neither value is written to any log.
CLI flags
multi-host-mcp [--config <path>] | [--config-url <url>] | [--config-json <json>]The three flags are mutually exclusive. Combining any two raises CONFLICTING_CONFIG_SOURCES.
| Flag | Description |
|------|-------------|
| --config <path> | Read config from a file path. Supports ~/ expansion and CWD-relative paths. |
| --config-url <url> | Fetch config from an HTTPS URL. Strict TLS; 15-second timeout; no HTTP auth. |
| --config-json <json> | Pass config as an inline JSON string. Must be a JSON object (starts with {). |
| (none) | Look for .multi-host.json in the current working directory. |
When no flag is given and no .multi-host.json exists in the CWD, the server exits with NO_CONFIG_SOURCE.
Path handling
All file paths (in --config and in the key field) go through the same resolution:
~/...expands to the current user's home directory.- Relative paths resolve against the current working directory.
- Absolute paths (including Windows drive-relative paths like
C:\...) are used as-is.
MCP tools
Server-scoped tools (always available)
| Tool | Description |
|------|-------------|
| listHosts | List all configured hosts and their connection status. |
Per-host tools
Each host contributes tools named {name}_{tool}. None of the per-host tools accept a hostName argument; the host is encoded in the tool name.
SSH hosts
The following tables describe toolList: 'dynamic' (the default). See the 'full' paragraph below for the alternative.
SSH hosts start disconnected. The available tools change as the connection state changes.
When disconnected:
| Tool | Description |
|------|-------------|
| {name}_connect | Establish an SSH connection to the host. |
When connected:
| Tool | Arguments | Description |
|------|-----------|-------------|
| {name}_disconnect | — | Close the SSH connection. |
| {name}_exec | command (string), workingDir? (string) | Execute a command. Returns stdout, stderr, and exit code. Auto-connects if not connected. |
| {name}_readTextFile | path (string) | Read a text file. |
| {name}_writeFile | path (string), content (string) | Write a file (creates parent directories, overwrites existing). |
| {name}_createDirectory | path (string) | Create a directory and any missing parents. Idempotent. |
| {name}_listDirectory | path (string) | List directory contents with file types. |
| {name}_readMultipleFiles | paths (string[]) | Read multiple files in one call. Per-file success/failure reporting. |
| {name}_searchFiles | path (string), pattern (string), isRegex? (boolean) | Recursive grep. Set isRegex: false for fixed-string matching. |
When the connection drops unexpectedly, the tool surface reverts to {name}_connect and the MCP client receives a tools/list_changed notification.
With toolList: 'full': {name}_connect, {name}_disconnect, and every enabled operational tool listed above are all registered at server startup and stay listed for the lifetime of the server, regardless of actual connection state. Operational tools auto-connect on first use if the host is not yet connected. An unexpected disconnect does not add, remove, or otherwise change any tool, and this host never fires a tools/list_changed notification.
WSL hosts
WSL hosts are always-on. There are no connect/disconnect tools. The operational tools ({name}_exec, etc.) are available immediately and remain available for the lifetime of the server.
Controlling which tools are registered
Use enabledTools to gate which operational tools are registered for a host. A tool with false is never registered and does not appear in the tool list.
{
"hosts": {
"readonly": {
"type": "ssh",
"host": "reports.example.com",
"user": "reader",
"key": "~/.ssh/id_ed25519",
"enabledTools": {
"exec": false,
"writeFile": false,
"createDirectory": false
}
}
}
}SSH connection options
Authentication
The server tries authentication methods in a fixed order: none, publickey, password, agent. Only methods with available credentials are included.
| Config field | Auth method | Notes |
|---|---|---|
| key | publickey | Path to private key file. ~/ expansion applied. |
| passphrase | publickey | Passphrase for the key. Always paired with key. |
| password | password | Plain password auth. |
| agent | agent | SSH agent socket. See below. |
The agent field accepts:
| Value | Resolves to |
|-------|-------------|
| true | pageant on Windows, $SSH_AUTH_SOCK on other platforms |
| "pageant" | Pageant (Windows only) |
| "env:VARNAME" | Value of process.env.VARNAME |
| Any other string | Literal socket path |
ssh -G fallback
At connect time, if the host config does not already supply all of user, and at least one of password, key, or agent, the server runs ssh -G <host> to pull values from ~/.ssh/config. This fallback runs at most once per connect attempt. Fields from ssh -G fill gaps left by the config; configured values always win.
ssh -G resolution is best-effort: if the invocation fails for any reason (the ssh binary is absent, a spawn error occurs, or it exits non-zero), the server treats it as "no ssh -G data," emits a single diagnostic warning to stderr, and falls through to the remaining resolution tiers (configured values, then terminal defaults). It never fails the connection attempt. If configured values and terminal defaults still leave no user or no credential, the existing MISSING_SSH_USER / MISSING_SSH_CREDENTIAL errors apply — those are unrelated to ssh -G availability.
Terminal defaults
If neither the config nor ssh -G supplies a user, the current OS username is used. If neither supplies a port, port 22 is used.
connectTimeout
connectTimeout bounds how long the server waits for an SSH connection attempt to succeed before giving up (the ssh2 readyTimeout). It resolves through the same 3-tier chain as port: per-host connectTimeout, defaults.ssh.connectTimeout, then the hardcoded default 30.
The value is whole seconds, as a positive integer — connectTimeout: 15 means a 15-second timeout, not 15 milliseconds. This is easy to confuse with timeout (see "timeout and maxChars" below), which is a millisecond value for a different budget entirely:
| Field | Unit | What it bounds | Default |
|-------|------|-----------------|---------|
| connectTimeout | whole seconds (integer) | Time to establish the SSH connection | 30 (30s) |
| timeout | milliseconds | Time budget for a single command execution | 60000 (60s) |
Non-integers, 0, and negative numbers are rejected with INVALID_CONFIG_SHAPE.
autoConnect
Set autoConnect: true on an SSH host to connect automatically when the server starts. All autoConnect: true hosts are attempted in parallel, and the server waits for every attempt to settle (succeed or fail) before it starts serving requests — so the initial tools/list response already reflects which hosts ended up connected.
A failed attempt is logged to stderr and that host simply starts disconnected; startup itself never fails because of an autoConnect failure. Under toolList: 'dynamic' (the default), a host that failed to auto-connect keeps its {name}_connect tool listed for manual retry.
Because startup waits for every attempt to settle, the worst-case added startup delay is the slowest autoConnect host's connectTimeout (default 30 seconds). If your MCP client enforces a short startup timeout, lower connectTimeout on the relevant hosts so the server finishes starting before the client gives up waiting.
timeout and maxChars
timeout (milliseconds) is the per-command execution budget. When a command exceeds the budget, the stream is terminated and COMMAND_TIMEOUT is raised.
maxChars limits command string length before the remote is contacted. Values of null, 0, or any negative number disable the limit. The default is 1000.
Error codes
These codes appear in error messages when the server cannot start or a connect-time operation fails.
Load-time errors
| Code | Trigger |
|------|---------|
| NO_CONFIG_SOURCE | No --config* flag and no .multi-host.json in CWD. |
| CONFLICTING_CONFIG_SOURCES | More than one --config* flag supplied. |
| CONFIG_FILE_NOT_FOUND | --config path does not exist or is not readable. |
| CONFIG_FETCH_FAILED | --config-url fetch failed (network error, timeout, or non-2xx HTTP status). |
| INVALID_JSON | Config text is not valid JSON. |
| INVALID_CONFIG_SHAPE | Config parses as JSON but fails Zod schema validation. details contains the full Zod issue list. |
| INVALID_HOST_NAME | A host key does not match ^[a-z][a-z0-9_-]*$ or exceeds 64 characters. |
| NO_HOSTS_CONFIGURED | hosts object is present but empty. |
| MISSING_TYPE | A host has no type field and defaults.type is not set. |
| MISSING_SSH_HOST | An SSH host has no host value after config resolution. |
Connect-time errors
| Code | Trigger |
|------|---------|
| MISSING_SSH_USER | No user found after config, ssh -G, and terminal defaults. |
| MISSING_SSH_CREDENTIAL | No key, password, or agent after full resolution. |
| MISSING_SSH_PORT | Port absent after resolution (defensive; normally unreachable since defaults to 22). |
| SSH_G_BLANK_HOST | ssh -G ran but returned a blank hostname. |
| INVALID_AGENT_VALUE | agent: "pageant" configured on a non-Windows platform. |
| COMMAND_TIMEOUT | Command exceeded timeout milliseconds. |
| COMMAND_TOO_LONG | Command string length exceeds maxChars. |
Project integration
Drop .multi-host.json in your project root, add it to .gitignore, and point your MCP host at multi-host-mcp. Any team member with the appropriate SSH keys can use the same config file with their own credentials by placing keys in their home directory and relying on the ~/ expansion.
echo ".multi-host.json" >> .gitignoreMCP host configuration example (Claude Desktop):
{
"mcpServers": {
"multi-host-mcp": {
"command": "npx",
"args": ["multi-host-mcp"],
"cwd": "/path/to/your/project"
}
}
}