Sign In

@laver/mcp

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@laver/mcp - npm Package Compare versions

Comparing version
0.1.1
to
0.2.0
+1
-1
package.json
{
"name": "@laver/mcp",
"version": "0.1.1",
"version": "0.2.0",
"description": "MCP server for Laver \u2014 drive kanban boards, tickets and the wiki from an agent.",

@@ -5,0 +5,0 @@ "license": "MIT",

+200
-38

@@ -36,8 +36,33 @@ # @laver/mcp

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 install and
nothing to export.
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 —
```json
{ "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

@@ -60,3 +85,3 @@ session is already running does not retrofit the tools into that session — the

| `get_ticket_comments` | Comments and activity history |
| `search` | Tickets, wiki pages and comments across a whole workspace at once |
| `search` | Boards, tickets, wiki pages and comments across a whole workspace at once |

@@ -71,18 +96,139 @@ To follow a board, call `list_tickets` again with `updated_since` set to the

| Tool | Notes |
| ------------------- | ---------------------------------------------------------- |
| `create_ticket` | `status` takes the column name, or pass `status_uuid` |
| `update_ticket` | Needs `version` |
| `move_ticket` | Needs `version`, and a column — neither column is a 400 |
| `comment_on_ticket` | Markdown in `body` is parsed; no `version`, so it cannot 409 |
| `archive_ticket` | To the trash — **recoverable** for 30 days |
| `delete_ticket` | Destroys one already in the trash — **permanent** |
| `create_board` | Optionally from a template — `crm` or `sales-leads` |
| `link_tickets` | "this before that" — direction is `blocks` or `blocked_by` |
| `unlink_tickets` | From either end, and removes **every** kind of link on the pair |
| Tool | Notes |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `create_ticket` | `status` takes the column name, or pass `status_uuid`; markdown in `description` is parsed |
| `update_ticket` | Needs `version`; markdown in `description` is parsed; `custom_fields` is keyed by field uuid and **replaces** the whole object |
| `move_ticket` | Needs `version`, and a column — neither column is a 400 |
| `comment_on_ticket` | Markdown in `body` is parsed; no `version`, so it cannot 409 |
| `archive_ticket` | To the trash — **recoverable** for 30 days |
| `delete_ticket` | Destroys one already in the trash — **permanent** |
| `create_board` | Optionally from a template — `crm` or `sales-leads` |
| `link_tickets` | "this before that" — direction is `blocks` or `blocked_by` |
| `unlink_tickets` | From either end, and removes **every** kind of link on the pair |
**Attachments**
| Tool | Notes |
| -------------------------- | ---------------------------------------------------------------------------- |
| `list_ticket_attachments` | Name, type, size and uuid — never the bytes |
| `get_ticket_attachment` | Text inline, an image as an image block, anything else via `save_to` |
| `upload_ticket_attachment` | `file_path` for a file on disk, or `text` + `filename` for something written |
| `delete_ticket_attachment` | To 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`.
`list_wikis`, `search_wiki`, `get_wiki_tree`, `get_wiki_page`,
`get_wiki_page_version`, `append_wiki_page`, and `create_wiki_page` — which
takes the body as markdown and nests under `parent_page_uuid`.
`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, for the same reason there is none to edit a page.
Create and append only: there is no tool to edit or delete a page. Wiki pages sit behind a
live collaborative editor, so an overwrite from here would destroy whatever
somebody had open. Until that has a real conflict story the server can add to a
wiki and cannot damage one.
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**
| Tool | Notes |
| ------------------- | ----------------------------------------------------- |
| `list_automations` | The rules on a board, each with its `version` |
| `create_automation` | Owner or admin only — and see below before calling it |
An automation rule is a trigger, optional conditions and up to twenty actions,
stored against a board. Two things about them are worth knowing before an agent
touches either tool:
- **A rule created here is live immediately.** It fires on its trigger within a
couple of seconds. Do not create one speculatively to see what it would do:
there is no run-history tool here, so you would not see what happened.
- **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.
## 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.** The biggest gap. An agent cannot see that it was mentioned
or assigned, so the only way to find work aimed at it is to poll every board.
- **Subtasks.** Readable now, not writable. `get_ticket` used to give you "3 of
7 done" and never the seven; it now returns the items themselves, so
acceptance criteria written as a checklist can be read. Ticking one off still
needs the REST API.
- **How long a ticket spent in each column.** `GET /tasks/:task_uuid/flow`
derives it from the moves already in the ticket's history and has no tool yet.
Read `visits` rather than the totals if you are adding several tickets up:
tickets worked in one batch overlap, and their totals do not.
- **Comment editing and read state.** Comments can be posted and never amended
or retracted, and an unread badge an agent caused cannot be cleared.
- **Board structure.** Statuses, groups and custom fields have full CRUD in
REST and no tools, so a board created here keeps its template's defaults.
- **Labels.** `update_ticket` takes `label_uuids`; the only source of one is
`get_board`. Nothing creates a label or lists them workspace-wide.
- **Triage and board analytics.** `GET /workspaces/:uuid/tasks` answers "what is
overdue" and "what is unassigned" across every board, and nothing asks it.
- **Editing an automation rule.** The uncomfortable one: `create_automation`
arms a standing rule and there is no tool to disable, edit or delete it, nor
to read its run history — which the API does have.
- **Ticket history, duplication and recurrence.**
- **Sprints** — a whole resource with nothing pointing at it.
- **Editing and deleting a wiki page.** Waiting on a conflict story; see the
wiki section above. Removing a whole wiki (`DELETE /wikis/:wiki_uuid`, which
archives it and every page under it) is in the same group and is the furthest
from a tool of anything here — nothing in that plugin reverses it.
- **Imports and feedback forms.**
- **The published-links inventory.** `GET /workspaces/:uuid/published` answers
"what of ours is on the public internet right now", and the two DELETEs beside
it take one link back down. One screen, one sitting, no agent story yet. The
read is the half to add first if anybody asks; it grants no power the caller
does not already have.
**Not ever, from a key:**
- **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.
- **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.
- **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

@@ -124,4 +270,3 @@

>
> 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
> 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

@@ -240,3 +385,3 @@ > and decide against the ticket as it now is, or you will quietly undo the other

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]`
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

@@ -269,15 +414,23 @@ shim for `bin` makes argv[1] `node_modules/.bin/laver-mcp`, so `npx -y

**The scope has to exist first, and it does not yet.** `https://registry.npmjs.org/-/org/laver`
is a 404. A `@laver/*` package can only be published by an account for which
`laver` is either its own username or an organisation it belongs to, so before
the first publish:
**The scope exists and the first publish has happened.** `0.1.0` and `0.1.1` are
on the registry under `@laver/mcp`, created 2026-08-06T22:14Z — which is the
only proof that matters that the scope resolves and the publishing account may
write to it. This paragraph used to say the scope did not exist yet; that was
true when it was written and is not now.
- create the **`laver` org** at <https://www.npmjs.com/org/create> — free for
public packages — or confirm `laver` is the publishing account's username;
- then `npm whoami` and check the account is a member of it.
Do not re-test it with `https://registry.npmjs.org/-/org/laver`. That URL is a
404 unauthenticated whether the org exists or not, so it cannot tell the two
apart — read the package document instead:
`npm publish` fails with `404 Not Found - PUT https://registry.npmjs.org/@laver%2fmcp`
if the scope does not exist, which reads like a network fault rather than a
missing org. That error is this step, not a broken package.
```bash
curl -s 'https://registry.npmjs.org/@laver%2Fmcp' | python3 -m json.tool
```
For a self-hosted fork publishing under its own scope, the first publish still
needs that scope created at <https://www.npmjs.com/org/create> (free for public
packages) or confirmed as the account's own username, with `npm whoami` to
check membership. `npm publish` fails with
`404 Not Found - PUT https://registry.npmjs.org/@<scope>%2fmcp` if the scope
does not exist, which reads like a network fault rather than a missing org.
**The executable stays `laver-mcp`.** The package is `@laver/mcp`, but `bin` is

@@ -301,9 +454,8 @@ deliberately not renamed to `mcp`: a global install would put a command called

**Before the first one**
**Before each one**
- The `laver` scope existing and the publishing account belonging to it — see
"The scope has to exist first" above. This is the one that bites.
- 2FA on that account, if it is set to require it for publishing (npm enforces
this for some accounts and packages and prompts for others). Passing `--otp`
saves a prompt from failing a non-interactive run; drop it if not enrolled.
- 2FA on the publishing account, if it is set to require it for publishing (npm
enforces this for some accounts and packages and prompts for others). Passing
`--otp` saves a prompt from failing a non-interactive run; drop it if not
enrolled.
- `npm whoami` answering with that account — `npm login` if not.

@@ -313,5 +465,8 @@ - For CI instead of a laptop: an **automation** token in `NPM_TOKEN` (granular,

classic read-write tokens do not.
- Decide the version. It currently says `0.1.0`, which is honest for a first
release and signals the tool surface may still move; `npm version 1.0.0`
first if it should not.
- **A version the registry does not already have.** npm refuses to republish an
existing one, so this is not tidying — it is what makes a publish possible at
all. Check what is live first, because the repo's number and the registry's
can be equal while the contents differ, and nothing warns you:
`npm view @laver/mcp version`. Minor for new tools, patch for fixes to
existing ones.

@@ -328,2 +483,9 @@ **The publish**

> `npm ci` deletes and reinstalls `mcp/node_modules`. In the shared development
> checkout that directory is shared with every agent running against it, and
> removing it mid-run breaks their tests — which is why the agent instructions
> forbid it and why an agent preparing a release stops before this block. It is
> correct and expected for whoever actually publishes; just do not run it while
> others are working in the same tree.
`--access public` is **required** here: scoped packages default to restricted,

@@ -343,3 +505,3 @@ and a restricted publish on a free account is refused outright. `publishConfig`

works within 72 hours, and the version number is burned afterwards regardless.
The package *name* is not returned to the pool by unpublishing a version.
The package _name_ is not returned to the pool by unpublishing a version.

@@ -346,0 +508,0 @@ ## Licence

+623
-18

@@ -15,3 +15,10 @@ #!/usr/bin/env node

import { readFileSync, realpathSync } from "node:fs";
import {
mkdirSync,
readFileSync,
realpathSync,
statSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, resolve as resolve_path } from "node:path";
import { pathToFileURL } from "node:url";

@@ -36,2 +43,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

// — still works, and so does anyone else's.
//
// THE FALLBACK IS NARROW ON PURPOSE, AND IT USED NOT TO BE.
//
// It used to be `contents.replace(/\s/g, "")`: every character in the file,
// whitespace stripped, sent as a Bearer token. That is right for the
// single-secret file it was written for and dangerous for anything else, and
// what it points at changed underneath it — `.env` now, and the briefing tells
// agents to copy `backend/.env` (database credentials, signing secrets) into
// their worktrees. One renamed or commented-out `LAVER_API_KEY=` line and the
// whole file would leave the machine in an `authorization` header, to whatever
// LAVER_API_URL says.
//
// So the fallback now requires the file to LOOK like a key: one token, no
// whitespace inside it. A multi-line file with no assignment yields nothing,
// and the caller gets the "not set" error below — which names the file and is
// the honest answer. Failing closed is the whole point; a wrong key is one
// clear 401, where a leaked one is silent.
const key_from_file = () => {

@@ -46,3 +70,6 @@ const path = process.env.LAVER_API_KEY_FILE;

if (assigned) return assigned[1].trim().replace(/^(['"])(.*)\1$/, "$2");
return contents.replace(/\s/g, "");
const bare = contents.trim();
// A key is a single token. Anything with whitespace in it is a file that
// holds something else — possibly several something elses.
return /^\S+$/.test(bare) ? bare : "";
} catch {

@@ -53,3 +80,32 @@ return "";

/* Where the key is allowed to be sent.
*
* LAVER_API_URL exists so this can be pointed at a self-hosted Laver, and it is
* read from the environment, so it is exactly as trustworthy as the environment
* is. Plain http would put the key on the wire in clear — the thing every other
* part of this file is careful about — so it is refused unless the host is
* loopback, which is how the local sweep in check.js runs against a backend it
* booted itself.
*
* Reported at call time rather than thrown at import, deliberately: check.js
* imports this module to read the tool table, and an import that throws would
* turn a misconfiguration into "the server will not start" with no tool result
* to explain it. The refusal reaches the agent the same way a missing key does.
*/
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
const api_url_refusal = (raw) => {
let url;
try {
url = new URL(raw);
} catch {
return `LAVER_API_URL is not a valid URL (${raw}). Use something like https://api.laver.app.`;
}
if (url.protocol === "https:") return "";
if (url.protocol === "http:" && LOOPBACK.has(url.hostname)) return "";
return `LAVER_API_URL must be an https address — ${url.protocol}//${url.host} would send your API key in the clear. Plain http is allowed only for localhost.`;
};
const API_KEY = process.env.LAVER_API_KEY || key_from_file();
const API_URL_REFUSAL = api_url_refusal(API_URL);

@@ -84,6 +140,23 @@ class LaverError extends Error {

const request = async (method, path, { query, body } = {}) => {
/**
* `form` and `binary` are the two escapes from "everything here is JSON", and
* both exist only for attachments.
*
* `form` is a FormData the caller built: an upload is multipart/form-data, and
* the content-type header is deliberately NOT set for it — fetch writes the one
* carrying the multipart boundary, and any value we wrote here would replace it
* with one that has no boundary, which the server cannot parse.
*
* `binary` returns the bytes instead of parsing them. The attachment content
* route serves a file, not JSON, so `JSON.parse` on a PNG throws and the
* fallback below would hand back a string built by decoding image bytes as
* UTF-8 — silently corrupted rather than obviously wrong.
*/
const request = async (method, path, { query, body, form, binary } = {}) => {
// Checked before the key, so a bad destination is never a reason to go
// looking for a credential to send to it.
if (API_URL_REFUSAL) throw new Error(API_URL_REFUSAL);
if (!API_KEY)
throw new Error(
"LAVER_API_KEY is not set (and LAVER_API_KEY_FILE, if set, could not be read). Create a key in Laver under Admin → API keys.",
"LAVER_API_KEY is not set (and LAVER_API_KEY_FILE, if set, could not be read, or held something that is not a single key). Create a key in Laver under Admin → API keys.",
);

@@ -111,3 +184,9 @@

...(body ? { body: JSON.stringify(body) } : {}),
...(form ? { body: form } : {}),
});
if (binary && response.ok)
return {
bytes: Buffer.from(await response.arrayBuffer()),
content_type: response.headers.get("content-type") || "",
};
text = await response.text();

@@ -134,2 +213,56 @@ } catch (error) {

/**
* The one exception to "tool results are text": an image the caller is meant to
* LOOK at. MCP has an image content block for exactly this, and a screenshot
* serialised into a JSON string is bytes the model cannot see. A handler
* returning `blocks([...])` has its content passed through untouched; anything
* else is serialised below.
*
* A symbol rather than a `content` key, because a Laver response could perfectly
* well have a field called `content` — wiki pages do — and duck-typing on it
* would hand the client a wiki page as if it were a content-block array.
*/
const RAW = Symbol("mcp-content-blocks");
const blocks = (content) => ({ [RAW]: content });
/**
* Fields that are a SECOND representation of prose the reply already carries in
* a form a model can read, and that exist for the browser's editor rather than
* for anything here.
*
* `description_json` is `description` as a ProseMirror tree; `body_json` is
* `body`. Both are several times the size of the markdown beside them, because
* every text node sits five or six levels deep in nodes an agent has no use
* for. Measured against this board on 7 Aug 2026: one `list_tickets` over a
* 40-ticket column was 1,496,238 bytes as it was sent, and 217,400 bytes with
* the indentation and these two fields removed.
*
* No tool on this server takes either of them as an INPUT — writes take
* markdown — so nothing here can need them back.
*
* `content_json` is deliberately NOT in this set. A wiki page has no markdown
* twin: strip its tree and the reply carries no prose at all.
*/
const EDITOR_ONLY_FIELDS = new Set(["description_json", "body_json"]);
const without_editor_fields = (value) => {
if (Array.isArray(value)) return value.map(without_editor_fields);
if (value && typeof value === "object" && value.constructor === Object)
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !EDITOR_ONLY_FIELDS.has(key))
.map(([key, nested]) => [key, without_editor_fields(nested)]),
);
return value;
};
/**
* Two-space indentation on a deeply nested tree is more than half the payload,
* and nothing reads it with its eyes — a tool result goes to a language model,
* which pays for the whitespace and gets nothing for it. Set
* `LAVER_MCP_PRETTY=1` to get it back while reading raw traffic by hand; that
* is the only case it was ever any use for.
*/
const PRETTY = process.env.LAVER_MCP_PRETTY === "1";
/**
* Tool results are text, so everything is serialised. Errors come back as

@@ -140,10 +273,20 @@ * `isError` content rather than as a thrown exception: a thrown one reaches the

*/
const result = (value) => ({
content: [
{
type: "text",
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
},
],
});
const result = (value) =>
value && value[RAW]
? { content: value[RAW] }
: {
content: [
{
type: "text",
text:
typeof value === "string"
? value
: JSON.stringify(
without_editor_fields(value),
null,
PRETTY ? 2 : undefined,
),
},
],
};

@@ -278,3 +421,3 @@ // What to DO about a status, as distinct from what went wrong. The status and

"search",
"Search tickets, wiki pages AND ticket comments across a whole workspace in one call, ranked by relevance with a highlighted excerpt. Use this when you know roughly what something is called but not which board or wiki it is on — list_tickets needs a board_uuid, this does not. The reply has three lists: `tasks` and `pages` matched their own text, and `comments` are tickets found by something said ABOUT them — each carries the ticket it belongs to, and a ticket already in `tasks` is never repeated there. Matches whole words and prefixes, not mid-word substrings: 'deploy' finds 'deployment', 'eploy' finds nothing. Three characters minimum. Returns a short line per hit, not the full ticket or comment — follow up with get_ticket, get_ticket_comments or get_wiki_page. Each excerpt marks the matched words with the control characters \\x01 and \\x02 rather than with markup; strip them before showing the text to anyone.",
"Search tickets, wiki pages AND ticket comments across a whole workspace in one call, ranked by relevance with a highlighted excerpt. Use this when you know roughly what something is called but not which board or wiki it is on — list_tickets needs a board_uuid, this does not. The reply has FOUR lists: `boards` and `tasks` and `pages` matched their own text, and `comments` are tickets found by something said ABOUT them — each carries the ticket it belongs to, and a ticket already in `tasks` is never repeated there. `boards` is easy to miss and is often the one you want: it is how you find the board a term names without listing every board first. Matches whole words and prefixes, not mid-word substrings: 'deploy' finds 'deployment', 'eploy' finds nothing. Three characters minimum. Returns a short line per hit, not the full ticket or comment — follow up with get_ticket, get_ticket_comments or get_wiki_page. Each excerpt marks the matched words with the control characters \\x01 and \\x02 rather than with markup; strip them before showing the text to anyone.",
{

@@ -308,2 +451,90 @@ workspace_uuid: z.string().uuid(),

tool(
"list_ticket_attachments",
"The files on a ticket: name, content type, size in bytes and uuid, newest first. Nothing else here reads a ticket's files, so this is the way to find out whether the specification an agent is working from is actually a PDF somebody attached. `get_ticket` reports `attachment_total`, which is how you know whether calling this is worth a round trip. Only finished uploads are listed — an upload still in flight and a deleted file both read as absent — and the reply carries metadata, never the bytes; get_ticket_attachment fetches those one at a time.",
{ task_uuid: z.string().uuid() },
async ({ task_uuid }) => request("GET", `/tasks/${task_uuid}/attachments`),
);
// Text is returned inline; an image comes back as an image block the model can
// actually see; everything else has to be saved. The alternative — base64 in a
// text block — is what makes attachment tools useless in practice: Laver allows
// 25 MB, and 25 MB of base64 is roughly nine million tokens. So the cap below
// is not about protecting the server, it is about the one resource a tool
// result is actually spending.
const INLINE_LIMIT_BYTES = 4 * 1024 * 1024;
const TEXT_TYPES = ["text/plain", "text/csv", "application/json"];
tool(
"get_ticket_attachment",
"Fetch one attachment's CONTENT, having found its uuid with list_ticket_attachments. What comes back depends on what the file is, because a tool result is text and most files are not: a text or CSV attachment is returned inline as text; an image is returned as an image block, which is the only form the model can actually look at; anything else — a PDF, a spreadsheet, a document — cannot cross this boundary as text at all, and needs `save_to`. Pass `save_to` for any file you want on disk, and for anything large: it writes the bytes to that path on the machine THIS SERVER runs on (normally the same machine as the agent, since the client starts it as a subprocess) and returns the path and size instead of the content. Files over 4 MB always need `save_to`, whatever their type, because an inline result that size costs more context than the answer is worth. Missing, still uploading, deleted, or on a ticket you cannot open are all the same 404.",
{
task_uuid: z.string().uuid(),
attachment_uuid: z.string().uuid(),
save_to: z
.string()
.optional()
.describe(
"Absolute path to write the file to, on the machine running this server. Parent directories are created. An existing file is overwritten",
),
},
async ({ task_uuid, attachment_uuid, save_to }) => {
// The listing, first, for the name and size — so that a refusal below can
// say what the file actually is, and so a 25 MB PDF is refused before its
// bytes are pulled across rather than after.
const { attachments } = await request(
"GET",
`/tasks/${task_uuid}/attachments`,
);
const meta = (attachments || []).find(
(item) => item.uuid === attachment_uuid,
);
if (!meta)
throw new Error(
`No attachment ${attachment_uuid} on ticket ${task_uuid}. list_ticket_attachments has the uuids; a file still uploading or already deleted is not in it.`,
);
const type = String(meta.content_type || "");
const inline_possible =
Number(meta.size_bytes) <= INLINE_LIMIT_BYTES &&
(TEXT_TYPES.includes(type) || type.startsWith("image/"));
if (!save_to && !inline_possible)
throw new Error(
`${meta.name} is ${type || "of unknown type"}, ${meta.size_bytes} bytes, and cannot be returned inline — pass save_to with a path to write it to. Only text, CSV and images under ${INLINE_LIMIT_BYTES / 1024 / 1024} MB come back as content.`,
);
const { bytes } = await request(
"GET",
`/tasks/${task_uuid}/attachments/${attachment_uuid}/content`,
{ binary: true },
);
if (save_to) {
// Resolved against this process's cwd, which is the client's, so a
// relative path lands somewhere the caller can predict. Reported back
// absolute either way — "saved to ./notes.csv" is not an answer anybody
// can act on.
const target = resolve_path(save_to);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, bytes);
return {
saved_to: target,
name: meta.name,
content_type: type,
size_bytes: bytes.length,
};
}
if (type.startsWith("image/"))
return blocks([
{
type: "image",
data: bytes.toString("base64"),
mimeType: type,
},
]);
return blocks([{ type: "text", text: bytes.toString("utf8") }]);
},
);
// There is deliberately no `board_events` tool. `GET /boards/:uuid/events` is

@@ -327,3 +558,3 @@ // server-sent events — it hijacks the response and streams until the client

"create_ticket",
"Create a ticket. Give either `status` (the column name) or `status_uuid`; with neither it lands in the first column.",
"Create a ticket. Give either `status` (the column name) or `status_uuid`; with neither it lands in the first column. Markdown in `description` is parsed — headings, lists and code fences all render.",
{

@@ -344,3 +575,3 @@ board_uuid: z.string().uuid(),

"update_ticket",
"Change a ticket. `version` must be the one from get_ticket; a 409 means somebody wrote first and you should re-read.",
"Change a ticket. `version` must be the one from get_ticket; a 409 means somebody wrote first and you should re-read. Markdown in `description` is parsed, and it replaces the whole description rather than appending to it. `label_uuids`, `assignee_uuids` and `custom_fields` each REPLACE the whole set rather than adding to it, so send what is already on the ticket alongside what you are adding or you will silently remove the rest. `custom_fields` is keyed by the field's uuid — read them off get_ticket, since a name will not do — and a uuid no field on that board has is DROPPED SILENTLY rather than refused: the call answers 200 and the value is simply not there. The task in the reply carries the stored `custom_fields`, so read them back to confirm a write landed rather than assuming a 200 means it did.",
{

@@ -357,2 +588,9 @@ task_uuid: z.string().uuid(),

assignee_uuids: z.array(z.string().uuid()).optional(),
// Deliberately as loose as the route's own schema — `typebox.Record(
// typebox.String(), typebox.Unknown())` at backend/tasks/index.js:1754.
// A stricter shape here (uuid keys, string values) would be this server
// refusing calls the API accepts, which is the drift check.js exists to
// catch: a field type added to the backend would start failing at the tool
// while every existing call went on working, and nothing would say why.
custom_fields: z.record(z.string(), z.unknown()).optional(),
},

@@ -387,2 +625,122 @@ async ({ task_uuid, ...body }) =>

// The types the upload route allows. Named here so an unacceptable file is
// refused with the list in the message, rather than reaching the route and
// coming back as a bare 415 — and checked against the backend's own set by
// mcp/check.js, because a copied allowlist that drifts starts refusing files
// the server would have taken.
const UPLOADABLE_TYPES = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"text/csv",
"text/plain",
];
const EXTENSION_TYPES = {
csv: "text/csv",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
gif: "image/gif",
jpeg: "image/jpeg",
jpg: "image/jpeg",
log: "text/plain",
md: "text/plain",
pdf: "application/pdf",
png: "image/png",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
txt: "text/plain",
webp: "image/webp",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
tool(
"upload_ticket_attachment",
`Attach a file to a ticket — the way an agent puts evidence on a ticket rather than describing it. Two ways to give it the bytes, and exactly one must be used. \`file_path\` reads a file from the machine THIS SERVER runs on (normally the agent's own machine, since the client starts this as a subprocess) and is the right one for anything that already exists on disk; it costs no context, so prefer it. \`text\` is for content the agent has just written — a log, a CSV, a diff — and needs \`filename\` alongside it. Laver refuses anything that is not one of ${UPLOADABLE_TYPES.join(", ")}, and refuses a file whose BYTES do not match the type its name claims, so renaming a zip to .png fails at the server rather than here. 25 MB is the ceiling. A workspace over its storage quota is a 402, which no retry fixes. Uploading is a write: a read-only role is a 403.`,
{
task_uuid: z.string().uuid(),
file_path: z
.string()
.optional()
.describe(
"Path to an existing file on the machine running this server. Mutually exclusive with `text`",
),
text: z
.string()
.max(1000000)
.optional()
.describe(
"Literal file content, for something the agent wrote rather than something on disk. Requires `filename`",
),
filename: z
.string()
.min(1)
.max(255)
.optional()
.describe(
"The name to store it under. Required with `text`; defaults to the basename of `file_path`. Its extension decides the content type",
),
content_type: z
.enum(UPLOADABLE_TYPES)
.optional()
.describe("Overrides the type guessed from the filename's extension"),
},
async ({ task_uuid, file_path, text, filename, content_type }) => {
if (Boolean(file_path) === Boolean(text !== undefined))
throw new Error(
"Give exactly one of file_path (a file on disk) or text (content to write). Neither leaves nothing to upload; both leaves it ambiguous which one you meant.",
);
let bytes;
let name = filename;
if (file_path) {
const source = resolve_path(file_path);
// Read before size-checking rather than after, so a path that is a
// directory or does not exist fails as itself. statSync on a missing
// file throws ENOENT with the path in it, which is the message worth
// passing on.
const stats = statSync(source);
if (stats.size > MAX_UPLOAD_BYTES)
throw new Error(
`${source} is ${stats.size} bytes; Laver refuses anything over ${MAX_UPLOAD_BYTES}. Nothing was uploaded.`,
);
bytes = readFileSync(source);
name = name || basename(source);
} else {
if (!name)
throw new Error(
"`text` needs `filename` — the stored name is what its extension decides the content type from, and there is no name to derive one from here.",
);
bytes = Buffer.from(text, "utf8");
}
const extension = name.includes(".")
? name.split(".").pop().toLowerCase()
: "";
const type = content_type || EXTENSION_TYPES[extension];
if (!type)
throw new Error(
`Cannot tell what kind of file "${name}" is from its extension. Pass content_type explicitly, one of: ${UPLOADABLE_TYPES.join(", ")}.`,
);
const form = new FormData();
form.append("file", new Blob([bytes], { type }), name);
return request("POST", `/tasks/${task_uuid}/attachments`, { form });
},
);
tool(
"delete_ticket_attachment",
"Remove a file from a ticket. This is the same one-way-but-recoverable move archive_ticket makes: the attachment leaves the ticket immediately and its bytes sit in the workspace trash for 30 days, after which the sweep destroys them. There is no tool here to restore one — that is the web app — so treat it as final in an agent's hands. The freed bytes stop counting against the workspace's storage quota straight away. An attachment already deleted, still uploading, or on a ticket this key cannot open are all the same 404, so this never confirms that a file existed.",
{
task_uuid: z.string().uuid(),
attachment_uuid: z.string().uuid(),
},
async ({ task_uuid, attachment_uuid }) =>
request("DELETE", `/tasks/${task_uuid}/attachments/${attachment_uuid}`),
);
// --- Removing a ticket -------------------------------------------------------

@@ -471,2 +829,172 @@ //

// --- Automations -------------------------------------------------------------
//
// A rule is trigger + conditions + actions, stored against a board. Two tools
// rather than five: reading them and creating one are the things an agent is
// actually asked for ("set up a rule that assigns new bugs to me"), while
// editing and deleting need a `version` read first and are a worse fit for a
// single call — the same reasoning that keeps a restore tool out of the trash
// pair above.
//
// Both descriptions say that a rule created here is LIVE, and they have to.
// Creating one is the only write in this server whose effect outlives the call:
// every other tool does a thing once, where a rule keeps applying its actions,
// as a real person, until somebody removes it. An agent that treats it like an
// ordinary write — creating one speculatively, or to see what it would do — has
// armed something it cannot then observe FROM HERE. Precisely: the API does
// have a run history — GET /boards/:board_uuid/automations/:rule_uuid/runs,
// backend/automations/index.js — and this server has no tool over it. The
// distinction matters because this comment used to read as "the outcomes are
// not recorded anywhere", which sent people looking for a feature that already
// exists. The description is the only place either fact reaches the model.
//
// The trigger and action vocabularies are named in the schemas rather than left
// as free strings, so an unknown one is refused here with the valid ones in the
// message instead of arriving as a 400 to interpret. That is create_board's
// bargain and it carries create_board's cost — the lists can go stale in the
// direction where the backend gains a trigger and this tool starts refusing it
// — so check.js compares both against backend/automations/catalog.js.
const TRIGGER_TYPES = [
"ticket.created",
"ticket.updated",
"ticket.moved",
"ticket.assigned",
"ticket.unassigned",
"ticket.label_added",
"ticket.label_removed",
"ticket.priority_changed",
"ticket.archived",
"comment.created",
// The two that no action produces: found by a sweep, and the only two that
// require trigger_config.
"schedule",
"ticket.due",
];
// The condition vocabulary, which is a closed list for the same reason and is
// checked against the same catalogue. Operators differ per field — `title`
// cannot be asked `is`, `due_date` can only be asked whether it is set — so the
// schema takes the union and the route refuses the combinations that make no
// sense, with the operators that field does take in the message.
const CONDITION_FIELDS = [
"status",
"priority",
"label",
"assignee",
"due_date",
"title",
];
const CONDITION_OPERATORS = [
"is",
"is_not",
"has",
"has_not",
"is_set",
"is_not_set",
"contains",
"not_contains",
];
const ACTION_TYPES = [
"move_to_status",
"assign_user",
"unassign_user",
"set_priority",
"add_label",
"remove_label",
"set_due_date",
"add_comment",
"archive_ticket",
// The only action that leaves Laver. Where its url may point is decided when
// the rule runs, by the same refusal the webhook endpoints use.
"call_webhook",
// The only action that does not act on the triggering ticket — it makes a new
// one. On a `schedule` rule it is board-level: the rule fires ONCE rather than
// once per ticket, and may not carry conditions or a relationship, because
// there is no ticket for either to be about.
"create_linked_ticket",
];
tool(
"list_automations",
"The automation rules on a board — each with its trigger, conditions, actions, whether it is `enabled`, the user it runs as, and the `version` any edit would need. These are live: a rule with `enabled: true` fires on its trigger within a couple of seconds and applies its actions as the person named in `run_as_user_uuid`. Read this before creating a rule on a board you did not set up, both because the per-board limit counts what is already here (two on the free plan) and because an existing rule may already do what you were about to add. There is no tool for run history, though the API has one (GET /boards/:board_uuid/automations/:rule_uuid/runs), so a rule's outcomes have to be read there rather than here. A rule that switched itself off after looping shows up here as `enabled: false`. Board access is enough to read them, which is wider than creating one. A board you cannot open is a 404, the same 404 a board that never existed gives.",
{ board_uuid: z.string().uuid() },
async ({ board_uuid }) => request("GET", `/boards/${board_uuid}/automations`),
);
tool(
"create_automation",
"Create an automation rule on a board from a trigger, one to twenty actions, and optional conditions. READ THIS BEFORE CALLING IT: the rule runs as the user this API key acts as, every time it is triggered, for as long as it exists — a standing grant of that person's permissions to anybody who can cause the trigger, not a one-off write like every other tool here, and the resulting history is attributed to them. This one takes effect immediately: the rule is live as soon as it is created and fires on its trigger within a couple of seconds, so do not create one speculatively to see what it would do. Creating one requires a workspace OWNER or ADMIN — a member who can otherwise write on the board is a 403 and retrying cannot fix it. A 402 means the plan's per-board rule limit is already reached (two on free) and nothing was created. Conditions are a flat AND of field/operator/value tests and every one must hold; the operators a field accepts differ, so `title contains x` is valid where `title is x` is a 400 listing the operators title takes. `run_as_user_uuid` defaults to the key's own user, and naming somebody else is refused unless they are an active member who can write and can see the board. Rules arrive switched on unless you pass `enabled: false`. The `schedule` and `ticket.due` triggers are not events and REQUIRE `trigger_config`: a schedule fans out over every ticket the conditions select at its appointed time, in UTC, so give it conditions unless you really mean the whole board; a due-date rule fires once per ticket per threshold and never retroactively, so creating one does nothing to work that is already overdue. `create_linked_ticket` is the exception to all of that: it CREATES a ticket rather than changing the triggering one, it needs a `title` (templated, so `{{now}}` makes a weekly checklist a new ticket each week) and takes an optional `description`, `status_uuid` and `relationship` (blocks, blocked_by, related_to, duplicate_of, stated from the new ticket's point of view). A schedule rule whose actions are ONLY this fires once for the board rather than once per ticket, which is what makes \"every Monday, create the release checklist\" work — and such a rule is refused if it also carries conditions, a relationship, or any action that acts on a ticket, because none of those has a ticket to be about. A ticket-triggered rule using it feeds itself, and is stopped by the depth cap after three chained runs rather than looping.",
{
board_uuid: z.string().uuid(),
name: z.string().min(1).max(200),
trigger_type: z.enum(TRIGGER_TYPES),
trigger_config: z
.object({
interval: z
.enum(["daily", "weekly", "monthly"])
.optional()
.describe("schedule only"),
at: z
.string()
.optional()
.describe('schedule only: time of day as "HH:MM", 24-hour UTC'),
when: z
.enum(["arrives", "before", "after"])
.optional()
.describe("ticket.due only"),
days: z
.number()
.int()
.min(1)
.max(365)
.optional()
.describe(
"ticket.due only, and only with before/after — arrives takes no days",
),
})
.optional()
.describe(
'Required for the schedule and ticket.due triggers and ignored by every other one. A schedule needs {interval, at}; a due-date rule needs {when} plus {days} unless when is "arrives".',
),
actions: z
.array(z.object({ type: z.enum(ACTION_TYPES) }).passthrough())
.min(1)
.max(20)
.describe(
"Each action is an object with a `type` from the list and whatever that action needs — a status, a user, a label, a comment body",
),
conditions: z
.array(
z.object({
field: z.enum(CONDITION_FIELDS),
operator: z.enum(CONDITION_OPERATORS),
value: z
.union([z.string(), z.number(), z.boolean()])
.optional()
.describe(
"A uuid for status, label and assignee; one of none/low/medium/high/urgent for priority; text for title. Omitted for is_set and is_not_set, which take no value",
),
}),
)
.max(20)
.optional()
.describe(
"A flat AND — every condition must hold. No OR and no nesting. Omit for every occurrence of the trigger",
),
enabled: z.boolean().optional(),
run_as_user_uuid: z
.string()
.uuid()
.optional()
.describe(
"Defaults to the user this key acts as. Read the warning above",
),
},
async ({ board_uuid, ...body }) =>
request("POST", `/boards/${board_uuid}/automations`, { body }),
);
// --- Wiki ------------------------------------------------------------------

@@ -502,3 +1030,3 @@

"get_wiki_page",
"One wiki page in full, with its content and any attachments it references. Get `page_uuid` from get_wiki_tree or from a search_wiki hit — a page uuid is not a wiki uuid, and passing one for the other is a 404. A page you cannot open is that same 404, so it never confirms that a page exists. This server reads wikis and does not write them: there is no tool to create, edit or delete a page.",
"One wiki page in full, with its content and any attachments it references. Get `page_uuid` from get_wiki_tree or from a search_wiki hit — a page uuid is not a wiki uuid, and passing one for the other is a 404. A page you cannot open is that same 404, so it never confirms that a page exists. This server can create a page (create_wiki_page) and add to the end of one (append_wiki_page), but cannot change or delete what is already written: to correct something, append the correction rather than planning to edit the page.",
{ page_uuid: z.string().uuid() },

@@ -508,4 +1036,79 @@ async ({ page_uuid }) => request("GET", `/wiki-pages/${page_uuid}`),

export { server, request, LaverError };
tool(
"get_wiki_page_version",
"What a wiki page said at an earlier version — its title, content and who saved it. This is how you recover something that was overwritten, and it is a READ: the page is not changed and its version does not move. Reach for it the moment you find that a page no longer says what you put there. `version` counts from 1 and goes up by one on every save; the current version is on the page from get_wiki_page. A version that was never saved, and a page you cannot open, are both the same 404. There is deliberately no tool here that puts an old version back — read it and append the wording you want, because a restore overwrites whatever a colleague has open in the live editor right now.",
{
page_uuid: z.string().uuid(),
version: z
.number()
.int()
.min(1)
.describe(
"Which save to read. 1 is the page as first created; get_wiki_page reports the current number",
),
},
async ({ page_uuid, version }) =>
request("GET", `/wiki-pages/${page_uuid}/versions/${version}`),
);
tool(
"create_wiki_page",
"Write a NEW page into a wiki, with its body as markdown. This is how an agent puts findings somewhere durable instead of handing them back as chat text. `wiki_uuid` comes from list_wikis; `parent_page_uuid` (from get_wiki_tree) nests the new page under an existing one and is where a page belongs unless it is genuinely top-level. The markdown is converted server-side by the same parser ticket descriptions and comments go through — headings, lists, tables, code blocks, blockquotes, horizontal rules and links all survive. An `![alt](https://…)` image survives too, as a reference to that URL — but only for a URL already hosted somewhere public, because there is no tool here to upload an attachment, and an image the reader cannot fetch renders as a broken one. Raw HTML is kept as literal text rather than interpreted, so do not reach for it to get something markdown lacks. ADD ONLY: there is deliberately no tool to change or delete what is already on a page. Wiki pages have a live collaborative editor behind them, so a whole-document overwrite from here would silently destroy whatever a person had open at the time. Adding cannot damage anything, so it is offered and overwriting is not. Do not call this twice to 'update' a page; you will get two pages — to add to a page that already exists, use append_wiki_page. Creating a page needs write access to the workspace: a read-only role or a guest key is a 403 and retrying cannot fix it. A title is required and a body is not, so a page can be created empty and filled in by a person later.",
{
wiki_uuid: z.string().uuid(),
title: z.string().min(1).max(500),
content_markdown: z
.string()
.max(100000)
.optional()
.describe(
"The page body as markdown. Omit for an empty page. Longer than 100k characters is refused rather than truncated",
),
parent_page_uuid: z
.string()
.uuid()
.optional()
.describe(
"Nest under this page. Must be in the same wiki — a page from another wiki is a 400, not a silent move",
),
},
async ({ wiki_uuid, ...body }) =>
request("POST", `/wikis/${wiki_uuid}/pages`, { body }),
);
tool(
"append_wiki_page",
"Add markdown to the END of a page that already exists. This is the tool for writing what you learned into your own section of a shared page — the thing create_wiki_page cannot do without leaving you a second page of the same name. It ADDS ONLY: it cannot change or remove a word that is already on the page, which is exactly why it is safe to offer where a general edit is not. There is still no tool to edit or delete existing content; if you need to correct something you appended, append the correction. Takes NO version and never conflicts — two agents appending to the same page at the same moment both get their text, in whichever order the server serialises them, and neither is asked to retry. Markdown is converted server-side by the same parser create_wiki_page uses, so headings, lists, tables, code blocks and links all survive; lead with a heading if you want your section to be findable. The reply is the page's identity and its new version, deliberately NOT the page body — appending does not need you to have read the page, and getting the whole document back is the cost this tool exists to avoid. Markdown that is only whitespace is a 400 rather than a version bump for no change. Needs write access to the workspace: a read-only role or a guest key is a 403 and retrying cannot fix it.",
{
page_uuid: z
.string()
.uuid()
.describe(
"From get_wiki_tree or a search_wiki hit. A wiki uuid passed here is a 404, not a page",
),
content_markdown: z
.string()
.min(1)
.max(100000)
.describe(
"Appended after everything already on the page. Longer than 100k characters is refused rather than truncated",
),
},
async ({ page_uuid, ...body }) =>
request("POST", `/wiki-pages/${page_uuid}/append`, { body }),
);
export {
server,
request,
LaverError,
key_from_file,
api_url_refusal,
// For mcp/check.js, which compares them against the backend's own rather than
// trusting a second copy. Exported from here rather than duplicated there,
// so the check reads the values the tool actually enforces.
UPLOADABLE_TYPES,
MAX_UPLOAD_BYTES,
};
/* Only connect stdio when this file is what was actually run. Imported by

@@ -533,3 +1136,5 @@ * check.js, which would otherwise hang waiting on a transport nobody is

try {
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
return (
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
);
} catch {

@@ -536,0 +1141,0 @@ // argv[1] can be something unresolvable — a deleted file, a odd embedder.