Skip to content

MCP service ​

/mcp is a Model Context Protocol endpoint: one URL an AI assistant connects to in order to manage the devices of the user who authorized it — the registry of devices, the revisions in trails, the logs, and the account's device tokens and registered applications. It is served by the API itself, next to the REST services, but it is not one of them: it is a single JSON-RPC endpoint with its own authentication. The connector as a user meets it is described in The MCP connector.

https://api.pantahub.com/mcp

Transport ​

Streamable HTTP, stateless, with plain JSON responses. Every call is one POST that answers in one round trip; nothing is pushed to the client, so there is no session to keep and no stream to hold open, and any replica can serve any request. Request bodies are capped at 1 MiB.

Browser-based clients are allowed from any origin: the endpoint authenticates with a bearer header and never with cookies, so there is no ambient credential to ride on.

Authorization ​

Every request carries Authorization: Bearer <token>. The token is an RS256 JWT of a USER or SESSION account, signed with the same key the REST API uses, and bound to this endpoint: aud is the endpoint URL, iss is the API, and cnx names the connection it was issued under. A token for any other audience is refused here, and a token bound to this endpoint is refused everywhere else — the REST API and the MQTT plane reject any token whose audience is a URL, so one cannot be replayed against them.

Without a valid token the endpoint answers 401 with an RFC 9728 challenge that names both the metadata document and the scopes a first connection should ask for:

sh
curl -s -o /dev/null -D - -X POST https://api.pantahub.com/mcp
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.pantahub.com/.well-known/oauth-protected-resource/mcp", scope="prn:pantahub.com:apis:/base/devices.readonly prn:pantahub.com:apis:/base/trails.readonly"

A tool call the token's scopes do not cover answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="...", which is how a client knows to ask the user for that one extra scope (step-up) instead of for everything up front.

Discovery ​

DocumentWhat it says
GET /.well-known/oauth-protected-resource/mcpRFC 9728 metadata for this endpoint: its canonical URL, the authorization server, and every scope a token here may carry
GET /.well-known/oauth-protected-resourcethe same document, where clients that ignore the path look for it
GET /.well-known/oauth-authorization-serverRFC 8414 metadata of the API as authorization server: authorize, token and registration endpoints, S256 as the only PKCE method, code as the only response type
sh
curl -s https://api.pantahub.com/.well-known/oauth-protected-resource/mcp
json
{
  "resource": "https://api.pantahub.com/mcp",
  "authorization_servers": ["https://api.pantahub.com"],
  "scopes_supported": ["prn:pantahub.com:apis:/base/devices.readonly", "..."],
  "bearer_methods_supported": ["header"],
  "resource_name": "Pantahub device management"
}

Clients obtain their token through the OAuth 2.1 authorization code flow with PKCE at /auth/oauth/authorize and /auth/oauth/token, naming this endpoint as the resource (RFC 8707). Clients that are not registered applications identify themselves either by a Client ID Metadata Document URL or through dynamic registration at /auth/oauth/register; both are opt-in per deployment. See auth for the flow itself.

Scopes ​

A token issued for this endpoint only ever carries the seven narrow scopes devices.readonly, trails.readonly, devices.write, devices.change, trails.write, apps.readonly and apps.write — never the account-wide all, devices or trails. On the wire they appear in the PRN-qualified form the apps service lists them under, for example prn:pantahub.com:apis:/base/devices.readonly.

A connection starts with devices.readonly and trails.readonly, which unlock nothing that changes anything; the rest are granted one at a time, when a tool that needs one is first used. The table under Tools names the scope each tool needs.

Connections ​

A grant for this endpoint is a connection of the account: GET /auth/oauth/connections lists them with the client, the scopes granted, when they were granted and when they lapse, and DELETE /auth/oauth/connections/:id ends one. Both take an ordinary user login; a connected client cannot call them with its own token, since this API refuses resource-bound tokens. Ending a connection stops the endpoint from accepting its access tokens within 15 seconds, not when they expire, and resetting the account password ends every connection.

Tools ​

Twenty tools, each taking a device by id or nick, each limited to the devices of the account in the token — somebody else's device answers exactly like one that does not exist. Read-only tools carry readOnlyHint; the ones that change something carry a destructive hint, which is what makes a client ask the user before every call.

ToolScopeWhat it does
list_devicesdevices.readonlyThe account's devices, paged, optionally by nick prefix
get_devicedevices.readonlyOne device with its user-meta (global profile meta included) and device-meta
get_device_logsdevices.readonlyA device's logs, filtered by revision, level, source and time
list_device_tokensdevices.readonlyActive device join tokens, without their secrets
get_device_tokendevices.readonlyOne join token by id, without its secret
get_export_linkdevices.readonlyA ten-minute download link to a revision's pvr export
get_device_statustrails.readonlyWhether a device runs its newest revision, and how far it got
list_revisionstrails.readonlyA device's revisions, newest first
get_revisiontrails.readonlyOne revision: status, progress log, files, optionally the state
get_revision_partstrails.readonlyA revision part by part: apps and BSP with their files, configuration overlays, documents, and each signature with what it protects
get_export_uploadtrails.readonlyHow an upload or import went, and what it holds
update_user_metadevices.writeSet and remove user-meta keys of a device
update_device_tokendevices.changeRename a join token, or replace the user-meta enrolled devices start with
plan_revisiontrails.writePrepare a new revision without sending anything
commit_revisiontrails.writeSend a planned revision to the device
get_export_upload_linktrails.writeA single-use link to upload one pvr export
import_export_from_urltrails.writeHave the server fetch a pvr export over HTTPS
list_appsapps.readonlyThe account's registered OAuth applications, without secrets
get_appapps.readonlyOne application by id, nick or client id
update_appapps.writeIts display name and callback URLs only

No tool creates or deletes a device, a join token or an application, and no tool returns a secret: a join token and a confidential application each yield a secret shown exactly once, which through this endpoint would end up in an assistant's conversation. That stays in the web app.

Revisions ​

A revision is changed in two calls, so that what the user approves is exactly what the device is sent.

  1. plan_revision(device, operations) starts from the device's newest revision, applies the operations in order, and stores the result as a plan. It answers the plan id, the new revision number, every file added, removed and changed, the parts touched, and warnings. Nothing is sent.
  2. commit_revision(plan_id, message) posts exactly that state as the next revision, through the same code as POST /trails/:id/steps (see trails). A plan is committed once, and the commit fails without posting anything if the device received another revision in the meantime.

Operations are merged with the same library pvr uses, so the result is the state pvr would have produced:

opArgumentsWhat it does
remove_partspartsRemove apps or signatures; an app goes with its _config/<app> overlay and its signature, and a signature takes the files only it protects
copy_partsparts, from_device and/or from_revisionTake parts from another device or an older revision, with the signatures that cover them
rollbackfrom_revision, optional partsGo back to an older revision entirely, or for some parts only
set_documentpath, valueReplace an inline JSON document such as <app>/run.json
delete_filepathRemove one file
import_exportupload_idMerge a received pvr export in, as the web app's editor does

Steps posted this way carry source: mcp, the plan id and the OAuth client in their step meta, and the commit message in the trail's history.

Signatures are never made or verified here. They are produced with pvr or in CI and checked by Pantavisor on the device; signing keys never reach the Hub. A plan reports where a device that verifies signatures would refuse the result: a signature left unchanged while files it protects changed, a signature that cannot be read, and parts nothing signs any more.

Exports ​

An export is what pvr export writes: a .tar.gz holding the state as json and every object as objects/<sha256>. Objects of a received export are stored in the account exactly as pvr post stores them, with the same quota, size and checksum checks.

ToolResult
get_export_linkA link to GET /exports/links/<token>/<nick>-<rev>.tar.gz that needs no sign-in — the same archive the owner gets from GET /exports/:owner/:nick/:rev/:file
get_export_upload_linkA PUT /exports/uploads/<token> URL, used once: curl -T export.tar.gz '<upload_url>'
import_export_from_urlA background fetch, HTTPS only, from a host the deployment allows, following redirects only to allowed hosts and connecting only to publicly routable addresses

Limits ​

Request body1 MiB
Operations per plan20
Plan lifetime1 hour, one commit
Commit message500 characters
One configuration change64 KiB encoded
State document inside an export32 MiB
Export archive2 GiB compressed, by default
Export upload record1 hour, which is how long its state can be planned from
Upload link30 minutes, single use
Export download linkup to 30 minutes; 10 minutes when issued by this endpoint
Refresh token30 days from last use, by default

Results are bounded too: log messages and state documents are truncated rather than returned whole, and each list in a plan's diff is capped.

On your own Hub ​

The endpoint is off unless it is asked for, and so is every way of signing in to it that does not involve a registered application:

VariableDefault
PANTAHUB_MCP_ENABLEDfalseServe the endpoint
PANTAHUB_MCP_PATH/mcpPath it is mounted under
PANTAHUB_MCP_RESOURCE_URLfrom PANTAHUB_SCHEME/HOST/PORTPublic URL of the endpoint, and the audience tokens are bound to; set it to exactly what users enter in their client when the API sits behind a proxy or tunnel
PANTAHUB_OAUTH_CIMD_ENABLEDfalseAccept clients identified by a metadata document URL
PANTAHUB_OAUTH_CIMD_ALLOWED_HOSTSany public hostHosts those URLs may live on, e.g. claude.ai
PANTAHUB_OAUTH_DCR_ALLOWED_REDIRECT_HOSTSempty (registration off)Hosts a dynamically registered client may redirect to, e.g. claude.ai,127.0.0.1,localhost
PANTAHUB_OAUTH_REFRESH_TOKEN_DAYS30How long an unused connection lasts
PANTAHUB_EXPORT_UPLOAD_MAX_BYTES2 GiBLargest export archive taken in
PANTAHUB_EXPORT_IMPORT_ALLOWED_HOSTSempty (imports off)Hosts import_export_from_url may fetch from

PANTAHUB_MCP_RESOURCE_URL has to match what the client is given character for character: it is the OAuth resource identifier, and a token bound to a different spelling of the same endpoint is refused.

Endpoint reference ​

The endpoint speaks JSON-RPC rather than REST, so it has no per-operation pages in the API Reference; the tools above are its interface. Its supporting HTTP endpoints belong to other services:

EndpointWhere it is documented
POST /mcpThis page
GET /.well-known/oauth-protected-resource[/mcp]This page
GET /.well-known/oauth-authorization-server · /auth/oauth/*auth
GET /exports/...Download and upload links, issued by the tools above