@yawlabs/tailscale-mcp 0.17.0 → 0.18.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 +12 -10
- package/bin/tailscale-mcp.mjs +240 -59
- package/dist/index.js +129 -32
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/YawLabs/tailscale-mcp/stargazers)
|
|
6
6
|
[](./release.sh)
|
|
7
7
|
|
|
8
|
-
**Ask your agent questions about your tailnet and have it act on the answers.**
|
|
8
|
+
**Ask your agent questions about your tailnet and have it act on the answers.** 96 admin-API tools + 6 optional local-CLI diagnostics + 4 resources spanning the [Tailscale v2 API](https://tailscale.com/api) — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1100+ unit tests and an opt-in live-tailnet integration suite.
|
|
9
9
|
|
|
10
10
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
11
11
|
|
|
@@ -104,7 +104,7 @@ That's it. Now ask your agent:
|
|
|
104
104
|
|
|
105
105
|
## Too many tools? Subset them.
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
96 tools is a lot. If you've already got a dozen MCP servers and your client is feeling heavy, trim what this one exposes. Three knobs, combinable:
|
|
108
108
|
|
|
109
109
|
### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
|
|
110
110
|
|
|
@@ -118,8 +118,8 @@ That's it. Now ask your agent:
|
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
- **`minimal`** (20 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
|
|
121
|
-
- **`core`** (
|
|
122
|
-
- **`full`** (
|
|
121
|
+
- **`core`** (51 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
|
|
122
|
+
- **`full`** (96 tools, default) — everything. Same as omitting the env var.
|
|
123
123
|
|
|
124
124
|
### Option 2: `TAILSCALE_TOOLS` (explicit group list)
|
|
125
125
|
|
|
@@ -227,7 +227,7 @@ Set `TAILSCALE_LOCAL_CLI=1` (in your shell or `.mcp.json` `env` block) to add si
|
|
|
227
227
|
|
|
228
228
|
Requirements: the `tailscale` binary must be in `PATH`. If it's installed somewhere unusual, set `TAILSCALE_BINARY` to its absolute path. The MCP server doesn't need root to run these — they're all diagnostic, not state-mutating. Operations that would need elevation (`tailscale up`, `set --advertise-routes`, `lock sign`) are deliberately not exposed.
|
|
229
229
|
|
|
230
|
-
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (
|
|
230
|
+
When opt-in is on, the startup banner reflects it: `@yawlabs/tailscale-mcp v0.13.3 ready (102 tools, local-cli=on)` — the 6 local CLI tools are additive on top of the default 96.
|
|
231
231
|
|
|
232
232
|
## Resources (4)
|
|
233
233
|
|
|
@@ -240,7 +240,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
240
240
|
| ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
|
|
241
241
|
| DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
|
|
242
242
|
|
|
243
|
-
## Tools (
|
|
243
|
+
## Tools (96 + 6 opt-in)
|
|
244
244
|
|
|
245
245
|
<details>
|
|
246
246
|
<summary><strong>Status</strong> (1 tool)</summary>
|
|
@@ -308,7 +308,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
308
308
|
</details>
|
|
309
309
|
|
|
310
310
|
<details>
|
|
311
|
-
<summary><strong>Keys / Trust Credentials</strong> (
|
|
311
|
+
<summary><strong>Keys / Trust Credentials</strong> (9 tools) — covers auth keys, OAuth clients, federated identities, and OAuth apps</summary>
|
|
312
312
|
|
|
313
313
|
| Tool | Description |
|
|
314
314
|
|------|-------------|
|
|
@@ -319,6 +319,8 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
319
319
|
| `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
|
|
320
320
|
| `tailscale_create_oauth_app` | Create an OAuth App for third-party device provisioning (Tailscale alpha) |
|
|
321
321
|
| `tailscale_get_oauth_app` | Get an OAuth App's name, redirect URIs, and scopes |
|
|
322
|
+
| `tailscale_list_oauth_apps` | List every OAuth App registered in the tailnet |
|
|
323
|
+
| `tailscale_delete_oauth_app` | Delete an OAuth App, revoking its ability to provision devices |
|
|
322
324
|
|
|
323
325
|
</details>
|
|
324
326
|
|
|
@@ -531,18 +533,18 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
531
533
|
|
|
532
534
|
## Requirements
|
|
533
535
|
|
|
534
|
-
- Node.js 20+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
|
|
536
|
+
- Node.js 20.11+ to run the server (22+ to develop — the test script passes a glob to `node --test`, supported from Node 21)
|
|
535
537
|
- A Tailscale API key or OAuth client credentials
|
|
536
538
|
|
|
537
539
|
## Running on oam.js (optional)
|
|
538
540
|
|
|
539
|
-
[oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all
|
|
541
|
+
[oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.9.0: full MCP handshake, all 96 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.
|
|
540
542
|
|
|
541
543
|
**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.
|
|
542
544
|
|
|
543
545
|
### Sandboxing (opt-in)
|
|
544
546
|
|
|
545
|
-
Set `TAILSCALE_MCP_SANDBOX=1` to run under oam's `--permission` model: network restricted to `api.tailscale.com` and
|
|
547
|
+
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 is also why `PATH` remains in the environment allow-list.
|
|
546
548
|
|
|
547
549
|
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.
|
|
548
550
|
|
package/bin/tailscale-mcp.mjs
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* THE `--permission` SANDBOX (oam 0.9.0+, opt-in)
|
|
26
26
|
* `TAILSCALE_MCP_SANDBOX=1` runs the server under oam's permission model:
|
|
27
|
-
* network limited to the
|
|
27
|
+
* network limited to the one host the bundle actually calls
|
|
28
|
+
* (api.tailscale.com), filesystem denied.
|
|
28
29
|
*
|
|
29
30
|
* Child-process is granted unconditionally because the local-CLI tools shell out
|
|
30
31
|
* to the `tailscale` binary; that is also why PATH stays in the env grant, since
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
* TAILSCALE_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
50
51
|
* TAILSCALE_MCP_RUNTIME=node never use oam
|
|
51
52
|
* TAILSCALE_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
53
|
+
* anything else warns on stderr, then behaves as auto
|
|
52
54
|
* TAILSCALE_MCP_SANDBOX=1 run oam under --permission (oam 0.9.0+)
|
|
53
55
|
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
54
56
|
*/
|
|
@@ -85,7 +87,7 @@ function findOam() {
|
|
|
85
87
|
// point deliberately at a dev build.
|
|
86
88
|
//
|
|
87
89
|
// Both forms are checked on Windows: the installer defaults to
|
|
88
|
-
// %LOCALAPPDATA
|
|
90
|
+
// %LOCALAPPDATA%\oam\bin there, but oam's docs name ~/.oam/bin first and
|
|
89
91
|
// OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
90
92
|
// install.
|
|
91
93
|
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
@@ -98,13 +100,16 @@ function findOam() {
|
|
|
98
100
|
|
|
99
101
|
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
100
102
|
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
101
|
-
|
|
103
|
+
// Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
|
|
104
|
+
// run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
|
|
105
|
+
// for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
|
|
106
|
+
// the full PATHEXT list would hand back a path this launcher cannot execute.
|
|
107
|
+
// Discovery has to agree with execution. A skipped shim is still reported --
|
|
108
|
+
// see findOamShim.
|
|
102
109
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
103
110
|
if (!dir) continue;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (existsSync(candidate)) return candidate;
|
|
107
|
-
}
|
|
111
|
+
const candidate = join(dir, exe);
|
|
112
|
+
if (existsSync(candidate)) return candidate;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
return null;
|
|
@@ -153,7 +158,16 @@ function atLeast(v, min) {
|
|
|
153
158
|
function sandboxFlags() {
|
|
154
159
|
if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
|
|
155
160
|
|
|
156
|
-
|
|
161
|
+
// ONE host, deliberately. Every outbound request the bundle makes targets
|
|
162
|
+
// api.tailscale.com: BASE_URL, the OAuth token exchange, and the absolute-URL
|
|
163
|
+
// allow-list that refuses to send credentials anywhere else. login.tailscale.com
|
|
164
|
+
// was granted here as well until an audit found no code path that contacts it
|
|
165
|
+
// -- console.tailscale.com appears in error TEXT, never as a request target.
|
|
166
|
+
// An unused grant is the one kind of over-permission nothing ever surfaces:
|
|
167
|
+
// removing it cannot break a call that was never made, and keeping it widens
|
|
168
|
+
// the sandbox for no behaviour. launcher.test.ts pins this list exactly so a
|
|
169
|
+
// future host lands as a reviewed diff rather than a quiet widening.
|
|
170
|
+
const hosts = ["api.tailscale.com"];
|
|
157
171
|
|
|
158
172
|
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
159
173
|
|
|
@@ -163,13 +177,73 @@ function sandboxFlags() {
|
|
|
163
177
|
// meant the local-CLI tool group silently failed to register under the
|
|
164
178
|
// sandbox even though --allow-child-process is granted below precisely so
|
|
165
179
|
// those tools can shell out.
|
|
166
|
-
const env = [
|
|
180
|
+
const env = [
|
|
181
|
+
"PATH",
|
|
182
|
+
"TAILSCALE_API_KEY",
|
|
183
|
+
"TAILSCALE_BINARY",
|
|
184
|
+
"TAILSCALE_DEBUG",
|
|
185
|
+
"TAILSCALE_EXTRA_POSTURE_PROVIDERS",
|
|
186
|
+
"TAILSCALE_EXTRA_WEBHOOK_EVENTS",
|
|
187
|
+
"TAILSCALE_LOCAL_CLI",
|
|
188
|
+
"TAILSCALE_MAX_CONCURRENT",
|
|
189
|
+
"TAILSCALE_OAUTH_CLIENT_ID",
|
|
190
|
+
"TAILSCALE_OAUTH_CLIENT_SECRET",
|
|
191
|
+
"TAILSCALE_OAUTH_TAILNET",
|
|
192
|
+
"TAILSCALE_PROFILE",
|
|
193
|
+
"TAILSCALE_READONLY",
|
|
194
|
+
"TAILSCALE_REQUEST_BUDGET_MS",
|
|
195
|
+
"TAILSCALE_RETRY_BASE_DELAY_MS",
|
|
196
|
+
"TAILSCALE_TAILNET",
|
|
197
|
+
"TAILSCALE_TOOLS",
|
|
198
|
+
];
|
|
167
199
|
|
|
168
200
|
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
169
201
|
flags.push("--allow-child-process");
|
|
170
202
|
return flags;
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Write a diagnostic to stderr synchronously, so a following process.exit
|
|
207
|
+
* cannot truncate it.
|
|
208
|
+
*
|
|
209
|
+
* Not a bare writeSync: that call can short-write (it returns a byte count) and
|
|
210
|
+
* on macOS it can throw EAGAIN, because Node makes a piped stderr non-blocking
|
|
211
|
+
* there rather than blocking the write. Loop over the remaining bytes, and if
|
|
212
|
+
* stderr turns out to be unusable give up quietly -- failing to print a
|
|
213
|
+
* diagnostic is not worth crashing a stdio server over.
|
|
214
|
+
*/
|
|
215
|
+
async function errSync(message) {
|
|
216
|
+
const { writeSync } = await import("node:fs");
|
|
217
|
+
const buf = Buffer.from(message);
|
|
218
|
+
let off = 0;
|
|
219
|
+
for (let attempts = 0; off < buf.length && attempts < 1000; attempts++) {
|
|
220
|
+
try {
|
|
221
|
+
off += writeSync(2, buf, off, buf.length - off);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err?.code !== "EAGAIN") return;
|
|
224
|
+
// Pipe is full and the reader has not drained yet -- retry.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
|
|
231
|
+
* cannot spawn. Reported rather than ignored, because "no oam binary was found"
|
|
232
|
+
* reads as "install oam" -- the one thing that will not help. Windows only;
|
|
233
|
+
* there is no such shim concept on POSIX.
|
|
234
|
+
*/
|
|
235
|
+
function findOamShim() {
|
|
236
|
+
if (!isWin) return null;
|
|
237
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
238
|
+
if (!dir) continue;
|
|
239
|
+
for (const ext of [".cmd", ".bat"]) {
|
|
240
|
+
const candidate = join(dir, `oam${ext}`);
|
|
241
|
+
if (existsSync(candidate)) return candidate;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
173
247
|
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
174
248
|
async function runInProcess() {
|
|
175
249
|
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
@@ -186,14 +260,43 @@ async function runInProcess() {
|
|
|
186
260
|
await import(SERVER_URL.href);
|
|
187
261
|
}
|
|
188
262
|
|
|
189
|
-
|
|
263
|
+
// Every value below is compared against `mode` after lowercasing, so an
|
|
264
|
+
// unrecognized one matched nothing and fell through to the auto branch --
|
|
265
|
+
// `TAILSCALE_MCP_RUNTIME=nod` silently PREFERRED oam on a box that has it,
|
|
266
|
+
// which is the opposite of what was asked for. Same handling as index.ts gives
|
|
267
|
+
// an unknown subcommand: auto is still the right landing place, it just stops
|
|
268
|
+
// being silent. An empty value is treated as unset, since `FOO=$UNSET` in a
|
|
269
|
+
// wrapper script is how it usually gets there.
|
|
270
|
+
const RUNTIMES = ["auto", "node", "oam"];
|
|
271
|
+
const requested = process.env.TAILSCALE_MCP_RUNTIME;
|
|
272
|
+
const mode = (requested ?? "auto").toLowerCase();
|
|
273
|
+
if (requested && !RUNTIMES.includes(mode)) {
|
|
274
|
+
// Echo what was SET, not the lowercased form, so the typo is recognisable in
|
|
275
|
+
// the host's log next to the config line that produced it.
|
|
276
|
+
await errSync(
|
|
277
|
+
`tailscale-mcp: unrecognized TAILSCALE_MCP_RUNTIME "${requested}" -- known values: ${RUNTIMES.join(", ")}. Using auto.\n`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
190
280
|
|
|
191
281
|
if (mode === "node") {
|
|
192
282
|
await runInProcess();
|
|
193
283
|
} else {
|
|
194
284
|
const oam = findOam();
|
|
285
|
+
// Read the version ONCE, and only when discovery found something: the
|
|
286
|
+
// gate below has to tell "too old" apart from "could not be read at all",
|
|
287
|
+
// and re-probing inside the branch would cost a second subprocess.
|
|
288
|
+
const found = oam ? oamVersion(oam) : null;
|
|
195
289
|
|
|
196
290
|
if (!oam) {
|
|
291
|
+
// An oam-named .cmd/.bat on PATH is a real install in a shape this
|
|
292
|
+
// launcher cannot spawn. Naming it turns "no oam binary was found" --
|
|
293
|
+
// which reads as "install oam", the one thing that will not help --
|
|
294
|
+
// into something the user can act on.
|
|
295
|
+
const oamShim = findOamShim();
|
|
296
|
+
const shimNote = oamShim
|
|
297
|
+
? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
|
|
298
|
+
"Install the native oam binary, or point OAM_BIN at one.\n"
|
|
299
|
+
: "";
|
|
197
300
|
if (mode === "oam") {
|
|
198
301
|
// Explicitly demanded, so this is a real misconfiguration. writeSync
|
|
199
302
|
// because stderr is async for TTYs/pipes on Windows and process.exit
|
|
@@ -201,75 +304,153 @@ if (mode === "node") {
|
|
|
201
304
|
const { writeSync } = await import("node:fs");
|
|
202
305
|
writeSync(
|
|
203
306
|
2,
|
|
204
|
-
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no oam binary was found.\n" +
|
|
307
|
+
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
308
|
+
shimNote +
|
|
205
309
|
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
|
|
206
310
|
);
|
|
207
311
|
process.exit(1);
|
|
208
312
|
}
|
|
313
|
+
// auto: falling back is correct, but silence is how someone never learns
|
|
314
|
+
// their oam install is a shape this launcher skips.
|
|
315
|
+
if (oamShim) await errSync(`tailscale-mcp: ${shimNote}Using Node instead.\n`);
|
|
209
316
|
await runInProcess();
|
|
210
|
-
} else if (!atLeast(
|
|
211
|
-
// Discovery itself stays stat-only; this is the first subprocess, and it
|
|
212
|
-
// runs only once we have already decided to spawn oam anyway. Measured 26ms
|
|
213
|
-
// median (n=12, windows-arm64), paid once per MCP session.
|
|
317
|
+
} else if (!atLeast(found, OAM_MIN)) {
|
|
214
318
|
const min = OAM_MIN.join(".");
|
|
319
|
+
// Two different causes reach this branch and they need different
|
|
320
|
+
// remedies. `found === null` is NOT "old": oamVersion returns null when
|
|
321
|
+
// the binary could not be run at all (not executable, wrong arch, a
|
|
322
|
+
// .cmd/.bat Node refuses, deleted between the stat and the probe) or
|
|
323
|
+
// when its --version output did not parse. Telling that user to
|
|
324
|
+
// `oam self-update` sends them after the one cause it definitely is not.
|
|
325
|
+
const detail = found
|
|
326
|
+
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
327
|
+
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
328
|
+
const remedy = found
|
|
329
|
+
? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
|
|
330
|
+
: "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
|
|
215
331
|
if (mode === "oam") {
|
|
216
|
-
|
|
217
|
-
writeSync(
|
|
218
|
-
2,
|
|
219
|
-
`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
|
|
220
|
-
`Run \`oam self-update\`, or use TAILSCALE_MCP_RUNTIME=node.\n`,
|
|
221
|
-
);
|
|
332
|
+
await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
222
333
|
process.exit(1);
|
|
223
334
|
}
|
|
224
|
-
// auto:
|
|
225
|
-
// a silent downgrade is how someone keeps running an oam they
|
|
226
|
-
//
|
|
227
|
-
|
|
335
|
+
// auto: neither cause is worth failing over -- prefer Node. Say so,
|
|
336
|
+
// because a silent downgrade is how someone keeps running an oam they
|
|
337
|
+
// meant to update, or never learns their oam is unexecutable.
|
|
338
|
+
await errSync(`tailscale-mcp: ${detail}; using Node instead.\n`);
|
|
228
339
|
await runInProcess();
|
|
229
340
|
} else {
|
|
230
341
|
// `--` separates oam's own flags from the script's argv, so `tailscale-mcp
|
|
231
342
|
// --version` and any host-supplied flags survive the hop unchanged.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
242
|
-
// wrong arch, permission), fall back rather than failing the whole server.
|
|
243
|
-
// `spawned` prevents falling back AFTER the child started, which would
|
|
244
|
-
// double-start the server on the same stdio.
|
|
245
|
-
let spawned = false;
|
|
246
|
-
child.on("spawn", () => {
|
|
247
|
-
spawned = true;
|
|
248
|
-
});
|
|
249
|
-
child.on("error", (err) => {
|
|
250
|
-
if (spawned) return;
|
|
343
|
+
// Every "oam could not be executed" outcome lands here: the synchronous
|
|
344
|
+
// throw from spawn() and the async 'error' event mean the same thing and
|
|
345
|
+
// must degrade the same way, so the handling lives in one place.
|
|
346
|
+
// errSync rather than process.stderr.write because stderr is async for
|
|
347
|
+
// TTYs and pipes on Windows and the process.exit below truncates pending
|
|
348
|
+
// writes.
|
|
349
|
+
const launchFailed = async (err) => {
|
|
251
350
|
if (mode === "oam") {
|
|
252
|
-
|
|
351
|
+
await errSync(`tailscale-mcp: failed to launch oam (${err?.message ?? err})\n`);
|
|
253
352
|
process.exit(1);
|
|
254
353
|
}
|
|
255
|
-
|
|
256
|
-
}
|
|
354
|
+
await runInProcess();
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// ONE reporter shared by both launchFailed call sites, so the sync-throw
|
|
358
|
+
// path and the 'error'-event path cannot drift apart. Either can reject:
|
|
359
|
+
// runInProcess() is a bare import() that rejects when dist/index.js is
|
|
360
|
+
// missing, and at ESM top level an unhandled rejection is an uncaught
|
|
361
|
+
// exception -- the exact failure this handling exists to prevent.
|
|
362
|
+
const fallbackFailed = (e) => {
|
|
363
|
+
process.stderr.write(`tailscale-mcp: fallback to Node failed (${e?.message ?? e})\n`);
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
};
|
|
257
366
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
367
|
+
let child = null;
|
|
368
|
+
try {
|
|
369
|
+
child = spawn(oam, [...sandboxFlags(), "run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
370
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
371
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
372
|
+
// server's shutdown path.
|
|
373
|
+
stdio: "inherit",
|
|
374
|
+
env: process.env,
|
|
375
|
+
windowsHide: true,
|
|
263
376
|
});
|
|
377
|
+
} catch (err) {
|
|
378
|
+
// spawn() THROWS for some failures instead of emitting 'error', and the
|
|
379
|
+
// 'error' listener is registered AFTER this call, so it can never observe
|
|
380
|
+
// one -- an uncaught throw here kills the launcher with a raw stack trace
|
|
381
|
+
// instead of falling back to Node.
|
|
382
|
+
await launchFailed(err).catch(fallbackFailed);
|
|
264
383
|
}
|
|
265
384
|
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
385
|
+
if (child) {
|
|
386
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
387
|
+
// wrong arch, permission), fall back rather than failing the whole server.
|
|
388
|
+
// `spawned` prevents falling back AFTER the child started, which would
|
|
389
|
+
// double-start the server on the same stdio.
|
|
390
|
+
let spawned = false;
|
|
391
|
+
child.on("spawn", () => {
|
|
392
|
+
spawned = true;
|
|
393
|
+
});
|
|
394
|
+
child.on("error", (err) => {
|
|
395
|
+
if (spawned) return;
|
|
396
|
+
// Handle the rejection instead of discarding it: a failing in-process
|
|
397
|
+
// fallback would otherwise escape as an unhandled rejection, replacing
|
|
398
|
+
// this launcher's diagnostic with a raw stack trace.
|
|
399
|
+
launchFailed(err).catch(fallbackFailed);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
403
|
+
// rather than the child being orphaned.
|
|
404
|
+
//
|
|
405
|
+
// Registering ANY handler for these suppresses Node's default
|
|
406
|
+
// terminate-on-signal, so the parent's exit has to be arranged explicitly.
|
|
407
|
+
// `child.killed` only records that kill() was CALLED, never that the child
|
|
408
|
+
// is gone, so gating on it swallows every signal after the first and wedges
|
|
409
|
+
// the launcher with no escape hatch.
|
|
410
|
+
//
|
|
411
|
+
// Escalation is driven by a TIMER, not by counting signals. Counting is
|
|
412
|
+
// ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
|
|
413
|
+
// apart, and a terminal Ctrl-C reaches the whole process group, so reading
|
|
414
|
+
// "a second signal" as impatience hard-kills a child that is already
|
|
415
|
+
// shutting down cleanly. A timer makes the count irrelevant -- ONE press is
|
|
416
|
+
// enough, and a wedged child dies on schedule. setTimeout is monotonic, so
|
|
417
|
+
// a wall-clock step cannot mis-gate the window either.
|
|
418
|
+
//
|
|
419
|
+
// POSIX vs Windows, and why we do NOT forward on Windows.
|
|
420
|
+
// On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
|
|
421
|
+
// is what lets the child run its shutdown. On Windows there are no POSIX
|
|
422
|
+
// signals: child.kill IGNORES the name and calls TerminateProcess -- an
|
|
423
|
+
// immediate hard kill (verified: a child with a SIGTERM handler never runs
|
|
424
|
+
// it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
|
|
425
|
+
// graceful shutdown the console's own Ctrl-C just started, skipping the
|
|
426
|
+
// child's process.on("exit") cleanup. The console has already notified the
|
|
427
|
+
// child, so on Windows the timer below is the only kill we issue.
|
|
428
|
+
const ESCALATE_AFTER_MS = 2000;
|
|
429
|
+
let escalation = null;
|
|
430
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
431
|
+
process.on(sig, () => {
|
|
432
|
+
// No try/catch: kill() on an already-exited child returns false, it does
|
|
433
|
+
// not throw. It throws only for a signal the platform does not know,
|
|
434
|
+
// which SIGINT/SIGTERM/SIGKILL never are.
|
|
435
|
+
if (!isWin) child.kill(sig);
|
|
436
|
+
if (escalation) return; // already counting down; further signals are noise
|
|
437
|
+
escalation = setTimeout(() => {
|
|
438
|
+
// Still here after its grace window. Stop waiting on it.
|
|
439
|
+
child.kill("SIGKILL");
|
|
440
|
+
process.exit(128 + (constants.signals[sig] ?? 15));
|
|
441
|
+
}, ESCALATE_AFTER_MS);
|
|
442
|
+
});
|
|
271
443
|
}
|
|
272
|
-
|
|
273
|
-
|
|
444
|
+
|
|
445
|
+
child.on("exit", (code, signal) => {
|
|
446
|
+
if (escalation) clearTimeout(escalation);
|
|
447
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
448
|
+
// conventional shell exit status rather than a bare 0.
|
|
449
|
+
if (signal) {
|
|
450
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
451
|
+
}
|
|
452
|
+
process.exit(code ?? 0);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
274
455
|
}
|
|
275
456
|
}
|
package/dist/index.js
CHANGED
|
@@ -31035,6 +31035,11 @@ async function getOAuthAccessToken(clientId, clientSecret) {
|
|
|
31035
31035
|
})();
|
|
31036
31036
|
return oauthRefreshPromise;
|
|
31037
31037
|
}
|
|
31038
|
+
function invalidateOAuthTokenOnUnauthorized(authorizationHeader) {
|
|
31039
|
+
if (!oauthToken) return;
|
|
31040
|
+
if (authorizationHeader !== `Bearer ${oauthToken.access_token}`) return;
|
|
31041
|
+
oauthToken = null;
|
|
31042
|
+
}
|
|
31038
31043
|
async function getAuthHeader() {
|
|
31039
31044
|
const config2 = getAuthConfig();
|
|
31040
31045
|
if (config2.kind === "apiKey") {
|
|
@@ -31130,14 +31135,17 @@ function getRetryBaseDelayMs() {
|
|
|
31130
31135
|
}
|
|
31131
31136
|
async function withConcurrencyLimit(fn) {
|
|
31132
31137
|
const limit = getConcurrencyLimit();
|
|
31133
|
-
if (limit === 0) return fn();
|
|
31138
|
+
if (limit === 0) return fn(0);
|
|
31139
|
+
let queuedForMs = 0;
|
|
31134
31140
|
if (inFlight >= limit) {
|
|
31141
|
+
const queueStartedAt = Date.now();
|
|
31135
31142
|
await new Promise((resolve) => concurrencyQueue.push(resolve));
|
|
31143
|
+
queuedForMs = Date.now() - queueStartedAt;
|
|
31136
31144
|
} else {
|
|
31137
31145
|
inFlight++;
|
|
31138
31146
|
}
|
|
31139
31147
|
try {
|
|
31140
|
-
return await fn();
|
|
31148
|
+
return await fn(queuedForMs);
|
|
31141
31149
|
} finally {
|
|
31142
31150
|
const next = concurrencyQueue.shift();
|
|
31143
31151
|
if (next) {
|
|
@@ -31188,6 +31196,15 @@ function describeTransportError(err, method, attemptTimeoutMs) {
|
|
|
31188
31196
|
}
|
|
31189
31197
|
return `${method} request failed: ${String(err)}`;
|
|
31190
31198
|
}
|
|
31199
|
+
function describeBudgetExhaustion(budgetMs, queuedForMs, lastTransportError) {
|
|
31200
|
+
if (lastTransportError) {
|
|
31201
|
+
return `${lastTransportError}; request budget of ${budgetMs}ms exhausted before next attempt could begin.`;
|
|
31202
|
+
}
|
|
31203
|
+
if (queuedForMs > 0) {
|
|
31204
|
+
return `Request budget of ${budgetMs}ms exhausted before attempt could begin: ${queuedForMs}ms of it was spent waiting for a free slot under TAILSCALE_MAX_CONCURRENT, so no request was ever sent. Raise TAILSCALE_MAX_CONCURRENT or TAILSCALE_REQUEST_BUDGET_MS -- this is queueing, not a network fault.`;
|
|
31205
|
+
}
|
|
31206
|
+
return `Request budget of ${budgetMs}ms exhausted before attempt could begin.`;
|
|
31207
|
+
}
|
|
31191
31208
|
async function apiRequest(method, path, body, options) {
|
|
31192
31209
|
const headers = {};
|
|
31193
31210
|
if (options?.accept) {
|
|
@@ -31217,7 +31234,7 @@ async function apiRequest(method, path, body, options) {
|
|
|
31217
31234
|
debugLog(`${method} ${url2}`);
|
|
31218
31235
|
const isRetryable = RETRYABLE_METHODS.has(method.toUpperCase());
|
|
31219
31236
|
const requestBudgetMs = getRequestBudgetMs();
|
|
31220
|
-
return withConcurrencyLimit(async () => {
|
|
31237
|
+
return withConcurrencyLimit(async (queuedForMs) => {
|
|
31221
31238
|
headers.Authorization = await getAuthHeader();
|
|
31222
31239
|
let res;
|
|
31223
31240
|
let lastTransportError;
|
|
@@ -31227,7 +31244,7 @@ async function apiRequest(method, path, body, options) {
|
|
|
31227
31244
|
return {
|
|
31228
31245
|
ok: false,
|
|
31229
31246
|
status: 0,
|
|
31230
|
-
error:
|
|
31247
|
+
error: describeBudgetExhaustion(requestBudgetMs, queuedForMs, lastTransportError)
|
|
31231
31248
|
};
|
|
31232
31249
|
}
|
|
31233
31250
|
const attemptTimeoutMs = Math.min(REQUEST_TIMEOUT_MS, remaining);
|
|
@@ -31268,6 +31285,9 @@ async function apiRequest(method, path, body, options) {
|
|
|
31268
31285
|
const etag = response.headers.get("etag") || void 0;
|
|
31269
31286
|
const elapsed = Date.now() - startedAt;
|
|
31270
31287
|
debugLog(` <- ${response.status} (${elapsed}ms)`);
|
|
31288
|
+
if (response.status === 401) {
|
|
31289
|
+
invalidateOAuthTokenOnUnauthorized(headers.Authorization);
|
|
31290
|
+
}
|
|
31271
31291
|
try {
|
|
31272
31292
|
if (options?.acceptRaw) {
|
|
31273
31293
|
const rawBody = await response.text();
|
|
@@ -31442,6 +31462,18 @@ function filterTools(groups, options) {
|
|
|
31442
31462
|
}
|
|
31443
31463
|
|
|
31444
31464
|
// src/tools/acl.ts
|
|
31465
|
+
var ETAG_FOOTER_MARKER = "// ETag: ";
|
|
31466
|
+
function stripEtagFooter(body) {
|
|
31467
|
+
const lines = body.split("\n");
|
|
31468
|
+
let cut = lines.length;
|
|
31469
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
31470
|
+
const line = lines[i].trim();
|
|
31471
|
+
if (line === "") continue;
|
|
31472
|
+
if (!line.startsWith("//")) break;
|
|
31473
|
+
if (line.startsWith(ETAG_FOOTER_MARKER)) cut = i;
|
|
31474
|
+
}
|
|
31475
|
+
return lines.slice(0, cut).join("\n");
|
|
31476
|
+
}
|
|
31445
31477
|
var aclTools = [
|
|
31446
31478
|
{
|
|
31447
31479
|
name: "tailscale_get_acl",
|
|
@@ -31462,12 +31494,12 @@ var aclTools = [
|
|
|
31462
31494
|
if (res.ok && res.etag) {
|
|
31463
31495
|
const footer = [
|
|
31464
31496
|
"",
|
|
31465
|
-
|
|
31497
|
+
`${ETAG_FOOTER_MARKER}${res.etag}`,
|
|
31466
31498
|
"// Pass this ETag to tailscale_update_acl when updating the policy.",
|
|
31467
31499
|
"// (HuJSON treats // as a comment \u2014 safe to leave in or strip before re-submitting.)",
|
|
31468
31500
|
""
|
|
31469
31501
|
].join("\n");
|
|
31470
|
-
return { ...res, rawBody: `${res.rawBody ?? ""}${footer}` };
|
|
31502
|
+
return { ...res, rawBody: `${stripEtagFooter(res.rawBody ?? "")}${footer}` };
|
|
31471
31503
|
}
|
|
31472
31504
|
return res;
|
|
31473
31505
|
}
|
|
@@ -31478,7 +31510,10 @@ var aclTools = [
|
|
|
31478
31510
|
annotations: {
|
|
31479
31511
|
title: "Update ACL policy",
|
|
31480
31512
|
readOnlyHint: false,
|
|
31481
|
-
|
|
31513
|
+
// Overwrites the whole policy file in one call, and a bad push can lock
|
|
31514
|
+
// every device out of the tailnet -- the widest blast radius of any write
|
|
31515
|
+
// here, so clients must gate it rather than auto-approve it.
|
|
31516
|
+
destructiveHint: true,
|
|
31482
31517
|
idempotentHint: true,
|
|
31483
31518
|
openWorldHint: true
|
|
31484
31519
|
},
|
|
@@ -31486,8 +31521,16 @@ var aclTools = [
|
|
|
31486
31521
|
policy: external_exports.string().describe(
|
|
31487
31522
|
"The full ACL policy text. Preserve existing formatting, comments, and structure. Only modify the specific parts that need to change."
|
|
31488
31523
|
),
|
|
31489
|
-
etag: external_exports.string().describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
|
|
31524
|
+
etag: external_exports.string().trim().min(1, "etag must not be empty -- an empty ETag would send this overwrite with no concurrency guard.").describe("The ETag from tailscale_get_acl. Required to prevent concurrent edit conflicts.")
|
|
31490
31525
|
}),
|
|
31526
|
+
// `.trim().min(1)`, not a bare `z.string()`: apiRequest sets If-Match behind
|
|
31527
|
+
// `if (options?.ifMatch)`, so an empty etag is falsy there and the header is
|
|
31528
|
+
// omitted entirely -- the write then overwrites a concurrent admin edit instead
|
|
31529
|
+
// of coming back 412, on the widest-blast-radius write in the package, with
|
|
31530
|
+
// no diagnostic anywhere. `.trim()` is load-bearing for the same reason it is
|
|
31531
|
+
// on tailnets.ts's ids: a bare `.min(1)` accepts " ", which is truthy, so the
|
|
31532
|
+
// header goes out carrying a precondition that cannot match any real ETag --
|
|
31533
|
+
// a confusing 412 instead of a local validation error naming the field.
|
|
31491
31534
|
handler: async (input) => {
|
|
31492
31535
|
return apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31493
31536
|
rawBody: input.policy,
|
|
@@ -31805,7 +31848,9 @@ var deviceTools = [
|
|
|
31805
31848
|
annotations: {
|
|
31806
31849
|
title: "Set device routes",
|
|
31807
31850
|
readOnlyHint: false,
|
|
31808
|
-
|
|
31851
|
+
// Replace-all: the routes array is the new enabled set, so `[]` silently
|
|
31852
|
+
// withdraws every subnet the device currently routes.
|
|
31853
|
+
destructiveHint: true,
|
|
31809
31854
|
idempotentHint: true,
|
|
31810
31855
|
openWorldHint: true
|
|
31811
31856
|
},
|
|
@@ -31890,7 +31935,9 @@ var deviceTools = [
|
|
|
31890
31935
|
annotations: {
|
|
31891
31936
|
title: "Set device tags",
|
|
31892
31937
|
readOnlyHint: false,
|
|
31893
|
-
|
|
31938
|
+
// Replace-all: `[]` strips every ACL tag, which can drop the device out of
|
|
31939
|
+
// the policy rules that grant it access.
|
|
31940
|
+
destructiveHint: true,
|
|
31894
31941
|
idempotentHint: true,
|
|
31895
31942
|
openWorldHint: true
|
|
31896
31943
|
},
|
|
@@ -31969,7 +32016,7 @@ var deviceTools = [
|
|
|
31969
32016
|
const failed = {};
|
|
31970
32017
|
for (const { deviceId, res } of results) {
|
|
31971
32018
|
if (res.ok) succeeded.push(deviceId);
|
|
31972
|
-
else failed[deviceId] = { status: res.status, error: res.error
|
|
32019
|
+
else failed[deviceId] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
31973
32020
|
}
|
|
31974
32021
|
const failedCount = Object.keys(failed).length;
|
|
31975
32022
|
if (failedCount === unique.length) {
|
|
@@ -32053,7 +32100,9 @@ var dnsTools = [
|
|
|
32053
32100
|
annotations: {
|
|
32054
32101
|
title: "Set nameservers",
|
|
32055
32102
|
readOnlyHint: false,
|
|
32056
|
-
|
|
32103
|
+
// Replace-all: `[]` clears tailnet DNS resolution rather than leaving the
|
|
32104
|
+
// current nameservers in place.
|
|
32105
|
+
destructiveHint: true,
|
|
32057
32106
|
idempotentHint: true,
|
|
32058
32107
|
openWorldHint: true
|
|
32059
32108
|
},
|
|
@@ -32085,7 +32134,8 @@ var dnsTools = [
|
|
|
32085
32134
|
annotations: {
|
|
32086
32135
|
title: "Set DNS search paths",
|
|
32087
32136
|
readOnlyHint: false,
|
|
32088
|
-
|
|
32137
|
+
// Replace-all: `[]` clears every configured search domain.
|
|
32138
|
+
destructiveHint: true,
|
|
32089
32139
|
idempotentHint: true,
|
|
32090
32140
|
openWorldHint: true
|
|
32091
32141
|
},
|
|
@@ -32119,7 +32169,9 @@ var dnsTools = [
|
|
|
32119
32169
|
annotations: {
|
|
32120
32170
|
title: "Set split DNS",
|
|
32121
32171
|
readOnlyHint: false,
|
|
32122
|
-
|
|
32172
|
+
// Replace-all (PUT): every domain absent from the map is dropped, so `{}`
|
|
32173
|
+
// clears the whole split DNS config. The PATCH sibling below merges instead.
|
|
32174
|
+
destructiveHint: true,
|
|
32123
32175
|
idempotentHint: true,
|
|
32124
32176
|
openWorldHint: true
|
|
32125
32177
|
},
|
|
@@ -32206,7 +32258,9 @@ var dnsTools = [
|
|
|
32206
32258
|
annotations: {
|
|
32207
32259
|
title: "Set DNS configuration (unified)",
|
|
32208
32260
|
readOnlyHint: false,
|
|
32209
|
-
|
|
32261
|
+
// Replace-all across every DNS setting at once -- a superset of the wipe
|
|
32262
|
+
// the individual setters above can do.
|
|
32263
|
+
destructiveHint: true,
|
|
32210
32264
|
idempotentHint: true,
|
|
32211
32265
|
openWorldHint: true
|
|
32212
32266
|
},
|
|
@@ -32663,6 +32717,48 @@ var keyTools = [
|
|
|
32663
32717
|
handler: async (input) => {
|
|
32664
32718
|
return apiGet(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
|
|
32665
32719
|
}
|
|
32720
|
+
},
|
|
32721
|
+
{
|
|
32722
|
+
name: "tailscale_list_oauth_apps",
|
|
32723
|
+
description: "List the OAuth Apps registered in your tailnet (Tailscale alpha). Returns an `oauthApps` array describing each app (id, name, redirect URIs, scopes). Client secrets are NOT included -- a secret is only returned once, by tailscale_create_oauth_app at creation time. This is how you recover the id of an app you did not record; pass that id to tailscale_delete_oauth_app to revoke it.",
|
|
32724
|
+
annotations: {
|
|
32725
|
+
title: "List OAuth apps",
|
|
32726
|
+
readOnlyHint: true,
|
|
32727
|
+
destructiveHint: false,
|
|
32728
|
+
idempotentHint: true,
|
|
32729
|
+
openWorldHint: true
|
|
32730
|
+
},
|
|
32731
|
+
// No limit/cursor: the live endpoint takes no parameters and returns the
|
|
32732
|
+
// whole collection. Advertising pagination the API ignores would let a
|
|
32733
|
+
// caller believe it had paged through the list when it had not.
|
|
32734
|
+
inputSchema: external_exports.object({}),
|
|
32735
|
+
handler: async () => {
|
|
32736
|
+
return apiGet(`/tailnet/${getTailnet()}/oauth-apps`);
|
|
32737
|
+
}
|
|
32738
|
+
},
|
|
32739
|
+
{
|
|
32740
|
+
name: "tailscale_delete_oauth_app",
|
|
32741
|
+
description: "Delete an OAuth App (Tailscale alpha). This is irreversible: the app's client secret stops working immediately and any integration using it loses its device-enrollment path, so no further device can be authorized through it. Devices already enrolled stay in the tailnet, exactly as they do when the auth key that added them is deleted. Use tailscale_list_oauth_apps to find the id.",
|
|
32742
|
+
annotations: {
|
|
32743
|
+
title: "Delete OAuth app",
|
|
32744
|
+
readOnlyHint: false,
|
|
32745
|
+
destructiveHint: true,
|
|
32746
|
+
idempotentHint: true,
|
|
32747
|
+
openWorldHint: true
|
|
32748
|
+
},
|
|
32749
|
+
inputSchema: external_exports.object({
|
|
32750
|
+
// `.trim().min(1)` rather than the bare `.min(1)` on
|
|
32751
|
+
// tailscale_get_oauth_app's appId, for the reason tailnets.ts spells out
|
|
32752
|
+
// on tailscale_delete_tailnet: a bare min(1) accepts " ", which encPath
|
|
32753
|
+
// then sends as the literal segment "%20". On a read that costs a wasted
|
|
32754
|
+
// round-trip; on an irreversible revoke it returns a 404 that reads like
|
|
32755
|
+
// the app is already gone. Trimming at the schema makes it a validation
|
|
32756
|
+
// error instead.
|
|
32757
|
+
appId: external_exports.string().trim().min(1).describe("The OAuth app ID to delete (see tailscale_list_oauth_apps)")
|
|
32758
|
+
}),
|
|
32759
|
+
handler: async (input) => {
|
|
32760
|
+
return apiDelete(`/tailnet/${getTailnet()}/oauth-apps/${encPath(input.appId)}`);
|
|
32761
|
+
}
|
|
32666
32762
|
}
|
|
32667
32763
|
];
|
|
32668
32764
|
|
|
@@ -32866,8 +32962,8 @@ var logStreamingTools = [
|
|
|
32866
32962
|
apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
|
|
32867
32963
|
]);
|
|
32868
32964
|
const errors = {};
|
|
32869
|
-
if (!configuration.ok) errors.configuration = configuration.error
|
|
32870
|
-
if (!network.ok) errors.network = network.error
|
|
32965
|
+
if (!configuration.ok) errors.configuration = configuration.error || `HTTP ${configuration.status}`;
|
|
32966
|
+
if (!network.ok) errors.network = network.error || `HTTP ${network.status}`;
|
|
32871
32967
|
if (!configuration.ok && !network.ok) {
|
|
32872
32968
|
return {
|
|
32873
32969
|
ok: false,
|
|
@@ -33353,8 +33449,8 @@ function composeTailnetStatusData(devicesRes, settingsRes, extras = {}) {
|
|
|
33353
33449
|
settings: settingsRes.ok ? settingsRes.data : null
|
|
33354
33450
|
};
|
|
33355
33451
|
const errors = {};
|
|
33356
|
-
if (!devicesRes.ok) errors.devices = devicesRes.error
|
|
33357
|
-
if (!settingsRes.ok) errors.settings = settingsRes.error
|
|
33452
|
+
if (!devicesRes.ok) errors.devices = devicesRes.error || `HTTP ${devicesRes.status}`;
|
|
33453
|
+
if (!settingsRes.ok) errors.settings = settingsRes.error || `HTTP ${settingsRes.status}`;
|
|
33358
33454
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
33359
33455
|
return data;
|
|
33360
33456
|
}
|
|
@@ -33486,7 +33582,7 @@ var tailnetTools = [
|
|
|
33486
33582
|
const failed = {};
|
|
33487
33583
|
for (const { contactType, res } of results) {
|
|
33488
33584
|
if (res.ok) applied[contactType] = res.data;
|
|
33489
|
-
else failed[contactType] = { status: res.status, error: res.error
|
|
33585
|
+
else failed[contactType] = { status: res.status, error: res.error || `HTTP ${res.status}` };
|
|
33490
33586
|
}
|
|
33491
33587
|
const hasFailed = Object.keys(failed).length > 0;
|
|
33492
33588
|
const hasApplied = Object.keys(applied).length > 0;
|
|
@@ -33573,7 +33669,7 @@ var tailnetsTools = [
|
|
|
33573
33669
|
},
|
|
33574
33670
|
{
|
|
33575
33671
|
name: "tailscale_delete_tailnet",
|
|
33576
|
-
description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
|
|
33672
|
+
description: "Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.\n\nBy default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass `tailnet` to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass `confirmTailnet` matching the effective target exactly; the call is refused locally otherwise. That check is a typo guard, not an authorization gate: when you also pass `tailnet` you are supplying both halves of the comparison, so it proves only that they agree -- it is a genuine second look only on the omit-`tailnet` path, where the value has to match the operator's environment. Restricting who may delete at all is TAILSCALE_READONLY / TAILSCALE_TOOLS, which drop this tool from the server entirely. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.",
|
|
33577
33673
|
annotations: {
|
|
33578
33674
|
title: "Delete tailnet",
|
|
33579
33675
|
readOnlyHint: false,
|
|
@@ -33586,7 +33682,7 @@ var tailnetsTools = [
|
|
|
33586
33682
|
"Tailnet to delete (e.g. an id from tailscale_list_org_tailnets). Omit to target the configured tailnet. Requires credentials scoped to reach it."
|
|
33587
33683
|
),
|
|
33588
33684
|
confirmTailnet: external_exports.string().trim().min(1).describe(
|
|
33589
|
-
"Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A
|
|
33685
|
+
"Must exactly match the effective target -- `tailnet` when given, otherwise the configured tailnet (TAILSCALE_TAILNET / TAILSCALE_OAUTH_TAILNET). A typo guard, not an authorization gate: on the explicit-`tailnet` path the caller writes both halves of the comparison, so it proves only self-agreement. It is a real second look only when `tailnet` is omitted and the value has to match the operator's environment."
|
|
33590
33686
|
)
|
|
33591
33687
|
}),
|
|
33592
33688
|
// `.trim().min(1)` on both fields, not just `.min(1)`: a bare min(1) accepts
|
|
@@ -33604,8 +33700,9 @@ var tailnetsTools = [
|
|
|
33604
33700
|
);
|
|
33605
33701
|
}
|
|
33606
33702
|
if (input.confirmTailnet !== target) {
|
|
33703
|
+
const source = input.tailnet?.trim() ? "tailnet you named" : "configured tailnet";
|
|
33607
33704
|
throw new Error(
|
|
33608
|
-
`confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the
|
|
33705
|
+
`confirmTailnet ${JSON.stringify(input.confirmTailnet)} does not match the ${source} ${JSON.stringify(target)}. Refusing to delete.`
|
|
33609
33706
|
);
|
|
33610
33707
|
}
|
|
33611
33708
|
return apiDelete(`/tailnet/${encPath(target)}`);
|
|
@@ -34011,7 +34108,7 @@ async function tailnetStatusResource(uri) {
|
|
|
34011
34108
|
}
|
|
34012
34109
|
async function tailnetDevicesResource(uri) {
|
|
34013
34110
|
const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
|
|
34014
|
-
const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error
|
|
34111
|
+
const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error || `HTTP ${res.status}` }, null, 2);
|
|
34015
34112
|
return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
|
|
34016
34113
|
}
|
|
34017
34114
|
async function tailnetAclResource(uri) {
|
|
@@ -34019,7 +34116,7 @@ async function tailnetAclResource(uri) {
|
|
|
34019
34116
|
if (res.ok) {
|
|
34020
34117
|
return { contents: [{ uri: uri.href, text: res.rawBody ?? "", mimeType: "application/hujson" }] };
|
|
34021
34118
|
}
|
|
34022
|
-
const lines = `Error: ${res.error
|
|
34119
|
+
const lines = `Error: ${res.error || `HTTP ${res.status}`}`.split("\n");
|
|
34023
34120
|
const text = `${lines.map((l) => `// ${l}`).join("\n")}
|
|
34024
34121
|
`;
|
|
34025
34122
|
return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
|
|
@@ -34038,16 +34135,16 @@ async function tailnetDnsResource(uri) {
|
|
|
34038
34135
|
preferences: preferences.ok ? preferences.data : null
|
|
34039
34136
|
};
|
|
34040
34137
|
const errors = {};
|
|
34041
|
-
if (!nameservers.ok) errors.nameservers = nameservers.error
|
|
34042
|
-
if (!searchPaths.ok) errors.searchPaths = searchPaths.error
|
|
34043
|
-
if (!splitDns.ok) errors.splitDns = splitDns.error
|
|
34044
|
-
if (!preferences.ok) errors.preferences = preferences.error
|
|
34138
|
+
if (!nameservers.ok) errors.nameservers = nameservers.error || `HTTP ${nameservers.status}`;
|
|
34139
|
+
if (!searchPaths.ok) errors.searchPaths = searchPaths.error || `HTTP ${searchPaths.status}`;
|
|
34140
|
+
if (!splitDns.ok) errors.splitDns = splitDns.error || `HTTP ${splitDns.status}`;
|
|
34141
|
+
if (!preferences.ok) errors.preferences = preferences.error || `HTTP ${preferences.status}`;
|
|
34045
34142
|
if (Object.keys(errors).length > 0) data.errors = errors;
|
|
34046
34143
|
return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
|
|
34047
34144
|
}
|
|
34048
34145
|
|
|
34049
34146
|
// src/index.ts
|
|
34050
|
-
var version2 = true ? "0.
|
|
34147
|
+
var version2 = true ? "0.18.0" : resolveVersionFallback();
|
|
34051
34148
|
var subcommand = process.argv[2];
|
|
34052
34149
|
var cliSubcommandHandled = false;
|
|
34053
34150
|
if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
@@ -34104,7 +34201,7 @@ if (!cliSubcommandHandled) {
|
|
|
34104
34201
|
}
|
|
34105
34202
|
if (unknownProfile) {
|
|
34106
34203
|
console.error(
|
|
34107
|
-
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles:
|
|
34204
|
+
`@yawlabs/tailscale-mcp: TAILSCALE_PROFILE="${unknownProfile}" is not a known profile. Valid profiles: ${Object.keys(PROFILES).join(", ")}. Falling back to no profile filter.`
|
|
34108
34205
|
);
|
|
34109
34206
|
}
|
|
34110
34207
|
const server = new McpServer({
|
|
@@ -34165,7 +34262,7 @@ if (!cliSubcommandHandled) {
|
|
|
34165
34262
|
const coreCount = profileCount(PROFILES.core);
|
|
34166
34263
|
const minimalCount = profileCount(PROFILES.minimal);
|
|
34167
34264
|
console.error(
|
|
34168
|
-
`@yawlabs/tailscale-mcp: tip
|
|
34265
|
+
`@yawlabs/tailscale-mcp: tip -- set TAILSCALE_PROFILE=core (${coreCount} tools) or =minimal (${minimalCount}) to load a smaller tool surface. See README.`
|
|
34169
34266
|
);
|
|
34170
34267
|
}
|
|
34171
34268
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/tailscale-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"mcpName": "io.github.YawLabs/tailscale-mcp",
|
|
5
5
|
"description": "Tailscale MCP server for managing your tailnet from AI assistants",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,10 +33,11 @@
|
|
|
33
33
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
34
34
|
"dev": "tsc --watch",
|
|
35
35
|
"start": "node dist/index.js",
|
|
36
|
-
"test": "npm run build && node --test \"dist/**/*.test.js\"",
|
|
36
|
+
"test": "npm run build && node --test-timeout=300000 --test \"dist/**/*.test.js\"",
|
|
37
37
|
"test:ci": "npm run test",
|
|
38
|
-
"
|
|
39
|
-
"lint
|
|
38
|
+
"test:coverage": "npm run build && node --test-timeout=300000 --experimental-test-coverage --test-coverage-exclude=\"**/*.test.js\" --test-coverage-exclude=\"**/dist/index.js\" --test \"dist/**/*.test.js\"",
|
|
39
|
+
"lint": "biome check src/ bin/ scripts/ build.mjs",
|
|
40
|
+
"lint:fix": "biome check --write src/ bin/ scripts/ build.mjs",
|
|
40
41
|
"check:oam": "oam check src/index.ts",
|
|
41
42
|
"build:binary": "node scripts/build-binary.mjs",
|
|
42
43
|
"build:binary:oam": "node scripts/build-binary-oam.mjs",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"zod": "^4.3.6"
|
|
60
61
|
},
|
|
61
62
|
"engines": {
|
|
62
|
-
"node": ">=20"
|
|
63
|
+
"node": ">=20.11.0"
|
|
63
64
|
},
|
|
64
65
|
"devEngines": {
|
|
65
66
|
"runtime": {
|