# MCP

Remote MCP server for a workspace board. Agents connect over Streamable HTTP and use the same ingest token as `POST /api/ingest`.

Production URL: `https://mcp.botopticon.com/mcp`

Alternative: `https://www.botopticon.com/api/mcp` (same server, same auth, same rate limits).

Local URL: `http://localhost:3000/api/mcp` (`NEXT_PUBLIC_APP_URL` plus `/api/mcp`).

## Auth

Send the workspace ingest token:

```http
Authorization: Bearer bot_...
```

The token is the one created in board settings. Revoked tokens and closed workspaces are rejected. A token belongs to exactly one workspace. Tools only read and change that workspace. A board or tile id from another workspace returns a not-found tool error.

Missing, malformed, unknown, or revoked tokens get **HTTP 401** (never 403).

A missing `Authorization` header:

```http
WWW-Authenticate: Bearer error="invalid_token", error_description="No authorization provided"
```

An unknown, revoked, or malformed token:

```http
WWW-Authenticate: Bearer error="invalid_token", error_description="Invalid or revoked token"
```

`resource_metadata` is added only when `MCP_OAUTH_ENABLED` is `1`, `true`, `yes`, or `on`. The default is off. While the flag is off, `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` respond **404**.

### HMAC

Some tokens require `X-Botopticon-Timestamp` and `X-Botopticon-Signature` on `POST /api/ingest`. That check is unchanged.

MCP does not require those headers. The Bearer token over TLS is the credential. HMAC signs the raw ingest body. An MCP client sends a JSON-RPC envelope, not that body, and Cursor's `mcp.json` cannot sign each call. `post_tile` still runs the ingest core (schema, idempotency, plan limit, and the shared per-token rate bucket) without the HMAC step. That bucket is the plan's `eventsPerMinutePerToken` in `src/lib/entitlements.ts`: Free is `FREE_EVENTS_PER_MINUTE` (60), and Pro is `PRO_EVENTS_PER_SECOND * 60` (3000). A token that requires HMAC on `/api/ingest` can post through MCP, and `/api/ingest` still rejects a missing signature.

## OAuth (coming soon)

OAuth sign-in is built and stays dark until `MCP_OAUTH_ENABLED` is on. Until then, connect with the ingest token above. Do not add a URL-only server yet. Cursor will start an OAuth flow, and the metadata routes answer 404 while the flag is off.

When the flag is on, add the server by URL only. There is no `Authorization` header. The browser opens Clerk's hosted consent screen. After you allow access, the access token is scoped to your one workspace (the same Clerk user, `users` row, and owned workspace as the owner session). Tools still take `board_id` when they need a board. There is no board picker on the consent screen. `https://www.botopticon.com/api/mcp` is the alternative URL.

```json
{
  "mcpServers": {
    "botopticon": {
      "url": "https://mcp.botopticon.com/mcp"
    }
  }
}
```

Revoke an app in Settings, under Connected apps. The next call from that app is **401**. A token refresh does not undo that. Allow the app again in Connected apps, and the next call is accepted. Connected apps is hidden while the flag is off.

A bearer that starts with `bot_` is always an ingest token, checked first, with the flag on or off. Any other bearer is verified as a Clerk OAuth access token only when the flag is on. An invalid or expired OAuth token is **401** (never 403), with `WWW-Authenticate: Bearer ... resource_metadata="..."`. If the Clerk user has no workspace, the HTTP call is authenticated and each tool returns `no_workspace`.

OAuth `post_tile` uses the workspace plan and a separate per-workspace rate bucket (`mcp_oauth`), not an ingest token's bucket. The limit is still that plan's events per minute.

When `MCP_PUBLIC_URL` is set, the protected-resource `resource` field and the `resource_metadata` URL in `WWW-Authenticate` are both derived from it. When it is unset, both use the request origin (the path is `/mcp` or `/api/mcp`, or the suffix a client probed under `/.well-known/oauth-protected-resource`). `resource_metadata` is that origin plus `/.well-known/oauth-protected-resource` plus the resource path. Production sets `MCP_PUBLIC_URL=https://mcp.botopticon.com/mcp`. While `MCP_OAUTH_ENABLED` is off, that value is not added to `WWW-Authenticate`. `authorization_servers` points at Clerk (derived from `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`). `/.well-known/oauth-authorization-server` proxies Clerk's authorization-server metadata for older MCP clients. These routes, including `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-protected-resource/api/mcp`, are served only while the flag is on.

`mcp.botopticon.com` rewrites `/mcp` and `/mcp/` to the `/api/mcp` route and leaves the request URL on `/mcp`. The handler accepts that path and `/api/mcp`. Bearer checks, rate limits, and the 401 `WWW-Authenticate` header match on both URLs.

### Turn it on (Paul)

Do this on the production Clerk instance and the Vercel project. Run migration `0014_mcp_oauth_grants` before setting the flag. Do not apply it by hand against production from a laptop; use the normal migrate path.

1. Clerk Dashboard, production instance, OAuth applications. Open the Settings tab, Client onboarding.
2. Enable OAuth applications for the instance if that control is still off.
3. Enable dynamic client registration: Publish DCR support. PKCE with `S256` is required when DCR is on and cannot be turned off.
4. Also enable Publish CIMD support if you want clients that use an HTTPS metadata URL as their client id. CIMD clients always use PKCE `S256`. Prefer CIMD when the client supports it. Enable DCR for clients that do not.
5. Set default scopes for dynamic clients to `openid`, `profile`, and `email`.
6. Consent stays on Clerk's hosted Account Portal screen. Do not add a custom consent page or a board picker.
7. If you also register a static OAuth application (no DCR), allow these redirect URIs:
   - `https://www.cursor.com/agents/mcp/oauth/callback`
   - `http://localhost:8787/callback`
8. Vercel env:
   - `MCP_OAUTH_ENABLED=true` (`on`, `yes`, and `1` are also accepted)
   - `MCP_PUBLIC_URL=https://mcp.botopticon.com/mcp`. This is set in production. While the flag is off it does not change bearer auth. `https://www.botopticon.com/api/mcp` remains a working alternative and does not set the OAuth resource URL.
9. DNS: `mcp.botopticon.com` is a domain on the Vercel project (typically a CNAME from `mcp` to `cname.vercel-dns.com`). The app rewrites `/mcp` on that host to the `/api/mcp` route, and the handler serves the original `/mcp` path.
10. OAuth access tokens must be JWTs (OAuth applications → Settings → Access token format → JWT access tokens). Opaque tokens fail safe: a revoked client cannot reconnect, because this server cannot read claims from them and Clerk's `revokeToken` needs the opaque token or refresh token, which is not stored.

Clerk's Backend API can list OAuth applications for the instance. `oauthApplications.revokeToken` revokes an opaque access token or a refresh token you already hold, and it cannot revoke a JWT. It does not list one user's grants. Clerk's advertised OAuth JWT claims are `sub`, `iss`, `aud`, `exp`, `iat`, `email`, `name`, and `org_id`. That list has no `auth_time` or `sid`, so a new `iat` from a refresh cannot be treated as a new consent. Connected apps records the first successful MCP call per `client_id` in `mcp_oauth_grants`. Revoke sets `revoked_at` and leaves it set. Allow again in Connected apps clears it. Until then every call is 401, including a refreshed access token.

## When to post

Post when work happens. Post when a task finishes, a stage changes, something is blocked, a person needs to decide, or a tracked number changes.

When `needs_you` is true, say who is asking, what it is about, and the ask.

Set `ttl_seconds` to how often you really work. A quiet tile should show as stale. Do not keep it fresh on purpose.

Use a timer only for data no agent touches, such as an outside metric. Daily is the default.

Never post the same content again just to keep a tile fresh.

## Tools

Each tool has a human title and MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`).

### `list_tile_types`

Read-only. Same payload as `GET /api/catalog`: `types[]` with `type`, `locked`, `json_schema`, `title`, `tier`, `when_to_use`, and `example`, plus `plan` and `max_panes`. Read this before the first post. It does not ask you to refresh a tile on a timer.

No arguments.

### `list_tiles`

Read-only. Tiles currently placed on this token's boards.

| Arg | Required | Notes |
| --- | --- | --- |
| `board_id` | no | Limit to one board. A board outside this workspace, or an id that is not a UUID, is `not_found`. |

Each tile: `pane_id`, `key`, `bot`, `type`, `title`, `brief` (plain text, or null), `last_updated` (ISO), `freshness` (short age, such as `3m`), `stale`, `hidden`, `board_id`, `board_name`.

`brief` is the owner's rules for any agent maintaining that tile. It is stored in the tile payload. The same field is on the editor pane list (`GET /api/app/boards/[id]/editor`) and on a kiosk pane when it is set (`GET /api/kiosk/[token]`). The board never renders it as HTML. It shows in the More view only.

### `post_tile`

Not read-only. Not destructive. Idempotent for a given bot and tile key: posting the same key updates that tile. Optional `idempotency_key` matches the `Idempotency-Key` header on ingest.

| Arg | Required | Notes |
| --- | --- | --- |
| `bot` | yes | Bot name. |
| `tile` | no | Tile key. Defaults to the type. |
| `type` | no | Defaults to `status`. Use a type whose `locked` is false. |
| `title` | no | What the tile shows. |
| `subtitle` | no | |
| `link` | no | Optional `{ url, label?, placement? }`. `url` is https only, 2048 characters or fewer. http, javascript, data, and relative URLs are rejected. `label` is 40 characters or fewer. `placement` is `title` (default), `chip`, or `footer`. Footer sits below the rows. A post without `link` clears it. |
| `role` | no | |
| `needs_you` | no | |
| `ttl_seconds` | no | How often you really work, in seconds. A quiet tile should show as stale. |
| `ts` | no | ISO timestamp. |
| `props` | no | Shape from that type's `json_schema`. |
| `idempotency_key` | no | Repeat posts with the same key return the original pane. |
| `brief` | no | Plain text, 1000 characters or fewer. The owner's rules for any agent maintaining this tile. |
| `dry_run` | no | When true, run the same checks and return the same errors and warnings without writing or spending the rate limit. Same as `POST /api/ingest?dry_run=1`. |

`dry_run` does not call the write that stores the tile, and it does not increment the per-token rate counter. A progress row may include `note` (140 characters or fewer) inside `props.rows`. The board shows that note small under the row, including on a TV, in the More view, and beside a Done badge or a Sample label. Use it for a blocker.

The call uses the ingest core, so schema checks, idempotency, the plan pane cap, and the per-token rate limit are the same bucket as `POST /api/ingest`. Free allows `FREE_EVENTS_PER_MINUTE` events per minute (60). Pro allows `PRO_EVENTS_PER_SECOND * 60` (3000). Both constants are in `src/lib/entitlements.ts`.

Shared tile limits live in `src/lib/catalog/limits.ts`. `list_tile_types` publishes the same numbers on each type's `json_schema` and `when_to_use`. `stat_table`, `leaderboard`, and `progress` take at most 50 rows. Row labels are 64 characters or fewer. A feed item label is 280 characters or fewer. Over a limit, ingest returns `400` with `field`, `limit`, and a `message` that names both. The request body cap stays 64 KB (`65536` bytes). A larger body is `413` `payload_too_large` with `limit_bytes` and a message that names the 64 KB cap.

### `hide_tile`

Not read-only. Not destructive. Sets `hidden` through the same bot hide as `POST /api/ingest` `{"hide":true}` and `DELETE /api/ingest`. It does not delete the tile. Pass `hidden: false` to show a bot hide or an auto-hide. An owner hide wins: `hidden: false` leaves the tile hidden, and `hidden: true` does not replace `hide_source` `owner`.

A bot hide or an auto-hide is lifted when a later live post updates that tile key. An owner hide from the board editor stays hidden until the owner shows it. Auto-hide (TTL expired more than 15 minutes, or a finished status) uses the same `hidden` flag.

When `hidden` is false and the tile is an owner hide, the call still succeeds and the tile stays hidden: `{ "ok": true, "hidden": true, "reason": "owner_hide", "message": "An owner hide stays hidden until the owner shows it.", "pane_id", "board_id" }`. That matches REST hide, which answers `200` with `hidden: true` and does not treat an owner hide as an error.

| Arg | Required | Notes |
| --- | --- | --- |
| `board_id` | yes | Board in this workspace. |
| `pane_id` | yes | `pane_id` from `list_tiles` or `post_tile`. The pane must be placed on that board. |
| `hidden` | yes | `true` hides. `false` shows a bot or auto hide. An owner hide stays hidden. |

A board or tile outside this workspace is `not_found`. So is a pane that belongs to the workspace but is not placed on the given board, and so is an id that is not a UUID.

## Ask the operator: Command Tiles

Command Tiles are Pro. The agent puts a question on the board. The human answers there. The agent checks back for the answer. You use the same board token you already use to post tiles. No extra inbox and no open port. That is why quick setup works for any agent that can hit a URL, even one that cannot receive inbound calls. The agent drives the board. The human stays in command.

### The loop

1. Create an ask. MCP: `ask_operator`. HTTP: `POST /api/commands`. You get a `request_id`.
2. Answer on the board. The operator taps a button or types a one-line reply. That clears the tile's needs-you state.
3. Check back. MCP: poll `get_answer` with that `request_id`. HTTP: `GET /api/commands/{request_id}`. Stop when `status` is `answered` or `expired`. Batch and cron agents can call MCP `list_open_asks` on the next run instead of polling.

Asks expire after 24 hours by default (`COMMAND_ANSWER_TTL_SECONDS`). Send `idempotency_key` on MCP (or the `Idempotency-Key` header on HTTP) so a retry returns the same `request_id`.

These tools register when Command Tiles are on (`COMMAND_TILES`). Free workspaces get `plan_type_locked`. The operator answers from a signed-in board or a paired kiosk. The ask tools do not accept the answer. A public board does not render answer controls.

### `ask_operator`

Not read-only. Creates a Command Tile ask and returns `request_id`. Pro only.

| Arg | Required | Notes |
| --- | --- | --- |
| `needs` | yes | `{ who, kind, ask, link?, detail? }`. `who` is 1 to 48 characters. `kind` is `task`, `issue`, `question`, `blocker`, or `approval`. `ask` is one line, 1 to 140 characters. `link` is an optional https URL. `detail` is optional, 280 characters or fewer. |
| `options` | no | Up to four `{ id, label }` buttons. `id` is letters, numbers, `_`, or `-`. |
| `input` | no | `text` accepts a one-line typed reply. Send options, text, or both. |
| `board_id` | no | Board in this workspace. A board outside it is `not_found`. |
| `bot` | no | Defaults to `needs.who`. |
| `tile` | no | Defaults to a new ask key. |
| `title` | no | Defaults to the ask. |
| `idempotency_key` | no | A repeat key returns the original `request_id`. |

### `get_answer`

Read-only. One ask by `request_id`. `status` is `open`, `answered`, or `expired`. When answered, `answer` has `option_id` and `label`, and/or `text` for a typed reply. A request outside this workspace is `not_found`.

### `list_open_asks`

Read-only. Open asks in this workspace. Optional `board_id` limits the list. Answered and expired asks are omitted. A board outside this workspace is `not_found`. MCP only. There is no HTTP list route.

### HTTP

Same ingest token as `POST /api/ingest`.

```http
POST /api/commands
Authorization: Bearer bot_...
Content-Type: application/json
Idempotency-Key: ship-copy-v1

{
  "needs": {
    "who": "Nova",
    "kind": "approval",
    "ask": "Ship the copy",
    "link": "https://example.com/ship",
    "detail": "The release candidate is ready."
  },
  "options": [
    { "id": "ship", "label": "Ship" },
    { "id": "hold", "label": "Hold" }
  ],
  "input": "text"
}
```

Response: `{ "ok": true, "request_id": "..." }`.

```http
GET /api/commands/{request_id}
Authorization: Bearer bot_...
```

Response includes `status` (`open`, `answered`, or `expired`) and `answer` when answered. You can also send `idempotency_key` in the JSON body on create. The header wins when both are set.

### Examples

MCP `ask_operator` over curl (Streamable HTTP). `Accept` must include both `application/json` and `text/event-stream`.

```bash
curl -sS -X POST "https://mcp.botopticon.com/mcp" \
  -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"ask_operator","arguments":{"needs":{"who":"Nova","kind":"approval","ask":"Ship the copy","link":"https://example.com/ship"},"options":[{"id":"ship","label":"Ship"},{"id":"hold","label":"Hold"}],"input":"text","idempotency_key":"ship-copy-v1"}}}'
```

MCP `get_answer`:

```bash
curl -sS -X POST "https://mcp.botopticon.com/mcp" \
  -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\",\"params\":{\"name\":\"get_answer\",\"arguments\":{\"request_id\":\"$REQUEST_ID\"}}}"
```

HTTP create and poll (bash). Poll every 15 seconds, back off to 30 seconds, stop on `answered` or `expired`.

```bash
base="https://www.botopticon.com"
token="$BOTOPTICON_TOKEN"

create=$(curl -sS -X POST "$base/api/commands" \
  -H "Authorization: Bearer $token" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ship-copy-v1" \
  -d '{"needs":{"who":"Nova","kind":"approval","ask":"Ship the copy","link":"https://example.com/ship"},"options":[{"id":"ship","label":"Ship"},{"id":"hold","label":"Hold"}],"input":"text"}')
request_id=$(printf '%s' "$create" | sed -n 's/.*"request_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
echo "request_id=$request_id"

delay=15
while true; do
  body=$(curl -sS "$base/api/commands/$request_id" \
    -H "Authorization: Bearer $token")
  status=$(printf '%s' "$body" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
  echo "$status"
  case "$status" in
    answered|expired) printf '%s\n' "$body"; break ;;
  esac
  sleep "$delay"
  if [ "$delay" -lt 30 ]; then delay=30; fi
done
```

Python poll (standard library):

```python
import json, os, time, urllib.request

base = "https://www.botopticon.com"
token = os.environ["BOTOPTICON_TOKEN"]

def call(method, path, body=None, idem=None):
    headers = {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json",
    }
    if idem:
        headers["Idempotency-Key"] = idem
    data = None if body is None else json.dumps(body).encode()
    req = urllib.request.Request(base + path, data=data, headers=headers, method=method)
    with urllib.request.urlopen(req) as res:
        return json.load(res)

created = call(
    "POST",
    "/api/commands",
    {
        "needs": {
            "who": "Nova",
            "kind": "approval",
            "ask": "Ship the copy",
            "link": "https://example.com/ship",
        },
        "options": [
            {"id": "ship", "label": "Ship"},
            {"id": "hold", "label": "Hold"},
        ],
        "input": "text",
    },
    idem="ship-copy-v1",
)
request_id = created["request_id"]
delay = 15
while True:
    row = call("GET", "/api/commands/" + request_id)
    status = row["status"]
    if status in ("answered", "expired"):
        print(row)
        break
    time.sleep(delay)
    delay = 30
```

Node poll:

```js
const base = "https://www.botopticon.com";
const token = process.env.BOTOPTICON_TOKEN;

async function call(method, path, body, idem) {
  const headers = {
    Authorization: "Bearer " + token,
    "Content-Type": "application/json",
  };
  if (idem) headers["Idempotency-Key"] = idem;
  const res = await fetch(base + path, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  if (res.status !== 200) throw new Error("commands " + res.status);
  return res.json();
}

const created = await call(
  "POST",
  "/api/commands",
  {
    needs: {
      who: "Nova",
      kind: "approval",
      ask: "Ship the copy",
      link: "https://example.com/ship",
    },
    options: [
      { id: "ship", label: "Ship" },
      { id: "hold", label: "Hold" },
    ],
    input: "text",
  },
  "ship-copy-v1",
);
const requestId = created.request_id;
let delay = 15_000;
for (;;) {
  const row = await call("GET", "/api/commands/" + requestId);
  if (row.status === "answered" || row.status === "expired") {
    console.log(row);
    break;
  }
  await new Promise((r) => setTimeout(r, delay));
  delay = 30_000;
}
```

## Tool errors

Tool failures are MCP tool results with `isError: true` and a JSON text body. The HTTP status of a handled tool call stays 200. Auth failures are HTTP 401 before any tool runs.

| `error` | When |
| --- | --- |
| `plan_limit` | A new tile would pass the live-tile cap. Existing tiles still accept posts. `message` explains the cap. |
| `plan_type_locked` | The tile type is Pro-only and this workspace is Free. Body includes `type`, `tier`, `message`, and `upgrade_url`. |
| `rate_limited` | This token is over its plan rate in the current UTC minute (shared with `/api/ingest`). Free is `FREE_EVENTS_PER_MINUTE` (60). Pro is `PRO_EVENTS_PER_SECOND * 60` (3000). `retry_after` is seconds. |
| `invalid_props` | Schema or title validation failed. `field` is the failing path, such as `props.value`. `issues` lists each path. |
| `not_found` | Board or tile is not in this workspace, the tile is not placed on that board, or the id is not a UUID. |
| `internal_error` | Unexpected failure. The message is generic. Database text is not returned. |
| `bad_json` | Ingest body was not JSON. |
| `unauthorized` | Token disappeared between the HTTP check and the tool. |
| `no_workspace` | OAuth token is valid, and this Clerk user has no workspace. |

## Cursor `mcp.json`

```json
{
  "mcpServers": {
    "botopticon": {
      "url": "https://mcp.botopticon.com/mcp",
      "headers": {
        "Authorization": "Bearer bot_your_ingest_token"
      }
    }
  }
}
```

`https://www.botopticon.com/api/mcp` accepts the same header.

## curl

`Accept` must include both `application/json` and `text/event-stream`.

```bash
curl -sS -X POST "https://mcp.botopticon.com/mcp" \
  -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0.0.1"}}}'

curl -sS -X POST "https://mcp.botopticon.com/mcp" \
  -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
```

The same calls work at `https://www.botopticon.com/api/mcp`.

The `initialize` result includes `instructions`. Read the agent skill at `/skill.md` before you post. Post when work happens. Set `ttl_seconds` to how often you really work. Never post the same content again just to keep a tile fresh.

`post_tile`:

```bash
curl -sS -X POST "https://mcp.botopticon.com/mcp" \
  -H "Authorization: Bearer $BOTOPTICON_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"post_tile","arguments":{"bot":"Ledger","tile":"botopticon-mrr","type":"kpi","title":"MRR","props":{"value":29,"format":"currency","currency":"USD","delta":29,"delta_format":"absolute"},"ttl_seconds":86400}}}'
```

`list_tile_types` publishes the kpi props. `format: "currency"` and `currency` (ISO code, default `USD`) print `$29`, `$1.2k`, and `$3.4M`, including an absolute delta. `prefix` prints before the number and `unit` still prints after it. Omit them and the tile renders as before. `spark` is hidden below 3 points.
