Inspiration

Local-first AI keeps your model and data on your machine — until a teammate needs to talk to your running agent. Today the options are bad: open an inbound port, stand up ngrok and expose everything, or copy-paste results around. None of them let you say "you can use exactly these two tools, for the next two minutes, and then this door disappears."

We built that. And then we saw the bigger shape: it isn't one tool, it's a host for capability-scoped tunnels — and every new capability is just a plugin that declares what it needs. Sharing a large file is simply the first plugin.

What it does

tnls is a pluggable host for ephemeral, capability-scoped tunnels to local MCP servers. The relay is live at https://tunnel.locker.

The host does three things: wrap a local MCP server, open a scoped, TTL'd tunnel to it, and dispatch to capability plugins.

  • The tunnel (the core). tnls open ./my-mcp-server --scope read --ttl 2m hands a teammate a link. They get a viewer listing only the tools you allowed — everything else is invisible and unreachable. We intercept the MCP tools/list and drop anything outside your --scope; out-of-scope tools/call requests are refused agent-side before the server ever sees them; and a TTL is re-checked on every call, so revocation is real, not cosmetic. Nothing is persisted; closing the tunnel kills the link instantly. The agent dials outbound, so you never expose a port.
  • Plugins (capabilities). Each plugin is a tnls-<name> binary that declares its commands and the scope it needs via a static describe manifest. The host tunnels it exactly like any MCP server — a tunneled plugin command is precisely tnls open <plugin> --scope <its declared scope>, so plugins add no new security-sensitive code.
    • rendezvous — the first plugin: send a large file over BitTorrent. tnls rendezvous share ./big.mov opens a scoped link; tnls rendezvous get <link> pulls it peer-to-peer. The bytes move between peers and never touch the relay (you don't eat the bandwidth); the tunnel gates which file a teammate may fetch and for how long, and even hands over the seeder's address so the swarm connects instantly instead of waiting on the DHT.
    • demo — the worked example: a tiny MCP server exposing read and shell, where shell is deny-by-default until you opt in with --scope read,shell.

How we built it

A Rust workspace, architected as a thin host + plugins around a single audited security boundary:

  • The host (tnls) creates MCP servers, creates tunnels, and dispatches to plugins. It is the agent: the sole holder of the per-session 32-byte HMAC secret and the only party that ever connects to the relay. It enforces scope + TTL on every call.
  • The tunnel library (tnls-tunnel) is session::open: it spawns the target MCP server as a stdio child, bridges it to the relay over a WebSocket, filters the advertised tool list to the scope, refuses out-of-scope calls, and re-checks exp per call. The capability token is HMAC-SHA256 over {tunnel_id, scope, exp}, in memory only, and rides the link's URL fragment so it never reaches the relay in a request line.
  • Plugins are subprocesses discovered as tnls-<name> next to the host. Each answers describe with a static manifest (commands + scope + tunneled-or-plain). Because a tunneled plugin command is the same agent code path as tnls open, a plugin can only ever narrow — it selects a scope (an allowlist of tool names); it can't widen a TTL, forge a token, or reach the relay. Plugins never see the secret.
  • MCP via the official SDK (rmcp). Plugins are rmcp servers (tools via #[tool], input schemas auto-generated by schemars); the agent is an rmcp client. We deleted our hand-rolled JSON-RPC — rmcp is confined to stdio framing on the child endpoints and never touches the token, the relay socket, or the scope/TTL decision.
  • The relay (relay, Rocket + rocket_ws) is a deliberately dumb byte-mover: it pairs a viewer socket to an agent socket by tunnel_id and shuttles opaque frames, holding no secret and no state. A compromised relay cannot widen scope or extend a TTL. We rewrote it from Axum onto Rocket behind an invariant-preservation gate (no scope logic, no persistence, opaque pass-through, the existing tests green). It scales across machines via a pluggable Redis backplane (live in production — two machines in iad pairing over Redis), and also serves the landing page and privacy-preserving, aggregate-only analytics.

Honest status: the codebase is mid-migration into this host+plugin shape. The capability-tunnel core and the file-sharing capability both work today and are deployed live; the crate split (host vs tnls-tunnel vs plugins) and the rmcp port are landing step by step (the renames are in; the plugin extraction is in flight). We designed it spec-first specifically so the security boundary — the agent as the one place enforcement lives — is preserved exactly through the move.

Challenges we ran into

  • Making revocation real, not cosmetic — re-checking exp on every tools/call, so an expired tunnel genuinely stops mid-session.
  • Designing the plugin contract as a security boundary. The whole point is that adding a capability can't add trust. describe is advisory metadata that can only narrow scope; the host stays the sole agent; plugins are spawned over stdio exactly like any MCP server and never see the secret or the relay. Getting that contract right — and proving the plugin path is literally the open path — was the core design work.
  • Adopting rmcp without surrendering the trust model — keeping the official SDK confined to stdio MCP framing while our own code keeps the token, the relay socket, and the per-call enforcement.
  • The BitTorrent NAT/discovery problem — a fresh swarm often never connects via DHT in a useful window; we fixed it by having the tunnel introduce the peers, turning "stuck at 0%" into an instant transfer.
  • Scaling a deliberately-dumb relay without making it smart — adding Redis behind a Backplane trait so the forwarding path stays opaque and stateless.
  • Real-world deploy gremlins: librqbit ships only as a 9.0.0-rc.0 pre-release; the Docker build context ballooned to gigabytes until we deployed from a clean git archive; and managed Redis needed rustls TLS with bundled roots because the slim runtime container has no CA store.

Accomplishments that we're proud of

  • The MCP demo runs as a single unbroken take: open a tunnel scoped to read, watch a teammate call read but get refused on shell, then watch the link die live on TTL.
  • The rendezvous plugin moves a real file over BitTorrent through a scoped link — verified by an end-to-end test asserting byte-for-byte equality, brokered entirely by the capability tunnel.
  • It's deployed and live at tunnel.locker, multi-machine on a Redis backplane (cross-instance pairing proven by the same test against both an in-memory and a real Redis backend).
  • An architecture where adding a capability = adding a plugin that declares its scope, with the agent as the single, audited enforcement point — and we're moving onto the official MCP SDK so we're not maintaining a bespoke protocol.

What we learned

The interesting wedge in tunneling isn't moving bytes — ngrok and Cloudflare Tunnel do that beautifully. The novelty is that the far end understands the protocol it's tunneling, so you can scope at the capability level instead of the network level. Two lessons followed. First, a capability control plane can bootstrap a separate data plane — the same tunnel that authorizes a file fetch can hand over the peer address that makes the BitTorrent transfer connect instantly. Second, capability scoping generalizes from "tools" to "plugins": if you keep exactly one agent as the enforcement boundary, a whole plugin system adds zero new trust surface. Designing clean seams — the Backplane trait, the plugin describe contract — let us prove risky changes before committing to them.

What's next for tunnel.solutions

  • Finish the host/plugin migration and the rmcp port, so every capability is a self-contained plugin binary that declares its own scope.
  • A multi-region relay fleet — now that the relay runs on Rocket behind a pluggable Redis backplane, pairing viewers and agents across regions (not just across machines in one region) is the natural next step.
  • More plugins — the contract makes a new capability a new binary, nothing more.
  • NAT hole-punching for cross-internet transfers, a native GUI for rendezvous, payload encryption beyond the transport (so even the relay operator can't read tool I/O), and multi-viewer sessions with per-viewer scopes + audit logging.

Built With

Share this project:

Updates

Submission history