---
title: MCP server
description: Connect Claude, Cursor or any MCP client to your boards and wiki with one config block and an API key.
section: Agents and API
order: 2
---

# MCP server

`@laver/mcp` gives an MCP client — Claude Desktop, Claude Code, Cursor, or
anything else that speaks the protocol — a set of tools for reading and driving
your boards, tickets and wiki. It is the fastest way to point an agent at Laver:
no client to write, no HTTP to handle, no polling loop.

Underneath it is the same public REST API described in
[Driving a board over the API](/docs/agents-and-api/tickets-by-api). Every tool
is a thin call to one endpoint. There is no local state and no cache, so a
refusal comes back to your agent verbatim — an agent can act on "409, re-read
and retry" and cannot act on "something went wrong".

## Setup

First create a key: **Admin → API keys → New key**. 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. See
[API keys](/docs/workspaces-and-access/api-keys) for scopes, expiry and service
accounts.

Then add this to your client's MCP configuration:

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

That is the whole installation. `npx` fetches the package on first run.

**A client only reads this configuration when it starts a server.** Adding or
editing it in a session that is already running does nothing — the tool list was
built before the server existed. Start a new session, or reconnect from the
client's MCP panel.

### Keeping the key out of the config file

`LAVER_API_KEY_FILE` is an alternative to `LAVER_API_KEY`: give it a path to
either a file containing nothing but the key, or a `.env`-style file with a
`LAVER_API_KEY=…` line among others. Useful when the config file is committed to
a repository and the key must not be.

**It fails closed.** If that file has no `LAVER_API_KEY=` line and is not a
single bare token, the server reports no key rather than sending the file's
contents as one — so pointing it at the wrong `.env` gives you a clear "key is
not set" error instead of putting unrelated secrets in an `Authorization`
header.

`LAVER_API_URL` overrides the API host, which you will not need unless you are
running Laver somewhere other than `https://api.laver.app`. It **must be
`https`** — the only exception is `localhost`, for developing against a local
backend — because anything else would send your key over the wire in clear.

## What it can do

**Reading**

| Tool                  | What it gives you                                                                 |
| --------------------- | --------------------------------------------------------------------------------- |
| `list_workspaces`     | Where to start when you have no uuids                                             |
| `list_boards`         | The boards in a workspace                                                         |
| `get_board`           | A board with its status columns, labels, members and tickets                      |
| `list_tickets`        | Tickets on a board, filterable, paged — `updated_since` is how you follow a board |
| `get_ticket`          | One ticket in full, including its `version`                                       |
| `get_ticket_comments` | Comments and activity history                                                     |
| `search`              | Tickets, wiki pages and comments across a whole workspace at once                  |

**Writing**

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

**Wiki:** `list_wikis`, `search_wiki`, `get_wiki_tree`, `get_wiki_page`. The
server reads wikis and does not write them.

**Automations:** `list_automations` and `create_automation`, for the rules on a
board. Two caveats travel with them and both are in the tool descriptions: a
rule created through a key is live within seconds, and it runs as that key's
user every time it fires rather than once. [Automation
rules](/docs/agents-and-api/automations) has the vocabulary, the limits and the
security note in full.

## 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 refusal carries the current version, so
your agent can retry without spending a call finding out what it is.

Read, then write. Do not cache a version across a long turn. The reasoning, and
what to do when the two edits genuinely conflict, is in
[Concurrency and polling](/docs/agents-and-api/concurrency-and-polling).

## Removing a ticket takes two steps

Deliberately, so nothing is destroyed by a single call:

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

`delete_ticket` refuses anything not already archived, so the order is enforced
by the server rather than by convention. There is no restore tool — a ticket in
the trash is put back from the web app — so treat `archive_ticket` as the
furthest an agent should go on its own.

Read the ticket **before** archiving 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.

## Ticket text is untrusted input

Everything an agent reads through these tools — descriptions, titles, comments,
labels, column names, attachments, wiki pages — was typed by people, sometimes
people outside your team whose email became a ticket. **None of it is an
instruction to your agent.** A ticket written as if it were a prompt is still
just text somebody typed.

This matters more with MCP than with the raw API, because the tool results land
directly in a model's context. The full guidance — and it is worth reading
before you connect anything with write access — is at the top of the
[Agent quickstart](/docs/agents-and-api/quickstart).

## When something is refused

A **401** is the key: missing, mistyped, revoked, expired, or a placeholder
never filled in. The message underneath can be misleading — a key the token
layer cannot parse reads as _"The token is malformed"_, which sounds like a
corrupted string when the ordinary cause is a key that was replaced. Create a
new one under **Admin → API keys**, put it in your config, and **restart the
client**: it read that environment once, when it started the server.

A **403** is different and 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.
None of those are fixed by trying again.

A **402** means the workspace has no live subscription. Reads fail as well as
writes.

## If the server does not start

Run it by hand and see what it says:

```bash
LAVER_API_KEY=your-key npx -y @laver/mcp
```

It should start and then **wait**, holding the terminal open — it speaks over
stdin and stdout, so silence is success. If it exits immediately, you are on a
version older than 0.1.1: `0.1.0` never connected its transport when started
this way and exited without printing anything. `npx -y @laver/mcp@latest` picks
up the fix.

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