tunnel-mcp 0.1.0 → 0.1.2
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 +41 -1
- package/README.md +49 -12
- package/SECURITY.md +19 -11
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +87 -0
- package/dist/cloudflared/tunnelProcess.d.ts +3 -0
- package/dist/cloudflared/tunnelProcess.js +85 -27
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/env.d.ts +7 -0
- package/dist/env.js +13 -0
- package/dist/index.js +65 -29
- package/dist/relay/hostRelay.d.ts +4 -0
- package/dist/relay/hostRelay.js +29 -3
- package/dist/session.d.ts +2 -0
- package/dist/session.js +17 -4
- package/dist/skillInstall.d.ts +23 -0
- package/dist/skillInstall.js +69 -0
- package/dist/tools.js +1 -1
- package/package.json +5 -1
- package/postinstall.mjs +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,44 @@ 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.2] - 2026-07-01
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`install-skill` command and automatic skill install.** `tunnel-mcp
|
|
19
|
+
install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
|
|
20
|
+
(override with `--dir`/`$TUNNEL_SKILLS_DIR`, overwrite with `--force`), and a
|
|
21
|
+
global `npm install` now installs it best-effort via a postinstall script.
|
|
22
|
+
Set `TUNNEL_SKIP_SKILL_INSTALL=1` to opt out; the postinstall never fails an
|
|
23
|
+
install and is a no-op under `npx`, `--ignore-scripts`, and CI.
|
|
24
|
+
- **`--help` and `--version` flags**, plus a one-line stderr startup banner, so
|
|
25
|
+
running the server by hand no longer looks like a silent hang. The server also
|
|
26
|
+
hints how to install the etiquette skill when it isn't present.
|
|
27
|
+
- **`TUNNEL_SKIP_REACHABILITY_CHECK` escape hatch.** Opens a tunnel even when the
|
|
28
|
+
host can't reach `*.trycloudflare.com` itself — useful when only the guest's
|
|
29
|
+
network needs to reach the URL.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- The MCP server reported a hardcoded, stale version (`0.1.0`) in its handshake;
|
|
34
|
+
it now reports the real package version.
|
|
35
|
+
- A failed cloudflared reachability probe surfaced a generic "never became
|
|
36
|
+
reachable" error. It now names the host and, when the failure is DNS
|
|
37
|
+
resolution, points at `*.trycloudflare.com` being blocked (a common
|
|
38
|
+
corporate/filtered-DNS cause) and mentions the escape hatch above.
|
|
39
|
+
|
|
40
|
+
## [0.1.1] - 2026-07-01
|
|
41
|
+
|
|
42
|
+
### Security
|
|
43
|
+
|
|
44
|
+
- **Join links are now single-use and expiring.** A join link is consumed by
|
|
45
|
+
the first guest who successfully authenticates and can no longer be redeemed
|
|
46
|
+
afterward — even once that guest disconnects — and links expire on their own
|
|
47
|
+
after 10 minutes (`DEFAULT_JOIN_LINK_TTL_MS`). This bounds the damage from a
|
|
48
|
+
leaked link, which previously stayed valid for the whole session.
|
|
49
|
+
`tunnel_open` now returns `joinLinkExpiresInSec` so the host can tell the
|
|
50
|
+
human how long the link is good for.
|
|
51
|
+
|
|
14
52
|
## [0.1.0] - 2026-06-30
|
|
15
53
|
|
|
16
54
|
### Added
|
|
@@ -42,5 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
42
80
|
declaring a fix "confirmed".
|
|
43
81
|
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
44
82
|
|
|
45
|
-
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.
|
|
83
|
+
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...HEAD
|
|
84
|
+
[0.1.2]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.1...v0.1.2
|
|
85
|
+
[0.1.1]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...v0.1.1
|
|
46
86
|
[0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/tunnel-mcp)
|
|
7
|
-
[](./LICENSE)
|
|
8
7
|

|
|
9
8
|
|
|
10
9
|
When two developers each run a Claude agent and need those agents to collaborate,
|
|
@@ -51,13 +50,29 @@ npx tunnel-mcp
|
|
|
51
50
|
Register it with Claude Code (both developers do this once):
|
|
52
51
|
|
|
53
52
|
```bash
|
|
54
|
-
claude mcp add tunnel -- tunnel-mcp
|
|
53
|
+
claude mcp add tunnel -- tunnel-mcp # if globally installed
|
|
54
|
+
# or, with no global install:
|
|
55
|
+
claude mcp add tunnel -- npx -y tunnel-mcp
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
> `tunnel-mcp` is a stdio MCP server, not an interactive CLI. Launching it by
|
|
59
|
+
> hand just waits silently for a client — that's expected. Run
|
|
60
|
+
> `tunnel-mcp --help` for usage, or `tunnel-mcp --version`.
|
|
61
|
+
|
|
62
|
+
The **tunnel-etiquette skill** teaches each agent how to behave inside a tunnel
|
|
63
|
+
(treat the peer as untrusted input, and check with its human before acting on
|
|
64
|
+
anything the peer says). Installing the package copies it into `~/.claude/skills/`
|
|
65
|
+
automatically (best-effort). If install scripts are disabled
|
|
66
|
+
(`npm install --ignore-scripts`), or you want it in a custom directory or force an
|
|
67
|
+
update, run it explicitly:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx tunnel-mcp install-skill # into ~/.claude/skills
|
|
71
|
+
npx tunnel-mcp install-skill --dir <path> --force # elsewhere / overwrite
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Set `TUNNEL_SKILLS_DIR` to change the default target, or
|
|
75
|
+
`TUNNEL_SKIP_SKILL_INSTALL=1` to opt out of the automatic copy.
|
|
61
76
|
|
|
62
77
|
`cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
|
|
63
78
|
it isn't already on your `PATH` — there's nothing extra to install.
|
|
@@ -70,7 +85,9 @@ it isn't already on your `PATH` — there's nothing extra to install.
|
|
|
70
85
|
|
|
71
86
|
Claude calls `tunnel_open({ goal })` and returns a join link. Share that link
|
|
72
87
|
with the other developer over a trusted channel (Slack DM, etc.) — **it's a
|
|
73
|
-
secret**, since it contains the encryption key for the session.
|
|
88
|
+
secret**, since it contains the encryption key for the session. The link is
|
|
89
|
+
**single-use and expires after ~10 minutes** (`tunnel_open` reports
|
|
90
|
+
`joinLinkExpiresInSec`), so share it promptly.
|
|
74
91
|
|
|
75
92
|
**Guest** — paste the link and ask Claude to join:
|
|
76
93
|
|
|
@@ -113,9 +130,12 @@ between two AI agents. Here's exactly what it does and does not protect:
|
|
|
113
130
|
- **Authentication is proof-of-key-possession, not key transmission.** Joining
|
|
114
131
|
uses an HMAC challenge to prove the guest holds the same key as the host; the
|
|
115
132
|
raw key itself is never sent over the wire.
|
|
116
|
-
- **The join link is a credential.** It embeds the session
|
|
117
|
-
like a password — share it only over a channel you already
|
|
118
|
-
etc.), never in a public issue, PR, or chat.
|
|
133
|
+
- **The join link is a single-use, expiring credential.** It embeds the session
|
|
134
|
+
key, so treat it like a password — share it only over a channel you already
|
|
135
|
+
trust (Slack DM, etc.), never in a public issue, PR, or chat. It is consumed
|
|
136
|
+
by the first guest who joins (and can't be reused, even after they leave) and
|
|
137
|
+
expires on its own after ~10 minutes, so a leaked link has a short, bounded
|
|
138
|
+
window of exposure.
|
|
119
139
|
- **Exactly two participants, enforced by a lock.** The first guest to
|
|
120
140
|
authenticate locks the session; nobody else can join after that.
|
|
121
141
|
- **The peer is untrusted input, not an instruction source.** Messages from the
|
|
@@ -142,7 +162,7 @@ vulnerability.
|
|
|
142
162
|
|
|
143
163
|
```bash
|
|
144
164
|
npm ci # install dependencies
|
|
145
|
-
npm test # run the test suite (
|
|
165
|
+
npm test # run the test suite (136 tests, TDD)
|
|
146
166
|
npm run build # compile TypeScript
|
|
147
167
|
npm run lint # eslint
|
|
148
168
|
npm run format:check # prettier --check .
|
|
@@ -151,6 +171,23 @@ npm run test:coverage # vitest run --coverage
|
|
|
151
171
|
|
|
152
172
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
|
|
153
173
|
|
|
174
|
+
## Troubleshooting
|
|
175
|
+
|
|
176
|
+
**`tunnel-mcp` / `npx tunnel-mcp` "does nothing".** It's a stdio MCP server, not
|
|
177
|
+
an interactive CLI — with no arguments it starts and waits for an MCP client to
|
|
178
|
+
connect over stdin/stdout. That's working as intended. Register it with a client
|
|
179
|
+
(above), or run `tunnel-mcp --help`.
|
|
180
|
+
|
|
181
|
+
**`tunnel_open` fails with "never became reachable" / can't resolve
|
|
182
|
+
`*.trycloudflare.com`.** cloudflared reaches Cloudflare's edge over its own
|
|
183
|
+
protocol, but the public `*.trycloudflare.com` hostname still has to resolve via
|
|
184
|
+
normal DNS — and some networks (corporate/filtered networks, and a few public
|
|
185
|
+
DNS resolvers) block `trycloudflare.com`. Both you **and your guest** need to be
|
|
186
|
+
able to resolve it. Check with `dig +short <random>.trycloudflare.com` or
|
|
187
|
+
`curl -sI https://<the-url>`. If only your guest's network needs to reach the
|
|
188
|
+
URL, set `TUNNEL_SKIP_REACHABILITY_CHECK=1` to open the tunnel without the
|
|
189
|
+
host-side reachability probe.
|
|
190
|
+
|
|
154
191
|
## Roadmap / not yet supported
|
|
155
192
|
|
|
156
193
|
This is an MVP. The following are explicitly out of scope for now:
|
|
@@ -158,7 +195,7 @@ This is an MVP. The following are explicitly out of scope for now:
|
|
|
158
195
|
- Host-offline / asynchronous messaging
|
|
159
196
|
- More than two participants in a session
|
|
160
197
|
- Alternative transports (ngrok, WebRTC)
|
|
161
|
-
- Join-link rotation
|
|
198
|
+
- Join-link rotation (re-issuing a fresh link mid-session; note that links are already single-use and expiring — see the security model above)
|
|
162
199
|
- Encrypting the goal or other metadata
|
|
163
200
|
|
|
164
201
|
## License
|
package/SECURITY.md
CHANGED
|
@@ -63,9 +63,15 @@ share a join link with anyone.
|
|
|
63
63
|
like a password** — share it only over a trusted, already-authenticated
|
|
64
64
|
channel (e.g. a Slack DM to a known teammate), not in a public channel or
|
|
65
65
|
ticket.
|
|
66
|
+
- **Join links are single-use and expiring.** A join link is consumed by the
|
|
67
|
+
first guest who successfully authenticates and can never be redeemed again —
|
|
68
|
+
even after that guest disconnects. Links also expire on their own (10 minutes
|
|
69
|
+
by default), so a link that is never used stops working. This bounds the
|
|
70
|
+
damage from a leaked link to a short window before it is used or expires.
|
|
66
71
|
- **Single-guest lock.** The first participant who successfully
|
|
67
72
|
authenticates as guest locks the session. Sessions are strictly two-party;
|
|
68
|
-
a second join attempt is rejected
|
|
73
|
+
a second concurrent join attempt is rejected ("tunnel full"), and any join
|
|
74
|
+
after the link has been consumed is rejected ("join link already used").
|
|
69
75
|
- **Peer input is untrusted.** Everything a peer sends over the tunnel is
|
|
70
76
|
data, never an instruction. The bundled `tunnel-etiquette` skill
|
|
71
77
|
instructs each agent to treat incoming peer messages as untrusted input
|
|
@@ -89,13 +95,15 @@ protect against:
|
|
|
89
95
|
and system/connection events are visible in plaintext to anything that
|
|
90
96
|
can observe that path (including Cloudflare's infrastructure). Do not
|
|
91
97
|
put secrets in the goal or names.
|
|
92
|
-
- **
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
- **A leaked link can still be redeemed within its window, before your guest
|
|
99
|
+
joins.** Join links are single-use and expire (10 minutes by default), so a
|
|
100
|
+
leaked link that is never used, has already been used, or has aged out can no
|
|
101
|
+
longer admit anyone. The residual risk is a race: if a link leaks and an
|
|
102
|
+
attacker redeems it faster than your intended guest — within the expiry
|
|
103
|
+
window and before that guest connects — the attacker consumes the single-use
|
|
104
|
+
link, joins as the guest, and locks out the real one. Share links only over
|
|
105
|
+
trusted channels, and open a fresh tunnel if you suspect a link was exposed
|
|
106
|
+
before it was used. There is no in-session key rotation.
|
|
99
107
|
- **The goal is never encrypted.** By design, the goal string is plaintext
|
|
100
108
|
metadata used for connection setup and display; it receives no
|
|
101
109
|
confidentiality protection at any layer.
|
|
@@ -113,9 +121,9 @@ protect against:
|
|
|
113
121
|
instructions by an unguarded agent.
|
|
114
122
|
- **Out of scope for this release**: host-offline/async messaging,
|
|
115
123
|
more than two participants, alternative transports (ngrok, WebRTC),
|
|
116
|
-
link rotation
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
in-session key/link rotation, and encryption of the goal or other
|
|
125
|
+
connection metadata. These may be considered for future versions but
|
|
126
|
+
should not be assumed to exist today.
|
|
119
127
|
|
|
120
128
|
If you find a way to break any of the guarantees above (e.g. read a chat
|
|
121
129
|
message body without the key, join a locked session, or get an agent to
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ParsedArgs = {
|
|
2
|
+
mode: 'serve';
|
|
3
|
+
} | {
|
|
4
|
+
mode: 'help';
|
|
5
|
+
} | {
|
|
6
|
+
mode: 'version';
|
|
7
|
+
} | {
|
|
8
|
+
mode: 'install-skill';
|
|
9
|
+
dir?: string;
|
|
10
|
+
force: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
mode: 'error';
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
16
|
+
export declare function helpText(version?: string): string;
|
|
17
|
+
/** Runs the `install-skill` command. Returns a process exit code. */
|
|
18
|
+
export declare function runInstallSkill(args: {
|
|
19
|
+
dir?: string;
|
|
20
|
+
force: boolean;
|
|
21
|
+
}, out: (msg: string) => void): number;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { installSkill, readVersion } from './skillInstall.js';
|
|
2
|
+
export function parseArgs(argv) {
|
|
3
|
+
if (argv.length === 0)
|
|
4
|
+
return { mode: 'serve' };
|
|
5
|
+
const [cmd, ...rest] = argv;
|
|
6
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help')
|
|
7
|
+
return { mode: 'help' };
|
|
8
|
+
if (cmd === '--version' || cmd === '-v')
|
|
9
|
+
return { mode: 'version' };
|
|
10
|
+
if (cmd === 'install-skill') {
|
|
11
|
+
let dir;
|
|
12
|
+
let force = false;
|
|
13
|
+
for (let i = 0; i < rest.length; i++) {
|
|
14
|
+
const a = rest[i];
|
|
15
|
+
if (a === '--force' || a === '-f')
|
|
16
|
+
force = true;
|
|
17
|
+
else if (a === '--dir') {
|
|
18
|
+
const next = rest[++i];
|
|
19
|
+
// Guard against `--dir --force` (eats the flag) and a trailing `--dir`.
|
|
20
|
+
if (next === undefined || next.startsWith('-')) {
|
|
21
|
+
return { mode: 'error', message: '--dir requires a path' };
|
|
22
|
+
}
|
|
23
|
+
dir = next;
|
|
24
|
+
}
|
|
25
|
+
else if (a.startsWith('--dir=')) {
|
|
26
|
+
dir = a.slice('--dir='.length);
|
|
27
|
+
if (dir === '')
|
|
28
|
+
return { mode: 'error', message: '--dir requires a path' };
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
return { mode: 'error', message: `unknown option for install-skill: ${a}` };
|
|
32
|
+
}
|
|
33
|
+
return { mode: 'install-skill', dir, force };
|
|
34
|
+
}
|
|
35
|
+
return { mode: 'error', message: `unknown command: ${cmd}` };
|
|
36
|
+
}
|
|
37
|
+
export function helpText(version = readVersion()) {
|
|
38
|
+
return [
|
|
39
|
+
`tunnel-mcp v${version}`,
|
|
40
|
+
`An MCP server that lets two developers' Claude agents talk directly through an`,
|
|
41
|
+
`ephemeral, end-to-end-encrypted tunnel.`,
|
|
42
|
+
``,
|
|
43
|
+
`This is a stdio MCP server, NOT an interactive CLI. With no arguments it starts`,
|
|
44
|
+
`the server and waits for an MCP client to connect over stdin/stdout, so running`,
|
|
45
|
+
`it by hand in a terminal will look like it "does nothing" — that's expected.`,
|
|
46
|
+
`You add it to an MCP client instead:`,
|
|
47
|
+
``,
|
|
48
|
+
` claude mcp add tunnel -- npx -y tunnel-mcp # Claude Code (this project)`,
|
|
49
|
+
` claude mcp add -s user tunnel -- npx -y tunnel-mcp # ...available everywhere`,
|
|
50
|
+
``,
|
|
51
|
+
`Commands:`,
|
|
52
|
+
` (no args) Start the MCP server on stdio (for an MCP client to launch)`,
|
|
53
|
+
` install-skill Install the tunnel-etiquette skill into your skills directory`,
|
|
54
|
+
` --help, -h Show this help and exit`,
|
|
55
|
+
` --version, -v Print the version and exit`,
|
|
56
|
+
``,
|
|
57
|
+
`install-skill options:`,
|
|
58
|
+
` --dir <path> Target skills directory (default: ~/.claude/skills or`,
|
|
59
|
+
` $TUNNEL_SKILLS_DIR)`,
|
|
60
|
+
` --force, -f Overwrite an existing install`,
|
|
61
|
+
``,
|
|
62
|
+
`Environment:`,
|
|
63
|
+
` TUNNEL_SKILLS_DIR Override the skills directory`,
|
|
64
|
+
` TUNNEL_SKIP_SKILL_INSTALL=1 Skip the automatic skill install on npm install`,
|
|
65
|
+
` TUNNEL_SKIP_REACHABILITY_CHECK=1 Open a tunnel even if this machine can't reach`,
|
|
66
|
+
` *.trycloudflare.com (your guest still must)`,
|
|
67
|
+
``,
|
|
68
|
+
`Docs: https://github.com/zachlikefolio/tunnel-mcp`,
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
/** Runs the `install-skill` command. Returns a process exit code. */
|
|
72
|
+
export function runInstallSkill(args, out) {
|
|
73
|
+
try {
|
|
74
|
+
const res = installSkill({ skillsDir: args.dir, overwrite: args.force });
|
|
75
|
+
if (res.installed) {
|
|
76
|
+
out(`Installed the tunnel-etiquette skill to ${res.target}${res.updated ? ' (overwrote the existing copy)' : ''}.`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
out(`The tunnel-etiquette skill is already present at ${res.target}. Re-run with --force to overwrite it.`);
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
out(`Failed to install the skill: ${e instanceof Error ? e.message : String(e)}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -9,9 +9,12 @@ export interface StartOptions {
|
|
|
9
9
|
intervalMs?: number;
|
|
10
10
|
healthCheck?: (url: string) => Promise<boolean>;
|
|
11
11
|
probeTimeoutMs?: number;
|
|
12
|
+
skipHealthCheck?: boolean;
|
|
12
13
|
}
|
|
13
14
|
export declare function parsePublicUrl(line: string): string | null;
|
|
15
|
+
export declare function describeProbeError(e: unknown): string;
|
|
14
16
|
export declare function defaultHealthCheck(url: string): Promise<boolean>;
|
|
17
|
+
export declare function unreachableMessage(url: string, attempts: number, lastReason?: string): string;
|
|
15
18
|
/**
|
|
16
19
|
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
17
20
|
* launched in place of `cloudflared tunnel --url ...`. Production passes none.
|
|
@@ -8,49 +8,95 @@ export function parsePublicUrl(line) {
|
|
|
8
8
|
const m = line.match(URL_RE);
|
|
9
9
|
return m ? m[0] : null;
|
|
10
10
|
}
|
|
11
|
-
//
|
|
11
|
+
// undici (Node's global fetch) reports the real network error on `.cause`, e.g.
|
|
12
|
+
// a DNS failure surfaces as `cause.code === 'ENOTFOUND'`. Pull that out so the
|
|
13
|
+
// caller can tell "DNS can't resolve the host" apart from "edge not ready yet".
|
|
14
|
+
export function describeProbeError(e) {
|
|
15
|
+
const err = e;
|
|
16
|
+
const code = err?.cause?.code;
|
|
17
|
+
if (code)
|
|
18
|
+
return err.cause?.message ? `${code}: ${err.cause.message}` : code;
|
|
19
|
+
if (err?.name === 'TimeoutError')
|
|
20
|
+
return 'probe timed out';
|
|
21
|
+
return err?.message || 'unknown error';
|
|
22
|
+
}
|
|
23
|
+
// Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to
|
|
24
|
+
// us. A thrown error carries why it failed (DNS, TLS, refused, timeout).
|
|
25
|
+
async function reachabilityProbe(url, probeTimeoutMs) {
|
|
26
|
+
try {
|
|
27
|
+
await fetch(url, { method: 'GET', signal: AbortSignal.timeout(probeTimeoutMs) });
|
|
28
|
+
return { ok: true };
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return { ok: false, reason: describeProbeError(e) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Back-compat boolean probe (kept for external callers/tests).
|
|
12
35
|
export async function defaultHealthCheck(url) {
|
|
36
|
+
return (await reachabilityProbe(url, DEFAULT_PROBE_TIMEOUT_MS)).ok;
|
|
37
|
+
}
|
|
38
|
+
// Builds the actionable "never became reachable" error. Names the host and,
|
|
39
|
+
// when the failure looks like DNS resolution, points at *.trycloudflare.com
|
|
40
|
+
// blocking — the single most common real-world cause. Always mentions the
|
|
41
|
+
// escape hatch, since the guest's network (not the host's) is what must reach
|
|
42
|
+
// the URL for messaging.
|
|
43
|
+
export function unreachableMessage(url, attempts, lastReason) {
|
|
44
|
+
let host = url;
|
|
13
45
|
try {
|
|
14
|
-
|
|
15
|
-
return true;
|
|
46
|
+
host = new URL(url).host;
|
|
16
47
|
}
|
|
17
48
|
catch {
|
|
18
|
-
|
|
49
|
+
/* keep the raw url */
|
|
50
|
+
}
|
|
51
|
+
const dnsish = !!lastReason && /ENOTFOUND|EAI_AGAIN|getaddrinfo|\bdns\b/i.test(lastReason);
|
|
52
|
+
let msg = `cloudflared reported ${url} but it never became reachable from this machine after ${attempts} probe(s)`;
|
|
53
|
+
if (lastReason)
|
|
54
|
+
msg += ` (last error: ${lastReason})`;
|
|
55
|
+
msg += '.';
|
|
56
|
+
if (dnsish) {
|
|
57
|
+
msg +=
|
|
58
|
+
` This machine can't resolve ${host} — your DNS or network may be blocking *.trycloudflare.com` +
|
|
59
|
+
` (common on filtered/corporate networks and some public DNS resolvers). You and your guest both` +
|
|
60
|
+
` need to be able to resolve it.`;
|
|
19
61
|
}
|
|
62
|
+
msg += ` If you're confident your guest's network can reach the URL, set TUNNEL_SKIP_REACHABILITY_CHECK=1 to open the tunnel anyway.`;
|
|
63
|
+
return msg;
|
|
20
64
|
}
|
|
21
|
-
// Races a single
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
function probeOnce(url, check, probeTimeoutMs) {
|
|
65
|
+
// Races a single probe against a per-attempt timeout so that a caller-supplied
|
|
66
|
+
// `check` that throws, rejects, or simply never resolves can never leave the
|
|
67
|
+
// loop (and therefore the outer startCloudflared promise) hanging.
|
|
68
|
+
function probeOnce(url, probe, probeTimeoutMs) {
|
|
26
69
|
return new Promise((resolve) => {
|
|
27
70
|
let settled = false;
|
|
28
|
-
const finish = (
|
|
71
|
+
const finish = (r) => {
|
|
29
72
|
if (!settled) {
|
|
30
73
|
settled = true;
|
|
31
|
-
resolve(
|
|
74
|
+
resolve(r);
|
|
32
75
|
}
|
|
33
76
|
};
|
|
34
|
-
const timer = setTimeout(() => finish(false), probeTimeoutMs);
|
|
77
|
+
const timer = setTimeout(() => finish({ ok: false, reason: 'probe timed out' }), probeTimeoutMs);
|
|
35
78
|
Promise.resolve()
|
|
36
|
-
.then(() =>
|
|
37
|
-
.then((
|
|
79
|
+
.then(() => probe(url))
|
|
80
|
+
.then((r) => {
|
|
38
81
|
clearTimeout(timer);
|
|
39
|
-
finish(
|
|
82
|
+
finish(r);
|
|
40
83
|
})
|
|
41
|
-
.catch(() => {
|
|
84
|
+
.catch((err) => {
|
|
42
85
|
clearTimeout(timer);
|
|
43
|
-
finish(false);
|
|
86
|
+
finish({ ok: false, reason: describeProbeError(err) });
|
|
44
87
|
});
|
|
45
88
|
});
|
|
46
89
|
}
|
|
47
|
-
async function waitHealthy(url, attempts, intervalMs,
|
|
90
|
+
async function waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs) {
|
|
91
|
+
let lastReason;
|
|
48
92
|
for (let i = 0; i < attempts; i++) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
const r = await probeOnce(url, probe, probeTimeoutMs);
|
|
94
|
+
if (r.ok)
|
|
95
|
+
return r;
|
|
96
|
+
lastReason = r.reason;
|
|
97
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
52
98
|
}
|
|
53
|
-
return false;
|
|
99
|
+
return { ok: false, reason: lastReason };
|
|
54
100
|
}
|
|
55
101
|
/**
|
|
56
102
|
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
@@ -63,8 +109,13 @@ export function startCloudflared(binPath, localPort, opts = {}) {
|
|
|
63
109
|
const timeoutMs = opts.timeoutMs ?? CLOUDFLARED_URL_TIMEOUT_MS;
|
|
64
110
|
const attempts = opts.attempts ?? CLOUDFLARED_HEALTH_ATTEMPTS;
|
|
65
111
|
const intervalMs = opts.intervalMs ?? CLOUDFLARED_HEALTH_INTERVAL_MS;
|
|
66
|
-
const check = opts.healthCheck ?? defaultHealthCheck;
|
|
67
112
|
const probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
113
|
+
// A caller-supplied boolean healthCheck carries no failure reason; the default
|
|
114
|
+
// probe does. Adapt the former into a ProbeResult either way.
|
|
115
|
+
const custom = opts.healthCheck;
|
|
116
|
+
const probe = custom
|
|
117
|
+
? async (u) => ({ ok: await custom(u) })
|
|
118
|
+
: (u) => reachabilityProbe(u, probeTimeoutMs);
|
|
68
119
|
return new Promise((resolve, reject) => {
|
|
69
120
|
const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
70
121
|
let settled = false;
|
|
@@ -91,13 +142,20 @@ export function startCloudflared(binPath, localPort, opts = {}) {
|
|
|
91
142
|
if (url && !settled) {
|
|
92
143
|
settled = true;
|
|
93
144
|
clearTimeout(timer);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
145
|
+
// Escape hatch: the reachability probe runs on the *host*, but only the
|
|
146
|
+
// guest's network must reach the URL for messaging. A host on a filtered
|
|
147
|
+
// network can opt to skip the probe and hand out the link regardless.
|
|
148
|
+
if (opts.skipHealthCheck) {
|
|
149
|
+
resolve({ publicUrl: url, stop });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs)
|
|
153
|
+
.then((res) => {
|
|
154
|
+
if (res.ok)
|
|
97
155
|
resolve({ publicUrl: url, stop });
|
|
98
156
|
else {
|
|
99
157
|
stop();
|
|
100
|
-
reject(new Error(
|
|
158
|
+
reject(new Error(unreachableMessage(url, attempts, res.reason)));
|
|
101
159
|
}
|
|
102
160
|
})
|
|
103
161
|
.catch((err) => {
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare const BIN_DIR: string;
|
|
|
3
3
|
export declare const SESSIONS_DIR: string;
|
|
4
4
|
export declare const DEFAULT_LISTEN_TIMEOUT_MS = 60000;
|
|
5
5
|
export declare const DEFAULT_IDLE_TEARDOWN_MS: number;
|
|
6
|
+
export declare const DEFAULT_JOIN_LINK_TTL_MS: number;
|
|
6
7
|
export declare const CLOUDFLARED_URL_TIMEOUT_MS = 30000;
|
|
7
8
|
export declare const CLOUDFLARED_HEALTH_ATTEMPTS = 10;
|
|
8
9
|
export declare const CLOUDFLARED_HEALTH_INTERVAL_MS = 1000;
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,9 @@ export const BIN_DIR = path.join(TUNNEL_HOME, 'bin');
|
|
|
5
5
|
export const SESSIONS_DIR = path.join(TUNNEL_HOME, 'sessions');
|
|
6
6
|
export const DEFAULT_LISTEN_TIMEOUT_MS = 60_000;
|
|
7
7
|
export const DEFAULT_IDLE_TEARDOWN_MS = 30 * 60_000;
|
|
8
|
+
// Join links are single-use and expire after this window; a leaked link that
|
|
9
|
+
// is never used (or is reused after the guest joined) can't admit anyone.
|
|
10
|
+
export const DEFAULT_JOIN_LINK_TTL_MS = 10 * 60_000;
|
|
8
11
|
// cloudflared startup robustness
|
|
9
12
|
export const CLOUDFLARED_URL_TIMEOUT_MS = 30_000; // wait for the URL line
|
|
10
13
|
export const CLOUDFLARED_HEALTH_ATTEMPTS = 10; // edge-reachability probes
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True only when an env var is set to a meaningfully "on" value. Unset, '', '0',
|
|
3
|
+
* 'false', 'no', and 'off' (any case, trimmed) all read as off — so `FOO=0`
|
|
4
|
+
* disables a flag instead of accidentally enabling it (a plain `process.env.FOO`
|
|
5
|
+
* truthiness check treats "0"/"false" as true).
|
|
6
|
+
*/
|
|
7
|
+
export declare function envFlag(name: string): boolean;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True only when an env var is set to a meaningfully "on" value. Unset, '', '0',
|
|
3
|
+
* 'false', 'no', and 'off' (any case, trimmed) all read as off — so `FOO=0`
|
|
4
|
+
* disables a flag instead of accidentally enabling it (a plain `process.env.FOO`
|
|
5
|
+
* truthiness check treats "0"/"false" as true).
|
|
6
|
+
*/
|
|
7
|
+
export function envFlag(name) {
|
|
8
|
+
const v = process.env[name];
|
|
9
|
+
if (v === undefined)
|
|
10
|
+
return false;
|
|
11
|
+
const s = v.trim().toLowerCase();
|
|
12
|
+
return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
|
|
13
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,35 +3,71 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { TunnelSession } from './session.js';
|
|
5
5
|
import { registerTools, defaultDisplayName } from './tools.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
import { parseArgs, helpText, runInstallSkill } from './cli.js';
|
|
7
|
+
import { readVersion, isSkillInstalled } from './skillInstall.js';
|
|
8
|
+
// Write a one-shot command's output, then exit only once it has flushed — a bare
|
|
9
|
+
// write()+exit() can truncate output piped to another process.
|
|
10
|
+
function writeThenExit(stream, text, code) {
|
|
11
|
+
stream.write(text.endsWith('\n') ? text : text + '\n', () => process.exit(code));
|
|
12
|
+
}
|
|
13
|
+
async function main() {
|
|
14
|
+
const version = readVersion();
|
|
15
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
16
|
+
// One-shot commands print and exit without ever opening the JSON-RPC channel.
|
|
17
|
+
if (parsed.mode === 'help')
|
|
18
|
+
return writeThenExit(process.stdout, helpText(version), 0);
|
|
19
|
+
if (parsed.mode === 'version')
|
|
20
|
+
return writeThenExit(process.stdout, version, 0);
|
|
21
|
+
if (parsed.mode === 'error') {
|
|
22
|
+
return writeThenExit(process.stderr, `tunnel-mcp: ${parsed.message}\n\n${helpText(version)}`, 2);
|
|
23
|
+
}
|
|
24
|
+
if (parsed.mode === 'install-skill') {
|
|
25
|
+
const lines = [];
|
|
26
|
+
const code = runInstallSkill({ dir: parsed.dir, force: parsed.force }, (m) => lines.push(m));
|
|
27
|
+
// Success → stdout; failure → stderr.
|
|
28
|
+
return writeThenExit(code === 0 ? process.stdout : process.stderr, lines.join('\n'), code);
|
|
29
|
+
}
|
|
30
|
+
// Serve. stdout is reserved for the JSON-RPC transport, so all human-facing
|
|
31
|
+
// output goes to stderr (MCP clients capture it into their logs).
|
|
32
|
+
const session = new TunnelSession();
|
|
33
|
+
const server = new McpServer({ name: 'tunnel', version });
|
|
34
|
+
registerTools(server, session, { displayName: defaultDisplayName() });
|
|
35
|
+
let closing = false;
|
|
36
|
+
async function teardown() {
|
|
37
|
+
if (closing)
|
|
38
|
+
return;
|
|
39
|
+
closing = true;
|
|
40
|
+
try {
|
|
41
|
+
await session.close('process exit');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* best effort */
|
|
45
|
+
}
|
|
46
|
+
process.exit(0);
|
|
16
47
|
}
|
|
17
|
-
|
|
18
|
-
|
|
48
|
+
process.on('SIGINT', teardown);
|
|
49
|
+
process.on('SIGTERM', teardown);
|
|
50
|
+
const transport = new StdioServerTransport();
|
|
51
|
+
// The host holds an HTTP/WS listener + a cloudflared child, so the event loop
|
|
52
|
+
// never drains and `beforeExit` would never fire. Drive teardown off the stdio
|
|
53
|
+
// pipe closing instead, which is how an MCP client actually ends the server.
|
|
54
|
+
transport.onclose = () => {
|
|
55
|
+
void teardown();
|
|
56
|
+
};
|
|
57
|
+
process.stdin.on('end', () => {
|
|
58
|
+
void teardown();
|
|
59
|
+
});
|
|
60
|
+
process.stdin.on('close', () => {
|
|
61
|
+
void teardown();
|
|
62
|
+
});
|
|
63
|
+
// Startup banner so a human who runs this by hand isn't staring at a silent,
|
|
64
|
+
// seemingly-hung process (this is a server, not an interactive CLI).
|
|
65
|
+
process.stderr.write(`tunnel-mcp v${version} ready on stdio — this is an MCP server, not an interactive CLI. ` +
|
|
66
|
+
`Add it to an MCP client (run \`tunnel-mcp --help\`).\n`);
|
|
67
|
+
if (!isSkillInstalled()) {
|
|
68
|
+
process.stderr.write(`tip: run \`npx tunnel-mcp install-skill\` to install the tunnel-etiquette skill ` +
|
|
69
|
+
`(teaches agents to treat the peer as untrusted input).\n`);
|
|
19
70
|
}
|
|
20
|
-
|
|
71
|
+
await server.connect(transport);
|
|
21
72
|
}
|
|
22
|
-
|
|
23
|
-
process.on('SIGTERM', teardown);
|
|
24
|
-
const transport = new StdioServerTransport();
|
|
25
|
-
// The host holds an HTTP/WS listener + a cloudflared child, so the event loop
|
|
26
|
-
// never drains and `beforeExit` would never fire. Drive teardown off the stdio
|
|
27
|
-
// pipe closing instead, which is how an MCP client actually ends the server.
|
|
28
|
-
transport.onclose = () => {
|
|
29
|
-
void teardown();
|
|
30
|
-
};
|
|
31
|
-
process.stdin.on('end', () => {
|
|
32
|
-
void teardown();
|
|
33
|
-
});
|
|
34
|
-
process.stdin.on('close', () => {
|
|
35
|
-
void teardown();
|
|
36
|
-
});
|
|
37
|
-
await server.connect(transport);
|
|
73
|
+
void main();
|
|
@@ -8,6 +8,7 @@ export interface HostRelayOptions {
|
|
|
8
8
|
goal: string;
|
|
9
9
|
hostName: string;
|
|
10
10
|
idleMs?: number;
|
|
11
|
+
joinTtlMs?: number;
|
|
11
12
|
}
|
|
12
13
|
export declare class HostRelay extends EventEmitter {
|
|
13
14
|
private opts;
|
|
@@ -19,9 +20,12 @@ export declare class HostRelay extends EventEmitter {
|
|
|
19
20
|
private challenges;
|
|
20
21
|
private idleTimer?;
|
|
21
22
|
private tearingDown;
|
|
23
|
+
private consumed;
|
|
24
|
+
private joinDeadline;
|
|
22
25
|
constructor(opts: HostRelayOptions, log: SessionLog);
|
|
23
26
|
start(): Promise<number>;
|
|
24
27
|
get peerConnected(): boolean;
|
|
28
|
+
armJoinDeadline(ttlMs?: number): void;
|
|
25
29
|
submitLocal(msg: WireMessage): WireMessage;
|
|
26
30
|
private resetIdle;
|
|
27
31
|
private submit;
|
package/dist/relay/hostRelay.js
CHANGED
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
4
|
import { makeChallenge, verifyChallenge } from '../protocol/crypto.js';
|
|
5
5
|
import { encodeFrame, decodeFrame, buildSystem, } from '../protocol/messages.js';
|
|
6
|
-
import { DEFAULT_IDLE_TEARDOWN_MS } from '../config.js';
|
|
6
|
+
import { DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS } from '../config.js';
|
|
7
7
|
export class HostRelay extends EventEmitter {
|
|
8
8
|
opts;
|
|
9
9
|
log;
|
|
@@ -14,10 +14,16 @@ export class HostRelay extends EventEmitter {
|
|
|
14
14
|
challenges = new WeakMap();
|
|
15
15
|
idleTimer;
|
|
16
16
|
tearingDown = false;
|
|
17
|
+
// Single-use, expiring join link: the link is valid until `joinDeadline`, and
|
|
18
|
+
// is consumed by the first successful authentication so it can never be reused
|
|
19
|
+
// (even after that guest disconnects).
|
|
20
|
+
consumed = false;
|
|
21
|
+
joinDeadline;
|
|
17
22
|
constructor(opts, log) {
|
|
18
23
|
super();
|
|
19
24
|
this.opts = opts;
|
|
20
25
|
this.log = log;
|
|
26
|
+
this.joinDeadline = Date.now() + (opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
|
|
21
27
|
this.server = http.createServer();
|
|
22
28
|
this.wss = new WebSocketServer({ server: this.server, path: `/t/${opts.tunnelId}` });
|
|
23
29
|
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
@@ -39,6 +45,13 @@ export class HostRelay extends EventEmitter {
|
|
|
39
45
|
get peerConnected() {
|
|
40
46
|
return !!this.guest && this.guest.readyState === WebSocket.OPEN;
|
|
41
47
|
}
|
|
48
|
+
// (Re)start the single-use join link's expiry window. The session calls this
|
|
49
|
+
// once the link is actually minted (after cloudflared provisioning), so the
|
|
50
|
+
// window is measured from when the host receives the link to share — not from
|
|
51
|
+
// relay construction, which happens before provisioning could burn time.
|
|
52
|
+
armJoinDeadline(ttlMs) {
|
|
53
|
+
this.joinDeadline = Date.now() + (ttlMs ?? this.opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
|
|
54
|
+
}
|
|
42
55
|
submitLocal(msg) {
|
|
43
56
|
return this.submit(msg);
|
|
44
57
|
}
|
|
@@ -95,11 +108,24 @@ export class HostRelay extends EventEmitter {
|
|
|
95
108
|
ws.close();
|
|
96
109
|
return;
|
|
97
110
|
}
|
|
98
|
-
if (
|
|
99
|
-
ws.send(encodeFrame({ t: 'auth_fail', reason: '
|
|
111
|
+
if (Date.now() > this.joinDeadline) {
|
|
112
|
+
ws.send(encodeFrame({ t: 'auth_fail', reason: 'join link expired' }));
|
|
113
|
+
ws.close();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (this.consumed) {
|
|
117
|
+
// The single-use link was already redeemed. Distinguish the case
|
|
118
|
+
// where the original guest is still connected ('tunnel full') from a
|
|
119
|
+
// reuse attempt after they left ('join link already used').
|
|
120
|
+
const connected = !!this.guest && this.guest.readyState === WebSocket.OPEN;
|
|
121
|
+
ws.send(encodeFrame({
|
|
122
|
+
t: 'auth_fail',
|
|
123
|
+
reason: connected ? 'tunnel full' : 'join link already used',
|
|
124
|
+
}));
|
|
100
125
|
ws.close();
|
|
101
126
|
return;
|
|
102
127
|
}
|
|
128
|
+
this.consumed = true;
|
|
103
129
|
this.guest = ws;
|
|
104
130
|
this.guestName = frame.name;
|
|
105
131
|
const sinceSeq = Number.isFinite(frame.sinceSeq) ? frame.sinceSeq : 0;
|
package/dist/session.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface SessionDeps {
|
|
|
4
4
|
ensureCloudflared: () => Promise<string>;
|
|
5
5
|
startCloudflared: (bin: string, port: number) => Promise<TunnelHandle>;
|
|
6
6
|
idleMs?: number;
|
|
7
|
+
joinTtlMs?: number;
|
|
7
8
|
}
|
|
8
9
|
export interface SessionStatus {
|
|
9
10
|
role: Role;
|
|
@@ -30,6 +31,7 @@ export declare class TunnelSession {
|
|
|
30
31
|
tunnelId: string;
|
|
31
32
|
joinLink: string;
|
|
32
33
|
status: string;
|
|
34
|
+
joinLinkExpiresInSec: number;
|
|
33
35
|
}>;
|
|
34
36
|
join(joinLink: string, guestName: string): Promise<{
|
|
35
37
|
tunnelId: string;
|
package/dist/session.js
CHANGED
|
@@ -6,10 +6,13 @@ import { HostRelay } from './relay/hostRelay.js';
|
|
|
6
6
|
import { GuestClient } from './relay/guestClient.js';
|
|
7
7
|
import { ensureCloudflared as realEnsure } from './cloudflared/provision.js';
|
|
8
8
|
import { startCloudflared as realStart } from './cloudflared/tunnelProcess.js';
|
|
9
|
-
import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
|
|
9
|
+
import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
|
|
10
|
+
import { envFlag } from './env.js';
|
|
10
11
|
const DEFAULT_DEPS = {
|
|
11
12
|
ensureCloudflared: realEnsure,
|
|
12
|
-
|
|
13
|
+
// Read the escape hatch per-call (not at module load) so it can be set right
|
|
14
|
+
// before opening a tunnel.
|
|
15
|
+
startCloudflared: (bin, port) => realStart(bin, port, envFlag('TUNNEL_SKIP_REACHABILITY_CHECK') ? { skipHealthCheck: true } : {}),
|
|
13
16
|
};
|
|
14
17
|
export class TunnelSession {
|
|
15
18
|
deps;
|
|
@@ -35,8 +38,9 @@ export class TunnelSession {
|
|
|
35
38
|
const key = generateKey();
|
|
36
39
|
const tunnelId = generateTunnelId();
|
|
37
40
|
const idleMs = this.deps.idleMs ?? DEFAULT_IDLE_TEARDOWN_MS;
|
|
41
|
+
const joinTtlMs = this.deps.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS;
|
|
38
42
|
const log = new SessionLog(tunnelId);
|
|
39
|
-
const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs }, log);
|
|
43
|
+
const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs, joinTtlMs }, log);
|
|
40
44
|
const port = await relay.start();
|
|
41
45
|
// Bounded retry: cloudflared may crash or never yield a URL; re-spawn before giving up.
|
|
42
46
|
let tunnel;
|
|
@@ -56,6 +60,10 @@ export class TunnelSession {
|
|
|
56
60
|
log.delete();
|
|
57
61
|
throw new Error(`could not establish a cloudflared tunnel after ${OPEN_RETRY_ATTEMPTS} attempts: ${String(lastErr)}`);
|
|
58
62
|
}
|
|
63
|
+
// Start the single-use link's expiry window now that the link exists —
|
|
64
|
+
// measured from mint time, not from relay construction (which happened
|
|
65
|
+
// before cloudflared provisioning could burn part of the window).
|
|
66
|
+
relay.armJoinDeadline();
|
|
59
67
|
const joinLink = mintLink(tunnel.publicUrl, tunnelId, key);
|
|
60
68
|
this.role = 'host';
|
|
61
69
|
this.key = key;
|
|
@@ -71,7 +79,12 @@ export class TunnelSession {
|
|
|
71
79
|
void this.close();
|
|
72
80
|
});
|
|
73
81
|
relay.submitLocal(buildSystem('host', `tunnel opened — goal: ${goal}`));
|
|
74
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
tunnelId,
|
|
84
|
+
joinLink,
|
|
85
|
+
status: 'waiting_for_guest',
|
|
86
|
+
joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
|
|
87
|
+
};
|
|
75
88
|
}
|
|
76
89
|
async join(joinLink, guestName) {
|
|
77
90
|
if (this.isOpen)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function bundledSkillDir(): string;
|
|
2
|
+
export declare function readVersion(): string;
|
|
3
|
+
export declare function defaultSkillsDir(): string;
|
|
4
|
+
/** Precedence: explicit arg → $TUNNEL_SKILLS_DIR → ~/.claude/skills. */
|
|
5
|
+
export declare function resolveSkillsDir(explicit?: string): string;
|
|
6
|
+
export declare function isSkillInstalled(skillsDir?: string): boolean;
|
|
7
|
+
export interface InstallResult {
|
|
8
|
+
installed: boolean;
|
|
9
|
+
updated: boolean;
|
|
10
|
+
target: string;
|
|
11
|
+
source: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function installSkill(opts?: {
|
|
14
|
+
skillsDir?: string;
|
|
15
|
+
overwrite?: boolean;
|
|
16
|
+
}): InstallResult;
|
|
17
|
+
/**
|
|
18
|
+
* Called from the postinstall script. Best-effort by contract: it must never
|
|
19
|
+
* throw (a failed skill copy must not fail `npm install`), it only installs
|
|
20
|
+
* when absent (never clobbers a user's copy on reinstall), and it bows out
|
|
21
|
+
* under CI or when the user opts out.
|
|
22
|
+
*/
|
|
23
|
+
export declare function installSkillBestEffort(log?: (msg: string) => void): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { cpSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { envFlag } from './env.js';
|
|
6
|
+
const SKILL_NAME = 'tunnel-etiquette';
|
|
7
|
+
// dist/skillInstall.js → package root is one level up. During tests (tsx runs
|
|
8
|
+
// src/skillInstall.ts) this resolves to the repo root, which has the same
|
|
9
|
+
// `skill/` and `package.json` layout as the published package.
|
|
10
|
+
function packageRoot() {
|
|
11
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
}
|
|
13
|
+
export function bundledSkillDir() {
|
|
14
|
+
return path.join(packageRoot(), 'skill', SKILL_NAME);
|
|
15
|
+
}
|
|
16
|
+
export function readVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
|
|
19
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return '0.0.0';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function defaultSkillsDir() {
|
|
26
|
+
return path.join(os.homedir(), '.claude', 'skills');
|
|
27
|
+
}
|
|
28
|
+
/** Precedence: explicit arg → $TUNNEL_SKILLS_DIR → ~/.claude/skills. */
|
|
29
|
+
export function resolveSkillsDir(explicit) {
|
|
30
|
+
return explicit || process.env.TUNNEL_SKILLS_DIR || defaultSkillsDir();
|
|
31
|
+
}
|
|
32
|
+
function targetDir(skillsDir) {
|
|
33
|
+
return path.join(skillsDir, SKILL_NAME);
|
|
34
|
+
}
|
|
35
|
+
export function isSkillInstalled(skillsDir = resolveSkillsDir()) {
|
|
36
|
+
return existsSync(path.join(targetDir(skillsDir), 'SKILL.md'));
|
|
37
|
+
}
|
|
38
|
+
export function installSkill(opts = {}) {
|
|
39
|
+
const source = bundledSkillDir();
|
|
40
|
+
if (!existsSync(path.join(source, 'SKILL.md'))) {
|
|
41
|
+
throw new Error(`bundled tunnel-etiquette skill not found at ${source}`);
|
|
42
|
+
}
|
|
43
|
+
const skillsDir = resolveSkillsDir(opts.skillsDir);
|
|
44
|
+
const target = targetDir(skillsDir);
|
|
45
|
+
const existed = existsSync(path.join(target, 'SKILL.md'));
|
|
46
|
+
if (existed && !opts.overwrite) {
|
|
47
|
+
return { installed: false, updated: false, target, source };
|
|
48
|
+
}
|
|
49
|
+
cpSync(source, target, { recursive: true });
|
|
50
|
+
return { installed: true, updated: existed, target, source };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Called from the postinstall script. Best-effort by contract: it must never
|
|
54
|
+
* throw (a failed skill copy must not fail `npm install`), it only installs
|
|
55
|
+
* when absent (never clobbers a user's copy on reinstall), and it bows out
|
|
56
|
+
* under CI or when the user opts out.
|
|
57
|
+
*/
|
|
58
|
+
export function installSkillBestEffort(log = () => { }) {
|
|
59
|
+
if (envFlag('TUNNEL_SKIP_SKILL_INSTALL') || envFlag('CI'))
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
const res = installSkill({ overwrite: false });
|
|
63
|
+
if (res.installed)
|
|
64
|
+
log(`tunnel-mcp: installed the tunnel-etiquette skill to ${res.target}`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* never break an install */
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -28,7 +28,7 @@ function register(server, name, schema, cb) {
|
|
|
28
28
|
}
|
|
29
29
|
export function registerTools(server, session, opts) {
|
|
30
30
|
register(server, 'tunnel_open', {
|
|
31
|
-
description: 'Open a tunnel as host and get a join link to share.',
|
|
31
|
+
description: 'Open a tunnel as host and get a join link to share. The link is a secret — share it over a trusted channel. It is single-use (works for exactly one guest) and expires (see joinLinkExpiresInSec in the result), so tell the human to share it promptly.',
|
|
32
32
|
inputSchema: { goal: z.string() },
|
|
33
33
|
}, async ({ goal }) => ok(await session.open(goal, opts.displayName)));
|
|
34
34
|
register(server, 'tunnel_join', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnel-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
13
|
"skill",
|
|
14
|
+
"postinstall.mjs",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE",
|
|
16
17
|
"CHANGELOG.md",
|
|
@@ -28,6 +29,8 @@
|
|
|
28
29
|
"format": "prettier --write .",
|
|
29
30
|
"format:check": "prettier --check .",
|
|
30
31
|
"dev": "tsx src/index.ts",
|
|
32
|
+
"e2e": "tsx scripts/e2e-two-agents.ts",
|
|
33
|
+
"postinstall": "node postinstall.mjs",
|
|
31
34
|
"prepublishOnly": "npm run build"
|
|
32
35
|
},
|
|
33
36
|
"keywords": [
|
|
@@ -62,6 +65,7 @@
|
|
|
62
65
|
"zod": "^3.23.0"
|
|
63
66
|
},
|
|
64
67
|
"devDependencies": {
|
|
68
|
+
"@anthropic-ai/sdk": "^0.109.0",
|
|
65
69
|
"@types/node": "^20.14.0",
|
|
66
70
|
"@types/ws": "^8.5.10",
|
|
67
71
|
"@vitest/coverage-v8": "^2.0.0",
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Best-effort: install the tunnel-etiquette skill into ~/.claude/skills when the
|
|
2
|
+
// package is installed. This MUST never fail an install and is deliberately a
|
|
3
|
+
// no-op in the cases where it can't or shouldn't run:
|
|
4
|
+
// - `npm install --ignore-scripts` (and hosts that disable scripts) skip it
|
|
5
|
+
// - CI, or TUNNEL_SKIP_SKILL_INSTALL=1, opt out (see installSkillBestEffort)
|
|
6
|
+
// - a dev checkout before `npm run build` has no dist/ yet → import throws → ignored
|
|
7
|
+
// It IS idempotent, so running again (e.g. npx populating its cache) is harmless.
|
|
8
|
+
try {
|
|
9
|
+
const { installSkillBestEffort } = await import('./dist/skillInstall.js');
|
|
10
|
+
installSkillBestEffort((m) => console.error(m));
|
|
11
|
+
} catch {
|
|
12
|
+
/* dist not built yet, or any other error — never break the install */
|
|
13
|
+
}
|