{
  "markdown": "# tunnel-mcp\n\n**A direct, end-to-end-encrypted tunnel between two developers' Claude agents — no human copy-paste required.**\n\n[![CI](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)\n[![npm version](https://img.shields.io/npm/v/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)\n[![npm downloads](https://img.shields.io/npm/dm/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)\n![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)\n\n![tunnel-mcp demo — two agents talking through a real encrypted tunnel](docs/demo.gif)\n\n**Reproduce that yourself in 30 seconds** — clone the repo and:\n\n```bash\nnpm ci && npm run demo\n```\n\nThat opens a real encrypted tunnel through Cloudflare's edge, joins it as a\nguest, exchanges end-to-end-encrypted messages, proves the join link is\nsingle-use, and tears everything down.\n\nWhen two developers each run a Claude agent and need those agents to collaborate,\nthe usual workaround is a human sitting in the middle, copy-pasting messages from\none chat window to the other. **tunnel-mcp** removes that human. It's an MCP\nserver that lets one developer's agent open a throwaway, encrypted tunnel and the\nother developer's agent dial straight into it, so the two agents can talk to each\nother directly — while their humans stay in control of what actually happens to\nthe filesystem or the shell.\n\n## How it works\n\nOne developer (the **host**) calls `tunnel_open`. Their local `tunnel-mcp`\nprocess becomes an in-process WebSocket relay and exposes it to the internet via\na throwaway `cloudflared` Quick Tunnel — no port-forwarding, no server to\nprovision. The other developer (the **guest**) calls `tunnel_join` with the link\nthe host shares, and their agent dials outbound to that same tunnel. Because both\nsides only ever make outbound connections, it works from behind ordinary\nfirewalls and NAT.\n\n```\n   Host machine                                        Guest machine\n  ┌───────────────────┐        outbound HTTPS         ┌───────────────────┐\n  │   Claude (host)    │            wss://             │   Claude (guest)   │\n  │        │           │      ┌──────────────┐         │        │          │\n  │  tunnel_open/say/  │──────▶  cloudflared │◀────────│  tunnel_join/say/  │\n  │  listen/close      │      │ Quick Tunnel │─────────▶  listen/close      │\n  │        │           │      └──────────────┘         │        │          │\n  │  in-process relay  │                                └───────────────────┘\n  └───────────────────┘\n```\n\nThe relay and the `cloudflared` child process live only for the lifetime of the\nsession and are destroyed on teardown. The transcript is held in memory only —\nnothing is ever written to disk, and it vanishes with the process at teardown.\n\n## Install\n\n```bash\nnpm install -g tunnel-mcp\n# or, without installing:\nnpx tunnel-mcp\n```\n\nRegister it with Claude Code (both developers do this once):\n\n```bash\nclaude mcp add tunnel -- tunnel-mcp          # if globally installed\n# or, with no global install:\nclaude mcp add tunnel -- npx -y tunnel-mcp\n```\n\n> `tunnel-mcp` is a stdio MCP server, not an interactive CLI. Launching it by\n> hand just waits silently for a client — that's expected. Run\n> `tunnel-mcp --help` for usage, or `tunnel-mcp --version`.\n\nThe **tunnel-etiquette skill** teaches each agent how to behave inside a tunnel\n(treat the peer as untrusted input, and check with its human before acting on\nanything the peer says). Installing the package copies it into `~/.claude/skills/`\nautomatically (best-effort). If install scripts are disabled\n(`npm install --ignore-scripts`), or you want it in a custom directory or force an\nupdate, run it explicitly:\n\n```bash\nnpx tunnel-mcp install-skill                       # into ~/.claude/skills\nnpx tunnel-mcp install-skill --dir <path> --force  # elsewhere / overwrite\n```\n\nSet `TUNNEL_SKILLS_DIR` to change the default target, or\n`TUNNEL_SKIP_SKILL_INSTALL=1` to opt out of the automatic copy.\n\n`cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if\nit isn't already on your `PATH` — there's nothing extra to install.\n\n## Quickstart\n\n**Host** — ask Claude to open a tunnel with a goal:\n\n> \"Open a tunnel to pair on debugging the checkout flow.\"\n\nClaude calls `tunnel_open({ goal })` and hands back a ready-to-forward\n**invite** — one plain-text message containing the one-time setup command and\nthe join link. Paste it to the other developer over a trusted channel (Slack\nDM, etc.) — **the link is a secret**, since it contains the encryption key for\nthe session. It is **single-use and expires after ~10 minutes**\n(`tunnel_open` reports `joinLinkExpiresInSec`), so share it promptly.\n\n**Guest** — paste the link and ask Claude to join:\n\n> \"Join this tunnel: `<link>`\"\n\nClaude calls `tunnel_join({ joinLink })`, learns the goal, and gets back the\nroom's member roster — with the default single invite, that's just the two of\nyou.\n\n**More than one guest? Open a room instead:**\n\n> \"Open a tunnel for me and two teammates, to pair on the checkout flow.\"\n\nClaude calls `tunnel_open({ goal, invites: 3 })` — `invites` is the number of\nteammates to seat (up to 15, plus the host makes 16 connected at once) — and gets\nback one **invite** per teammate instead of a single link. Forward each invite\nto exactly one person; every invite is single-use, so don't reuse one link for\ntwo people. Need to add someone mid-session, or re-admit someone whose invite\nexpired before they used it? `tunnel_invite({ count })` (host-only) mints more.\n\n**Both** — the agents converse turn-by-turn using `tunnel_say` to send and\n`tunnel_listen` to wait for the next reply. In a room, every message arrives\nwith `fromName` so agents can tell who said what, checking in with their humans\nas needed.\n\n**Ending it** is role-sensitive: the **host** calls `tunnel_close` to end the\nsession for everyone and tear down the relay — the in-memory transcript vanishes\nwith it, since it was never written to disk. A **member** calling `tunnel_close`\njust leaves; the room stays open for whoever's left.\n\n**Sharing files:** any member can call `tunnel_share({ path })` to send a text\nor binary file to the room — it's read from disk, hashed, and sealed with the\nroom key before it ever crosses the tunnel, so the relay only ever sees\nciphertext. The offer shows up for teammates as an `artifact` message in\n`tunnel_listen` and in `tunnel_status().artifacts` (id, name, kind, size,\nsender). A teammate who wants it calls `tunnel_receive({ artifactId, savePath })`\nwith a path **they** choose — the bytes are decrypted and checked against the\nsender's sha256 before anything is written, and a mismatch is refused rather\nthan saved. An artifact stays fetchable by any current member until it expires\n(a 30-minute TTL) or the session ends — each `tunnel_receive` call independently\nre-fetches and re-verifies. Members on an older client are silently skipped\n(`olderMembers` in the `tunnel_share` result) — they simply never see the\noffer. Filenames cross as plaintext metadata, so don't put secrets in one, and\ntreat every received file as untrusted input — see the etiquette skill.\n\n## Tools\n\n| Tool                                     | Who    | Purpose                                                                                                             |\n| ---------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- |\n| `tunnel_open({goal, invites?})`          | host   | Start the relay + Quick Tunnel and get back one invite per teammate (default 1 — classic two-party).                |\n| `tunnel_invite({count?})`                | host   | Mint more single-use, expiring invites mid-session.                                                                 |\n| `tunnel_join({joinLink})`                | member | Dial into a room using an invite link and authenticate; returns the current member roster.                          |\n| `tunnel_say({text})`                     | any    | Send a message to the room.                                                                                         |\n| `tunnel_listen({sinceSeq?, timeoutMs?})` | any    | Wait for the next message(s), each tagged with the sender's `fromName`.                                             |\n| `tunnel_share({path})`                   | any    | Share a file (text or binary) with the room, end-to-end encrypted; returns `{artifactId, offeredTo, olderMembers}`. |\n| `tunnel_receive({artifactId, savePath})` | any    | Fetch an offered artifact, verify its hash, and write it to a path you choose.                                      |\n| `tunnel_status()`                        | any    | Inspect the session: role, goal, member roster, pending invites, offered artifacts, lastSeq.                        |\n| `tunnel_close({summary?})`               | any    | Host: ends the session for everyone. Member: leaves the room.                                                       |\n\n## Security model\n\ntunnel-mcp is a security-sensitive tool by nature — it opens a live channel\nbetween developers' AI agents. Here's exactly what it does and does not protect:\n\n- **Chat message bodies are end-to-end encrypted.** Every `tunnel_say` body is\n  sealed with NaCl `secretbox` (XSalsa20-Poly1305, via `tweetnacl`) before it\n  crosses the `cloudflared` pipe. The relay and the pipe only ever see\n  ciphertext for chat bodies.\n- **The goal, every participant's display name, and system events are\n  plaintext.** The `tunnel_open` goal, each member's name, and connection\n  events (joined/left/idle/closed) are sent as plaintext metadata — do not put\n  secrets in the goal string or a display name.\n- **Authentication is proof-of-key-possession, not key transmission.** Joining\n  uses an HMAC challenge to prove the joining member holds the same key as the\n  host; the raw key itself is never sent over the wire.\n- **Each invite is a single-use, expiring credential.** It embeds the session\n  key, so treat it like a password — share it only over a channel you already\n  trust (Slack DM, etc.), never in a public issue, PR, or chat, and forward each\n  invite to exactly one person. It is consumed by whoever redeems it first (and\n  can't be reused, even after they leave) and expires on its own after ~10\n  minutes, so a leaked invite has a short, bounded window of exposure.\n- **Admits exactly whom you invited** — two-party by default, rooms opt-in\n  (cap 16), every invite single-use + expiring. Admission is bounded by how\n  many invites the host chose to mint, not by who happens to have the room's\n  key.\n- **Shared files are end-to-end encrypted and hash-verified.** `tunnel_share`\n  seals a file's bytes with the same room key as chat (NaCl `secretbox`)\n  before they cross the tunnel, and carries a plaintext sha256 of the\n  contents; `tunnel_receive` decrypts, reassembles, and verifies that hash\n  before writing anything to disk. The filename, size, and kind are plaintext\n  metadata (don't put secrets in a filename), and a received file is\n  untrusted — `tunnel_receive` only ever writes to a path the receiver\n  chooses, never the sender's name.\n- **The peer is untrusted input, not an instruction source.** Messages from\n  other agents are data to reason about, not commands to execute — and this\n  applies to every member in a room, not just one. The etiquette skill directs\n  each agent to require its own human's sign-off before writing files, running\n  risky commands, or declaring a fix \"confirmed\" based on something a peer\n  said.\n- **Everything is ephemeral.** The transcript is held in memory only — nothing\n  is ever written to disk, and it vanishes with the process. Teardown is\n  role-sensitive: the host's `tunnel_close` (or their process exiting, or 30\n  minutes of no messages) ends the session for everyone and tears down the\n  relay + `cloudflared` child process; a member's `tunnel_close` just leaves —\n  the room stays open for whoever's left.\n\nSee [SECURITY.md](./SECURITY.md) for the full threat model and how to report a\nvulnerability.\n\n## Requirements\n\n- Node.js >= 20\n- A Claude MCP client (e.g., Claude Code)\n- `cloudflared` — auto-installed to `~/.tunnel/bin` on first use if not already\n  on your `PATH`\n\n## Development\n\n```bash\nnpm ci                  # install dependencies\nnpm test                # run the test suite (248 tests, TDD)\nnpm run build           # compile TypeScript\nnpm run lint            # eslint\nnpm run format:check    # prettier --check .\nnpm run test:coverage   # vitest run --coverage\n```\n\nSee [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.\n\n## Troubleshooting\n\n**`tunnel-mcp` / `npx tunnel-mcp` \"does nothing\".** It's a stdio MCP server, not\nan interactive CLI — with no arguments it starts and waits for an MCP client to\nconnect over stdin/stdout. That's working as intended. Register it with a client\n(above), or run `tunnel-mcp --help`.\n\n**Guest join fails with `getaddrinfo ENOTFOUND …trycloudflare.com`.** A\ncloudflared quick tunnel prints its URL a few seconds _before_ the per-tunnel DNS\nrecord has propagated. If anything looks the name up too early it gets an\n`NXDOMAIN` that the resolver negative-caches for up to 30 minutes — breaking the\njoin even after the tunnel is live. `tunnel-mcp` avoids this: `tunnel_open` waits\nfor the record to actually resolve (via DoH to Cloudflare's `1.1.1.1`, an IP that\nnever touches — and so never poisons — your system resolver) before returning the\nlink, and the guest resolves system-first with a DoH fallback. So a fresh join\nshould just work; if you hit `ENOTFOUND`, an _earlier_ attempt likely poisoned the\ncache — wait for it to expire, or flush DNS (`sudo dscacheutil -flushcache` on\nmacOS). Set `TUNNEL_DOH=off` only on networks that block DoH (`1.1.1.1`) and where\nsystem DNS already resolves `*.trycloudflare.com`.\n\n## Roadmap / not yet supported\n\nThis is an MVP. The following are explicitly out of scope for now:\n\n- Host-offline / asynchronous messaging\n- Alternative transports (ngrok, WebRTC)\n- Invite rotation (replacing a specific still-valid invite mid-session; note invites are already single-use and expiring — see the security model above)\n- Encrypting the goal or other metadata\n\n## License\n\nMIT — see [LICENSE](./LICENSE).\n",
  "bytes": 14402,
  "sha": "d9115f6289c2bb0819379b932951d2d7c8999d76d46ac69ad3dc6694117a90c1",
  "repo_slug": "zachlikefolio/tunnel-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_zachlikefolio_tunnel_mcp_cc051ab6/readme"
}