@yawlabs/tailscale-mcp 0.19.3 → 0.19.4
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/README.md +15 -3
- package/bin/tailscale-mcp.mjs +452 -255
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -677,9 +677,17 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
677
677
|
|
|
678
678
|
## Running on oam.js (optional)
|
|
679
679
|
|
|
680
|
-
[oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.
|
|
680
|
+
[oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.15.2**. Verified against oam 0.15.2: full MCP handshake, all 97 admin-API tools plus `tailscale_tool_groups`, all 4 resources, identical error responses, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
|
|
681
681
|
|
|
682
|
-
**oam 0.
|
|
682
|
+
**oam 0.15.2 is the minimum.** A floor matters here: releases before 0.9.0 ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument, and this server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one.
|
|
683
|
+
|
|
684
|
+
How the `tailscale-mcp` command (`bin/tailscale-mcp.mjs`) picks a runtime:
|
|
685
|
+
|
|
686
|
+
- **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.15.2 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.15.2 or newer, else asks every oam binary it can find — `%LOCALAPPDATA%\oam\bin` then `~/.oam/bin` on Windows, `~/.oam/bin` elsewhere, then `PATH` — for its version and uses the newest at or above the floor (on a tie the installed copy wins). With none, it runs on Node. An oam host older than 0.15.2 never serves the server itself: it hands off to the newest usable oam, or to Node on `PATH`, or exits with an error when there is neither. Whenever it looks for an oam, stderr names an `OAM_BIN` that was passed over and why; the oam binaries it found and passed over are named, each with its reason, only when no usable oam turns up.
|
|
687
|
+
- **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node.
|
|
688
|
+
- **`TAILSCALE_MCP_RUNTIME=node`** — always Node: in-process under `npx`, handed off to Node on `PATH` when a client launches the command with `oam run`.
|
|
689
|
+
|
|
690
|
+
The value is case-insensitive; anything else is warned about on stderr and treated as `auto`. On Windows only `oam.exe` counts: an `oam.cmd` / `oam.bat` shim is never run, and it is named on stderr only when no usable oam turns up.
|
|
683
691
|
|
|
684
692
|
### Sandboxing (opt-in)
|
|
685
693
|
|
|
@@ -687,6 +695,8 @@ Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network r
|
|
|
687
695
|
|
|
688
696
|
It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it **absent** from `process.env` rather than throwing, so an under-granted `TAILSCALE_API_KEY` reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new `process.env` lookup, extend that list with it.
|
|
689
697
|
|
|
698
|
+
The sandbox is applied by the `tailscale-mcp` command, which spawns a fresh oam for it -- even when a client launched the command with `oam run` -- because `--permission` is a process-level flag. If it finds no usable oam to spawn, `TAILSCALE_MCP_RUNTIME=auto` still starts the server, without the sandbox; set `TAILSCALE_MCP_RUNTIME=oam` to make that an error. `TAILSCALE_MCP_RUNTIME=node` runs on Node, so it never applies the sandbox.
|
|
699
|
+
|
|
690
700
|
```jsonc
|
|
691
701
|
{
|
|
692
702
|
"mcpServers": {
|
|
@@ -698,7 +708,9 @@ It is opt-in rather than default because a wrong grant does not fail loudly. oam
|
|
|
698
708
|
}
|
|
699
709
|
```
|
|
700
710
|
|
|
701
|
-
**
|
|
711
|
+
**Measure startup on your own hardware.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for `oam run` over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Those runs used an oam that predates the 0.15.2 floor and have not been repeated since, so do not read them as a current ranking.
|
|
712
|
+
|
|
713
|
+
The published `tailscale-mcp` command prefers the newest usable oam it finds (see above). Without oam that costs almost nothing: discovery is file-existence checks only, never a subprocess, and the fallback runs the server inside the Node process npm already started. With oam installed, though, the command boots Node, runs `--version` on every oam binary it found to pick the newest, and only then boots oam, so it is always slower than pointing your client at a runtime directly — the config above for oam, `node /path/to/tailscale-mcp/dist/index.js` for Node. `TAILSCALE_MCP_RUNTIME=node` skips oam entirely.
|
|
702
714
|
|
|
703
715
|
Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
|
|
704
716
|
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -2,26 +2,42 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Runtime launcher for @yawlabs/tailscale-mcp.
|
|
4
4
|
*
|
|
5
|
-
* Prefers the oam runtime (https://oamjs.org) and falls back to
|
|
6
|
-
*
|
|
5
|
+
* Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
|
|
6
|
+
* Node. It never serves on an oam older than the floor below.
|
|
7
7
|
*
|
|
8
8
|
*
|
|
9
9
|
* WHY THE FALLBACK COSTS NOTHING
|
|
10
10
|
* npm has already started Node to run this launcher, so falling back is a
|
|
11
11
|
* plain `import()` of the server into THIS process: no extra spawn, no extra
|
|
12
|
-
* startup, byte-identical to invoking dist/index.js directly.
|
|
13
|
-
* stat-only
|
|
12
|
+
* startup, byte-identical to invoking dist/index.js directly. Finding the
|
|
13
|
+
* candidates is stat-only, so a machine without oam never pays for a
|
|
14
|
+
* subprocess.
|
|
14
15
|
*
|
|
15
16
|
* WHAT THE OAM PATH COSTS
|
|
16
|
-
* Reaching oam through an npm `bin` means Node boots first
|
|
17
|
-
*
|
|
17
|
+
* Reaching oam through an npm `bin` means Node boots first, every oam binary
|
|
18
|
+
* found is asked for its version, and then oam boots to serve -- so the
|
|
19
|
+
* launcher is slower than pointing a host at oam directly. Measured on
|
|
18
20
|
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
19
|
-
* oam 116ms, node 172ms, launcher 243ms.
|
|
20
|
-
* launcher is the slowest path -- it exists for `npx` convenience.
|
|
21
|
+
* oam 116ms, node 172ms, launcher 243ms. It exists for `npx` convenience.
|
|
21
22
|
*
|
|
22
23
|
* For an MCP host config, point straight at oam and skip this file:
|
|
23
24
|
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
24
25
|
*
|
|
26
|
+
* WHICH OAM
|
|
27
|
+
* OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
|
|
28
|
+
* discovery can see -- the installed locations, then PATH -- is asked for its
|
|
29
|
+
* version, and the NEWEST one at or above the floor wins; a tie keeps search
|
|
30
|
+
* order. Taking the first binary found instead let a stale copy early in the
|
|
31
|
+
* search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
|
|
32
|
+
* and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
|
|
33
|
+
* are searched first.
|
|
34
|
+
*
|
|
35
|
+
* An OAM_BIN that does not exist, is below the floor, or will not run is always
|
|
36
|
+
* named on stderr, and discovery carries on. It used to stop everything: a typo
|
|
37
|
+
* in OAM_BIN meant Node, with no hint why. The binaries discovery passes over,
|
|
38
|
+
* and any oam.cmd/oam.bat shim, are named only when NO usable oam is found --
|
|
39
|
+
* choosing a newer oam over an older one is not news.
|
|
40
|
+
*
|
|
25
41
|
* ALREADY RUNNING ON OAM
|
|
26
42
|
* A host can resolve this package's `bin` and launch `oam run <this file>`
|
|
27
43
|
* instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
|
|
@@ -33,17 +49,34 @@
|
|
|
33
49
|
* no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
|
|
34
50
|
* is not consulted on that path: the host has already chosen which oam runs.
|
|
35
51
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* apply
|
|
39
|
-
* security downgrade dressed up as an optimisation.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
52
|
+
* TAILSCALE_MCP_SANDBOX=1 still takes the discovery path on such a host,
|
|
53
|
+
* deliberately: `--permission` is a process-level flag that only a FRESH oam
|
|
54
|
+
* can apply, so serving in-process there would drop the sandbox without a
|
|
55
|
+
* word -- a security downgrade dressed up as an optimisation.
|
|
56
|
+
*
|
|
57
|
+
* A host oam BELOW the floor never serves. It used to, whenever discovery came
|
|
58
|
+
* up empty. It now hands the server off to the newest usable oam, or to Node
|
|
59
|
+
* found on PATH, or exits with an error when there is neither.
|
|
60
|
+
*
|
|
61
|
+
* Every handoff from an oam host PIPES stdio rather than inheriting it. Before
|
|
62
|
+
* 0.9.0 oam treated `stdio: 'inherit'` as `'pipe'`, so an inherited handoff
|
|
63
|
+
* from such a host connects the child to pipes nobody reads: measured with a
|
|
64
|
+
* real oam 0.8.2 host on aws-mcp's copy of this launcher, the MCP handshake
|
|
65
|
+
* never answered. Piping the streams explicitly completes it, to both oam and
|
|
66
|
+
* Node, and is equally correct from a current oam, so the rule does not depend
|
|
67
|
+
* on the host's version. A Node host keeps `inherit`, which hands over the
|
|
68
|
+
* same fds untouched.
|
|
45
69
|
*
|
|
46
|
-
*
|
|
70
|
+
* Discovery asks for a spawn; it does not guarantee one. If it finds no usable
|
|
71
|
+
* oam, or the spawn itself fails, the default TAILSCALE_MCP_RUNTIME=auto falls
|
|
72
|
+
* back. A Node host runs the server in-process, and so does an oam host at the
|
|
73
|
+
* floor -- which only reaches discovery for the sandbox -- so under
|
|
74
|
+
* TAILSCALE_MCP_SANDBOX=1 that fallback serves WITHOUT `--permission`, and
|
|
75
|
+
* nothing on stderr mentions the sandbox. A host below the floor hands off to
|
|
76
|
+
* Node instead, which has no `--permission` to apply either. Pair the sandbox
|
|
77
|
+
* with TAILSCALE_MCP_RUNTIME=oam to make a sandbox that cannot be applied fatal.
|
|
78
|
+
*
|
|
79
|
+
* THE `--permission` SANDBOX (opt-in)
|
|
47
80
|
* `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
48
81
|
* network limited to the one host the bundle actually calls
|
|
49
82
|
* (api.tailscale.com), filesystem denied.
|
|
@@ -58,33 +91,46 @@
|
|
|
58
91
|
* shipped bundle; keep it in step.
|
|
59
92
|
*
|
|
60
93
|
* MINIMUM OAM VERSION
|
|
61
|
-
* 0.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
94
|
+
* The latest oam release, 0.15.2 -- bump OAM_MIN when oam ships a newer one.
|
|
95
|
+
* Only the current oam is used and verified; an older one is passed over for a
|
|
96
|
+
* newer oam, or for Node. The floor is not cosmetic: before 0.9.0
|
|
97
|
+
* `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
|
|
98
|
+
* `timeout` and ignored it, `spawnSync` truncated at `maxBuffer` while
|
|
99
|
+
* reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
|
|
100
|
+
* `'pipe'`. This server shells out to the `tailscale` CLI on its local-CLI
|
|
101
|
+
* tools, so those were reachable bugs rather than theoretical ones: an argument
|
|
102
|
+
* containing shell metacharacters was re-split and executed.
|
|
69
103
|
*
|
|
70
104
|
* SELECTION
|
|
71
|
-
* TAILSCALE_MCP_RUNTIME=
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
105
|
+
* TAILSCALE_MCP_RUNTIME=auto newest usable oam, else Node (default)
|
|
106
|
+
* TAILSCALE_MCP_RUNTIME=oam newest usable oam, else exit with an error
|
|
107
|
+
* (already running on oam at the floor satisfies
|
|
108
|
+
* it, unless TAILSCALE_MCP_SANDBOX=1 needs a
|
|
109
|
+
* fresh one)
|
|
110
|
+
* TAILSCALE_MCP_RUNTIME=node Node: in THIS process on Node, handed off to
|
|
111
|
+
* Node on PATH when THIS process is oam; never
|
|
112
|
+
* sandboxed
|
|
75
113
|
* anything else warns on stderr, then behaves as auto
|
|
76
|
-
* TAILSCALE_MCP_SANDBOX=1
|
|
77
|
-
* OAM_BIN=/path/to/oam
|
|
114
|
+
* TAILSCALE_MCP_SANDBOX=1 spawn oam under --permission (see above)
|
|
115
|
+
* OAM_BIN=/path/to/oam use this oam when it is usable, before discovery
|
|
116
|
+
* The TAILSCALE_MCP_RUNTIME value is case-insensitive, and an empty value counts
|
|
117
|
+
* as unset.
|
|
78
118
|
*/
|
|
79
119
|
|
|
80
120
|
import { execFileSync, spawn } from "node:child_process";
|
|
81
|
-
import { existsSync } from "node:fs";
|
|
121
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
82
122
|
import { constants, homedir } from "node:os";
|
|
83
123
|
import { delimiter, join } from "node:path";
|
|
84
124
|
import { fileURLToPath } from "node:url";
|
|
85
125
|
|
|
86
|
-
/**
|
|
87
|
-
const OAM_MIN = [0,
|
|
126
|
+
/** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
|
|
127
|
+
const OAM_MIN = [0, 15, 2];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
|
|
131
|
+
* the bound only exists so a wedged binary on PATH cannot hang the launch.
|
|
132
|
+
*/
|
|
133
|
+
const VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
88
134
|
|
|
89
135
|
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
90
136
|
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
@@ -94,47 +140,57 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
|
94
140
|
const isWin = process.platform === "win32";
|
|
95
141
|
const exe = isWin ? "oam.exe" : "oam";
|
|
96
142
|
|
|
97
|
-
/**
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
143
|
+
/** Identity for de-duplicating paths: resolved, and case-folded on Windows. */
|
|
144
|
+
function pathKey(p) {
|
|
145
|
+
let key = p;
|
|
146
|
+
try {
|
|
147
|
+
key = realpathSync(p);
|
|
148
|
+
} catch {
|
|
149
|
+
// Unresolvable: fall back to the literal path.
|
|
150
|
+
}
|
|
151
|
+
return isWin ? key.toLowerCase() : key;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every oam binary discovery can see, in search order, de-duplicated. Stat-only,
|
|
156
|
+
* never a subprocess.
|
|
157
|
+
*
|
|
158
|
+
* Installed locations come BEFORE PATH, so when two binaries report the same
|
|
159
|
+
* version the installed copy wins the tie. Someone who develops oam itself
|
|
160
|
+
* usually has oam/target/release on PATH, and cargo replaces that binary
|
|
161
|
+
* underneath running processes; OAM_BIN remains the way to point deliberately
|
|
162
|
+
* at a dev build. Both forms are checked on Windows: the installer defaults to
|
|
163
|
+
* %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
|
|
164
|
+
* OAM_INSTALL_DIR can pick either.
|
|
165
|
+
*
|
|
166
|
+
* PATH is walked manually rather than by spawning `which`/`where`, which would
|
|
167
|
+
* cost a subprocess on every launch just to decide whether to spawn.
|
|
168
|
+
*
|
|
169
|
+
* Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
170
|
+
* run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
|
|
171
|
+
* spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking the
|
|
172
|
+
* full PATHEXT list would hand back a path this launcher cannot execute. When
|
|
173
|
+
* no usable oam is found, a skipped shim is still named -- see findOamShim.
|
|
174
|
+
*/
|
|
175
|
+
function discoverOamPaths() {
|
|
115
176
|
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
116
177
|
if (isWin) {
|
|
117
178
|
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
118
179
|
}
|
|
119
|
-
|
|
120
|
-
|
|
180
|
+
const onPath = (process.env.PATH ?? "")
|
|
181
|
+
.split(delimiter)
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.map((dir) => join(dir, exe));
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const found = [];
|
|
186
|
+
for (const candidate of [...installed, ...onPath]) {
|
|
187
|
+
if (!existsSync(candidate)) continue;
|
|
188
|
+
const key = pathKey(candidate);
|
|
189
|
+
if (seen.has(key)) continue;
|
|
190
|
+
seen.add(key);
|
|
191
|
+
found.push(candidate);
|
|
121
192
|
}
|
|
122
|
-
|
|
123
|
-
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
124
|
-
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
125
|
-
// Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
126
|
-
// run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
|
|
127
|
-
// for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
|
|
128
|
-
// the full PATHEXT list would hand back a path this launcher cannot execute.
|
|
129
|
-
// Discovery has to agree with execution. A skipped shim is still reported --
|
|
130
|
-
// see findOamShim.
|
|
131
|
-
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
132
|
-
if (!dir) continue;
|
|
133
|
-
const candidate = join(dir, exe);
|
|
134
|
-
if (existsSync(candidate)) return candidate;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return null;
|
|
193
|
+
return found;
|
|
138
194
|
}
|
|
139
195
|
|
|
140
196
|
/**
|
|
@@ -157,10 +213,12 @@ function oamVersion(cmd) {
|
|
|
157
213
|
const out = execFileSync(cmd, ["--version"], {
|
|
158
214
|
encoding: "utf-8",
|
|
159
215
|
stdio: ["ignore", "pipe", "ignore"],
|
|
216
|
+
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
217
|
+
windowsHide: true,
|
|
160
218
|
});
|
|
161
219
|
return parseVersion(out);
|
|
162
220
|
} catch {
|
|
163
|
-
// Not executable, wrong arch, or deleted since the stat. Caller degrades.
|
|
221
|
+
// Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
|
|
164
222
|
return null;
|
|
165
223
|
}
|
|
166
224
|
}
|
|
@@ -175,34 +233,65 @@ function atLeast(v, min) {
|
|
|
175
233
|
return true;
|
|
176
234
|
}
|
|
177
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The newest candidate at or above the floor, or null. `candidates` is
|
|
238
|
+
* `{ path, version }[]` in search order, `version` null when unreadable.
|
|
239
|
+
* Strictly-greater replaces, so a tie keeps the earlier candidate.
|
|
240
|
+
*
|
|
241
|
+
* Pure on purpose, like runtimePlan: the choice is testable without binaries.
|
|
242
|
+
*/
|
|
243
|
+
function pickNewest(candidates) {
|
|
244
|
+
let best = null;
|
|
245
|
+
for (const candidate of candidates) {
|
|
246
|
+
if (!atLeast(candidate.version, OAM_MIN)) continue;
|
|
247
|
+
if (!best || !atLeast(best.version, candidate.version)) best = candidate;
|
|
248
|
+
}
|
|
249
|
+
return best;
|
|
250
|
+
}
|
|
251
|
+
|
|
178
252
|
/**
|
|
179
253
|
* Where the server runs, decided BEFORE any discovery:
|
|
180
|
-
* "in-process"
|
|
181
|
-
* "discover"
|
|
182
|
-
*
|
|
254
|
+
* "in-process" import it into THIS process
|
|
255
|
+
* "discover" choose an oam and spawn it, or fall back (see fallBack)
|
|
256
|
+
* "handoff-node" hand it off to Node on PATH: THIS process is an oam and
|
|
257
|
+
* TAILSCALE_MCP_RUNTIME=node asked for Node
|
|
183
258
|
*
|
|
184
|
-
* `hostOam` is `process.versions.oam`: oam's own key, absent on Node
|
|
185
|
-
*
|
|
186
|
-
* is
|
|
187
|
-
*
|
|
188
|
-
*
|
|
259
|
+
* `hostOam` is `process.versions.oam`: oam's own key, absent on Node. An oam
|
|
260
|
+
* host whose version cannot be read is treated as below the floor -- it never
|
|
261
|
+
* proved it is a supported oam. Every mode but `node` is decided the same way:
|
|
262
|
+
* `oam` is satisfied by a host that already is one, and an unrecognized value
|
|
263
|
+
* has been warned about and lands with auto.
|
|
189
264
|
*
|
|
190
265
|
* `sandbox` is whether a spawn would carry flags only a fresh oam can apply;
|
|
191
|
-
* see ALREADY RUNNING ON OAM above for why that alone forces discovery
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
266
|
+
* see ALREADY RUNNING ON OAM above for why that alone forces the discovery
|
|
267
|
+
* path, and why discovery can still end in-process without those flags. Under
|
|
268
|
+
* `node` it is moot: Node has no `--permission` to apply. The floor is OAM_MIN
|
|
269
|
+
* itself, not a parameter, so a host oam and a discovered one can never be held
|
|
270
|
+
* to different minimums.
|
|
196
271
|
*
|
|
197
272
|
* Pure on purpose: every input is passed in, so the whole decision is testable
|
|
198
273
|
* without booting a runtime.
|
|
199
274
|
*/
|
|
200
275
|
function runtimePlan({ mode, hostOam, sandbox }) {
|
|
201
|
-
|
|
276
|
+
const onOam = hostOam !== undefined;
|
|
277
|
+
if (mode === "node") return onOam ? "handoff-node" : "in-process";
|
|
202
278
|
if (sandbox) return "discover";
|
|
203
279
|
return atLeast(parseVersion(hostOam ?? ""), OAM_MIN) ? "in-process" : "discover";
|
|
204
280
|
}
|
|
205
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Whether a fallback may serve in THIS process: on Node, or on an oam host at
|
|
284
|
+
* the floor. The only host at the floor that ever reaches a fallback is one
|
|
285
|
+
* that took the discovery path for TAILSCALE_MCP_SANDBOX=1, and it serves
|
|
286
|
+
* without `--permission`, as it always has. A host below the floor never
|
|
287
|
+
* serves.
|
|
288
|
+
*
|
|
289
|
+
* Pure on purpose, like runtimePlan.
|
|
290
|
+
*/
|
|
291
|
+
function fallbackInProcess(hostOam) {
|
|
292
|
+
return hostOam === undefined || atLeast(parseVersion(hostOam), OAM_MIN);
|
|
293
|
+
}
|
|
294
|
+
|
|
206
295
|
/**
|
|
207
296
|
* The `--permission` grant list, or [] when the sandbox is not requested.
|
|
208
297
|
*
|
|
@@ -290,9 +379,11 @@ async function errSync(message) {
|
|
|
290
379
|
|
|
291
380
|
/**
|
|
292
381
|
* An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
|
|
293
|
-
* cannot spawn.
|
|
294
|
-
* reads as "install oam" -- the one thing that
|
|
295
|
-
*
|
|
382
|
+
* cannot spawn. Named in the no-usable-oam note rather than ignored, because
|
|
383
|
+
* "no usable oam was found" alone reads as "install oam" -- the one thing that
|
|
384
|
+
* will not help. Only consulted when no usable oam was found: with one chosen,
|
|
385
|
+
* the shim is irrelevant and goes unmentioned. Windows only; there is no such
|
|
386
|
+
* shim concept on POSIX.
|
|
296
387
|
*/
|
|
297
388
|
function findOamShim() {
|
|
298
389
|
if (!isWin) return null;
|
|
@@ -306,6 +397,56 @@ function findOamShim() {
|
|
|
306
397
|
return null;
|
|
307
398
|
}
|
|
308
399
|
|
|
400
|
+
/** A Node binary on PATH, or null. Stat-only; used only when THIS process is oam. */
|
|
401
|
+
function findNodeOnPath() {
|
|
402
|
+
const name = isWin ? "node.exe" : "node";
|
|
403
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
404
|
+
if (!dir) continue;
|
|
405
|
+
const candidate = join(dir, name);
|
|
406
|
+
if (existsSync(candidate)) return candidate;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Why a candidate was passed over, for stderr. "Too old" and "could not be run"
|
|
413
|
+
* stay distinct: a binary that is not executable, is the wrong arch, or printed
|
|
414
|
+
* no parseable version is not outdated, and `oam self-update` will not fix it.
|
|
415
|
+
*/
|
|
416
|
+
function unusableReason(path, version, label = path) {
|
|
417
|
+
const min = OAM_MIN.join(".");
|
|
418
|
+
return version
|
|
419
|
+
? `${label} is oam ${version.join(".")}, older than ${min}`
|
|
420
|
+
: `${label} could not be run, or did not report a version this launcher understands`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
|
|
425
|
+
* binary. Returns the choice (or null) plus stderr notes: `overrideNote` about
|
|
426
|
+
* an unusable OAM_BIN, and `skipped` describing what was found and rejected
|
|
427
|
+
* when nothing was usable.
|
|
428
|
+
*/
|
|
429
|
+
function chooseOam() {
|
|
430
|
+
const override = process.env.OAM_BIN;
|
|
431
|
+
let overrideNote = null;
|
|
432
|
+
if (override) {
|
|
433
|
+
if (!existsSync(override)) {
|
|
434
|
+
overrideNote = `OAM_BIN=${override} does not exist`;
|
|
435
|
+
} else {
|
|
436
|
+
const version = oamVersion(override);
|
|
437
|
+
if (atLeast(version, OAM_MIN)) return { chosen: { path: override, version }, overrideNote, skipped: [] };
|
|
438
|
+
overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const overrideKey = override ? pathKey(override) : null;
|
|
442
|
+
const candidates = discoverOamPaths()
|
|
443
|
+
.filter((path) => pathKey(path) !== overrideKey)
|
|
444
|
+
.map((path) => ({ path, version: oamVersion(path) }));
|
|
445
|
+
const chosen = pickNewest(candidates);
|
|
446
|
+
const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
|
|
447
|
+
return { chosen, overrideNote, skipped };
|
|
448
|
+
}
|
|
449
|
+
|
|
309
450
|
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
310
451
|
async function runInProcess() {
|
|
311
452
|
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
@@ -322,6 +463,183 @@ async function runInProcess() {
|
|
|
322
463
|
await import(SERVER_URL.href);
|
|
323
464
|
}
|
|
324
465
|
|
|
466
|
+
// ONE reporter for every failed in-process fallback. runInProcess() is a bare
|
|
467
|
+
// import() that rejects when dist/index.js is missing, and at ESM top level an
|
|
468
|
+
// unhandled rejection is an uncaught exception -- replacing this launcher's
|
|
469
|
+
// diagnostic with a raw stack trace.
|
|
470
|
+
const fallbackFailed = (e) => {
|
|
471
|
+
process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
|
|
472
|
+
process.exitCode = 1;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Spawn the server in a child runtime and mirror its lifetime.
|
|
477
|
+
*
|
|
478
|
+
* `onLaunchFailed(err)` runs when the child could not be started at all; it is
|
|
479
|
+
* never called once the child is running, which would double-start the server
|
|
480
|
+
* on the same stdio.
|
|
481
|
+
*/
|
|
482
|
+
async function launchChild(cmd, args, onLaunchFailed) {
|
|
483
|
+
// Every handoff from an oam host pipes; see ALREADY RUNNING ON OAM. That is a
|
|
484
|
+
// host below the floor, one at the floor spawning a fresh oam for the
|
|
485
|
+
// sandbox, or any oam under TAILSCALE_MCP_RUNTIME=node.
|
|
486
|
+
const piped = process.versions.oam !== undefined;
|
|
487
|
+
let child = null;
|
|
488
|
+
try {
|
|
489
|
+
child = spawn(cmd, args, {
|
|
490
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
491
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
492
|
+
// server's shutdown path. Piping preserves both as well: bytes are copied
|
|
493
|
+
// unchanged, and stdin's end propagates to the child.
|
|
494
|
+
stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
495
|
+
env: process.env,
|
|
496
|
+
windowsHide: true,
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
500
|
+
// 'error' listener is registered AFTER this call, so it can never observe
|
|
501
|
+
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
502
|
+
// instead of falling back.
|
|
503
|
+
await onLaunchFailed(err).catch(fallbackFailed);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// If the runtime cannot be executed at all (deleted between the version probe
|
|
508
|
+
// and the spawn, wrong arch, permission), fall back rather than failing the
|
|
509
|
+
// whole server. `spawned` prevents falling back AFTER the child started.
|
|
510
|
+
//
|
|
511
|
+
// Everything that assumes a live child waits for 'spawn'. A failed spawn
|
|
512
|
+
// still emits 'close' (after 'error', with the negative errno as its code), so
|
|
513
|
+
// an unguarded close handler would process.exit() out from under the fallback
|
|
514
|
+
// onLaunchFailed has just started. Stdin piped into a child that never ran is
|
|
515
|
+
// the other half: an in-process fallback still answers the host's first
|
|
516
|
+
// request, but the pipe's write to the dead child fails, stdin stops flowing,
|
|
517
|
+
// and the next request is never read. Until 'spawn', process.stdin has no
|
|
518
|
+
// reader and simply stays paused.
|
|
519
|
+
let spawned = false;
|
|
520
|
+
child.on("spawn", () => {
|
|
521
|
+
spawned = true;
|
|
522
|
+
if (piped) {
|
|
523
|
+
process.stdin.pipe(child.stdin);
|
|
524
|
+
child.stdout.pipe(process.stdout);
|
|
525
|
+
child.stderr.pipe(process.stderr);
|
|
526
|
+
}
|
|
527
|
+
forwardSignals();
|
|
528
|
+
});
|
|
529
|
+
child.on("error", (err) => {
|
|
530
|
+
if (spawned) return;
|
|
531
|
+
// Handle the rejection instead of discarding it: a failing in-process
|
|
532
|
+
// fallback would otherwise escape as an unhandled rejection, replacing
|
|
533
|
+
// this launcher's diagnostic with a raw stack trace.
|
|
534
|
+
onLaunchFailed(err).catch(fallbackFailed);
|
|
535
|
+
});
|
|
536
|
+
// A child that exits before reading everything closes its stdin; the
|
|
537
|
+
// resulting EPIPE is not worth crashing over. Null when stdio is inherited.
|
|
538
|
+
child.stdin?.on("error", () => {});
|
|
539
|
+
|
|
540
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
541
|
+
// rather than the child being orphaned.
|
|
542
|
+
//
|
|
543
|
+
// Registering ANY handler for these suppresses Node's default
|
|
544
|
+
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
545
|
+
// `child.killed` only records that kill() was CALLED, never that the child
|
|
546
|
+
// is gone, so gating on it swallows every signal after the first and wedges
|
|
547
|
+
// the launcher with no escape hatch.
|
|
548
|
+
//
|
|
549
|
+
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
550
|
+
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
551
|
+
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
552
|
+
// "a second signal" as impatience hard-kills a child that is already
|
|
553
|
+
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
554
|
+
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
555
|
+
// a wall-clock step cannot mis-gate the window either.
|
|
556
|
+
//
|
|
557
|
+
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
558
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
559
|
+
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
560
|
+
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
561
|
+
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
562
|
+
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
563
|
+
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
564
|
+
// child's process.on("exit") cleanup. The console has already notified the
|
|
565
|
+
// child, so on Windows the timer below is the only kill we issue.
|
|
566
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
567
|
+
let escalation = null;
|
|
568
|
+
// Called from the 'spawn' handler above: a child that never ran has nothing
|
|
569
|
+
// to forward to, and handlers registered for it would still replace Node's
|
|
570
|
+
// default terminate-on-signal for whatever fallback serves instead.
|
|
571
|
+
function forwardSignals() {
|
|
572
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
573
|
+
process.on(sig, () => {
|
|
574
|
+
// No try/catch: kill() on an already-exited child returns false, it does
|
|
575
|
+
// not throw. It throws only for a signal the platform does not know,
|
|
576
|
+
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
577
|
+
if (!isWin) child.kill(sig);
|
|
578
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
579
|
+
escalation = setTimeout(() => {
|
|
580
|
+
// Still here after its grace window. Stop waiting on it.
|
|
581
|
+
child.kill("SIGKILL");
|
|
582
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
583
|
+
}, ESCALATE_AFTER_MS);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Piped: wait for 'close', so the child's last stdout bytes are copied out
|
|
589
|
+
// before this process exits. Inherited: 'exit' is enough, the fds were never
|
|
590
|
+
// ours to drain. Either way, only for a child that actually ran -- see the
|
|
591
|
+
// 'spawn' handler above.
|
|
592
|
+
child.on(piped ? "close" : "exit", (code, signal) => {
|
|
593
|
+
if (!spawned) return;
|
|
594
|
+
if (escalation) clearTimeout(escalation);
|
|
595
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
596
|
+
// conventional shell exit status rather than a bare 0.
|
|
597
|
+
if (signal) {
|
|
598
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
599
|
+
}
|
|
600
|
+
process.exit(code ?? 0);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Hand the server to Node on PATH. Only reachable when THIS process is oam --
|
|
606
|
+
* one below the floor, or any oam under TAILSCALE_MCP_RUNTIME=node -- so there
|
|
607
|
+
* is no in-process option left. An empty `reason` prints no note: the host is a
|
|
608
|
+
* supported oam and TAILSCALE_MCP_RUNTIME=node asked for Node, which is not news.
|
|
609
|
+
*/
|
|
610
|
+
async function handOffToNode(reason) {
|
|
611
|
+
const node = findNodeOnPath();
|
|
612
|
+
if (!node) {
|
|
613
|
+
const remedy =
|
|
614
|
+
mode === "node"
|
|
615
|
+
? "Put Node on PATH, or launch this command with node.\n"
|
|
616
|
+
: `Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer, or launch this command with node.\n`;
|
|
617
|
+
await errSync(
|
|
618
|
+
`tailscale-mcp: ${reason || `TAILSCALE_MCP_RUNTIME=node on oam ${process.versions.oam}`}, and no Node was found on PATH to run the server.\n${remedy}`,
|
|
619
|
+
);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
if (reason) await errSync(`tailscale-mcp: ${reason}; running on ${node} instead.\n`);
|
|
623
|
+
await launchChild(node, [SERVER_ENTRY, ...process.argv.slice(2)], async (err) => {
|
|
624
|
+
await errSync(`tailscale-mcp: failed to launch Node at ${node} (${err?.message ?? err})\n`);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** What a fallback serves on, for stderr. */
|
|
630
|
+
function fallbackTarget(hostOam) {
|
|
631
|
+
return hostOam !== undefined && fallbackInProcess(hostOam) ? `this oam ${hostOam} process` : "Node";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** No usable oam, or it would not start, under a mode that allows a fallback. */
|
|
635
|
+
async function fallBack(hostOam, why) {
|
|
636
|
+
if (fallbackInProcess(hostOam)) {
|
|
637
|
+
await runInProcess();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and ${why}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
325
643
|
// Every value below is compared against `mode` after lowercasing, so an
|
|
326
644
|
// unrecognized one matched nothing and fell through to the auto branch --
|
|
327
645
|
// `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
|
|
@@ -340,184 +658,63 @@ if (requested && !RUNTIMES.includes(mode)) {
|
|
|
340
658
|
);
|
|
341
659
|
}
|
|
342
660
|
|
|
661
|
+
const hostOam = process.versions.oam;
|
|
662
|
+
|
|
343
663
|
// The sandbox is read off the grant list rather than TAILSCALE_MCP_SANDBOX, so
|
|
344
664
|
// "would the spawn carry --permission" cannot drift from what the spawn below
|
|
345
665
|
// actually passes.
|
|
346
|
-
const
|
|
666
|
+
const sandbox = sandboxFlags();
|
|
667
|
+
const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
|
|
347
668
|
|
|
348
669
|
if (plan === "in-process") {
|
|
349
670
|
await runInProcess();
|
|
671
|
+
} else if (plan === "handoff-node") {
|
|
672
|
+
const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
|
|
673
|
+
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
|
|
350
674
|
} else {
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (!oam) {
|
|
358
|
-
// An oam-named .cmd/.bat on PATH is a real install in a shape this
|
|
359
|
-
// launcher cannot spawn. Naming it turns "no oam binary was found" --
|
|
360
|
-
// which reads as "install oam", the one thing that will not help --
|
|
361
|
-
// into something the user can act on.
|
|
362
|
-
const oamShim = findOamShim();
|
|
363
|
-
const shimNote = oamShim
|
|
364
|
-
? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
|
|
365
|
-
"Install the native oam binary, or point OAM_BIN at one.\n"
|
|
366
|
-
: "";
|
|
367
|
-
if (mode === "oam") {
|
|
368
|
-
// Explicitly demanded, so this is a real misconfiguration. writeSync
|
|
369
|
-
// because stderr is async for TTYs/pipes on Windows and process.exit
|
|
370
|
-
// truncates pending writes.
|
|
371
|
-
const { writeSync } = await import("node:fs");
|
|
372
|
-
writeSync(
|
|
373
|
-
2,
|
|
374
|
-
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
375
|
-
shimNote +
|
|
376
|
-
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
|
|
377
|
-
);
|
|
378
|
-
process.exit(1);
|
|
379
|
-
}
|
|
380
|
-
// auto: falling back is correct, but silence is how someone never learns
|
|
381
|
-
// their oam install is a shape this launcher skips.
|
|
382
|
-
if (oamShim) await errSync(`tailscale-mcp: ${shimNote}Using Node instead.\n`);
|
|
383
|
-
await runInProcess();
|
|
384
|
-
} else if (!atLeast(found, OAM_MIN)) {
|
|
385
|
-
const min = OAM_MIN.join(".");
|
|
386
|
-
// Two different causes reach this branch and they need different
|
|
387
|
-
// remedies. `found === null` is NOT "old": oamVersion returns null when
|
|
388
|
-
// the binary could not be run at all (not executable, wrong arch, a
|
|
389
|
-
// .cmd/.bat Node refuses, deleted between the stat and the probe) or
|
|
390
|
-
// when its --version output did not parse. Telling that user to
|
|
391
|
-
// `oam self-update` sends them after the one cause it definitely is not.
|
|
392
|
-
const detail = found
|
|
393
|
-
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
394
|
-
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
395
|
-
const remedy = found
|
|
396
|
-
? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
|
|
397
|
-
: "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
|
|
398
|
-
if (mode === "oam") {
|
|
399
|
-
await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
400
|
-
process.exit(1);
|
|
675
|
+
const { chosen, overrideNote, skipped } = chooseOam();
|
|
676
|
+
|
|
677
|
+
if (chosen) {
|
|
678
|
+
if (overrideNote) {
|
|
679
|
+
await errSync(`tailscale-mcp: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join(".")}).\n`);
|
|
401
680
|
}
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
await
|
|
406
|
-
await runInProcess();
|
|
407
|
-
} else {
|
|
408
|
-
// `--` separates oam's own flags from the script's argv, so `tailscale-mcp
|
|
409
|
-
// --version` and any host-supplied flags survive the hop unchanged.
|
|
410
|
-
// Every "oam could not be executed" outcome lands here: the synchronous
|
|
411
|
-
// throw from spawn() and the async 'error' event mean the same thing and
|
|
412
|
-
// must degrade the same way, so the handling lives in one place.
|
|
413
|
-
// errSync rather than process.stderr.write because stderr is async for
|
|
414
|
-
// TTYs and pipes on Windows and the process.exit below truncates pending
|
|
415
|
-
// writes.
|
|
416
|
-
const launchFailed = async (err) => {
|
|
681
|
+
// The sandbox flags go BEFORE `run` (see sandboxFlags), and `--` separates
|
|
682
|
+
// oam's own flags from the script's argv, so `tailscale-mcp --version` and
|
|
683
|
+
// any host-supplied flags survive the hop unchanged.
|
|
684
|
+
await launchChild(chosen.path, [...sandbox, "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], async (err) => {
|
|
417
685
|
if (mode === "oam") {
|
|
418
|
-
await errSync(`tailscale-mcp: failed to launch oam (${err?.message ?? err})\n`);
|
|
686
|
+
await errSync(`tailscale-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err})\n`);
|
|
419
687
|
process.exit(1);
|
|
420
688
|
}
|
|
421
|
-
await
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
} catch (err) {
|
|
445
|
-
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
446
|
-
// 'error' listener is registered AFTER this call, so it can never observe
|
|
447
|
-
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
448
|
-
// instead of falling back to Node.
|
|
449
|
-
await launchFailed(err).catch(fallbackFailed);
|
|
689
|
+
await errSync(
|
|
690
|
+
`tailscale-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err}); using ${fallbackTarget(hostOam)} instead.\n`,
|
|
691
|
+
);
|
|
692
|
+
await fallBack(hostOam, "the newer oam would not start");
|
|
693
|
+
});
|
|
694
|
+
} else {
|
|
695
|
+
const shim = findOamShim();
|
|
696
|
+
const notes = [
|
|
697
|
+
...(overrideNote ? [overrideNote] : []),
|
|
698
|
+
...skipped,
|
|
699
|
+
...(shim
|
|
700
|
+
? [
|
|
701
|
+
`found ${shim}, but Node cannot execute a .cmd/.bat directly -- install the native oam binary, or point OAM_BIN at one`,
|
|
702
|
+
]
|
|
703
|
+
: []),
|
|
704
|
+
];
|
|
705
|
+
if (mode === "oam") {
|
|
706
|
+
await errSync(
|
|
707
|
+
`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
|
|
708
|
+
notes.map((note) => ` ${note}\n`).join("") +
|
|
709
|
+
"Install or update from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
|
|
710
|
+
);
|
|
711
|
+
process.exit(1);
|
|
450
712
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// `spawned` prevents falling back AFTER the child started, which would
|
|
456
|
-
// double-start the server on the same stdio.
|
|
457
|
-
let spawned = false;
|
|
458
|
-
child.on("spawn", () => {
|
|
459
|
-
spawned = true;
|
|
460
|
-
});
|
|
461
|
-
child.on("error", (err) => {
|
|
462
|
-
if (spawned) return;
|
|
463
|
-
// Handle the rejection instead of discarding it: a failing in-process
|
|
464
|
-
// fallback would otherwise escape as an unhandled rejection, replacing
|
|
465
|
-
// this launcher's diagnostic with a raw stack trace.
|
|
466
|
-
launchFailed(err).catch(fallbackFailed);
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
// Forward termination so the server's own shutdown path runs in the child
|
|
470
|
-
// rather than the child being orphaned.
|
|
471
|
-
//
|
|
472
|
-
// Registering ANY handler for these suppresses Node's default
|
|
473
|
-
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
474
|
-
// `child.killed` only records that kill() was CALLED, never that the child
|
|
475
|
-
// is gone, so gating on it swallows every signal after the first and wedges
|
|
476
|
-
// the launcher with no escape hatch.
|
|
477
|
-
//
|
|
478
|
-
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
479
|
-
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
480
|
-
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
481
|
-
// "a second signal" as impatience hard-kills a child that is already
|
|
482
|
-
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
483
|
-
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
484
|
-
// a wall-clock step cannot mis-gate the window either.
|
|
485
|
-
//
|
|
486
|
-
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
487
|
-
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
488
|
-
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
489
|
-
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
490
|
-
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
491
|
-
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
492
|
-
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
493
|
-
// child's process.on("exit") cleanup. The console has already notified the
|
|
494
|
-
// child, so on Windows the timer below is the only kill we issue.
|
|
495
|
-
const ESCALATE_AFTER_MS = 2000;
|
|
496
|
-
let escalation = null;
|
|
497
|
-
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
498
|
-
process.on(sig, () => {
|
|
499
|
-
// No try/catch: kill() on an already-exited child returns false, it does
|
|
500
|
-
// not throw. It throws only for a signal the platform does not know,
|
|
501
|
-
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
502
|
-
if (!isWin) child.kill(sig);
|
|
503
|
-
if (escalation) return; // already counting down; further signals are noise
|
|
504
|
-
escalation = setTimeout(() => {
|
|
505
|
-
// Still here after its grace window. Stop waiting on it.
|
|
506
|
-
child.kill("SIGKILL");
|
|
507
|
-
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
508
|
-
}, ESCALATE_AFTER_MS);
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
child.on("exit", (code, signal) => {
|
|
513
|
-
if (escalation) clearTimeout(escalation);
|
|
514
|
-
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
515
|
-
// conventional shell exit status rather than a bare 0.
|
|
516
|
-
if (signal) {
|
|
517
|
-
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
518
|
-
}
|
|
519
|
-
process.exit(code ?? 0);
|
|
520
|
-
});
|
|
713
|
+
// auto: falling back is correct, but silence is how someone never learns
|
|
714
|
+
// their OAM_BIN is wrong or their oam is too old to use.
|
|
715
|
+
if (notes.length > 0) {
|
|
716
|
+
await errSync(`tailscale-mcp: ${notes.join("; ")}; using ${fallbackTarget(hostOam)} instead.\n`);
|
|
521
717
|
}
|
|
718
|
+
await fallBack(hostOam, "no newer oam was found").catch(fallbackFailed);
|
|
522
719
|
}
|
|
523
720
|
}
|
package/dist/index.js
CHANGED
|
@@ -34551,7 +34551,7 @@ function buildMetaTools(state) {
|
|
|
34551
34551
|
}
|
|
34552
34552
|
|
|
34553
34553
|
// src/index.ts
|
|
34554
|
-
var version2 = true ? "0.19.
|
|
34554
|
+
var version2 = true ? "0.19.4" : resolveVersionFallback();
|
|
34555
34555
|
var subcommand = process.argv[2];
|
|
34556
34556
|
var USAGE = `Usage: tailscale-mcp [command]
|
|
34557
34557
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/tailscale-mcp",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.4",
|
|
4
4
|
"mcpName": "io.github.YawLabs/tailscale-mcp",
|
|
5
5
|
"description": "Tailscale MCP server: admin-API tools for devices, ACLs, DNS, auth keys, users, and audit logs, plus a CLI to validate and deploy ACLs.",
|
|
6
6
|
"license": "MIT",
|