* fix(mcp): a refused call names its cause on both transports (BUG-3864) A 403 from one of the shared gates now carries details.reason (role, token_scope, no_access, workspace_not_authorized), with code and message unchanged. MCP builds one permission_denied envelope from it on both transports, with a hint that says what to do next. It used to lead with "Insufficient role" for every 403, so a bot with a read-only API token was told to ask for a role. An unverified Cloud email is reported as the server's own email_not_verified code. Stdio gets the same envelope from a structured marker the CLI root writes for a 403 coded forbidden, permission_denied or email_not_verified. Before it, the token-scope and unverified-email refusals said server_error there. ToolSurfaceVersion 0.99 -> 0.100 (behavior). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): the inline workspace-role gates name their reason too (BUG-3864) The bulk endpoint, the owner-only member doors and the owner-only app install and upgrade doors say role, or no_access for a guest. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(mcp): the permission hint is the same on both transports (BUG-3864) The remote hint appended the route, which stdio has no way to know, so one refusal reached the two transports with different hints. The tests compared prefixes only and missed it; both now assert the transports agree exactly. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): no role hint for a caller with no role to change (BUG-3864) requireMinRole and the grants list said role for a guest, who holds grants rather than a role, and for a caller with no workspace role at all. Both now go through roleGateReason, which answers no_access for either. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
23 KiB
Pad is a project tracker for developers and AI agents — issues (TASK, BUG), plans (PLAN), ideas (IDEA), docs (DOC), conventions, comments, and dependencies. Use this server when a user mentions:
- Issue refs like
TASK-5,BUG-12,PLAN-3,IDEA-8— they are stable, human-readable IDs and the canonical way to address items. Use them, never slugs; they are what audit trails and PR titles show. - Tasks / issues / items / plans / progress / "what's on my plate" / "what to work on next" / standup / changelog / retrospective.
- Project conventions, decision records, or "how should this team do X."
If the user is asking general code questions with no project-management thread, you don't need this server.
Tool surface (v0.100)
Ten resource × action tools, plus pad_set_workspace. Eleven tools total.
Inputs are validated strictly: an undeclared top-level key is rejected with a structured validation_failed naming it, never accepted and silently dropped.
pad_item— Items: create / update / delete / get / list / move / restore / link / unlink / deps / context / star / unstar / starred / comment / list-comments / edit-comment / delete-comment / backlinks / bulk-update / note / decide / export / import / history / remind / ack-reminder / reminders / unremind / link-pr / unlink-pr / claim / release / patch. On create/update, field values may be passed as afieldsOBJECT (the same shape reads return, e.g.{"status":"done","effort":"l"}) — it merges into the same path as the dedicated params andfield: ["key=value"]; the same key in two places with CONFLICTING values is refused, not silently resolved.listacceptsunparented: trueto keep items with no parent or implements relationship (mutually exclusive withparent).listresults are SUMMARY-shaped by default on both transports — no content bodies; passfull: truefor complete bodies (token-expensive), or prefergetfor a single item's body.updatefield writes are a server-side field-level merge (only the keys you set change); passexpected_seqfor optimistic concurrency — theseqyou last read, which is on every item AND every list summary — and a stale value fails with a structured 409update_conflictwhose details carryactual_seqto retry with. PREFER IT overexpected_updated_at, which cannot tell two writes inside one second apart; that one still works, and if you send both, seq decides. To change PART of a body, preferpatch:edits: [{old, new}], where eacholdis exact text occurring once in the current body (read it withgetandlines: "A-B"to quote it). The answer is a unifieddiff: read it, because anchors guarantee position, not meaning. A movedexpected_seqis applied over and reported asrebasedwith the intervening writes. On an item open in a browser editor, the editor applies the patch to its live document, merging with the typing there, and the answer sayscontent_state: "applied_pending_flush".patch_anchor_missing/patch_anchor_ambiguousmean re-read and quote current text;patch_unstable_roundtripmeans the open editor would also change content you did not edit, so send a whole-body update;patch_room_openmeans an older editor that cannot apply patches holds the item. Every patch answer carries apatch_id;patchwithundo: <patch_id>inverts it, and is refused when the text it changed has changed since. Send anop_key(any unique string, e.g. a UUID) with a patch whenever you might retry it: the same request with the same key returns the first answer instead of applying twice.patch_outcome_unknownmeans an earlier attempt's editor never confirmed it and is gone; readdetails.diffbefore deciding.contentREPLACES the whole body, so sendexpected_seqwith it:getthe item, edit that body, and send it back with theseqyou read. Anupdate_conflictwhose details carrycontent_writemeans someone changed the item after your read. Do NOT resend your body, because it would undo their change: re-read, apply your edit to the current content, and send that with the new seq.replace: trueoverwrites on purpose instead. A content write with neither carrieswarnings.unguarded_content_write, and a later release refuses it. A token cannot see a browser tab's unsaved edits, socontentsent WITH one is refused with 409content_pending_flushwhile any exist; re-reading will not clear it. Wait for the editor to save, or passoverwrite_pending_edits: trueto replace them.content_state: "superseded_set_aside"is different and wins when both apply: an editor upgrade set aside edits no tab can restore, so EVERY content write is refused (token or not;details.set_aside_rows) and waiting never clears it. Tell the user, or passoverwrite_pending_edits: trueto discard them. Writingcontentwhile a browser tab has the item open routes the markdown through that live document; the stored row updates only on a later collab-snapshot flush, which nothing guarantees. The 200 echoes what you SENT withwarnings.content_outcome: "applied_pending_flush", which describes your WRITE (it went to the document, not the row). Aget(or alistwithfull: true; a default list carries no content) before the flush returns the PREVIOUS content: that is lag, not a failed write, so do not re-send. A read whose body the server knows is behind the live document carriescontent_state: "applied_pending_flush", also when someone ELSE's tab holds the edits and you wrote nothing: same value, but it describes the ROW, not your request. Treat it as re-read later, never as re-send, and do not wait on it in a loop: the server normally recovers the stored copy itself about a minute after the last tab leaves the item, but that is not guaranteed (a recovery can fail, and an operator can turn it off), and otherwise the row catches up only when a tab next opens the item. The stored form may also not be byte-identical to what you sent, since the markdown round-trips through the editor (bullet markers, emphasis characters, list numbering and blank-line runs normalise; already-canonical markdown survives unchanged), so compare on meaning rather than bytes.movechanges an item's COLLECTION within its workspace: system metadata (implementation notes, decision log, linked PR) survives it, and any field value the target schema has no home for is dropped AND reported in the move's activity entry — check there rather than assuming a move is lossless. A carried unique-field collision is dropped, named inwarnings.not_unique. Three system keys —implementation_notes,decision_log,convention— cannot be set throughfieldon update or move; the call is refused withvalidation_failed, naming the key and the write path that does maintain it (note,decide, and library activation respectively).github_pris refused as well (BUG-2696): afieldvalue arrived as a string, so the PR data was stored double-encoded and no link appeared. Its writers areaction=link-pr(pr_number+pr_url, metadata you supply; nothing is fetched from GitHub) andaction=unlink-pr. Oncreatethey are refused too (BUG-3163), with the samevalidation_failed: create the item first, then usenote/decideon it; convention metadata comes from library activation.context(intent,role,budget): one brief before you start work, with parents, matching convention bodies, deps and links.historyreturns read-only item version metadata (newest-first), bounded to the NEWEST 50 versions by default (max 300 — passlimitto change the window); passfull: trueto include each version's resolved content body (token-expensive). There is nooffset: versions are stored as reverse patches, so only a newest-end window is cheap to reconstruct. Unassign or detach with theclear_assigned_user/clear_agent_role/clear_parentbooleans; an emptyassign/role/parentdoes NOT clear.remind/ack-reminder: a fired reminder shows inpad_projectnext / ready (the delivery path on an instance with no webhook) until acknowledged, and one on a completed item is hidden there.claim/release: claim an item before you work it; a loser's 409 islease_held(pick another, don't retry), and a lease bumps neitherupdated_atnorseq. A claim answers yourworker_id; pass it to resume elsewhere. next / ready skip items other workers hold.edit-comment/delete-commenttake acomment_idfromlist-comments(whose rows carryedited); only a comment's author may edit it. Correct a comment in place rather than appending a correction. Deleting a comment that has replies leaves a placeholder (deleted: true, empty body) so the replies keep their parent; editing, replying to or reacting to it answers 409comment_deleted.
List filters must name a field (v0.75). A pad_item list filter on a key no collection in scope declares, and no item stores, is refused with validation_failed ("invalid list filter"), where it used to return an empty list; a stored undeclared key still filters. Check the schema; don't retry.
Library words in a blank workspace (v0.67). A brand-new blank workspace lists one convention trigger (always) and one scope (all). Activating a library entry, or creating a convention or playbook with a trigger or scope the library itself uses, adds that word to the collection's options and says so in warnings.options_added. A trigger you invent is still refused until the workspace owner adds it to the collection's schema.
App attribution (v0.65). An item an installed app created, a comment an app wrote and a history row an app's write made carry via_app (the install id) and via_app_name (the app's name). On an item they name the app that CREATED it, not the last writer. They are absent for anything a person or an agent wrote.
Relation fields (v0.31). A relation value may be a UUID, an issue ref (COLO-3) or the target's EXACT TITLE, tried in that order (a title that is also a ref resolves as the ref). A title matches only inside the declared collection, among items you can see; found only elsewhere, it is refused naming that collection; several matches are ambiguous. Full rules: docs/mcp.md.
Reads carry a relation_targets member beside fields: field key → {id, ref, title}, so you can render a relation without a request per value. fields still holds the canonical id, which is what you write back. An entry with an id and NO ref/title means the target is gone or you may not see it — the two are deliberately indistinguishable, so do not render it as either "deleted" or "hidden"; "unavailable" is the honest word. An entry with stored_as_text: true differs: the stored value is not UUID-shaped, so it was never an id (an imported title, legacy text). Render it as text, not as unavailable.
multi_relation fields (v0.33). An ORDERED LIST of references, each resolved like a relation (UUID, ref, or exact title in the declared collection). Declare one as owners:multi_relation:people; the target collection is required. Rules: [] means absent; a blank element is refused by index; order is part of the value; duplicates are refused after resolution (duplicate_referent); any failing element refuses the whole write; required needs one resolved element; a cross-workspace copy drops the whole value (in warnings.dropped_fields). relation_targets holds an ARRAY of {id, ref, title} in stored order (id-only where unresolved); scalar keys stay single objects. Full rules: docs/mcp.md.
pad_workspace— Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.pad_collection— Collections: list / create / update / delete / list-archived / restore.deletearchives: the collection and its items leave every view, andrestorebrings them back. Anupdatethat removes a field or a select option still in use answers withwarnings.orphaned: tell the user how many items keep the removed value.pad_project— Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Usereadyfor the ranked top items to work on next (blocked work is left out) andstalefor items needing attention;activityto catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).pad_role— Agent roles: list / create / update / delete.pad_search— Full-text search across items: query.pad_playbook— Invokable procedures: list / get / run / match. Userunto bind args against a playbook's declared spec and get the rendered body back; side-effect-free.runrefuses a playbook whose status isn'tactive(a draft still being authored) with aplaybook_not_activeerror — passallow_draft: trueto override. Bothrunandgetecho the playbook'sstatus. Both may also carrycontent_state("applied_pending_flush", or"superseded_set_aside"as underpad_item), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has.matchfinds a playbook from text.pad_library— Convention + playbook library (the global catalog of pre-built entries workspaces activate): list / get / activate.pad_attachment— Read-only attachment metadata: list / show.listenumerates a workspace's attachments (filter by item / category / collection / attached / unattached);showreturns one attachment's MIME, size, filename, and ETag via a HEAD request. Uploading and general file downloads stay CLI-only; bounded image bytes are available through the attachment resource below.pad_meta— Server introspection: server-info / version / tool-surface / bootstrap. Thebootstrapaction returns one-shot workspace context (user + collections + always-on conventions + a metadata-onlyconvention_indexof every active convention + roles + playbook metadata + dashboard + recent activity).pad_set_workspace— Load workspace context; response embeds the bootstrap blob so you load context in one call. Whether it also pins a session default: see Workspace context below. Takesworkspace: <slug>only (noaction).
For the ten resource × action tools, always pass action as a top-level field. Per-action required parameters are documented in each tool's description.
Resources are cheaper than tool calls
Read these directly when you need workspace state (described in the resource listings): pad://workspaces, pad://workspace/{ws}/dashboard, .../collections, .../items (use pad_item list to filter), .../items/{ref} (markdown), .../attachments/{id} (images only, at most 1 MiB, as base64 thumb-md), .../bootstrap (the same payload as pad_meta bootstrap), and pad://_meta/version. A host can prefetch them once per session.
Load the bootstrap resource once per workspace and reuse its collections, conventions, roles, and playbook metadata. Refresh it after switching workspaces, after changing those configuration surfaces, when Pad reports stale schema/context, or when the user asks. For changing work state, read the affected item or use a targeted project tool instead of fetching the whole bootstrap again.
Workspace context
Every action that operates within a workspace accepts an optional workspace parameter. Resolution order:
- Explicit
workspaceargument on the call (always wins). - On a single-user local server only: the session default set via
pad_set_workspace. - On a single-user local server only: the CWD-linked workspace from
.pad.toml.
A multi-user/remote server does not persist a session default — pass workspace explicitly on every call. If none resolves, the action returns a structured no_workspace error with available_workspaces, on both the local and the remote transport.
Update flow: read first, then patch
For pad_item.action: update, the server merges your patch with the item's current state. Pass only the fields you want to change. When changing status, ALWAYS include a comment explaining why — it builds the audit trail that helps the team understand history.
One error code you must not retry
Errors come back as a structured envelope with a code. Most failures are worth a retry after you fix something, and a server_error may be transient. stored_state_unreadable is neither.
It means the ITEM'S STORED DATA cannot be decoded — your input was fine. Today it fires when note / decide would append to an implementation_notes / decision_log value that is not a list of entries. The operation is refused precisely because completing it would overwrite that value. The refusal is fully deterministic, so retrying — immediately or after a backoff — fails identically every time and changes nothing.
When you see it: tell the user which item is affected, and stop. Whether you can inspect the bad value depends on which layer is broken — if the item's whole fields blob fails to parse, action: get hands it back as a raw string; if one structured key is at fault, you won't see it at all, because the blob is normalized on this surface and a value that doesn't decode is dropped from the top-level arrays. A human can always read it with pad item show <ref> --format json. Do not route around the refusal by writing the field some other way; that is the write that caused it.
Project conventions
Workspaces can declare conventions (e.g. "run make test before PR", "use conventional commit format"). The bootstrap blob gives you two views:
conventions— full bodies of the always-on (trigger=always) rules. Follow these unconditionally.convention_index— METADATA ONLY (ref,title,trigger,role; no bodies) for every active convention, including the triggered ones whose bodies are NOT inconventions. This is your map of what triggered rules exist.
Before performing meaningful work with a specific trigger (e.g. on-implement before writing code), consult convention_index: if it lists entries for that trigger, pull their bodies on demand; if it lists none, skip the query.
pad_item.action: list, collection: "conventions", status: "active"
Filter by trigger (always, on-implement, on-task-complete, etc.) when relevant — the convention_index triggers tell you which filters are worth running.
"conventions" above is the DEFAULT collection slug and a workspace may have renamed it, in which case that literal returns nothing while convention_index still lists entries — the bootstrap payload resolves by declaration, not by name. When the two disagree, address the items by the refs the index gave you, or find the collection with pad_collection.action: list. The same applies to "playbooks"; pad_playbook resolves by declaration and is unaffected.
Pushes and the session monitor (security note)
Pad can push an item into a Claude Code session (pad push, or the web item view's push composer). That is terminal instruction injection by design, so receiving is consent-gated (PLAN-2613, since v0.15.0): a session receives pushes only after it has connected (/pad:connect in the plugin, or a repo-level push.auto_arm = true in .pad.toml — which a per-user auto_arm = false in ~/.pad/config.toml vetoes), and pushes are self-addressed only — the sender is always the receiving session's own user. This MCP connection is not a session: it cannot arm one, receive a push, or run the monitor, and the pad_item / pad_project tools expose no push action. If a user asks you to "push" something to their terminal, tell them it happens from their own terminal (pad push <ref> -m "..." — the sending session need not be connected, the receiving ones must have consented); do not try to emulate it by writing comments or instructions into items.
Adding a workspace to this connection
If the user references a workspace this connection can't see (you'll get a 403 from workspace tools, or the workspace won't appear in pad_workspace.list), tell the user you can't see that workspace with your current permissions, then walk them through how to grant access: open Pad in their browser → switch to that workspace → avatar menu → "Connect project..." A 6-digit claim code will appear. Have them paste it back in chat, then call pad_workspace.claim with {workspace: "<slug>", code: "<6 digits>"}. The workspace joins this connection's allow-list and stays until the user revokes it via /console/connected-apps. No re-auth required.
For brand-new workspaces, pad_workspace.create with {name: "<name>"} (and optional template) creates the workspace AND auto-adds it to this connection's allow-list in one call — no claim code needed. Only works when the connection currently carries the "may create workspaces" grant — granted at consent time, or enabled later on the connections page. If the grant is present and set to false, the create is refused with a 403 and no workspace is made — retrying won't help and neither will the claim flow, since there is nothing to claim. Tell the user this connection isn't permitted to create workspaces, and offer them the two ways forward: create the workspace themselves in the browser and grant access with a claim code, or re-authorize this connection with "Let this app create new workspaces" enabled (/console/connected-apps can also flip it on an existing connection).
New workspace: offer to set it up
The bootstrap blob carries needs_onboarding: bool — true when the workspace has zero user-created items (template seeds don't count). When it's true, lead with an active offer before anything else: "This workspace is brand new and isn't set up yet. Want me to set it up? I'll ask a few quick questions and adapt it to your project."
This is an offer, not an auto-run — wait for the user to say yes before running onboarding. If they accept, run the onboard playbook (use the pad_onboard prompt, or load the body via pad_playbook action: get, ref: onboard). If they decline, or already declined earlier in the session, respect that and skip the offer. The flag flips to false the moment any item exists, so it won't nag past first setup.
Built-in updates: mention once
The bootstrap blob may carry builtin_updates: int (absent when there are none): how many of the conventions and playbooks Pad ships have newer text in Pad's library than this workspace's copy. When it is present, mention it once per session, in one line, then carry on. Never apply an update yourself: a person reviews each one and accepts it or not.
Multi-step workflows
Four prompts ship with the server: pad_plan, pad_ideate, pad_retro, pad_onboard. Use them when the user wants help planning, brainstorming, retrospecting, or onboarding into a workspace — they encode the multi-step Pad-aware playbook for each.