Appearance
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/mcpTransport
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/mcpHTTP/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
| Document | What it says |
|---|---|
GET /.well-known/oauth-protected-resource/mcp | RFC 9728 metadata for this endpoint: its canonical URL, the authorization server, and every scope a token here may carry |
GET /.well-known/oauth-protected-resource | the same document, where clients that ignore the path look for it |
GET /.well-known/oauth-authorization-server | RFC 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/mcpjson
{
"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.
| Tool | Scope | What it does |
|---|---|---|
list_devices | devices.readonly | The account's devices, paged, optionally by nick prefix |
get_device | devices.readonly | One device with its user-meta (global profile meta included) and device-meta |
get_device_logs | devices.readonly | A device's logs, filtered by revision, level, source and time |
list_device_tokens | devices.readonly | Active device join tokens, without their secrets |
get_device_token | devices.readonly | One join token by id, without its secret |
get_export_link | devices.readonly | A ten-minute download link to a revision's pvr export |
get_device_status | trails.readonly | Whether a device runs its newest revision, and how far it got |
list_revisions | trails.readonly | A device's revisions, newest first |
get_revision | trails.readonly | One revision: status, progress log, files, optionally the state |
get_revision_parts | trails.readonly | A revision part by part: apps and BSP with their files, configuration overlays, documents, and each signature with what it protects |
get_export_upload | trails.readonly | How an upload or import went, and what it holds |
update_user_meta | devices.write | Set and remove user-meta keys of a device |
update_device_token | devices.change | Rename a join token, or replace the user-meta enrolled devices start with |
plan_revision | trails.write | Prepare a new revision without sending anything |
commit_revision | trails.write | Send a planned revision to the device |
get_export_upload_link | trails.write | A single-use link to upload one pvr export |
import_export_from_url | trails.write | Have the server fetch a pvr export over HTTPS |
list_apps | apps.readonly | The account's registered OAuth applications, without secrets |
get_app | apps.readonly | One application by id, nick or client id |
update_app | apps.write | Its 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.
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.commit_revision(plan_id, message)posts exactly that state as the next revision, through the same code asPOST /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:
op | Arguments | What it does |
|---|---|---|
remove_parts | parts | Remove apps or signatures; an app goes with its _config/<app> overlay and its signature, and a signature takes the files only it protects |
copy_parts | parts, from_device and/or from_revision | Take parts from another device or an older revision, with the signatures that cover them |
rollback | from_revision, optional parts | Go back to an older revision entirely, or for some parts only |
set_document | path, value | Replace an inline JSON document such as <app>/run.json |
delete_file | path | Remove one file |
import_export | upload_id | Merge 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.
| Tool | Result |
|---|---|
get_export_link | A 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_link | A PUT /exports/uploads/<token> URL, used once: curl -T export.tar.gz '<upload_url>' |
import_export_from_url | A 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 body | 1 MiB |
| Operations per plan | 20 |
| Plan lifetime | 1 hour, one commit |
| Commit message | 500 characters |
| One configuration change | 64 KiB encoded |
| State document inside an export | 32 MiB |
| Export archive | 2 GiB compressed, by default |
| Export upload record | 1 hour, which is how long its state can be planned from |
| Upload link | 30 minutes, single use |
| Export download link | up to 30 minutes; 10 minutes when issued by this endpoint |
| Refresh token | 30 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:
| Variable | Default | |
|---|---|---|
PANTAHUB_MCP_ENABLED | false | Serve the endpoint |
PANTAHUB_MCP_PATH | /mcp | Path it is mounted under |
PANTAHUB_MCP_RESOURCE_URL | from PANTAHUB_SCHEME/HOST/PORT | Public 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_ENABLED | false | Accept clients identified by a metadata document URL |
PANTAHUB_OAUTH_CIMD_ALLOWED_HOSTS | any public host | Hosts those URLs may live on, e.g. claude.ai |
PANTAHUB_OAUTH_DCR_ALLOWED_REDIRECT_HOSTS | empty (registration off) | Hosts a dynamically registered client may redirect to, e.g. claude.ai,127.0.0.1,localhost |
PANTAHUB_OAUTH_REFRESH_TOKEN_DAYS | 30 | How long an unused connection lasts |
PANTAHUB_EXPORT_UPLOAD_MAX_BYTES | 2 GiB | Largest export archive taken in |
PANTAHUB_EXPORT_IMPORT_ALLOWED_HOSTS | empty (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:
| Endpoint | Where it is documented |
|---|---|
POST /mcp | This 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 |