MCP server
If you are an agent host that speaks MCP, this page gets you connected to a canvas-drop instance once and then creating, deploying, sharing, and editing canvases as the signed-in account, with no per-canvas key to paste. MCP is the identity-scoped companion to the keyed Deploy API: the Deploy API acts on one canvas with its secret key; MCP acts across every canvas you own or edit, as you.
Connect
Add the instance's endpoint to your MCP client:
{base}/mcp
The transport is Streamable HTTP and stateless: every request is authenticated on its
own, and there is no session to keep alive. The server identifies itself as
canvas-drop, version 1.
A first session, as tool calls:
whoami {}
-> { id, email, name, orgs, teams, isGuest }
create_canvas { "title": "Retro board" }
-> { id, slug, url, apiKey, deploy, ... } apiKey is returned once
deploy_canvas { "id": "<id>", "files": [{ "path": "index.html", "content": "<h1>Hi</h1>" }] }
-> { url, version: 1, fileCount: 1, totalBytes, warnings: [] }
get_canvas_file { "id": "<id>", "path": "index.html" }
-> { version, path, size, mime, hash, encoding: "utf8", content }
The canvas is live after the third call. A new canvas starts Restricted (the private API value), so
its URL serves content only to you until you widen access with update_canvas.
Sign-in (OAuth 2.1)
canvas-drop is its own OAuth 2.1 authorization server; it does not proxy your identity provider. A compliant client needs nothing but the URL:
- Discovery.
{base}/.well-known/oauth-authorization-serverand{base}/.well-known/oauth-protected-resource(RFC 8414 and RFC 9728). The issuer is{base}; the only scope iscanvas-drop. - Registration.
POST {base}/register(Dynamic Client Registration). - Authorization.
{base}/authorizeresolves identity server-side with the same auth strategy the dashboard uses, then checks the email allowlist and the blocked flag. Inoidcmode a signed-out browser goes through the normal login and back; inproxyanddevmode identity is already present, and a request without it is denied. PKCES256is required. - Tokens.
POST {base}/tokenexchanges the single-use code (60 s lifetime; client id and redirect URI must match) for an access token (token_type: "bearer",expires_in: 3600) and a refresh token. Refresh tokens rotate on every use.POST {base}/revokerevokes.
Every tool call carries Authorization: Bearer <access_token>. Tokens are stored
hashed. On each call the server looks the token up and re-checks that the account is
still active, so blocking a user or removing their email domain from the allowlist ends
a live token on the next request. A missing or rejected token answers
401 { "error": "unauthorized" } with a WWW-Authenticate: Bearer header whose
resource_metadata points at {base}/.well-known/oauth-protected-resource. Nothing the
client asserts about identity reaches the tools; org membership is resolved server-side
on every request. Sign-in and token events are audited (mcp_authorize_ok,
mcp_authorize_denied, mcp_token_issue, mcp_token_revoke).
How every tool behaves
- Results. Success returns the JSON result as text content. Failure returns
CODE: messageas text withisError: true. Codes are stable; messages are for humans. - Ids, not slugs. A canvas tool's
idis the canvas id. A team tool'sidis the team id. - Scope. Canvas tools see the canvases you own or edit. A canvas you hold no role on,
including one you can only view, reads as
canvas not found: no existence leak, no cross-account management. Admins get no extra reach on this surface. - Roles. An editor can do everything the owner can except
delete_canvas,transfer_canvas, and the guest-AI fields ofupdate_canvas(guestAiEnabled,guestAiCap). Those answerOWNER_ONLY: Only the canvas owner can do this.for an editor. Roles are resolved on every call; a demotion or removal applies to your next request. - Disabled canvases. When an admin has disabled a canvas, read tools keep working and
every mutation answers
DISABLED: This canvas has been disabled by an administrator.(followed byReason: …when one was given), the same contract as the management API's409 { "code": "DISABLED" }. Only an admin can re-enable it. - Archived canvases. Deploy, publish, and rollback refuse with
NOT_ACTIVE: …; callunarchive_canvasfirst. - Limits. Calls are rate-limited per account from the canvas-API bucket
(
CANVAS_DROP_RATELIMIT_CANVAS_API_PER_MIN, default 120 per minute); over the limit answers429 { "error": "rate_limited" }withRetry-After. A request body over 110 MiB answers413 { "error": "payload_too_large" }. - Audit. Every mutation writes the same audit event as its dashboard equivalent.
Refusal codes you will meet across tools:
| Code | Meaning |
|---|---|
OWNER_ONLY |
An editor called an owner-only act, or tried to change the owner entry. |
DISABLED |
Admin takedown; the canvas is read-only. |
NOT_ACTIVE |
The canvas is archived. |
GUEST_VIEWER_ONLY |
editor was requested for a guest (an email outside the org); guests are always viewers. |
PUBLIC_LINK_OWNER_GATED |
You are an editor and the owner's account cannot publish public links; the entitlement follows the owner, whoever acts. The owner hears PUBLIC_NOT_ALLOWED for the same state. |
DRAFT_CONFLICT |
A stale draft write; see the draft tools. |
INVALID_REQUEST |
Mutually exclusive inputs were both supplied, or neither was. |
Tools
49 tools: 13 open to any signed-in account, 32 at minimum role editor, 2 owner-only.
Optional inputs are marked ?. "View" is the canvas projection described under Return
shapes below.
Account, lists, create
Available to any signed-in account.
| Tool | Input | Result |
|---|---|---|
whoami |
none | {id, email, name, orgs: [{id, name}], teams: [{id, name, slug, orgId}], isGuest}. orgs is empty when no org boundary is configured; isGuest is true only when an org boundary exists and you belong to no org. |
list_canvases |
role? (owned | edited), access? (restricted | whole_org | public_link, or a legacy value), scope? (active default | archived), shared?, protected?, listed?, template?, undeployed? (booleans), query?, tags? (string[]), sort? (updated default, created, title, popular), limit? (clamped to 1-100, default 50), offset? (clamped to 0 or greater, default 0) |
{total, limit, offset, summary, canvases: [View + {owner: {id, name, email}, role: "owner" | "editor", recentViews}]}. summary is the filter-independent Your-canvases inventory: {active, archived, shared, protected, listed, templates, neverDeployed, owned, edited}. The five boolean state filters match the dashboard chips; shared means access extends beyond the people-and-teams list. query is a forgiving text filter over title, description, tags, and slug (case-, accent-, and whitespace-insensitive; words are AND-ed). tags matches canvases carrying any of the given tags. popular ranks by views in the last 30 days (recentViews). For a multi-page sweep that mutates canvases, use the stable created or title sort. |
list_shared_canvases |
query?, sort? (updated default, title, owner), limit? (1-100, default 50), offset? (default 0) |
{total, limit, offset, canvases: [{id, slug, url, title, description, tags, access: {kind: "direct" | "team" | "whole_org", label, teamIds?, teamNames?}, hasPassword, hasPreview, owner: {id, name, avatarUrl} | null, createdAt, updatedAt}]}. Canvases you can open but do not manage: anything you are on the people-and-teams list of (directly or through a team, at any access value), plus the Whole-org shares their owner listed. Display-only; open the url. |
create_canvas |
title?, description?, backendEnabled?, slug? (≤63), orgId? (string or null) |
View + apiKey (the deploy key, returned once) + deploy (ready-to-run endpoints with the real key embedded, see Return shapes). orgId from whoami.orgs homes the canvas in that org so it can be shared org-wide; omit it for a personal canvas. ORG_FORBIDDEN, INVALID_SLUG, SLUG_TAKEN. |
clone_canvas |
id (the source) |
View of the new canvas: an unpublished, Restricted draft with an empty direct-access list, a fresh slug and key, and backend off. Eligible sources: any active canvas you own or edit, a gallery-listed templatable canvas (org-scoped), or a published, active, unexpired, password-free canvas granted directly to you or to a team you belong to, at any General-access value. General access alone does not grant cloning; pending invitations and retained legacy guests cannot clone. A viewer's new copy remains theirs if the source grant is later revoked. Anything else reads canvas not found. |
Read a canvas
Minimum role: editor. These keep working on a disabled canvas.
| Tool | Input | Result |
|---|---|---|
get_canvas |
id |
View + owner, role, publicLinkEnabled, teamIds (the team grants on the people-and-teams list, at any access value; [] when none), ownerOnlyActs: ["delete", "transfer", "guest_ai"], deploy with a $CANVAS_KEY placeholder (the key is never re-issued), plus the deployment-coordination readback: publicationToken (always present) and currentVersion: {id, number, releaseId, createdAt} | null. |
list_versions |
id |
{versions: [{id, number, source, status, createdBy, createdByName, createdByEmail, createdAt, fileCount, totalBytes, releaseId, current, downloadUrl}]}. id is the immutable version id, releaseId the release identity it was deployed under (or null); downloadUrl is {base}/mcp/canvases/{id}/versions/{n}/download, a ZIP of that version. |
get_canvas_file |
id, path? |
Without path: {version, fileCount, files: [{path, size, mime, hash}]} for the live version. With path: {version, path, size, mime, hash, encoding: "utf8" | "base64", content}. A file over 256 KiB returns truncated: true and a note instead of content; compare the hash. Fails with this canvas has no live version yet or no file at "…". |
get_canvas_usage |
id |
{totalViews, uniqueViewers, lastViewedAt, viewsByDay, kvOps, fileOps, fileCount, fileBytes, aiCalls, aiTokens, aiCostUsd, realtimeConnects}. |
list_canvas_connections |
id |
{connections: [{key, label, origin, allowedMethods, protectedHeaderNames, enabled, available, unavailableReason}]}. This is sanitized authority metadata only: protected header values are never returned. A Connection works only while its live admin grant and profile remain enabled and the canvas backend is on. |
list_access |
id |
{entries: [{id, kind, role, email, name, userId, teamId, teamOrgId, createdAt}]}: the owner first, then people, pending sign-in grants, and teams. kind is owner, member, guest, pending, or team; role is owner, viewer, or editor. Entry ids are stable (owner, member:<id>, guest:<id>, pending:<id>, team:<teamId>); pass them to set_access_role and revoke_access. When you are the owner the result also carries transferCandidates: [{id, name, email}], the set transfer_canvas accepts. |
search_people |
context (canvas | team), canvasId?, teamId?, q (1-80 chars) |
{people: [{id, email, name}]}: the dashboard's Add person suggestions, scoped to a canvas you own or edit (canvasId) or a team you can see (teamId). INVALID_REQUEST: missing canvasId or teamId for context when the matching id is absent; a canvas you do not manage or a team you cannot see reads not found. Does not expose the admin People directory. |
Deploy
Minimum role: editor. deploy_canvas, begin_deploy, and finalize_deploy also
require an active canvas (NOT_ACTIVE); add_files needs only the open handle. Every
deploy goes live at once as a new immutable version. There is no draft step on this
path; for a draft, use the editor tools below.
| Tool | Input | Result |
|---|---|---|
deploy_canvas |
id, exactly one of zipBase64 or files: [{path, content, encoding?: "utf8" | "base64"}], and optionally releaseId, expectedPublicationToken |
{outcome: "published" | "already_current", url, version, versionId, releaseId, publicationToken, fileCount, totalBytes, warnings: []}. INVALID_REQUEST for both or neither payload; empty deploy for a zero-byte ZIP. Ingest failures use the Deploy API codes: EMPTY_DEPLOY, TOO_MANY_FILES, FILE_TOO_LARGE, CANVAS_TOO_LARGE, INVALID_ZIP, INVALID_PATH, INVALID_ENCODING, ZIP_SLIP_REJECTED, ZIP_BOMB_REJECTED. Coordination: INVALID_RELEASE_ID, and the conflicts PUBLICATION_CHANGED / RELEASE_NOT_CURRENT, whose text ends with the current publication as JSON. |
begin_deploy |
id, manifest: [{path, hash, size}] (hash is the sha256 hex of the bytes), optionally releaseId, expectedPublicationToken |
{uploadId, missingHashes}: the blobs the server does not already hold. The handle lives 15 minutes. A release that is live already answers the already_current result instead (no uploadId); a stale token fails PUBLICATION_CHANGED before anything is staged. INVALID_MANIFEST, INVALID_RELEASE_ID. |
add_files |
id, uploadId, files: [{path, content, encoding?}] |
{staged: <count>}. Call repeatedly to chunk. UPLOAD_HANDLE_INVALID, UPLOAD_EXPIRED, UPLOAD_ALREADY_FINALIZED, UPLOAD_UNEXPECTED_BLOB, BLOB_HASH_MISMATCH, INVALID_ENCODING. |
finalize_deploy |
id, uploadId, optionally expectedPublicationToken (replaces the one captured at begin), releaseId (must equal begin's) |
Same result as deploy_canvas. Single-use, except that a repeated finalize of a live release answers already_current. UPLOAD_MISSING_BLOB (stage it and retry), UPLOAD_IN_PROGRESS (a 60 s lease), UPLOAD_ALREADY_FINALIZED, RELEASE_ID_MISMATCH; a PUBLICATION_CHANGED conflict leaves the handle usable for a finalize with the fresh token. |
Limits: 100 MiB per canvas, 25 MiB per file, 2000 files. Read "Which deploy tool to use" below before sending bytes through a tool call.
Versions and lifecycle
Minimum role: editor unless marked owner-only.
| Tool | Input | Result |
|---|---|---|
rollback_canvas |
id, version (integer) |
View + version. The target must be a ready version (no ready version N otherwise); NOT_ACTIVE on an archived canvas; that version was just removed; retry if it was pruned mid-swap. |
unpublish_canvas |
id |
{url, publicationState: "draft", currentVersionId: null}. Takes the canvas offline, drops live sockets, and revokes legacy guest grants. CANNOT_UNPUBLISH: this canvas isn't published. |
delete_version |
id, version (integer > 0) |
{ok: true, version}. The current version is protected (CURRENT_VERSION); also VERSION_NOT_FOUND, VERSION_UNAVAILABLE. Blobs shared with other versions or the draft are retained. |
archive_canvas |
id |
View with status: "archived". Reversible; takes the URL offline. NOT_ACTIVE: only an active canvas can be archived. |
unarchive_canvas |
id |
View + owner, role, with status: "active". NOT_ARCHIVED: canvas is not archived. |
delete_canvas (owner-only) |
id |
{ok: true}. Soft-delete: the URL stops resolving and the canvas is purged after the retention window. Refused with DISABLED on a disabled canvas; OWNER_ONLY for an editor. Not reversible over MCP. |
transfer_canvas (owner-only) |
id, toUserId (a user id, never an email) |
{ok: true, canvas: View, previousOwnerEditor, publicLinkReverted}. Instant: the recipient, an existing editor (see transferCandidates), becomes owner and you stay on as an editor; the public-link entitlement now follows their account, and publicLinkReverted tells you if a public link had to be turned off. A team cannot receive a canvas. NOT_ELIGIBLE, TARGET_NOT_FOUND, TARGET_BLOCKED, TARGET_NOT_MEMBER, ALREADY_OWNER, SELF, CONFLICT. |
Settings
Minimum role: editor.
| Tool | Input | Result |
|---|---|---|
update_canvas |
id plus any of: title (≤200), description (≤2000; null clears), access (private | whole_org | public_link; the legacy specific_people / team are accepted as aliases of private), discoverability (link_only | listed), teamIds (string[], ≤50), password (null clears), sharedExpiresAt (Unix ms; null clears), spaFallback, previewMode (auto | off), galleryListed, galleryTemplatable, tags (≤20, each ≤50 chars), and the owner-only guestAiEnabled, guestAiCap |
View + owner, role, teamIds, and sometimes warning (an edge-cache staleness notice when restricting a formerly public canvas; surface it to the user). Omitted fields are unchanged. Audits password_change and share_change. |
set_capabilities |
id, backendEnabled?, kv?, files?, ai?, realtime?, authoring? (all booleans) |
View + owner, role. backendEnabled is the master switch; the others take effect only when it is on. authoring also needs the instance switch on. Omitted fields are unchanged; switching a capability off drops sockets that lost access. |
set_canvas_slug |
id, slug? (≤63; omit for a fresh random slug) |
View + deploy ($CANVAS_KEY placeholder). The old URL stops resolving immediately. INVALID_SLUG, SLUG_TAKEN. |
set_canvas_preview |
id, image? (base64 PNG, JPEG, or WebP; the string ≤40 MiB, decoded ≤25 MiB) |
View + owner, role. With image, previewMode becomes custom and a publish never overwrites the cover; without it, a custom cover is cleared back to auto (an auto-captured screenshot is left alone). INVALID_IMAGE, IMAGE_TOO_LARGE. For the auto/off toggle use update_canvas previewMode. |
regenerate_deploy_key |
id |
{apiKey, deploy}. Mints a new cd_… key (returned once, embedded in deploy.curl) and invalidates the old one. An editor may rotate it; the owner is emailed naming the actor. |
Notes on update_canvas:
- The people-and-teams list (
grant_access/revoke_access) applies at everyaccessvalue;accessonly says who else may open the canvas.whole_organdpublic_linkneed a published canvas: active with a live version (SHARE_REQUIRES_PUBLISH).public_linkalso needs the instance switch on (PUBLIC_LINKS_DISABLED) and an owner whose account may publish public links (PUBLIC_NOT_ALLOWEDto the owner,PUBLIC_LINK_OWNER_GATEDto an editor). When an org boundary is configured,whole_orgneeds a canvas homed in an org (ORG_REQUIRED); passorgIdatcreate_canvas. - To share with a team,
grant_accessit withteamId(and arole). The legacyteamIdsfield replaces the viewer-team grants on the list (at least one team you belong to; personal teams fit any canvas you own, org teams must match the canvas's org:TEAM_REQUIRED,TEAM_FORBIDDEN). Changingaccessnever touches team grants. discoverabilitycontrols only whether a Whole-org canvas appears in Shared for the whole org; people and teams on the list always see it there. It never widens URL access. Offwhole_orgit is pinned tolink_onlyon every write (a legacyteamrow that storedlistedreadslisteduntil its next settings write).- Legacy shape:
teamIds: []sent by a client that sees the canvas on the legacyteamvalue, together with anaccesschange to another value, is a no-op (the grants stay on the list);teamIds: []on its own, or with any otheraccessvalue, is refused (TEAM_REQUIRED). SettinggalleryListed: trueon a Whole-org canvas also setsdiscoverability: "listed"; passinglink_onlyin the same call is refused (DISCOVERY_CONFLICT). - Gallery listing needs a shared, published, password-free canvas on
public_linkor a listedwhole_org(NOT_SHARED,NOT_PUBLISHED,PASSWORD_PROTECTED,NOT_GALLERY_ELIGIBLE).galleryTemplatable: trueneeds the canvas listed first (NOT_LISTED). - The people-and-teams list itself is managed with
grant_access,set_access_role, andrevoke_access.
Sharing and people
Minimum role: editor.
| Tool | Input | Result |
|---|---|---|
grant_access |
id, exactly one of email or teamId, role? (viewer default | editor) |
Person: {ok: true, status: "granted" | "pending" | "role_changed" | "already_added" | "already_pending", role, emailDelivery?}. An existing user is granted now; an admissible new email becomes a pending sign-in grant that carries the role; passing role for someone already listed updates it, and omitting it never changes an existing entry. Team: {ok: true, status: "granted" | "role_changed" | "already_added", role, from}. Only org members and teams can be editors (GUEST_VIEWER_ONLY). Every grant opens the canvas at once, whatever access (General access) says; an editor also manages it. Also INVALID_REQUEST: pass exactly one of email or teamId, NOT_PERMITTED, AUTH_ADMISSION_REQUIRED, BLOCKED, RATE_LIMITED, TEAM_FORBIDDEN, EMAIL_NOT_CONFIGURED. |
invite_to_canvas |
id, email, role? |
The same person result as grant_access, through the same Add person service, and it sends the access email. A brand-new external email is refused for a non-admin unless the instance allows it (NOT_PERMITTED); RATE_LIMITED past the cap. |
revoke_access |
id, entryId (from list_access) |
{ok: true}. Removes a person (another editor, or yourself), a pending grant, a legacy guest row, or a team grant; sockets the entry no longer permits are dropped. The owner entry refuses (OWNER_ONLY); an unknown id reads access entry not found. |
set_access_role |
id, entryId, role (viewer | editor) |
{ok: true}. A guest can only be a viewer (GUEST_VIEWER_ONLY); the owner entry refuses (OWNER_ONLY; use transfer_canvas). Demoting drops the person's live editor sockets. |
Draft editor loop
Minimum role: editor. These mirror the browser editor: a per-canvas draft that
publish_draft snapshots into a live version. DraftView is
{files: [{path, size, mime, hash, updatedBy, updatedByName, updatedAt}], stale, baseVersionId, updatedAt, dirty, changes: [{path, kind}], entry},
where changes compares saved files with live (added, modified, deleted),
entry describes the home page (path, reason), dirty means the draft differs
from live and stale means live moved on since
the draft was based.
| Tool | Input | Result |
|---|---|---|
get_draft |
id |
DraftView. Created from the live version on first open. Works on a disabled canvas. |
read_draft_file |
id, path |
{path, encoding, content, hash, updatedBy, updatedByName, updatedAt}. no draft file at "…". |
write_draft_file |
id, path, content, encoding? (utf8 default | base64), create?, expectedHash? |
DraftView. create: true refuses to overwrite (PATH_EXISTS). expectedHash is the hash you loaded, or the literal none for a path you believe absent; a mismatch fails with DRAFT_CONFLICT: … (path= currentHash= updatedBy= updatedByName= updatedAt=): re-read and retry with the current hash. Without expectedHash the write still fails with DRAFT_CONFLICT if a different user wrote that file last. Two editors in different files never conflict. |
delete_draft_file |
id, path, expectedHash? |
DraftView. |
rename_draft_file |
id, from, to, expectedHash? (checked on from) |
DraftView. |
publish_draft |
id |
{version, versionId, fileCount, totalBytes}. NOT_ACTIVE: unarchive this canvas before publishing, EMPTY_DEPLOY, DISABLED. |
restore_draft |
id, version (integer > 0) |
DraftView, reset to that version's files. |
Teams
Available to any signed-in account; self-serve only, with no admin reach. Here id is
a team id.
| Tool | Input | Result |
|---|---|---|
list_teams |
none | {teams: [{id, orgId, name, slug, mine, canManage}]}. orgId is null for a personal team. mine: you belong to it. canManage: you created it, so you can rename or delete it. |
create_team |
orgId? (string or null), name (1-80 chars) |
{id, orgId, name, slug}. Omit orgId for a personal team; pass one from whoami.orgs to attach the team to that org. You become its first member and its manager. |
rename_team |
id, name (1-80 chars) |
{id, name}. Creator only (FORBIDDEN). |
delete_team |
id |
{ok: true}. Creator only (FORBIDDEN). |
add_team_member |
id, email |
{status: "granted" | "pending", emailDelivery?}. Any member may add; an org team takes same-org members only; a brand-new external email on a personal team is refused for a non-admin unless the instance allows it. |
remove_team_member |
id, userId |
{ok: true}. Pass your own id to leave. |
cancel_team_invite |
id, inviteId (a pending row id from list_team_members) |
{ok: true}. A stale id reads TARGET_NOT_FOUND. |
list_team_members |
id |
{members: [{userId, email, name}], pending: [{id, email, invitedAt}]}. |
Team error codes: NOT_A_MEMBER, TEAM_NOT_FOUND, TEAM_NAME_TAKEN, FORBIDDEN,
TARGET_NOT_FOUND, TARGET_NOT_MEMBER, TARGET_NOT_PERMITTED, TARGET_BLOCKED,
AUTH_ADMISSION_REQUIRED, RATE_LIMITED.
Return shapes
Single-canvas management responses (get_canvas, update_canvas, and the dashboard HTTP view) include publicLinkEnabled: true only when Public link is selected, the instance allows public sharing, and the owner retains permission to publish publicly. Lifecycle, password, and expiry still apply separately. This field is resolved from the same policy as content access; it does not use the requesting editor's public-publishing permission.
View, the canvas projection every canvas tool echoes:
{ id, slug, url, ownerId, title, description, status, publicationState,
currentVersionId, access, accessMode, discoverability, hasPassword, sharedExpiresAt,
spaFallback, backendEnabled, disabledReason, galleryListed, galleryTemplatable,
tags, guestAiEnabled, guestAiCap, previewMode, viewCount, lastViewedAt,
hasPreview, previewUrl? }
publicationState is draft, published, archived, disabled, or deleted.
accessMode is the audience — restricted (private or a legacy alias: only the
people-and-teams list, which applies at every value), whole_org, or public_link — so an
agent never has to know the three spellings of "restricted". previewUrl is present when a
preview exists. Identity-bearing tools add owner: {id, name, email} | null and
role: "owner" | "editor"; teamIds lists the granted teams at any access value. The
password hash and the API key are never included. list_canvases also returns limit,
offset, and the filter-independent summary counts shown on the dashboard; these describe
the whole owned-or-edited inventory even when the returned page is filtered.
deploy, returned by create_canvas, get_canvas, set_canvas_slug, and
regenerate_deploy_key: the exact keyed Deploy API endpoints
for this canvas, so there is nothing to probe.
{
"apiBase": "https://canvases.example.com/v1/canvases/{id}",
"zipUpload": "PUT https://canvases.example.com/v1/canvases/{id}/deploy",
"staged": {
"begin": "POST https://canvases.example.com/v1/canvases/{id}/uploads",
"stageBlob": "PUT https://canvases.example.com/v1/canvases/{id}/uploads/{uploadId}/blobs/{hash}",
"finalize": "POST https://canvases.example.com/v1/canvases/{id}/uploads/{uploadId}/finalize"
},
"readback": "GET https://canvases.example.com/v1/canvases/{id}/files",
"status": "GET https://canvases.example.com/v1/canvases/{id}",
"curl": "curl -X PUT \"https://canvases.example.com/v1/canvases/{id}/deploy\" -H \"Authorization: Bearer $CANVAS_KEY\" --data-binary @site.zip"
}
status is the canvas readback: publication state, the current releaseId, and the
publicationToken a coordinated deploy passes back.
create_canvas and regenerate_deploy_key embed the real key in curl (returned
once); get_canvas and set_canvas_slug show the $CANVAS_KEY placeholder, so set it
from your own copy. The host is CANVAS_DROP_API_BASE_URL, falling back to
CANVAS_DROP_BASE_URL. In subdomain mode it differs from the canvas hosts, which is
why you should use the advertised endpoints rather than guessing.
Which deploy tool to use
deploy_canvas sends the whole payload in one call: use it for the first publish of a
small canvas. Use the staged flow (begin_deploy, add_files, finalize_deploy) when
the canvas already has content or has many, large, or binary files. Fresh tiny canvas:
deploy_canvas. Everything else: staged.
The staged flow:
begin_deploywith the full manifest (path,hashas sha256 of the bytes,size). The reply'smissingHasheslists the blobs the server does not already hold. Storage is content-addressed, so an unchanged file is never re-sent; a re-deploy that changed one file sends one file.add_fileswith the contents for those hashes, in as many calls as you like.finalize_deployto publish. The handle is single-use and short-lived; a finalize that is missing a blob fails cleanly and can be retried after staging it.
Over MCP, add_files content still travels in the tool call. The saving comes from not
resending unchanged files and from chunking.
When several publishers may ship the same build (a local tool plus a CI fallback),
pass an opaque releaseId and the publicationToken from get_canvas as
expectedPublicationToken on deploy_canvas or begin_deploy. A release that is live
already answers outcome: "already_current" and creates nothing; a token that went
stale fails PUBLICATION_CHANGED with the current publication; a release that exists
only in history fails RELEASE_NOT_CURRENT. Treat each as a reassess signal: read
back and decide, never refresh the token and retry blindly. The full contract and a
recipe live under Coordinate two publishers.
Prefer curl and the keyed Deploy API for the file transfer whenever you can run
shell commands. Every MCP deploy tool inlines file contents into the tool call, so they
pass through the model. If you lack command or network permission, request it rather
than inlining bytes. The same staged flow runs over plain HTTP: POST {apiBase}/uploads
with the manifest, PUT {apiBase}/uploads/{uploadId}/blobs/{hash} with each blob's raw
bytes, then POST {apiBase}/uploads/{uploadId}/finalize. The bytes go from disk to the
server without entering the model context, with no tool-call size ceiling.
create_canvas returns the per-canvas key and the exact URLs in its deploy block.
Reserve the MCP deploy tools for a small first publish when shell access is
unavailable.
Verify a deploy
The live URL is access-controlled, so do not confirm a deploy by fetching it: an
unauthenticated GET returns a login page, not your files. Verify through the server:
- The deploy or finalize result already returns
{url, version, fileCount, totalBytes}. list_versionsshows the new version ascurrent.get_canvas_filereads back what is live: nopathlists the live files (path,size,mime,hash); apathsuch asindex.htmlreturns that file's content (text as UTF-8, binary as base64; files over 256 KiB return their hash only, so compare it to what you deployed).- Over curl, the same read-back is
GET {apiBase}/files, with?path=for raw bytes and no size cap.apiBasecomes from thedeployblock.
Each list_versions row carries a downloadUrl for a complete ZIP export of that
immutable version. Fetch it with the same OAuth access token in the
Authorization: Bearer … header; the route accepts no query-string token and no
dashboard cookie, and applies the same role gate and rate limit as the tools. It answers
application/zip on success, 404 { "error": "not_found" } for a canvas you hold no
role on, 400 { "error": "invalid_version" } for a version that is not a positive
integer, 404 { "code": "NOT_FOUND" } for a version that does not exist, and
503 { "code": "VERSION_INCOMPLETE" } rather than a partial archive when a referenced
blob is missing.
Enabling and disabling
The MCP surface is on by default (CANVAS_DROP_MCP=on). CANVAS_DROP_MCP=off removes
the /mcp endpoint, the version download route, and the OAuth routes (/authorize,
/token, /register, /revoke, /.well-known/*) entirely: they are not mounted, so
they answer like any unknown path. Behind a reverse proxy or identity-aware proxy, /mcp
must reach the app with its Authorization header intact and bypass the proxy's own
login; MCP carries its own auth. See Configuration
and Deploy.
Which path should an agent use?
- MCP: your host speaks MCP and you want a connect-once, multi-canvas, identity-scoped surface with the draft editor, sharing, and teams.
- Deploy API (HTTP with a per-canvas key): a keyed, sessionless agent or a CI step that holds one canvas's key. Also the right transport for the bytes of any large deploy, even from an MCP session.
- The packaged Agent skill documents both for a coding agent,
and
/llms.txtis the single-file quick reference.
Version cleanup and runtime audiences
preview_version_prune(id, versions) accepts "previous" or an explicit numeric
selection. Review its { versions, expectedVersionIds, skipped, estimatedReclaimableBytes } with the user. prune_versions(id, versions, expectedVersionIds) deletes only the explicit immutable selection and returns
{ deleted, skipped }. Both require owner/editor, as does single delete_version.
The estimate excludes references retained by current/history/draft/active uploads;
it does not promise recovered bytes. See version cleanup.
set_capabilities additionally accepts aiAudience and connectionsAudience,
each "editors" (default) or "viewers". Canvas views expose both. Runtime canvas
code reads its own role and permissions from me().
set_capabilities also accepts a full runtimePolicy document and
expectedRuntimePolicy, copied exactly from get_canvas.runtimePolicyRevision
(initially null). Preserve existing entries when adding resources. Stale/missing
revisions fail with POLICY_CONFLICT; reload and reconcile. Policies support
collection/file presets and overrides, channel rights, and per-Connection audiences
and methods. Defaults initialize new resources; they do not change existing ones.
See Permissions and defaults for the complete schema.
Collections group authored records inside KV, file groups organize standalone
uploads inside Files, and channels carry messages/presence inside Realtime. Each
name identifies a resource with its own policy. Match configured names in canvas
code; settings do not generate application features or migrate raw keys. The
Data storage guide explains the storage choices and collection API.
Viewers use configured authored collections, private preferences or the
submissions convenience API. Raw shared KV mutations
retain owner/editor gates. Runtime code cannot configure resource policies.