TSLink as an MCP Server
44 owner tools for sharing and managing local services over stdio or a tailnet-only HTTP control plane
Scenario guides: Use your coding agent's web UI from your phone.
What tslink mcp is#
TSLink exposes 44 owner tools from one registry over two transports: a local stdio child process (tslink mcp) and an opt-in tailnet-only Streamable HTTP endpoint (tslink serve --mcp). This page describes controlling TSLink itself. To proxy an existing third-party MCP server, see MCP Server Hosting.
Register with a local MCP client
{"mcpServers":{"tslink":{"command":"tslink","args":["mcp"]}}}Use the installed binary's absolute path if the client has a different PATH. Do not add a credential to this client configuration. The default share flow requires no stored credential: TSLink returns needs_login for browser enrollment when needed.
The MCP process itself opens no network listener. Calling share, add, or template_apply may install/start the separate background gateway and its tsnet services. Installation announcements go to stderr.
The stdio contract
| Item | Behavior |
|---|---|
| stdout | JSON-RPC frames only, one JSON object per line |
| stderr | Diagnostics, logs, and normal installation announcements; successful sessions need not have empty stderr |
--json | tslink mcp --json is rejected with exit 2; stdout is reserved for protocol frames |
| Versions | Current 2026-07-28; older 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 are supported |
| Negotiation | Current clients put their version in each request's _meta and need no initialize. Older clients use initialize then notifications/initialized. The deprecated handshake echoes a supported version or falls back to 2025-11-25 |
| EOF | Already-read requests drain their answers before exit. Keep reading stdout while closing stdin |
| EOF watchdog | A call still running 6m after stdin closes is cancelled; after 5s handler grace the command exits 1. A blocked stdout response gets at most a further 5s before it is abandoned |
| Signals | SIGINT/SIGTERM cancels in-flight calls, rolls back a share still waiting for its URL, and exits 1. A second signal terminates immediately |
| Session-ending input | Malformed JSON, a value that is not a JSON-RPC message, a JSON-RPC batch, or a record over 1,048,576 bytes ends the session, drops in-flight answers, and prevents reading later records |
| Duplicate in-flight id | Receives no answer; do not reuse an id while its call is outstanding |
| URL polling | url.wait is a Go duration capped at 5m; an excessive wait is a usage error |
Do not assume malformed input receives a recoverable JSON-RPC error. Reopen the child process after session termination. The SDK owns protocol negotiation and error handling. serverInfo.version identifies the installed build (or dev when unstamped).
For an older client, the handshake begins with:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0.0"}}}{"jsonrpc":"2.0","method":"notifications/initialized"}Tools and complete input fields
Owner tools/list publishes 44 tools; reduced sessions advertise their permitted subset, each with inputSchema and outputSchema. Inputs are strict: unknown fields and invalid types are refused. The tables below list every input field; live schemas from your installed build remain authoritative. Daemon lifecycle, installation, login/logout, and configuration are CLI-only.
Session tool index
Local owner exposes 44 tools; viewer 12, people-manager 18. Use tools/list in the actual session. Required parameters are explicit below; optional fields retain their live schema constraints. Roles never widen through arguments. V = viewer, A = app-operator, P = people-manager; every row is available to owner. Reduced roles also enforce app and duration scope, and request tools add portal-owner checks.
| Tool | Required parameters | Optional parameters | Reduced roles |
|---|---|---|---|
access_explain | service: string | None | V/A/P |
access_log | None | app: string, decision: string, limit: integer, since: string, until: string, who: string | V/A/P |
access_summary | None | app: string, decision: string, limit: integer, since: string, until: string, who: string | V/A/P |
add | name: string, type: string | allow: array, control_url: string, dir: string, ephemeral: boolean, funnel: boolean, funnel_ttl: string, health: object, no_auto_provision: boolean, no_daemon_install: boolean, preserve_host: boolean, public_ack: boolean, request_limits: object, requestable: boolean, tags: array, target: string | Owner only |
app_restart | app: string | None | A |
apps_detect | None | None | Owner only |
doctor | None | probe_external: boolean | V/A/P |
extend | service: string | ack_never: boolean, for: string, regrant: boolean, until: string, who: string | A/P |
guest_create | app: string, for: string | label: string, pin: string, print_link: boolean, public: boolean | Owner only |
guest_list | None | None | Owner only |
guest_revoke | id: string | None | Owner only |
guest_show | id: string | None | Owner only |
health | None | None | V/A/P |
invite_device | email: string, service: string | allow_exit_node: boolean, multi_use: boolean, print_link: boolean | Owner only |
invite_list | None | show_urls: boolean | Owner only |
invite_resend | invite_id: string, kind: string | None | Owner only |
invite_revoke | invite_id: string, kind: string | None | Owner only |
invite_user | email: string | print_link: boolean, role: string | Owner only |
list | None | None | V/A/P |
logs | None | last: integer, level: string, since: string, source: string | Owner only |
mcp_audit | None | None | Owner only |
people_add | apps: array, who: string | ack_never: boolean, for: string, invite: boolean, print_links: boolean, qr: boolean, qr_invite: string, until: string | Owner only |
people_grant | app: string, for: string, who: string | None | A/P |
people_list | None | None | V/A/P |
people_remove | who: string | reconcile_invites: object | Owner only |
people_revoke | app: string, who: string | None | A/P |
people_update | who: string | ack_never: boolean, apps: array, for: string, invite: boolean, print_links: boolean, qr: boolean, qr_invite: string, reconcile_invites: object, replace_invites: object, until: string | Owner only |
portal_disable | None | None | Owner only |
portal_enable | owner: string | admins: array, funnel: boolean, hostname: string | Owner only |
recipe_apply | recipe_id: string | allow: string, control_url: string, ephemeral: boolean, force_unsafe_public: boolean, funnel: boolean, funnel_ttl: string, health: object, name: string, no_auto_provision: boolean, no_daemon_install: boolean, preserve_host: boolean, public_ack: boolean, request_limits: object, tags: string, target: string | Owner only |
recipe_list | None | None | V/A/P |
recipe_plan | recipe_id: string | allow: string, control_url: string, ephemeral: boolean, force_unsafe_public: boolean, funnel: boolean, funnel_ttl: string, health: object, name: string, no_auto_provision: boolean, no_daemon_install: boolean, preserve_host: boolean, public_ack: boolean, request_limits: object, tags: string, target: string | Owner only |
requests_approve | for: string, id: string | ack_never: boolean | P |
requests_deny | id: string | reason: string | P |
requests_list | None | None | P |
share | target: string | allow: array, ephemeral: boolean, funnel: boolean, funnel_ttl: string, name: string, no_daemon_install: boolean, preserve_host: boolean, public_ack: boolean, request_limits: object, tags: array | Owner only |
status | None | None | V/A/P |
tags_list | None | None | V/A/P |
tags_set | service: string, tag: string | None | Owner only |
template_apply | name: string | no_daemon_install: boolean | Owner only |
template_list | None | None | V/A/P |
template_plan | name: string | None | Owner only |
unshare | name: string | None | Owner only |
url | name: string | wait: string | V/A/P |
For extend, give exactly one of for/until; reduced roles must select who. Guest creation requires for and public: true when first enabling the gate. People permanent member grants require ack_never: true; new guest/public never is refused. Normal qr returns payload text; MCP has no qr_png argument. portal_enable.funnel: true is refused.
share#
Share an existing file, directory, or HTTP port. Directory targets expose browsable contents; regular-file targets expose only that file. Existing target/name matches can be reused; unrelated name collisions receive a numeric suffix.
| Field | Type / required | Behavior |
|---|---|---|
target | string, required | Existing path, port 1..65535, or host:port HTTP target |
name | string | Optional DNS label, at most 63 characters, ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ |
ephemeral | boolean | Default true; tailnet node lifetime, not registry auto-deletion |
no_daemon_install | boolean | Require an already-running gateway; do not install it automatically |
allow | string array | Login emails or tag: entries; Omitted means no extra allow rule; people policy still applies. Conflicts with Funnel |
tags | string array | Each begins with tag:; defaults to the configured tag in credential-backed mode |
funnel | boolean | Default false; public exposure requires an HTTP port target, public_ack: true, and no allow |
public_ack | boolean | Default false; explicit acknowledgement of public internet exposure |
funnel_ttl | string | Shared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused. |
preserve_host | boolean | Proxy only; forward the node’s canonical external Host. Default false rewrites upstream Host; Origin is unchanged |
request_limits | object | max_body, unlimited_ack, header_timeout, read_timeout, idle_timeout; upload size/inactivity limits, not request frequency |
share waits up to 30s for readiness or enrollment. status is always present: ready carries url and name; needs_login carries auth_url. Public shares may include funnel_expires_at and funnel_rearmed. A reused active share keeps its existing deadline; an expired Funnel can be rearmed with the requested lifetime.
add#
Write or replace a named service registry entry. It ensures the gateway exists by default and returns current URL/enrollment evidence without an additional URL wait; call url to poll.
| Field | Type / required | Behavior |
|---|---|---|
name | string, required | DNS label, at most 63 characters; same pattern as share.name. Existing name is replaced |
type | string, required | proxy, file, or tcp |
target | string | Required for proxy/TCP, rejected for file. Proxy accepts host:port or URL; TCP accepts host:port |
dir | string | Absolute directory path required for file; rejected for proxy/TCP |
allow | string array | HTTP login emails or tag: entries; rejected for TCP and with Funnel |
tags | string array | tag: entries; configured default in credential-backed mode |
ephemeral | boolean | Default false |
funnel | boolean | Default false; requires proxy, public_ack: true, no allow, and no control_url |
public_ack | boolean | Default false; explicit public acknowledgement |
funnel_ttl | string | Shared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused. |
no_daemon_install | boolean | Save configuration without installing the background service |
no_auto_provision | boolean | Default false; disable automatic Funnel policy provisioning, valid only with Funnel |
control_url | string | Custom control server, such as Headscale; rejected with Funnel |
preserve_host | boolean | Proxy only; forward the node’s canonical external Host. Default false rewrites upstream Host; Origin is unchanged |
request_limits | object | max_body, unlimited_ack, header_timeout, read_timeout, idle_timeout; upload size/inactivity limits, not request frequency |
health | object | path, status_min, status_max, body_contains, timeout, interval; path/status/body are proxy only |
requestable | boolean | Opt in to name discovery in private portal requests; default false, private HTTP/file only. |
Read created, url, url_pending, endpoint, exposure, funnel_rearmed, and any auth_url / next / warnings to distinguish registration from readiness. Target validation refuses literal link-local, unspecified, and cloud-metadata IPs and metadata.google.internal, but does not resolve other hostnames or recheck destinations at connection time. An authorized agent can select other addresses reachable by the daemon.
Remaining 17 service tools
An empty object {} is the complete input for a tool listed as having no fields.
| Tool | Complete input fields | Result / semantics |
|---|---|---|
list | None | services: exact or pending URL and state, plus funnel_requested, funnel_active, funnel_state, optional deadline/remaining/error |
unshare | name: required DNS-label string | ok, name, removed, device_cleaned, device_cleanup_skipped, optional device_skip_reason / device_warning |
status | None | authenticated aliases node_authorized; separately read credential_stored, authorization/service counts, daemon_running, supervision, optional status, auth_url, next |
url | name: required DNS-label string; wait: optional Go duration, maximum 5m, empty/nonpositive means no polling | Exact name, url, state; url_not_ready while pending, never a guessed hostname |
tags_list | None | Registered service names and locally recorded tags |
tags_set | service: required string; tag: required string beginning tag: | Replace a service's local tags with that one tag; result service, tags |
access_explain | service: required string | Local exposure/enforcement evidence, unknown external policy, and backend-auth assumptions; not effective tailnet authorization proof |
doctor | probe_external: optional boolean, default false | Findings, counts, supervision, runtime/credential evidence. health_exit_code reports CLI health severity; warnings/critical findings do not themselves make the MCP tool fail |
logs | source: err or out, default err; last: integer 1..1000, default 100; level: optional debug, info, warn, error minimum; since: positive Go duration, default 1h, maximum 168h | Read-only bounded log window; see below |
invite_user | email: required string; role: optional member, admin, billing-admin, it-admin, network-admin, auditor, default member; print_link: boolean, default false | Real tailnet invitation; confirm recipient and role. print_link returns a bearer URL instead of sending email |
invite_device | service, email: required strings; print_link, multi_use, allow_exit_node: booleans, default false | Real invitation to share one owned service device with a person outside the tailnet |
invite_list | show_urls: boolean, default false | Reads open invitations; bearer URLs hidden by default. complete: false means some owned devices could not be checked |
invite_revoke | kind: required user or device; invite_id: required string | Cancels a real invitation; result includes revoked and a remote-side-effect plan |
invite_resend | kind: required user or device; invite_id: required string | Resends real email to original recipient; not supported for a print_link invitation |
template_list | None | Built-in templates and their service counts; installs no third-party applications |
template_plan | name: required string | Read-only plan; dry_run: true, applied: false |
template_apply | name: required string; no_daemon_install: optional boolean | Write missing template services; preserve existing names, install gateway if needed unless disabled. Read applied, created, skipped |
Invitation URLs are bearer credentials. Public Funnel and invitation mutations have real external effects; an agent should confirm the exact action with its user before calling them. MCP user invitations with a role other than member, and device invitations permitting exit-node use, additionally require owner opt-in via mcp.allow_elevated_invites; otherwise the result is mcp_elevated_invite_refused. The unshare tool is also annotated destructive: it performs the same exact-ownership device and node-state deletion as tslink remove, and the server instructions ask clients to confirm it with the user.
logs and identity boundaries#
Application/access logs go to stderr (tslink.err.log); out is daemon stdout. MCP returns source, file, optional level, since, since_at, lines, count, matched, truncated, optional truncated_reason (line_limit or byte_limit), and redacted: true. Returned text is also capped at 256 KiB after redaction.
Credential material, recognized Tailscale login/invitation URLs, and email addresses are redacted. This narrows accidental model context; it does not guarantee removal of every secret shape. tslink logs CLI reads the same file verbatim, so redaction is not an access boundary for an agent with a shell. Use access_log / access_summary for the bounded retained history and receipts described in access history; identity may be absent and drops/crash gaps remain possible. Proxy header propagation retains its separate best-effort cache boundary.
Worked example: report to URL
After the appropriate version negotiation, call:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"share","arguments":{"target":"/absolute/path/report.html","name":"report"}}}A successful handoff contains the same payload as JSON in content[0].text and as a parsed object in structuredContent:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"status\":\"needs_login\",\"auth_url\":\"https://login.tailscale.com/a/...\"}"}],"structuredContent":{"status":"needs_login","auth_url":"https://login.tailscale.com/a/..."}}}Show auth_url to the user. Once authorized, call url with {"name":"report","wait":"30s"} and hand back structuredContent.url. Use list to inspect readiness without reissuing the share. When done, call unshare with {"name":"report"}.
unshare is idempotent: ok: true can accompany removed: false for an absent name. Local removal is separate from remote device cleanup, which requires an API client and exact ownership proof. Default zero-credential operation can remove the registry entry while leaving the remote device behind.
Errors and cancellation
Tool/business failures return isError: true, human error text in content[0].text, and structuredContent containing ok: false, numeric CLI code, and error with a stable code/message and possible next actions. needs_login is a successful tool result. Protocol errors such as an unknown method (-32601) or invalid parameters (-32602) belong to JSON-RPC, not the CLI envelope. Session-ending inputs follow the termination rules above; do not treat them as recoverable per-line errors.
Use unique request ids and MCP cancellation notifications when abandoning a request. Closing stdin drains already-read requests; it does not immediately cancel them. Use signals to cancel the process, while continuing to drain stdout until it exits.
Remote MCP control plane
tslink serve --mcp exposes the same 44 owner tools at https://<node>.<tailnet>.ts.net/mcp on a dedicated TLS tsnet node. Default name: tslink-mcp; override with mcp.node_name. Its state is ~/.config/tslink/mcp-node/. It is not a registry service and cannot be published through Funnel.
Enabling it
The control plane is off by default. Set --mcp or mcp.enabled: true; both require a nonempty owner mcp.allow or explicit mcp.bindings in config.json. MCP configuration is edited directly; tslink config set also manages supported access-log keys:
{"mcp":{"enabled":true,"allow":["you@example.com","tag:ops"],"node_name":"tslink-mcp","events_keepalive":"20s"}}Use a deliberate daemon restart or foreground serve --mcp workflow after configuration; do not start a second gateway while one is already running.
Boundaries
- The listener is tailnet-only TLS on its own tsnet node, never a host-interface or Funnel listener. Authorized peers have high-privilege control, including public shares and real invitations.
mcp.allowmatches WhoIs login emails /tag:entries. Without valid owner entries or bindings, startup is refused. Scope identity denials use HTTP 403 withmcp_scope_denied; see roles and app scopes.- An
Originheader must exactly match the endpoint's own HTTPS origin; other origins receive403before authorization. Requests withoutOrigincan proceed to authorization. - Streamable HTTP is stateless, with a 1 MiB request-body limit. Client requests must originate on a device able to reach your tailnet; cloud-hosted connectors cannot reach a private address merely because your laptop can.
- Credential-backed control-plane nodes are ephemeral; zero-credential browser-enrolled nodes are persistent and may need explicit tailnet device removal when disabled. Crashes can leave devices pending Tailscale cleanup.
Event stream at /events#
The same remote node mounts owner-only server-sent events with the same Origin and caller checks. Reduced roles receive 404 and poll tools instead. It emits only named snapshot, update, and keepalive events, not default message events. Register handlers for those names; onmessage alone receives nothing.
Every payload includes its type. SSE id: carries instance-global event_id, distinct from the per-stream sequence; deduplicate with event_id. Drop cached state when instance changes because the daemon restarted. There is no retry: field or Last-Event-ID replay: every reconnect receives a full snapshot.
mcp.events_keepalive is a Go duration, defaults to 20s, and must be between 5s and 5m. This event endpoint is part of the remote control plane, not the roadmap admin REST/dashboard API.
Related
People and app recipe tools
Local owner remains default; shipped MCP roles and app scopes restrict operations. They do not sandbox shell/filesystem access or grant app access. Mutation receipts are bounded; see access history.
| Tool | Complete input fields | Semantics |
|---|---|---|
people_add | who: required login; apps: required nonempty string array; for: duration or never; invite, print_links: booleans ; until: string; ack_never, qr: boolean; qr_invite: string | Save private HTTP/file grants. First grant scopes an app. Invite creation needs a user-owned API token; explicit print reveals bearer links |
people_list | None | Read people, grants, deadlines and revocation records |
people_update | who: required; apps, for, invite, print_links; reconcile_invites, replace_invites: app-to-ID objects ; until: string; ack_never, qr: boolean; qr_invite: string | Supply app scope, lifetime or invite: true. Invite-only retries reuse completed IDs. Reconciliation needs owner verification; replacement needs invite-only and preserves grants/deadlines |
people_remove | who: required; reconcile_invites: app-to-ID object | Save denial before pending-invite cleanup. Read complete and cleanup; accepted network shares may remain |
apps_detect | None | Bounded loopback GET detection; complete: false means partial, not proof of app security |
recipe_list | None | Versioned catalog with ports, app-side advice, health paths and safety levels |
recipe_plan | Fields listed below | Read-only preview |
recipe_apply | Same fields as recipe_plan | Apply the reviewed plan. Existing names remain unchanged; does not install or configure the backend |
Both recipe tools accept these fields:
| Field | Type / behavior |
|---|---|
recipe_id | Required string from recipe_list |
name, target | Optional strings; name override and loopback HTTP(S) host-port override |
allow, tags | Optional comma-separated strings, unlike the service tools' arrays |
health, request_limits | Objects with the same fields as add above |
preserve_host | Boolean override; omission uses the recipe's Host default |
ephemeral, no_daemon_install | Optional booleans |
funnel, public_ack, no_auto_provision | Optional booleans; public exposure requires funnel: true, public_ack: true, and suitable app authentication |
funnel_ttl | Shared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused. |
control_url | Optional string; incompatible with Funnel |
force_unsafe_public | Explicit dangerous boolean override of a never-public recipe; does not establish app authentication |
Call recipe_plan before recipe_apply with the same options. People mutations ask for confirmation of person, apps and deadline because they can narrow existing exposure. They cannot scope TCP or Funnel. Revocation blocks new requests, not already accepted streams. See people sharing.
Example tool arguments for a seven-day grant:
{"who":"alice@example.com","apps":["preview"],"for":"7d"}Use this with people_add after registering and enrolling preview. Read person, complete, invites, message and invite_requirement; a successful command can still have incomplete invitations.
Sources: TSLink MCP service registry, people tools, recipe tools.
New access tools in practice
health reads backend observations; access_log/access_summary query retained scoped history. people_grant/people_revoke change one pre-existing person’s app grant. extend sets now + duration, with explicit regrant for expired state. app_restart queues a gateway node restart, not a backend restart or completed result: poll status/health. Reduced operators cannot restart an existing public Funnel app.
guest_create/list/show/revoke remain owner-only and support one HTTP proxy app; link/PIN can be forwarded and never proves a person’s identity. portal_enable/disable manages a private per-host node; remote enable requires the current portal owner, and first bootstrap/recovery is local. requests_list/approve/deny is owner/people-manager only; remote callers also need the current human portal-owner identity, not merely an admin designation. Reduced roles cannot create unknown people or modify protected portal-owner/admin records.
mcp_audit is owner-only. A persisted intent precedes mutation; missing completion means unknown outcome. Access history and receipts have independent bounds and possible gaps; they are not complete-audit or compliance guarantees. See guest links, portal/requests, roles, durations and access history.