{
  "markdown": "# @laver/mcp\n\nAn MCP server for [Laver](https://laver.app). Gives an agent tools to read and\ndrive kanban boards, tickets and the workspace wiki.\n\nEvery tool is a thin call to the same public REST API the web app uses. There is\nno local state, no cache, and no second implementation of anything — if Laver\nrefuses a write, the refusal comes back verbatim, because an agent can act on\n\"409, re-read and retry\" and cannot act on \"something went wrong\".\n\n## Setup\n\nCreate a workspace-scoped API key in Laver under **Admin → API keys**. It acts\nas the person who created it, so it can do exactly what they can do and nothing\nmore, and it can be revoked without touching their account.\n\n```json\n{\n  \"mcpServers\": {\n    \"laver\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@laver/mcp\"],\n      \"env\": { \"LAVER_API_KEY\": \"your key here\" }\n    }\n  }\n}\n```\n\n`LAVER_API_URL` overrides the API host; it defaults to `https://api.laver.app`.\n`LAVER_API_KEY_FILE` is an alternative to `LAVER_API_KEY`: a path to either a\nfile containing nothing but the key, or a `.env`-style file with a\n`LAVER_API_KEY=…` line among others (an assignment line wins; quotes and an\n`export` prefix are both fine). That is how the `.mcp.json` in this repo\nregisters the server without a secret in a tracked file.\n\nA file with neither — no assignment line, and more than one token in it — yields\n**no key at all**, and you get the \"key is not set\" error. It used to send the\nwhole file as the token, which is fine for a file holding one secret and is a\nleak for anything else.\n\n`LAVER_API_URL` must be `https`, except for `localhost`.\n\n### Working in this repo\n\n`.mcp.json` at the repo root registers this server for anyone who opens the\nproject, reading the key from the gitignored `.env`. Nothing to export.\n\nIt runs the **published** package, `npx -y @laver/mcp`, rather than the\n`mcp/server.js` beside it. That is deliberate: pointing it at the local file\nmeant everyone here ran the one code path no user takes, and that is precisely\nhow 0.1.0 shipped with an entry point that never connected its transport when\nstarted through `bin` — which is the only way a real client starts it. Running\nwhat we publish means we meet what users meet.\n\n**If you are editing this server**, that same choice will fool you: your changes\ndo nothing until they are published. Point the client at the working copy while\nyou work on it —\n\n```json\n{ \"command\": \"node\", \"args\": [\"mcp/server.js\"] }\n```\n\n— and put it back before you commit. `npm run check` and\n`frontend/tests/check-mcp-bin-entrypoint.mjs` both run against the working copy\nregardless, so the tests never depend on a publish.\n\n**A client only connects to MCP servers at startup.** `claude mcp add` while a\nsession is already running does not retrofit the tools into that session — the\ntool list was built before the server existed. Start a new session (or\nreconnect from the client's MCP panel) and the tools appear.\n\n## Tools\n\n**Reading**\n\n| Tool                     | What it gives you                                                                     |\n| ------------------------ | ------------------------------------------------------------------------------------- |\n| `list_workspaces`        | Where to start when you have no uuids                                                 |\n| `list_boards`            | The boards in a workspace                                                             |\n| `get_board`              | A board with its status columns, labels, members and tickets                          |\n| `list_tickets`           | Tickets on a board, filterable, paged — `updated_since` is how you follow a board     |\n| `list_workspace_tickets` | Triage across every board at once — `overdue`, `unassigned`, or free text             |\n| `get_ticket`             | One ticket in full, its subtasks, **including its `version`**                         |\n| `get_ticket_comments`    | Comments and activity history                                                         |\n| `get_ticket_flow`        | How long the ticket has spent in each column                                          |\n| `list_custom_fields`     | A board's custom field definitions — the uuids `update_ticket` writes against         |\n| `list_labels`            | Every label in the workspace, not only the ones already used on one board             |\n| `search`                 | Boards, tickets, wiki pages and comments across a whole workspace at once             |\n\n`list_workspace_tickets` is the stand-up read: it needs no `board_uuid`, and it\nis the only thing here that answers \"what is late\" and \"what does nobody own\"\nwithout walking every board. It is deliberately the small sibling of\n`list_tickets` — one page, 50 by default and 200 at most, no cursor — so narrow\nit rather than paging it.\n\n`get_ticket_flow` derives its numbers from the moves already in a ticket's\nhistory. Read `visits` rather than the `by_status` totals if you are adding\nseveral tickets up: tickets worked in one batch overlap, and their totals do not.\n`gaps` says when the history and the ticket's current column disagree, which\nmakes the totals a floor rather than a measurement.\n\nTo follow a board, call `list_tickets` again with `updated_since` set to the\n`server_time` the previous call returned; you get back the tickets that changed\nand nothing else. There is no tool for the server-sent event stream at\n`GET /boards/:uuid/events` — a tool call is one request and one answer, and a\nstream that never ends is neither.\n\n**Asking for less.** A tool result is charged against the model's context, and\nthree of these calls are the ones you cannot route around: a `status_uuid` or a\n`label_uuid` comes from `get_board`, and a `page_uuid` comes from\n`get_wiki_tree`. On a busy board they were 154 kB, 119 kB and 180 kB. Each now\ntakes a parameter that narrows the reply, and every one of them is opt-in — a\ncall that passes none of them is unchanged.\n\n| Call                                        | Gives you                                      | Measured        |\n| ------------------------------------------- | ---------------------------------------------- | --------------- |\n| `get_board(include_tasks: false)`           | the structure alone — statuses, labels, fields | 155 kB → 17 kB  |\n| `get_wiki_tree(depth: 1)`                   | the top level, nothing nested under it         | 119 kB → 1.8 kB |\n| `get_wiki_tree(parent_page_uuid: …)`        | one page and everything beneath it             | 119 kB → 9.8 kB |\n| `list_tickets(include_descriptions: false)` | titles and uuids without the bodies            | 167 kB → 45 kB  |\n\nReach for `get_board(include_tasks: false)` whenever you called it for a uuid\nrather than for the tickets, and `list_tickets` when you want the tickets —\nthat one takes `status` and `limit` as well.\n\n**Writing**\n\n| Tool                | Notes                                                                                                                          |\n| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `create_ticket`     | `status` takes the column name, or pass `status_uuid`; markdown in `description` is parsed                                     |\n| `update_ticket`     | Needs `version`; markdown in `description` is parsed; `custom_fields` is keyed by field uuid and **replaces** the whole object |\n| `move_ticket`       | Needs `version`, and a column — neither column is a 400                                                                        |\n| `comment_on_ticket` | Markdown in `body` is parsed; no `version`, so it cannot 409                                                                   |\n| `update_comment`    | Your own comments only; replaces the whole body and is marked as edited                                                       |\n| `delete_comment`    | Your own comments only; to the trash, and nothing here restores one                                                           |\n| `mark_comments_read`| Clears this ticket's unread badge for the user the key acts as                                                                |\n| `add_subtask`       | One checklist item, appended — plain text, not markdown                                                                       |\n| `update_subtask`    | Tick it off (`is_done`), rename it, or move it up the list                                                                    |\n| `delete_subtask`    | **Not** recoverable — a checklist item has no trash                                                                            |\n| `archive_ticket`    | To the trash — **recoverable** for 30 days                                                                                     |\n| `delete_ticket`     | Destroys one already in the trash — **permanent**                                                                              |\n| `create_board`      | Optionally from a template — `crm` or `sales-leads`                                                                            |\n| `link_tickets`      | \"this before that\" — direction is `blocks` or `blocked_by`                                                                     |\n| `unlink_tickets`    | From either end, and removes **every** kind of link on the pair                                                                |\n\nComments are not versioned, so none of the three comment writes takes a\n`version` and none of them can 409 — the last edit wins. Only the author may\nedit or delete one, and somebody else's is a 404 indistinguishable from a\ncomment that does not exist, so these never report who wrote what.\n`get_ticket_comments` deliberately marks nothing read; `mark_comments_read` is\nthe only call that does, and it marks up to the newest comment that exists at\nthat moment rather than subscribing.\n\nSubtasks are the checklist on a ticket — a progress count on the card, and where\nacceptance criteria belong when they are meant to be ticked off one at a time.\n`get_ticket` returns the items themselves, so there is no separate list tool.\n\n**Board structure**\n\n| Tool                  | Notes                                                                       |\n| --------------------- | --------------------------------------------------------------------------- |\n| `create_status`       | A new column, appended to the right-hand end                                |\n| `update_status`       | Rename, recolour, or set `is_complete` — a board has at most one            |\n| `reorder_statuses`    | The **complete** list of uuids, in order; a stale list is a 409             |\n| `delete_status`       | The column must be empty, and a board keeps one                            |\n| `create_group`        | A swimlane; there is no rename route, so a wrong name is deleted and remade |\n| `reorder_groups`      | Same complete-list contract as `reorder_statuses`                          |\n| `delete_group`        | Tickets in it survive, ungrouped — and their versions all move             |\n| `create_custom_field` | Workspace owner or admin; `type` cannot be changed afterwards               |\n| `update_custom_field` | Rename, reorder, or replace a select's whole `options` list                |\n| `delete_custom_field` | Takes every ticket's value in that field with it — no trash, no restore     |\n\nThis is what a board an agent creates needs to stop being its template's\ndefaults. The two reorder tools want the whole list because a partial one is how\ntwo simultaneous reorders silently drop a column: read the uuids off `get_board`\nimmediately before calling, and re-read on a 409.\n\n**Labels**\n\n| Tool           | Notes                                                                    |\n| -------------- | ------------------------------------------------------------------------ |\n| `list_labels`  | Workspace-wide, alphabetical — `get_board` shows only what a board uses  |\n| `create_label` | A hex colour is required; names are unique ignoring case and space       |\n| `update_label` | Renames it **everywhere** — one label, not a copy per board              |\n| `delete_label` | Removes it from every ticket that carries it                             |\n\nA label is a workspace object shared by every board, which is the thing to be\nsure of before renaming one: it changes for everybody. The uuids these return\nare what `update_ticket` takes as `label_uuids`.\n\n**Attachments**\n\n| Tool                       | Notes                                                                        |\n| -------------------------- | ---------------------------------------------------------------------------- |\n| `list_ticket_attachments`  | Name, type, size and uuid — never the bytes                                  |\n| `get_ticket_attachment`    | Text inline, an image as an image block, anything else via `save_to`         |\n| `upload_ticket_attachment` | `file_path` for a file on disk, or `text` + `filename` for something written |\n| `delete_ticket_attachment` | To the trash for 30 days; there is no restore tool                           |\n\n`get_ticket` reports `attachment_total`, so you know whether listing is worth a\nround trip.\n\nBinary content crosses the tool boundary by **not** crossing it. A tool result\nis text, Laver allows 25 MB per file, and 25 MB of base64 is roughly nine\nmillion tokens — so only text, CSV and images under 4 MB come back inline, and\neverything else needs `save_to`, which writes the file to a path on the machine\nrunning this server (normally the agent's own, since the client starts it as a\nsubprocess). Uploads go the same way round: `file_path` reads from that machine\nand costs no context.\n\nAn image comes back as an MCP image block rather than as text, which is the only\nform a model can actually look at — that is the whole point of the tool, since\nthe screenshot somebody attached is usually the specification.\n\n**Wiki**\n\n`list_wikis`, `search_wiki`, `get_wiki_tree`, `get_wiki_page`,\n`get_wiki_page_version`, `append_wiki_page`, `update_wiki_page`,\n`create_wiki_page` — which takes the body as markdown and nests under\n`parent_page_uuid` — and `restore_wiki`.\n\nArchiving a wiki (done in the browser; there is no tool here for it) takes it\nand every page under it out of `list_wikis` entirely. `list_wikis` takes\n`archived: true` to see those instead — the only place an archived\n`wiki_uuid` is visible at all — and `restore_wiki` is the only thing here\nthat does something with one: it un-archives the wiki, and every page under\nit, in one call.\n\n`get_wiki_page_version` is how you read something that was overwritten. It is a\nread: the page does not move. There is deliberately no tool to put an old\nversion back — read it and write the wording you want with `update_wiki_page`,\nwhich leaves a version of its own behind.\n\n`append_wiki_page` adds to the end of a page and `update_wiki_page` replaces\nwhat is on one; prefer the first when you are adding, because it takes no\n`version` and two agents appending at once both get their text. Replacing takes\nthe `version` from `get_wiki_page`, and a page somebody wrote to meanwhile is a\n409 carrying the current version rather than an overwrite — the guard that lets\nthis exist at all, since wiki pages sit behind a live collaborative editor. A\nreplace re-seeds that editor from what it just wrote, so a colleague with the\npage open sees the new text instead of putting the old text back, and every\nprevious save stays readable through `get_wiki_page_version`. There is still no\ntool to delete a page.\n\nThe markdown goes through the same parser ticket descriptions do. Headings,\nlists, tables, code blocks, blockquotes, rules and links survive; so does an\n`![alt](https://…)` image, as a reference to that URL — there is no tool here to\nupload an attachment, so the URL has to be public already. Raw HTML is kept as\nliteral text rather than interpreted.\n\n**Automations**\n\n| Tool                   | Notes                                                            |\n| ---------------------- | ---------------------------------------------------------------- |\n| `list_automations`     | The rules on a board, each with its `version`                    |\n| `get_automation`       | One rule in full — the conditions and actions an edit replaces   |\n| `list_automation_runs` | What a rule has actually done, newest first                      |\n| `create_automation`    | Owner or admin only — and see below before calling it            |\n| `update_automation`    | Needs `version`; `enabled: false` is the reversible stop         |\n| `delete_automation`    | Needs `version`, and takes the rule's whole run history with it  |\n\nAn automation rule is a trigger, optional conditions and up to twenty actions,\nstored against a board. Three things about them are worth knowing before an\nagent touches these tools:\n\n- **A rule created here is live immediately.** It fires on its trigger within a\n  couple of seconds, so read `list_automation_runs` afterwards rather than\n  creating one speculatively to see what it would do.\n- **A rule is a standing grant.** It runs as the user this key acts as, every\n  time it is triggered, for as long as it exists — not once, like every other\n  write in this server. Revoking the key does not stop it; disabling or deleting\n  the rule does.\n- **Pause before you delete.** `update_automation` with `enabled: false` stops a\n  misbehaving rule at once and keeps its history; deleting destroys the history\n  along with the rule. `conditions` and `actions` replace the stored lists rather\n  than merging into them, so build them from `get_automation` and not from\n  memory.\n\nBoth writes take the rule's `version` and answer 409 with the current one, the\nsame contract ticket writes have.\n\n**Published links**\n\n`list_published_links` is the inventory read: what of this workspace's is on the\npublic internet right now, each row with its share token, who published it, when,\nand how many strangers have looked. `live` is what a stranger actually gets and\n`dark_reason` says why a row is not, which is the only way to find a published\npage that is currently archived and would go straight back online if somebody\nrestored it. Workspace owner or admin only; an ordinary member is a 403. Taking\na link back down is deliberately not a tool — report what is published and let a\nperson decide what comes off.\n\n## Not covered\n\nThe REST API is larger than this server, and the difference is deliberate rather\nthan accidental — `mcp/route-coverage.js` lists every backend route with either\nthe tool that calls it or the reason it has none, and `npm run check` fails if a\nroute appears that is in neither. That is what keeps this section true: it went\nstale before, silently, which is how the server spent its whole life unable to\nread a ticket's attachments while every check stayed green.\n\n**Not yet** — wanted, not built:\n\n- **Notifications.** Still the biggest gap, and now blocked on something a tool\n  cannot fix: an agent cannot see that it was mentioned or assigned, and\n  `backend/notifications/index.js` installs `session_only` on the whole plugin,\n  so every route there answers a key with a 401 however good the tool is.\n  Adding one means first deciding whether a key may read its owner's inbox at\n  all — a surface carrying other people's messages — which is a product and\n  security call rather than a delivery task. `list_workspace_tickets` covers the\n  triage half and is the closest substitute meanwhile.\n- **Listing subtasks on their own.** Needs no tool rather than lacking one:\n  `get_ticket` already returns the checklist items themselves — uuid, title and\n  done state — so a dedicated list route would be a second way to ask the same\n  question. Writing them is covered.\n- **Board templates.** A workspace can save one of its own boards as a template\n  and start the next board from it. Listing them has no tool, which is what\n  keeps the saved ones out of reach: `create_board` names the two built-in ids\n  in its schema, and a saved template's id is a uuid nothing here can discover.\n  Saving and deleting are deliberately absent rather than pending — a template\n  is workspace-wide furniture in everyone's board-create form, and adding to or\n  removing from that list is a decision taken in front of the board.\n- **Attachment thumbnails.** Deliberate, not a gap. The thumbnail route serves\n  the web client a downscaled webp so a card cover costs kilobytes instead of\n  megabytes; an agent wants the file somebody actually uploaded, and\n  `get_ticket_attachment` already returns that at full resolution in its\n  original format.\n- **Where you left off.** The five things the ⌘K palette offers a person before\n  they type anything — the tickets and pages they last opened or worked on.\n  Work done through a key is kept out of it on purpose, so a tool here would\n  ask which tickets its owner had been reading; `list_workspace_tickets` is the\n  better answer to what to pick up, and `search` to anything more specific.\n- **Board analytics.** A small loss now that the triage half is covered by\n  `list_workspace_tickets`. What is left is shaped for charts rather than for a\n  decision: stats and board-wide flow are aggregates a person reads on a screen,\n  activity is a feed, search-text serves find-as-you-type, and export hands back\n  a file. `get_ticket_flow` covers the one figure an agent acts on, per ticket,\n  where it can be attributed.\n- **The wiki page a board is about.** Deliberate, because every part of the\n  answer is already reachable and the route is only the join. `get_board`\n  returns the board's `wiki_page_uuid`, and `get_wiki_page` and `get_wiki_tree`\n  read that page and everything under it with the same access rules and more of\n  the content. The route exists so a person looking at a board can reach the\n  handbook without remembering its name; an agent holding the uuid needs no\n  such shortcut.\n- **A conductor's scoreboard.** Never, and not for want of the tool being easy.\n  The route reads back how the tickets one person wrote fared once an agent\n  picked them up, for that person alone; an agent reading the scores of the\n  people briefing it is the wrong way round, and one that could read them could\n  play to them.\n- **Pressing an automation button.** Deliberate rather than pending: a `manual`\n  rule exists so that a person decides when it runs, and a tool that pressed it\n  would hand that back. Creating one is the safe half and is covered.\n- **Ticket history, duplication and recurrence.**\n- **Sprints.** Blocked in the same place notifications are:\n  `backend/sprints/index.js` installs `session_only` on the plugin, so a key gets\n  a 401 from every route there — `POST /boards/:uuid/sprints` included. Opening\n  them to keys is a decision about what a key may do to a team's planning cadence\n  (a rollover migrates every unfinished ticket onto a new board), not a matter of\n  writing the tools.\n- **Deleting and rearranging a wiki page.** Editing one is covered now, by\n  `update_wiki_page`; archiving, moving and duplicating are not, because they\n  change what a colleague can find rather than what a page says, and a page\n  nobody can find has no version history to consult. Removing a whole wiki (`DELETE /wikis/:wiki_uuid`, which\n  archives it and every page under it) is in the same group: it takes a wiki's\n  published pages off the internet in the same instant it archives them, which\n  is consent, not editing. Its restore route is covered instead, by\n  `restore_wiki`, now that `list_wikis` can find an archived uuid to give it.\n- **Commenting on a wiki page.** Uncovered because of the anchor rather than the\n  shape of the call. A comment there is attached to the words it is about — the\n  quote, and which occurrence of it — because a page is edited collaboratively\n  and a stored document position comes to mean different text the moment a\n  colleague types above it; the client re-finds that quote in the document it\n  has just rendered. An agent holds no document, so it would be sending a quote\n  it believes is on the page, and the failure it hits most is silent: a phrase\n  that appears twice, counted differently at the two ends, anchors the remark to\n  the wrong sentence. A tool worth having would take the page and the quote and\n  say plainly when the quote was not found, which is the right next job if\n  agents are ever asked to review pages. Until then a key already has the honest\n  way to say something about a page: `append_wiki_page` and `update_wiki_page`\n  put it IN the page, where the people reading it will see it, rather than in a\n  margin no other tool can read back. Editing, withdrawing and resolving follow\n  from that — there is nothing for a key to edit while it cannot comment — and\n  resolving in particular takes the highlight off somebody else's prose and\n  declares a conversation between people finished. Reading the thread is\n  uncovered on its own merits: those comments are a discussion about a draft,\n  and an agent asking what a page says wants `get_wiki_page`.\n- **Imports and feedback forms.**\n\n**Not ever, from a key:**\n\n- **Reporting a deployment.** `POST /tasks/:task_uuid/deployments` is how a\n  build pipeline tells Laver that a ticket's pull request reached an environment\n  or failed to, so the people assigned to it are told. The caller is a CI job\n  holding a key in a secret, at a moment when no model is running; a tool over\n  it would let an assistant announce a deployment no pipeline performed, and the\n  whole worth of the notification is that it reports a fact rather than a claim.\n  The reading half an agent might want it already has: the deployment is a\n  ticket event, so `get_ticket_flow` and the timeline carry what happened, when,\n  and which key said so.\n- **Reporting a merged pull request.** `POST\n  /tasks/:task_uuid/pull-requests/merged` is the same bargain: a CI job says the\n  ticket's pull request was merged, and the board moves the ticket into the\n  column tagged for merges. A tool would be a second way to move a card — an\n  agent has `move_ticket` — differing only in that it also writes \"this ticket's\n  pull request was merged\" onto the timeline, which an assistant that merged\n  nothing has no business claiming. Reading is covered: the merge is a ticket\n  event, so `get_ticket_flow` carries it.\n- **Public links and publishing.** Publishing turns something private into\n  something anyone with the URL can read. That is consent, and a tool call is\n  the wrong shape for it.\n- **Taking a published link back down.** The read shipped and the two DELETEs\n  did not, which is the same split: a tool cannot carry consent, and that says\n  nothing about ASKING what is public. `list_published_links` answers the\n  question and grants no power the caller did not already have, since every link\n  it names is public by definition. Retracting one is a bounded admin power\n  exercised in front of a screen showing what is about to go dark, so an agent\n  reports the inventory and a person decides what comes down.\n- **Asking a person for their signature.** Refused at the route rather than\n  merely unbuilt: all three answer a key with a 401 before they read anything.\n  Sending a request puts a message into a stranger's inbox carrying our SPF and\n  DKIM, and the judgement that an address deserves to be asked for a signature\n  is a person's — a key acts as whoever minted it and would inherit their edit\n  access, so permissions would not stand in the way. Listing the outstanding\n  requests is the dialog's own read, returning them beside the sections computed\n  from the live document they may attach to, and `get_wiki_page` already carries\n  the text. Withdrawing one is the same judgement from the other side.\n- **Taking a board away, and how it looks.** The structure half of this group is\n  covered — statuses, groups and custom fields all have tools. What is left is\n  removal and decoration. Archiving and deleting a board are one write under two\n  names, and a tool over either would let a key take a whole board and every\n  ticket on it out of the workspace's view in one call; restore is uncovered as\n  a consequence, since nothing an agent can do archives a board and `list_boards`\n  deliberately cannot find an archived uuid. Permanent deletion is firmer still\n  — the row, its tickets and their attachments' bytes all go — and is a person's\n  decision made twice, from a screen showing what they are about to lose. The\n  board's colour and background picture are decoration chosen while looking at\n  the board, which is the one thing an agent cannot do. A column's entry\n  requirements sit here too: the read needs no tool, because `get_board` already\n  sends each column's `entry_requirements` and a refused `move_ticket` names\n  every one the ticket does not meet — but a key that could set them could\n  remove them and then move the ticket, so the gate would only be as strong as\n  the weakest tool over it.\n- **Moving a board between workspaces.** Not a judgement about blast radius —\n  a key cannot make this call at all. A key is confined to the one workspace it\n  was issued for, and `POST /boards/:board_uuid/move-workspace` names two: the\n  workspace the board is in, and the one it is going to. A tool over it would\n  need a key that reached both, which is the confinement gone, or it would\n  refuse every call it was ever given. The gesture is not an agent's either: an\n  admin of two workspaces decides which of them a whole board — its tickets,\n  comments, attachments and history — belongs to, and the route requires owner\n  or admin in both ends before it moves anything.\n- **Workspace and membership administration.** A key acts as the person who\n  created it; renaming or deleting their workspace, or answering an invitation\n  for them, reaches further than delegating a board task ever meant.\n- **The whole-workspace export.** `POST /workspaces/:uuid/exports` and the\n  routes beside it build one file holding every ticket, comment and wiki page in\n  the workspace, and they refuse a key before looking at anything else. A key in\n  a CI variable that could ask for one turns any leak of it into a full data\n  breach rather than the scoped access it was issued for. Taking a copy of the\n  company's data is a thing a person does, signed in, from Admin, and the audit\n  trail records which person.\n- **Bulk ticket writes.** `POST /tasks/archive` bins a list in one call.\n  `archive_ticket`, one at a time, is the deliberate choice.\n- **Trash.** One-way on purpose: an agent can archive and can destroy what it\n  already archived, and a person puts things back.\n- **The board event stream.** Server-sent events; a tool call is one request and\n  one answer. `list_tickets` with `updated_since` is the replacement.\n- **Scheduler endpoints.** The deployment's own cron hooks.\n\n## Removing a ticket\n\nTwo steps, deliberately, so that nothing is destroyed by a single call:\n\n```\narchive_ticket  task_uuid                    → the workspace trash, recoverable for 30 days\ndelete_ticket   workspace_uuid + task_uuid   → gone, and nothing brings it back\n```\n\n`delete_ticket` refuses anything that is not already archived, so the order is\nenforced by the server rather than by convention. There is no restore tool here\n— a ticket in the trash is put back from the web app — so treat\n`archive_ticket` as the furthest you can go on your own.\n\nRead the ticket **before** you archive it if you intend to destroy it:\n`delete_ticket` needs the `workspace_uuid`, `get_ticket` is where you get one,\nand an archived ticket can no longer be read.\n\n## Working out what to do next\n\nEvery ticket read carries `blocked_by`, `blocks` and `is_blocked`. `is_blocked`\nis false once every blocker has reached a completion column, so the tickets a\nboard is ready for are the ones where it is false. `link_tickets` records the\ndependency; a link that would make a loop is refused with a 409, because a loop\nmakes the ordering unanswerable.\n\n## The one rule worth knowing\n\nTickets carry a `version`. Every write must send the version you read, and a\nwrite against a stale one is refused with **409** rather than silently\noverwriting whoever got there first. The server turns that into an instruction,\nand Laver's refusal carries the current version, so the instruction can include\nit rather than spending a second call on it:\n\n> Laver 409: Task was updated by another request.\n>\n> Somebody wrote first, so the version you sent is stale. The current version is 12. If your change does not depend on what you read — moving a ticket to a\n> named column, say — retry with that version. If it does, call get_ticket again\n> and decide against the ticket as it now is, or you will quietly undo the other\n> write.\n\nRead, then write. Do not cache a version across a long turn.\n\n## When the key is refused\n\nA **401** is the key: missing, mistyped, revoked, expired, or a placeholder that\nwas never filled in. The underlying message is not always a fair description of\nwhat happened — a key the JWT layer cannot parse comes back as _\"Authorization\ntoken is invalid: The token is malformed\"_, which sounds like a corrupted string\nwhen the usual cause is simply a key that was replaced. The server appends what\nto do about it, including the part that catches people out:\n\n> An MCP client reads that environment once, when it starts this server, so it\n> must be restarted afterwards — editing the config in a running session changes\n> nothing.\n\nA **403** is different and is never worth retrying: the key was accepted, and\nthen refused this particular action. It is scoped to another workspace, or the\nperson it acts as has a read-only role, or is a guest without access to that\nboard.\n\nThe key itself is read in exactly one place, sent as a bearer token, and never\nlogged, echoed, or included in any error text.\n\n## Checking it\n\n```bash\nnode check.js                  # schema, then every read-only tool actually called\nnode check.js --require-live   # …and a skipped sweep is a failure, for CI\n\n# the same calls against the API the published package actually talks to\nLAVER_API_KEY_FILE=../.env node check.js --live-api\n```\n\nTwo halves. The first is static: every tool registered, classified read or\nwrite, described, and given a schema. The second boots the backend from\n`../backend` on a spare port, creates a workspace of its own, mints a key\nagainst it, and **calls every read-only tool** — through the tool's own zod\nschema and then its handler — sending every parameter the tool declares.\n\nThat half exists because the first one passed while `list_wikis` sent\n`?workspace=` at a route that requires `workspace_uuid`. It 400'd on every call\nit ever made, and since it is the only tool that yields a `wiki_uuid`, the whole\nwiki half of this server was unreachable from the day it shipped — with\nregistration, descriptions and schema shape perfect throughout.\n\nIt needs the same things `npm test` in `backend/` needs: that directory, its\n`node_modules`, its `.env`, and the Postgres they point at. No API key and no\nnetwork beyond localhost — a real `LAVER_API_KEY` in the environment is ignored.\nWithout a backend it prints a banner saying the tools were **not** called and\nruns the schema half alone; `--require-live` turns that into a failure.\n\nIt is only as local as `backend/.env` is, though. Running it **writes to\nwhatever database that file points at**: it creates a workspace, a board, two\ntickets, a comment, a wiki, a page and an API key, and deletes them again at the\nend. It also loads the backend into its own process. It listens with\n`app.server.listen` rather than `app.listen` — the same idiom the collab and\nboard-events integration tests use — so Fastify's `onListen` hooks do not fire\nand none of the seven schedulers start; without that, the billing sweep alone\nwould run against every workspace in that database. Point `backend/.env` at\nstaging and this is a check that writes to staging.\n\nThe _sweep_ is read-only and stays that way: a check that creates tickets in\nsomebody's workspace every time it runs is a check people stop running. The\nwrite tools are covered by the schema half only — see the note at the foot of\n`check.js` for the way to cover them without sending a write.\n\nCtrl-C is safe. The sweep stops after the call in flight and the fixture\nworkspace is deleted before the process exits; a second Ctrl-C kills it outright\nif the call in flight is the thing that is stuck.\n\n### Against the deployed API\n\n`--live-api` points the same calls at `LAVER_API_URL` — `https://api.laver.app`\nunless you say otherwise — with a real key, taken from `LAVER_API_KEY` or from\nthe file `LAVER_API_KEY_FILE` names, exactly as the server itself takes it.\n\nIt exists because a green local run and a working published package are two\ndifferent claims. The local sweep proves the tools agree with the code in front\nof you; this package talks to the deployed API, so a route that ships a rename\nbefore the package does breaks every agent in the field while the local sweep\nstays green. That is a narrow window — the tools and the routes live in one repo\nand move together — but it is exactly the window publishing to npm opens.\n\nBoth modes run the same table, in `cases.js`, and every case carries an\nexpectation for each: exact counts locally, where the fixture is known, and\nshapes and invariants live, where the workspace is somebody's real one and\ncannot be seeded or torn down. A case with only one of the two is a failure, so\na new call cannot cover one transport and skip the other.\n\nIt **creates nothing and deletes nothing**, and that is enforced rather than\npromised: live mode replaces `fetch` with one that refuses any method but GET,\nso a write tool called by mistake cannot reach the network at all.\n`frontend/tests/check-mcp-live-api-mode.mjs` runs the whole mode against a stub\nAPI and asserts that every request that left the process was a GET.\n\nInstead of a fixture it goes looking for something to point at, and wants a\nboard with at least two tickets in at least two columns, in a workspace with a\nwiki that has a page. It refuses to run against anything thinner rather than\npassing quietly: a filter case against an empty board passes whether or not the\nfilter was applied, which is the failure this whole file exists to prevent.\n\nOpt-in, and never part of `npm run check:all` or CI — it needs a key and a\nnetwork, and neither belongs in a check that runs on a box with no secrets.\n\n## Publishing\n\n**0.1.0 is published and is broken. Do not tell anyone to install it.** It\nstarts, registers all 20 tools, connects no transport, and exits 0 without\nwriting anything to stdout or stderr — so a client sees the process end and\nnothing else. The entry-point guard compared the _basename_ of `process.argv[1]`\nagainst this file's name, which is true only for `node mcp/server.js`; npm's\nshim for `bin` makes argv[1] `node_modules/.bin/laver-mcp`, so `npx -y\n@laver/mcp` — the way this README tells everyone to run it — never matched.\nFixed in 0.1.1, and `tests/check-mcp-bin-entrypoint.mjs` now spawns the server\nthrough a symlink and speaks MCP to it, so the same class of bug cannot ship\nagain.\n\nWhen 0.1.1 goes out, mark the broken one so nobody lands on it:\n\n```bash\nnpm deprecate @laver/mcp@0.1.0 \"Never connects its stdio transport when run via npx or the bin shim. Use 0.1.1 or later.\"\n```\n\nUnpublishing 0.1.0 is the other option and is worse: within 72 hours it removes\nthe version, but the number stays burned either way, and anything that already\npinned it breaks rather than being warned.\n\nThe publish itself is the owner's to run, because it is public, permanent enough\nto matter, and takes a name nobody else can then have.\n\n**The name.** `laver` on npm is taken — v1.0.0, published in 2021 by an\nunrelated maintainer — so the bare name is not available and never will be.\nThis package is therefore **`@laver/mcp`**: the brand name kept as the scope,\nwith the generic part where it belongs. Checked against the registry on\n6 Aug 2026 — `@laver/mcp` is free, and nothing has ever been published under the\nold unscoped `laver-mcp`, so the rename costs nothing.\n\n**The scope exists and the first publish has happened.** `0.1.0` and `0.1.1` are\non the registry under `@laver/mcp`, created 2026-08-06T22:14Z — which is the\nonly proof that matters that the scope resolves and the publishing account may\nwrite to it. This paragraph used to say the scope did not exist yet; that was\ntrue when it was written and is not now.\n\nDo not re-test it with `https://registry.npmjs.org/-/org/laver`. That URL is a\n404 unauthenticated whether the org exists or not, so it cannot tell the two\napart — read the package document instead:\n\n```bash\ncurl -s 'https://registry.npmjs.org/@laver%2Fmcp' | python3 -m json.tool\n```\n\nFor a self-hosted fork publishing under its own scope, the first publish still\nneeds that scope created at <https://www.npmjs.com/org/create> (free for public\npackages) or confirmed as the account's own username, with `npm whoami` to\ncheck membership. `npm publish` fails with\n`404 Not Found - PUT https://registry.npmjs.org/@<scope>%2fmcp` if the scope\ndoes not exist, which reads like a network fault rather than a missing org.\n\n**The executable stays `laver-mcp`.** The package is `@laver/mcp`, but `bin` is\ndeliberately not renamed to `mcp`: a global install would put a command called\n`mcp` on the PATH, which is far too generic and collides with every other MCP\nserver anyone installs. `npx -y @laver/mcp` works regardless — npx runs the\npackage's only bin whatever it is called — so nothing in the config snippet\nabove depends on the command's name.\n\n**`repository` and `bugs` point at the public mirror.** They were absent while\n`github.com/Developyn/laver` was the only home — it is private, npm renders both\nfields as links on the package page, and aiming the only two \"where does this\ncome from\" links at a 404 is worse than having neither. Since August 2026 the\npublished files are mirrored to `github.com/Developyn/laver-mcp`, which is\npublic, so both now resolve. Note there is no `\"directory\"` key: the mirror's\nroot *is* the package, whereas here the same files live under `mcp/`.\n\nThe mirror is what every MCP directory anchors a listing to, so it has to keep\nup. After each publish, copy the published tarball's contents over it —\n`npm pack @laver/mcp && tar xzf laver-mcp-<version>.tgz` — and commit. Its\n`Dockerfile`, `glama.json` and CI workflow are mirror-only and are not in\n`files`, so they never ship to npm.\n\n**`mcpName` is for the official MCP registry**, which matches the package\nagainst the server name being published there. It must equal the namespace\n`mcp-publisher login github` actually grants you — `io.github.developyn/…` if it\nauthorises the org, `io.github.melvyn-developyn/…` if only the personal account.\nGetting it wrong is not fatal, but correcting it costs another version.\n\n**Before each one**\n\n- 2FA on the publishing account, if it is set to require it for publishing (npm\n  enforces this for some accounts and packages and prompts for others). Passing\n  `--otp` saves a prompt from failing a non-interactive run; drop it if not\n  enrolled.\n- `npm whoami` answering with that account — `npm login` if not.\n- For CI instead of a laptop: an **automation** token in `NPM_TOKEN` (granular,\n  write-scoped to this package). Automation tokens bypass the 2FA prompt, which\n  classic read-write tokens do not.\n- **A version the registry does not already have.** npm refuses to republish an\n  existing one, so this is not tidying — it is what makes a publish possible at\n  all. Check what is live first, because the repo's number and the registry's\n  can be equal while the contents differ, and nothing warns you:\n  `npm view @laver/mcp version`. Minor for new tools, patch for fixes to\n  existing ones.\n\n**The publish**\n\n```bash\ncd mcp\nnpm ci                    # the lockfile, not whatever resolves today\nnpm run check             # schema + every read-only tool actually called\nnpm pack --dry-run        # confirm the file list is LICENSE, README.md, package.json, server.js\nnpm publish --access public --otp=<code-from-your-authenticator>\n```\n\n> `npm ci` deletes and reinstalls `mcp/node_modules`. In the shared development\n> checkout that directory is shared with every agent running against it, and\n> removing it mid-run breaks their tests — which is why the agent instructions\n> forbid it and why an agent preparing a release stops before this block. It is\n> correct and expected for whoever actually publishes; just do not run it while\n> others are working in the same tree.\n\n`--access public` is **required** here: scoped packages default to restricted,\nand a restricted publish on a free account is refused outright. `publishConfig`\nin package.json already sets it, so the flag is belt and braces rather than the\nonly thing standing between this and a private package.\n\n**Afterwards**\n\n```bash\nnpx -y @laver/mcp         # should start and wait on stdio, not exit\nnpm view @laver/mcp\n```\n\nA mistake is recoverable only briefly: `npm unpublish @laver/mcp@<version>`\nworks within 72 hours, and the version number is burned afterwards regardless.\nThe package _name_ is not returned to the pool by unpublishing a version.\n\n## Licence\n\nMIT — see `LICENSE`, which ships in the package.\n",
  "bytes": 45367,
  "sha": "7e537f9763058728ceab08bacd591cc3b14dd65a6d9c42d774a8ebc1a7e48b50",
  "repo_slug": "developyn/laver-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_developyn_laver_mcp_7371a882/readme"
}