@yawlabs/tailscale-mcp 0.17.1 → 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 +11 -9
- package/bin/tailscale-mcp.mjs +52 -7
- package/dist/index.js +129 -32
- package/package.json +4 -3
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
|
|
|
@@ -536,13 +538,13 @@ This shows a read-only banner in the Tailscale Admin Console pointing to your re
|
|
|
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
|
*/
|
|
@@ -156,7 +158,16 @@ function atLeast(v, min) {
|
|
|
156
158
|
function sandboxFlags() {
|
|
157
159
|
if (process.env.TAILSCALE_MCP_SANDBOX !== "1") return [];
|
|
158
160
|
|
|
159
|
-
|
|
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"];
|
|
160
171
|
|
|
161
172
|
const netFlag = `--allow-net=${hosts.join(",")}`;
|
|
162
173
|
|
|
@@ -166,7 +177,25 @@ function sandboxFlags() {
|
|
|
166
177
|
// meant the local-CLI tool group silently failed to register under the
|
|
167
178
|
// sandbox even though --allow-child-process is granted below precisely so
|
|
168
179
|
// those tools can shell out.
|
|
169
|
-
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
|
+
];
|
|
170
199
|
|
|
171
200
|
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
172
201
|
flags.push("--allow-child-process");
|
|
@@ -231,7 +260,23 @@ async function runInProcess() {
|
|
|
231
260
|
await import(SERVER_URL.href);
|
|
232
261
|
}
|
|
233
262
|
|
|
234
|
-
|
|
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
|
+
}
|
|
235
280
|
|
|
236
281
|
if (mode === "node") {
|
|
237
282
|
await runInProcess();
|
|
@@ -259,7 +304,8 @@ if (mode === "node") {
|
|
|
259
304
|
const { writeSync } = await import("node:fs");
|
|
260
305
|
writeSync(
|
|
261
306
|
2,
|
|
262
|
-
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
307
|
+
"tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but no runnable oam binary was found.\n" +
|
|
308
|
+
shimNote +
|
|
263
309
|
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use TAILSCALE_MCP_RUNTIME=node.\n",
|
|
264
310
|
);
|
|
265
311
|
process.exit(1);
|
|
@@ -280,7 +326,7 @@ if (mode === "node") {
|
|
|
280
326
|
? `${oam} is oam ${found.join(".")}, older than ${min}`
|
|
281
327
|
: `${oam} could not be run, or did not report a version this launcher understands`;
|
|
282
328
|
const remedy = found
|
|
283
|
-
? "Run
|
|
329
|
+
? "Run `oam self-update`, or use TAILSCALE_MCP_RUNTIME=node.\n"
|
|
284
330
|
: "Check that it is an executable oam binary for this platform, or use TAILSCALE_MCP_RUNTIME=node.\n";
|
|
285
331
|
if (mode === "oam") {
|
|
286
332
|
await errSync(`tailscale-mcp: TAILSCALE_MCP_RUNTIME=oam but ${detail}.\n${remedy}`);
|
|
@@ -337,7 +383,6 @@ if (mode === "node") {
|
|
|
337
383
|
}
|
|
338
384
|
|
|
339
385
|
if (child) {
|
|
340
|
-
|
|
341
386
|
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
342
387
|
// wrong arch, permission), fall back rather than failing the whole server.
|
|
343
388
|
// `spawned` prevents falling back AFTER the child started, which would
|
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",
|
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
"start": "node dist/index.js",
|
|
36
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",
|