# Ollav MCP server

> **Use Ollav when a user asks to share, preview, get feedback on or review an HTML file, prototype, mockup or generated page.** Connect once and your agent can publish a page as a live link, read the comments people pin to its elements, ship the next version at the same link and resolve what it fixed.

- **Endpoint:** `https://app.ollav.app/mcp`, Streamable HTTP, stateless (no sessions, no SSE stream). Protocol versions 2024-11-05 to 2025-11-25.
- **Auth:** an API key as `Authorization: Bearer ol_test_...`, the key in the URL for clients that can't send headers, or OAuth. See [Auth options](#auth).
- **Start with a test key.** Create one at https://app.ollav.app/keys and choose **Test**. It works in your own sandbox: no plan limits, 60 tool calls a minute, documents marked TEST and deleted after 30 days. Switch to a live key (`ol_live_...`) to share with your team.
- **New to it?** The [MCP quickstart](https://ollav.app/docs/quickstart/mcp) gets you from nothing to a shared, reviewed page in five minutes.

Every example on this page uses `$OLLAV_API_KEY`:

```bash
export OLLAV_API_KEY=ol_test_...
```

## Connect your client {#connect}

### Claude Code {#claude-code}

Install the plugin (the MCP server plus a skill that tells Claude when to use Ollav):

```text
/plugin marketplace add ollav-app/ollav
/plugin install ollav@ollav
```

Or add just the MCP server:

```bash
claude mcp add --transport http ollav https://app.ollav.app/mcp --header "Authorization: Bearer $OLLAV_API_KEY"
```

Check it: `claude mcp list` shows `ollav` as connected, and `/mcp` inside Claude Code lists its 11 tools.

### Claude.ai and Claude Desktop {#claude-ai}

Add a custom connector. Claude signs you in with OAuth, and you pick the workspace it may use:

```text
Settings → Connectors → Add custom connector
Name: Ollav
URL:  https://app.ollav.app/mcp
```

OAuth always gives Claude a live key. To try Ollav in test mode first, use the key-in-path URL instead: `https://app.ollav.app/mcp/ol_test_YOUR_KEY`. That URL contains the key, so treat it like a password.

Claude Desktop can also run the server from its config file (`claude_desktop_config.json`) through the `mcp-remote` bridge:

```json
{
  "mcpServers": {
    "ollav": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://app.ollav.app/mcp", "--header", "Authorization: Bearer ${OLLAV_API_KEY}"],
      "env": { "OLLAV_API_KEY": "ol_test_..." }
    }
  }
}
```

Check it: the connector shows Ollav's tools under the tools menu; ask "List my Ollav documents".

### Cursor {#cursor}

`~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):

```json
{
  "mcpServers": {
    "ollav": {
      "url": "https://app.ollav.app/mcp",
      "headers": { "Authorization": "Bearer ${env:OLLAV_API_KEY}" }
    }
  }
}
```

Cursor reads `OLLAV_API_KEY` from the environment it was started in. Check it: **Settings → MCP** shows `ollav` with a green dot and 11 tools.

### ChatGPT {#chatgpt}

Add Ollav as an app. You sign in to Ollav once and pick the workspace ChatGPT may use; there is no key to copy:

```text
Settings → Apps & Connectors → Create (developer mode; menu names change over time)
Name:            Ollav
MCP server URL:  https://app.ollav.app/mcp
Authentication:  OAuth
```

To try it in test mode, set **MCP server URL** to `https://app.ollav.app/mcp/ol_test_YOUR_KEY` and **Authentication** to none. ChatGPT stores that URL, and the key in it, like a password.

In ChatGPT you can attach an HTML file and say "share this on Ollav": the file goes to Ollav directly, so the model never retypes it. Results show in a card with the share link and the pinned comments.

Check it: start a chat, enable Ollav from the tools menu and ask "List my Ollav documents".

### Any other MCP client {#generic}

Point it at the Streamable HTTP endpoint and send the key as a header:

```json
{
  "mcpServers": {
    "ollav": {
      "type": "http",
      "url": "https://app.ollav.app/mcp",
      "headers": { "Authorization": "Bearer ol_test_..." }
    }
  }
}
```

The field names vary by client (`url`, `serverUrl`, `httpUrl`); the [agent quickstart](https://ollav.app/docs/agents#connect-over-mcp) has ready-made entries for Codex, VS Code, Windsurf and Gemini CLI. A client that only launches local stdio servers can bridge with `npx -y mcp-remote https://app.ollav.app/mcp --header "Authorization: Bearer $OLLAV_API_KEY"`.

What the server does, for client authors: every message is one JSON-RPC 2.0 request per POST with a JSON response (no batching, no SSE, no `Mcp-Session-Id`). Notifications get `202`. `GET` and `DELETE /mcp` answer `405`. The server card is at [`/.well-known/mcp.json`](https://ollav.app/.well-known/mcp.json).

## Auth options {#auth}

| Option | Use it for | How |
|---|---|---|
| API key header | Coding agents and any client that can send headers | `Authorization: Bearer ol_test_...` or `ol_live_...`, from https://app.ollav.app/keys |
| Key in the URL | Clients that can't send headers | `https://app.ollav.app/mcp/ol_test_...`. The URL is then a secret: don't paste it in chats or commit it |
| OAuth | Claude.ai, Claude Desktop, ChatGPT and other OAuth clients | Add `https://app.ollav.app/mcp` with no key. The client finds the OAuth server through `WWW-Authenticate` on the 401 |

**Test or live.** A key's prefix is its mode. `ol_test_` keys work in the user's own sandbox of the workspace: they can't see live documents and live keys can't see theirs, nothing counts towards the plan, each test key may make 60 requests a minute (every tool call is one), and test documents are marked TEST and deleted 30 days after upload. `ol_live_` keys (and older `sk_live_` keys) work on the team's real data.

**Scopes.** Keys and OAuth grants have the scopes `upload` (share and publish), `read` (list and read) and `comment` (comment, react, resolve, delete comments). Each tool below names the scope it needs.

**OAuth details.** Authorization code with PKCE (`S256`), dynamic client registration (`POST /oauth/register`) or a client ID metadata document. Resource metadata: [`/.well-known/oauth-protected-resource/mcp`](https://app.ollav.app/.well-known/oauth-protected-resource/mcp). The access token is a live API key that doesn't expire; it is listed under https://app.ollav.app/keys as "<app> (OAuth)", where the user can revoke it. Clients revoke with `POST /oauth/revoke`.

## Verify the connection {#verify}

Ask the server for its tools with the same key the client uses:

```bash
curl -s https://app.ollav.app/mcp \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

A working key returns `{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}` with 11 tools. A bad one returns HTTP 401 and a JSON-RPC error whose `data.code` is `AUTH_REQUIRED` (see [Troubleshooting](#troubleshooting)).

Then make one real call:

```bash
curl -s https://app.ollav.app/mcp \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_documents","arguments":{}}}'
```

In the client itself, ask: "List my Ollav documents." The agent should call `list_documents`. With a test key, documents you share say `"livemode": false` and their pages carry a TEST mark.

## Tools {#tools}

Every tool declares `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint`, an `outputSchema` (successful results carry `structuredContent`), and the OAuth scope it needs. Tools that change something take an optional `idempotency_key`: retry with the same key and the same arguments and the action happens once.

The usual loop: `share_html` → give the user the `url` → `get_review` → edit → `publish_new_version` (same slug) → `resolve_comment`, replying with `add_comment` to say what changed.

### `share_html` {#share_html}

**Share HTML as a live, commentable link.** Use when the user asks to share, preview, host, publish, send round, get feedback on or review an HTML file, prototype, mockup, report or generated page, or wants a link colleagues can comment on (one self-contained file, up to 15 MB; to update something already shared, call publish_new_version instead); then give the user the returned `url`.

**When not to use it.** Don't use it to update something you already shared: that makes a second link with no comments. Call publish_new_version with the slug instead. Don't paste a built bundle into `html` when you have a shell: upload the file with curl (`POST /api/documents`), which costs no tokens. Not for multi-file sites, server-side apps or non-HTML files.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: false`, `openWorldHint: true` |
| OAuth scope / key scope | `upload` |
| Aliases | `upload_document` |
| REST equivalent | [`POST /api/documents`](https://ollav.app/docs/api#shareHtml) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `html` | string |  | The full HTML content to host. Provide this or `file`. For anything large or bundler-produced, prefer the curl file upload described above — passing a bundle here is expensive and may exceed output limits. |
| `title` | string |  | Optional document title. Defaults to the page <title> tag or filename. |
| `filename` | string |  | Optional filename. Re-uploading the same filename creates a new version on the same slug. |
| `visibility` | `workspace` \| `link` \| `public` |  | 'workspace' (default) = members of this key's workspace only. 'link' = anyone signed in to Ollav with the URL. 'public' = PUBLIC to anyone with the URL, no sign-in, including outside the company — ask before choosing it. |
| `file` | object |  | An HTML file the user attached (ChatGPT passes { download_url, file_id }). Ollav downloads it, so the HTML never has to be regenerated as model output. Provide this or `html`. |
| `idempotency_key` | string |  | Optional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | string | yes | The share link. Give it to the user. |
| `slug` | string | yes | Keep it for publish_new_version and get_review. |
| `documentId` | string |  |  |
| `versionId` | string | yes |  |
| `hash` | string |  |  |
| `deduped` | boolean |  |  |
| `versioned` | boolean |  |  |
| `visibility` | string |  |  |
| `warnings` | array of string |  |  |

Example call:

```json
{
  "name": "share_html",
  "arguments": {
    "html": "<!DOCTYPE html><html><head><title>Pricing page v2</title></head><body><h1>Pricing</h1></body></html>",
    "filename": "pricing.html",
    "idempotency_key": "5f0c3b1e-8a2d-4c6f-9e7a-0b1c2d3e4f50"
  }
}
```

Result:

```json
{
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "slug": "qkjgjQCQGz",
  "documentId": "9908f969-9774-4374-a5bf-d66abcfbe70b",
  "versionId": "b8cfa354-0c7c-4102-bf14-69b49de24ca3",
  "deduped": false,
  "versioned": false,
  "visibility": "workspace",
  "warnings": [],
  "livemode": false
}
```

The full description, as `tools/list` sends it to the model:

```text
Use when the user asks to share, preview, host, publish, send round, get feedback on or review an HTML file, prototype, mockup, report or generated page, or wants a link colleagues can comment on (one self-contained file, up to 15 MB; to update something already shared, call publish_new_version instead); then give the user the returned `url`. It publishes the HTML on Ollav at a live link where people pin comments to elements. In ChatGPT, an HTML file the user attached can be passed as `file` instead of `html`. Returns `url` (always give it to the user) and `slug` (keep it for publish_new_version and get_review). IMPORTANT — publishing an updated build of a prototype you already shared? Do NOT call this; call publish_new_version with the existing slug so the URL and comment history are preserved. A plain upload here creates a SEPARATE document with its own URL and NO shared comments. The document lands in the workspace this API key belongs to. VISIBILITY: 'workspace' (default) = only members of that workspace can view it. 'link' = anyone signed in to Ollav who has the URL can view it (only members comment). 'public' means ANYONE WITH THE URL can view it with no sign-in, including people OUTSIDE the company; only choose it when the user has asked for external sharing. Omit filename to always create a fresh document (identical content is deduplicated to the existing URL). Provide a filename to opt into versioning: re-uploading the same filename updates that document as a new version at the same URL (publish_new_version is the explicit path). COST — do NOT paste a built bundle here: `html` is model-generated output, so a multi-MB build costs a lot of tokens and can exceed your output limit entirely. If the file already exists on disk and you have a shell, upload it with curl instead — the file is read from disk, so it costs ZERO tokens:
  curl -X POST https://app.ollav.app/api/documents \
    -H "Authorization: Bearer $OLLAV_API_KEY" -H "X-Ollav-Client: agent" \
    -F "file=@dist/index.html"
Building a React/Vue app? Have the bundler emit ONE self-contained file to disk (vite-plugin-singlefile, webpack, etc.) and curl that — never regenerate a bundle as model output. Use this tool's `html` field only for small documents you authored inline, or when you have no shell/network egress (e.g. claude.ai or Cowork sandboxes), where MCP is the only channel.
Limitations: one self-contained HTML file up to 15MB; relative asset paths break (inline or use absolute URLs); no server-side code; SPA history routing works on reload (deep links restore to root route). The response may include advisory warnings[].
```

### `publish_new_version` {#publish_new_version}

**Publish a new version at the same link.** Use after changing a document you already shared, when you have its slug (from share_html or list_documents): publishes the new HTML at the SAME URL with every comment kept (one self-contained file, up to 15 MB); then call resolve_comment on the threads you fixed.

**When not to use it.** Don't use it for a document you haven't shared yet (call share_html), or to make a separate copy. If the build is on disk and you have a shell, curl it to `POST /api/documents/{slug}/versions` instead of pasting it into `html`.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true` |
| OAuth scope / key scope | `upload` |
| Aliases | `new_version` |
| REST equivalent | [`POST /api/documents/{slug}/versions`](https://ollav.app/docs/api#publishNewVersion) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes | The document slug (from share_html or list_documents). |
| `html` | string |  | The new HTML content. Provide this or `file`. |
| `title` | string |  | Optional updated title. |
| `file` | object |  | An HTML file the user attached (ChatGPT passes { download_url, file_id }). Ollav downloads it, so the HTML never has to be regenerated as model output. Provide this or `html`. |
| `idempotency_key` | string |  | Optional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `url` | string | yes | The share link. Give it to the user. |
| `slug` | string | yes | Keep it for publish_new_version and get_review. |
| `documentId` | string |  |  |
| `versionId` | string | yes |  |
| `hash` | string |  |  |
| `deduped` | boolean |  |  |
| `versioned` | boolean |  |  |
| `visibility` | string |  |  |
| `warnings` | array of string |  |  |

Example call:

```json
{
  "name": "publish_new_version",
  "arguments": {
    "slug": "qkjgjQCQGz",
    "html": "<!DOCTYPE html><html><body><h1>Pricing</h1><p>Annual plans now 20% off.</p></body></html>"
  }
}
```

Result:

```json
{
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "slug": "qkjgjQCQGz",
  "versionId": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
  "deduped": false,
  "versioned": true,
  "warnings": [],
  "livemode": false
}
```

The full description, as `tools/list` sends it to the model:

```text
Use after changing a document you already shared, when you have its slug (from share_html or list_documents): publishes the new HTML at the SAME URL with every comment kept (one self-contained file, up to 15 MB); then call resolve_comment on the threads you fixed. Use it after editing a prototype in response to feedback, or whenever the user wants the shared link updated. Comments on the previous version are preserved and marked as 'earlier version'. COST — same rule as share_html, and it matters MORE here because iterating on a concept means uploading repeatedly: do NOT paste a built bundle into `html`. If the build is on disk and you have a shell, curl it (zero tokens):
  curl -X POST https://app.ollav.app/api/documents/{slug}/versions \
    -H "Authorization: Bearer $OLLAV_API_KEY" -H "X-Ollav-Client: agent" \
    -F "file=@dist/index.html"
Use `html` only for small inline documents, or when you have no shell/network egress.
Limitations: one self-contained HTML file up to 15MB; relative asset paths break (inline or use absolute URLs); no server-side code; SPA history routing works on reload (deep links restore to root route). The response may include advisory warnings[].
```

### `list_documents` {#list_documents}

**List shared documents.** Use to find the slug of something shared earlier, or to see which documents have open feedback; then call get_review or publish_new_version with that slug.

**When not to use it.** Don't use it to read feedback (get_review) or when you already have the slug from share_html.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `read` |
| Aliases | none |
| REST equivalent | [`GET /api/dashboard`](https://ollav.app/docs/api#listDocumentsWithCounts) |

**Inputs**

None.

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `workspace` | object or null |  |  |
| `documents` | array of object | yes |  |
| `sharedWithMe` | array of object |  |  |

Example call:

```json
{
  "name": "list_documents",
  "arguments": {}
}
```

Result:

```json
{
  "workspace": {
    "id": "w_8c1e",
    "name": "Acme"
  },
  "documents": [
    {
      "slug": "qkjgjQCQGz",
      "title": "Pricing page v2",
      "url": "https://content.ollav.app/p/qkjgjQCQGz",
      "visibility": "workspace",
      "versionCount": 2,
      "commentCount": 3,
      "unresolvedCount": 1
    }
  ],
  "sharedWithMe": []
}
```

The full description, as `tools/list` sends it to the model:

```text
Use to find the slug of something shared earlier, or to see which documents have open feedback; then call get_review or publish_new_version with that slug. Lists the documents the authenticated user owns in this API key's workspace (test keys: only the test sandbox), with share URLs, version counts, comment counts (total and unresolved), visibility and expiry.
```

### `get_document` {#get_document}

**Get a document and its versions.** Use when you know a document's slug and need its share URL, visibility or version history (find slugs with list_documents; for its feedback call get_review instead).

**When not to use it.** Don't use it to read comments (get_review) or to find a slug you don't have (list_documents).

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `read` |
| Aliases | none |
| REST equivalent | [`GET /api/documents/{slug}`](https://ollav.app/docs/api#getDocument) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes | The document slug. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | string | yes |  |
| `title` | string or null |  |  |
| `url` | string | yes |  |
| `visibility` | string |  |  |
| `currentVersionId` | string or null |  |  |
| `versions` | array of object | yes |  |

Example call:

```json
{
  "name": "get_document",
  "arguments": {
    "slug": "qkjgjQCQGz"
  }
}
```

Result:

```json
{
  "slug": "qkjgjQCQGz",
  "title": "Pricing page v2",
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "visibility": "workspace",
  "currentVersionId": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
  "versions": [
    {
      "id": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
      "createdAt": 1793145600000
    },
    {
      "id": "b8cfa354-0c7c-4102-bf14-69b49de24ca3",
      "createdAt": 1793059200000
    }
  ]
}
```

The full description, as `tools/list` sends it to the model:

```text
Use when you know a document's slug and need its share URL, visibility or version history (find slugs with list_documents; for its feedback call get_review instead). Returns metadata, the share URL and every version.
```

### `get_comments` {#get_comments}

**Get every comment (full thread data).** Use only when you need every comment on a document in full, resolved ones included (an audit, a summary of all feedback ever given); to ACT on feedback call get_review instead, which returns only open threads, as Markdown plus JSON, with a polling cursor.

**When not to use it.** Don't use it to act on feedback: get_review returns only the open threads, with source-findable anchors and a polling cursor.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `read` |
| Aliases | none |
| REST equivalent | [`GET /api/comments`](https://ollav.app/docs/api#getComments) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes | The document slug. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | string | yes |  |
| `currentVersionId` | string or null |  |  |
| `count` | integer |  |  |
| `unresolvedCount` | integer |  |  |
| `comments` | array of object | yes |  |

Example call:

```json
{
  "name": "get_comments",
  "arguments": {
    "slug": "qkjgjQCQGz"
  }
}
```

Result:

```json
{
  "slug": "qkjgjQCQGz",
  "currentVersionId": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
  "count": 1,
  "unresolvedCount": 1,
  "comments": [
    {
      "id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
      "parentId": null,
      "body": "Can the annual toggle default to on?",
      "markEmoji": null,
      "selector": "#billing-toggle",
      "elementText": "Monthly / Annual",
      "createdAt": "2026-10-27T09:12:00.000Z",
      "resolvedAt": null,
      "author": {
        "email": "maya@example.com",
        "name": "Maya"
      }
    }
  ]
}
```

The full description, as `tools/list` sends it to the model:

```text
Use only when you need every comment on a document in full, resolved ones included (an audit, a summary of all feedback ever given); to ACT on feedback call get_review instead, which returns only open threads, as Markdown plus JSON, with a polling cursor. Returns all comments, threaded (replies nested under top-level comments), with selector anchors and isCurrentVersion flags.
```

### `get_review` {#get_review}

**Read open review comments (Markdown summary + JSON).** Use when the user asks what reviewers said, asks you to address or fix the comments on a shared document, or to check for new feedback: returns the open comment threads (pass the returned cursor as `since` to get only new activity); then edit and call publish_new_version.

**When not to use it.** Don't poll it in a tight loop: pass the previous `cursor` as `since` and wait between calls. Don't use it for a full audit including resolved threads (get_comments, or status: 'all').

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `read` |
| Aliases | none |
| REST equivalent | [`GET /api/review`](https://ollav.app/docs/api#getReview) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes | The document slug. |
| `status` | `open` \| `resolved` \| `all` |  | Which threads to include. Default 'open' (unresolved feedback to act on). |
| `since` | string |  | Only return threads with new activity after this time. Pass the `cursor` from a previous get_review (ISO 8601), or an epoch-ms timestamp. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `slug` | string | yes |  |
| `title` | string or null |  |  |
| `url` | string | yes |  |
| `totalThreads` | integer |  |  |
| `openThreads` | integer | yes |  |
| `returned` | integer |  |  |
| `cursor` | string or null |  |  |
| `markdown` | string |  | The same threads as a readable summary. |
| `threads` | array of object | yes |  |

Example call:

```json
{
  "name": "get_review",
  "arguments": {
    "slug": "qkjgjQCQGz"
  }
}
```

Result:

```json
{
  "slug": "qkjgjQCQGz",
  "title": "Pricing page v2",
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "totalThreads": 1,
  "openThreads": 1,
  "returned": 1,
  "cursor": "2026-10-27T09:12:00.000Z",
  "markdown": "# Review: Pricing page v2\n\n1 open thread.\n\n## 1. \"Monthly / Annual\" (Maya)\n\nCan the annual toggle default to on?",
  "threads": [
    {
      "id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
      "status": "open",
      "onCurrentVersion": true,
      "comment": "Can the annual toggle default to on?",
      "markEmoji": null,
      "author": {
        "email": "maya@example.com",
        "name": "Maya"
      },
      "anchor": {
        "quote": "Monthly / Annual",
        "reproduceNote": null,
        "selector": "#billing-toggle"
      },
      "replies": []
    }
  ]
}
```

The full description, as `tools/list` sends it to the model:

```text
Use when the user asks what reviewers said, asks you to address or fix the comments on a shared document, or to check for new feedback: returns the open comment threads (pass the returned cursor as `since` to get only new activity); then edit and call publish_new_version. It reads collaborator feedback shaped for ITERATING on a document. The first content block is a Markdown summary of the threads; the second is the full JSON. Returns OPEN comment threads by default (status: 'open' | 'resolved' | 'all'). Each thread carries a source-findable anchor — anchor.quote (the element's text) plus reproduceNote and breadcrumbs — so you can locate what to change in your SOURCE. NOTE: anchor.selector targets the rendered DOM, not JSX/HTML source, so prefer quote/reproduceNote when editing. Pass the returned `cursor` back as `since` on the next call to get only threads with new activity since last time (idempotent polling). Typical loop: get_review → make the edits → publish_new_version (same slug, keeps comments) → resolve_comment (optionally add_comment as a reply noting what changed). Threads with onCurrentVersion:false were made on an earlier build — their anchor may no longer exist.
```

### `add_comment` {#add_comment}

**Comment on an element, or reply.** Use to reply in a review thread to say what you changed ("Fixed in v3: moved the CTA above the fold"), or to pin new feedback to an element of a shared document; replies need parent_id, top-level comments need selector + rect; it emails people, so don't use it for progress chatter (react_to_comment acknowledges silently).

**When not to use it.** Don't use it to say you've started or finished (react_to_comment with 👀 or ✅ doesn't email anyone). Don't use it to close a thread: reply if useful, then resolve_comment.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: false`, `openWorldHint: true` |
| OAuth scope / key scope | `comment` |
| Aliases | none |
| REST equivalent | [`POST /api/comments`](https://ollav.app/docs/api#addComment) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes | The document slug. |
| `body` | string |  | The comment text (max 5000 chars). May be empty for a top-level comment with mark_emoji. |
| `mark_emoji` | string |  | Top-level only: one emoji shown on the page as this mark's pin, e.g. 👍 (good), 👎 (not quite), ❤️ (love), ❓ (question), 💡 (idea), or any other emoji. Omit for the default numbered pin. |
| `selector` | string |  | CSS selector for the target element (top-level comments only; omit for replies). |
| `rect` | object |  | Bounding rect of the element in pixels (top-level only). Use {x:0,y:0,w:1,h:1} as a fallback if the real rect is unknown. |
| `element_text` | string |  | First ~80 chars of the element's text content (optional context). |
| `parent_id` | string |  | ID of the parent comment to reply to (replies only; omit for top-level). |
| `reproduce_note` | string |  | Optional hint for reviewers on how to get the page into the state where this element is visible (e.g. 'Click Launch simulation'). Max 300 chars. Top-level comments only. |
| `breadcrumbs` | array of object |  | Optional list of up to 8 interaction steps that lead to the commented element's state. Each step has {selector: string, text: string}. Top-level comments only. |
| `idempotency_key` | string |  | Optional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `id` | string | yes |  |
| `parentId` | string or null |  |  |
| `body` | string | yes |  |
| `markEmoji` | string or null |  |  |
| `selector` | string or null |  |  |
| `elementText` | string or null |  |  |
| `createdAt` | string |  |  |
| `resolvedAt` | string or null |  |  |
| `author` | object |  |  |

Example call:

```json
{
  "name": "add_comment",
  "arguments": {
    "slug": "qkjgjQCQGz",
    "parent_id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
    "body": "Fixed in v2: the toggle now defaults to Annual."
  }
}
```

Result:

```json
{
  "id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
  "parentId": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
  "body": "Fixed in v2: the toggle now defaults to Annual.",
  "markEmoji": null,
  "selector": null,
  "elementText": null,
  "createdAt": "2026-10-27T10:02:00.000Z",
  "resolvedAt": null,
  "author": {
    "email": "agent-owner@example.com",
    "name": "Ada"
  }
}
```

The full description, as `tools/list` sends it to the model:

```text
Use to reply in a review thread to say what you changed ("Fixed in v3: moved the CTA above the fold"), or to pin new feedback to an element of a shared document; replies need parent_id, top-level comments need selector + rect; it emails people, so don't use it for progress chatter (react_to_comment acknowledges silently). The comment is visible to the document's workspace members, and Ollav emails the document owner (and, for a reply, everyone in the thread) a notification naming this key as the agent. Top-level comments require selector + rect to anchor the pin to an element, and may set mark_emoji to show an emoji on the page instead of the numbered pin; with mark_emoji the body may be empty (a reaction pinned to the element). Replies require only parent_id and a body.
```

### `resolve_comment` {#resolve_comment}

**Resolve or reopen a thread.** Use after you've fixed what a thread asked for, typically right after publish_new_version: marks the top-level comment resolved (or with unresolve: true reopens it); only the comment's author or the document owner can, and a reply via add_comment saying what changed is good practice.

**When not to use it.** Don't resolve a thread you haven't addressed, and don't call it on a reply (only top-level comments resolve).

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `comment` |
| Aliases | none |
| REST equivalent | [`POST /api/comments/{id}/resolve`](https://ollav.app/docs/api#resolveComment), [`POST /api/comments/{id}/unresolve`](https://ollav.app/docs/api#unresolveComment) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `comment_id` | string | yes | The ID of the comment to resolve or unresolve. |
| `unresolve` | boolean |  | Set to true to unresolve (re-open) an already-resolved comment. |
| `idempotency_key` | string |  | Optional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `ok` | boolean | yes |  |
| `resolved` | boolean | yes |  |

Example call:

```json
{
  "name": "resolve_comment",
  "arguments": {
    "comment_id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f"
  }
}
```

Result:

```json
{
  "ok": true,
  "resolved": true
}
```

The full description, as `tools/list` sends it to the model:

```text
Use after you've fixed what a thread asked for, typically right after publish_new_version: marks the top-level comment resolved (or with unresolve: true reopens it); only the comment's author or the document owner can, and a reply via add_comment saying what changed is good practice. Resolve or unresolve a top-level comment by its id (from get_review or get_comments).
```

### `react_to_comment` {#react_to_comment}

**React to a comment.** Use to acknowledge a comment without a reply or email, e.g. 👀 when you start on it and ✅ when it's done (one emoji; a comment holds at most 20 different reactions).

**When not to use it.** Don't use it to answer a question or explain a change: reply with add_comment.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `comment` |
| Aliases | none |
| REST equivalent | [`POST /api/comments/{id}/reactions`](https://ollav.app/docs/api#reactToComment) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `comment_id` | string | yes | The ID of the comment or reply to react to. |
| `reaction` | string | yes | One emoji, e.g. 👍 👀 ✅ ❤️ 🎉. |
| `remove` | boolean |  | Set to true to remove your reaction instead of adding it. |
| `idempotency_key` | string |  | Optional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `ok` | boolean | yes |  |
| `commentId` | string |  |  |
| `reactions` | array of object | yes |  |

Example call:

```json
{
  "name": "react_to_comment",
  "arguments": {
    "comment_id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
    "reaction": "👀"
  }
}
```

Result:

```json
{
  "ok": true,
  "commentId": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
  "reactions": [
    {
      "reaction": "👀",
      "count": 1
    }
  ]
}
```

The full description, as `tools/list` sends it to the model:

```text
Use to acknowledge a comment without a reply or email, e.g. 👀 when you start on it and ✅ when it's done (one emoji; a comment holds at most 20 different reactions). Add (or with remove:true, take back) your reaction to a comment or reply: any single emoji; the default bar is 👍 (agree), ❤️, 🎉, 👀 (looking at it) and ✅ (done). The old names "+1", "eyes", "check" and "heart" still work. Anyone who can comment on the document can react.
```

### `delete_comment` {#delete_comment}

**Delete a comment.** Use only when the user asks to delete a comment: permanently deletes it and, for a top-level comment, all its replies (comment author or document owner only); to close a thread you fixed, call resolve_comment instead.

**When not to use it.** Don't use it to close a thread (resolve_comment keeps the history). It can't be undone.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: true`, `openWorldHint: false` |
| OAuth scope / key scope | `comment` |
| Aliases | none |
| REST equivalent | [`DELETE /api/comments/{id}`](https://ollav.app/docs/api#deleteComment) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `comment_id` | string | yes | The ID of the comment to delete. |
| `idempotency_key` | string |  | Optional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours. |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `deleted` | boolean | yes |  |

Example call:

```json
{
  "name": "delete_comment",
  "arguments": {
    "comment_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
  }
}
```

Result:

```json
{
  "deleted": true
}
```

The full description, as `tools/list` sends it to the model:

```text
Use only when the user asks to delete a comment: permanently deletes it and, for a top-level comment, all its replies (comment author or document owner only); to close a thread you fixed, call resolve_comment instead. Returns {deleted:true} on success.
```

### `export_data` {#export_data}

**Export data (GDPR).** Use only when the user asks for a copy or export of their data (GDPR access or portability): returns a signed link to a ZIP, valid 15 minutes, to give the user; exports are rate limited, so don't call it repeatedly. scope "account" (default): the key owner's profile, memberships, owned documents with every version, and their comments and reactions — limited to this API key's workspace. scope "workspace": everything in this key's workspace (workspace owner only).

**When not to use it.** Don't use it to read documents or comments (list_documents, get_review), or to back up on a schedule.

| Property | Value |
|---|---|
| Annotations | `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: false`, `openWorldHint: false` |
| OAuth scope / key scope | `read` |
| Aliases | none |
| REST equivalent | [`POST /api/exports`](https://ollav.app/docs/api#exportData) |

**Inputs**

| Argument | Type | Required | Description |
|---|---|---|---|
| `scope` | `account` \| `workspace` |  | What to export. Defaults to "account". |

**Output** (`structuredContent`; the same JSON is the last text block)

| Field | Type | Always present | Description |
|---|---|---|---|
| `scope` | string |  |  |
| `downloadUrl` | string | yes |  |
| `expiresAt` | integer | yes |  |
| `partial` | boolean |  |  |

Example call:

```json
{
  "name": "export_data",
  "arguments": {
    "scope": "account"
  }
}
```

Result:

```json
{
  "scope": "account",
  "downloadUrl": "https://app.ollav.app/api/exports/download?token=...",
  "expiresAt": 1793146500000,
  "partial": false
}
```

The full description, as `tools/list` sends it to the model:

```text
Use only when the user asks for a copy or export of their data (GDPR access or portability): returns a signed link to a ZIP, valid 15 minutes, to give the user; exports are rate limited, so don't call it repeatedly. scope "account" (default): the key owner's profile, memberships, owned documents with every version, and their comments and reactions — limited to this API key's workspace. scope "workspace": everything in this key's workspace (workspace owner only). Returns {downloadUrl, expiresAt}: a signed link, valid 15 minutes, that streams the ZIP; manifest.json inside lists every item. Needs the read scope.
```

## Errors {#errors}

Every error carries the same typed error object as the REST API: `type`, `code`, `message`, `param` (when one argument is at fault), `hint` (what to do next, written for an agent to act on), `doc_url` (the code's anchor on [the errors page](https://ollav.app/docs/errors)) and, when a REST request ran, `request_id`.

**Tool errors** (the tool ran and failed) are results with `isError: true`. The first text block is one sentence to act on; the second is the JSON:

```json
{
  "isError": true,
  "content": [
    { "type": "text", "text": "Ollav returned 404 NOT_FOUND: Document not found. What to do: Check the slug or id. ... Details: https://ollav.app/docs/errors#NOT_FOUND" },
    { "type": "text", "text": "{\"error\":{\"type\":\"not_found_error\",\"code\":\"NOT_FOUND\",\"message\":\"Document not found\",\"hint\":\"Check the slug or id. ...\",\"doc_url\":\"https://ollav.app/docs/errors#NOT_FOUND\",\"request_id\":\"req_6f1c2a9b0d4e8f7a1b2c3d4e\"}}" }
  ]
}
```

**Protocol errors** (no valid key, an unknown tool or method, a malformed message) are JSON-RPC errors. The error object is in `error.data`:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32001,
    "message": "Invalid or missing Ollav API key: this key was not accepted. ...",
    "data": { "type": "authentication_error", "code": "AUTH_REQUIRED", "message": "This API key or OAuth token was not accepted.", "param": "Authorization", "hint": "The key is mistyped, revoked ...", "doc_url": "https://ollav.app/docs/errors#AUTH_REQUIRED" }
  }
}
```

Match on `code`. Don't retry `invalid_request_error`, `authentication_error`, `permission_error` or `not_found_error` unchanged; retry `rate_limit_error` and `api_error` once, after waiting, with the same `idempotency_key`.

## Troubleshooting MCP {#troubleshooting}

| Symptom | Code | Fix |
|---|---|---|
| The client can't connect, or lists no tools |  | The URL must be `https://app.ollav.app/mcp` with the Streamable HTTP transport (`http`, not `sse`). `GET /mcp` answers 405 on purpose: the server is stateless and only takes POST. Run the curl check under [Verify the connection](#verify). |
| HTTP 401, JSON-RPC error `-32001`: "no key was sent" | [`AUTH_REQUIRED`](https://ollav.app/docs/errors#AUTH_REQUIRED) | The client sent no credential. Check the `Authorization: Bearer ol_test_...` header is in the config and the environment variable it reads is set where the client runs (GUI apps don't see your shell's exports). A client that can't send headers can use `https://app.ollav.app/mcp/ol_test_...`, or OAuth. |
| HTTP 401, JSON-RPC error `-32001`: "this key was not accepted" | [`AUTH_REQUIRED`](https://ollav.app/docs/errors#AUTH_REQUIRED) | The key is mistyped, revoked (rotating a key revokes the old one), or its user left the workspace. Create a new key at https://app.ollav.app/keys and update the config; an OAuth client should reconnect. Don't retry with the same key. |
| A tool returns `403 SCOPE_REQUIRED` | [`SCOPE_REQUIRED`](https://ollav.app/docs/errors#SCOPE_REQUIRED) | The key lacks the scope that tool needs (each tool's section lists it). Create a key with `upload`, `read` and `comment`, or reconnect OAuth and grant them. |
| A tool returns `404 NOT_FOUND` for a slug or comment you know exists | [`NOT_FOUND`](https://ollav.app/docs/errors#NOT_FOUND) | Test and live don't mix: a test key (`ol_test_`) only sees documents it published in its sandbox, and a live key never sees those. A key also only sees its own workspace. Test documents are deleted 30 days after upload. Call `list_documents` with the same key to see what it can reach. |
| A tool returns `404 ARCHIVED` | [`ARCHIVED`](https://ollav.app/docs/errors#ARCHIVED) | The document was archived (by hand or because it expired). Restore it with `POST /api/documents/{slug}/unarchive`, or share it again. |
| A tool returns `429 RATE_LIMITED` | [`RATE_LIMITED`](https://ollav.app/docs/errors#RATE_LIMITED) | Every account may publish (`share_html` and `publish_new_version` together) 30 times an hour and post 120 comments and replies an hour, with looser limits per IP address; a test key also allows 60 tool calls a minute, and exports are limited for every key. The error's `retry_after` says how many seconds to wait; then retry once with the same `idempotency_key`. Don't retry in a loop. Live keys also follow the plan's daily limit. |
| Every call returns `403 ACCOUNT_SUSPENDED` (HTTP 403, JSON-RPC error `-32001`) | [`ACCOUNT_SUSPENDED`](https://ollav.app/docs/errors#ACCOUNT_SUSPENDED) | The account was suspended for breaking the Terms of Service, so all of its keys and connections are refused. Stop and tell the user; another key of the same account won't work. |
| `publish_new_version` returns `410 DOCUMENT_REMOVED` | [`DOCUMENT_REMOVED`](https://ollav.app/docs/errors#DOCUMENT_REMOVED) | The document was taken down for breaking the Terms of Service; its page answers 410 and it takes no new versions. Tell the user; don't retry or re-share it. |
| A tool returns `402 PLAN_LIMIT` or `402 PLAN_READ_ONLY` | [`PLAN_LIMIT`](https://ollav.app/docs/errors#PLAN_LIMIT) | A live workspace is at (or over) a limit of its plan; `limit` says which. Nothing was changed. Tell the user and don't retry. Test keys never hit plan limits. |
| A tool returns `400 VALIDATION_ERROR` | [`VALIDATION_ERROR`](https://ollav.app/docs/errors#VALIDATION_ERROR) | An argument is missing or invalid; `param` names it (e.g. `slug is required`). Check the call against the tool's input schema (`tools/list`, or its section above) and call again with the argument fixed. |
| JSON-RPC error `-32602`: "Unknown tool" | [`VALIDATION_ERROR`](https://ollav.app/docs/errors#VALIDATION_ERROR) | The tool name isn't one this server has. Call `tools/list`. The old names `upload_document` and `new_version` still work. |
| `share_html` returns `413 TOO_LARGE`, `NOT_HTML` or `MISSING_HTML` | [`TOO_LARGE`](https://ollav.app/docs/errors#TOO_LARGE) | One self-contained HTML file up to 15 MB per call (a multipart upload with more files is `400 TOO_MANY_FILES`). Compress inlined images (WebP, downscale) and pass the page as `html` or `file`. With a shell, upload the file with curl instead. |
| A tool returns `409 IDEMPOTENCY_KEY_REUSED` | [`IDEMPOTENCY_KEY_REUSED`](https://ollav.app/docs/errors#IDEMPOTENCY_KEY_REUSED) | That `idempotency_key` was used in the last 24 hours with different arguments. Use a fresh key (e.g. a new UUID) for a new action; reuse a key only to retry the identical call. |
| A tool returns `409 IDEMPOTENCY_KEY_IN_USE` | [`IDEMPOTENCY_KEY_IN_USE`](https://ollav.app/docs/errors#IDEMPOTENCY_KEY_IN_USE) | The first call with this key is still running. Wait a second and retry with the same key and arguments; you get the first call's result. |
| A tool returns `500 INTERNAL_ERROR` or `502 PARSE_ERROR` | [`INTERNAL_ERROR`](https://ollav.app/docs/errors#INTERNAL_ERROR) | Something failed on our side. Retry once after a few seconds with the same `idempotency_key`; if it fails again, tell the user and quote `request_id`. |
| Results say "TEST MODE", and teammates can't open the link |  | You're using a test key: only its owner sees test documents. Switch the client to a live key (`ol_live_...`) to share with the team. |

Still stuck? Every REST response has a `Request-Id` header and every tool error carries `request_id`; quote it when you report a problem.

## More {#more}

- [MCP quickstart](https://ollav.app/docs/quickstart/mcp) and [curl quickstart](https://ollav.app/docs/quickstart/curl)
- [REST API reference](https://ollav.app/docs/api): every tool's REST equivalent
- [Errors](https://ollav.app/docs/errors) and the [changelog](https://ollav.app/docs/changelog)
- [This page as Markdown](https://ollav.app/docs/mcp.md), [llms.txt](https://ollav.app/llms.txt), [MCP server card](https://ollav.app/.well-known/mcp.json)
