tunnel-mcp 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  - Nothing yet.
13
13
 
14
+ ## [0.1.8] - 2026-07-02
15
+
16
+ ### Added
17
+
18
+ - `mcpName` (`io.github.zachlikefolio/tunnel-mcp`) in `package.json`, the
19
+ ownership-verification field required to list the server in the official
20
+ MCP Registry (registry.modelcontextprotocol.io). No functional changes.
21
+
22
+ ## [0.1.7] - 2026-07-01
23
+
24
+ ### Security
25
+
26
+ - **Hardened the untrusted-input decoders against malformed frames.** Added
27
+ property-based fuzzing (`fast-check`) of `decrypt`, `decodeFrame`, and
28
+ `parseLink`, which surfaced two robustness bugs — both now fixed:
29
+ - `decodeFrame` did a blind `JSON.parse(...) as ControlFrame`; a payload that
30
+ parsed to `null` or a primitive could crash a downstream `frame.t` access. It
31
+ now rejects anything that isn't a proper frame object with a string `t`.
32
+ - The guest's WebSocket message handler only guarded frame decoding, so a
33
+ malformed frame from an untrusted host (e.g. an `auth_ok` with no `backlog`)
34
+ could throw uncaught and crash the guest. The whole handler is now defended,
35
+ matching the host relay.
36
+
14
37
  ## [0.1.6] - 2026-07-01
15
38
 
16
39
  ### Security
@@ -148,7 +171,9 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
148
171
  declaring a fix "confirmed".
149
172
  - Test suite of 109 tests built with vitest, developed test-first (TDD).
150
173
 
151
- [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.6...HEAD
174
+ [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.8...HEAD
175
+ [0.1.8]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.7...v0.1.8
176
+ [0.1.7]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.6...v0.1.7
152
177
  [0.1.6]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...v0.1.6
153
178
  [0.1.5]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.4...v0.1.5
154
179
  [0.1.4]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...v0.1.4
package/README.md CHANGED
@@ -4,8 +4,21 @@
4
4
 
5
5
  [![CI](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)
7
+ [![npm downloads](https://img.shields.io/npm/dm/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)
7
8
  ![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
8
9
 
10
+ ![tunnel-mcp demo — two agents talking through a real encrypted tunnel](docs/demo.gif)
11
+
12
+ **Reproduce that yourself in 30 seconds** — clone the repo and:
13
+
14
+ ```bash
15
+ npm ci && npm run demo
16
+ ```
17
+
18
+ That opens a real encrypted tunnel through Cloudflare's edge, joins it as a
19
+ guest, exchanges end-to-end-encrypted messages, proves the join link is
20
+ single-use, and tears everything down.
21
+
9
22
  When two developers each run a Claude agent and need those agents to collaborate,
10
23
  the usual workaround is a human sitting in the middle, copy-pasting messages from
11
24
  one chat window to the other. **tunnel-mcp** removes that human. It's an MCP
@@ -30,6 +30,17 @@ export function decrypt(msg, key) {
30
30
  export function encodeFrame(frame) {
31
31
  return JSON.stringify(frame);
32
32
  }
33
+ // Validate enough of the shape that the `as ControlFrame` cast is honest: every
34
+ // caller switches on `frame.t`, so a parsed value that is null, a primitive, an
35
+ // array, or lacks a string `t` must be rejected here (callers wrap this in
36
+ // try/catch) rather than handed back as a frame that crashes `frame.t`.
33
37
  export function decodeFrame(data) {
34
- return JSON.parse(data);
38
+ const parsed = JSON.parse(data);
39
+ if (parsed === null ||
40
+ typeof parsed !== 'object' ||
41
+ Array.isArray(parsed) ||
42
+ typeof parsed.t !== 'string') {
43
+ throw new Error('malformed control frame');
44
+ }
45
+ return parsed;
35
46
  }
@@ -58,38 +58,48 @@ export class GuestClient extends EventEmitter {
58
58
  reject(e);
59
59
  };
60
60
  ws.on('message', (data) => {
61
- let frame;
61
+ // The host is untrusted: a malformed or schema-invalid frame must never
62
+ // crash the guest. decodeFrame guarantees a string `t`, but a variant's
63
+ // fields (e.g. auth_ok.backlog) are still unchecked, so defend the whole
64
+ // handler like the host relay does. A swallowed frame just stalls, and
65
+ // the overall connect deadline rejects on a stall.
62
66
  try {
63
- frame = decodeFrame(data.toString());
67
+ let frame;
68
+ try {
69
+ frame = decodeFrame(data.toString());
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ if (frame.t === 'challenge') {
75
+ ws.send(encodeFrame({
76
+ t: 'auth',
77
+ response: respondChallenge(frame.nonce, this.link.key),
78
+ name: this.guestName,
79
+ sinceSeq,
80
+ }));
81
+ }
82
+ else if (frame.t === 'auth_ok') {
83
+ for (const m of frame.backlog)
84
+ this.log.record(m);
85
+ settleResolve({ goal: frame.goal, peerName: frame.peerName });
86
+ }
87
+ else if (frame.t === 'auth_fail') {
88
+ settleReject(new Error(`auth failed: ${frame.reason}`));
89
+ ws.close();
90
+ }
91
+ else if (frame.t === 'msg') {
92
+ this.log.record(frame.msg);
93
+ const waiter = this.pending.get(frame.msg.id);
94
+ if (waiter) {
95
+ this.pending.delete(frame.msg.id);
96
+ waiter.resolve(frame.msg.seq);
97
+ }
98
+ this.emit('message', frame.msg);
99
+ }
64
100
  }
65
101
  catch {
66
- return;
67
- }
68
- if (frame.t === 'challenge') {
69
- ws.send(encodeFrame({
70
- t: 'auth',
71
- response: respondChallenge(frame.nonce, this.link.key),
72
- name: this.guestName,
73
- sinceSeq,
74
- }));
75
- }
76
- else if (frame.t === 'auth_ok') {
77
- for (const m of frame.backlog)
78
- this.log.record(m);
79
- settleResolve({ goal: frame.goal, peerName: frame.peerName });
80
- }
81
- else if (frame.t === 'auth_fail') {
82
- settleReject(new Error(`auth failed: ${frame.reason}`));
83
- ws.close();
84
- }
85
- else if (frame.t === 'msg') {
86
- this.log.record(frame.msg);
87
- const waiter = this.pending.get(frame.msg.id);
88
- if (waiter) {
89
- this.pending.delete(frame.msg.id);
90
- waiter.resolve(frame.msg.seq);
91
- }
92
- this.emit('message', frame.msg);
102
+ /* untrusted-frame guard: ignore anything malformed */
93
103
  }
94
104
  });
95
105
  ws.on('close', () => this.failPending(new Error('tunnel disconnected')));
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "tunnel-mcp",
3
- "version": "0.1.6",
3
+ "mcpName": "io.github.zachlikefolio/tunnel-mcp",
4
+ "version": "0.1.8",
4
5
  "description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
5
6
  "type": "module",
6
7
  "bin": {
@@ -30,6 +31,7 @@
30
31
  "format:check": "prettier --check .",
31
32
  "dev": "tsx src/index.ts",
32
33
  "e2e": "tsx scripts/e2e-two-agents.ts",
34
+ "demo": "tsx scripts/demo.ts",
33
35
  "postinstall": "node postinstall.mjs",
34
36
  "prepublishOnly": "npm run build"
35
37
  },
@@ -66,14 +68,15 @@
66
68
  },
67
69
  "devDependencies": {
68
70
  "@anthropic-ai/sdk": "^0.109.0",
69
- "@types/node": "^20.14.0",
71
+ "@types/node": "^26.1.0",
70
72
  "@types/ws": "^8.5.10",
71
- "@vitest/coverage-v8": "^2.0.0",
72
- "eslint": "^9.9.0",
73
+ "@vitest/coverage-v8": "^4.1.9",
74
+ "eslint": "^10.6.0",
75
+ "fast-check": "^4.8.0",
73
76
  "prettier": "^3.3.0",
74
77
  "tsx": "^4.16.0",
75
- "typescript": "^5.5.0",
78
+ "typescript": "^6.0.3",
76
79
  "typescript-eslint": "^8.0.0",
77
- "vitest": "^2.0.0"
80
+ "vitest": "^4.1.9"
78
81
  }
79
82
  }