@yawlabs/tailscale-mcp
Use this server to inspect and administer a Tailscale tailnet through typed MCP tools, with filters and write gating for safety.
Check API/auth status and explain which tools are available or withheld by configuration.
Manage devices: list/get, authorize/deauthorize, delete, rename, expire, routes, tags, IPs, key expiry, posture attributes, and bulk posture updates.
Work with ACL policy: get HuJSON + ETag, validate, preview, diff access, and update safely.
Configure DNS: nameservers, search paths, split DNS, MagicDNS preferences, and unified DNS config.
Manage keys and trust credentials: auth keys, OAuth clients, federated identities, API access tokens, and OAuth apps.
Administer users, roles, approval, suspension/restoration/deletion, and user invites.
Manage tailnet settings, contacts, and contact verification.
Manage organization/API-only tailnets: list, create, and delete with OAuth.
Manage webhooks, posture integrations, Services and service-host approvals, and device invites.
Configure log streaming and validate AWS external IDs/trust policies.
Read audit logs and network flow logs with server-side filters.
Optionally run local Tailscale CLI diagnostics when enabled.
Browse read-only resources for tailnet status, devices, ACL policy, and DNS config.
Integrates with the Tailscale v2 API to manage a tailnet, including tools for devices, ACLs, DNS, keys, users, webhooks, audit logs, and other administrative functions.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@@yawlabs/tailscale-mcp list devices that haven't checked in for 30 days"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
@yawlabs/tailscale-mcp
One click adds this to your local Yaw MCP config so it's available in every Yaw Terminal session. Or install manually below.
Ask your agent questions about your tailnet and have it act on the answers. 97 admin-API tools + 6 optional local-CLI diagnostics + 1 always-on catalog tool + 4 resources spanning the Tailscale v2 API — devices, ACLs, DNS, keys and trust credentials, users, invites, webhooks, log streaming, posture, services, and organization tailnets. Backed by 1900+ unit tests and an opt-in live-tailnet integration suite.
Built and maintained by Yaw Labs.
What's the point if the API already exists?
You could curl the Tailscale API. The point isn't replacing curl — it's letting an agent compose multi-endpoint workflows in one turn without writing a script:
"Which devices haven't checked in for 30 days and have key expiry disabled?" — lists devices, filters by
lastSeen(online devices carry none), filters bykeyExpiryDisabled, returns a table. Three endpoints, one question."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.
"Draft an ACL change that lets
tag:mobilereachtag:dashboardbut nottag:db, preserving my comments" — reads the current HuJSON, proposes a minimal diff, validates it against the API, returns the diff for you to apply."Rotate every auth key older than 90 days and print the new ones" — iterates, creates new keys with matching tags, revokes the old ones.
"Create an OAuth client for our CI pipeline scoped to
devices:core:readanddns:read" — creates a trust credential viatailscale_create_keywithkeyType=client, returns the credentials once (save them immediately).
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.
If all you need is one endpoint in a CI job, use curl — we even have a CLI subcommand for the common ACL-from-git case. The MCP is for the interactive, exploratory, "I don't know what I need yet" work.
Related MCP server: tailscale-blade-mcp
Why MCP vs. a skill or the tailscale CLI?
Reasonable question. Both have their place. Where this MCP is better:
Broad admin API coverage. The
tailscaleCLI 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 tocurlanyway.Typed tool surface, not string parsing. Every tool has a Zod-validated input schema and a structured response. No brittle
tailscale status --json | jqpipelines that break when the schema evolves.Cross-client, and updates arrive as a version bump. An MCP server works in Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, and anything else that speaks MCP; a skill written to the Agent Skills standard travels between agents too, but it is prose you maintain. Version bumps ship through
npx— nobody rewrites a skill's instructions when Tailscale adds an endpoint.Safe-by-default writes. Every tool declares
readOnlyHint/destructiveHint/idempotentHintso clients can skip confirmation on reads and require it on mutations. A skill that shells out to the CLI can't express that.Real tests. 1900+ 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.
If you already have a skill that covers your 10% of Tailscale workflows, great — keep it. The MCP is for the other 90%.
What about Tailscale's own MCP endpoints and skill? As of 2026-09-19 they solve different problems, and nothing here duplicates them. Tailscale's official MCP tools are two alpha built-in connectors inside Aperture: Tailnet, whose Tailnet_provision_node returns a single-use auth key so an agent can join one new node after a person approves it, and Tailscale SSH, whose TailnetSSH_list_machines and TailnetSSH_run_command discover SSH-enabled machines and run one command on one of them. They require Aperture. tailscale/tailscale-skill is an alpha, knowledge-only skill — reference material that teaches an agent to curl the v2 API, with no server of its own. Neither exposes the admin API as typed tools, so the overlap with this server is close to nil. Nor can Aperture front this one today: this server speaks stdio, and Aperture proxies only URL-addressable Streamable-HTTP or SSE servers. Both of those products are alpha, so treat the date on this paragraph as its expiry.
Trust signals
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:
1900+ tests (
node --test) covering every tool's input validation, API shape, and error handling. Runnpm testto see them pass locally.Local release flow via
release.sh: lint + test + bump + tag + push + npm publish + MCP Registry publish, all from the workstation. No CI workflow to babysit.Dependabot alerts surface on this repo and get fixed, not ignored.
Every tool names a real endpoint from the OpenAPI spec. If it's in the tool list, it calls a path documented in the current v2 API -- no placeholder 404 tools. To be clear about the limits of that: no recorded live exchange ships with this repo (
fixtures/live/is empty on purpose), and no claim here rests on an observation against a live tailnet.
Issues and PRs are triaged. File one if something is off — github.com/YawLabs/tailscale-mcp/issues.
Quick start
1. Set your API key
Get an API key from Tailscale Admin Console > Settings > Keys and set it where your MCP client will see it. The .mcp.json env block in step 2 works identically on every platform and is the option to prefer; to export it from a shell profile instead (~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish):
macOS / Linux / WSL (bash, zsh):
export TAILSCALE_API_KEY="tskey-api-..."fish:
set -Ux TAILSCALE_API_KEY tskey-api-...Windows (PowerShell 5.1 and 7) — [Environment]::SetEnvironmentVariable persists it for the user, where $env: alone lasts only for the session:
[Environment]::SetEnvironmentVariable('TAILSCALE_API_KEY', 'tskey-api-...', 'User')2. Create .mcp.json in your project root
macOS / Linux / WSL:
{
"mcpServers": {
"tailscale": {
"command": "npx",
"args": ["-y", "@yawlabs/tailscale-mcp@latest"]
}
}
}Windows:
{
"mcpServers": {
"tailscale": {
"command": "cmd",
"args": ["/c", "npx", "-y", "@yawlabs/tailscale-mcp@latest"]
}
}
}Why the extra step on Windows? On Windows,
npxis a.cmdfile, and Node 20+ refuses to spawn.cmdfiles directly. Wrapping withcmd /cis the standard workaround.
3. Restart and approve
Restart Claude Code (or your MCP client) and approve the Tailscale MCP server when prompted.
That's it. Now ask your agent:
"List my Tailscale devices that haven't been seen in the last 7 days"
"Summarize every ACL change in the audit log from yesterday"
"Draft an ACL rule that lets
tag:cireachtag:registryon port 5000 only"
Too many tools? Subset them.
97 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:
The
envblocks below show only the variable under discussion. Your credentials come from the environment, as set in Quick start — keep them in your shell profile rather than in the client's JSON config, which is world-readable on most systems and easy to commit by accident.
Option 1: TAILSCALE_PROFILE (preset, easiest)
{
"env": {
"TAILSCALE_PROFILE": "core"
}
}minimal(20 tools) —status,devices,audit. Observe the tailnet, read the audit log.core(52 tools) — addsacl,dns,keys,users. The day-to-day admin surface.full(97 tools, default) — everything. Same as omitting the env var.
Profile names are case-insensitive and ignore surrounding whitespace (Core selects core). The group names in TAILSCALE_TOOLS and TAILSCALE_WRITE_GROUPS are case-sensitive.
Option 2: TAILSCALE_TOOLS (explicit group list)
{
"env": {
"TAILSCALE_TOOLS": "devices,acl,dns,audit"
}
}Comma-separated group names. Overrides TAILSCALE_PROFILE when both are set — use this when the presets aren't quite right.
Valid group names: status, devices, acl, dns, keys, users, tailnet, org-tailnets, webhooks, posture, audit, invites, services, log-streaming. The local-cli group is also available, but only when TAILSCALE_LOCAL_CLI=1 is set — see Local CLI integration.
Option 3: TAILSCALE_READONLY (drop mutations)
{
"env": {
"TAILSCALE_PROFILE": "core",
"TAILSCALE_READONLY": "1"
}
}Set to 1 or true to drop every tool without readOnlyHint: true. Stacks with TAILSCALE_PROFILE or TAILSCALE_TOOLS as an intersection — combine for maximum minimalism.
Confirming what loaded
The server logs the active filter to stderr on startup:
@yawlabs/tailscale-mcp v0.22.0 ready (20 tools, profile=minimal, readonly)When both TAILSCALE_PROFILE and TAILSCALE_TOOLS are set, TAILSCALE_TOOLS wins. The banner marks the profile as overridden so the precedence is obvious at a glance — no need to guess which filter actually applied:
@yawlabs/tailscale-mcp v0.22.0 ready (22 tools, profile=core (overridden by TAILSCALE_TOOLS), groups=devices,acl)The "(overridden)" marker only fires for substantive profiles (minimal / core); profile=full is a no-op preset, so it's shown without the marker when TAILSCALE_TOOLS is also set.
If you don't set any filter, startup prints a tip pointing you at the profiles.
And how the agent knows
Everything above is stderr -- your MCP client's log. The model never sees it, so a withheld tool and a tool that was never built look identical from the agent's side. That is how an agent ends up working around a restriction instead of reporting it.
tailscale_tool_groups closes that gap. It is always registered, whatever the
filters say, and answers the question in-band:
> "Why can't you delete that device?"
tailscale_tool_groups({ toolName: "tailscale_delete_device" })
{
"tool": "tailscale_delete_device",
"available": false,
"group": "devices",
"kind": "write",
"reason": "TAILSCALE_WRITE_GROUPS is set to \"dns\", which does not grant writes here",
"toEnable": "add \"devices\" to TAILSCALE_WRITE_GROUPS (e.g. \"dns,devices\")"
}It separates the three cases an agent otherwise cannot tell apart:
Case | What the agent should do |
No such tool exists, under any configuration | Find another approach -- no setting will produce it |
Exists, but its group is not loaded | Report |
Exists and loaded, but writes are withheld there | Same -- the fix is yours, not a workaround |
Called with no arguments it lists every group with its availability and, for anything withheld, the exact environment change that would restore it. It reads no network and needs no credentials, so it works even when the server is misconfigured.
Scoping writes to areas
TAILSCALE_WRITE_GROUPS names the areas an agent may write to. Everything else stays readable:
{
"env": {
"TAILSCALE_WRITE_GROUPS": "devices,keys"
}
}That serves all 41 read tools plus the 18 writes in devices and keys, and withholds the other 38 writes — the ACL, DNS, users, tailnet, webhooks, posture, services, invites, org-tailnets and log-streaming writes are simply not registered. Unset means no write gate, which is the shipped default.
Read this first: this filters the tool list, not your API token. The server still holds one credential with full tailnet authority in every configuration. An agent that also has a shell can
curl api.tailscale.comwith that same token and do everything this knob withheld. Scope the Tailscale OAuth client itself to the areas you actually need (scopes per tool group) — that bound survives outside this process; this one does not.TAILSCALE_WRITE_GROUPSis the low-friction complement to credential scoping, not a replacement for it.
What a grant actually contains
Group names are the same ones TAILSCALE_TOOLS uses. Writes per group:
Group | Writes | Group | Writes |
| 13 |
| 5 |
| 7 |
| 3 |
| 6 |
| 3 |
| 5 |
| 3 |
| 5 |
| 3 |
| 2 |
| 1 |
status, audit and the opt-in local-cli group contain no writes at all, so granting them does nothing.
devices is the widest grant, and the one most likely to be set. In a compose file write=devices reads like "device admin", but it hands over delete_device, set_devices_authorized, expire_device, deauthorize_device, set_device_routes, set_device_tags and update_device_key alongside rename_device. There is no finer setting: an honest "safe subset" of devices is rename_device alone, and a knob whose useful value is one tool is not a knob.
Three grants are tailnet-admin-equivalent
keys, users and acl are not blocked — CI key rotation legitimately needs keys — but grant them knowing:
keys—tailscale_create_keymints an OAuth client with whatever scopes the caller asks for, includingpolicy_fileandall. That credential outlives the agent's session and is not subject to this or any other setting here.users—tailscale_update_user_roleacceptsowner.acl—tailscale_update_aclrewrites policy for every principal in the tailnet.
The server prints this on startup when your grant includes one of them.
What it does and does not bound
It bounds where an agent may write. It does not bound severity within a granted area: inside a granted group, writes run unattended, including the grant-direction ones. Pair it with TAILSCALE_REQUIRE_APPROVAL=1 when a human is at the keyboard — but understand that for an unattended agent that pairing contributes nothing, because a never-prompt client denies those calls rather than prompting.
Precedence, and what happens when you get it wrong
Situation | Result |
Unset, empty, whitespace, or commas-only | No write gate. |
| Readonly wins; banner says |
A name is misspelled ( | Grants nothing and names the typo. Unlike |
Partly misspelled ( | Grants the valid half, warns about the rest. |
A granted group is not loaded by | The grant has no effect; a separate warning says so, since the name is not a typo. |
| Not reserved words — they are unknown group names, so they grant nothing. Use |
Names are case-sensitive, matching TAILSCALE_TOOLS.
The upgrade contract: upgrading this package can never widen the set of areas an agent may write to. A new group is in nobody's grant until a human types its name. It can, however, add tools inside an area you already granted — a pinned test makes that a reviewed line in the diff rather than a silent change.
The startup banner shows what applied:
@yawlabs/tailscale-mcp v0.22.0 ready (59 tools, write=devices,keys)Requiring approval on irreversible tools
readOnlyHint / destructiveHint are advisory — the MCP spec says clients MUST treat annotations as untrusted, and most don't gate on them. TAILSCALE_REQUIRE_APPROVAL=1 adds a stronger signal that supported clients enforce:
{
"env": {
"TAILSCALE_REQUIRE_APPROVAL": "1"
}
}Nine tools are then advertised with _meta["anthropic/requiresUserInteraction"], which forces a confirmation prompt even when an allow-rule would otherwise auto-approve the call:
Tool | Why it's on the list |
| Can lock every device out of the tailnet; the previous HuJSON (comments included) is gone unless you captured it |
| The device must re-enroll |
| No undelete |
| Destroys an entire tailnet |
| The secret is never returned again |
| Same |
| Same |
| Same |
| Same |
The line drawn is "this server cannot undo it with information you still hold", which is narrower than the 23 tools annotated destructiveHint: true. tailscale_suspend_user is deliberately excluded — tailscale_restore_user reverses it. So are tailscale_deauthorize_device (reversed by tailscale_authorize_device) and the replace-all setters, which all have a get_* counterpart you can read before writing.
Opt-in on purpose, and read this before turning it on. In a client mode that never prompts (an unattended agent, a CI run), the flag causes those calls to be denied rather than run. That is the right default when a human is at the keyboard and the wrong one when nobody is, so it stays off unless you set it.
Requires a client that honors the annotation; Claude Code added support in v2.1.199. Clients that don't recognize it ignore it, so setting the variable is never worse than leaving it off.
Separately and always on, the tools whose response size scales with the tailnet rather than with the request — tailscale_list_devices, tailscale_list_users, tailscale_get_acl, tailscale_diff_acl_access, tailscale_get_audit_log, tailscale_get_network_flow_logs, tailscale_local_status — declare _meta["anthropic/maxResultSizeChars"], so a large-but-legitimate result stays inline instead of being truncated into a file reference the agent has to read back mid-task. The last of those is the one entry behind an opt-in: it declares the cap whenever TAILSCALE_LOCAL_CLI=1 registers it, and is absent entirely otherwise.
Using with mcp.hosting / mcph
If you run this server through mcp.hosting (via the @yawlabs/mcph local agent), the two filtering layers compose cleanly:
Server-side —
TAILSCALE_PROFILE/TAILSCALE_TOOLS/TAILSCALE_READONLYreduce the tool surface before mcph sees it. The unloaded tools aren't registered at all.Client-side — mcph's
mcp_connect_activate({ tools: [...] })filters further for what appears intools/list. Tools not in that list stay reachable viamcp_connect_dispatch, so you don't lose capability.
Recommended pattern for mcph users: set TAILSCALE_PROFILE=core (or narrower) in your mcp.hosting server config, then let mcph handle per-conversation activation on top. The server stays lean by default, and mcp_connect_dispatch covers the long-tail tools for ad-hoc needs.
Authentication
API key (simplest): Set TAILSCALE_API_KEY in your shell or MCP config.
OAuth (scoped access): For fine-grained permissions, set TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET instead. Create an OAuth client at Tailscale Admin Console > Settings > Trust credentials, with the scopes listed below for the tool groups you load.
The server checks for an API key first, then falls back to OAuth. If neither is set, tools return a clear error telling you what to configure — the server still starts, so your MCP client doesn't loop restarting.
Tailnet: Uses the credential's own tailnet (-) automatically, which is what most setups want. To name one explicitly, set TAILSCALE_TAILNET to the Tailnet ID shown under Settings > General in the admin console — it looks like T1234CNTRL. Tailnets created before October 2025 can still use their legacy organization name; newer ones have no such name to use. Per the OpenAPI spec, the Tailnet ID is the preferred identifier either way.
TAILSCALE_OAUTH_TAILNET — target an API-only tailnet (one created by tailscale_create_org_tailnet). Those tailnets are not reachable with a plain client-credentials exchange: you authenticate with an OAuth client belonging to the creating tailnet (all scope) and the target rides on the token request. Set this to the new tailnet's id. Deliberately separate from TAILSCALE_TAILNET so the default token exchange is unchanged for everyone else. If you set this, leave TAILSCALE_TAILNET unset (or -) so tool requests follow the token — pointing the two at different tailnets makes every tailnet-scoped tool return 403, and the server warns about it at startup.
OAuth scopes by tool group
The scopes an OAuth client needs for each TAILSCALE_TOOLS group. Grant the Read column for the groups you load, and add the Write column for the ones you let it change; Notes lists what a group needs beyond that. all:read (every read scope) and all (everything) are the broadest grants there are.
Group | Read | Write | Notes |
|
| none | Reads the device list and the tailnet settings, and still returns one when the other is refused. |
|
|
| The route tools use the |
|
|
| Tailscale requires the device scopes alongside |
|
|
| Unverified. The OpenAPI spec names no scope on any DNS operation, and the trust credentials doc these come from does not list |
|
|
| Key scopes go by key type: |
|
|
| |
|
|
| Settings are split by field: network flow logging needs |
|
|
|
|
|
|
| |
|
|
| The same scope governs most tailnet settings, so a client that can manage posture integrations can change those too. |
|
| none |
|
|
|
| Creating, resending and accepting a device invite ( |
|
|
|
|
|
|
| Streaming to a private endpoint also needs |
| none | none | Runs the local |
Scopes are taken from Tailscale's OpenAPI spec as of 2026-09-19. The DNS row, which the spec omits, and the device scopes in the acl row come from the trust credentials doc. All of it is read from the documentation, not observed against a tailnet.
Reliability and debugging
429 and gateway-error retry (built-in). HTTP 429, 502, 503 and 504 are retried up to 3 times on the idempotent methods (GET, PUT, DELETE), honoring the Retry-After header (both seconds-integer and HTTP-date forms). Falls back to exponential backoff with jitter, capped at 30s per wait. No env var needed — this is on by default. Workflows like "rotate every key older than 90 days" no longer fail mid-loop on Tailscale's per-tenant rate limits, and a gateway blip no longer fails a whole tool call: per the OpenAPI spec, 504 is documented on every device and service operation with the message "request took too long to process, please try again later", and 502 on the log reads. HTTP 500 is not retried — it means the server failed to process the request, not that something in front of it gave up. POST and PATCH are never retried, on any status. A DELETE that retried past a gateway error and then got a 404 says so in its error, because the attempt that timed out may already have deleted the resource.
TAILSCALE_DEBUG=1 — log every HTTP method, URL, status, and elapsed time to stderr. Authorization headers are never logged. Use this when a tool returns an unexpected error and you want to see the actual request that went out. Example:
[tailscale-mcp] GET https://api.tailscale.com/api/v2/tailnet/-/devices
[tailscale-mcp] <- 200 (148ms)TAILSCALE_MAX_CONCURRENT=N — cap in-flight API requests at N. Default is unlimited (no behavior change for users who don't opt in). Useful when an agent fans out aggressively against a tailnet that has stricter limits than the per-call retry can absorb.
TAILSCALE_REQUEST_BUDGET_MS=N — total wall-clock budget per request, including retries and their sleeps. Default 90000 (90s). When the next retry's predicted wall time would exceed the budget, the call surfaces the error immediately instead of holding the line. For a gateway 5xx that prediction also charges the duration of the attempt that just failed: a 504 arrives only after the gateway has already waited, so the retry most likely costs the same again, and spending the rest of the budget on it would leave your client with silence instead of the 504. A call that retries past a gateway 5xx is also held to half this budget from that point on — 45s by default, under the 60s low end of the usual MCP client timeout — since those attempts cost gateway wait time rather than backoff, and a chain of them can outlast the client while a 429 chain cannot. Raising this value raises that ceiling with it; a 429 chain keeps the whole budget either way. Tune lower if your MCP client has a tighter outer timeout. Non-idempotent methods (POST, PATCH) are never retried — those return immediately regardless of budget.
TAILSCALE_RETRY_BASE_DELAY_MS=N — base delay for the exponential backoff between retries; attempt N waits base * 2^N (capped at 30s, plus jitter). Default 1000 (1s), so a fully-exhausted retry chain spends roughly 1s + 2s + 4s sleeping. Pairs with TAILSCALE_REQUEST_BUDGET_MS: lowering the budget on its own doesn't get you more retries, it just makes the default backoff exhaust the budget sooner and give up. Shrink both if you want "retry hard, fail fast". A server-supplied Retry-After header always wins over this value.
TAILSCALE_EXTRA_WEBHOOK_EVENTS=eventA,eventB — opt-in escape hatch for webhook event types Tailscale ships after the latest release of this package. The webhook tools validate subscriptions against a strict static catalog so typos and stale event names fail fast with a clear error; if you need a brand-new event before the catalog catches up, list it here (comma-separated) and the schema will accept it. The two category subscriptions (categoryTailnetManagement, categoryDeviceMisconfigurations) are in the catalog, so they need no entry here. Please also open an issue so the static list catches up.
TAILSCALE_EXTRA_POSTURE_PROVIDERS=providerA,providerB — the same escape hatch for device-posture integration providers. tailscale_create_posture_integration validates provider against a static list (falcon, fleet, huntress, intune, jamfpro, kandji, kolide, sentinelone); if Tailscale adds one before this package catches up, list it here rather than waiting for a release. This field used to be a closed enum, which made a newly-supported provider uncreatable rather than merely unvalidated.
Friendlier error messages. JSON error bodies of the form {"message":"..."} or {"error":"..."} are unwrapped before display, so you see the prose explanation instead of raw JSON. When the body also carries a data array — which the ACL endpoints use to report a failing policy test — it is rendered under the message, so a rejected policy says which user and which assertion failed instead of just test(s) failed. 401s still get the full multi-line auth-error formatter (with the Windows env-var hint when applicable).
Local CLI integration (opt-in)
Most tools talk to the Tailscale v2 admin API — they describe the tailnet. Sometimes you want to ask about this machine's view: is it actually connected? What DERP region is it on? How far is my-laptop from here? Those answers come from the local tailscale binary, not the admin API.
Set TAILSCALE_LOCAL_CLI=1 (in your shell or .mcp.json env block) to add 6 read-only diagnostic tools:
Tool | Equivalent CLI command | Use it for |
|
| This machine's connection state + peers it can see; |
|
| Latency probe to another tailnet node (direct vs DERP-relayed) |
|
| NAT type, DERP latency map, IPv4/IPv6 support |
|
| Which client version is actually running |
|
| Which user and device this machine is authenticated as (needs tailscale >= 1.102.1) |
|
| Tailscale Services visible to this node (needs tailscale >= 1.102.1) |
Requirements: the tailscale binary has to be findable. It's looked up on PATH first, then at the default install paths below, and TAILSCALE_BINARY overrides both with an absolute path of your choosing.
Platform | Where it looks beyond | Notes |
macOS |
| The standard install keeps the CLI inside the app bundle and adds nothing to |
Linux |
| The snap wrapper is outside some minimal |
Windows | — | The installer puts |
WSL |
| These tools report the Linux node, and need Tailscale installed inside the distro with |
The MCP server doesn't need root to run these — they're all diagnostic, not state-mutating. Operations that would need elevation (tailscale up, set --advertise-routes, lock sign) are deliberately not exposed.
When opt-in is on, the startup banner reflects it: @yawlabs/tailscale-mcp v0.22.0 ready (103 tools, local-cli=on) — the 6 local CLI tools are additive on top of the default 97.
Resources (4)
MCP Resources expose read-only data clients can browse without a tool call.
Resource | URI | Description |
Tailnet Status |
| Device count and tailnet settings |
Devices |
| All devices with status and IPs |
ACL Policy |
| Full ACL policy (HuJSON preserved) |
DNS Config |
| Nameservers, search paths, split DNS, MagicDNS |
Tools (97 + 6 opt-in)
Tool | Description |
| Verify API connection, see tailnet info and device count |
Tool | Description |
| List devices (default field subset; |
| Get one device ( |
| Authorize a pending device |
| Deauthorize a device |
| Authorize/deauthorize many devices in one call (parallel, per-id error reporting) |
| Remove a device from the tailnet |
| Rename a device (FQDN or base name; empty string resets to the OS hostname) |
| Expire a device's key, forcing re-authentication |
| Get advertised and enabled subnet routes |
| Enable or disable subnet routes |
| Get all posture attributes for a device |
| Set a custom posture attribute (optional expiry and audit-log comment) |
| Delete a custom posture attribute |
| Set ACL tags on a device |
| Set a device's Tailscale IPv4 address |
| Update device key settings (e.g. disable key expiry) |
| Batch update custom posture attributes across devices |
Tool | Description |
| Get ACL policy with formatting preserved (HuJSON) + ETag |
| Update ACL policy (requires ETag for safe concurrent edits; |
| Validate a policy without applying it |
| Preview rules that would apply to a user or IP |
| Compare a proposed policy against the live one — who gains and loses access |
Tool | Description |
| Get DNS nameservers |
| Set DNS nameservers |
| Get DNS search paths |
| Set DNS search paths |
| Get split DNS configuration |
| Set split DNS configuration (full replace; |
| Update split DNS configuration (partial merge; |
| Get DNS preferences (MagicDNS) |
| Set DNS preferences (MagicDNS) |
| Get unified DNS configuration (all settings in one call) |
| Set unified DNS configuration (all settings in one call) |
Tool | Description |
| List keys (default set depends on the credential; |
| Get details for a key of any type |
| Create an auth key, OAuth client ( |
| Delete a key of any type, including the API access token this server runs on |
| Update a key's description, scopes, tags, or federated claim settings |
| Create an OAuth App for third-party device provisioning (Tailscale alpha) |
| Get an OAuth App's name, redirect URIs, and scopes |
| List every OAuth App registered in the tailnet |
| Delete an OAuth App, revoking its ability to provision devices |
Tool | Description |
| List all users in the tailnet |
| Get details for a specific user |
| Approve a pending user |
| Suspend a user, revoking access |
| Restore a suspended user |
| Update a user's role (owner, admin, member, etc.) |
| Delete a user and all their devices |
Tool | Description |
| Get tailnet settings (HTTPS, device approval, key expiry, etc.) |
| Update tailnet settings (HTTPS certificates, approval, auto-updates, key expiry, posture, regional routing, network flow logging, external ACL management) |
| Get tailnet contacts |
| Set tailnet contacts |
| Resend verification email for a contact |
Tool | Description |
| List webhooks |
| Get a specific webhook |
| Create a webhook (raw JSON, or formatted for Slack / Mattermost / Google Chat / Discord via |
| Update a webhook's endpoint URL and/or subscriptions |
| Delete a webhook |
| Rotate a webhook's secret |
| Send a test event to verify webhook delivery |
Tool | Description |
| List posture integrations |
| Get a posture integration |
| Create a posture integration |
| Update a posture integration |
| Delete a posture integration |
Tool | Description |
| List all Tailscale Services in your tailnet |
| Get details for a specific service |
| Update a service's configuration |
| Delete a service |
| List devices hosting a service |
| Get approval status of a device for a service |
| Approve or reject a device to host a service |
Create and tear down whole tailnets programmatically — useful for per-agent sandboxes,
per-tenant isolation, and ephemeral CI environments. Organizations get 10 tailnets
including the original by default. Unlike every other group here these endpoints live
under /organizations, authenticate only with an OAuth client (the tailnets scope
to create, all to then reach the tailnet), and produce tailnets that are not managed in
the admin console. Set TAILSCALE_OAUTH_TAILNET to operate on one.
Tool | Description |
| List the organization's tailnets (paginated via |
| Create an API-only tailnet; returns its OAuth client secret once |
| Delete a tailnet (the configured one, or an explicit |
Tool | Description |
| List log streaming configurations (both audit and network) |
| Get log streaming config for a log type |
| Set where logs are sent (Axiom, Datadog, Splunk, etc.) |
| Delete a log streaming configuration |
| Check if log streaming is delivering successfully |
| Create/get the AWS external ID for S3 role-based log streaming ( |
| Validate AWS IAM role trust policy for S3 log streaming |
Tool | Description |
| List device invites for a specific device |
| Create a device invite |
| Get a device invite |
| Delete a device invite |
| Accept a device share invitation |
| Resend a device invite email |
Tool | Description |
| List open (not yet accepted) user invites |
| Create a user invite |
| Get a user invite |
| Delete a user invite |
| Resend a user invite email |
Tool | Description |
| Get configuration audit log (who changed what, when); optional server-side actor / target / event filter |
| Get network traffic flow logs between devices |
Tool | Description |
| This machine's view of the tailnet (own connection state, peers, DERP region); narrow with |
| Latency probe to another tailnet node from this machine |
| NAT type, DERP latency map, IPv4/IPv6 support diagnostics |
| Local |
| Which user and device this machine is authenticated as (needs tailscale >= 1.102.1) |
| Tailscale Services visible to this node (needs tailscale >= 1.102.1) |
GitOps: deploy ACLs from CI
For the simple "deploy ACL from git on merge" workflow, you don't need an MCP server or an agent — use the built-in CLI:
# PR check: validate the proposed policy without touching the tailnet
npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
# On merge: ETag fetch + validate + deploy with If-Match, fail-closed at every step
npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.jsonWorks in any CI system. Set TAILSCALE_API_KEY as an env var; TAILSCALE_TAILNET is optional — leave it unset to act on the key's own tailnet, or set it to the Tailnet ID to name one explicitly. Both commands exit non-zero on any failure; deploy-acl refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
When validation reports a failing policy test, the CI log names the user and the assertion (For user user1@example.com: / Errors found:), the same detail upstream's gitops-pusher prints. Validation warnings — a SCIM group that is not syncing, for instance — fail the run too, matching gitops-pusher and Tailscale's own Go client; their text is printed alongside so you can see what was flagged.
A complete GitHub Actions workflow — validate on PR, deploy on merge:
name: tailscale-acl
on:
pull_request:
paths: ["tailscale/acl.json"]
push:
branches: [main]
paths: ["tailscale/acl.json"]
jobs:
acl:
runs-on: ubuntu-latest
env:
TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }}
TAILSCALE_TAILNET: T1234CNTRL # Tailnet ID from Settings > General; or omit: defaults to the key's tailnet
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Validate ACL
if: github.event_name == 'pull_request'
run: npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
- name: Deploy ACL
if: github.event_name == 'push'
run: npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.jsonFor reproducible deploys, replace @latest with a pinned version.
If you hand-roll this with
curlinstead: Tailscale's ACL endpoint only returns theETagheader on GET, not HEAD. Acurl -I(HEAD) ETag fetch silently yields an empty value — and an emptyIf-Matcheither deploys unguarded (clobbering concurrent edits) or trips your guard and fails the deploy. Fetch the ETag with a GET (curl -fsS -D - -o /dev/null ...), and fail the job if it comes back empty. The CLI above does all of this for you.
Optional: Lock the Admin Console to prevent manual edits that drift from git. Ask your agent:
"Set aclsExternallyManagedOn to true and aclsExternalLink to our repo URL"
This shows a read-only banner in the Tailscale Admin Console pointing to your repo. Use the MCP for reads and investigations, and let CI handle the deploy.
Requirements
Node.js 20.11+ to run the server (22+ to develop — the test script passes a glob to
node --test, supported from Node 21)A Tailscale API key or OAuth client credentials
Running on oam.js (optional)
oam.js runs this server unmodified, and the tailscale-mcp command only ever uses the latest oam release, currently 0.19.5. Verified against the published oam 0.19.5: full MCP handshake, all 97 admin-API tools plus tailscale_tool_groups, all 4 resources, identical error responses, and a clean stdout protocol stream — from the shipped bundle and straight from the TypeScript source with no build step.
oam 0.19.5 is the minimum. A floor matters here: releases before 0.9.0 ran child_process.execFile arguments through a shell, re-splitting them on whitespace and executing shell metacharacters inside an argument, and this server shells out to the tailscale binary across its local-CLI tools, so that was a reachable bug rather than a theoretical one.
How the tailscale-mcp command (bin/tailscale-mcp.mjs) picks a runtime:
TAILSCALE_MCP_RUNTIME=auto(the default) — if a client already launched it withoam runon oam 0.19.5 or newer, the server runs in that process. Otherwise it usesOAM_BINwhen that is 0.19.5 or newer, else asks every oam binary it can find —OAM_INSTALL_DIRwhen that is set, then%LOCALAPPDATA%\oam\binand~/.oam/binon Windows,~/.oam/binelsewhere, thenPATH— for its version and uses the newest at or above the floor (on a tie the installed copy wins). With none, it runs on Node. An oam host older than 0.19.5 never serves the server itself: it hands off to the newest usable oam, or to Node onPATH, or exits with an error when there is neither. Whenever it looks for an oam, stderr names anOAM_BINthat was passed over and why; the oam binaries it found and passed over are named, each with its reason, only when no usable oam turns up.TAILSCALE_MCP_RUNTIME=oam— the same, but exit with an error instead of falling back to Node. The error names the remedy for what it actually found:oam self-updatefor an oam older than the floor, a check of the binary for one that would not run, and installing oam only when none was found (and on linux-arm64, which oam publishes no build for, not even then).TAILSCALE_MCP_RUNTIME=node— always Node: in-process undernpx, handed off to Node onPATHwhen a client launches the command withoam run.
The value is case-insensitive and surrounding whitespace is ignored; anything else is warned about on stderr and treated as auto. On Windows only oam.exe counts: an oam.cmd / oam.bat shim is never run, and it is named on stderr only when no usable oam turns up.
Sandboxing (opt-in)
Set TAILSCALE_MCP_SANDBOX=1 to run under oam's --permission model: network restricted to api.tailscale.com:443 -- the only host the bundle contacts, including the OAuth token exchange, and every request is HTTPS -- and filesystem denied. Child-process stays granted because the local-CLI tools shell out to the tailscale binary. That child inherits the server's environment, which under the sandbox holds only the granted variables, so besides the TAILSCALE_* settings the allow-list carries PATH (to find the binary) and the variables a Windows program needs to start -- SYSTEMROOT, WINDIR, SYSTEMDRIVE, TEMP/TMP, USERPROFILE, HOMEDRIVE/HOMEPATH, APPDATA, LOCALAPPDATA -- plus HOME. oam takes even the variables Windows adds to every child from that filtered environment, so without them the CLI started with no SYSTEMROOT. oam matches these names exactly, case included, so on Windows the launcher also grants each one in the spelling your environment uses (Path, SystemRoot, SystemDrive, windir when a client inherits them from Explorer).
Under the sandbox, TAILSCALE_BINARY must point at the native tailscale CLI, not at a Node-based wrapper script: oam hands its permission flags to every child through NODE_OPTIONS, as Node does, and Node refuses --allow-net and --allow-env there and exits before running anything. The native CLI ignores the variable.
It is opt-in rather than default because a wrong grant does not fail loudly. oam denies a non-granted environment variable by making it absent from process.env rather than throwing, so an under-granted TAILSCALE_API_KEY reads as "unauthenticated" rather than "denied". The env allow-list in the launcher is derived from what the shipped bundle actually reads -- if you add a new process.env lookup, extend that list with it.
The sandbox is applied by the tailscale-mcp command, which spawns a fresh oam for it -- even when a client launched the command with oam run -- because --permission is a process-level flag. If it finds no usable oam to spawn, TAILSCALE_MCP_RUNTIME=auto still starts the server, without the sandbox; set TAILSCALE_MCP_RUNTIME=oam to make that an error. TAILSCALE_MCP_RUNTIME=node runs on Node, so it never applies the sandbox.
The sandbox applies to the CLI subcommands too, and with the filesystem denied tailscale-mcp validate-acl <file> / deploy-acl <file> cannot read the policy file. Unset TAILSCALE_MCP_SANDBOX for those commands -- for example, if you export it in a shell profile for the server, run TAILSCALE_MCP_SANDBOX= tailscale-mcp validate-acl policy.hujson.
{
"mcpServers": {
"tailscale": {
"command": "oam",
"args": ["run", "/path/to/tailscale-mcp/dist/index.js"]
}
}
}Measure startup on your own hardware. An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid, and on the machine this was measured on node won it — 437ms vs 1554ms for oam run over 10 warmed runs (an earlier 5-run round showed 326ms vs 427ms; the box was busy, so treat the magnitude as noisy and the direction as the finding). Those runs were taken with an oam older than 0.18.0 and have not been repeated since, so do not read them as a current ranking.
The published tailscale-mcp command prefers the newest usable oam it finds (see above). Without oam that costs almost nothing: discovery is file-existence checks only, never a subprocess, and the fallback runs the server inside the Node process npm already started. With oam installed, though, the command boots Node, runs --version on every oam binary it found to pick the newest, and only then boots oam, so it is always slower than pointing your client at a runtime directly — the config above for oam, node /path/to/tailscale-mcp/dist/index.js for Node. TAILSCALE_MCP_RUNTIME=node skips oam entirely.
Two places oam does win for this repo, both opt-in and neither touching the npm package:
npm run check:oam— type-checks viaoam check(tsgo, TypeScript 7 native). Measured 4015ms against 7680ms fortsc --noEmit, same clean result.npx tsc --noEmitremains the portable default.npm run build:binary:oam— builds the standalone binary viaoam compile(~57.7 MB measured). Writesbin/<platform>-<arch>/(orbin/$TAILSCALE_MCP_BINARY_TARGET/for a cross-build), whichscripts/stage-release-asset.mjsthen stages under the public asset name. If you redistribute that binary it embeds oam's runtime, so ship oam'sLICENSE,NOTICEandTHIRD_PARTY_LICENSES.mdwith it. A cross-build (TAILSCALE_MCP_BINARY_TARGET=<platform>-<arch>) downloads the target's oam release binary as its carrier and checks it against the release's signedRELEASE-MANIFEST, verified withssh-keygen -Y verify(OpenSSH 8.1+) against oam's release keys inscripts/oam-release-keys/; it aborts when the signature, the manifest or the checksum does not check out. AnOAM_VERSIONbelow v0.18.0, released before oam signed its releases, is checked against itsSHA256SUMSonly once that file matches the digest pinned for the tag inscripts/oam-release-keys/presigning-sums.
The source stays runtime-agnostic on purpose: no oam: imports anywhere, and tests stay on node:test. That is what keeps the Node fallback real rather than nominal. Note that any oam invocation writes a bytecode cache to oam/ in the working directory — already in .gitignore.
Contributing
Contributions welcome. See CONTRIBUTING.md for the PR workflow and AI-agent guidelines. Please open an issue to discuss before a PR for anything beyond a typo fix.
git clone https://github.com/YawLabs/tailscale-mcp.git
cd tailscale-mcp
npm install
npm run lint # Biome check
npm run lint:fix # Auto-fix
npm run build # tsc + esbuild bundle
npm test # node --test (full suite)For integration testing against your own tailnet: set TAILSCALE_API_KEY and run node dist/index.js.
Security
Found a vulnerability? See SECURITY.md — please use GitHub's private vulnerability reporting, not a public issue.
License
MIT
Available Tools
98 toolstailscale_accept_device_inviteAccept device inviteBIdempotent
Accept a device share invitation using the invite URL or code.
Inputs: invite (required).
tailscale_approve_userApprove userAIdempotent
Approve a pending user, granting them access to the tailnet.
Inputs: userId (required).
tailscale_authorize_deviceAuthorize deviceAIdempotent
Authorize a device that is pending authorization.
Inputs: deviceId (required).
tailscale_batch_update_posture_attributesBatch update posture attributesAIdempotent
Batch update custom posture attributes across multiple devices. Each attribute key must start with 'custom:'. Uses JSON Merge Patch semantics -- pass null as the attribute config to delete.
Inputs: nodes (required), comment.
tailscale_create_aws_external_idCreate AWS external IDA
Create or get the AWS external ID Tailscale presents when assuming your IAM role for S3 log streaming. Put it in the role trust policy's sts:ExternalId condition, then check it with tailscale_validate_aws_trust_policy.
Inputs: reusable.
tailscale_create_device_inviteCreate device inviteB
Create a device share invitation that allows an external user to access a specific device in your tailnet.
Inputs: deviceId (required), email, multiUse, allowExitNode.
tailscale_create_keyCreate keyA
Create a new key in your tailnet. Supports auth keys (for adding devices), OAuth clients (for programmatic API access), and federated identities (for OIDC-based CI/CD access). Returns the key value -- save it immediately, as it cannot be retrieved again.
SECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive (do not commit it, avoid re-sharing it in unrelated chat history).
Examples:
Auth key: {keyType:'auth', reusable:true, tags:['tag:ci']}
OAuth client: {keyType:'client', scopes:['devices:core:read','dns:read']}
Federated (GitHub Actions): {keyType:'federated', scopes:['devices:core:read'], issuer:'https://token.actions.githubusercontent.com', subject:'repo:my-org/my-repo:*'}
Inputs: tags, issuer, scopes, keyType, +8 more.
tailscale_create_oauth_appCreate OAuth appA
Create an OAuth App for device provisioning (Tailscale alpha). Lets a third-party application enroll a device into your tailnet via the authorization-code flow, after a user consents. Returns the app's client secret -- save it immediately, it cannot be retrieved again.
SECURITY: the response body contains a long-lived credential verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.
Use scope 'auth_keys:create:once' (one auth key per authorization, no refresh token) -- the scope Tailscale's device-provisioning guide documents. The API reference's example shows 'auth_keys:create'; this tool does not restrict the value. Distinct from tailscale_create_key with keyType='client', which mints a machine-to-machine OAuth client instead.
Inputs: name (required), scopes (required), redirectUris (required), allowedNodeAttributes.
tailscale_create_org_tailnetCreate organization tailnetA
Create a new API-only tailnet in your organization. Returns the tailnet (id, displayName, orgId, dnsName, createdAt) AND a freshly-minted OAuth client for it.
SECURITY: the response body contains that OAuth client's secret verbatim, and it cannot be retrieved again. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.
Requires an OAuth client with the 'tailnets' scope -- an API key will not work. To then operate on the new tailnet, set TAILSCALE_OAUTH_TAILNET to its id and use an OAuth client with the 'all' scope.
Organizations are limited to 10 tailnets including the original unless Tailscale sales has raised the limit.
The response may include alreadyExists: true; Tailscale's spec ALSO documents a 400 for a name already in use and neither has been observed, so after a timeout call tailscale_list_org_tailnets before retrying rather than assuming either.
Inputs: displayName (required), organization.
tailscale_create_posture_integrationCreate posture integrationB
Create a new device posture integration.
Inputs: provider (required), clientSecret (required), cloudId, clientId, +1 more.
tailscale_create_user_inviteCreate user inviteC
Create a new user invite that allows someone to join your tailnet.
Inputs: role, email.
tailscale_create_webhookCreate webhookA
Create a new webhook. The response includes the webhook's signing secret -- this is the only opportunity to capture it; save it immediately. Set providerType when the endpoint is a Slack, Mattermost, Google Chat or Discord incoming-webhook URL, so the events arrive in the format that provider renders.
SECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.
Inputs: endpointUrl (required), subscriptions (required), providerType.
tailscale_deauthorize_deviceDeauthorize deviceADestructiveIdempotent
Deauthorize a device, immediately removing its access to the tailnet. The device will need to be re-authorized to reconnect.
Inputs: deviceId (required).
tailscale_delete_deviceDelete deviceADestructiveIdempotent
Permanently remove a device from the tailnet. This is irreversible -- the device must re-authenticate to rejoin.
Inputs: deviceId (required).
tailscale_delete_device_inviteDelete device inviteADestructiveIdempotent
Delete a device invite. This is irreversible -- the invite link will stop working.
Inputs: inviteId (required).
tailscale_delete_device_posture_attributeDelete device posture attributeADestructiveIdempotent
Delete a custom posture attribute from a device. This is irreversible.
Inputs: deviceId (required), attributeKey (required).
tailscale_delete_keyDelete keyADestructiveIdempotent
Delete a key (auth key, API access token, OAuth client, or federated identity). This is irreversible. For auth keys, devices already authenticated are unaffected but no new devices can use it. For OAuth clients and federated identities, any integrations using them lose access immediately. API access tokens are deletable here too: if keyId is the token this server authenticates with -- it shows up in tailscale_list_keys under API-key auth -- the server revokes its own credential and every later call fails with 401 until it is reconfigured.
Inputs: keyId (required).
tailscale_delete_log_stream_configDelete log stream configADestructiveIdempotent
Delete a log streaming configuration. Logs will stop being sent to the configured destination.
Inputs: logType (required).
tailscale_delete_oauth_appDelete OAuth appADestructiveIdempotent
Delete an OAuth App (Tailscale alpha). This is irreversible: the app's client secret stops working immediately and any integration using it loses its device-enrollment path, so no further device can be authorized through it. Devices already enrolled stay in the tailnet, exactly as they do when the auth key that added them is deleted. Use tailscale_list_oauth_apps to find the id.
Inputs: appId (required).
tailscale_delete_posture_integrationDelete posture integrationADestructiveIdempotent
Delete a posture integration. This is irreversible.
Inputs: integrationId (required).
tailscale_delete_serviceDelete serviceADestructiveIdempotent
Delete a Tailscale Service. This is irreversible -- the service's MagicDNS name and virtual IP will be released.
Inputs: serviceName (required).
tailscale_delete_tailnetDelete tailnetADestructiveIdempotent
Permanently delete a tailnet. This is IRREVERSIBLE and removes every device, user, ACL, and key in it.
By default it acts on the tailnet the current credentials point at (TAILSCALE_TAILNET, or TAILSCALE_OAUTH_TAILNET when targeting an API-only tailnet). Pass tailnet to name a different one -- e.g. an id returned by tailscale_list_org_tailnets -- which requires credentials scoped to reach it; UNVERIFIED against a live tailnet, so expect a 403/404 if your token cannot. You must always pass confirmTailnet matching the effective target exactly; the call is refused locally otherwise. That check is a typo guard, not an authorization gate: when you also pass tailnet you are supplying both halves of the comparison, so it proves only that they agree -- it is a genuine second look only on the omit-tailnet path, where the value has to match the operator's environment. Restricting who may delete at all is TAILSCALE_READONLY / TAILSCALE_TOOLS, which drop this tool from the server entirely. Intended for tearing down API-only tailnets created by tailscale_create_org_tailnet.
Inputs: confirmTailnet (required), tailnet.
tailscale_delete_userDelete userADestructiveIdempotent
Delete a user from the tailnet. This is irreversible -- the user and all their devices will be removed.
Inputs: userId (required).
tailscale_delete_user_inviteDelete user inviteADestructiveIdempotent
Delete a user invite. This is irreversible -- the invite link will stop working.
Inputs: inviteId (required).
tailscale_delete_webhookDelete webhookADestructiveIdempotent
Delete a webhook. This is irreversible -- the webhook secret cannot be recovered.
Inputs: webhookId (required).
tailscale_diff_acl_accessDiff ACL accessARead-onlyIdempotent
Answer 'who loses access?' before applying an ACL change. Compares the CURRENT policy against a proposed one and reports, per user, which destinations they gain and lose. Run this before tailscale_update_acl -- validate_acl only checks syntax and the policy's own tests block, so a policy with no tests validates clean while revoking everyone. LIMITS, all reported in the response rather than left to be discovered. It compares USER principals only, so a revocation that runs through a tag or group can show a clean diff, and an empty result is never proof a change is safe. Posture DEFINITION changes ARE detected: posture names are resolved to their rules, so tightening posture:corp shows as a change -- except when a preview omits the definitions map, where it falls back to comparing names. It costs two preview requests per user, so it checks the first 25 by default and stops starting new users after 60 seconds (a user whose previews are already in flight still finishes); either way it sets truncated, reports how many were skipped, and says which limit stopped it. Users whose preview fails are listed in failed and excluded from the compared count -- a failure is never reported as lost access, and if nothing could be compared the call fails rather than returning an empty diff.
Inputs: policy (required), principals, maxPrincipals.
tailscale_expire_deviceExpire device keyADestructiveIdempotent
Expire a device's key, forcing it to re-authenticate.
Inputs: deviceId (required).
tailscale_get_aclGet ACL policyARead-onlyIdempotent
Get the current ACL policy for your tailnet. Returns the raw policy text with original formatting preserved, including comments and trailing commas (HuJSON). Also returns an ETag -- you must pass it to tailscale_update_acl to safely update the policy.
Inputs: no named parameters.
tailscale_get_audit_logGet audit logARead-onlyIdempotent
Get the tailnet audit/configuration log. Shows who changed what and when -- useful for troubleshooting and compliance. Optional actor, target and event filters narrow the query server-side, so a targeted question doesn't have to pull the whole window.
Inputs: start (required), end, actor, event, +1 more.
tailscale_get_contactsGet contactsARead-onlyIdempotent
Get the tailnet contact information (security, support, admin emails).
Inputs: no named parameters.
tailscale_get_deviceGet deviceARead-onlyIdempotent
Get detailed information about a specific device by its ID. Returns the default field subset unless fields: 'all'. 'lastSeen' is omitted while a device is connected (connectedToControl: true) and for devices that have never been online -- on a connected device a missing lastSeen means online now, not never seen.
Inputs: deviceId (required), fields.
tailscale_get_device_inviteGet device inviteCRead-onlyIdempotent
Get details for a specific device invite.
Inputs: inviteId (required).
tailscale_get_device_posture_attributesGet device posture attributesARead-onlyIdempotent
Get all posture attributes for a device, including custom and system-managed attributes.
Inputs: deviceId (required).
tailscale_get_device_routesGet device routesARead-onlyIdempotent
Get the subnet routes a device advertises and which are enabled.
Inputs: deviceId (required).
tailscale_get_dns_configurationGet DNS configuration (unified)ARead-onlyIdempotent
Get the unified DNS configuration for your tailnet, including nameservers, search paths, split DNS, and MagicDNS preference in a single call.
Inputs: no named parameters.
tailscale_get_dns_preferencesGet DNS preferencesBRead-onlyIdempotent
Get DNS preferences for your tailnet, including whether MagicDNS is enabled.
Inputs: no named parameters.
tailscale_get_keyGet keyARead-onlyIdempotent
Get details for a specific key (auth key, API access token, OAuth client, or federated identity). A revoked or expired key is still returned, with invalid: true.
Inputs: keyId (required).
tailscale_get_log_stream_configGet log stream configBRead-onlyIdempotent
Get the log streaming configuration for a specific log type.
Inputs: logType (required).
tailscale_get_log_stream_statusGet log stream statusARead-onlyIdempotent
Get the status of log streaming for a specific log type. Shows whether logs are being delivered successfully.
Inputs: logType (required).
tailscale_get_nameserversGet nameserversBRead-onlyIdempotent
Get the DNS nameservers configured for your tailnet.
Inputs: no named parameters.
tailscale_get_network_flow_logsGet network flow logsBRead-onlyIdempotent
Get network traffic flow logs showing connections between devices. Shows source/destination nodes, timestamps, and traffic metadata -- useful for security monitoring and debugging connectivity.
Inputs: start (required), end.
tailscale_get_oauth_appGet OAuth appARead-onlyIdempotent
Get an OAuth App's configuration (name, redirect URIs, scopes) by its app ID. Use this to verify an app was registered as intended. The client secret is not returned -- it is only available at creation time.
Inputs: appId (required).
tailscale_get_posture_integrationGet posture integrationBRead-onlyIdempotent
Get details for a specific device posture integration.
Inputs: integrationId (required).
tailscale_get_search_pathsGet DNS search pathsARead-onlyIdempotent
Get the DNS search paths configured for your tailnet.
Inputs: no named parameters.
tailscale_get_serviceGet serviceBRead-onlyIdempotent
Get details for a specific Tailscale Service, including its MagicDNS name, virtual IP, and configuration.
Inputs: serviceName (required).
tailscale_get_service_device_approvalGet service device approvalBRead-onlyIdempotent
Get the approval status of a specific device for a Tailscale Service.
Inputs: deviceId (required), serviceName (required).
tailscale_get_split_dnsGet split DNSBRead-onlyIdempotent
Get the split DNS configuration for your tailnet.
Inputs: no named parameters.
tailscale_get_tailnet_settingsGet tailnet settingsARead-onlyIdempotent
Get your tailnet settings (device approval, key expiry, HTTPS certificates, etc.).
Inputs: no named parameters.
tailscale_get_userGet userCRead-onlyIdempotent
Get details for a specific user.
Inputs: userId (required).
tailscale_get_user_inviteGet user inviteCRead-onlyIdempotent
Get details for a specific user invite.
Inputs: inviteId (required).
tailscale_get_webhookGet webhookCRead-onlyIdempotent
Get details for a specific webhook.
Inputs: webhookId (required).
tailscale_list_device_invitesList device invitesCRead-onlyIdempotent
List all device invites for a specific device.
Inputs: deviceId (required).
tailscale_list_devicesList devicesARead-onlyIdempotent
List all devices in your tailnet with their status, IP addresses, OS, and last seen time. 'lastSeen' is omitted while a device is connected (connectedToControl: true) and for devices that have never been online -- on a connected device a missing lastSeen means online now, not never seen.
Inputs: fields, filters.
tailscale_list_keysList keysARead-onlyIdempotent
List keys in your tailnet: auth keys, API access tokens, OAuth clients and federated identities. Without 'all', what comes back depends on the credential this server runs on -- a user-owned API key sees only that user's keys (including the API access token the server itself is using, keyType 'api'); an OAuth-client token sees the tailnet's OAuth clients; a federated-identity token sees its federated identities. Set 'all' to true for the tailnet-wide list (needs the matching :read scopes; only 'all:read' and 'all' return every API access token).
Inputs: all.
tailscale_list_log_stream_configsList log stream configsARead-onlyIdempotent
List all log streaming configurations for your tailnet. Fetches both 'configuration' (audit) and 'network' (flow) log stream configs. Log streaming sends logs to external destinations like Axiom, Datadog, Splunk, Elasticsearch, or S3.
Inputs: no named parameters.
tailscale_list_oauth_appsList OAuth appsARead-onlyIdempotent
List the OAuth Apps registered in your tailnet (Tailscale alpha). Returns an oauthApps array describing each app (id, name, redirect URIs, scopes). Client secrets are NOT included -- a secret is only returned once, by tailscale_create_oauth_app at creation time. This is how you recover the id of an app you did not record; pass that id to tailscale_delete_oauth_app to revoke it.
Inputs: no named parameters.
tailscale_list_org_tailnetsList organization tailnetsARead-onlyIdempotent
List the tailnets in your organization, including API-only tailnets created via the API. Paginated: returns at most limit results (Tailscale defaults to 100) plus a cursor. Pass that cursor back to fetch the next page; an empty cursor in the response means you have reached the end. Requires OAuth authentication.
Inputs: limit, cursor, organization.
tailscale_list_posture_integrationsList posture integrationsARead-onlyIdempotent
List all device posture integrations configured for your tailnet.
Inputs: no named parameters.
tailscale_list_service_hostsList service hostsARead-onlyIdempotent
List devices hosting a specific Tailscale Service.
Inputs: serviceName (required).
tailscale_list_servicesList servicesARead-onlyIdempotent
List all Tailscale Services in your tailnet. Services provide stable MagicDNS names and virtual IPs, decoupled from individual devices. Note: this MCP has no create-service tool. The API documents its Service PUT as create-or-update, but tailscale_update_service sends only the fields you pass, so do not use it to create one. Use the update/delete/approval tools here once the service exists.
Inputs: no named parameters.
tailscale_list_user_invitesList user invitesARead-onlyIdempotent
List the open (not yet accepted) user invites for your tailnet. Accepted invites are not returned.
Inputs: no named parameters.
tailscale_list_usersList usersBRead-onlyIdempotent
List all users in your tailnet.
Inputs: role, type.
tailscale_list_webhooksList webhooksARead-onlyIdempotent
List all webhooks configured for your tailnet.
Inputs: no named parameters.
tailscale_preview_aclPreview ACL rulesARead-onlyIdempotent
Preview the ACL rules that would apply to a specific user or IP address if a proposed policy were applied.
Inputs: type (required), policy (required), previewFor (required).
tailscale_rename_deviceRename deviceAIdempotent
Set the name of a device in the tailnet, or reset it to its OS hostname.
Inputs: name (required), deviceId (required).
tailscale_resend_contact_verificationResend contact verificationC
Resend the verification email for a tailnet contact.
Inputs: contactType (required).
tailscale_resend_device_inviteResend device inviteC
Resend a device invite email.
Inputs: inviteId (required).
tailscale_resend_user_inviteResend user inviteC
Resend a user invite email.
Inputs: inviteId (required).
tailscale_restore_userRestore userAIdempotent
Restore a previously suspended user, re-granting them access to the tailnet.
Inputs: userId (required).
tailscale_rotate_webhook_secretRotate webhook secretA
Rotate a webhook's secret. Returns the new secret -- save it immediately, as it cannot be retrieved again. The old secret is immediately invalidated.
SECURITY: the response body contains the secret verbatim. MCP clients commonly persist tool responses to logs and conversation transcripts; treat this response as sensitive.
Inputs: webhookId (required).
tailscale_set_contactsSet contactsAIdempotent
Update tailnet contact information. Each provided contact type (account/support/security) is PATCHed in parallel; per-type errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the response is data: { applied, failed } -- inspect data.failed for per-type error details.
Inputs: account, support, security.
tailscale_set_device_ipSet device IPAIdempotent
Set the Tailscale IPv4 address for a device.
Inputs: ipv4 (required), deviceId (required).
tailscale_set_device_posture_attributeSet device posture attributeBIdempotent
Set a custom posture attribute on a device. Creates or updates the attribute. Attribute keys must start with 'custom:'. Useful for compliance tracking, JIT access, and custom security policies.
Inputs: value (required), deviceId (required), attributeKey (required), expiry, +1 more.
tailscale_set_device_routesSet device routesADestructiveIdempotent
Set the enabled subnet routes for a device. Replaces all currently enabled routes -- pass the full list of routes you want enabled.
Inputs: routes (required), deviceId (required).
tailscale_set_devices_authorizedSet devices authorized (bulk)ADestructiveIdempotent
Authorize or deauthorize multiple devices in one call. Each device's POST runs in parallel; per-device errors are returned alongside the successes so a partial failure doesn't lose the work that succeeded. On partial failure the call still returns success (ok) with data: { authorized, succeeded, failed } -- inspect data.failed for the per-device errors. POSTs are never retried, so on a large batch a rate-limited device lands in data.failed with status 429: re-call with just those ids, or set TAILSCALE_MAX_CONCURRENT to cap requests in flight. Common use: authorize a batch of newly-enrolled CI hosts, or deauthorize a group of devices flagged by a security review.
Inputs: deviceIds (required), authorized (required).
tailscale_set_device_tagsSet device tagsADestructiveIdempotent
Set ACL tags on a device. Replaces all existing tags -- pass the full list of tags you want applied.
Inputs: tags (required), deviceId (required).
tailscale_set_dns_configurationSet DNS configuration (unified)ADestructiveIdempotent
Set the unified DNS configuration for your tailnet in a single call. Replaces all DNS settings (nameservers, search paths, split DNS, MagicDNS preference). This is a replace-all endpoint: pass every top-level field you want to keep, because a field you omit may be cleared rather than left alone.
Inputs: dns, magicDNS, splitDns, searchPaths.
tailscale_set_dns_preferencesSet DNS preferencesAIdempotent
Set DNS preferences for your tailnet, such as enabling or disabling MagicDNS. The API reference says enabling can fail when the tailnet has no nameservers; if it does, add one with tailscale_set_nameservers first.
Inputs: magicDNS (required).
tailscale_set_log_stream_configSet log stream configAIdempotent
Set the log streaming configuration for a specific log type. Configures where logs are sent (e.g. Axiom, Datadog, Splunk, Elasticsearch, S3).
Per-destination required fields:
splunk / elastic / panther / cribl / datadog / axiom: url + token (user optional)
s3: s3Bucket + s3Region + s3AuthenticationType, plus either (s3AccessKeyId + s3SecretAccessKey) for 'accesskey' auth or s3RoleArn for 'rolearn' auth. Call tailscale_create_aws_external_id first when using 'rolearn'.
Inputs: logType (required), destinationType (required), url, user, +10 more.
tailscale_set_nameserversSet nameserversADestructiveIdempotent
Set the DNS nameservers for your tailnet. Replaces all existing nameservers. Removing every nameserver may also change MagicDNS: the API reference says it is switched off, while Tailscale's current MagicDNS docs say a nameserver is no longer required -- check magicDNS in the response.
Inputs: dns (required).
tailscale_set_search_pathsSet DNS search pathsADestructiveIdempotent
Set the DNS search paths for your tailnet. Replaces all existing search paths.
Inputs: searchPaths (required).
tailscale_set_service_device_approvalSet service device approvalBIdempotent
Approve or reject a device to host a Tailscale Service.
Inputs: approved (required), deviceId (required), serviceName (required).
tailscale_set_split_dnsSet split DNSADestructiveIdempotent
Set split DNS configuration. Maps domains to specific nameservers. Replaces the entire split DNS configuration: a domain you leave out is removed, and an empty object clears every domain. Per the API reference, setting a domain to null clears that domain's nameservers.
Inputs: splitDns (required).
tailscale_statusCheck API statusARead-onlyIdempotent
Check that the Tailscale API connection is working. Returns your tailnet name, device count, and confirms authentication is valid. Use this to verify setup.
Inputs: no named parameters.
tailscale_suspend_userSuspend userADestructiveIdempotent
Suspend a user, immediately revoking their access to the tailnet. Their devices will be disconnected. Can be reversed with tailscale_restore_user.
Inputs: userId (required).
tailscale_test_webhookTest webhookA
Send a test event to a webhook endpoint to verify it is configured correctly and receiving events.
Inputs: webhookId (required).
tailscale_tool_groupsExplain available toolsARead-onlyIdempotent
Explain which of this server's tools are available and why. Call this FIRST when a Tailscale tool you expected is missing, instead of assuming the capability does not exist -- tools can be withheld by configuration, and the fix is usually one environment variable. Pass toolName to ask about one specific tool (e.g. 'tailscale_delete_device'): the answer distinguishes 'no such tool exists' -- where you should find another approach -- from 'it exists but its group is not loaded' and 'it exists and loaded but writes are withheld there', both of which the operator can enable and neither of which you should work around. With no arguments it lists every group with its availability and, where something is withheld, the exact environment change that would restore it. Always available regardless of filters.
Inputs: toolName.
tailscale_update_aclUpdate ACL policyADestructiveIdempotent
Update the ACL policy for your tailnet. Accepts the full policy as a string to preserve formatting, comments, and trailing commas (HuJSON). You MUST pass the ETag from tailscale_get_acl to prevent overwriting concurrent changes, or ts-default for the first write to a tailnet nobody has edited yet. Always get the current ACL first, make targeted edits to the text, and pass the full modified text back.
Inputs: etag (required), policy (required).
tailscale_update_device_keyUpdate device keyAIdempotent
Update a device's key settings, such as disabling key expiry. Useful for servers that should never need to re-authenticate.
Inputs: deviceId (required), keyExpiryDisabled (required).
tailscale_update_keyUpdate keyAIdempotent
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' -- the Tailscale API will reject other fields.
Inputs: keyId (required), tags, issuer, scopes, +4 more.
tailscale_update_posture_integrationUpdate posture integrationCIdempotent
Update an existing posture integration's credentials or configuration.
Inputs: integrationId (required), cloudId, clientId, tenantId, +1 more.
tailscale_update_serviceUpdate serviceCIdempotent
Update a Tailscale Service's configuration.
Inputs: serviceName (required), tags, ports, autoApproveHosts.
tailscale_update_split_dnsUpdate split DNS (partial)AIdempotent
Partially update split DNS configuration. Merges the provided domains with the existing config -- only the specified domains are changed, others are untouched. To remove a domain, set it to null: that is the idiom the API reference documents. An empty array is also accepted and forwarded as-is -- it is what Tailscale's Terraform provider sends.
Inputs: splitDns (required).
tailscale_update_tailnet_settingsUpdate tailnet settingsCIdempotent
Update tailnet settings (device approval, auto-updates, key expiry, HTTPS certificates, network flow logging, regional routing, posture identity collection).
Inputs: httpsEnabled, usersApprovalOn, aclsExternalLink, devicesApprovalOn, +7 more.
tailscale_update_user_roleUpdate user roleCIdempotent
Update a user's role in the tailnet.
Inputs: role (required), userId (required).
tailscale_update_webhookUpdate webhookBIdempotent
Update an existing webhook's endpoint URL and/or subscriptions.
Inputs: webhookId (required), endpointUrl, subscriptions.
tailscale_validate_aclValidate ACL policyARead-onlyIdempotent
Validate an ACL policy without applying it. Returns any errors found, or confirms the policy is valid.
Inputs: policy (required).
tailscale_validate_aws_trust_policyValidate AWS trust policyARead-onlyIdempotent
Validate that an AWS IAM role trust policy is correctly configured with the Tailscale external ID. Use this after setting up the IAM role for S3 log streaming.
Inputs: roleArn (required), externalId (required).
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 98 tools
Most tools target clearly distinct resources and actions, but several clusters overlap heavily: DNS can be changed through set_dns_preferences, set_nameservers, set_search_paths, set_split_dns, update_split_dns, and set_dns_configuration; device authorization has both single-device and batch variants; and key/OAuth-app/token operations require careful reading to separate. The descriptions do help, but with 98 tools an agent can still easily choose the wrong DNS or authorization entry point.
Names follow a highly predictable tailscale_verb_noun pattern in snake_case, such as tailscale_list_devices, tailscale_get_device, tailscale_create_key, and tailscale_delete_webhook. The few exceptions like tailscale_status and tailscale_tool_groups are clearly intentional utility tools and do not undermine the convention.
Ninety-eight tools is an extreme surface for a single MCP server and far exceeds what an agent can efficiently discover, select, and safely operate. Even though each tool maps to some Tailscale API capability, the sheer count creates high selection cost and makes the toolset unwieldy without strong grouping or progressive disclosure.
Coverage is broad across devices, users, keys, DNS, ACLs, webhooks, invites, posture, logging, and organization tailnets, with lifecycle operations mostly present. The notable gap is service creation, which the server explicitly says it does not support, plus some DNS operations are split across overlapping endpoints, so agents may need workarounds in a few areas.
Maintenance
Related MCP Connectors
Read devices, users, keys, ACLs and DNS for a tailnet; manage devices, routes and auth keys.
Tailscale device, route, DNS, key, user, and ACL management over MCP and CLI.
Deploy, monitor, and manage your OpenClaw AI assistants via natural language.
Query, explore, and manage your databases through natural conversation.
Related MCP Servers
- FlicenseNot gradedqualityNot gradedmaintenanceEnables interaction with the Tailscale API to manage devices, ACLs, keys, and DNS configurations directly from MCP clients like Claude Desktop or Raycast.-
- AlicenseAqualityCmaintenanceEnables AI agents to securely interact with Tailscale tailnets for device inventory, ACL review, key hygiene, and more, with token-efficient output and write-gated mutations.19MIT
- AlicenseNot gradedqualityDmaintenanceQuery devices, check connectivity, and get network status from your Tailscale tailnet directly via an AI assistant.1MIT
- AlicenseNot gradedqualityDmaintenanceWraps the Tailscale CLI to enable MCP-capable AI agents to manage devices, serve configurations, and diagnose connectivity.3MIT