@yawlabs/tailscale-mcp 0.8.8 → 0.9.1

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.
Files changed (3) hide show
  1. package/README.md +17 -52
  2. package/dist/index.js +101 -297
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub stars](https://img.shields.io/github/stars/YawLabs/tailscale-mcp)](https://github.com/YawLabs/tailscale-mcp/stargazers)
6
6
  [![CI](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/ci.yml) [![Release](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml/badge.svg)](https://github.com/YawLabs/tailscale-mcp/actions/workflows/release.yml)
7
7
 
8
- **Ask your agent questions about your tailnet and have it act on the answers.** 99 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 736 unit tests and an opt-in live-tailnet integration suite.
8
+ **Ask your agent questions about your tailnet and have it act on the answers.** 88 tools + 4 resources covering the full [Tailscale v2 API](https://tailscale.com/api). Backed by 700+ 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
 
@@ -20,8 +20,8 @@ You could `curl` the Tailscale API. The point isn't replacing `curl` — it's le
20
20
  - **"Which devices haven't checked in for 30 days and have key expiry disabled?"** — lists devices, filters by `lastSeen`, filters by `keyExpiryDisabled`, returns a table. Three endpoints, one question.
21
21
  - **"Someone broke DNS at 2am — who changed what in the last 24 hours?"** — pulls the audit log, filters by DNS-related actors and endpoints, reads each change's before/after, summarizes in English.
22
22
  - **"Draft an ACL change that lets `tag:mobile` reach `tag:dashboard` but not `tag:db`, preserving my comments"** — reads the current HuJSON, proposes a minimal diff, validates it against the API, returns the diff for you to apply.
23
- - **"Show me the OIDC workload identity for our GitHub Actions and confirm its allowed subjects still match `repo:Acme/*`"** — fetches the workload identity, parses the JWT claim patterns, tells you whether the claim still matches your repo naming.
24
23
  - **"Rotate every auth key older than 90 days and print the new ones"** — iterates, creates new keys with matching tags, revokes the old ones.
24
+ - **"Create an OAuth client for our CI pipeline scoped to `devices:read` and `dns`"** — creates a trust credential via `tailscale_create_key` with `keyType=client`, returns the credentials once (save them immediately).
25
25
 
26
26
  A curl can do each step. The agent composes them. That's where the lift is, and that's what the tool surface is designed for — every read endpoint is first-class so the agent can synthesize, and every write endpoint is tagged `destructiveHint` or `idempotentHint` so your MCP client can gate mutations the way you configured it.
27
27
 
@@ -31,11 +31,11 @@ If all you need is one endpoint in a CI job, use `curl` — we even have a [CLI
31
31
 
32
32
  Reasonable question. Both have their place. Where this MCP is better:
33
33
 
34
- - **Full admin API coverage.** The `tailscale` CLI is scoped to the node it runs on. Admin concerns — ACLs, users, invites, webhooks, log streaming, workload identity, OAuth clients, posture — live in the v2 HTTP API. You'd be shelling out to `curl` anyway.
34
+ - **Full admin API coverage.** The `tailscale` CLI is scoped to the node it runs on. Admin concerns — ACLs, users, invites, webhooks, log streaming, posture integrations, auth keys, OAuth clients, and federated identities — live in the v2 HTTP API. You'd be shelling out to `curl` anyway.
35
35
  - **Typed tool surface, not string parsing.** Every tool has a Zod-validated input schema and a structured response. No brittle `tailscale status --json | jq` pipelines that break when the schema evolves.
36
36
  - **Cross-client, no user rewriting.** A Claude Code skill only loads in Claude Code. An MCP server works in Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, and anything else that speaks MCP. Version bumps ship through `npx` — users don't re-author their skill when Tailscale adds an endpoint.
37
37
  - **Safe-by-default writes.** Every tool declares `readOnlyHint` / `destructiveHint` / `idempotentHint` so clients can skip confirmation on reads and require it on mutations. A skill that shells out to the CLI can't express that.
38
- - **Real tests.** 736 unit tests covering every tool's input validation, API shape, and error handling. Plus an opt-in live-tailnet integration suite (`RUN_INTEGRATION_TESTS=1` + a tailnet API key) for shape-drift detection. Most skills are short markdown prompts without their own test layer — if the vendor changes output format, nothing catches it for you.
38
+ - **Real tests.** 700+ unit tests covering every tool's input validation, API shape, and error handling. Plus an opt-in live-tailnet integration suite (`RUN_INTEGRATION_TESTS=1` + a tailnet API key) for shape-drift detection. Most skills are short markdown prompts without their own test layer — if the vendor changes output format, nothing catches it for you.
39
39
 
40
40
  If you already have a skill that covers your 10% of Tailscale workflows, great — keep it. The MCP is for the other 90%.
41
41
 
@@ -43,7 +43,7 @@ If you already have a skill that covers your 10% of Tailscale workflows, great
43
43
 
44
44
  Fair critique from Reddit: a new repo claiming "actively maintained" with no visible tests is worth exactly zero trust. Here's what's actually verifiable:
45
45
 
46
- - **736 tests** (179 suites, `node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
46
+ - **700+ tests** (`node --test`) covering every tool's input validation, API shape, and error handling. Run `npm test` to see them pass locally.
47
47
  - **3 CI workflows** on GitHub Actions:
48
48
  - [`ci.yml`](.github/workflows/ci.yml) — lint + typecheck + build + unit tests on every push and PR.
49
49
  - [`integration.yml`](.github/workflows/integration.yml) — read-only live-API smoke tests against a real tailnet. Wired up with three triggers (nightly schedule, every tag push via `release.yml`, manual dispatch); skips gracefully when no test-tailnet secret is configured, so forks aren't blocked.
@@ -107,7 +107,7 @@ That's it. Now ask your agent:
107
107
 
108
108
  ## Too many tools? Subset them.
109
109
 
110
- 99 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:
110
+ 88 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:
111
111
 
112
112
  ### Option 1: `TAILSCALE_PROFILE` (preset, easiest)
113
113
 
@@ -122,7 +122,7 @@ That's it. Now ask your agent:
122
122
 
123
123
  - **`minimal`** (19 tools) — `status`, `devices`, `audit`. Observe the tailnet, read the audit log.
124
124
  - **`core`** (46 tools) — adds `acl`, `dns`, `keys`, `users`. The day-to-day admin surface.
125
- - **`full`** (99 tools, default) — everything. Same as omitting the env var.
125
+ - **`full`** (88 tools, default) — everything. Same as omitting the env var.
126
126
 
127
127
  ### Option 2: `TAILSCALE_TOOLS` (explicit group list)
128
128
 
@@ -137,7 +137,7 @@ That's it. Now ask your agent:
137
137
 
138
138
  Comma-separated group names. Overrides `TAILSCALE_PROFILE` when both are set — use this when the presets aren't quite right.
139
139
 
140
- Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `network-lock`, `posture`, `audit`, `invites`, `services`, `log-streaming`, `workload-identity`, `oauth-clients`.
140
+ Valid group names: `status`, `devices`, `acl`, `dns`, `keys`, `users`, `tailnet`, `webhooks`, `posture`, `audit`, `invites`, `services`, `log-streaming`.
141
141
 
142
142
  ### Option 3: `TAILSCALE_READONLY` (drop mutations)
143
143
 
@@ -158,7 +158,7 @@ Set to `1` or `true` to drop every tool without `readOnlyHint: true`. Stacks wit
158
158
  The server logs the active filter to stderr on startup:
159
159
 
160
160
  ```
161
- @yawlabs/tailscale-mcp v0.8.8 ready (19 tools, profile=core, readonly)
161
+ @yawlabs/tailscale-mcp v0.9.1 ready (19 tools, profile=core, readonly)
162
162
  ```
163
163
 
164
164
  If you don't set any filter, startup prints a tip pointing you at the profiles.
@@ -193,7 +193,7 @@ MCP Resources expose read-only data clients can browse without a tool call.
193
193
  | ACL Policy | `tailscale://tailnet/acl` | Full ACL policy (HuJSON preserved) |
194
194
  | DNS Config | `tailscale://tailnet/dns` | Nameservers, search paths, split DNS, MagicDNS |
195
195
 
196
- ## Tools (99)
196
+ ## Tools (88)
197
197
 
198
198
  <details>
199
199
  <summary><strong>Status</strong> (1 tool)</summary>
@@ -260,15 +260,15 @@ MCP Resources expose read-only data clients can browse without a tool call.
260
260
  </details>
261
261
 
262
262
  <details>
263
- <summary><strong>Auth Keys</strong> (5 tools)</summary>
263
+ <summary><strong>Keys / Trust Credentials</strong> (5 tools) — covers auth keys, OAuth clients, and federated identities</summary>
264
264
 
265
265
  | Tool | Description |
266
266
  |------|-------------|
267
- | `tailscale_list_keys` | List auth keys |
268
- | `tailscale_get_key` | Get details for an auth key |
269
- | `tailscale_create_key` | Create a new auth key |
270
- | `tailscale_delete_key` | Delete an auth key |
271
- | `tailscale_update_key` | Update an existing auth key |
267
+ | `tailscale_list_keys` | List keys (auth keys; pass `all=true` to include OAuth clients and federated identities) |
268
+ | `tailscale_get_key` | Get details for a key |
269
+ | `tailscale_create_key` | Create an auth key, OAuth client (`keyType=client`), or federated identity (`keyType=federated`) |
270
+ | `tailscale_delete_key` | Delete a key |
271
+ | `tailscale_update_key` | Update a key's description, scopes, tags, or federated claim settings |
272
272
 
273
273
  </details>
274
274
 
@@ -300,15 +300,6 @@ MCP Resources expose read-only data clients can browse without a tool call.
300
300
 
301
301
  </details>
302
302
 
303
- <details>
304
- <summary><strong>Network Lock</strong> (1 tool)</summary>
305
-
306
- | Tool | Description |
307
- |------|-------------|
308
- | `tailscale_get_network_lock_status` | Get tailnet lock status and trusted signing keys |
309
-
310
- </details>
311
-
312
303
  <details>
313
304
  <summary><strong>Webhooks</strong> (7 tools)</summary>
314
305
 
@@ -367,32 +358,6 @@ MCP Resources expose read-only data clients can browse without a tool call.
367
358
 
368
359
  </details>
369
360
 
370
- <details>
371
- <summary><strong>Workload Identity</strong> (5 tools)</summary>
372
-
373
- | Tool | Description |
374
- |------|-------------|
375
- | `tailscale_list_workload_identities` | List federated workload identity providers |
376
- | `tailscale_get_workload_identity` | Get a workload identity provider |
377
- | `tailscale_create_workload_identity` | Create an OIDC federation provider (GitHub Actions, GitLab CI, etc.) |
378
- | `tailscale_update_workload_identity` | Update a workload identity provider |
379
- | `tailscale_delete_workload_identity` | Delete a workload identity provider |
380
-
381
- </details>
382
-
383
- <details>
384
- <summary><strong>OAuth Clients</strong> (5 tools)</summary>
385
-
386
- | Tool | Description |
387
- |------|-------------|
388
- | `tailscale_list_oauth_clients` | List OAuth clients |
389
- | `tailscale_get_oauth_client` | Get an OAuth client |
390
- | `tailscale_create_oauth_client` | Create an OAuth client for programmatic API access |
391
- | `tailscale_update_oauth_client` | Update an OAuth client |
392
- | `tailscale_delete_oauth_client` | Delete an OAuth client |
393
-
394
- </details>
395
-
396
361
  <details>
397
362
  <summary><strong>Device Invites</strong> (6 tools)</summary>
398
363
 
@@ -462,7 +427,7 @@ npm install
462
427
  npm run lint # Biome check
463
428
  npm run lint:fix # Auto-fix
464
429
  npm run build # tsc + esbuild bundle
465
- npm test # node --test (736 tests)
430
+ npm test # node --test (full suite)
466
431
  ```
467
432
 
468
433
  For integration testing against your own tailnet: set `TAILSCALE_API_KEY` and run `node dist/index.js`.
package/dist/index.js CHANGED
@@ -30339,7 +30339,8 @@ function filterTools(groups, options) {
30339
30339
  unknownProfile2 = profileKey;
30340
30340
  }
30341
30341
  }
30342
- const explicitTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
30342
+ const parsedTools = options.tools ? options.tools.split(",").map((s) => s.trim()).filter(Boolean) : null;
30343
+ const explicitTools = parsedTools && parsedTools.length > 0 ? parsedTools : null;
30343
30344
  const effectiveGroups = explicitTools ?? profileGroups ?? null;
30344
30345
  const enabledGroups = effectiveGroups ? new Set(effectiveGroups) : null;
30345
30346
  const unknownGroups2 = enabledGroups ? [...enabledGroups].filter((g) => !validNames.has(g)) : [];
@@ -30470,7 +30471,8 @@ Pass this ETag to tailscale_update_acl when updating the policy.`
30470
30471
 
30471
30472
  // src/tools/audit.ts
30472
30473
  function assertRFC3339(value, label) {
30473
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
30474
+ const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
30475
+ if (!rfc3339.test(value) || Number.isNaN(Date.parse(value))) {
30474
30476
  throw new Error(`${label} must be a valid RFC3339 date-time (e.g. '2026-04-01T00:00:00Z'), got: '${value}'`);
30475
30477
  }
30476
30478
  }
@@ -30611,7 +30613,7 @@ var deviceTools = [
30611
30613
  title: "Delete device",
30612
30614
  readOnlyHint: false,
30613
30615
  destructiveHint: true,
30614
- idempotentHint: false,
30616
+ idempotentHint: true,
30615
30617
  openWorldHint: true
30616
30618
  },
30617
30619
  inputSchema: external_exports3.object({
@@ -30817,7 +30819,7 @@ var deviceTools = [
30817
30819
  },
30818
30820
  {
30819
30821
  name: "tailscale_batch_update_posture_attributes",
30820
- description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'.",
30822
+ description: "Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics \u2014 pass null as the attribute config to delete.",
30821
30823
  annotations: {
30822
30824
  title: "Batch update posture attributes",
30823
30825
  readOnlyHint: false,
@@ -30826,13 +30828,26 @@ var deviceTools = [
30826
30828
  openWorldHint: true
30827
30829
  },
30828
30830
  inputSchema: external_exports3.object({
30829
- attributes: external_exports3.record(external_exports3.string(), external_exports3.record(external_exports3.string(), external_exports3.unknown())).describe(
30830
- 'Map of device ID to attribute map (e.g. { "12345": { "custom:compliant": "true" }, "67890": { "custom:compliant": "false" } })'
30831
- )
30831
+ nodes: external_exports3.record(
30832
+ external_exports3.string(),
30833
+ external_exports3.record(
30834
+ external_exports3.string(),
30835
+ external_exports3.union([
30836
+ external_exports3.object({
30837
+ value: external_exports3.union([external_exports3.string(), external_exports3.number(), external_exports3.boolean()]),
30838
+ expiry: external_exports3.string().optional()
30839
+ }),
30840
+ external_exports3.null()
30841
+ ])
30842
+ )
30843
+ ).describe(
30844
+ 'Map of device ID to attribute config map (e.g. { "12345": { "custom:compliant": { "value": "true" } }, "67890": { "custom:compliant": { "value": false, "expiry": "2026-12-01T00:00:00Z" } } }). Pass null as the config to delete an attribute.'
30845
+ ),
30846
+ comment: external_exports3.string().optional().describe("Optional comment added to the audit log explaining why attributes are being set (max 200 chars)")
30832
30847
  }),
30833
30848
  handler: async (input) => {
30834
30849
  const invalidKeys = [];
30835
- for (const attrs of Object.values(input.attributes)) {
30850
+ for (const attrs of Object.values(input.nodes)) {
30836
30851
  for (const key of Object.keys(attrs)) {
30837
30852
  if (!key.startsWith("custom:")) invalidKeys.push(key);
30838
30853
  }
@@ -30842,7 +30857,9 @@ var deviceTools = [
30842
30857
  `All attribute keys must start with 'custom:' prefix. Invalid keys: ${[...new Set(invalidKeys)].join(", ")}`
30843
30858
  );
30844
30859
  }
30845
- return apiPatch(`/tailnet/${getTailnet()}/device-attributes`, input.attributes);
30860
+ const body = { nodes: input.nodes };
30861
+ if (input.comment !== void 0) body.comment = input.comment;
30862
+ return apiPatch(`/tailnet/${getTailnet()}/device-attributes`, body);
30846
30863
  }
30847
30864
  }
30848
30865
  ];
@@ -31267,16 +31284,16 @@ var keyTools = [
31267
31284
  },
31268
31285
  {
31269
31286
  name: "tailscale_get_key",
31270
- description: "Get details for a specific auth key.",
31287
+ description: "Get details for a specific key (auth key, OAuth client, or federated identity).",
31271
31288
  annotations: {
31272
- title: "Get auth key",
31289
+ title: "Get key",
31273
31290
  readOnlyHint: true,
31274
31291
  destructiveHint: false,
31275
31292
  idempotentHint: true,
31276
31293
  openWorldHint: true
31277
31294
  },
31278
31295
  inputSchema: external_exports3.object({
31279
- keyId: external_exports3.string().describe("The auth key ID")
31296
+ keyId: external_exports3.string().describe("The key ID (auth key, OAuth client, or federated identity)")
31280
31297
  }),
31281
31298
  handler: async (input) => {
31282
31299
  return apiGet(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -31359,16 +31376,16 @@ var keyTools = [
31359
31376
  },
31360
31377
  {
31361
31378
  name: "tailscale_delete_key",
31362
- description: "Delete an auth key. This is irreversible \u2014 devices already authenticated with this key are unaffected, but no new devices can use it.",
31379
+ description: "Delete a key (auth key, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately.",
31363
31380
  annotations: {
31364
- title: "Delete auth key",
31381
+ title: "Delete key",
31365
31382
  readOnlyHint: false,
31366
31383
  destructiveHint: true,
31367
31384
  idempotentHint: true,
31368
31385
  openWorldHint: true
31369
31386
  },
31370
31387
  inputSchema: external_exports3.object({
31371
- keyId: external_exports3.string().describe("The auth key ID to delete")
31388
+ keyId: external_exports3.string().describe("The key ID to delete (auth key, OAuth client, or federated identity)")
31372
31389
  }),
31373
31390
  handler: async (input) => {
31374
31391
  return apiDelete(`/tailnet/${getTailnet()}/keys/${encPath(input.keyId)}`);
@@ -31416,7 +31433,7 @@ var keyTools = [
31416
31433
  var logStreamingTools = [
31417
31434
  {
31418
31435
  name: "tailscale_list_log_stream_configs",
31419
- description: "List all log streaming configurations for your tailnet. Fetches both 'configuration' (audit) and 'network' (flow) log stream configs. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, S3, or GCS.",
31436
+ description: "List all log streaming configurations for your tailnet. Fetches both 'configuration' (audit) and 'network' (flow) log stream configs. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, or S3.",
31420
31437
  annotations: {
31421
31438
  title: "List log stream configs",
31422
31439
  readOnlyHint: true,
@@ -31430,14 +31447,22 @@ var logStreamingTools = [
31430
31447
  apiGet(`/tailnet/${getTailnet()}/logging/configuration/stream`),
31431
31448
  apiGet(`/tailnet/${getTailnet()}/logging/network/stream`)
31432
31449
  ]);
31433
- return {
31434
- ok: true,
31435
- status: 200,
31436
- data: {
31437
- configuration: configuration.ok ? configuration.data : { error: configuration.error },
31438
- network: network.ok ? network.data : { error: network.error }
31439
- }
31450
+ const errors = {};
31451
+ if (!configuration.ok) errors.configuration = configuration.error ?? `HTTP ${configuration.status}`;
31452
+ if (!network.ok) errors.network = network.error ?? `HTTP ${network.status}`;
31453
+ if (!configuration.ok && !network.ok) {
31454
+ return {
31455
+ ok: false,
31456
+ status: configuration.status || network.status || 500,
31457
+ error: `Both log streams failed: ${JSON.stringify(errors)}`
31458
+ };
31459
+ }
31460
+ const data = {
31461
+ configuration: configuration.ok ? configuration.data : null,
31462
+ network: network.ok ? network.data : null
31440
31463
  };
31464
+ if (Object.keys(errors).length > 0) data.errors = errors;
31465
+ return { ok: true, status: 200, data };
31441
31466
  }
31442
31467
  },
31443
31468
  {
@@ -31459,7 +31484,7 @@ var logStreamingTools = [
31459
31484
  },
31460
31485
  {
31461
31486
  name: "tailscale_set_log_stream_config",
31462
- description: "Set the log streaming configuration for a specific log type. Configures where logs are sent (e.g. Axiom, Datadog, Splunk, Elasticsearch, S3, GCS).",
31487
+ description: "Set the log streaming configuration for a specific log type. Configures where logs are sent (e.g. Axiom, Datadog, Splunk, Elasticsearch, S3).\n\nPer-destination required fields:\n- splunk / elastic / panther / cribl / datadog / axiom: url + token (user optional)\n- s3: s3Bucket + s3Region + s3AuthenticationType, plus either (s3AccessKeyId + s3SecretAccessKey) for 'accesskey' auth or s3RoleArn for 'rolearn' auth. Call tailscale_create_aws_external_id first when using 'rolearn'.",
31463
31488
  annotations: {
31464
31489
  title: "Set log stream config",
31465
31490
  readOnlyHint: false,
@@ -31470,9 +31495,22 @@ var logStreamingTools = [
31470
31495
  inputSchema: external_exports3.object({
31471
31496
  logType: external_exports3.enum(["configuration", "network"]).describe("The log type: 'configuration' for audit logs, 'network' for network flow logs"),
31472
31497
  destinationType: external_exports3.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
31473
- url: external_exports3.string().optional().describe("Destination URL (required for most destination types)"),
31498
+ url: external_exports3.string().optional().describe("Destination URL (required for non-s3 destinations)"),
31474
31499
  token: external_exports3.string().optional().describe("Authentication token or API key for the destination"),
31475
- user: external_exports3.string().optional().describe("Username for the destination (if required)")
31500
+ user: external_exports3.string().optional().describe("Username for the destination (if required)"),
31501
+ uploadPeriodMinutes: external_exports3.number().optional().describe("Minutes to wait between uploads (max 1440). Optional."),
31502
+ compressionFormat: external_exports3.enum(["zstd", "gzip", "none"]).optional().describe("Compression algorithm for log uploads. Defaults to 'none'."),
31503
+ s3Bucket: external_exports3.string().optional().describe("(s3 only) S3 bucket name. Required when destinationType is 's3'."),
31504
+ s3Region: external_exports3.string().optional().describe("(s3 only) AWS region of the S3 bucket. Required when destinationType is 's3'."),
31505
+ s3KeyPrefix: external_exports3.string().optional().describe("(s3 only) Optional prefix prepended to the auto-generated S3 object key."),
31506
+ s3AuthenticationType: external_exports3.enum(["accesskey", "rolearn"]).optional().describe(
31507
+ "(s3 only) Authentication mode. Required when destinationType is 's3'. Tailscale recommends 'rolearn'."
31508
+ ),
31509
+ s3AccessKeyId: external_exports3.string().optional().describe("(s3 only) AWS access key id. Required when s3AuthenticationType is 'accesskey'."),
31510
+ s3SecretAccessKey: external_exports3.string().optional().describe("(s3 only) AWS secret access key. Required when s3AuthenticationType is 'accesskey'."),
31511
+ s3RoleArn: external_exports3.string().optional().describe(
31512
+ "(s3 only) IAM role ARN that Tailscale will assume. Required when s3AuthenticationType is 'rolearn'."
31513
+ )
31476
31514
  }),
31477
31515
  handler: async (input) => {
31478
31516
  const { logType, ...body } = input;
@@ -31555,135 +31593,6 @@ var logStreamingTools = [
31555
31593
  }
31556
31594
  ];
31557
31595
 
31558
- // src/tools/network-lock.ts
31559
- var networkLockTools = [
31560
- {
31561
- name: "tailscale_get_network_lock_status",
31562
- description: "Get the tailnet lock (network lock) status, including whether it is enabled and the list of trusted signing keys.",
31563
- annotations: {
31564
- title: "Get network lock status",
31565
- readOnlyHint: true,
31566
- destructiveHint: false,
31567
- idempotentHint: true,
31568
- openWorldHint: true
31569
- },
31570
- inputSchema: external_exports3.object({}),
31571
- handler: async () => {
31572
- return apiGet(`/tailnet/${getTailnet()}/network-lock/status`);
31573
- }
31574
- }
31575
- ];
31576
-
31577
- // src/tools/oauth-clients.ts
31578
- var oauthClientTools = [
31579
- {
31580
- name: "tailscale_list_oauth_clients",
31581
- description: "List all OAuth clients configured for your tailnet.",
31582
- annotations: {
31583
- title: "List OAuth clients",
31584
- readOnlyHint: true,
31585
- destructiveHint: false,
31586
- idempotentHint: true,
31587
- openWorldHint: true
31588
- },
31589
- inputSchema: external_exports3.object({}),
31590
- handler: async () => {
31591
- return apiGet(`/tailnet/${getTailnet()}/oauth-clients`);
31592
- }
31593
- },
31594
- {
31595
- name: "tailscale_get_oauth_client",
31596
- description: "Get details for a specific OAuth client.",
31597
- annotations: {
31598
- title: "Get OAuth client",
31599
- readOnlyHint: true,
31600
- destructiveHint: false,
31601
- idempotentHint: true,
31602
- openWorldHint: true
31603
- },
31604
- inputSchema: external_exports3.object({
31605
- clientId: external_exports3.string().describe("The OAuth client ID")
31606
- }),
31607
- handler: async (input) => {
31608
- return apiGet(`/tailnet/${getTailnet()}/oauth-clients/${encPath(input.clientId)}`);
31609
- }
31610
- },
31611
- {
31612
- name: "tailscale_create_oauth_client",
31613
- description: "Create a new OAuth client for programmatic API access. Returns the client secret \u2014 save it immediately, as it cannot be retrieved again.",
31614
- annotations: {
31615
- title: "Create OAuth client",
31616
- readOnlyHint: false,
31617
- destructiveHint: false,
31618
- idempotentHint: false,
31619
- openWorldHint: true
31620
- },
31621
- inputSchema: external_exports3.object({
31622
- name: external_exports3.string().describe("A human-readable name for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)"),
31623
- scopes: external_exports3.array(external_exports3.string()).describe(
31624
- "OAuth scopes to grant (e.g. ['devices:read', 'dns', 'acl']). See Tailscale docs for available scopes."
31625
- ),
31626
- tags: external_exports3.array(external_exports3.string()).optional().describe("ACL tags to assign to the OAuth client"),
31627
- description: external_exports3.string().optional().describe("Description for this OAuth client (max 50 chars, alphanumeric/hyphens/spaces)")
31628
- }),
31629
- handler: async (input) => {
31630
- validateTags(input.tags);
31631
- const body = { ...input };
31632
- body.name = sanitizeDescription(input.name);
31633
- if (input.description !== void 0) body.description = sanitizeDescription(input.description);
31634
- return apiPost(`/tailnet/${getTailnet()}/oauth-clients`, body);
31635
- }
31636
- },
31637
- {
31638
- name: "tailscale_update_oauth_client",
31639
- description: "Update an OAuth client's name, description, or scopes.",
31640
- annotations: {
31641
- title: "Update OAuth client",
31642
- readOnlyHint: false,
31643
- destructiveHint: false,
31644
- idempotentHint: true,
31645
- openWorldHint: true
31646
- },
31647
- inputSchema: external_exports3.object({
31648
- clientId: external_exports3.string().describe("The OAuth client ID to update"),
31649
- name: external_exports3.string().optional().describe("Updated name"),
31650
- scopes: external_exports3.array(external_exports3.string()).optional().describe("Updated OAuth scopes"),
31651
- description: external_exports3.string().optional().describe("Updated description")
31652
- }),
31653
- handler: async (input) => {
31654
- const { clientId, ...body } = input;
31655
- const cleanBody = {};
31656
- for (const [key, value] of Object.entries(body)) {
31657
- if (value !== void 0) cleanBody[key] = value;
31658
- }
31659
- if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
31660
- if (cleanBody.description !== void 0)
31661
- cleanBody.description = sanitizeDescription(cleanBody.description);
31662
- if (Object.keys(cleanBody).length === 0) {
31663
- throw new Error("No fields to update. Provide at least one of: name, scopes, description.");
31664
- }
31665
- return apiPatch(`/tailnet/${getTailnet()}/oauth-clients/${encPath(clientId)}`, cleanBody);
31666
- }
31667
- },
31668
- {
31669
- name: "tailscale_delete_oauth_client",
31670
- description: "Delete an OAuth client. This is irreversible \u2014 any integrations using this client will lose access immediately.",
31671
- annotations: {
31672
- title: "Delete OAuth client",
31673
- readOnlyHint: false,
31674
- destructiveHint: true,
31675
- idempotentHint: true,
31676
- openWorldHint: true
31677
- },
31678
- inputSchema: external_exports3.object({
31679
- clientId: external_exports3.string().describe("The OAuth client ID to delete")
31680
- }),
31681
- handler: async (input) => {
31682
- return apiDelete(`/tailnet/${getTailnet()}/oauth-clients/${encPath(input.clientId)}`);
31683
- }
31684
- }
31685
- ];
31686
-
31687
31596
  // src/tools/posture.ts
31688
31597
  var postureTools = [
31689
31598
  {
@@ -31715,7 +31624,7 @@ var postureTools = [
31715
31624
  integrationId: external_exports3.string().describe("The posture integration ID")
31716
31625
  }),
31717
31626
  handler: async (input) => {
31718
- return apiGet(`/tailnet/${getTailnet()}/posture/integrations/${encPath(input.integrationId)}`);
31627
+ return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
31719
31628
  }
31720
31629
  },
31721
31630
  {
@@ -31729,20 +31638,24 @@ var postureTools = [
31729
31638
  openWorldHint: true
31730
31639
  },
31731
31640
  inputSchema: external_exports3.object({
31732
- provider: external_exports3.string().describe("The posture provider (e.g. 'crowdstrike', 'sentinelone', 'intune')"),
31733
- clientId: external_exports3.string().describe("The OAuth client ID for the provider"),
31734
- clientSecret: external_exports3.string().describe("The OAuth client secret for the provider"),
31735
- tenantId: external_exports3.string().optional().describe("The tenant ID (required for some providers)"),
31736
- cloudEnvironment: external_exports3.string().optional().describe("Cloud environment (e.g. 'us-1', 'eu-1')")
31641
+ provider: external_exports3.enum(["falcon", "intune", "jamfpro", "kandji", "kolide", "sentinelone"]).describe("The posture provider"),
31642
+ clientId: external_exports3.string().optional().describe(
31643
+ "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank)"
31644
+ ),
31645
+ clientSecret: external_exports3.string().describe("The secret (auth key, token, etc.) used to authenticate with the provider"),
31646
+ tenantId: external_exports3.string().optional().describe("Microsoft Intune directory (tenant) ID. Other providers leave blank."),
31647
+ cloudId: external_exports3.string().optional().describe(
31648
+ "Identifies which of the provider's clouds to integrate with. Falcon: us-1|us-2|eu-1|us-gov; Intune: global|us-gov; Jamf Pro/Kandji/Sentinel One: FQDN of your subdomain; Kolide: leave blank."
31649
+ )
31737
31650
  }),
31738
31651
  handler: async (input) => {
31739
31652
  const body = {
31740
31653
  provider: input.provider,
31741
- clientId: input.clientId,
31742
31654
  clientSecret: input.clientSecret
31743
31655
  };
31656
+ if (input.clientId !== void 0) body.clientId = input.clientId;
31744
31657
  if (input.tenantId !== void 0) body.tenantId = input.tenantId;
31745
- if (input.cloudEnvironment !== void 0) body.cloudEnvironment = input.cloudEnvironment;
31658
+ if (input.cloudId !== void 0) body.cloudId = input.cloudId;
31746
31659
  return apiPost(`/tailnet/${getTailnet()}/posture/integrations`, body);
31747
31660
  }
31748
31661
  },
@@ -31758,10 +31671,10 @@ var postureTools = [
31758
31671
  },
31759
31672
  inputSchema: external_exports3.object({
31760
31673
  integrationId: external_exports3.string().describe("The posture integration ID to update"),
31761
- clientId: external_exports3.string().optional().describe("Updated OAuth client ID for the provider"),
31762
- clientSecret: external_exports3.string().optional().describe("Updated OAuth client secret for the provider"),
31674
+ clientId: external_exports3.string().optional().describe("Updated client ID for the provider"),
31675
+ clientSecret: external_exports3.string().optional().describe("Updated client secret for the provider (omit to retain the existing secret)"),
31763
31676
  tenantId: external_exports3.string().optional().describe("Updated tenant ID"),
31764
- cloudEnvironment: external_exports3.string().optional().describe("Updated cloud environment (e.g. 'us-1', 'eu-1')")
31677
+ cloudId: external_exports3.string().optional().describe("Updated cloud identifier (e.g. 'us-1', 'global', or provider FQDN)")
31765
31678
  }),
31766
31679
  handler: async (input) => {
31767
31680
  const { integrationId, ...body } = input;
@@ -31770,11 +31683,9 @@ var postureTools = [
31770
31683
  if (value !== void 0) cleanBody[key] = value;
31771
31684
  }
31772
31685
  if (Object.keys(cleanBody).length === 0) {
31773
- throw new Error(
31774
- "No fields to update. Provide at least one of: clientId, clientSecret, tenantId, cloudEnvironment."
31775
- );
31686
+ throw new Error("No fields to update. Provide at least one of: clientId, clientSecret, tenantId, cloudId.");
31776
31687
  }
31777
- return apiPatch(`/tailnet/${getTailnet()}/posture/integrations/${encPath(integrationId)}`, cleanBody);
31688
+ return apiPatch(`/posture/integrations/${encPath(integrationId)}`, cleanBody);
31778
31689
  }
31779
31690
  },
31780
31691
  {
@@ -31791,7 +31702,7 @@ var postureTools = [
31791
31702
  integrationId: external_exports3.string().describe("The posture integration ID to delete")
31792
31703
  }),
31793
31704
  handler: async (input) => {
31794
- return apiDelete(`/tailnet/${getTailnet()}/posture/integrations/${encPath(input.integrationId)}`);
31705
+ return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
31795
31706
  }
31796
31707
  }
31797
31708
  ];
@@ -31871,7 +31782,7 @@ var serviceTools = [
31871
31782
  title: "Delete service",
31872
31783
  readOnlyHint: false,
31873
31784
  destructiveHint: true,
31874
- idempotentHint: false,
31785
+ idempotentHint: true,
31875
31786
  openWorldHint: true
31876
31787
  },
31877
31788
  inputSchema: external_exports3.object({
@@ -31960,21 +31871,20 @@ var statusTools = [
31960
31871
  apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
31961
31872
  apiGet(`/tailnet/${getTailnet()}/settings`)
31962
31873
  ]);
31963
- if (!devicesRes.ok) {
31874
+ if (!devicesRes.ok && !settingsRes.ok) {
31964
31875
  return devicesRes;
31965
31876
  }
31966
- const deviceCount = devicesRes.data?.devices?.length ?? 0;
31967
- return {
31968
- ok: true,
31969
- status: 200,
31970
- data: {
31971
- connected: true,
31972
- tailnet: getTailnet(),
31973
- deviceCount,
31974
- settings: settingsRes.ok ? settingsRes.data : void 0,
31975
- ...settingsRes.ok ? {} : { settingsError: settingsRes.error || "Failed to fetch tailnet settings" }
31976
- }
31877
+ const data = {
31878
+ connected: true,
31879
+ tailnet: getTailnet(),
31880
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : null,
31881
+ settings: settingsRes.ok ? settingsRes.data : null
31977
31882
  };
31883
+ const errors = {};
31884
+ if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
31885
+ if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
31886
+ if (Object.keys(errors).length > 0) data.errors = errors;
31887
+ return { ok: true, status: 200, data };
31978
31888
  }
31979
31889
  }
31980
31890
  ];
@@ -32068,15 +31978,16 @@ var tailnetTools = [
32068
31978
  if (value === void 0) continue;
32069
31979
  const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
32070
31980
  if (res.ok) applied[contactType] = res.data;
32071
- else failed[contactType] = res.error ?? `HTTP ${res.status}`;
31981
+ else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
32072
31982
  }
32073
31983
  const hasFailed = Object.keys(failed).length > 0;
32074
31984
  const hasApplied = Object.keys(applied).length > 0;
32075
31985
  if (hasFailed && !hasApplied) {
32076
- return { ok: false, status: 500, error: `Contact update failed: ${JSON.stringify(failed)}` };
31986
+ const first = Object.values(failed)[0];
31987
+ return { ok: false, status: first.status, error: `Contact update failed: ${JSON.stringify(failed)}` };
32077
31988
  }
32078
31989
  if (hasFailed) {
32079
- return { ok: true, status: 207, data: { applied, failed } };
31990
+ return { ok: true, status: 200, data: { applied, failed } };
32080
31991
  }
32081
31992
  return { ok: true, status: 200, data: applied };
32082
31993
  }
@@ -32217,7 +32128,7 @@ var userTools = [
32217
32128
  title: "Delete user",
32218
32129
  readOnlyHint: false,
32219
32130
  destructiveHint: true,
32220
- idempotentHint: false,
32131
+ idempotentHint: true,
32221
32132
  openWorldHint: true
32222
32133
  },
32223
32134
  inputSchema: external_exports3.object({
@@ -32382,112 +32293,8 @@ var webhookTools = [
32382
32293
  }
32383
32294
  ];
32384
32295
 
32385
- // src/tools/workload-identity.ts
32386
- var workloadIdentityTools = [
32387
- {
32388
- name: "tailscale_list_workload_identities",
32389
- description: "List all federated workload identity providers configured for your tailnet. Workload identities allow CI/CD pipelines and automated systems to authenticate using OIDC federation.",
32390
- annotations: {
32391
- title: "List workload identities",
32392
- readOnlyHint: true,
32393
- destructiveHint: false,
32394
- idempotentHint: true,
32395
- openWorldHint: true
32396
- },
32397
- inputSchema: external_exports3.object({}),
32398
- handler: async () => {
32399
- return apiGet(`/tailnet/${getTailnet()}/workload-identity/providers`);
32400
- }
32401
- },
32402
- {
32403
- name: "tailscale_get_workload_identity",
32404
- description: "Get details for a specific federated workload identity provider, including issuer URL, audience, and the subject patterns it accepts for OIDC token exchange.",
32405
- annotations: {
32406
- title: "Get workload identity",
32407
- readOnlyHint: true,
32408
- destructiveHint: false,
32409
- idempotentHint: true,
32410
- openWorldHint: true
32411
- },
32412
- inputSchema: external_exports3.object({
32413
- providerId: external_exports3.string().describe("The workload identity provider ID")
32414
- }),
32415
- handler: async (input) => {
32416
- return apiGet(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(input.providerId)}`);
32417
- }
32418
- },
32419
- {
32420
- name: "tailscale_create_workload_identity",
32421
- description: "Create a new workload identity provider for OIDC federation. Enables CI/CD systems (GitHub Actions, GitLab CI, etc.) to authenticate to your tailnet without static credentials.",
32422
- annotations: {
32423
- title: "Create workload identity",
32424
- readOnlyHint: false,
32425
- destructiveHint: false,
32426
- idempotentHint: false,
32427
- openWorldHint: true
32428
- },
32429
- inputSchema: external_exports3.object({
32430
- name: external_exports3.string().describe("A human-readable name for this provider (max 50 chars, alphanumeric/hyphens/spaces)"),
32431
- issuerUrl: external_exports3.string().describe("The OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com' for GitHub Actions)"),
32432
- audience: external_exports3.string().optional().describe("Expected audience claim in the OIDC token"),
32433
- claimMappings: external_exports3.record(external_exports3.string(), external_exports3.string()).optional().describe("Map of Tailscale attributes to OIDC token claims (e.g. { 'tag': 'repository' })")
32434
- }),
32435
- handler: async (input) => {
32436
- const body = { ...input };
32437
- body.name = sanitizeDescription(input.name);
32438
- return apiPost(`/tailnet/${getTailnet()}/workload-identity/providers`, body);
32439
- }
32440
- },
32441
- {
32442
- name: "tailscale_update_workload_identity",
32443
- description: "Update an existing workload identity provider's configuration.",
32444
- annotations: {
32445
- title: "Update workload identity",
32446
- readOnlyHint: false,
32447
- destructiveHint: false,
32448
- idempotentHint: true,
32449
- openWorldHint: true
32450
- },
32451
- inputSchema: external_exports3.object({
32452
- providerId: external_exports3.string().describe("The workload identity provider ID to update"),
32453
- name: external_exports3.string().optional().describe("Updated human-readable name"),
32454
- audience: external_exports3.string().optional().describe("Updated expected audience claim"),
32455
- claimMappings: external_exports3.record(external_exports3.string(), external_exports3.string()).optional().describe("Updated claim mappings")
32456
- }),
32457
- handler: async (input) => {
32458
- const { providerId, ...body } = input;
32459
- const cleanBody = {};
32460
- for (const [key, value] of Object.entries(body)) {
32461
- if (value !== void 0) cleanBody[key] = value;
32462
- }
32463
- if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
32464
- if (Object.keys(cleanBody).length === 0) {
32465
- throw new Error("No fields to update. Provide at least one of: name, audience, claimMappings.");
32466
- }
32467
- return apiPatch(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(providerId)}`, cleanBody);
32468
- }
32469
- },
32470
- {
32471
- name: "tailscale_delete_workload_identity",
32472
- description: "Delete a workload identity provider. This is irreversible \u2014 any CI/CD pipelines using this provider will lose access.",
32473
- annotations: {
32474
- title: "Delete workload identity",
32475
- readOnlyHint: false,
32476
- destructiveHint: true,
32477
- idempotentHint: true,
32478
- openWorldHint: true
32479
- },
32480
- inputSchema: external_exports3.object({
32481
- providerId: external_exports3.string().describe("The workload identity provider ID to delete")
32482
- }),
32483
- handler: async (input) => {
32484
- return apiDelete(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(input.providerId)}`);
32485
- }
32486
- }
32487
- ];
32488
-
32489
32296
  // src/index.ts
32490
- var version2 = true ? "0.8.8" : (await null).createRequire(import.meta.url)("../package.json").version;
32297
+ var version2 = true ? "0.9.1" : (await null).createRequire(import.meta.url)("../package.json").version;
32491
32298
  var subcommand = process.argv[2];
32492
32299
  if (subcommand === "deploy-acl") {
32493
32300
  const filePath = process.argv[3];
@@ -32513,14 +32320,11 @@ var toolGroups = {
32513
32320
  users: userTools,
32514
32321
  tailnet: tailnetTools,
32515
32322
  webhooks: webhookTools,
32516
- "network-lock": networkLockTools,
32517
32323
  posture: postureTools,
32518
32324
  audit: auditTools,
32519
32325
  invites: inviteTools,
32520
32326
  services: serviceTools,
32521
- "log-streaming": logStreamingTools,
32522
- "workload-identity": workloadIdentityTools,
32523
- "oauth-clients": oauthClientTools
32327
+ "log-streaming": logStreamingTools
32524
32328
  };
32525
32329
  var {
32526
32330
  tools: allTools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.8.8",
3
+ "version": "0.9.1",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",