@yawlabs/tailscale-mcp 0.21.1 → 0.22.0
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 +14 -8
- package/bin/tailscale-mcp.mjs +218 -46
- package/dist/index.js +515 -245
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Fair critique from Reddit: a new repo claiming "actively maintained" with no vis
|
|
|
48
48
|
- **1900+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
|
|
49
49
|
- **Local release flow** via [`release.sh`](./release.sh): lint + test + bump + tag + push + npm publish + MCP Registry publish, all from the workstation. No CI workflow to babysit.
|
|
50
50
|
- **Dependabot alerts** surface on this repo and get fixed, not ignored.
|
|
51
|
-
- **Every tool
|
|
51
|
+
- **Every tool names a real endpoint from the OpenAPI spec.** If it's in the tool list, it calls a path documented in the current v2 API -- no placeholder 404 tools. To be clear about the limits of that: no recorded live exchange ships with this repo (`fixtures/live/` is empty on purpose), and no claim here rests on an observation against a live tailnet.
|
|
52
52
|
|
|
53
53
|
Issues and PRs are triaged. File one if something is off — [github.com/YawLabs/tailscale-mcp/issues](https://github.com/YawLabs/tailscale-mcp/issues).
|
|
54
54
|
|
|
@@ -138,6 +138,8 @@ That's it. Now ask your agent:
|
|
|
138
138
|
- **`core`** (52 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
|
|
139
139
|
- **`full`** (97 tools, default) — everything. Same as omitting the env var.
|
|
140
140
|
|
|
141
|
+
Profile names are case-insensitive and ignore surrounding whitespace (`Core` selects `core`). The group names in `TAILSCALE_TOOLS` and `TAILSCALE_WRITE_GROUPS` are case-sensitive.
|
|
142
|
+
|
|
141
143
|
### Option 2: `TAILSCALE_TOOLS` (explicit group list)
|
|
142
144
|
|
|
143
145
|
```json
|
|
@@ -730,26 +732,30 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
730
732
|
|
|
731
733
|
## Running on oam.js (optional)
|
|
732
734
|
|
|
733
|
-
[oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.
|
|
735
|
+
[oam.js](https://oamjs.org) runs this server unmodified, and the `tailscale-mcp` command only ever uses the **latest oam release, currently 0.18.0**. Verified against the published oam 0.18.0: 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.
|
|
734
736
|
|
|
735
|
-
**oam 0.
|
|
737
|
+
**oam 0.18.0 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.
|
|
736
738
|
|
|
737
739
|
How the `tailscale-mcp` command (`bin/tailscale-mcp.mjs`) picks a runtime:
|
|
738
740
|
|
|
739
|
-
- **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.
|
|
740
|
-
- **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node.
|
|
741
|
+
- **`TAILSCALE_MCP_RUNTIME=auto`** (the default) — if a client already launched it with `oam run` on oam 0.18.0 or newer, the server runs in that process. Otherwise it uses `OAM_BIN` when that is 0.18.0 or newer, else asks every oam binary it can find — `OAM_INSTALL_DIR` when that is set, then `%LOCALAPPDATA%\oam\bin` and `~/.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.18.0 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.
|
|
742
|
+
- **`TAILSCALE_MCP_RUNTIME=oam`** — the same, but exit with an error instead of falling back to Node. The error names the remedy for what it actually found: `oam self-update` for an oam older than the floor, a check of the binary for one that would not run, and installing oam only when none was found (and on linux-arm64, which oam publishes no build for, not even then).
|
|
741
743
|
- **`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`.
|
|
742
744
|
|
|
743
745
|
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.
|
|
744
746
|
|
|
745
747
|
### Sandboxing (opt-in)
|
|
746
748
|
|
|
747
|
-
Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` -- the only host the bundle contacts, including the OAuth token exchange -- and filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which
|
|
749
|
+
Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com:443` -- the only host the bundle contacts, including the OAuth token exchange, and every request is HTTPS -- and filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary. That child inherits the server's environment, which under the sandbox holds only the granted variables, so besides the `TAILSCALE_*` settings the allow-list carries `PATH` (to find the binary) and the variables a Windows program needs to start -- `SYSTEMROOT`, `WINDIR`, `SYSTEMDRIVE`, `TEMP`/`TMP`, `USERPROFILE`, `HOMEDRIVE`/`HOMEPATH`, `APPDATA`, `LOCALAPPDATA` -- plus `HOME`. oam takes even the variables Windows adds to every child from that filtered environment, so without them the CLI started with no `SYSTEMROOT`. oam matches these names exactly, case included, so on Windows the launcher also grants each one in the spelling your environment uses (`Path`, `SystemRoot`, `SystemDrive`, `windir` when a client inherits them from Explorer).
|
|
750
|
+
|
|
751
|
+
Under the sandbox, `TAILSCALE_BINARY` must point at the native `tailscale` CLI, not at a Node-based wrapper script: oam hands its permission flags to every child through `NODE_OPTIONS`, as Node does, and Node refuses `--allow-net` and `--allow-env` there and exits before running anything. The native CLI ignores the variable.
|
|
748
752
|
|
|
749
753
|
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.
|
|
750
754
|
|
|
751
755
|
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.
|
|
752
756
|
|
|
757
|
+
The sandbox applies to the CLI subcommands too, and with the filesystem denied `tailscale-mcp validate-acl <file>` / `deploy-acl <file>` cannot read the policy file. Unset `TAILSCALE_MCP_SANDBOX` for those commands -- for example, if you export it in a shell profile for the server, run `TAILSCALE_MCP_SANDBOX= tailscale-mcp validate-acl policy.hujson`.
|
|
758
|
+
|
|
753
759
|
```jsonc
|
|
754
760
|
{
|
|
755
761
|
"mcpServers": {
|
|
@@ -761,14 +767,14 @@ The sandbox is applied by the `tailscale-mcp` command, which spawns a fresh oam
|
|
|
761
767
|
}
|
|
762
768
|
```
|
|
763
769
|
|
|
764
|
-
**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.
|
|
770
|
+
**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.18.0 floor and have not been repeated since, so do not read them as a current ranking.
|
|
765
771
|
|
|
766
772
|
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.
|
|
767
773
|
|
|
768
774
|
Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
|
|
769
775
|
|
|
770
776
|
- **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 4015ms against 7680ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default.
|
|
771
|
-
- **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of Node SEA. Measured ~57.7 MB against ~73.6 MB for the Node SEA carrier *before* its blob is injected. Writes
|
|
777
|
+
- **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of Node SEA. Measured ~57.7 MB against ~73.6 MB for the Node SEA carrier *before* its blob is injected. Writes `bin/<platform>-<arch>/` (or `bin/$TAILSCALE_MCP_BINARY_TARGET/` for a cross-build), while `npm run build:binary` adds a `-glibc` / `-musl` suffix on Linux; `scripts/stage-release-asset.mjs` looks in both and stages the newer build — run one or the other per target. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it. A cross-build (`TAILSCALE_MCP_BINARY_TARGET=<platform>-<arch>`) downloads the target's oam release binary as its carrier and checks it against the release's signed `RELEASE-MANIFEST`, verified with `ssh-keygen -Y verify` (OpenSSH 8.1+) against oam's release keys in `scripts/oam-release-keys/`; it aborts when the signature, the manifest or the checksum does not check out. An `OAM_VERSION` older than v0.18.0, released before oam signed its releases, is checked against its `SHA256SUMS` only once that file matches the digest pinned for the tag in `scripts/oam-release-keys/presigning-sums`.
|
|
772
778
|
|
|
773
779
|
The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal. Note that any `oam` invocation writes a bytecode cache to `oam/` in the working directory — already in `.gitignore`.
|
|
774
780
|
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -72,18 +72,32 @@
|
|
|
72
72
|
* back. A Node host runs the server in-process, and so does an oam host at the
|
|
73
73
|
* floor -- which only reaches discovery for the sandbox -- so under
|
|
74
74
|
* TAILSCALE_MCP_SANDBOX=1 that fallback serves WITHOUT `--permission`, and
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* runInProcess() now says exactly that on stderr: the sandbox was requested but
|
|
76
|
+
* is NOT active, rather than the silence that used to hide the downgrade. A host
|
|
77
|
+
* below the floor hands off to Node instead, which has no `--permission` to
|
|
78
|
+
* apply either. Pair the sandbox with TAILSCALE_MCP_RUNTIME=oam to make a
|
|
79
|
+
* sandbox that cannot be applied fatal.
|
|
78
80
|
*
|
|
79
81
|
* THE `--permission` SANDBOX (opt-in)
|
|
80
82
|
* `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
81
|
-
* network limited to the one host the bundle actually calls
|
|
82
|
-
* (api.tailscale.com), filesystem denied.
|
|
83
|
+
* network limited to the one host and port the bundle actually calls
|
|
84
|
+
* (api.tailscale.com:443), filesystem denied.
|
|
83
85
|
*
|
|
84
86
|
* Child-process is granted unconditionally because the local-CLI tools shell out
|
|
85
87
|
* to the `tailscale` binary; that is also why PATH stays in the env grant, since
|
|
86
|
-
* resolving the binary needs it.
|
|
88
|
+
* resolving the binary needs it. The CLI is spawned without an `env` option, so
|
|
89
|
+
* it inherits the server's process.env -- which under a list `--allow-env` holds
|
|
90
|
+
* ONLY the granted variables. oam also takes the variables libuv adds to a
|
|
91
|
+
* Windows child (SYSTEMROOT, TEMP, USERPROFILE, ...) from that filtered
|
|
92
|
+
* process.env rather than from the real environment, so the grant names them
|
|
93
|
+
* too: without SYSTEMROOT a Windows child cannot even start Winsock. oam
|
|
94
|
+
* matches a grant case-sensitively, so on Windows each name is granted in the
|
|
95
|
+
* environment's own spelling as well (SystemRoot, Path, windir).
|
|
96
|
+
*
|
|
97
|
+
* Under the sandbox TAILSCALE_BINARY must name the native `tailscale` CLI, not
|
|
98
|
+
* a Node-based wrapper script. oam passes its permission flags on to every
|
|
99
|
+
* child through NODE_OPTIONS, as Node does, and Node refuses `--allow-net` and
|
|
100
|
+
* `--allow-env` there (exit 9); the native CLI ignores the variable.
|
|
87
101
|
*
|
|
88
102
|
* Opt-in, not default, because a denied environment variable is ABSENT from
|
|
89
103
|
* process.env rather than throwing -- an under-granted TAILSCALE_API_KEY reads as
|
|
@@ -91,7 +105,7 @@
|
|
|
91
105
|
* shipped bundle; keep it in step.
|
|
92
106
|
*
|
|
93
107
|
* MINIMUM OAM VERSION
|
|
94
|
-
* The latest oam release, 0.
|
|
108
|
+
* The latest oam release, 0.18.0 -- bump OAM_MIN when oam ships a newer one.
|
|
95
109
|
* Only the current oam is used and verified; an older one is passed over for a
|
|
96
110
|
* newer oam, or for Node. The floor is not cosmetic: before 0.9.0
|
|
97
111
|
* `child_process.execFile` ran its arguments through a SHELL, `exec` accepted
|
|
@@ -118,13 +132,13 @@
|
|
|
118
132
|
*/
|
|
119
133
|
|
|
120
134
|
import { execFileSync, spawn } from "node:child_process";
|
|
121
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
135
|
+
import { existsSync, realpathSync, writeSync } from "node:fs";
|
|
122
136
|
import { constants, homedir } from "node:os";
|
|
123
137
|
import { delimiter, join } from "node:path";
|
|
124
138
|
import { fileURLToPath } from "node:url";
|
|
125
139
|
|
|
126
140
|
/** The latest oam release, and the oldest one used. See MINIMUM OAM VERSION above. */
|
|
127
|
-
const OAM_MIN = [0,
|
|
141
|
+
const OAM_MIN = [0, 18, 0];
|
|
128
142
|
|
|
129
143
|
/**
|
|
130
144
|
* The oldest Node this package supports, matching package.json `engines.node`.
|
|
@@ -140,8 +154,16 @@ const NODE_MIN = [20, 11, 0];
|
|
|
140
154
|
/**
|
|
141
155
|
* Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
|
|
142
156
|
* the bound only exists so a wedged binary on PATH cannot hang the launch.
|
|
157
|
+
*
|
|
158
|
+
* 30s, not 5s: the probe runs whatever binary is named, and on Windows that
|
|
159
|
+
* can be Node itself (the test suite passes the Node running it) -- spawning
|
|
160
|
+
* node measured 2.4-4.2s on a loaded box and blew a 5s cap often enough that
|
|
161
|
+
* the probe returned null on a perfectly healthy binary. A wrong null here is
|
|
162
|
+
* worse than a slow one: it flips chooseOam() off the override and the launcher
|
|
163
|
+
* takes a different branch entirely (discovery serves in-process instead of
|
|
164
|
+
* spawning). 30s still bounds a genuinely wedged binary.
|
|
143
165
|
*/
|
|
144
|
-
const VERSION_PROBE_TIMEOUT_MS =
|
|
166
|
+
const VERSION_PROBE_TIMEOUT_MS = 30_000;
|
|
145
167
|
|
|
146
168
|
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
147
169
|
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
@@ -172,7 +194,10 @@ function pathKey(p) {
|
|
|
172
194
|
* underneath running processes; OAM_BIN remains the way to point deliberately
|
|
173
195
|
* at a dev build. Both forms are checked on Windows: the installer defaults to
|
|
174
196
|
* %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
|
|
175
|
-
* OAM_INSTALL_DIR can pick either.
|
|
197
|
+
* OAM_INSTALL_DIR can pick either. OAM_INSTALL_DIR itself, when set, is searched
|
|
198
|
+
* first of all: it is where oam's installer and `oam self-update` put the
|
|
199
|
+
* binary, so an oam installed to a custom directory and left off PATH is still
|
|
200
|
+
* found.
|
|
176
201
|
*
|
|
177
202
|
* PATH is walked manually rather than by spawning `which`/`where`, which would
|
|
178
203
|
* cost a subprocess on every launch just to decide whether to spawn.
|
|
@@ -188,6 +213,7 @@ function discoverOamPaths() {
|
|
|
188
213
|
if (isWin) {
|
|
189
214
|
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
190
215
|
}
|
|
216
|
+
if (process.env.OAM_INSTALL_DIR) installed.unshift(join(process.env.OAM_INSTALL_DIR, exe));
|
|
191
217
|
const onPath = (process.env.PATH ?? "")
|
|
192
218
|
.split(delimiter)
|
|
193
219
|
.filter(Boolean)
|
|
@@ -334,7 +360,11 @@ function fallbackInProcess(hostOam) {
|
|
|
334
360
|
* not after it. `oam run --permission file.js` is rejected outright, which is a
|
|
335
361
|
* good failure but only because it is loud -- ordering here is load-bearing.
|
|
336
362
|
*
|
|
337
|
-
* Net grants
|
|
363
|
+
* Net grants, since oam 0.18.0 (the floor): an entry without a port admits that
|
|
364
|
+
* host on every port, and a `host:port` entry admits that port alone -- for
|
|
365
|
+
* fetch and https.request exactly as for sockets. Up to 0.17.1 a port-scoped
|
|
366
|
+
* entry admitted no HTTP request at all, which is why this used to be a bare
|
|
367
|
+
* host.
|
|
338
368
|
* A denied environment variable is ABSENT from process.env rather than throwing,
|
|
339
369
|
* so the env list below is derived from what the bundle actually reads; trimming
|
|
340
370
|
* it produces silent misbehaviour, not a clear denial.
|
|
@@ -350,8 +380,10 @@ function sandboxFlags() {
|
|
|
350
380
|
// An unused grant is the one kind of over-permission nothing ever surfaces:
|
|
351
381
|
// removing it cannot break a call that was never made, and keeping it widens
|
|
352
382
|
// the sandbox for no behaviour. launcher.test.ts pins this list exactly so a
|
|
353
|
-
// future host lands as a reviewed diff rather than a quiet widening.
|
|
354
|
-
|
|
383
|
+
// future host lands as a reviewed diff rather than a quiet widening. Scoped
|
|
384
|
+
// to :443 for the same reason: every request is https://, so no other port is
|
|
385
|
+
// ever dialled, and an http:// URL or a redirect to another port is refused.
|
|
386
|
+
const hosts = ["api.tailscale.com:443"];
|
|
355
387
|
|
|
356
388
|
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
357
389
|
|
|
@@ -361,8 +393,25 @@ function sandboxFlags() {
|
|
|
361
393
|
// meant the local-CLI tool group silently failed to register under the
|
|
362
394
|
// sandbox even though --allow-child-process is granted below precisely so
|
|
363
395
|
// those tools can shell out.
|
|
396
|
+
//
|
|
397
|
+
// The non-TAILSCALE_ names besides PATH are for the CLI child, not the server:
|
|
398
|
+
// see THE `--permission` SANDBOX above. HOMEDRIVE, HOMEPATH, SYSTEMDRIVE,
|
|
399
|
+
// SYSTEMROOT, TEMP, USERPROFILE and WINDIR are libuv's Windows set (measured on
|
|
400
|
+
// oam 0.18.0: a child under `--allow-env=PATH,...` got neither SYSTEMROOT nor
|
|
401
|
+
// TEMP), TMP its POSIX-style twin, APPDATA and LOCALAPPDATA where Windows
|
|
402
|
+
// programs keep per-user state, and HOME the POSIX home directory. Granting a
|
|
403
|
+
// name that is not set is harmless: it stays absent. WSL_DISTRO_NAME is the
|
|
404
|
+
// server's own: local-cli.ts reads it to recognise WSL when the CLI is
|
|
405
|
+
// missing, and its fallback, /proc/version, is a file read the sandbox denies.
|
|
364
406
|
const env = [
|
|
407
|
+
"APPDATA",
|
|
408
|
+
"HOME",
|
|
409
|
+
"HOMEDRIVE",
|
|
410
|
+
"HOMEPATH",
|
|
411
|
+
"LOCALAPPDATA",
|
|
365
412
|
"PATH",
|
|
413
|
+
"SYSTEMDRIVE",
|
|
414
|
+
"SYSTEMROOT",
|
|
366
415
|
"TAILSCALE_API_KEY",
|
|
367
416
|
"TAILSCALE_BINARY",
|
|
368
417
|
"TAILSCALE_DEBUG",
|
|
@@ -381,9 +430,30 @@ function sandboxFlags() {
|
|
|
381
430
|
"TAILSCALE_TAILNET",
|
|
382
431
|
"TAILSCALE_TOOLS",
|
|
383
432
|
"TAILSCALE_WRITE_GROUPS",
|
|
433
|
+
"TEMP",
|
|
434
|
+
"TMP",
|
|
435
|
+
"USERPROFILE",
|
|
436
|
+
"WINDIR",
|
|
437
|
+
"WSL_DISTRO_NAME",
|
|
384
438
|
];
|
|
385
439
|
|
|
386
|
-
|
|
440
|
+
// On Windows each name is ALSO granted in the case this process holds it.
|
|
441
|
+
// oam compares an env grant with the variable's name exactly, case and all,
|
|
442
|
+
// but Windows keeps its own spellings -- `Path`, `SystemRoot`, `SystemDrive`,
|
|
443
|
+
// `windir` -- in the environment a client launched from Explorer passes on,
|
|
444
|
+
// and `SYSTEMROOT` does not admit `SystemRoot` (measured on oam 0.18.0: with
|
|
445
|
+
// those spellings and the upper-case grant, neither the server nor the CLI
|
|
446
|
+
// child got Path or SystemRoot). A shell that upper-cases them, as Git Bash
|
|
447
|
+
// does, hides this. Only a case variant of a name already listed is added.
|
|
448
|
+
const granted = [...env];
|
|
449
|
+
if (process.platform === "win32") {
|
|
450
|
+
for (const name of Object.keys(process.env)) {
|
|
451
|
+
const upper = name.toUpperCase();
|
|
452
|
+
if (name !== upper && env.includes(upper) && !granted.includes(name)) granted.push(name);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const flags = ["--permission", netFlag, `--allow-env=${granted.join(",")}`];
|
|
387
457
|
flags.push("--allow-child-process");
|
|
388
458
|
return flags;
|
|
389
459
|
}
|
|
@@ -398,8 +468,7 @@ function sandboxFlags() {
|
|
|
398
468
|
* stderr turns out to be unusable give up quietly -- failing to print a
|
|
399
469
|
* diagnostic is not worth crashing a stdio server over.
|
|
400
470
|
*/
|
|
401
|
-
|
|
402
|
-
const { writeSync } = await import("node:fs");
|
|
471
|
+
function errSync(message) {
|
|
403
472
|
const buf = Buffer.from(message);
|
|
404
473
|
let off = 0;
|
|
405
474
|
for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
|
|
@@ -457,20 +526,30 @@ function unusableReason(path, version, label = path) {
|
|
|
457
526
|
|
|
458
527
|
/**
|
|
459
528
|
* Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
|
|
460
|
-
* binary. Returns the choice (or null) plus stderr
|
|
461
|
-
*
|
|
462
|
-
* when
|
|
529
|
+
* binary. Returns the choice (or null) plus what stderr needs:
|
|
530
|
+
* overrideNote why OAM_BIN was passed over, or null
|
|
531
|
+
* skipped why each discovered binary was passed over, when none was
|
|
532
|
+
* chosen
|
|
533
|
+
* passedOver the `version` of every existing binary rejected (OAM_BIN
|
|
534
|
+
* included), so a hard failure can name the right remedy
|
|
535
|
+
* overrideMissing OAM_BIN was set to a path that does not exist
|
|
463
536
|
*/
|
|
464
537
|
function chooseOam() {
|
|
465
538
|
const override = process.env.OAM_BIN;
|
|
466
539
|
let overrideNote = null;
|
|
540
|
+
let overrideMissing = false;
|
|
541
|
+
const passedOver = [];
|
|
467
542
|
if (override) {
|
|
468
543
|
if (!existsSync(override)) {
|
|
469
544
|
overrideNote = `OAM_BIN=${override} does not exist`;
|
|
545
|
+
overrideMissing = true;
|
|
470
546
|
} else {
|
|
471
547
|
const version = oamVersion(override);
|
|
472
|
-
if (atLeast(version, OAM_MIN))
|
|
548
|
+
if (atLeast(version, OAM_MIN)) {
|
|
549
|
+
return { chosen: { path: override, version }, overrideNote, skipped: [], passedOver, overrideMissing };
|
|
550
|
+
}
|
|
473
551
|
overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
|
|
552
|
+
passedOver.push(version);
|
|
474
553
|
}
|
|
475
554
|
}
|
|
476
555
|
const overrideKey = override ? pathKey(override) : null;
|
|
@@ -479,11 +558,91 @@ function chooseOam() {
|
|
|
479
558
|
.map((path) => ({ path, version: oamVersion(path) }));
|
|
480
559
|
const chosen = pickNewest(candidates);
|
|
481
560
|
const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
|
|
482
|
-
|
|
561
|
+
if (!chosen) passedOver.push(...candidates.map((c) => c.version));
|
|
562
|
+
return { chosen, overrideNote, skipped, passedOver, overrideMissing };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* What would fix "no usable oam", one line per cause that was actually seen.
|
|
567
|
+
*
|
|
568
|
+
* An oam that reported an old version needs `oam self-update`; one that would
|
|
569
|
+
* not run at all needs checking, and self-update will not help it; a missing
|
|
570
|
+
* OAM_BIN needs pointing somewhere real. Only when nothing at all was found is
|
|
571
|
+
* installing oam the remedy -- and not even then on a platform oam publishes no
|
|
572
|
+
* build for (there is no linux-arm64 asset), where it would send the user after
|
|
573
|
+
* a download that does not exist.
|
|
574
|
+
*
|
|
575
|
+
* Pure, with the platform passed in, so launcher.test.ts can exercise every
|
|
576
|
+
* branch on any host.
|
|
577
|
+
*/
|
|
578
|
+
function remedyFor({ passedOver, overrideMissing, shim, platform, arch }) {
|
|
579
|
+
const lines = [];
|
|
580
|
+
if (passedOver.some((v) => v !== null)) {
|
|
581
|
+
lines.push(`Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer.\n`);
|
|
582
|
+
}
|
|
583
|
+
if (passedOver.some((v) => v === null)) {
|
|
584
|
+
lines.push("Check that it is an executable oam binary for this platform.\n");
|
|
585
|
+
}
|
|
586
|
+
if (overrideMissing) lines.push("Point OAM_BIN at an existing oam binary, or unset it.\n");
|
|
587
|
+
if (lines.length === 0 && !shim) {
|
|
588
|
+
lines.push(
|
|
589
|
+
platform === "linux" && arch !== "x64"
|
|
590
|
+
? `oam publishes no build for linux-${arch}, so there is nothing to install here; set OAM_BIN=/path/to/oam if you built one yourself.\n`
|
|
591
|
+
: "Install oam from https://oamjs.org, or set OAM_BIN=/path/to/oam.\n",
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
lines.push("Or use TAILSCALE_MCP_RUNTIME=node to run on Node.\n");
|
|
595
|
+
return lines.join("");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The environment a Node handoff gets: `env` itself, or -- when THIS process is
|
|
600
|
+
* oam and its NODE_OPTIONS carries permission-model flags -- a copy without
|
|
601
|
+
* them.
|
|
602
|
+
*
|
|
603
|
+
* oam passes `--permission` and every `--allow-*` flag on to its children
|
|
604
|
+
* through NODE_OPTIONS, as Node does, and a grandchild of a sandboxed oam
|
|
605
|
+
* inherits them in the variable itself. oam reads them back from there, but
|
|
606
|
+
* Node refuses `--allow-net` and `--allow-env` in NODE_OPTIONS and exits 9
|
|
607
|
+
* before running a line (measured on Node 22.22.2: "--allow-net= is not allowed
|
|
608
|
+
* in NODE_OPTIONS"), so a handoff to Node would die without a word from the
|
|
609
|
+
* server. Every other token is kept, in order.
|
|
610
|
+
*
|
|
611
|
+
* What this cannot reach: flags oam was given on its own command line
|
|
612
|
+
* (`oam --permission --allow-net=... run`) are appended to the child's
|
|
613
|
+
* NODE_OPTIONS by oam at the spawn, from process.execArgv, whatever `env`
|
|
614
|
+
* says. Node has no `--allow-net` to honour, so a sandboxed oam host has no way
|
|
615
|
+
* to hand the server to Node intact; it only gets there under
|
|
616
|
+
* TAILSCALE_MCP_RUNTIME=node or below the floor.
|
|
617
|
+
*
|
|
618
|
+
* Pure, taking `hostOam` rather than reading process.versions, so
|
|
619
|
+
* launcher.test.ts can exercise it like runtimePlan().
|
|
620
|
+
*/
|
|
621
|
+
function nodeHandoffEnv(env, hostOam) {
|
|
622
|
+
if (hostOam === undefined || !env.NODE_OPTIONS) return env;
|
|
623
|
+
const tokens = env.NODE_OPTIONS.split(/\s+/).filter(Boolean);
|
|
624
|
+
const kept = tokens.filter((token) => !/^--(?:permission|allow-[a-z-]+)(?:=|$)/.test(token));
|
|
625
|
+
if (kept.length === tokens.length) return env;
|
|
626
|
+
const copy = { ...env };
|
|
627
|
+
if (kept.length > 0) copy.NODE_OPTIONS = kept.join(" ");
|
|
628
|
+
else delete copy.NODE_OPTIONS;
|
|
629
|
+
return copy;
|
|
483
630
|
}
|
|
484
631
|
|
|
485
632
|
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
486
633
|
async function runInProcess() {
|
|
634
|
+
// Every route into this function is a path that CANNOT carry --permission:
|
|
635
|
+
// the sandbox is a process-level flag only a FRESH oam spawn applies (see
|
|
636
|
+
// THE `--permission` SANDBOX above). The note lives here rather than at each
|
|
637
|
+
// call site because in-process serve is exactly what this function is, so
|
|
638
|
+
// neither the spawn path nor a handOffToNode can print it while every silent
|
|
639
|
+
// downgrade -- fallBack's branch, plan === "in-process", the no-oam fallback
|
|
640
|
+
// at ~763 when it lands here -- is covered by one emission.
|
|
641
|
+
if (sandbox.length > 0) {
|
|
642
|
+
await errSync(
|
|
643
|
+
`tailscale-mcp: TAILSCALE_MCP_SANDBOX=1 requested, but the server is serving in-process where --permission cannot be applied -- the sandbox is NOT active. Pair it with TAILSCALE_MCP_RUNTIME=oam to make this failure fatal.\n`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
487
646
|
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
488
647
|
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
489
648
|
// test file can import the module for unit tests without connecting a stdio
|
|
@@ -512,9 +671,10 @@ const fallbackFailed = (e) => {
|
|
|
512
671
|
*
|
|
513
672
|
* `onLaunchFailed(err)` runs when the child could not be started at all; it is
|
|
514
673
|
* never called once the child is running, which would double-start the server
|
|
515
|
-
* on the same stdio.
|
|
674
|
+
* on the same stdio. `env` is the child's whole environment; a Node handoff
|
|
675
|
+
* passes nodeHandoffEnv()'s.
|
|
516
676
|
*/
|
|
517
|
-
async function launchChild(cmd, args, onLaunchFailed) {
|
|
677
|
+
async function launchChild(cmd, args, onLaunchFailed, env = process.env) {
|
|
518
678
|
// Every handoff from an oam host pipes; see ALREADY RUNNING ON OAM. That is a
|
|
519
679
|
// host below the floor, one at the floor spawning a fresh oam for the
|
|
520
680
|
// sandbox, or any oam under TAILSCALE_MCP_RUNTIME=node.
|
|
@@ -527,7 +687,7 @@ async function launchChild(cmd, args, onLaunchFailed) {
|
|
|
527
687
|
// server's shutdown path. Piping preserves both as well: bytes are copied
|
|
528
688
|
// unchanged, and stdin's end propagates to the child.
|
|
529
689
|
stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
530
|
-
env
|
|
690
|
+
env,
|
|
531
691
|
windowsHide: true,
|
|
532
692
|
});
|
|
533
693
|
} catch (err) {
|
|
@@ -641,24 +801,31 @@ async function launchChild(cmd, args, onLaunchFailed) {
|
|
|
641
801
|
* one below the floor, or any oam under TAILSCALE_MCP_RUNTIME=node -- so there
|
|
642
802
|
* is no in-process option left. An empty `reason` prints no note: the host is a
|
|
643
803
|
* supported oam and TAILSCALE_MCP_RUNTIME=node asked for Node, which is not news.
|
|
804
|
+
* `mode` is passed in rather than read from the module scope, where it is
|
|
805
|
+
* declared after this function.
|
|
644
806
|
*/
|
|
645
|
-
async function handOffToNode(reason) {
|
|
807
|
+
async function handOffToNode(reason, mode) {
|
|
646
808
|
const node = findNodeOnPath();
|
|
647
809
|
if (!node) {
|
|
648
810
|
const remedy =
|
|
649
811
|
mode === "node"
|
|
650
812
|
? "Put Node on PATH, or launch this command with node.\n"
|
|
651
813
|
: `Run \`oam self-update\` to get oam ${OAM_MIN.join(".")} or newer, or launch this command with node.\n`;
|
|
652
|
-
|
|
814
|
+
errSync(
|
|
653
815
|
`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}`,
|
|
654
816
|
);
|
|
655
817
|
process.exit(1);
|
|
656
818
|
}
|
|
657
819
|
if (reason) await errSync(`tailscale-mcp: ${reason}; running on ${node} instead.\n`);
|
|
658
|
-
await launchChild(
|
|
659
|
-
|
|
660
|
-
process.
|
|
661
|
-
|
|
820
|
+
await launchChild(
|
|
821
|
+
node,
|
|
822
|
+
[SERVER_ENTRY, ...process.argv.slice(2)],
|
|
823
|
+
async (err) => {
|
|
824
|
+
await errSync(`tailscale-mcp: failed to launch Node at ${node} (${err?.message ?? err})\n`);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
},
|
|
827
|
+
nodeHandoffEnv(process.env, process.versions.oam),
|
|
828
|
+
);
|
|
662
829
|
}
|
|
663
830
|
|
|
664
831
|
/** What a fallback serves on, for stderr. */
|
|
@@ -667,12 +834,12 @@ function fallbackTarget(hostOam) {
|
|
|
667
834
|
}
|
|
668
835
|
|
|
669
836
|
/** No usable oam, or it would not start, under a mode that allows a fallback. */
|
|
670
|
-
async function fallBack(hostOam, why) {
|
|
837
|
+
async function fallBack(hostOam, why, mode) {
|
|
671
838
|
if (fallbackInProcess(hostOam)) {
|
|
672
839
|
await runInProcess();
|
|
673
840
|
return;
|
|
674
841
|
}
|
|
675
|
-
await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and ${why}
|
|
842
|
+
await handOffToNode(`this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}, and ${why}`, mode);
|
|
676
843
|
}
|
|
677
844
|
|
|
678
845
|
// Before anything else: a Node below the floor cannot be relied on to reach
|
|
@@ -681,7 +848,7 @@ async function fallBack(hostOam, why) {
|
|
|
681
848
|
// deep, and it costs one comparison on every launch.
|
|
682
849
|
const nodeFloorMessage = nodeFloorFailure(process.versions);
|
|
683
850
|
if (nodeFloorMessage) {
|
|
684
|
-
|
|
851
|
+
errSync(nodeFloorMessage);
|
|
685
852
|
process.exit(1);
|
|
686
853
|
}
|
|
687
854
|
|
|
@@ -698,7 +865,7 @@ const mode = (requested ?? "auto").toLowerCase();
|
|
|
698
865
|
if (requested && !RUNTIMES.includes(mode)) {
|
|
699
866
|
// Echo what was SET, not the lowercased form, so the typo is recognisable in
|
|
700
867
|
// the host's log next to the config line that produced it.
|
|
701
|
-
|
|
868
|
+
errSync(
|
|
702
869
|
`tailscale-mcp: unrecognized TAILSCALE_MCP_RUNTIME "${requested}" -- known values: ${RUNTIMES.join(", ")}. Using auto.\n`,
|
|
703
870
|
);
|
|
704
871
|
}
|
|
@@ -712,29 +879,34 @@ const sandbox = sandboxFlags();
|
|
|
712
879
|
const plan = runtimePlan({ mode, hostOam, sandbox: sandbox.length > 0 });
|
|
713
880
|
|
|
714
881
|
if (plan === "in-process") {
|
|
715
|
-
|
|
882
|
+
// Same reason as fallbackFailed: a bare rejection here (dist/index.js missing)
|
|
883
|
+
// would surface as a raw ERR_MODULE_NOT_FOUND stack instead of one line.
|
|
884
|
+
await runInProcess().catch((e) => {
|
|
885
|
+
process.stderr.write(`tailscale-mcp: could not load the server (${e?.message ?? e})\n`);
|
|
886
|
+
process.exitCode = 1;
|
|
887
|
+
});
|
|
716
888
|
} else if (plan === "handoff-node") {
|
|
717
889
|
const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
|
|
718
|
-
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "");
|
|
890
|
+
await handOffToNode(belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join(".")}` : "", mode);
|
|
719
891
|
} else {
|
|
720
|
-
const { chosen, overrideNote, skipped } = chooseOam();
|
|
892
|
+
const { chosen, overrideNote, skipped, passedOver, overrideMissing } = chooseOam();
|
|
721
893
|
|
|
722
894
|
if (chosen) {
|
|
723
895
|
if (overrideNote) {
|
|
724
|
-
|
|
896
|
+
errSync(`tailscale-mcp: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join(".")}).\n`);
|
|
725
897
|
}
|
|
726
898
|
// The sandbox flags go BEFORE `run` (see sandboxFlags), and `--` separates
|
|
727
899
|
// oam's own flags from the script's argv, so `tailscale-mcp --version` and
|
|
728
900
|
// any host-supplied flags survive the hop unchanged.
|
|
729
901
|
await launchChild(chosen.path, [...sandbox, "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], async (err) => {
|
|
730
902
|
if (mode === "oam") {
|
|
731
|
-
|
|
903
|
+
errSync(`tailscale-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err})\n`);
|
|
732
904
|
process.exit(1);
|
|
733
905
|
}
|
|
734
|
-
|
|
906
|
+
errSync(
|
|
735
907
|
`tailscale-mcp: failed to launch oam at ${chosen.path} (${err?.message ?? err}); using ${fallbackTarget(hostOam)} instead.\n`,
|
|
736
908
|
);
|
|
737
|
-
await fallBack(hostOam, "the newer oam would not start");
|
|
909
|
+
await fallBack(hostOam, "the newer oam would not start", mode);
|
|
738
910
|
});
|
|
739
911
|
} else {
|
|
740
912
|
const shim = findOamShim();
|
|
@@ -748,18 +920,18 @@ if (plan === "in-process") {
|
|
|
748
920
|
: []),
|
|
749
921
|
];
|
|
750
922
|
if (mode === "oam") {
|
|
751
|
-
|
|
923
|
+
errSync(
|
|
752
924
|
`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no usable oam (${OAM_MIN.join(".")} or newer) was found.\n` +
|
|
753
925
|
notes.map((note) => ` ${note}\n`).join("") +
|
|
754
|
-
|
|
926
|
+
remedyFor({ passedOver, overrideMissing, shim, platform: process.platform, arch: process.arch }),
|
|
755
927
|
);
|
|
756
928
|
process.exit(1);
|
|
757
929
|
}
|
|
758
930
|
// auto: falling back is correct, but silence is how someone never learns
|
|
759
931
|
// their OAM_BIN is wrong or their oam is too old to use.
|
|
760
932
|
if (notes.length > 0) {
|
|
761
|
-
|
|
933
|
+
errSync(`tailscale-mcp: ${notes.join("; ")}; using ${fallbackTarget(hostOam)} instead.\n`);
|
|
762
934
|
}
|
|
763
|
-
await fallBack(hostOam, "no newer oam was found").catch(fallbackFailed);
|
|
935
|
+
await fallBack(hostOam, "no newer oam was found", mode).catch(fallbackFailed);
|
|
764
936
|
}
|
|
765
937
|
}
|