Files
pad/internal/mcp/instructions.md
T
xarmian ed89604ad8 fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* 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
2026-10-17 19:11:58 -04:00

23 KiB
Raw Blame History

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 a fields OBJECT (the same shape reads return, e.g. {"status":"done","effort":"l"}) — it merges into the same path as the dedicated params and field: ["key=value"]; the same key in two places with CONFLICTING values is refused, not silently resolved. list accepts unparented: true to keep items with no parent or implements relationship (mutually exclusive with parent). list results are SUMMARY-shaped by default on both transports — no content bodies; pass full: true for complete bodies (token-expensive), or prefer get for a single item's body. update field writes are a server-side field-level merge (only the keys you set change); pass expected_seq for optimistic concurrency — the seq you last read, which is on every item AND every list summary — and a stale value fails with a structured 409 update_conflict whose details carry actual_seq to retry with. PREFER IT over expected_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, prefer patch: edits: [{old, new}], where each old is exact text occurring once in the current body (read it with get and lines: "A-B" to quote it). The answer is a unified diff: read it, because anchors guarantee position, not meaning. A moved expected_seq is applied over and reported as rebased with 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 says content_state: "applied_pending_flush". patch_anchor_missing / patch_anchor_ambiguous mean re-read and quote current text; patch_unstable_roundtrip means the open editor would also change content you did not edit, so send a whole-body update; patch_room_open means an older editor that cannot apply patches holds the item. Every patch answer carries a patch_id; patch with undo: <patch_id> inverts it, and is refused when the text it changed has changed since. Send an op_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_unknown means an earlier attempt's editor never confirmed it and is gone; read details.diff before deciding. content REPLACES the whole body, so send expected_seq with it: get the item, edit that body, and send it back with the seq you read. An update_conflict whose details carry content_write means 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: true overwrites on purpose instead. A content write with neither carries warnings.unguarded_content_write, and a later release refuses it. A token cannot see a browser tab's unsaved edits, so content sent WITH one is refused with 409 content_pending_flush while any exist; re-reading will not clear it. Wait for the editor to save, or pass overwrite_pending_edits: true to 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 pass overwrite_pending_edits: true to discard them. Writing content while 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 with warnings.content_outcome: "applied_pending_flush", which describes your WRITE (it went to the document, not the row). A get (or a list with full: 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 carries content_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. move changes 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 in warnings.not_unique. Three system keys — implementation_notes, decision_log, convention — cannot be set through field on update or move; the call is refused with validation_failed, naming the key and the write path that does maintain it (note, decide, and library activation respectively). github_pr is refused as well (BUG-2696): a field value arrived as a string, so the PR data was stored double-encoded and no link appeared. Its writers are action=link-pr (pr_number + pr_url, metadata you supply; nothing is fetched from GitHub) and action=unlink-pr. On create they are refused too (BUG-3163), with the same validation_failed: create the item first, then use note / decide on 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. history returns read-only item version metadata (newest-first), bounded to the NEWEST 50 versions by default (max 300 — pass limit to change the window); pass full: true to include each version's resolved content body (token-expensive). There is no offset: versions are stored as reverse patches, so only a newest-end window is cheap to reconstruct. Unassign or detach with the clear_assigned_user / clear_agent_role / clear_parent booleans; an empty assign / role / parent does NOT clear. remind / ack-reminder: a fired reminder shows in pad_project next / 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 is lease_held (pick another, don't retry), and a lease bumps neither updated_at nor seq. A claim answers your worker_id; pass it to resume elsewhere. next / ready skip items other workers hold. edit-comment / delete-comment take a comment_id from list-comments (whose rows carry edited); 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 409 comment_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. delete archives: the collection and its items leave every view, and restore brings them back. An update that removes a field or a select option still in use answers with warnings.orphaned: tell the user how many items keep the removed value.
  • pad_project — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use ready for the ranked top items to work on next (blocked work is left out) and stale for items needing attention; activity to 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. Use run to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. run refuses a playbook whose status isn't active (a draft still being authored) with a playbook_not_active error — pass allow_draft: true to override. Both run and get echo the playbook's status. Both may also carry content_state ("applied_pending_flush", or "superseded_set_aside" as under pad_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. match finds 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. list enumerates a workspace's attachments (filter by item / category / collection / attached / unattached); show returns 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. The bootstrap action returns one-shot workspace context (user + collections + always-on conventions + a metadata-only convention_index of 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. Takes workspace: <slug> only (no action).

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:

  1. Explicit workspace argument on the call (always wins).
  2. On a single-user local server only: the session default set via pad_set_workspace.
  3. 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 in conventions. 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.