@yawlabs/tailscale-mcp 0.8.7 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +17 -52
  2. package/dist/index.js +111 -301
  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 735 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.** 735 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
- - **735 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.7 ready (19 tools, profile=core, readonly)
161
+ @yawlabs/tailscale-mcp v0.9.0 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 (735 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
  ];
@@ -31376,7 +31393,7 @@ var keyTools = [
31376
31393
  },
31377
31394
  {
31378
31395
  name: "tailscale_update_key",
31379
- description: "Update an existing key's description or configuration. For OAuth clients and federated identities, scopes, tags, and identity fields can also be updated. Auth keys only support description updates.",
31396
+ description: "Update an existing key. Supported fields depend on the key type: all key types accept 'description'; OAuth clients and federated identities additionally accept 'scopes' and 'tags'; federated identities additionally accept 'issuer', 'subject', 'audience', and 'customClaimRules'. For auth keys, pass only 'description' \u2014 the Tailscale API will reject other fields.",
31380
31397
  annotations: {
31381
31398
  title: "Update key",
31382
31399
  readOnlyHint: false,
@@ -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
  {
@@ -31469,7 +31494,7 @@ var logStreamingTools = [
31469
31494
  },
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
- destinationType: external_exports3.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3"]).describe("The log streaming destination type"),
31497
+ destinationType: external_exports3.enum(["splunk", "elastic", "panther", "cribl", "datadog", "axiom", "s3", "gcs"]).describe("The log streaming destination type"),
31473
31498
  url: external_exports3.string().optional().describe("Destination URL (required for most destination types)"),
31474
31499
  token: external_exports3.string().optional().describe("Authentication token or API key for the destination"),
31475
31500
  user: external_exports3.string().optional().describe("Username for the destination (if required)")
@@ -31555,135 +31580,6 @@ var logStreamingTools = [
31555
31580
  }
31556
31581
  ];
31557
31582
 
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
31583
  // src/tools/posture.ts
31688
31584
  var postureTools = [
31689
31585
  {
@@ -31715,7 +31611,7 @@ var postureTools = [
31715
31611
  integrationId: external_exports3.string().describe("The posture integration ID")
31716
31612
  }),
31717
31613
  handler: async (input) => {
31718
- return apiGet(`/tailnet/${getTailnet()}/posture/integrations/${encPath(input.integrationId)}`);
31614
+ return apiGet(`/posture/integrations/${encPath(input.integrationId)}`);
31719
31615
  }
31720
31616
  },
31721
31617
  {
@@ -31729,20 +31625,24 @@ var postureTools = [
31729
31625
  openWorldHint: true
31730
31626
  },
31731
31627
  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')")
31628
+ provider: external_exports3.enum(["falcon", "intune", "jamfpro", "kandji", "kolide", "sentinelone"]).describe("The posture provider"),
31629
+ clientId: external_exports3.string().optional().describe(
31630
+ "Client ID for the provider (Intune: application UUID; Falcon/Jamf Pro: client id; Kandji/Kolide/Sentinel One: leave blank)"
31631
+ ),
31632
+ clientSecret: external_exports3.string().describe("The secret (auth key, token, etc.) used to authenticate with the provider"),
31633
+ tenantId: external_exports3.string().optional().describe("Microsoft Intune directory (tenant) ID. Other providers leave blank."),
31634
+ cloudId: external_exports3.string().optional().describe(
31635
+ "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."
31636
+ )
31737
31637
  }),
31738
31638
  handler: async (input) => {
31739
31639
  const body = {
31740
31640
  provider: input.provider,
31741
- clientId: input.clientId,
31742
31641
  clientSecret: input.clientSecret
31743
31642
  };
31643
+ if (input.clientId !== void 0) body.clientId = input.clientId;
31744
31644
  if (input.tenantId !== void 0) body.tenantId = input.tenantId;
31745
- if (input.cloudEnvironment !== void 0) body.cloudEnvironment = input.cloudEnvironment;
31645
+ if (input.cloudId !== void 0) body.cloudId = input.cloudId;
31746
31646
  return apiPost(`/tailnet/${getTailnet()}/posture/integrations`, body);
31747
31647
  }
31748
31648
  },
@@ -31758,10 +31658,10 @@ var postureTools = [
31758
31658
  },
31759
31659
  inputSchema: external_exports3.object({
31760
31660
  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"),
31661
+ clientId: external_exports3.string().optional().describe("Updated client ID for the provider"),
31662
+ clientSecret: external_exports3.string().optional().describe("Updated client secret for the provider (omit to retain the existing secret)"),
31763
31663
  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')")
31664
+ cloudId: external_exports3.string().optional().describe("Updated cloud identifier (e.g. 'us-1', 'global', or provider FQDN)")
31765
31665
  }),
31766
31666
  handler: async (input) => {
31767
31667
  const { integrationId, ...body } = input;
@@ -31770,11 +31670,9 @@ var postureTools = [
31770
31670
  if (value !== void 0) cleanBody[key] = value;
31771
31671
  }
31772
31672
  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
- );
31673
+ throw new Error("No fields to update. Provide at least one of: clientId, clientSecret, tenantId, cloudId.");
31776
31674
  }
31777
- return apiPatch(`/tailnet/${getTailnet()}/posture/integrations/${encPath(integrationId)}`, cleanBody);
31675
+ return apiPatch(`/posture/integrations/${encPath(integrationId)}`, cleanBody);
31778
31676
  }
31779
31677
  },
31780
31678
  {
@@ -31791,7 +31689,7 @@ var postureTools = [
31791
31689
  integrationId: external_exports3.string().describe("The posture integration ID to delete")
31792
31690
  }),
31793
31691
  handler: async (input) => {
31794
- return apiDelete(`/tailnet/${getTailnet()}/posture/integrations/${encPath(input.integrationId)}`);
31692
+ return apiDelete(`/posture/integrations/${encPath(input.integrationId)}`);
31795
31693
  }
31796
31694
  }
31797
31695
  ];
@@ -31871,7 +31769,7 @@ var serviceTools = [
31871
31769
  title: "Delete service",
31872
31770
  readOnlyHint: false,
31873
31771
  destructiveHint: true,
31874
- idempotentHint: false,
31772
+ idempotentHint: true,
31875
31773
  openWorldHint: true
31876
31774
  },
31877
31775
  inputSchema: external_exports3.object({
@@ -31960,21 +31858,20 @@ var statusTools = [
31960
31858
  apiGet(`/tailnet/${getTailnet()}/devices?fields=id`),
31961
31859
  apiGet(`/tailnet/${getTailnet()}/settings`)
31962
31860
  ]);
31963
- if (!devicesRes.ok) {
31861
+ if (!devicesRes.ok && !settingsRes.ok) {
31964
31862
  return devicesRes;
31965
31863
  }
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
- }
31864
+ const data = {
31865
+ connected: true,
31866
+ tailnet: getTailnet(),
31867
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : null,
31868
+ settings: settingsRes.ok ? settingsRes.data : null
31977
31869
  };
31870
+ const errors = {};
31871
+ if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
31872
+ if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
31873
+ if (Object.keys(errors).length > 0) data.errors = errors;
31874
+ return { ok: true, status: 200, data };
31978
31875
  }
31979
31876
  }
31980
31877
  ];
@@ -32061,16 +31958,25 @@ var tailnetTools = [
32061
31958
  security: external_exports3.object({ email: external_exports3.string() }).optional().describe("Security contact email")
32062
31959
  }),
32063
31960
  handler: async (input) => {
32064
- const results = {};
31961
+ const applied = {};
31962
+ const failed = {};
32065
31963
  for (const contactType of ["account", "support", "security"]) {
32066
31964
  const value = input[contactType];
32067
- if (value !== void 0) {
32068
- const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
32069
- if (!res.ok) return res;
32070
- results[contactType] = res.data;
32071
- }
31965
+ if (value === void 0) continue;
31966
+ const res = await apiPatch(`/tailnet/${getTailnet()}/contacts/${encPath(contactType)}`, value);
31967
+ if (res.ok) applied[contactType] = res.data;
31968
+ else failed[contactType] = { status: res.status, error: res.error ?? `HTTP ${res.status}` };
31969
+ }
31970
+ const hasFailed = Object.keys(failed).length > 0;
31971
+ const hasApplied = Object.keys(applied).length > 0;
31972
+ if (hasFailed && !hasApplied) {
31973
+ const first = Object.values(failed)[0];
31974
+ return { ok: false, status: first.status, error: `Contact update failed: ${JSON.stringify(failed)}` };
31975
+ }
31976
+ if (hasFailed) {
31977
+ return { ok: true, status: 200, data: { applied, failed } };
32072
31978
  }
32073
- return { ok: true, status: 200, data: results };
31979
+ return { ok: true, status: 200, data: applied };
32074
31980
  }
32075
31981
  },
32076
31982
  {
@@ -32209,7 +32115,7 @@ var userTools = [
32209
32115
  title: "Delete user",
32210
32116
  readOnlyHint: false,
32211
32117
  destructiveHint: true,
32212
- idempotentHint: false,
32118
+ idempotentHint: true,
32213
32119
  openWorldHint: true
32214
32120
  },
32215
32121
  inputSchema: external_exports3.object({
@@ -32374,112 +32280,8 @@ var webhookTools = [
32374
32280
  }
32375
32281
  ];
32376
32282
 
32377
- // src/tools/workload-identity.ts
32378
- var workloadIdentityTools = [
32379
- {
32380
- name: "tailscale_list_workload_identities",
32381
- 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.",
32382
- annotations: {
32383
- title: "List workload identities",
32384
- readOnlyHint: true,
32385
- destructiveHint: false,
32386
- idempotentHint: true,
32387
- openWorldHint: true
32388
- },
32389
- inputSchema: external_exports3.object({}),
32390
- handler: async () => {
32391
- return apiGet(`/tailnet/${getTailnet()}/workload-identity/providers`);
32392
- }
32393
- },
32394
- {
32395
- name: "tailscale_get_workload_identity",
32396
- description: "Get details for a specific federated workload identity provider, including issuer URL, audience, and the subject patterns it accepts for OIDC token exchange.",
32397
- annotations: {
32398
- title: "Get workload identity",
32399
- readOnlyHint: true,
32400
- destructiveHint: false,
32401
- idempotentHint: true,
32402
- openWorldHint: true
32403
- },
32404
- inputSchema: external_exports3.object({
32405
- providerId: external_exports3.string().describe("The workload identity provider ID")
32406
- }),
32407
- handler: async (input) => {
32408
- return apiGet(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(input.providerId)}`);
32409
- }
32410
- },
32411
- {
32412
- name: "tailscale_create_workload_identity",
32413
- 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.",
32414
- annotations: {
32415
- title: "Create workload identity",
32416
- readOnlyHint: false,
32417
- destructiveHint: false,
32418
- idempotentHint: false,
32419
- openWorldHint: true
32420
- },
32421
- inputSchema: external_exports3.object({
32422
- name: external_exports3.string().describe("A human-readable name for this provider (max 50 chars, alphanumeric/hyphens/spaces)"),
32423
- issuerUrl: external_exports3.string().describe("The OIDC issuer URL (e.g. 'https://token.actions.githubusercontent.com' for GitHub Actions)"),
32424
- audience: external_exports3.string().optional().describe("Expected audience claim in the OIDC token"),
32425
- claimMappings: external_exports3.record(external_exports3.string(), external_exports3.string()).optional().describe("Map of Tailscale attributes to OIDC token claims (e.g. { 'tag': 'repository' })")
32426
- }),
32427
- handler: async (input) => {
32428
- const body = { ...input };
32429
- body.name = sanitizeDescription(input.name);
32430
- return apiPost(`/tailnet/${getTailnet()}/workload-identity/providers`, body);
32431
- }
32432
- },
32433
- {
32434
- name: "tailscale_update_workload_identity",
32435
- description: "Update an existing workload identity provider's configuration.",
32436
- annotations: {
32437
- title: "Update workload identity",
32438
- readOnlyHint: false,
32439
- destructiveHint: false,
32440
- idempotentHint: true,
32441
- openWorldHint: true
32442
- },
32443
- inputSchema: external_exports3.object({
32444
- providerId: external_exports3.string().describe("The workload identity provider ID to update"),
32445
- name: external_exports3.string().optional().describe("Updated human-readable name"),
32446
- audience: external_exports3.string().optional().describe("Updated expected audience claim"),
32447
- claimMappings: external_exports3.record(external_exports3.string(), external_exports3.string()).optional().describe("Updated claim mappings")
32448
- }),
32449
- handler: async (input) => {
32450
- const { providerId, ...body } = input;
32451
- const cleanBody = {};
32452
- for (const [key, value] of Object.entries(body)) {
32453
- if (value !== void 0) cleanBody[key] = value;
32454
- }
32455
- if (cleanBody.name !== void 0) cleanBody.name = sanitizeDescription(cleanBody.name);
32456
- if (Object.keys(cleanBody).length === 0) {
32457
- throw new Error("No fields to update. Provide at least one of: name, audience, claimMappings.");
32458
- }
32459
- return apiPatch(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(providerId)}`, cleanBody);
32460
- }
32461
- },
32462
- {
32463
- name: "tailscale_delete_workload_identity",
32464
- description: "Delete a workload identity provider. This is irreversible \u2014 any CI/CD pipelines using this provider will lose access.",
32465
- annotations: {
32466
- title: "Delete workload identity",
32467
- readOnlyHint: false,
32468
- destructiveHint: true,
32469
- idempotentHint: true,
32470
- openWorldHint: true
32471
- },
32472
- inputSchema: external_exports3.object({
32473
- providerId: external_exports3.string().describe("The workload identity provider ID to delete")
32474
- }),
32475
- handler: async (input) => {
32476
- return apiDelete(`/tailnet/${getTailnet()}/workload-identity/providers/${encPath(input.providerId)}`);
32477
- }
32478
- }
32479
- ];
32480
-
32481
32283
  // src/index.ts
32482
- var version2 = true ? "0.8.7" : (await null).createRequire(import.meta.url)("../package.json").version;
32284
+ var version2 = true ? "0.9.0" : (await null).createRequire(import.meta.url)("../package.json").version;
32483
32285
  var subcommand = process.argv[2];
32484
32286
  if (subcommand === "deploy-acl") {
32485
32287
  const filePath = process.argv[3];
@@ -32505,14 +32307,11 @@ var toolGroups = {
32505
32307
  users: userTools,
32506
32308
  tailnet: tailnetTools,
32507
32309
  webhooks: webhookTools,
32508
- "network-lock": networkLockTools,
32509
32310
  posture: postureTools,
32510
32311
  audit: auditTools,
32511
32312
  invites: inviteTools,
32512
32313
  services: serviceTools,
32513
- "log-streaming": logStreamingTools,
32514
- "workload-identity": workloadIdentityTools,
32515
- "oauth-clients": oauthClientTools
32314
+ "log-streaming": logStreamingTools
32516
32315
  };
32517
32316
  var {
32518
32317
  tools: allTools,
@@ -32584,9 +32383,13 @@ server.resource(
32584
32383
  ]);
32585
32384
  const data = {
32586
32385
  tailnet: getTailnet(),
32587
- deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : "error",
32588
- settings: settingsRes.ok ? settingsRes.data : void 0
32386
+ deviceCount: devicesRes.ok ? devicesRes.data?.devices?.length ?? 0 : null,
32387
+ settings: settingsRes.ok ? settingsRes.data : null
32589
32388
  };
32389
+ const errors = {};
32390
+ if (!devicesRes.ok) errors.devices = devicesRes.error ?? `HTTP ${devicesRes.status}`;
32391
+ if (!settingsRes.ok) errors.settings = settingsRes.error ?? `HTTP ${settingsRes.status}`;
32392
+ if (Object.keys(errors).length > 0) data.errors = errors;
32590
32393
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
32591
32394
  }
32592
32395
  );
@@ -32596,7 +32399,7 @@ server.resource(
32596
32399
  { description: "List of all devices in the tailnet with their status", mimeType: "application/json" },
32597
32400
  async (uri) => {
32598
32401
  const res = await apiGet(`/tailnet/${getTailnet()}/devices`);
32599
- const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error });
32402
+ const text = res.ok ? JSON.stringify(res.data, null, 2) : JSON.stringify({ error: res.error ?? `HTTP ${res.status}` }, null, 2);
32600
32403
  return { contents: [{ uri: uri.href, text, mimeType: "application/json" }] };
32601
32404
  }
32602
32405
  );
@@ -32606,7 +32409,8 @@ server.resource(
32606
32409
  { description: "Current ACL policy (HuJSON with comments preserved)", mimeType: "application/hujson" },
32607
32410
  async (uri) => {
32608
32411
  const res = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
32609
- const text = res.ok ? res.rawBody ?? "" : `Error: ${res.error}`;
32412
+ const text = res.ok ? res.rawBody ?? "" : `// Error: ${res.error ?? `HTTP ${res.status}`}
32413
+ `;
32610
32414
  return { contents: [{ uri: uri.href, text, mimeType: "application/hujson" }] };
32611
32415
  }
32612
32416
  );
@@ -32625,11 +32429,17 @@ server.resource(
32625
32429
  apiGet(`/tailnet/${getTailnet()}/dns/preferences`)
32626
32430
  ]);
32627
32431
  const data = {
32628
- nameservers: nameservers.ok ? nameservers.data : void 0,
32629
- searchPaths: searchPaths.ok ? searchPaths.data : void 0,
32630
- splitDns: splitDns.ok ? splitDns.data : void 0,
32631
- preferences: preferences.ok ? preferences.data : void 0
32432
+ nameservers: nameservers.ok ? nameservers.data : null,
32433
+ searchPaths: searchPaths.ok ? searchPaths.data : null,
32434
+ splitDns: splitDns.ok ? splitDns.data : null,
32435
+ preferences: preferences.ok ? preferences.data : null
32632
32436
  };
32437
+ const errors = {};
32438
+ if (!nameservers.ok) errors.nameservers = nameservers.error ?? `HTTP ${nameservers.status}`;
32439
+ if (!searchPaths.ok) errors.searchPaths = searchPaths.error ?? `HTTP ${searchPaths.status}`;
32440
+ if (!splitDns.ok) errors.splitDns = splitDns.error ?? `HTTP ${splitDns.status}`;
32441
+ if (!preferences.ok) errors.preferences = preferences.error ?? `HTTP ${preferences.status}`;
32442
+ if (Object.keys(errors).length > 0) data.errors = errors;
32633
32443
  return { contents: [{ uri: uri.href, text: JSON.stringify(data, null, 2), mimeType: "application/json" }] };
32634
32444
  }
32635
32445
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/tailscale-mcp",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "Tailscale MCP server for managing your tailnet from AI assistants",
5
5
  "license": "MIT",
6
6
  "author": "YawLabs <contact@yaw.sh>",