Back to Browse

Laver MCP Server

Developer ToolsLow Risk10.0MCP RegistryLocal
Free

Server data from the Official MCP Registry

Kanban boards, sprints, tickets and a team wiki, with versioned writes for concurrent agents.

About

Kanban boards, sprints, tickets and a team wiki, with versioned writes for concurrent agents.

Security Report

10.0
Low Risk10.0Low Risk

Valid MCP server (2 strong, 2 medium validity signals). No known CVEs in dependencies. Package registry verified. Imported from the Official MCP Registry.

3 files analyzed · 1 issue found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

file_system

Check that this permission is expected for this type of plugin.

env_vars

Check that this permission is expected for this type of plugin.

What You'll Need

Set these up before or after installing:

A workspace-scoped API key, created in Laver under Admin → API keys. It acts as the person who created it, so it holds exactly their access and can be revoked without touching their account. Supply this or LAVER_API_KEY_FILE.Required

Environment variable: LAVER_API_KEY

Alternative to LAVER_API_KEY: a path to a file holding nothing but the key, or a .env-style file carrying a LAVER_API_KEY= line. Use this to keep the key out of your client's config file.Optional

Environment variable: LAVER_API_KEY_FILE

Overrides the API host. Defaults to https://api.laver.app, and must be https except for localhost.Optional

Environment variable: LAVER_API_URL

How to Install

Add this to your MCP configuration file:

{
  "mcpServers": {
    "io-github-developyn-laver-mcp": {
      "env": {
        "LAVER_API_KEY": "your-laver-api-key-here",
        "LAVER_API_URL": "your-laver-api-url-here",
        "LAVER_API_KEY_FILE": "your-laver-api-key-file-here"
      },
      "args": [
        "-y",
        "@laver/mcp"
      ],
      "command": "npx"
    }
  }
}

Documentation

View on GitHub

From the project's GitHub README.

@laver/mcp

An MCP server for Laver. Gives an agent tools to read and drive kanban boards, tickets and the workspace wiki.

Every tool is a thin call to the same public REST API the web app uses. There is no local state, no cache, and no second implementation of anything — if Laver refuses a write, the refusal comes back verbatim, because an agent can act on "409, re-read and retry" and cannot act on "something went wrong".

Setup

Create a workspace-scoped API key in Laver under Admin → API keys. It acts as the person who created it, so it can do exactly what they can do and nothing more, and it can be revoked without touching their account.

{
  "mcpServers": {
    "laver": {
      "command": "npx",
      "args": ["-y", "@laver/mcp"],
      "env": { "LAVER_API_KEY": "your key here" }
    }
  }
}

LAVER_API_URL overrides the API host; it defaults to https://api.laver.app. LAVER_API_KEY_FILE is an alternative to LAVER_API_KEY: a path to either a file containing nothing but the key, or a .env-style file with a LAVER_API_KEY=… line among others (an assignment line wins; quotes and an export prefix are both fine). That is how the .mcp.json in this repo registers the server without a secret in a tracked file.

A file with neither — no assignment line, and more than one token in it — yields no key at all, and you get the "key is not set" error. It used to send the whole file as the token, which is fine for a file holding one secret and is a leak for anything else.

LAVER_API_URL must be https, except for localhost.

Working in this repo

.mcp.json at the repo root registers this server for anyone who opens the project, reading the key from the gitignored .env. Nothing to export.

It runs the published package, npx -y @laver/mcp, rather than the mcp/server.js beside it. That is deliberate: pointing it at the local file meant everyone here ran the one code path no user takes, and that is precisely how 0.1.0 shipped with an entry point that never connected its transport when started through bin — which is the only way a real client starts it. Running what we publish means we meet what users meet.

If you are editing this server, that same choice will fool you: your changes do nothing until they are published. Point the client at the working copy while you work on it —

{ "command": "node", "args": ["mcp/server.js"] }

— and put it back before you commit. npm run check and frontend/tests/check-mcp-bin-entrypoint.mjs both run against the working copy regardless, so the tests never depend on a publish.

A client only connects to MCP servers at startup. claude mcp add while a session is already running does not retrofit the tools into that session — the tool list was built before the server existed. Start a new session (or reconnect from the client's MCP panel) and the tools appear.

Tools

Reading

ToolWhat it gives you
list_workspacesWhere to start when you have no uuids
list_boardsThe boards in a workspace
get_boardA board with its status columns, labels, members and tickets
list_ticketsTickets on a board, filterable, paged — updated_since is how you follow a board
list_workspace_ticketsTriage across every board at once — overdue, unassigned, or free text
get_ticketOne ticket in full, its subtasks, including its version
get_ticket_commentsComments and activity history
get_ticket_flowHow long the ticket has spent in each column
list_custom_fieldsA board's custom field definitions — the uuids update_ticket writes against
list_labelsEvery label in the workspace, not only the ones already used on one board
searchBoards, tickets, wiki pages and comments across a whole workspace at once

list_workspace_tickets is the stand-up read: it needs no board_uuid, and it is the only thing here that answers "what is late" and "what does nobody own" without walking every board. It is deliberately the small sibling of list_tickets — one page, 50 by default and 200 at most, no cursor — so narrow it rather than paging it.

get_ticket_flow derives its numbers from the moves already in a ticket's history. Read visits rather than the by_status totals if you are adding several tickets up: tickets worked in one batch overlap, and their totals do not. gaps says when the history and the ticket's current column disagree, which makes the totals a floor rather than a measurement.

To follow a board, call list_tickets again with updated_since set to the server_time the previous call returned; you get back the tickets that changed and nothing else. There is no tool for the server-sent event stream at GET /boards/:uuid/events — a tool call is one request and one answer, and a stream that never ends is neither.

Asking for less. A tool result is charged against the model's context, and three of these calls are the ones you cannot route around: a status_uuid or a label_uuid comes from get_board, and a page_uuid comes from get_wiki_tree. On a busy board they were 154 kB, 119 kB and 180 kB. Each now takes a parameter that narrows the reply, and every one of them is opt-in — a call that passes none of them is unchanged.

CallGives youMeasured
get_board(include_tasks: false)the structure alone — statuses, labels, fields155 kB → 17 kB
get_wiki_tree(depth: 1)the top level, nothing nested under it119 kB → 1.8 kB
get_wiki_tree(parent_page_uuid: …)one page and everything beneath it119 kB → 9.8 kB
list_tickets(include_descriptions: false)titles and uuids without the bodies167 kB → 45 kB

Reach for get_board(include_tasks: false) whenever you called it for a uuid rather than for the tickets, and list_tickets when you want the tickets — that one takes status and limit as well.

Writing

ToolNotes
create_ticketstatus takes the column name, or pass status_uuid; markdown in description is parsed
update_ticketNeeds version; markdown in description is parsed; custom_fields is keyed by field uuid and replaces the whole object
move_ticketNeeds version, and a column — neither column is a 400
comment_on_ticketMarkdown in body is parsed; no version, so it cannot 409
update_commentYour own comments only; replaces the whole body and is marked as edited
delete_commentYour own comments only; to the trash, and nothing here restores one
mark_comments_readClears this ticket's unread badge for the user the key acts as
add_subtaskOne checklist item, appended — plain text, not markdown
update_subtaskTick it off (is_done), rename it, or move it up the list
delete_subtaskNot recoverable — a checklist item has no trash
archive_ticketTo the trash — recoverable for 30 days
delete_ticketDestroys one already in the trash — permanent
create_boardOptionally from a template — crm or sales-leads
link_tickets"this before that" — direction is blocks or blocked_by
unlink_ticketsFrom either end, and removes every kind of link on the pair

Comments are not versioned, so none of the three comment writes takes a version and none of them can 409 — the last edit wins. Only the author may edit or delete one, and somebody else's is a 404 indistinguishable from a comment that does not exist, so these never report who wrote what. get_ticket_comments deliberately marks nothing read; mark_comments_read is the only call that does, and it marks up to the newest comment that exists at that moment rather than subscribing.

Subtasks are the checklist on a ticket — a progress count on the card, and where acceptance criteria belong when they are meant to be ticked off one at a time. get_ticket returns the items themselves, so there is no separate list tool.

Board structure

ToolNotes
create_statusA new column, appended to the right-hand end
update_statusRename, recolour, or set is_complete — a board has at most one
reorder_statusesThe complete list of uuids, in order; a stale list is a 409
delete_statusThe column must be empty, and a board keeps one
create_groupA swimlane; there is no rename route, so a wrong name is deleted and remade
reorder_groupsSame complete-list contract as reorder_statuses
delete_groupTickets in it survive, ungrouped — and their versions all move
create_custom_fieldWorkspace owner or admin; type cannot be changed afterwards
update_custom_fieldRename, reorder, or replace a select's whole options list
delete_custom_fieldTakes every ticket's value in that field with it — no trash, no restore

This is what a board an agent creates needs to stop being its template's defaults. The two reorder tools want the whole list because a partial one is how two simultaneous reorders silently drop a column: read the uuids off get_board immediately before calling, and re-read on a 409.

Labels

ToolNotes
list_labelsWorkspace-wide, alphabetical — get_board shows only what a board uses
create_labelA hex colour is required; names are unique ignoring case and space
update_labelRenames it everywhere — one label, not a copy per board
delete_labelRemoves it from every ticket that carries it

A label is a workspace object shared by every board, which is the thing to be sure of before renaming one: it changes for everybody. The uuids these return are what update_ticket takes as label_uuids.

Attachments

ToolNotes
list_ticket_attachmentsName, type, size and uuid — never the bytes
get_ticket_attachmentText inline, an image as an image block, anything else via save_to
upload_ticket_attachmentfile_path for a file on disk, or text + filename for something written
delete_ticket_attachmentTo the trash for 30 days; there is no restore tool

get_ticket reports attachment_total, so you know whether listing is worth a round trip.

Binary content crosses the tool boundary by not crossing it. A tool result is text, Laver allows 25 MB per file, and 25 MB of base64 is roughly nine million tokens — so only text, CSV and images under 4 MB come back inline, and everything else needs save_to, which writes the file to a path on the machine running this server (normally the agent's own, since the client starts it as a subprocess). Uploads go the same way round: file_path reads from that machine and costs no context.

An image comes back as an MCP image block rather than as text, which is the only form a model can actually look at — that is the whole point of the tool, since the screenshot somebody attached is usually the specification.

Wiki

list_wikis, search_wiki, get_wiki_tree, get_wiki_page, get_wiki_page_version, append_wiki_page, update_wiki_page, create_wiki_page — which takes the body as markdown and nests under parent_page_uuid — and restore_wiki.

Archiving a wiki (done in the browser; there is no tool here for it) takes it and every page under it out of list_wikis entirely. list_wikis takes archived: true to see those instead — the only place an archived wiki_uuid is visible at all — and restore_wiki is the only thing here that does something with one: it un-archives the wiki, and every page under it, in one call.

get_wiki_page_version is how you read something that was overwritten. It is a read: the page does not move. There is deliberately no tool to put an old version back — read it and write the wording you want with update_wiki_page, which leaves a version of its own behind.

append_wiki_page adds to the end of a page and update_wiki_page replaces what is on one; prefer the first when you are adding, because it takes no version and two agents appending at once both get their text. Replacing takes the version from get_wiki_page, and a page somebody wrote to meanwhile is a 409 carrying the current version rather than an overwrite — the guard that lets this exist at all, since wiki pages sit behind a live collaborative editor. A replace re-seeds that editor from what it just wrote, so a colleague with the page open sees the new text instead of putting the old text back, and every previous save stays readable through get_wiki_page_version. There is still no tool to delete a page.

The markdown goes through the same parser ticket descriptions do. Headings, lists, tables, code blocks, blockquotes, rules and links survive; so does an ![alt](https://…) image, as a reference to that URL — there is no tool here to upload an attachment, so the URL has to be public already. Raw HTML is kept as literal text rather than interpreted.

Automations

ToolNotes
list_automationsThe rules on a board, each with its version
get_automationOne rule in full — the conditions and actions an edit replaces
list_automation_runsWhat a rule has actually done, newest first
create_automationOwner or admin only — and see below before calling it
update_automationNeeds version; enabled: false is the reversible stop
delete_automationNeeds version, and takes the rule's whole run history with it

An automation rule is a trigger, optional conditions and up to twenty actions, stored against a board. Three things about them are worth knowing before an agent touches these tools:

  • A rule created here is live immediately. It fires on its trigger within a couple of seconds, so read list_automation_runs afterwards rather than creating one speculatively to see what it would do.
  • A rule is a standing grant. It runs as the user this key acts as, every time it is triggered, for as long as it exists — not once, like every other write in this server. Revoking the key does not stop it; disabling or deleting the rule does.
  • Pause before you delete. update_automation with enabled: false stops a misbehaving rule at once and keeps its history; deleting destroys the history along with the rule. conditions and actions replace the stored lists rather than merging into them, so build them from get_automation and not from memory.

Both writes take the rule's version and answer 409 with the current one, the same contract ticket writes have.

Published links

list_published_links is the inventory read: what of this workspace's is on the public internet right now, each row with its share token, who published it, when, and how many strangers have looked. live is what a stranger actually gets and dark_reason says why a row is not, which is the only way to find a published page that is currently archived and would go straight back online if somebody restored it. Workspace owner or admin only; an ordinary member is a 403. Taking a link back down is deliberately not a tool — report what is published and let a person decide what comes off.

Not covered

The REST API is larger than this server, and the difference is deliberate rather than accidental — mcp/route-coverage.js lists every backend route with either the tool that calls it or the reason it has none, and npm run check fails if a route appears that is in neither. That is what keeps this section true: it went stale before, silently, which is how the server spent its whole life unable to read a ticket's attachments while every check stayed green.

Not yet — wanted, not built:

  • Notifications. Still the biggest gap, and now blocked on something a tool cannot fix: an agent cannot see that it was mentioned or assigned, and backend/notifications/index.js installs session_only on the whole plugin, so every route there answers a key with a 401 however good the tool is. Adding one means first deciding whether a key may read its owner's inbox at all — a surface carrying other people's messages — which is a product and security call rather than a delivery task. list_workspace_tickets covers the triage half and is the closest substitute meanwhile.
  • Listing subtasks on their own. Needs no tool rather than lacking one: get_ticket already returns the checklist items themselves — uuid, title and done state — so a dedicated list route would be a second way to ask the same question. Writing them is covered.
  • Board templates. A workspace can save one of its own boards as a template and start the next board from it. Listing them has no tool, which is what keeps the saved ones out of reach: create_board names the two built-in ids in its schema, and a saved template's id is a uuid nothing here can discover. Saving and deleting are deliberately absent rather than pending — a template is workspace-wide furniture in everyone's board-create form, and adding to or removing from that list is a decision taken in front of the board.
  • Attachment thumbnails. Deliberate, not a gap. The thumbnail route serves the web client a downscaled webp so a card cover costs kilobytes instead of megabytes; an agent wants the file somebody actually uploaded, and get_ticket_attachment already returns that at full resolution in its original format.
  • Where you left off. The five things the ⌘K palette offers a person before they type anything — the tickets and pages they last opened or worked on. Work done through a key is kept out of it on purpose, so a tool here would ask which tickets its owner had been reading; list_workspace_tickets is the better answer to what to pick up, and search to anything more specific.
  • Board analytics. A small loss now that the triage half is covered by list_workspace_tickets. What is left is shaped for charts rather than for a decision: stats and board-wide flow are aggregates a person reads on a screen, activity is a feed, search-text serves find-as-you-type, and export hands back a file. get_ticket_flow covers the one figure an agent acts on, per ticket, where it can be attributed.
  • The wiki page a board is about. Deliberate, because every part of the answer is already reachable and the route is only the join. get_board returns the board's wiki_page_uuid, and get_wiki_page and get_wiki_tree read that page and everything under it with the same access rules and more of the content. The route exists so a person looking at a board can reach the handbook without remembering its name; an agent holding the uuid needs no such shortcut.
  • A conductor's scoreboard. Never, and not for want of the tool being easy. The route reads back how the tickets one person wrote fared once an agent picked them up, for that person alone; an agent reading the scores of the people briefing it is the wrong way round, and one that could read them could play to them.
  • Pressing an automation button. Deliberate rather than pending: a manual rule exists so that a person decides when it runs, and a tool that pressed it would hand that back. Creating one is the safe half and is covered.
  • Ticket history, duplication and recurrence.
  • Sprints. Blocked in the same place notifications are: backend/sprints/index.js installs session_only on the plugin, so a key gets a 401 from every route there — POST /boards/:uuid/sprints included. Opening them to keys is a decision about what a key may do to a team's planning cadence (a rollover migrates every unfinished ticket onto a new board), not a matter of writing the tools.
  • Deleting and rearranging a wiki page. Editing one is covered now, by update_wiki_page; archiving, moving and duplicating are not, because they change what a colleague can find rather than what a page says, and a page nobody can find has no version history to consult. Removing a whole wiki (DELETE /wikis/:wiki_uuid, which archives it and every page under it) is in the same group: it takes a wiki's published pages off the internet in the same instant it archives them, which is consent, not editing. Its restore route is covered instead, by restore_wiki, now that list_wikis can find an archived uuid to give it.
  • Commenting on a wiki page. Uncovered because of the anchor rather than the shape of the call. A comment there is attached to the words it is about — the quote, and which occurrence of it — because a page is edited collaboratively and a stored document position comes to mean different text the moment a colleague types above it; the client re-finds that quote in the document it has just rendered. An agent holds no document, so it would be sending a quote it believes is on the page, and the failure it hits most is silent: a phrase that appears twice, counted differently at the two ends, anchors the remark to the wrong sentence. A tool worth having would take the page and the quote and say plainly when the quote was not found, which is the right next job if agents are ever asked to review pages. Until then a key already has the honest way to say something about a page: append_wiki_page and update_wiki_page put it IN the page, where the people reading it will see it, rather than in a margin no other tool can read back. Editing, withdrawing and resolving follow from that — there is nothing for a key to edit while it cannot comment — and resolving in particular takes the highlight off somebody else's prose and declares a conversation between people finished. Reading the thread is uncovered on its own merits: those comments are a discussion about a draft, and an agent asking what a page says wants get_wiki_page.
  • Imports and feedback forms.

Not ever, from a key:

  • Reporting a deployment. POST /tasks/:task_uuid/deployments is how a build pipeline tells Laver that a ticket's pull request reached an environment or failed to, so the people assigned to it are told. The caller is a CI job holding a key in a secret, at a moment when no model is running; a tool over it would let an assistant announce a deployment no pipeline performed, and the whole worth of the notification is that it reports a fact rather than a claim. The reading half an agent might want it already has: the deployment is a ticket event, so get_ticket_flow and the timeline carry what happened, when, and which key said so.
  • Reporting a merged pull request. POST /tasks/:task_uuid/pull-requests/merged is the same bargain: a CI job says the ticket's pull request was merged, and the board moves the ticket into the column tagged for merges. A tool would be a second way to move a card — an agent has move_ticket — differing only in that it also writes "this ticket's pull request was merged" onto the timeline, which an assistant that merged nothing has no business claiming. Reading is covered: the merge is a ticket event, so get_ticket_flow carries it.
  • Public links and publishing. Publishing turns something private into something anyone with the URL can read. That is consent, and a tool call is the wrong shape for it.
  • Taking a published link back down. The read shipped and the two DELETEs did not, which is the same split: a tool cannot carry consent, and that says nothing about ASKING what is public. list_published_links answers the question and grants no power the caller did not already have, since every link it names is public by definition. Retracting one is a bounded admin power exercised in front of a screen showing what is about to go dark, so an agent reports the inventory and a person decides what comes down.
  • Asking a person for their signature. Refused at the route rather than merely unbuilt: all three answer a key with a 401 before they read anything. Sending a request puts a message into a stranger's inbox carrying our SPF and DKIM, and the judgement that an address deserves to be asked for a signature is a person's — a key acts as whoever minted it and would inherit their edit access, so permissions would not stand in the way. Listing the outstanding requests is the dialog's own read, returning them beside the sections computed from the live document they may attach to, and get_wiki_page already carries the text. Withdrawing one is the same judgement from the other side.
  • Taking a board away, and how it looks. The structure half of this group is covered — statuses, groups and custom fields all have tools. What is left is removal and decoration. Archiving and deleting a board are one write under two names, and a tool over either would let a key take a whole board and every ticket on it out of the workspace's view in one call; restore is uncovered as a consequence, since nothing an agent can do archives a board and list_boards deliberately cannot find an archived uuid. Permanent deletion is firmer still — the row, its tickets and their attachments' bytes all go — and is a person's decision made twice, from a screen showing what they are about to lose. The board's colour and background picture are decoration chosen while looking at the board, which is the one thing an agent cannot do. A column's entry requirements sit here too: the read needs no tool, because get_board already sends each column's entry_requirements and a refused move_ticket names every one the ticket does not meet — but a key that could set them could remove them and then move the ticket, so the gate would only be as strong as the weakest tool over it.
  • Moving a board between workspaces. Not a judgement about blast radius — a key cannot make this call at all. A key is confined to the one workspace it was issued for, and POST /boards/:board_uuid/move-workspace names two: the workspace the board is in, and the one it is going to. A tool over it would need a key that reached both, which is the confinement gone, or it would refuse every call it was ever given. The gesture is not an agent's either: an admin of two workspaces decides which of them a whole board — its tickets, comments, attachments and history — belongs to, and the route requires owner or admin in both ends before it moves anything.
  • Workspace and membership administration. A key acts as the person who created it; renaming or deleting their workspace, or answering an invitation for them, reaches further than delegating a board task ever meant.
  • The whole-workspace export. POST /workspaces/:uuid/exports and the routes beside it build one file holding every ticket, comment and wiki page in the workspace, and they refuse a key before looking at anything else. A key in a CI variable that could ask for one turns any leak of it into a full data breach rather than the scoped access it was issued for. Taking a copy of the company's data is a thing a person does, signed in, from Admin, and the audit trail records which person.
  • Bulk ticket writes. POST /tasks/archive bins a list in one call. archive_ticket, one at a time, is the deliberate choice.
  • Trash. One-way on purpose: an agent can archive and can destroy what it already archived, and a person puts things back.
  • The board event stream. Server-sent events; a tool call is one request and one answer. list_tickets with updated_since is the replacement.
  • Scheduler endpoints. The deployment's own cron hooks.

Removing a ticket

Two steps, deliberately, so that nothing is destroyed by a single call:

archive_ticket  task_uuid                    → the workspace trash, recoverable for 30 days
delete_ticket   workspace_uuid + task_uuid   → gone, and nothing brings it back

delete_ticket refuses anything that is not already archived, so the order is enforced by the server rather than by convention. There is no restore tool here — a ticket in the trash is put back from the web app — so treat archive_ticket as the furthest you can go on your own.

Read the ticket before you archive it if you intend to destroy it: delete_ticket needs the workspace_uuid, get_ticket is where you get one, and an archived ticket can no longer be read.

Working out what to do next

Every ticket read carries blocked_by, blocks and is_blocked. is_blocked is false once every blocker has reached a completion column, so the tickets a board is ready for are the ones where it is false. link_tickets records the dependency; a link that would make a loop is refused with a 409, because a loop makes the ordering unanswerable.

The one rule worth knowing

Tickets carry a version. Every write must send the version you read, and a write against a stale one is refused with 409 rather than silently overwriting whoever got there first. The server turns that into an instruction, and Laver's refusal carries the current version, so the instruction can include it rather than spending a second call on it:

Laver 409: Task was updated by another request.

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 named column, say — retry with that version. If it does, call get_ticket again and decide against the ticket as it now is, or you will quietly undo the other write.

Read, then write. Do not cache a version across a long turn.

When the key is refused

A 401 is the key: missing, mistyped, revoked, expired, or a placeholder that was never filled in. The underlying message is not always a fair description of what happened — a key the JWT layer cannot parse comes back as "Authorization token is invalid: The token is malformed", which sounds like a corrupted string when the usual cause is simply a key that was replaced. The server appends what to do about it, including the part that catches people out:

An MCP client reads that environment once, when it starts this server, so it must be restarted afterwards — editing the config in a running session changes nothing.

A 403 is different and is never worth retrying: the key was accepted, and then refused this particular action. It is scoped to another workspace, or the person it acts as has a read-only role, or is a guest without access to that board.

The key itself is read in exactly one place, sent as a bearer token, and never logged, echoed, or included in any error text.

Checking it

node check.js                  # schema, then every read-only tool actually called
node check.js --require-live   # …and a skipped sweep is a failure, for CI

# the same calls against the API the published package actually talks to
LAVER_API_KEY_FILE=../.env node check.js --live-api

Two halves. The first is static: every tool registered, classified read or write, described, and given a schema. The second boots the backend from ../backend on a spare port, creates a workspace of its own, mints a key against it, and calls every read-only tool — through the tool's own zod schema and then its handler — sending every parameter the tool declares.

That half exists because the first one passed while list_wikis sent ?workspace= at a route that requires workspace_uuid. It 400'd on every call it ever made, and since it is the only tool that yields a wiki_uuid, the whole wiki half of this server was unreachable from the day it shipped — with registration, descriptions and schema shape perfect throughout.

It needs the same things npm test in backend/ needs: that directory, its node_modules, its .env, and the Postgres they point at. No API key and no network beyond localhost — a real LAVER_API_KEY in the environment is ignored. Without a backend it prints a banner saying the tools were not called and runs the schema half alone; --require-live turns that into a failure.

It is only as local as backend/.env is, though. Running it writes to whatever database that file points at: it creates a workspace, a board, two tickets, a comment, a wiki, a page and an API key, and deletes them again at the end. It also loads the backend into its own process. It listens with app.server.listen rather than app.listen — the same idiom the collab and board-events integration tests use — so Fastify's onListen hooks do not fire and none of the seven schedulers start; without that, the billing sweep alone would run against every workspace in that database. Point backend/.env at staging and this is a check that writes to staging.

The sweep is read-only and stays that way: a check that creates tickets in somebody's workspace every time it runs is a check people stop running. The write tools are covered by the schema half only — see the note at the foot of check.js for the way to cover them without sending a write.

Ctrl-C is safe. The sweep stops after the call in flight and the fixture workspace is deleted before the process exits; a second Ctrl-C kills it outright if the call in flight is the thing that is stuck.

Against the deployed API

--live-api points the same calls at LAVER_API_URLhttps://api.laver.app unless you say otherwise — with a real key, taken from LAVER_API_KEY or from the file LAVER_API_KEY_FILE names, exactly as the server itself takes it.

It exists because a green local run and a working published package are two different claims. The local sweep proves the tools agree with the code in front of you; this package talks to the deployed API, so a route that ships a rename before the package does breaks every agent in the field while the local sweep stays green. That is a narrow window — the tools and the routes live in one repo and move together — but it is exactly the window publishing to npm opens.

Both modes run the same table, in cases.js, and every case carries an expectation for each: exact counts locally, where the fixture is known, and shapes and invariants live, where the workspace is somebody's real one and cannot be seeded or torn down. A case with only one of the two is a failure, so a new call cannot cover one transport and skip the other.

It creates nothing and deletes nothing, and that is enforced rather than promised: live mode replaces fetch with one that refuses any method but GET, so a write tool called by mistake cannot reach the network at all. frontend/tests/check-mcp-live-api-mode.mjs runs the whole mode against a stub API and asserts that every request that left the process was a GET.

Instead of a fixture it goes looking for something to point at, and wants a board with at least two tickets in at least two columns, in a workspace with a wiki that has a page. It refuses to run against anything thinner rather than passing quietly: a filter case against an empty board passes whether or not the filter was applied, which is the failure this whole file exists to prevent.

Opt-in, and never part of npm run check:all or CI — it needs a key and a network, and neither belongs in a check that runs on a box with no secrets.

Publishing

0.1.0 is published and is broken. Do not tell anyone to install it. It starts, registers all 20 tools, connects no transport, and exits 0 without writing anything to stdout or stderr — so a client sees the process end and nothing else. The entry-point guard compared the basename of process.argv[1] against this file's name, which is true only for node mcp/server.js; npm's shim for bin makes argv[1] node_modules/.bin/laver-mcp, so npx -y @laver/mcp — the way this README tells everyone to run it — never matched. Fixed in 0.1.1, and tests/check-mcp-bin-entrypoint.mjs now spawns the server through a symlink and speaks MCP to it, so the same class of bug cannot ship again.

When 0.1.1 goes out, mark the broken one so nobody lands on it:

npm 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."

Unpublishing 0.1.0 is the other option and is worse: within 72 hours it removes the version, but the number stays burned either way, and anything that already pinned it breaks rather than being warned.

The publish itself is the owner's to run, because it is public, permanent enough to matter, and takes a name nobody else can then have.

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!