github YawLabs/tailscale-mcp v0.21.0

3 hours ago

Added

  • tailscale_set_device_posture_attribute takes a comment, and now documents the key and value limits. Per the OpenAPI spec the single-attribute write accepts an optional comment of up to 200 characters, "which will be added to the audit log", and Tailscale's own Go client sends the field on every such request. The tool did not expose it -- so the JIT-access and compliance-tracking flows its own description advertises wrote an attribute with no recorded reason, and getting one into the audit log meant knowing to reach for tailscale_batch_update_posture_attributes with a one-device nodes map instead, which is the only place in the package that already had the field. The attributeKey and value descriptions now also carry the limits the spec states and the tool never mentioned: keys are capped at 128 characters including the custom: prefix, allow only letters, numbers, underscores and colons, and are unique case-insensitively; string values are capped at 50 characters of letters, numbers, underscores and periods, numbers must be JSON-safe integers, and a key's type is fixed by the first value written for it. Those are description text only -- the API owns the rules and returns its own error, and a second copy in a local regex would only drift. That the comment lands in the configuration audit log is what the spec says, not something observed against a live tailnet.
  • Server-side actor, target and event filters on tailscale_get_audit_log. Per the OpenAPI spec the configuration audit log accepts all three, and the tool exposed none of them, so "who changed DNS at 2am" pulled the entire window -- up to 30 days of every change anyone made -- for the model to sift in its own context. actor takes an exact actor ID or ~text to wildcard-match a login or display name, target matches any part of any of an entry's targets, and event takes an event type such as TAILNET.UPDATE.ACL or NODE.CREATE. Each takes one value per call for now: the spec declares no serialization style on these array parameters, so repeated keys are only the OpenAPI default, and nothing upstream corroborates it -- neither the Go client nor the Terraform provider implements this endpoint, and the KB page documents only start and end. One value goes on the wire identically whether the server wants repeated keys or a comma-joined list; two under the wrong guess would come back as a subset with no error at all, which in a compliance query reads as "that change never happened". A later release lifts the cap once a live call settles it. event is a free string rather than the spec's 138-value enum: that list is still growing (the PAM_* entries are recent), and a closed enum would make a newly added event type unqueryable rather than merely unvalidated -- the bug class recorded below for webhook subscriptions. tailscale_get_network_flow_logs gets no filters, because the spec gives that endpoint only start and end.
  • tailscale_get_device takes fields. Per the OpenAPI spec the single-device read references the same fields parameter as the device listing, and the tool never sent it -- so advertisedRoutes, enabledRoutes, clientConnectivity, sshEnabled, distro, multipleConnections and postureIdentity were missing from the obvious answer to "tell me everything about this device", and reaching them meant knowing to call tailscale_list_devices with fields: "all" and a nodeId filter instead. It takes the spec's two values, all and default, and nothing else. Omitting it still sends no query string at all: quietly defaulting to all would start handing every caller serial numbers, magicsock endpoints and -- where a posture integration collects them -- MAC addresses that nobody asked for.
  • tailscale_list_devices filter values may be arrays, which repeat the key. The spec's own filter example is isEphemeral=true&tags=tag:prod&tags=tag:subnetrouter, returning devices whose tags contain BOTH; a JSON object cannot carry a duplicate key and the handler used URLSearchParams.set, so multi-value AND was not expressible through any tool in the package. { tags: ["tag:prod", "tag:subnetrouter"] } now sends both. An empty array is rejected rather than silently dropped, because a filter that emits no parameter returns the entire tailnet -- the widest possible answer to a request that asked to narrow. filters.fields is still refused: with append in place it no longer overwrites the top-level fields, but a second fields= on the wire is ambiguous rather than what the caller meant. How the server treats a repeated key on a non-list property is undocumented upstream, and the description says so.
  • tailscale_create_webhook takes providerType. Pointing a webhook at a Slack, Discord, Mattermost or Google Chat incoming-webhook URL did not work through this server: per the OpenAPI spec the create body takes an optional providerType of slack, mattermost, googlechat or discord, and events "are sent in the format expected by the provider type if non-empty" -- so without it Tailscale delivers its own JSON, which none of those four render. Create-only, like the endpoint URL: the spec's PATCH body carries subscriptions alone, and Tailscale's Terraform provider marks provider_type as forcing a replacement. Omitting the field sends no key at all rather than the "" the Go client puts on the wire for "no provider", because the spec's enum has no empty member. It is a closed enum despite the lesson recorded below for posture providers: the field is optional, so a provider Tailscale adds later is merely unselectable rather than making a webhook uncreatable. That the provider formatting actually renders is what the spec says, not something observed against a live tailnet.
  • tailscale_local_status takes peers and activeOnly, and declares a result-size cap. The tool had no inputs at all and sent a bare tailscale status --json, so the whole peer map was the only response it could produce -- while its own overflow error told the caller to "narrow the query if the command supports it". Per upstream's cmd/tailscale/cli/status.go both narrowing flags do apply in JSON mode: --peers=false swaps in the peerless status call before the JSON branch, and --active deletes every peer without a recent session inside it. peers: false now sends the first and activeOnly: true the second; both are optional, and omitting them sends the byte-identical argv the tool has always sent. An overflow now names the two inputs rather than leaving the caller to find them. The tool also joins the ones declaring _meta["anthropic/maxResultSizeChars"], which it was missing: its size is bounded by how many peers this node can see rather than by the request, so without the annotation the client's own lower default -- not the documented 500000 ceiling -- decided when a status dump became a file reference the agent has to read back. It is the only entry on that list behind an opt-in, so it carries the cap only when TAILSCALE_LOCAL_CLI=1 registers it. Nothing here was measured against a large tailnet: the flags are read off the upstream source, and the cap is the same ceiling every other entry declares.
  • The tailscale_local_status description says how to read a peer's path. Upstream decides it in order -- direct when CurAddr is set, peer-relayed when PeerRelay is set, otherwise DERP via the region in Relay -- and Relay carries the peer's home DERP region whether or not traffic is relayed. So an agent reading a non-empty Relay as "this path is DERP-relayed" gets it wrong for every direct peer, which is the misdiagnosis the description now heads off.
  • Webhook subscriptions accept the two category values. categoryTailnetManagement and categoryDeviceMisconfigurations subscribe to a whole group of events and pick up the ones Tailscale adds to it later -- the staleness this package's strict catalog otherwise guarantees. They were reachable only through TAILSCALE_EXTRA_WEBHOOK_EVENTS, because the catalog is built from the OpenAPI enum and neither value is in it. Both are per the official Go client, which documents each as implying "any future events added below", and the Terraform provider, which validates both; the control plane has not been observed accepting the literal strings here. If that is wrong the failure mode is the one the catalog already lives with -- a loud API 400 instead of a loud local rejection, never anything silent. They are kept in a constant of their own so "the static list is the spec enum" stays checkable in one pass at refresh time.

Fixed

  • A Node older than the package's own floor now fails with a clear message, and npm test fails on an API newer than that floor. engines: { node: ">=20.11.0" } is advisory -- npm only warns, nothing sets engine-strict, and an MCP client that spawns node itself never reads it -- so bin/tailscale-mcp.mjs, which has always enforced a floor for oam, enforced none for the runtime it falls back to, and neither did dist/index.js, the fast path the README documents and the launcher never touches. Both now refuse a sub-floor Node naming the version they found. Nothing in the toolchain could have caught the violation that guard exists for: types: ["node"] pulls in @types/node 26, which declares util.styleText (Node 20.12), process.getBuiltinModule (20.16) and node:sqlite (22.5) unconditionally, and the default lib for the tsconfig target is es2022.full, which lends this Node-only package document, window and localStorage on top. src/node-floor.test.ts scans every shipped file in src/ and bin/ for both kinds, and holds the three declarations of the floor -- package.json, the launcher and the server entry -- to the same three numbers.
  • tailscale_list_devices told agents that omitting fields returns every field; per the OpenAPI spec Tailscale returns the limited default set. The parameter was described as a "Comma-separated list of fields to include. Omit for all fields", followed by 27 field names to pick from. The spec documents exactly two values, all and default, and states that "If the fields parameter is not supplied, then the default (limited fields) option is used" -- and that default set excludes advertisedRoutes, enabledRoutes, clientConnectivity, sshEnabled, distro, multipleConnections and postureIdentity. So an agent following the description's main instruction got a subnet-router audit with no routes in it, an exit-node review with no connectivity report, and a posture check with no posture identity, with nothing in the response to say a field had been withheld. Both device-read tools now share one description that names the default set, says what all adds, and states outright that omitting the parameter does not return everything. The type stays a free string here rather than becoming the spec's enum: the comma-separated form has been advertised since the package's first commit and two call sites inside this server still send fields=id, so whether the API honours an undocumented projection or falls back to the default is a question for a live call, not a guess -- a later release settles it. The enumerated list itself was also wrong in a smaller way, omitting isEphemeral, multipleConnections and postureIdentity.
  • Neither device tool mentioned that lastSeen is now absent on connected devices. Since Tailscale's 2025-10-08 devices API change the field is omitted when connectedToControl is true, and for devices that have never been online -- so on a connected device a missing lastSeen means online now, which an agent reading it as "never seen" gets exactly backwards. Both descriptions and the README's device rows now say so. No code depended on the field; responses pass through verbatim, which is also why the postureIdentity.hardwareAddresses and clientConnectivity.derp fields that Tailscale's Go client carries but the spec does not already reach callers unchanged.
  • tailscale_get_audit_log and tailscale_get_network_flow_logs now always send end. Both described it as "Defaults to now" and then left it off the request, relying on a server default that no Tailscale source documents. Per the OpenAPI spec end is a required query parameter on both logging endpoints; Tailscale's audit-logging and network-flow-log KB pages each label it "Required." and send it in every example; and the Go client's network-flow reader carries the comment "Both start and end parameters are required by the server". The tools now fill it in at the second precision the Go client sends -- no upstream example carries a fractional second -- and hand that exact string to the 30-day range guard, so what is checked is what is sent. Truncating the milliseconds only moves end earlier, which is why the 30-day cap still clears; the same property inverts the end >= start check when start falls inside the second the handler runs in, which is what "tail the audit log from now" looks like, and that case used to pass the guard and reach the API as a backwards range. An empty-string end is treated as omitted, which is what the range guard already did. Whether the old start-only request was actually rejected is unverified against a live tailnet; the fix is correct either way.
  • The README's opt-in Local CLI table listed four of the six tools. tailscale_local_whoami and tailscale_local_service_list appeared only in the collapsed tool reference further down, so a reader of the section that explains TAILSCALE_LOCAL_CLI=1 never saw them. The table now lists all six, and the section's count is written as a digit so a test can hold it.
  • Two more README passages disagreed with the registry. The TAILSCALE_WRITE_GROUPS=devices,keys example said it serves "all 47 read tools", counting the opt-in local-CLI tools that the same example's banner (59 tools = 41 reads + 18 writes) does not; it now says 41, and its list of withheld write areas now includes tailnet. The always-on maxResultSizeChars sentence said five tools and left out tailscale_diff_acl_access; it now names every declaring tool and carries no count at all.
  • npm test now fails when those README numbers drift. release-metadata.test.ts checked the totals, the profiles and the tool tables, but none of the write-scoping section: the read and write counts, the per-group writes table, all N writes, the write-grant banner example, the destructiveHint: true count and the large-result list were all hand-typed, so a new write tool could leave every one of them stale with the suite green. Each is now derived from the registry, its annotations or filterTools, as are the Local CLI section's count and table. A sweep in tools.test.ts also fails when a tool description or parameter description names a tailscale_* tool that is not registered, which catches a description that points an agent at a renamed tool or at one that has not shipped. Code comments that quoted tool counts no test checked now carry no number.
  • tailscale-mcp deploy-acl and validate-acl strip a UTF-8 BOM from the policy file. Every PowerShell redirect writes one -- Out-File, Set-Content -Encoding utf8 and plain > all emit EF BB BF on 5.1 -- and several Windows editors do too, so a policy authored on Windows usually carries U+FEFF. It was sent verbatim, ahead of the first {, to both /acl/validate and /acl; the API answers with a diagnostic that does not name the BOM, so a file that looks correct in every editor failed for a reason nothing on screen showed. The byte is now dropped before either request. A BOM-free file is unchanged, and the bytes after the BOM are still sent exactly as written -- comments and trailing commas included, which is what makes a HuJSON policy round-trip.
  • A rejected ACL now says which test failed. Tailscale answers a failing policy test with a message and a data array naming the user and the assertion (address "2.2.2.2:22": want: Drop, got: Accept), per the OpenAPI spec and Tailscale's historical API docs. extractErrorMessage returned the message alone, and the deploy-acl / validate-acl CLI dropped the array a second time in its own parser, so tailscale_update_acl reached the agent as Error: test(s) failed and CI printed ACL validation failed: test(s) failed -- with nothing to act on. A shared renderer now prints each entry the way upstream's gitops-pusher does (For user <u>:, then Errors found: / Warnings found:), under the message, in the MCP tool error, in the tailscale://tailnet/acl resource and in the CLI's CI logs. Validation warnings still fail the run, as they do in gitops-pusher and Tailscale's Go client, but their text is now printed instead of discarded. The spec types the array's items as a bare object, so an entry the renderer cannot place is printed as JSON rather than summarised away, with values under secret-shaped keys redacted and that one line trimmed to 1000 characters -- a line cap cannot trim a single long line at all. The render as a whole is capped at 100 lines and 8000 characters, and it stops at an entry boundary with a count of the entries left out, so a policy with hundreds of failures ends on a complete diagnostic rather than part-way through one under its own Errors found: heading.
  • tailscale_create_aws_external_id sends reusable and is no longer marked idempotent. It used to POST no body at all while advertising idempotentHint: true, so the ID it handed back depended on a server default for an absent body that no Tailscale source documents. Per the OpenAPI spec the endpoint takes an optional reusable boolean, and the same ID comes back on later calls "if and only if those calls also mark reusable as true, and the ID has not yet been linked with an AWS account"; Tailscale's Go client always sends the flag, and its Terraform provider deliberately passes reusable: false precisely because such calls mint distinct IDs. A retried or repeated mint could therefore hand back an ID that no longer matches the one already pasted into an IAM role trust policy. The tool now sends reusable: true by default -- the admin-console behaviour, and the one that makes "create or get" true for the mint, paste into IAM, validate flow -- and takes reusable: false for a fresh ID per call. The hint is false because reusable: false is non-idempotent by design, and because even a reusable ID is replaced once it has been linked. What the server did with the old body-less request is unverified; the flag is explicit either way now.
  • OAuth error guidance and the README linked the retired OAuth clients page. Tailscale replaced that admin-console page with Trust credentials on 2025-10-30. The token-exchange failure hint, the OAuth 403 hint and the README's Authentication link now point at console.tailscale.com/admin/settings/trust-credentials, and the 403 hint says "Adjust the credential's scopes" rather than naming an OAuth client, since the page also holds federated identities. These strings are what a stuck operator reads.
  • tailscale_list_keys said the default listing is "auth keys only"; per the OpenAPI spec it is whatever the credential owns. The spec's listing returns "active auth keys, API access tokens and trust credentials", and without all the set "depends on the access token used to make the request": a user-owned API key returns only that user's keys -- which includes the api access token this server is itself authenticating with -- an OAuth-client token returns the tailnet's OAuth clients, and a federated one its federated identities. So the old sentence was wrong under every credential the server supports, and API access tokens went unmentioned by all three key tools. tailscale_delete_key now warns about the consequence the spec's own "deletes a specific api access token or auth key" implies: a keyId naming the token this server runs on revokes its credential, and every later call fails with 401 until it is reconfigured. tailscale_get_key also now says a revoked or expired key is still returned, carrying invalid: true. The all parameter is described as the tailnet-wide list rather than an enumeration of types, because the spec's text for the parameter omits federated identities where the operation's own text includes them. No request changes.
  • More descriptions drifted from what the API reference says. tailscale_list_user_invites said "List all user invites"; the spec's endpoint is "List all open (not yet accepted) user invites to the tailnet", and the UserInvite schema carries no accepted-or-not field, so an agent asking "who has already joined?" was reading a list that structurally cannot answer it. tailscale_set_nameservers and tailscale_set_dns_preferences said nothing about the nameserver/MagicDNS coupling the spec documents -- that removing every nameserver disables MagicDNS, and that enabling MagicDNS without a DNS server returns an error. Both now carry it, hedged: Tailscale's current MagicDNS page says a nameserver has not been required since client v1.20, so the two sources disagree, and rather than assert either the descriptions point at the observable -- magicDNS in the nameserver response. And tailscale_create_oauth_app called auth_keys:create:once "the supported scope"; it is the one Tailscale's device-provisioning guide documents, but the API reference's example shows the bare auth_keys:create, and the schema has never restricted the value -- the description now says all three of those things instead of picking one. None of this was checked against a live tailnet; it is what the documentation states.
  • tailscale_set_split_dns and tailscale_update_split_dns rejected null, the only way the API documents to clear a domain. Per the OpenAPI spec the split-DNS body is a "map of domain names to lists of nameservers or to null", and both the PUT and the PATCH state that "setting the value of a mapping to null clears the nameservers for that domain" -- but both tools typed every value as a plain list of strings, and the MCP SDK validates against that schema before a handler ever runs. So an agent following Tailscale's own API reference got a local type error rather than a request: on tailscale_update_split_dns the description at least named this package's empty-array alternative, and on tailscale_set_split_dns it named neither, leaving the agent to guess or give up on a domain it had been asked to unset. Both now take a list or null per domain and still forward the body verbatim, so a null goes on the wire as JSON null instead of being pruned -- pruning it would leave the domain untouched on the merging PATCH, which reads as a removal that removed nothing. [] keeps working and keeps being described: it is what this package has told users to send since its first release and what Tailscale's Terraform provider sends for its own Delete. The descriptions stop short of calling the two forms equivalent, because only null is documented upstream and nothing here was checked against a live tailnet. A bare nameserver string is still rejected; the widening is to the spec's shape, not to anything falsy.
  • The local-CLI tools now find the tailscale binary where it is actually installed, and say something useful when they cannot. Discovery was a bare PATH lookup with a TAILSCALE_BINARY override and no probe, which misses a default macOS install outright: the CLI lives inside the app bundle, nothing is added to PATH, and an MCP client launched from the Dock or Spotlight sees a minimal PATH rather than the shell one where an alias might have covered it. The lookup now falls back to the app bundle, Homebrew and /usr/local on macOS, and to /usr/bin and the snap wrapper on Linux -- one stat each, no subprocess, PATH still first. tailscale.exe is deliberately never a candidate on Linux: in WSL it is usually the only tailscale in reach, and it answers for the WINDOWS host tailnet while every tool in this group describes "this machine". The ENOENT message is no longer one sentence blaming PATH for everything -- when TAILSCALE_BINARY was set it names the value that did not resolve and says PATH was never consulted (with the Git Bash /c/... trap spelled out on Windows), on macOS it names the app-bundle path, and in WSL it says a Linux-side tailscaled is what these tools need. The README gains the same per-OS table and the WSL warning, which its one-line "must be in PATH" note did not carry.
  • A tailscale_local_status timeout now points at peers:false. The narrowing hint was gated on the 10 MB output-limit error alone, so the timeout branch -- the failure a large tailnet on slow hardware reaches first -- ended with nothing to try next, while the tool description two lines away said peers is what makes the response scale. --peers=false swaps in the peerless status call before any peer is serialized, so it cuts the work and not just the output; activeOnly is deliberately not suggested there, because --active filters peers already in hand and cannot make a slow call finish.

Changed

  • 502, 503 and 504 are now retried on GET, PUT and DELETE, with the same backoff and budget as 429. Per the OpenAPI spec, a 504 -- "request took too long to process, please try again later" -- is a documented response on every Devices and Services operation, and a 502 ("The system was unable to communicate with logging server") on the network-flow-log and log-streaming reads. The retry loop broke out on any status but 429, so upstream's own advice to try again was never taken and a single gateway blip failed the whole tool call -- including the user listing that tailscale_diff_acl_access reads before it can compare anyone, which made one blip abort the entire fan-out. 503 joins them as the standard load-shed status a fronting proxy returns; HTTP 500 does not, because it says the server failed to process the request rather than that something in front of it gave up. POST and PATCH are still never retried, on any status. The cost is latency: a GET, PUT or DELETE that keeps drawing a gateway error now spends up to the retry budget (roughly 7s of backoff at the default base, bounded by TAILSCALE_REQUEST_BUDGET_MS) before it returns, where it used to fail at once. For a gateway 5xx the budget check also charges the duration of the attempt that just failed -- a 504 arrives only after the gateway has already waited, so a slow one is surfaced rather than retried into the client's own outer timeout -- and a call that retries past one is held to half of TAILSCALE_REQUEST_BUDGET_MS from that point on, 45s by default and under the 60s low end of the usual client timeout, so a gateway that answers 504 after 15 seconds surfaces it at ~31s instead of spending ~67s on four attempts nobody is still waiting for. A 429 chain keeps the whole budget: the limiter answers immediately, so what it spends is its own backoff. And a DELETE that retried past an ambiguous attempt and then got a 404 now says so, rather than reporting a bare "not found" for a resource the earlier attempt may have removed: (an earlier attempt returned HTTP 504; the delete may already have succeeded) after a gateway status, (an earlier attempt never returned a response (DELETE request timed out after 30000ms); the delete may already have succeeded) after a retried transport failure, and both named when both happened. The transport half is not the milder one -- a gateway at least answered, while a reset socket or a client-side timeout leaves no evidence either way about whether the server ran the delete.
  • The integration suite's two key round-trips need a second opt-in, RUN_MUTATING_INTEGRATION_TESTS=1. They mint a real OAuth client and a real federated identity in the target tailnet and delete them again, and they sat behind the same RUN_INTEGRATION_TESTS=1 flag as the read-only describes -- so a contributor who wanted API-shape-drift coverage got credential creation as a side effect, and the documented command was unsafe to point at production even though everything else in it was a GET. The read-only describes keep the base flag; setting the mutating flag on its own now fails with what is missing rather than running nothing. Nothing in the published package changes. The read-only describes also gained live coverage of both logging tools, which had none, so the target tailnet now needs at least one configuration audit entry in the last 29 days alongside the existing device and key preconditions.
  • The README now says what TAILSCALE_TAILNET takes, and no longer shows a .ts.net name as its value. Per the OpenAPI spec the value is - -- the credential's own tailnet, and the right answer for most setups -- or the Tailnet ID from Settings > General in the admin console, which looks like T1234CNTRL. The legacy organization name works only on tailnets created before October 2025; a newer tailnet never gets one, so a reader copying the old shape had nothing to copy. Authentication used to say "set TAILSCALE_TAILNET to specify one explicitly" and stop there, naming no value at all, while the GitHub Actions example filled the gap with your-tailnet.ts.net -- a MagicDNS name upstream documents for DNS, HTTPS and sharing, and nowhere as an API identifier. The GitOps section also told CI users to set it alongside the API key as though it were required; it is optional. The comments on getOAuthTailnet and getTailnet in src/api.ts and on the delete guard in src/tools/tailnets.ts now describe the same three forms. Nothing changes at runtime: getTailnet() still interpolates its value raw, and a Tailnet ID carries no URL-significant character, so the rationale documented there still holds.
  • The README now says where Tailscale's own MCP tools and skill stop and this server starts. As of 2026-09-19 the official MCP surface is two alpha built-in connectors inside Aperture -- node provisioning, and SSH machine listing plus single-command execution -- and tailscale/tailscale-skill is an alpha, knowledge-only skill that teaches an agent to curl the v2 API. Neither exposes the admin API as typed tools, so the overlap with this server is close to nil, and Aperture proxies only URL-addressable Streamable-HTTP or SSE servers, so it cannot front this stdio server either. The "Why MCP vs. a skill or the tailscale CLI?" section named neither of them and carried one claim that has since gone stale -- that a Claude Code skill only loads in Claude Code, where the official skill's own README lists several agents that follow the Agent Skills standard. The new paragraph is date-stamped, because both of those products are alpha and expected to move.
  • The README now says which OAuth scopes each tool group needs. It advised scoping the OAuth client to the areas you use but never named a current scope, so following that advice meant reverse-engineering scopes from 403s. A new "OAuth scopes by tool group" table under Authentication gives the read and write scopes for every TAILSCALE_TOOLS group, per the OpenAPI spec, including the cases a guess gets wrong: the service host and approval tools need devices:core as well as services, reads included; the ACL scopes need device scopes alongside; tailscale_validate_aws_trust_policy is a read that needs the log_streaming write scope; and creating, resending and accepting a device invite cannot be done with an OAuth token at all. The spec names no DNS scope, so the DNS row comes from the trust credentials doc and is marked unverified. The OAuth 403 hint now names the table, and release-metadata.test.ts fails when a tool group has no row, a row names a group that does not exist, or the heading the hint quotes is renamed.
  • The OAuth scope examples use Tailscale's current names. tailscale_create_key's description, its scopes hint and the README's "OAuth client for our CI pipeline" example all suggested devices:read and acl. Tailscale's trust-credentials reference lists both under "Legacy scopes", superseded on 2024-11-14 by devices:core[:read] and policy_file[:read], so an agent copying the example minted a credential in the older, coarser vocabulary -- one devices:read grant covers the device list, auth keys, attributes and tags together. The same schema's tags hint had already moved to devices:core and auth_keys, so the file contradicted itself. The examples now read devices:core:read and dns:read; the scopes hint links the reference's scope list rather than inlining a vocabulary that goes stale, and names the two legacy strings so an agent recognises its own. The README's keys warning says policy_file and all where it said acl. Nothing is restricted: the parameter has always taken any scope string and still does, and per the reference the credentials already holding a legacy scope, and the keys they generated, stay valid.
  • Every deviceId input now says the nodeId is the preferred identifier. Per the OpenAPI spec's shared deviceId parameter, "Using the device's nodeId is preferred, but its numeric id value can also be used", and Tailscale's Go client marks the numeric field a legacy identifier. Seventeen of the eighteen deviceId inputs in this package said only "The device ID", the eighteenth offered the two forms as equals, and the batch posture example handed agents two numeric ids ("12345", "67890") to copy -- so which identifier an agent reached for was decided by whichever description it happened to read. All eighteen now share one hint that names the nodeId first, carries the spec's own example value, and says the value is not the nodeKey; the batch example's keys are the nodeIds from the spec's example. Both forms still work and no request changes -- the wording just stops steering agents at the form upstream is moving away from. The hint costs roughly twenty tokens per input in every tools/list, which is the price of the thing it fixes: agents copy the identifier the description shows.
  • tailscale_rename_device says a base name is accepted and an empty name resets the device to its OS hostname. It described name as "the FQDN within your tailnet" and stopped there. Per the OpenAPI spec the field takes "either the fully qualified domain name for the device (e.g. nodename.your-domain.ts.net) or just the base name (e.g. nodename)", and "if name is unset or provided empty, the device's name is reset to be generated from its OS hostname" -- so two of the three things the endpoint does went unmentioned, and resetting a device's name looked impossible through this server. Both already worked: the schema is an open z.string() and the handler posts the value unmodified. Tests now pin that, because it stays true only while the empty string is neither rejected by a .min(1) nor dropped the way this package drops falsy optional fields elsewhere. None of this was checked against a live tailnet, and the description attributes the reset behaviour to the spec.
  • tailscale_create_org_tailnet documents the display-name rules, the per-organization cap and the alreadyExists response field. Per the OpenAPI spec displayName "can contain letters, numbers, spaces, apostrophes, and hyphens, and must be unique within the organization"; all plans can create at most ten tailnets including their original; and the response carries an alreadyExists boolean the tool never mentioned. So an agent naming a tailnet had no rule to follow and no idea the call was capped. The cap keeps the spec's own escape hatch -- "unless Tailscale sales has raised the limit" -- so an agent working in an organization that has one does not refuse an eleventh create that would have succeeded. The duplicate-name path stays hedged: the same spec documents both a 200 carrying alreadyExists: true and a 400 for a name already in use, so the description says to call tailscale_list_org_tailnets after a timeout rather than assume either, the annotation comment says the same, and idempotentHint stays false until one of the two is observed. Settling that means creating real tailnets, which mints OAuth secrets and spends the cap. No charset regex was added: Tailscale owns the rule and returns its own error, and a local copy would only drift.
  • The "leave blank" guidance for Fleet and Huntress is withdrawn rather than replaced with a guess. tailscale_create_posture_integration's clientId told Fleet and Huntress users to leave the field empty. Nothing upstream says that: the spec's sentence covers only Kandji, Kolide and Sentinel One, its provider enum lists neither product, and neither Tailscale's Go client nor its Terraform provider carries per-provider notes. It is very likely wrong for Huntress, whose admin console asks for an API key and an API secret while the tool would be left holding one field for two credentials, and incomplete for Fleet, whose console asks for a Fleet URL that has to go somewhere -- and cloudId never mentioned either provider, while tenantId told everyone but Intune to leave it blank, which is where Huntress's optional organization ID might well belong. All three fields now say the mapping is undocumented upstream, clientId names what each console asks for, and all of them deliberately stop short of the plausible mapping: observing the real one needs live Fleet or Huntress credentials, not an API call. Every field was passable before and still is; this was misleading prose, not a blocked capability.
  • tailscale_update_acl now sends the If-Match ETag quoted, whatever quoting the caller supplied. tailscale_get_acl hands the ETag back inside a // ETag: "..." footer, so the quotes arrive as part of a comment line the agent retypes rather than a field it passes through -- and the handler forwarded whatever came back verbatim, so an agent that dropped them sent an unquoted precondition on the widest-blast-radius write in the package. Both of Tailscale's own clients normalize instead: tailscale-client-go-v2 trims any quotes off the value and formats it with %q to put them back, and gitops-pusher concatenates them on. The OpenAPI spec's own examples are quoted too, for the sentinel below as much as for a real ETag. Whether the server tolerates the unquoted form is unverified against a live tailnet, and it does not need to be: the worst case either way is a 412, which fails safe, so normalizing can only move the header toward the form upstream is known to send. A caller already passing the quoted value sends a byte-identical header, and a W/ weak validator passes through untouched, because stripping its quotes would leave a different validator rather than a requoted one. An ETag with nothing inside its quotes -- "" clears the schema's non-empty check at two characters, " " at three -- is now refused locally rather than sent. Both are truthy, so unlike the genuinely empty string fixed in 0.18.0 the header does go out for them; what it carries is a precondition that cannot match any ETag the tailnet holds, which per the spec buys a 412. That fails safe, so this is a confusing round trip traded for a local error naming the field, not an unguarded write -- and what the server really does with such a value was not observed against a live tailnet either. The deploy-acl CLI is unaffected: it forwards the ETag the API itself returned, which is always quoted.
  • The etag description names ts-default, the sentinel for the first write to a fresh tailnet. Per the OpenAPI spec, setting If-Match to ts-default replaces the policy file "only if the current policy file is still the untouched default created automatically" for the tailnet -- the guarded way to make the first push to a tailnet nobody has edited yet, and nothing in this package mentioned it. It was always passable, since etag is a free-form string, so the gap was in what the server told agents rather than in what it could do. What it buys is worth naming precisely: a first push that needs no prior tailscale_get_acl -- the spec says the GET returns an ETag header with no fresh-tailnet exception, so the get-then-update workflow this tool already prescribes was never blocked -- and a precondition on the policy still being the untouched default, which is a stronger assertion than any one version's ETag can make. Not a recent upstream addition either: it is in Tailscale's v1.66 API reference as well as in today's spec. The tool's own description now names it too, so an agent that reads only that and stops does not miss it.
  • Contributor tooling: a dry-run-by-default live shape probe harness (scripts/live-probe.mjs, scripts/lib/, fixtures/live/). It does not ship -- package.json's files allow-list publishes only bin/tailscale-mcp.mjs, dist/index.js, LICENSE and README.md -- and it changes nothing about the server's behavior. It exists to settle the handful of open questions that the OpenAPI spec, the Go client and the docs cannot: what the API actually does with a request shape this server sends, given that a 200 does not prove a write took effect and a 400 does not say which part was wrong. Each probe sends the shape the shipped tool emits and the shape the spec documents, against the same target in the same run, with a GET before and after. Dry run is the default, --execute has to be typed, and the interlocks (credential isolation, an explicit non-forbidden target, provisioning provenance, server-attested emptiness, typed confirmation, and a fetch-layer egress guard) are covered by src/live-fixtures.test.ts in the ordinary offline npm test. Nothing here has been run against a live tailnet: fixtures/live/ ships empty, and no claim anywhere in this repo rests on an observation. See CONTRIBUTING.md, "Live shape probes".

Don't miss a new tailscale-mcp release

NewReleases is sending notifications on new releases.