@yawlabs/tailscale-mcp 0.15.0 → 0.16.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 +9 -1
- package/bin/tailscale-mcp.mjs +102 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -511,7 +511,15 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
511
511
|
|
|
512
512
|
## Running on oam.js (optional)
|
|
513
513
|
|
|
514
|
-
[oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.
|
|
514
|
+
[oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 89 tools, all 4 resources, identical error messages, and a clean stdout protocol stream — from the shipped bundle *and* straight from the TypeScript source with no build step.
|
|
515
|
+
|
|
516
|
+
**oam 0.9.0 is the minimum.** Older releases ran `child_process.execFile` arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument. This server shells out to the `tailscale` binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one. The launcher enforces the floor: given an older oam it falls back to Node and says so on stderr, and `TAILSCALE_MCP_RUNTIME=oam` turns that into a hard error.
|
|
517
|
+
|
|
518
|
+
### Sandboxing (opt-in)
|
|
519
|
+
|
|
520
|
+
Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` and `login.tailscale.com`, filesystem denied. Child-process stays granted because the local-CLI tools shell out to the `tailscale` binary, which is also why `PATH` remains in the environment allow-list.
|
|
521
|
+
|
|
522
|
+
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.
|
|
515
523
|
|
|
516
524
|
```jsonc
|
|
517
525
|
{
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -22,19 +22,46 @@
|
|
|
22
22
|
* For an MCP host config, point straight at oam and skip this file:
|
|
23
23
|
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
24
24
|
*
|
|
25
|
+
* THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
|
|
26
|
+
* `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
27
|
+
* network limited to the control-plane hosts, filesystem denied.
|
|
28
|
+
*
|
|
29
|
+
* Child-process is granted unconditionally because the local-CLI tools shell out
|
|
30
|
+
* to the `tailscale` binary; that is also why PATH stays in the env grant, since
|
|
31
|
+
* resolving the binary needs it.
|
|
32
|
+
*
|
|
33
|
+
* Opt-in, not default, because a denied environment variable is ABSENT from
|
|
34
|
+
* process.env rather than throwing -- an under-granted TAILSCALE_API_KEY reads as
|
|
35
|
+
* "unauthenticated" rather than "denied". The env list is derived from the
|
|
36
|
+
* shipped bundle; keep it in step.
|
|
37
|
+
*
|
|
38
|
+
* MINIMUM OAM VERSION
|
|
39
|
+
* 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
|
|
40
|
+
* `exec` accepted `timeout` and ignored it, `spawnSync` truncated at
|
|
41
|
+
* `maxBuffer` while reporting success, and `stdio: 'inherit'`/`'ignore'` both
|
|
42
|
+
* behaved as `'pipe'`. This server shells out to a CLI on its
|
|
43
|
+
* main paths, so those were reachable bugs rather than theoretical ones: an
|
|
44
|
+
* argument containing shell metacharacters was re-split and executed.
|
|
45
|
+
* An older oam is not an error: the launcher falls back to Node and says so on
|
|
46
|
+
* stderr. Pinning the floor here is what makes that fallback automatic.
|
|
47
|
+
*
|
|
25
48
|
* SELECTION
|
|
26
49
|
* TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
27
50
|
* TAILSCALE_MCP_RUNTIME=node never use oam
|
|
28
51
|
* TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
52
|
+
* TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
|
|
29
53
|
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
30
54
|
*/
|
|
31
55
|
|
|
32
|
-
import { spawn } from "node:child_process";
|
|
56
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
33
57
|
import { existsSync } from "node:fs";
|
|
34
58
|
import { constants, homedir } from "node:os";
|
|
35
59
|
import { delimiter, join } from "node:path";
|
|
36
60
|
import { fileURLToPath } from "node:url";
|
|
37
61
|
|
|
62
|
+
/** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
|
|
63
|
+
const OAM_MIN = [0, 9, 0];
|
|
64
|
+
|
|
38
65
|
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
39
66
|
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
40
67
|
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
@@ -83,6 +110,60 @@ function findOam() {
|
|
|
83
110
|
return null;
|
|
84
111
|
}
|
|
85
112
|
|
|
113
|
+
/**
|
|
114
|
+
* `oam --version` -> [major, minor, patch], or null when it cannot be read.
|
|
115
|
+
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
|
|
116
|
+
*/
|
|
117
|
+
function oamVersion(cmd) {
|
|
118
|
+
try {
|
|
119
|
+
const out = execFileSync(cmd, ["--version"], {
|
|
120
|
+
encoding: "utf-8",
|
|
121
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
122
|
+
});
|
|
123
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
|
|
124
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
125
|
+
} catch {
|
|
126
|
+
// Not executable, wrong arch, or deleted since the stat. Caller degrades.
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** True when `v` is at least `min`, comparing major/minor/patch in order. */
|
|
132
|
+
function atLeast(v, min) {
|
|
133
|
+
if (!v) return false;
|
|
134
|
+
for (let i = 0; i < min.length; i++) {
|
|
135
|
+
if (v[i] > min[i]) return true;
|
|
136
|
+
if (v[i] < min[i]) return false;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `--permission` grant list, or [] when the sandbox is not requested.
|
|
143
|
+
*
|
|
144
|
+
* These are oam's PROCESS-level flags: they belong before the `run` subcommand,
|
|
145
|
+
* not after it. `oam run --permission file.js` is rejected outright, which is a
|
|
146
|
+
* good failure but only because it is loud -- ordering here is load-bearing.
|
|
147
|
+
*
|
|
148
|
+
* Net grants prefix-match `host` for fetch and `host:port` for sockets.
|
|
149
|
+
* A denied environment variable is ABSENT from process.env rather than throwing,
|
|
150
|
+
* so the env list below is derived from what the bundle actually reads; trimming
|
|
151
|
+
* it produces silent misbehaviour, not a clear denial.
|
|
152
|
+
*/
|
|
153
|
+
function sandboxFlags() {
|
|
154
|
+
if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
|
|
155
|
+
|
|
156
|
+
const hosts = ["api.tailscale.com","login.tailscale.com"];
|
|
157
|
+
|
|
158
|
+
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
159
|
+
|
|
160
|
+
const env = ["PATH","TAILSCALE_API_KEY","TAILSCALE_BINARY","TAILSCALE_DEBUG","TAILSCALE_EXTRA_WEBHOOK_EVENTS","TAILSCALE_MAX_CONCURRENT","TAILSCALE_OAUTH_CLIENT_ID","TAILSCALE_OAUTH_CLIENT_SECRET","TAILSCALE_PROFILE","TAILSCALE_READONLY","TAILSCALE_REQUEST_BUDGET_MS","TAILSCALE_RETRY_BASE_DELAY_MS","TAILSCALE_TAILNET","TAILSCALE_TOOLS"];
|
|
161
|
+
|
|
162
|
+
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
163
|
+
flags.push("--allow-child-process");
|
|
164
|
+
return flags;
|
|
165
|
+
}
|
|
166
|
+
|
|
86
167
|
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
87
168
|
async function runInProcess() {
|
|
88
169
|
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
@@ -120,10 +201,29 @@ if (mode === "node") {
|
|
|
120
201
|
process.exit(1);
|
|
121
202
|
}
|
|
122
203
|
await runInProcess();
|
|
204
|
+
} else if (!atLeast(oamVersion(oam), OAM_MIN)) {
|
|
205
|
+
// Discovery itself stays stat-only; this is the first subprocess, and it
|
|
206
|
+
// runs only once we have already decided to spawn oam anyway. Measured 26ms
|
|
207
|
+
// median (n=12, windows-arm64), paid once per MCP session.
|
|
208
|
+
const min = OAM_MIN.join(".");
|
|
209
|
+
if (mode === "oam") {
|
|
210
|
+
const { writeSync } = await import("node:fs");
|
|
211
|
+
writeSync(
|
|
212
|
+
2,
|
|
213
|
+
`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
|
|
214
|
+
`Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n`,
|
|
215
|
+
);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
// auto: an old oam is a reason to prefer Node, not to fail. Say so, because
|
|
219
|
+
// a silent downgrade is how someone keeps running an oam they meant to
|
|
220
|
+
// update. stderr is safe -- MCP frames travel on stdout.
|
|
221
|
+
process.stderr.write(`tailscale-mcp: oam at ${oam} is older than ${min}; using Node instead.\n`);
|
|
222
|
+
await runInProcess();
|
|
123
223
|
} else {
|
|
124
224
|
// `--` separates oam's own flags from the script's argv, so `tailscale-mcp
|
|
125
225
|
// --version` and any host-supplied flags survive the hop unchanged.
|
|
126
|
-
const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
226
|
+
const child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
127
227
|
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
128
228
|
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
129
229
|
// server's shutdown path.
|
package/dist/index.js
CHANGED
|
@@ -33824,7 +33824,7 @@ async function tailnetDnsResource(uri) {
|
|
|
33824
33824
|
}
|
|
33825
33825
|
|
|
33826
33826
|
// src/index.ts
|
|
33827
|
-
var version2 = true ? "0.
|
|
33827
|
+
var version2 = true ? "0.16.0" : resolveVersionFallback();
|
|
33828
33828
|
var subcommand = process.argv[2];
|
|
33829
33829
|
var cliSubcommandHandled = false;
|
|
33830
33830
|
if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|