Added
tailscale_set_device_posture_attributetakes acomment, and now documents the key and value limits. Per the OpenAPI spec the single-attribute write accepts an optionalcommentof 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 fortailscale_batch_update_posture_attributeswith a one-devicenodesmap instead, which is the only place in the package that already had the field. TheattributeKeyandvaluedescriptions now also carry the limits the spec states and the tool never mentioned: keys are capped at 128 characters including thecustom: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,targetandeventfilters ontailscale_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.actortakes an exact actor ID or~textto wildcard-match a login or display name,targetmatches any part of any of an entry's targets, andeventtakes an event type such asTAILNET.UPDATE.ACLorNODE.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 onlystartandend. 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.eventis a free string rather than the spec's 138-value enum: that list is still growing (thePAM_*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_logsgets no filters, because the spec gives that endpoint onlystartandend. tailscale_get_devicetakesfields. Per the OpenAPI spec the single-device read references the samefieldsparameter as the device listing, and the tool never sent it -- soadvertisedRoutes,enabledRoutes,clientConnectivity,sshEnabled,distro,multipleConnectionsandpostureIdentitywere missing from the obvious answer to "tell me everything about this device", and reaching them meant knowing to calltailscale_list_deviceswithfields: "all"and anodeIdfilter instead. It takes the spec's two values,allanddefault, and nothing else. Omitting it still sends no query string at all: quietly defaulting toallwould start handing every caller serial numbers, magicsock endpoints and -- where a posture integration collects them -- MAC addresses that nobody asked for.tailscale_list_devicesfilter values may be arrays, which repeat the key. The spec's own filter example isisEphemeral=true&tags=tag:prod&tags=tag:subnetrouter, returning devices whose tags contain BOTH; a JSON object cannot carry a duplicate key and the handler usedURLSearchParams.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.fieldsis still refused: withappendin place it no longer overwrites the top-levelfields, but a secondfields=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_webhooktakesproviderType. 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 optionalproviderTypeofslack,mattermost,googlechatordiscord, 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 carriessubscriptionsalone, and Tailscale's Terraform provider marksprovider_typeas 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_statustakespeersandactiveOnly, and declares a result-size cap. The tool had no inputs at all and sent a baretailscale 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'scmd/tailscale/cli/status.goboth narrowing flags do apply in JSON mode:--peers=falseswaps in the peerless status call before the JSON branch, and--activedeletes every peer without a recent session inside it.peers: falsenow sends the first andactiveOnly: truethe 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 whenTAILSCALE_LOCAL_CLI=1registers 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_statusdescription says how to read a peer's path. Upstream decides it in order -- direct whenCurAddris set, peer-relayed whenPeerRelayis set, otherwise DERP via the region inRelay-- andRelaycarries the peer's home DERP region whether or not traffic is relayed. So an agent reading a non-emptyRelayas "this path is DERP-relayed" gets it wrong for every direct peer, which is the misdiagnosis the description now heads off. - Webhook
subscriptionsaccept the two category values.categoryTailnetManagementandcategoryDeviceMisconfigurationssubscribe 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 throughTAILSCALE_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 testfails 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 spawnsnodeitself never reads it -- sobin/tailscale-mcp.mjs, which has always enforced a floor for oam, enforced none for the runtime it falls back to, and neither diddist/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 declaresutil.styleText(Node 20.12),process.getBuiltinModule(20.16) andnode:sqlite(22.5) unconditionally, and the default lib for the tsconfig target is es2022.full, which lends this Node-only packagedocument,windowandlocalStorageon top.src/node-floor.test.tsscans every shipped file insrc/andbin/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_devicestold agents that omittingfieldsreturns 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,allanddefault, and states that "If thefieldsparameter is not supplied, then the default (limited fields) option is used" -- and that default set excludesadvertisedRoutes,enabledRoutes,clientConnectivity,sshEnabled,distro,multipleConnectionsandpostureIdentity. 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 whatalladds, 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 sendfields=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, omittingisEphemeral,multipleConnectionsandpostureIdentity.- Neither device tool mentioned that
lastSeenis now absent on connected devices. Since Tailscale's 2025-10-08 devices API change the field is omitted whenconnectedToControlistrue, and for devices that have never been online -- so on a connected device a missinglastSeenmeans 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 thepostureIdentity.hardwareAddressesandclientConnectivity.derpfields that Tailscale's Go client carries but the spec does not already reach callers unchanged. tailscale_get_audit_logandtailscale_get_network_flow_logsnow always sendend. 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 specendis 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 movesendearlier, which is why the 30-day cap still clears; the same property inverts theend >= startcheck whenstartfalls 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-stringendis 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_whoamiandtailscale_local_service_listappeared only in the collapsed tool reference further down, so a reader of the section that explainsTAILSCALE_LOCAL_CLI=1never 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,keysexample 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 includestailnet. The always-onmaxResultSizeCharssentence said five tools and left outtailscale_diff_acl_access; it now names every declaring tool and carries no count at all. npm testnow fails when those README numbers drift.release-metadata.test.tschecked 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, thedestructiveHint: truecount 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 orfilterTools, as are the Local CLI section's count and table. A sweep intools.test.tsalso fails when a tool description or parameter description names atailscale_*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-aclandvalidate-aclstrip a UTF-8 BOM from the policy file. Every PowerShell redirect writes one --Out-File,Set-Content -Encoding utf8and 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/validateand/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
messageand adataarray 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.extractErrorMessagereturned the message alone, and thedeploy-acl/validate-aclCLI dropped the array a second time in its own parser, sotailscale_update_aclreached the agent asError: test(s) failedand CI printedACL validation failed: test(s) failed-- with nothing to act on. A shared renderer now prints each entry the way upstream'sgitops-pusherdoes (For user <u>:, thenErrors found:/Warnings found:), under the message, in the MCP tool error, in thetailscale://tailnet/aclresource and in the CLI's CI logs. Validation warnings still fail the run, as they do ingitops-pusherand Tailscale's Go client, but their text is now printed instead of discarded. The spec types the array's items as a bareobject, 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 ownErrors found:heading. tailscale_create_aws_external_idsendsreusableand is no longer marked idempotent. It used to POST no body at all while advertisingidempotentHint: 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 optionalreusableboolean, and the same ID comes back on later calls "if and only if those calls also markreusableas 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 passesreusable: falseprecisely 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 sendsreusable: trueby default -- the admin-console behaviour, and the one that makes "create or get" true for the mint, paste into IAM, validate flow -- and takesreusable: falsefor a fresh ID per call. The hint is false becausereusable: falseis 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_keyssaid 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 withoutallthe set "depends on the access token used to make the request": a user-owned API key returns only that user's keys -- which includes theapiaccess 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_keynow warns about the consequence the spec's own "deletes a specific api access token or auth key" implies: akeyIdnaming the token this server runs on revokes its credential, and every later call fails with 401 until it is reconfigured.tailscale_get_keyalso now says a revoked or expired key is still returned, carryinginvalid: true. Theallparameter 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_invitessaid "List all user invites"; the spec's endpoint is "List all open (not yet accepted) user invites to the tailnet", and theUserInviteschema 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_nameserversandtailscale_set_dns_preferencessaid 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 --magicDNSin the nameserver response. Andtailscale_create_oauth_appcalledauth_keys:create:once"the supported scope"; it is the one Tailscale's device-provisioning guide documents, but the API reference's example shows the bareauth_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_dnsandtailscale_update_split_dnsrejectednull, 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 tonull", and both the PUT and the PATCH state that "setting the value of a mapping tonullclears 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: ontailscale_update_split_dnsthe description at least named this package's empty-array alternative, and ontailscale_set_split_dnsit named neither, leaving the agent to guess or give up on a domain it had been asked to unset. Both now take a list ornullper domain and still forward the body verbatim, so anullgoes 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 onlynullis 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
tailscalebinary where it is actually installed, and say something useful when they cannot. Discovery was a bare PATH lookup with aTAILSCALE_BINARYoverride 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/localon macOS, and to/usr/binand the snap wrapper on Linux -- onestateach, no subprocess, PATH still first.tailscale.exeis 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 -- whenTAILSCALE_BINARYwas 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-sidetailscaledis 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_statustimeout now points atpeers: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 saidpeersis what makes the response scale.--peers=falseswaps in the peerless status call before any peer is serialized, so it cuts the work and not just the output;activeOnlyis deliberately not suggested there, because--activefilters 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_accessreads 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 byTAILSCALE_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 ofTAILSCALE_REQUEST_BUDGET_MSfrom 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 sameRUN_INTEGRATION_TESTS=1flag 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_TAILNETtakes, and no longer shows a.ts.netname 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 likeT1234CNTRL. 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 "setTAILSCALE_TAILNETto specify one explicitly" and stop there, naming no value at all, while the GitHub Actions example filled the gap withyour-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 ongetOAuthTailnetandgetTailnetinsrc/api.tsand on the delete guard insrc/tools/tailnets.tsnow 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-skillis an alpha, knowledge-only skill that teaches an agent tocurlthe 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 thetailscaleCLI?" 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_TOOLSgroup, per the OpenAPI spec, including the cases a guess gets wrong: the service host and approval tools needdevices:coreas well asservices, reads included; the ACL scopes need device scopes alongside;tailscale_validate_aws_trust_policyis a read that needs thelog_streamingwrite 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, andrelease-metadata.test.tsfails 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, itsscopeshint and the README's "OAuth client for our CI pipeline" example all suggesteddevices:readandacl. Tailscale's trust-credentials reference lists both under "Legacy scopes", superseded on 2024-11-14 bydevices:core[:read]andpolicy_file[:read], so an agent copying the example minted a credential in the older, coarser vocabulary -- onedevices:readgrant covers the device list, auth keys, attributes and tags together. The same schema'stagshint had already moved todevices:coreandauth_keys, so the file contradicted itself. The examples now readdevices:core:readanddns:read; thescopeshint 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'skeyswarning sayspolicy_fileandallwhere it saidacl. 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
deviceIdinput now says the nodeId is the preferred identifier. Per the OpenAPI spec's shareddeviceIdparameter, "Using the device'snodeIdis preferred, but its numericidvalue can also be used", and Tailscale's Go client marks the numeric field a legacy identifier. Seventeen of the eighteendeviceIdinputs 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 everytools/list, which is the price of the thing it fixes: agents copy the identifier the description shows. tailscale_rename_devicesays a base name is accepted and an empty name resets the device to its OS hostname. It describednameas "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 "ifnameis 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 openz.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_tailnetdocuments the display-name rules, the per-organization cap and thealreadyExistsresponse field. Per the OpenAPI specdisplayName"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 analreadyExistsboolean 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 carryingalreadyExists: trueand a 400 for a name already in use, so the description says to calltailscale_list_org_tailnetsafter a timeout rather than assume either, the annotation comment says the same, andidempotentHintstays 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'sclientIdtold 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 -- andcloudIdnever mentioned either provider, whiletenantIdtold 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,clientIdnames 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_aclnow sends theIf-MatchETag quoted, whatever quoting the caller supplied.tailscale_get_aclhands 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-v2trims any quotes off the value and formats it with%qto put them back, andgitops-pusherconcatenates 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 aW/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. Thedeploy-aclCLI is unaffected: it forwards the ETag the API itself returned, which is always quoted.- The
etagdescription namests-default, the sentinel for the first write to a fresh tailnet. Per the OpenAPI spec, settingIf-Matchtots-defaultreplaces 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, sinceetagis 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 priortailscale_get_acl-- the spec says the GET returns anETagheader 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'sfilesallow-list publishes onlybin/tailscale-mcp.mjs,dist/index.js,LICENSEandREADME.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,--executehas 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 bysrc/live-fixtures.test.tsin the ordinary offlinenpm 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".