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 2mhands a teammate a link. They get a viewer listing only the tools you allowed — everything else is invisible and unreachable. We intercept the MCPtools/listand drop anything outside your--scope; out-of-scopetools/callrequests 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 staticdescribemanifest. The host tunnels it exactly like any MCP server — a tunneled plugin command is preciselytnls 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.movopens 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 exposingreadandshell, whereshellis 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) issession::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-checksexpper 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 answersdescribewith a static manifest (commands + scope + tunneled-or-plain). Because a tunneled plugin command is the same agent code path astnls 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 arermcpservers (tools via#[tool], input schemas auto-generated by schemars); the agent is anrmcpclient. We deleted our hand-rolled JSON-RPC —rmcpis 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 bytunnel_idand 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 iniadpairing 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
expon everytools/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.
describeis 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 theopenpath — was the core design work. - Adopting
rmcpwithout 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
Backplanetrait so the forwarding path stays opaque and stateless. - Real-world deploy gremlins:
librqbitships only as a9.0.0-rc.0pre-release; the Docker build context ballooned to gigabytes until we deployed from a cleangit 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 callreadbut get refused onshell, then watch the link die live on TTL. - The
rendezvousplugin 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
rmcpport, 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
- axum
- javascript
- json-rpc
- mcp
- rust
- serde
- tokio
- websockets

Log in or sign up for Devpost to join the conversation.