TSLinkTSLink Docs

TSLink as an MCP Server

44 owner tools for sharing and managing local services over stdio or a tailnet-only HTTP control plane

View as Markdown

Scenario guides: Use your coding agent's web UI from your phone.

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

json
{"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

ItemBehavior
stdoutJSON-RPC frames only, one JSON object per line
stderrDiagnostics, logs, and normal installation announcements; successful sessions need not have empty stderr
--jsontslink mcp --json is rejected with exit 2; stdout is reserved for protocol frames
VersionsCurrent 2026-07-28; older 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 are supported
NegotiationCurrent 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
EOFAlready-read requests drain their answers before exit. Keep reading stdout while closing stdin
EOF watchdogA 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
SignalsSIGINT/SIGTERM cancels in-flight calls, rolls back a share still waiting for its URL, and exits 1. A second signal terminates immediately
Session-ending inputMalformed 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 idReceives no answer; do not reuse an id while its call is outstanding
URL pollingurl.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:

json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0.0"}}}
json
{"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.

ToolRequired parametersOptional parametersReduced roles
access_explainservice: stringNoneV/A/P
access_logNoneapp: string, decision: string, limit: integer, since: string, until: string, who: stringV/A/P
access_summaryNoneapp: string, decision: string, limit: integer, since: string, until: string, who: stringV/A/P
addname: string, type: stringallow: 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: stringOwner only
app_restartapp: stringNoneA
apps_detectNoneNoneOwner only
doctorNoneprobe_external: booleanV/A/P
extendservice: stringack_never: boolean, for: string, regrant: boolean, until: string, who: stringA/P
guest_createapp: string, for: stringlabel: string, pin: string, print_link: boolean, public: booleanOwner only
guest_listNoneNoneOwner only
guest_revokeid: stringNoneOwner only
guest_showid: stringNoneOwner only
healthNoneNoneV/A/P
invite_deviceemail: string, service: stringallow_exit_node: boolean, multi_use: boolean, print_link: booleanOwner only
invite_listNoneshow_urls: booleanOwner only
invite_resendinvite_id: string, kind: stringNoneOwner only
invite_revokeinvite_id: string, kind: stringNoneOwner only
invite_useremail: stringprint_link: boolean, role: stringOwner only
listNoneNoneV/A/P
logsNonelast: integer, level: string, since: string, source: stringOwner only
mcp_auditNoneNoneOwner only
people_addapps: array, who: stringack_never: boolean, for: string, invite: boolean, print_links: boolean, qr: boolean, qr_invite: string, until: stringOwner only
people_grantapp: string, for: string, who: stringNoneA/P
people_listNoneNoneV/A/P
people_removewho: stringreconcile_invites: objectOwner only
people_revokeapp: string, who: stringNoneA/P
people_updatewho: stringack_never: boolean, apps: array, for: string, invite: boolean, print_links: boolean, qr: boolean, qr_invite: string, reconcile_invites: object, replace_invites: object, until: stringOwner only
portal_disableNoneNoneOwner only
portal_enableowner: stringadmins: array, funnel: boolean, hostname: stringOwner only
recipe_applyrecipe_id: stringallow: 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: stringOwner only
recipe_listNoneNoneV/A/P
recipe_planrecipe_id: stringallow: 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: stringOwner only
requests_approvefor: string, id: stringack_never: booleanP
requests_denyid: stringreason: stringP
requests_listNoneNoneP
sharetarget: stringallow: array, ephemeral: boolean, funnel: boolean, funnel_ttl: string, name: string, no_daemon_install: boolean, preserve_host: boolean, public_ack: boolean, request_limits: object, tags: arrayOwner only
statusNoneNoneV/A/P
tags_listNoneNoneV/A/P
tags_setservice: string, tag: stringNoneOwner only
template_applyname: stringno_daemon_install: booleanOwner only
template_listNoneNoneV/A/P
template_planname: stringNoneOwner only
unsharename: stringNoneOwner only
urlname: stringwait: stringV/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.

FieldType / requiredBehavior
targetstring, requiredExisting path, port 1..65535, or host:port HTTP target
namestringOptional DNS label, at most 63 characters, ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
ephemeralbooleanDefault true; tailnet node lifetime, not registry auto-deletion
no_daemon_installbooleanRequire an already-running gateway; do not install it automatically
allowstring arrayLogin emails or tag: entries; Omitted means no extra allow rule; people policy still applies. Conflicts with Funnel
tagsstring arrayEach begins with tag:; defaults to the configured tag in credential-backed mode
funnelbooleanDefault false; public exposure requires an HTTP port target, public_ack: true, and no allow
public_ackbooleanDefault false; explicit acknowledgement of public internet exposure
funnel_ttlstringShared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused.
preserve_hostbooleanProxy only; forward the node’s canonical external Host. Default false rewrites upstream Host; Origin is unchanged
request_limitsobjectmax_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.

FieldType / requiredBehavior
namestring, requiredDNS label, at most 63 characters; same pattern as share.name. Existing name is replaced
typestring, requiredproxy, file, or tcp
targetstringRequired for proxy/TCP, rejected for file. Proxy accepts host:port or URL; TCP accepts host:port
dirstringAbsolute directory path required for file; rejected for proxy/TCP
allowstring arrayHTTP login emails or tag: entries; rejected for TCP and with Funnel
tagsstring arraytag: entries; configured default in credential-backed mode
ephemeralbooleanDefault false
funnelbooleanDefault false; requires proxy, public_ack: true, no allow, and no control_url
public_ackbooleanDefault false; explicit public acknowledgement
funnel_ttlstringShared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused.
no_daemon_installbooleanSave configuration without installing the background service
no_auto_provisionbooleanDefault false; disable automatic Funnel policy provisioning, valid only with Funnel
control_urlstringCustom control server, such as Headscale; rejected with Funnel
preserve_hostbooleanProxy only; forward the node’s canonical external Host. Default false rewrites upstream Host; Origin is unchanged
request_limitsobjectmax_body, unlimited_ack, header_timeout, read_timeout, idle_timeout; upload size/inactivity limits, not request frequency
healthobjectpath, status_min, status_max, body_contains, timeout, interval; path/status/body are proxy only
requestablebooleanOpt 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.

ToolComplete input fieldsResult / semantics
listNoneservices: exact or pending URL and state, plus funnel_requested, funnel_active, funnel_state, optional deadline/remaining/error
unsharename: required DNS-label stringok, name, removed, device_cleaned, device_cleanup_skipped, optional device_skip_reason / device_warning
statusNoneauthenticated aliases node_authorized; separately read credential_stored, authorization/service counts, daemon_running, supervision, optional status, auth_url, next
urlname: required DNS-label string; wait: optional Go duration, maximum 5m, empty/nonpositive means no pollingExact name, url, state; url_not_ready while pending, never a guessed hostname
tags_listNoneRegistered service names and locally recorded tags
tags_setservice: required string; tag: required string beginning tag:Replace a service's local tags with that one tag; result service, tags
access_explainservice: required stringLocal exposure/enforcement evidence, unknown external policy, and backend-auth assumptions; not effective tailnet authorization proof
doctorprobe_external: optional boolean, default falseFindings, counts, supervision, runtime/credential evidence. health_exit_code reports CLI health severity; warnings/critical findings do not themselves make the MCP tool fail
logssource: 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 168hRead-only bounded log window; see below
invite_useremail: required string; role: optional member, admin, billing-admin, it-admin, network-admin, auditor, default member; print_link: boolean, default falseReal tailnet invitation; confirm recipient and role. print_link returns a bearer URL instead of sending email
invite_deviceservice, email: required strings; print_link, multi_use, allow_exit_node: booleans, default falseReal invitation to share one owned service device with a person outside the tailnet
invite_listshow_urls: boolean, default falseReads open invitations; bearer URLs hidden by default. complete: false means some owned devices could not be checked
invite_revokekind: required user or device; invite_id: required stringCancels a real invitation; result includes revoked and a remote-side-effect plan
invite_resendkind: required user or device; invite_id: required stringResends real email to original recipient; not supported for a print_link invitation
template_listNoneBuilt-in templates and their service counts; installs no third-party applications
template_planname: required stringRead-only plan; dry_run: true, applied: false
template_applyname: required string; no_daemon_install: optional booleanWrite 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:

json
{"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:

json
{"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:

json
{"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.allow matches WhoIs login emails / tag: entries. Without valid owner entries or bindings, startup is refused. Scope identity denials use HTTP 403 with mcp_scope_denied; see roles and app scopes.
  • An Origin header must exactly match the endpoint's own HTTPS origin; other origins receive 403 before authorization. Requests without Origin can 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.

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.

ToolComplete input fieldsSemantics
people_addwho: required login; apps: required nonempty string array; for: duration or never; invite, print_links: booleans ; until: string; ack_never, qr: boolean; qr_invite: stringSave private HTTP/file grants. First grant scopes an app. Invite creation needs a user-owned API token; explicit print reveals bearer links
people_listNoneRead people, grants, deadlines and revocation records
people_updatewho: required; apps, for, invite, print_links; reconcile_invites, replace_invites: app-to-ID objects ; until: string; ack_never, qr: boolean; qr_invite: stringSupply 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_removewho: required; reconcile_invites: app-to-ID objectSave denial before pending-invite cleanup. Read complete and cleanup; accepted network shares may remain
apps_detectNoneBounded loopback GET detection; complete: false means partial, not proof of app security
recipe_listNoneVersioned catalog with ports, app-side advice, health paths and safety levels
recipe_planFields listed belowRead-only preview
recipe_applySame fields as recipe_planApply the reviewed plan. Existing names remain unchanged; does not install or configure the backend

Both recipe tools accept these fields:

FieldType / behavior
recipe_idRequired string from recipe_list
name, targetOptional strings; name override and loopback HTTP(S) host-port override
allow, tagsOptional comma-separated strings, unlike the service tools' arrays
health, request_limitsObjects with the same fields as add above
preserve_hostBoolean override; omission uses the recipe's Host default
ephemeral, no_daemon_installOptional booleans
funnel, public_ack, no_auto_provisionOptional booleans; public exposure requires funnel: true, public_ack: true, and suitable app authentication
funnel_ttlShared relative/absolute lifetime grammar; minimum 1h, default 24h, default public maximum 7d; new public never is refused.
control_urlOptional string; incompatible with Funnel
force_unsafe_publicExplicit 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:

json
{"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.

Table of Contents