tunnel-mcp 0.1.1 → 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 +29 -1
- package/README.md +40 -7
- 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/env.d.ts +7 -0
- package/dist/env.js +13 -0
- package/dist/index.js +65 -29
- package/dist/session.js +4 -1
- package/dist/skillInstall.d.ts +23 -0
- package/dist/skillInstall.js +69 -0
- package/package.json +3 -1
- package/postinstall.mjs +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,32 @@ 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
|
+
|
|
14
40
|
## [0.1.1] - 2026-07-01
|
|
15
41
|
|
|
16
42
|
### Security
|
|
@@ -54,5 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
54
80
|
declaring a fix "confirmed".
|
|
55
81
|
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
56
82
|
|
|
57
|
-
[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
|
|
58
86
|
[0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -50,13 +50,29 @@ npx tunnel-mcp
|
|
|
50
50
|
Register it with Claude Code (both developers do this once):
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
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
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
60
76
|
|
|
61
77
|
`cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
|
|
62
78
|
it isn't already on your `PATH` — there's nothing extra to install.
|
|
@@ -146,7 +162,7 @@ vulnerability.
|
|
|
146
162
|
|
|
147
163
|
```bash
|
|
148
164
|
npm ci # install dependencies
|
|
149
|
-
npm test # run the test suite (
|
|
165
|
+
npm test # run the test suite (136 tests, TDD)
|
|
150
166
|
npm run build # compile TypeScript
|
|
151
167
|
npm run lint # eslint
|
|
152
168
|
npm run format:check # prettier --check .
|
|
@@ -155,6 +171,23 @@ npm run test:coverage # vitest run --coverage
|
|
|
155
171
|
|
|
156
172
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
|
|
157
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
|
+
|
|
158
191
|
## Roadmap / not yet supported
|
|
159
192
|
|
|
160
193
|
This is an MVP. The following are explicitly out of scope for now:
|
|
@@ -162,7 +195,7 @@ This is an MVP. The following are explicitly out of scope for now:
|
|
|
162
195
|
- Host-offline / asynchronous messaging
|
|
163
196
|
- More than two participants in a session
|
|
164
197
|
- Alternative transports (ngrok, WebRTC)
|
|
165
|
-
- 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)
|
|
166
199
|
- Encrypting the goal or other metadata
|
|
167
200
|
|
|
168
201
|
## License
|
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/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();
|
package/dist/session.js
CHANGED
|
@@ -7,9 +7,12 @@ 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
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;
|
|
@@ -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/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",
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"format:check": "prettier --check .",
|
|
30
31
|
"dev": "tsx src/index.ts",
|
|
31
32
|
"e2e": "tsx scripts/e2e-two-agents.ts",
|
|
33
|
+
"postinstall": "node postinstall.mjs",
|
|
32
34
|
"prepublishOnly": "npm run build"
|
|
33
35
|
},
|
|
34
36
|
"keywords": [
|
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
|
+
}
|