Skip to content
Open
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
124 changes: 124 additions & 0 deletions MACOS_LOCAL_NETWORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# macOS Local Network Permission for ssh-mcp-server

## Symptom

SSH calls made through the MCP fail with:

```
ssh: connect to host 192.168.0.11 port 22: No route to host
```

...even though `ssh hass` from Terminal works fine, ping works, and the port is reachable.

The failures hit only hosts on the **Mac's own LAN subnet** (e.g. 192.168.0.x). Hosts reached
through a gateway (192.168.37.x via pfSense) work, because that traffic leaves via the default
route and doesn't trip the local-network check.

## Root cause

Since macOS Sonoma, every process that wants to talk to devices on its own LAN needs the
**Local Network** privacy entitlement (System Settings → Privacy & Security → Local Network).
Processes launched by `launchd` as LaunchAgents cannot show UI prompts, so the grant must either
be pre-existing or triggered by a foreground launch.

Denied access returns `EHOSTUNREACH` (rendered as "No route to host") — not "Permission denied",
which is why this is easy to misdiagnose as a networking problem.

## Previous (too broad) fix

Toggling `node` ON in Local Network settings works, but it grants LAN access to **every** Node.js
process on the Mac, not just ssh-mcp-server. That's more permission than we want.

## Scoped fix: give the server its own binary identity

macOS TCC attributes Local Network grants by signed binary (cdhash) and, for LaunchAgents, by the
plist's `Label`. By launching the MCP server via a **private copy of the Node binary** with its own
ad-hoc signature, we get a distinct TCC identity that appears in the Privacy UI as
`ssh-mcp-server` rather than `node`.

### Steps performed

1. Create `~/bin/` and copy the real Node binary (resolving the Homebrew symlink):

```bash
mkdir -p ~/bin
cp "$(readlink -f /opt/homebrew/bin/node)" ~/bin/ssh-mcp-server
```

2. Homebrew's `node` is a thin launcher that loads `libnode.141.dylib` via an `@loader_path/../lib`
rpath, which won't resolve from `~/bin/`. Add an absolute rpath to the Cellar lib dir:

```bash
install_name_tool -add_rpath \
/opt/homebrew/Cellar/node/<version>/lib \
~/bin/ssh-mcp-server
```

`install_name_tool` invalidates the code signature, so re-sign ad-hoc:

```bash
codesign --force --sign - ~/bin/ssh-mcp-server
```

3. Verify the copy still runs:

```bash
~/bin/ssh-mcp-server --version
```

4. Point the LaunchAgent at the new binary. Edit
`~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist`:

```xml
<key>ProgramArguments</key>
<array>
<string>/Users/yuriy/bin/ssh-mcp-server</string>
<string>/Users/yuriy/git/ssh-mcp-server/index.js</string>
</array>
```

5. Reload the agent:

```bash
launchctl unload ~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist
launchctl load ~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist
```

6. Confirm the running process is the renamed binary:

```bash
ps aux | grep ssh-mcp-server | grep -v grep
# → /Users/yuriy/bin/ssh-mcp-server /Users/yuriy/git/ssh-mcp-server/index.js
```

7. Trigger an SSH call through MCP. If macOS hasn't seen this binary before, a Local Network
prompt should appear for **ssh-mcp-server** — click Allow.

In practice, because the LaunchAgent `Label` (`com.idletoaster.ssh-mcp-server`) is unchanged,
macOS kept the pre-existing grant and no prompt was needed.

8. Open **System Settings → Privacy & Security → Local Network**. Confirm `ssh-mcp-server` is
listed and enabled. The broad `node` entry can be toggled OFF (use the MCP to test a LAN host
like `hass` afterward to verify isolation).

## Maintenance

- **Homebrew node upgrades do NOT propagate** to `~/bin/ssh-mcp-server`. It's a frozen copy. When
the server is upgraded (or if something breaks), re-run steps 1–3 and reload the LaunchAgent.
- **The hardcoded rpath** (`/opt/homebrew/Cellar/node/<version>/lib`) breaks when the Cellar
version directory changes on upgrade. Re-copy or `install_name_tool -rpath <old> <new>`.
- **Ad-hoc signing is sufficient** for user-scope LaunchAgents; no notarization needed.

## Diagnostic quick reference

| Symptom | Meaning |
|---|---|
| `ssh: connect to host X port 22: No route to host` via MCP, but Terminal SSH works | macOS Local Network privacy gate |
| Fails for 192.168.0.x, works for 192.168.37.x | Same subnet = local net check; routed = no check |
| No entry for `ssh-mcp-server` in Privacy UI | Binary has never attempted local network from a prompt-capable context |
| Error is "Permission denied" not "No route to host" | NOT this bug — check SSH keys / config |

## Files touched

- `~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist` (ProgramArguments[0])
- `~/bin/ssh-mcp-server` (new, ad-hoc signed copy of node)
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,34 @@ Add to your Claude Desktop MCP configuration file:

**That's it!** Claude can now execute SSH commands on your remote servers.

### Optional OpenSSH Alias Mode
If you already have trusted hosts configured in `~/.ssh/config`, you can run the server in an alias-restricted mode that only allows approved SSH aliases and reuses your local OpenSSH configuration:

```json
{
"mcpServers": {
"ssh": {
"command": "npx",
"args": ["-y", "@idletoaster/ssh-mcp-server@latest"],
"env": {
"SSH_MCP_MODE": "openssh-alias",
"SSH_MCP_ALLOWED_ALIASES": "host123,staging-box"
}
}
}
}
```

In `openssh-alias` mode:
- Tools accept `hostAlias` instead of raw `host`, `user`, `port`, and `privateKeyPath`
- The server shells out to your local `ssh` binary, so `Host`, `User`, `IdentityFile`, `ProxyJump`, and known-hosts behavior come from your OpenSSH configuration
- Only aliases in `SSH_MCP_ALLOWED_ALIASES` are permitted

Optional environment variables:
- `SSH_MCP_OPENSSH_BINARY` - path to the SSH binary (default: `ssh`)
- `SSH_MCP_OPENSSH_CONFIG` - explicit SSH config path (default: OpenSSH default lookup)
- `SSH_MCP_OPENSSH_TIMEOUT_MS` - per-command timeout in milliseconds (default: `300000`)

---

## 💬 Usage Examples
Expand All @@ -76,6 +104,18 @@ Once configured, Claude can help you with commands like:
}
```

Alias mode example:

```json
{
"tool": "remote-ssh",
"arguments": {
"hostAlias": "host123",
"command": "uname -a"
}
}
```

---

## 🔧 Configuration
Expand Down Expand Up @@ -166,7 +206,9 @@ ssh-mcp-server/
├── package.json # NPM configuration & dependencies
├── index.js # Main MCP server (Official SDK)
├── lib/
│ └── ssh-client.js # SSH connection management
│ ├── ssh-client.js # Direct ssh2 connection management
│ ├── openssh-client.js # OpenSSH alias-mode transport
│ └── remote-commands.js# Shared remote command builders for alias mode
├── README.md # Documentation
├── LICENSE # MIT license
└── .gitignore # Node.js gitignore
Expand All @@ -176,6 +218,7 @@ ssh-mcp-server/
- **Runtime**: Node.js 18+ with ES Modules
- **MCP SDK**: @modelcontextprotocol/sdk (Official)
- **SSH**: ssh2 library for Node.js
- **Optional SSH Alias Mode**: System OpenSSH client for ~/.ssh/config host aliases
- **Distribution**: NPM with direct NPX execution

---
Expand Down Expand Up @@ -366,4 +409,3 @@ Enhanced with 4 powerful tools inspired by Desktop Commander for optimal token u
}
}
```

Loading