npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-mcp

Or run without installing:

npx multi-host-mcp

Quick 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-mcp

The 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:

  1. Host-level value (e.g., hosts.prod.timeout)
  2. Type-scoped default (e.g., defaults.ssh.timeout)
  3. Top-level default (e.g., defaults.timeout)
  4. 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 $VAR is 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_SHAPE or MISSING_SSH_HOST error.
  • No type coercion. Wrapping a numeric field in ${...} (e.g., "port": "${SSH_PORT}") produces a string. The schema rejects it with INVALID_CONFIG_SHAPE. Use numeric literals for port and 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 integerconnectTimeout: 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" >> .gitignore

MCP host configuration example (Claude Desktop):

{
  "mcpServers": {
    "multi-host-mcp": {
      "command": "npx",
      "args": ["multi-host-mcp"],
      "cwd": "/path/to/your/project"
    }
  }
}