tunnel-mcp 0.1.2 → 0.1.3
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 +16 -1
- package/README.md +15 -9
- package/dist/cli.js +6 -4
- package/dist/cloudflared/tunnelProcess.d.ts +4 -1
- package/dist/cloudflared/tunnelProcess.js +41 -19
- package/dist/env.d.ts +12 -0
- package/dist/env.js +18 -0
- package/dist/session.d.ts +1 -0
- package/dist/session.js +7 -4
- package/dist/tools.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,20 @@ 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.3] - 2026-07-01
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`tunnel_open` no longer hard-fails when the host can't reach
|
|
19
|
+
`*.trycloudflare.com`.** Because this is a cross-network tool, only the guest's
|
|
20
|
+
network has to reach the link — so a host-side reachability-probe failure
|
|
21
|
+
(blocked DNS, or a proxy Node's `fetch` ignores) now **opens the tunnel anyway
|
|
22
|
+
and returns a `reachabilityWarning`** by default, instead of blocking a tunnel
|
|
23
|
+
that would have worked for the guest. Behavior is configurable via
|
|
24
|
+
`TUNNEL_REACHABILITY`: `warn` (default), `strict` (previous hard-fail), or
|
|
25
|
+
`off` (skip the probe). This replaces the `TUNNEL_SKIP_REACHABILITY_CHECK` flag
|
|
26
|
+
from 0.1.2, which is still honored as `off` for backward compatibility.
|
|
27
|
+
|
|
14
28
|
## [0.1.2] - 2026-07-01
|
|
15
29
|
|
|
16
30
|
### Added
|
|
@@ -80,7 +94,8 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
|
|
|
80
94
|
declaring a fix "confirmed".
|
|
81
95
|
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
82
96
|
|
|
83
|
-
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.
|
|
97
|
+
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...HEAD
|
|
98
|
+
[0.1.3]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...v0.1.3
|
|
84
99
|
[0.1.2]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.1...v0.1.2
|
|
85
100
|
[0.1.1]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...v0.1.1
|
|
86
101
|
[0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -178,15 +178,21 @@ an interactive CLI — with no arguments it starts and waits for an MCP client t
|
|
|
178
178
|
connect over stdin/stdout. That's working as intended. Register it with a client
|
|
179
179
|
(above), or run `tunnel-mcp --help`.
|
|
180
180
|
|
|
181
|
-
**`tunnel_open`
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
`
|
|
188
|
-
|
|
189
|
-
|
|
181
|
+
**`tunnel_open` warns that this machine can't reach `*.trycloudflare.com`.**
|
|
182
|
+
cloudflared reaches Cloudflare's edge over its own protocol, but the public
|
|
183
|
+
`*.trycloudflare.com` hostname still has to resolve via normal DNS — and some
|
|
184
|
+
networks (corporate/filtered networks, some public DNS resolvers, or a proxy that
|
|
185
|
+
Node's `fetch` ignores) can't reach it from the host. Because only your
|
|
186
|
+
**guest's** network truly has to reach the link, `tunnel_open` **opens anyway and
|
|
187
|
+
returns a `reachabilityWarning` by default** — share the link and have your guest
|
|
188
|
+
confirm they can open it. Control this with `TUNNEL_REACHABILITY`:
|
|
189
|
+
|
|
190
|
+
- `warn` (default) — open, but warn when the host can't reach the URL
|
|
191
|
+
- `strict` — fail `tunnel_open` unless the host can reach the URL first
|
|
192
|
+
- `off` — skip the host-side reachability probe entirely
|
|
193
|
+
|
|
194
|
+
Diagnose the host's DNS with `dig +short <random>.trycloudflare.com` or
|
|
195
|
+
`curl -sI https://<the-url>`.
|
|
190
196
|
|
|
191
197
|
## Roadmap / not yet supported
|
|
192
198
|
|
package/dist/cli.js
CHANGED
|
@@ -60,10 +60,12 @@ export function helpText(version = readVersion()) {
|
|
|
60
60
|
` --force, -f Overwrite an existing install`,
|
|
61
61
|
``,
|
|
62
62
|
`Environment:`,
|
|
63
|
-
` TUNNEL_SKILLS_DIR
|
|
64
|
-
` TUNNEL_SKIP_SKILL_INSTALL=1
|
|
65
|
-
`
|
|
66
|
-
`
|
|
63
|
+
` TUNNEL_SKILLS_DIR Override the skills directory`,
|
|
64
|
+
` TUNNEL_SKIP_SKILL_INSTALL=1 Skip the automatic skill install on npm install`,
|
|
65
|
+
` TUNNEL_REACHABILITY warn (default) | strict | off — how tunnel_open reacts`,
|
|
66
|
+
` when this host can't reach *.trycloudflare.com. Only the`,
|
|
67
|
+
` guest's network truly must reach it, so the default warns`,
|
|
68
|
+
` and opens anyway; strict fails; off skips the check.`,
|
|
67
69
|
``,
|
|
68
70
|
`Docs: https://github.com/zachlikefolio/tunnel-mcp`,
|
|
69
71
|
].join('\n');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { ReachabilityMode } from '../env.js';
|
|
1
2
|
export interface TunnelHandle {
|
|
2
3
|
publicUrl: string;
|
|
3
4
|
stop(): void;
|
|
5
|
+
reachabilityWarning?: string;
|
|
4
6
|
}
|
|
5
7
|
export interface StartOptions {
|
|
6
8
|
timeoutMs?: number;
|
|
@@ -9,12 +11,13 @@ export interface StartOptions {
|
|
|
9
11
|
intervalMs?: number;
|
|
10
12
|
healthCheck?: (url: string) => Promise<boolean>;
|
|
11
13
|
probeTimeoutMs?: number;
|
|
12
|
-
|
|
14
|
+
reachability?: ReachabilityMode;
|
|
13
15
|
}
|
|
14
16
|
export declare function parsePublicUrl(line: string): string | null;
|
|
15
17
|
export declare function describeProbeError(e: unknown): string;
|
|
16
18
|
export declare function defaultHealthCheck(url: string): Promise<boolean>;
|
|
17
19
|
export declare function unreachableMessage(url: string, attempts: number, lastReason?: string): string;
|
|
20
|
+
export declare function reachabilityWarningMessage(url: string, lastReason?: string): string;
|
|
18
21
|
/**
|
|
19
22
|
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
20
23
|
* launched in place of `cloudflared tunnel --url ...`. Production passes none.
|
|
@@ -35,12 +35,12 @@ async function reachabilityProbe(url, probeTimeoutMs) {
|
|
|
35
35
|
export async function defaultHealthCheck(url) {
|
|
36
36
|
return (await reachabilityProbe(url, DEFAULT_PROBE_TIMEOUT_MS)).ok;
|
|
37
37
|
}
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
// The shared DNS sentence: when a probe failure looks like name resolution,
|
|
39
|
+
// point at *.trycloudflare.com being blocked — the single most common real-world
|
|
40
|
+
// cause. Returns '' when the failure isn't DNS-shaped.
|
|
41
|
+
function dnsHint(url, reason) {
|
|
42
|
+
if (!reason || !/ENOTFOUND|EAI_AGAIN|getaddrinfo|\bdns\b/i.test(reason))
|
|
43
|
+
return '';
|
|
44
44
|
let host = url;
|
|
45
45
|
try {
|
|
46
46
|
host = new URL(url).host;
|
|
@@ -48,18 +48,31 @@ export function unreachableMessage(url, attempts, lastReason) {
|
|
|
48
48
|
catch {
|
|
49
49
|
/* keep the raw url */
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
return (` This machine can't resolve ${host} — your DNS or network may be blocking *.trycloudflare.com` +
|
|
52
|
+
` (common on filtered/corporate networks and some public DNS resolvers).`);
|
|
53
|
+
}
|
|
54
|
+
// Fatal error for `strict` mode: the host never confirmed the edge was routing.
|
|
55
|
+
export function unreachableMessage(url, attempts, lastReason) {
|
|
52
56
|
let msg = `cloudflared reported ${url} but it never became reachable from this machine after ${attempts} probe(s)`;
|
|
53
57
|
if (lastReason)
|
|
54
58
|
msg += ` (last error: ${lastReason})`;
|
|
55
|
-
msg += '.';
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
msg += '.' + dnsHint(url, lastReason);
|
|
60
|
+
msg +=
|
|
61
|
+
` Both you and your guest must be able to reach it. Set TUNNEL_REACHABILITY=warn (the default) to` +
|
|
62
|
+
` open anyway with a warning, or TUNNEL_REACHABILITY=off to skip this check entirely.`;
|
|
63
|
+
return msg;
|
|
64
|
+
}
|
|
65
|
+
// Non-fatal warning for `warn` mode: the tunnel is open, but this host couldn't
|
|
66
|
+
// confirm reachability. Only the guest's network has to reach the URL, so this
|
|
67
|
+
// is often a false alarm — but surface it so the human can sanity-check.
|
|
68
|
+
export function reachabilityWarningMessage(url, lastReason) {
|
|
69
|
+
let msg = `Tunnel opened, but this machine could not reach ${url}`;
|
|
70
|
+
if (lastReason)
|
|
71
|
+
msg += ` (${lastReason})`;
|
|
72
|
+
msg += '.' + dnsHint(url, lastReason);
|
|
73
|
+
msg +=
|
|
74
|
+
` Your guest still needs to reach the link — if they can't open it, check your DNS/proxy. Set` +
|
|
75
|
+
` TUNNEL_REACHABILITY=strict to require host reachability, or =off to silence this check.`;
|
|
63
76
|
return msg;
|
|
64
77
|
}
|
|
65
78
|
// Races a single probe against a per-attempt timeout so that a caller-supplied
|
|
@@ -142,10 +155,12 @@ export function startCloudflared(binPath, localPort, opts = {}) {
|
|
|
142
155
|
if (url && !settled) {
|
|
143
156
|
settled = true;
|
|
144
157
|
clearTimeout(timer);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
158
|
+
// The reachability probe runs on the *host*, but only the guest's
|
|
159
|
+
// network must reach the URL for messaging. 'off' skips it entirely;
|
|
160
|
+
// 'warn' (the product default) opens anyway and reports a warning;
|
|
161
|
+
// 'strict' fails open() if the host can't confirm reachability.
|
|
162
|
+
const mode = opts.reachability ?? 'strict';
|
|
163
|
+
if (mode === 'off') {
|
|
149
164
|
resolve({ publicUrl: url, stop });
|
|
150
165
|
return;
|
|
151
166
|
}
|
|
@@ -153,6 +168,13 @@ export function startCloudflared(binPath, localPort, opts = {}) {
|
|
|
153
168
|
.then((res) => {
|
|
154
169
|
if (res.ok)
|
|
155
170
|
resolve({ publicUrl: url, stop });
|
|
171
|
+
else if (mode === 'warn') {
|
|
172
|
+
resolve({
|
|
173
|
+
publicUrl: url,
|
|
174
|
+
stop,
|
|
175
|
+
reachabilityWarning: reachabilityWarningMessage(url, res.reason),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
156
178
|
else {
|
|
157
179
|
stop();
|
|
158
180
|
reject(new Error(unreachableMessage(url, attempts, res.reason)));
|
package/dist/env.d.ts
CHANGED
|
@@ -5,3 +5,15 @@
|
|
|
5
5
|
* truthiness check treats "0"/"false" as true).
|
|
6
6
|
*/
|
|
7
7
|
export declare function envFlag(name: string): boolean;
|
|
8
|
+
export type ReachabilityMode = 'warn' | 'strict' | 'off';
|
|
9
|
+
/**
|
|
10
|
+
* How `tunnel_open` treats a host-side reachability-probe failure:
|
|
11
|
+
* warn (default) — open anyway, surface a warning; the guest is the real test
|
|
12
|
+
* strict — fail open() if the host can't reach the public URL
|
|
13
|
+
* off — skip the probe entirely
|
|
14
|
+
* Reads `TUNNEL_REACHABILITY`. Only when it is unset/blank does it fall back to
|
|
15
|
+
* the deprecated `TUNNEL_SKIP_REACHABILITY_CHECK` (== off) from 0.1.2 — an
|
|
16
|
+
* explicitly set (even mistyped) `TUNNEL_REACHABILITY` never defers to the alias.
|
|
17
|
+
* Any unrecognized value defaults to warn.
|
|
18
|
+
*/
|
|
19
|
+
export declare function reachabilityMode(): ReachabilityMode;
|
package/dist/env.js
CHANGED
|
@@ -11,3 +11,21 @@ export function envFlag(name) {
|
|
|
11
11
|
const s = v.trim().toLowerCase();
|
|
12
12
|
return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* How `tunnel_open` treats a host-side reachability-probe failure:
|
|
16
|
+
* warn (default) — open anyway, surface a warning; the guest is the real test
|
|
17
|
+
* strict — fail open() if the host can't reach the public URL
|
|
18
|
+
* off — skip the probe entirely
|
|
19
|
+
* Reads `TUNNEL_REACHABILITY`. Only when it is unset/blank does it fall back to
|
|
20
|
+
* the deprecated `TUNNEL_SKIP_REACHABILITY_CHECK` (== off) from 0.1.2 — an
|
|
21
|
+
* explicitly set (even mistyped) `TUNNEL_REACHABILITY` never defers to the alias.
|
|
22
|
+
* Any unrecognized value defaults to warn.
|
|
23
|
+
*/
|
|
24
|
+
export function reachabilityMode() {
|
|
25
|
+
const raw = (process.env.TUNNEL_REACHABILITY ?? '').trim().toLowerCase();
|
|
26
|
+
if (raw === 'warn' || raw === 'strict' || raw === 'off')
|
|
27
|
+
return raw;
|
|
28
|
+
if (raw === '' && envFlag('TUNNEL_SKIP_REACHABILITY_CHECK'))
|
|
29
|
+
return 'off';
|
|
30
|
+
return 'warn';
|
|
31
|
+
}
|
package/dist/session.d.ts
CHANGED
package/dist/session.js
CHANGED
|
@@ -7,12 +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 {
|
|
10
|
+
import { reachabilityMode } from './env.js';
|
|
11
11
|
const DEFAULT_DEPS = {
|
|
12
12
|
ensureCloudflared: realEnsure,
|
|
13
|
-
//
|
|
14
|
-
// before opening a tunnel.
|
|
15
|
-
startCloudflared: (bin, port) => realStart(bin, port,
|
|
13
|
+
// Resolve the reachability mode per-call (not at module load) so TUNNEL_REACHABILITY
|
|
14
|
+
// can be set right before opening a tunnel.
|
|
15
|
+
startCloudflared: (bin, port) => realStart(bin, port, { reachability: reachabilityMode() }),
|
|
16
16
|
};
|
|
17
17
|
export class TunnelSession {
|
|
18
18
|
deps;
|
|
@@ -84,6 +84,9 @@ export class TunnelSession {
|
|
|
84
84
|
joinLink,
|
|
85
85
|
status: 'waiting_for_guest',
|
|
86
86
|
joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
|
|
87
|
+
// Present only in 'warn' mode when the host couldn't confirm reachability;
|
|
88
|
+
// the agent should relay it to the human before sharing the link.
|
|
89
|
+
...(tunnel.reachabilityWarning ? { reachabilityWarning: tunnel.reachabilityWarning } : {}),
|
|
87
90
|
};
|
|
88
91
|
}
|
|
89
92
|
async join(joinLink, guestName) {
|
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. 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.',
|
|
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. If the result includes a reachabilityWarning, relay it to the human: this host could not confirm it can reach the link, so the guest should verify they can open it.',
|
|
32
32
|
inputSchema: { goal: z.string() },
|
|
33
33
|
}, async ({ goal }) => ok(await session.open(goal, opts.displayName)));
|
|
34
34
|
register(server, 'tunnel_join', {
|