developers

On this page

Colosseum REST API

A small REST API to CRUD your channels and blocks programmatically. All endpoints live under /api/v1.

Authentication

Create a token under Settings → API tokens. The plaintext is shown once at creation — store it somewhere safe; only its hash is kept server-side.

Send it as a bearer token on every request:

Authorization: Bearer clsm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • 401 — missing or invalid token.
  • 403 — valid token, but you don't own the resource you're trying to modify.
  • 404 — the resource doesn't exist, or it's a private channel you can't see.

A banned account's tokens stop working immediately, the same as its browser session — the rows survive, and lifting the ban restores them.

A token grants the same access its owner has: it can read public channels and anything you own, and write only what you own. "You" includes the groups you belong to — a group's channels are yours to read, and yours to write if your role there allows it.

Channels

GET /api/v1/channels

List your channels, including those owned by groups you're in. → { "channels": [...] }

Each channel carries the handle it lives under, which for a group's channel is the group's rather than yours — so its page is /{handle}/{id}.

POST /api/v1/channels

Create a channel. Owned by you unless owner names a group you manage.

{ "title": "My channel", "description": "optional", "private": false }
Field
titleRequired.
descriptionOptional.
accesspublic, open or private. Defaults to public.
ownerOptional group handle (see GET /api/v1/groups). Omit for your own.

→ 201 { "channel": { ... } }

403 if owner names a group you're only a member of: adding blocks to a group's channels is what membership buys, while starting new ones takes the owner or admin role.

GET /api/v1/channels/:id

Fetch a channel (public or owned). → { "channel": { ... } }

PATCH /api/v1/channels/:id

Update a channel you own. Partial — omitted fields are unchanged.

{ "title": "Renamed", "private": true }

→ { "channel": { ... } }

DELETE /api/v1/channels/:id

Delete a channel you own (its blocks cascade). → { "success": true }

POST /api/v1/channels/:id/nest

Add another channel as a block inside this one — the Are.na-style link. :id is the host; the body names the channel being linked.

{ "channelId": 34 }

→ 201 { "block": { ..., "type": "channel", "linked_channel_id": 34 } }

The one block type POST /channels/:id/blocks cannot produce.

You must own the host, which is stricter than adding a block to it: nesting puts a permanent link to someone's collection in your channel and notifies its owner. The linked channel only has to be visible, and a private one is a 404 rather than a 403, so this never confirms one exists. A channel cannot be nested in itself (400). Charged to your block quota.

GET /api/v1/channels/:id/members

The channel's roster. → { "members": [...] }

Readable by anyone who can see the channel — the channel page shows the same list.

POST /api/v1/channels/:id/members

Add someone by handle. Owner only.

{ "handle": "alice" }

→ 201 { "member": { ... } }

This is how a private channel gets shared: a channel an API client creates as private is otherwise a dead end until a person opens the app. The person added is notified, once — re-adding an existing member changes nothing and sends no second notice. 400 for a handle nobody has, or the channel's own owner.

DELETE /api/v1/channels/:id/members/:handle

Take someone off the roster. Owner only. → { "success": true }

DELETE /api/v1/channels/:id/members/me

Give up your own membership of a channel someone else owns. → 204, no body.

Scoped to me. Removing other people is roster management and stays in the app, where it is confirmed.

409 if you manage the channel — ownership is not a membership row, so there would be nothing to remove, and the answer is to delete the channel or hand it on. 409 if you were never a member. 404 if you cannot read the channel at all, so this never confirms that someone else's private channel exists.

GET /api/v1/channels/:id/share-links

A private channel's live share links, block links included, newest first. Channel managers only. → { "share_links": [...] }

Each link has id, label, block_id (null for a whole-channel link), block_label (what that block is called), created_at, expires_at (null for a link that never expires) and status:

  • active: the link opens what it shares.
  • expired: past expires_at.
  • block_moved: a block link whose block has moved to another channel. It works again if the block comes back.

Expired and moved links stay listed until revoked. A link also stops working while its creator, or the person who owns the channel, is banned. The URLs themselves aren't stored, so they can't be listed.

POST /api/v1/channels/:id/share-links

Make an "anyone with the link" URL for a private channel. Channel managers only. Every field is optional:

{ "block_id": 12, "label": "for Sam", "expires_in_days": 30 }

→ 201 { "share_link": { ... }, "url": "https://…/s/<token>" }

  • Whoever opens url can read the channel and every block in it, with comments and members, without an account and without being able to change anything. With block_id, the link opens that one block instead.
  • expires_in_days defaults to 30 and takes 1 to 3650. null makes a link that never expires, which keeps working for anyone it's forwarded to until it is revoked.
  • url holds the token, and this response is the only place it appears. Only a hash is stored.
  • 400 for a public or open channel, which anyone can read already. 404 for a block_id that isn't in this channel.

DELETE /api/v1/share-links/:id

Revoke a share link. It stops working on the next request. Managers of the link's channel only. → { "success": true }

Account

GET /api/v1/me

Who the token belongs to, and what it may still add.

→ { "me": { "handle", "about", "avatar_url", "created_at", "blocks": { "used", "limit" } } }

blocks is the account's block allowance: used against the limit that POST /api/v1/channels/:id/blocks refuses on once reached. A null limit is unlimited — admins, and instances that set no cap. Reading it is how a client adding a batch can tell a quota refusal apart from a transient failure before it hits one.

Every other endpoint is addressed by channel id, or by a group handle from GET /api/v1/groups, so without this a client can name every group it belongs to but not its own account.

Identity only. The user id and owner id are not returned: no endpoint accepts either, and the two are easy to mistake for one another.

GET /api/v1/resolve

Turn a Colosseum link, or a bare handle, into the ids everything else takes. ?q= accepts a full URL, a host/path, a /path, or just a handle.

→ { "owner": { "handle", "kind", "about" }, "channel": { ... }, "block": { ... } }

channel and block appear only when the link named them. A handle is a person or a group — kind says which.

Every other endpoint takes a numeric id, and GET /channels lists only your own, so without this a link — which is how a channel actually gets shared — was unusable.

The channel is authorized like any other read, so a private one you can't see is a 404 whether or not it exists. A block that isn't in the channel the link named is a 404 too.

GET /api/v1/owners/:handle/channels

The channels under a handle, whether or not you own it.

→ { "owner": { "handle", "kind", "about" }, "channels": [...] }

Scoped through the viewer exactly as the profile page is: public and open channels, plus any private ones you can already reach. GET /channels stays what it is — yours — and this is how anyone else's become reachable.

Invites and tokens

GET /api/v1/invites

The invite codes you've minted, and your allowance. → { "invites": [...], "quota": { "used", "limit" } }

Colosseum is invite-gated, so a code is how someone new gets in. quota.used counts capacity minted rather than redemptions: it moves when a code is created or revoked, not when someone signs up with one. A null limit is unlimited.

POST /api/v1/invites

Mint a code. → 201 { "invite": { ... } }

{ "max_uses": 1, "note": "for a friend" }

max_uses counts against your allowance in full the moment the code exists, not as people use it. 403 with the reason when it would exceed it.

DELETE /api/v1/invites/:code

Revoke an unused code of yours, giving its capacity back. → { "success": true }

Scoped to your own, unused codes: a spent or foreign code matches nothing and still comes back successful, rather than reporting whether it exists. The record of who joined through a used code is never touched.

GET /api/v1/tokens

Your API tokens, without their secrets. → { "tokens": [...] }

The token making the request is marked "current": true.

DELETE /api/v1/tokens/:id

Revoke one of your tokens. → { "success": true, "revoked_current": false }

Revoking the current token is allowed — it's the honest way to hand back access — and takes effect immediately, so the next call with it gets a 401.

There is no POST /api/v1/tokens. Minting a token over a token would make revocation unreliable: revoking the one you know about doesn't help if it has already made more. Creation stays at POST /api/tokens, behind a browser session.

GET /api/v1/search

Find people, channels and blocks matching ?q=. Optional ?limit=N per kind, 1–50, default 10.

→ { "profiles": [...], "channels": [...], "blocks": [...] }

Results are scoped to what the token can see: public things, plus channels you own or belong to. Nothing private leaks.

This is the way to find a block. Listing every channel and matching client-side pulls most of a collection across the wire to locate one link. To read a whole channel rather than find something in it, page GET /api/v1/channels/:id/blocks instead — search is capped, and deliberately.

PATCH /api/v1/me

Edit your own profile.

{ "handle": "alice", "about": "collects type specimens", "avatar": "https://.../me.png" }

→ { "me": { ... } }

The avatar is a URL the server fetches, not bytes — the same shape an image block takes. 409 if the handle is taken, 400 if it isn't a valid handle, 422 if the avatar can't be fetched. Replacing an avatar drops the old one's media.

Creating a profile is deliberately absent: a token can only be minted from the settings page, which redirects to onboarding when there is no profile, so no API caller can hold a token and lack one.

GET /api/v1/handles/:handle

Whether a handle is free. → { "available": true }

{ "available": null, "reason": ... } means it isn't a valid handle, which is a different answer from taken. People and groups share one namespace, so a handle a group holds is not available.

Groups

A group is a handle several people share. The channels made in it belong to the group rather than to any one account, and each member's role says what they may do with them: member adds blocks, admin also manages the channels and the roster, and the single owner can hand the group on or delete it.

GET /api/v1/groups

List the groups you're in. → { "groups": [{ "handle", "name", "about", "role", "created_at" }] }

This is how a client learns which handles it may pass as owner when creating a channel; role says which of them will be accepted.

POST /api/v1/groups

Start a group. → 201 { "group": { ... } }

{ "handle": "studio", "name": "Studio" }

You become its owner. The handle comes from the same pool people's handles do, so 409 if it's taken — check GET /api/v1/handles/:handle first.

PATCH /api/v1/groups/:handle

Rename a group or change its blurb. Owner or admin. → { "group": { ... } }

The handle itself isn't editable here: it's the group's address, and every link to its channels runs through it.

DELETE /api/v1/groups/:handle

Delete a group. Owner only. → { "success": true }

Its channels go with it, and their blocks — the channels belong to the group, so nothing is left holding them. That cascade is why this is the owner's alone rather than an admin's.

GET /api/v1/groups/:handle/members

The roster, with each member's role. Any member may read it. → { "members": [...] }

POST /api/v1/groups/:handle/members

Add someone. Owner or admin. → 201 { "member": { ... } }

{ "handle": "alice", "role": "member" }

role is member or admin; owner isn't assignable, since a group has exactly one and it changes hands through the transfer route. They're notified, once — a role change on someone already in the group sends nothing.

PATCH /api/v1/groups/:handle/members/:member

Change a member's role. Owner or admin. → { "success": true }

The owner's role can't be set here, which is what stops a group being left with nobody able to administer it.

DELETE /api/v1/groups/:handle/members/:member

Remove someone, or leave by naming your own handle. Removing anyone else takes owner or admin. The owner can't be removed — they transfer or delete.

POST /api/v1/groups/:handle/transfer

Hand the group to another member. Owner only. { "to": "alice" }

POST /api/v1/channels/:id/transfer

Move a channel to another owner — a group you administer, or back to yourself. { "to": "studio" } → { "channel": { ... } }

You must own the channel now and be able to manage where it's going. Ownership is what grants access to a private channel, so this changes who can read it. The channel's own member roster is deliberately left as it is.

Blocks

GET /api/v1/channels/:id/blocks

List a channel's blocks (public or owned), newest first. Optional ?limit=N&offset=N. → { "blocks": [...], "total": 128 }

total is the channel's whole block count, not the page's, so a client can page to the end instead of guessing: request offset=0&limit=50, then offset=50, until offset + blocks.length reaches total. Without an offset the newest limit blocks are a sample of a large channel rather than a listing of it.

POST /api/v1/channels/:id/blocks

Add a block to a channel you own. One of:

{ "type": "text",  "text": "a note" }
{ "type": "url",   "url": "https://example.com", "detect": true }
{ "type": "image", "image": "https://.../public-image.png" }
{ "type": "pdf",   "pdf": "https://.../paper.pdf" }
{ "type": "video", "video": "https://.../clip.mp4" }

Or post multipart/form-data with a file field to upload a local image, PDF or video directly. The file's own mime picks the block type — there is no type field to disagree with it. Limits are the app's: 10MB image, 25MB PDF, 100MB video, and the same accepted formats. A file that fails either check is a 422.

Media follows the channel's privacy, not yours: a file added to a private channel is stored private, and its URL needs your token (or a session) to fetch.

Optional tags on any of them: { "type": "url", "url": "...", "tags": ["reading", "css"] }.

A url is ingested as what it points at: a tweet, a YouTube video or channel, a Spotify item, a GitHub repo or account, an Instagram post, or a direct image each become that kind of block, with their details fetched and stored — the same as a link pasted into the web app. Anything else stays a plain link, and only a plain link gets a screenshot.

So the block that comes back is often not type: "url". Read the returned type rather than assuming. Pass "detect": false to force a plain link block — worth it if you want the screenshot card, or if the link's host is slow and you'd rather not wait on the lookup.

When a lookup fails — a deleted tweet, a rate-limited GitHub, an unreachable host — the block falls back to a plain link rather than failing the request.

→ 201 { "block": { ..., "preview": null } }

A url block's preview screenshot captures in the background — the create call returns immediately with preview: null and doesn't block on it. Poll GET /api/v1/blocks/:id and watch preview:

  • null — still capturing (or not triggered yet). Keep polling.
  • { "failed": true } — capture ran and failed permanently (dead site, DNS failure, etc). Stop polling.
  • { "image_url": "...", "title": "..." } — captured successfully.

Skipped entirely if another block already has a cached preview for the same URL. text and image blocks always have preview: null.

GET /api/v1/blocks/:id

Fetch a block (visible if its channel is public or owned). → { "block": { ... } }

PATCH /api/v1/blocks/:id

Update a block in a channel you own. Editable fields by type: title, description, plus text / url / image for that block's type, and tags on any type. A pdf or video block takes title, description and tags only — the stored file isn't replaceable in place, a new one is a new block. → { "block": { ... } }

tags replaces the whole list rather than adding to it, so send the tags you want to keep; [] clears them.

Tags

Tags are alphanumeric with dashes. Spaces become dashes, anything else is dropped, and duplicates are removed — ["design systems", "#css!"] is stored as ["design-systems", "css"]. The board filters tags on an exact match, so a tag stored in any other shape would match nothing and could not be removed from the editor either; the same rule runs in both places (lib/tags.ts).

POST /api/v1/blocks/:id/copy

Put a copy of a block in another channel, leaving the original where it is.

{ "channelId": 12 }

→ 201 { "block": { ... } }

Asymmetric with a move, and looser: copying only reads the source, so any block in a channel you can see may be copied, while the target must be one you can contribute to. A move needs ownership of both, because it takes the block away from where it was.

The copy is a new block with its own id and, for an uploaded image or PDF, its own media reference minted under the target channel's privacy — so deleting either block leaves the other whole, and a copy into a private channel doesn't keep pointing at public media. A copy is charged to your block quota.

PUT /api/v1/blocks/:id/position

Place a block in its channel's manual order. → { "block": { ... } }

{ "after": 41 }
{ "after": null }

after is the id of the block this one should sit behind, or null to put it first. Both blocks must be in the same channel; an anchor from elsewhere is a 404, since a position key only orders a block against its own channel's.

Required, not optional — a missing after is a 400 rather than being read as null, so forgetting the field can't silently send a block to the top.

Channel owners only, which is stricter than PATCH /api/v1/blocks/:id: a contributor may edit the block they added, but a reorder rearranges everyone's. That is why it is its own endpoint rather than a field on the PATCH.

DELETE /api/v1/blocks/:id

Delete a block in a channel you own. → { "success": true }

Notifications

GET /api/v1/notifications

What has happened to your account. Optional ?before= (the at of the last one you saw) and ?unread=true.

→ { "notifications": [...], "unread": 3, "page_size": 30 }

unread is the whole count, not the page's, so a client can show a badge without paging to the end. Reading the list marks nothing.

POST /api/v1/notifications/:id/read

Mark one read. → { "success": true }

Scoped to you as the recipient, so someone else's id matches nothing and comes back successful rather than reporting whether it exists.

POST /api/v1/notifications/read-all

Mark every unread one read. → { "success": true }

GET / PATCH /api/v1/notifications/preferences

Which kinds of notification are also emailed.

{ "type": "comment", "enabled": false }

→ { "email_notifications": { ... } }

One kind per call: the preferences are a single JSON column, so a partial write of the whole object would drop whichever keys were left out. The in-app notification arrives either way — this only decides whether it is also mailed.

Comments

GET /api/v1/blocks/:id/comments

What has been said about a block. → { "comments": [...] }

Visible to anyone who can read the block.

POST /api/v1/blocks/:id/comments

Leave a comment. → 201 { "comment": { ... } }

{ "body": "the second half of this is the good part" }

Any reader may comment, not only the channel's owner. @handle in the text notifies that person — but only if they can read the channel, so a mention can never hand a private channel's contents to someone outside it. Empty or over-length is a 400.

DELETE /api/v1/comments/:id

Remove a comment. → { "success": true }

Its author always may; otherwise the block's channel owner may moderate it. Addressed by comment id, which is what the list hands back.

Admin

For self-hosters. Every route here is 404 for a non-admin rather than 403 — an ordinary token has no reason to learn the admin surface exists.

GET /api/v1/admin/users

Every account, with its limits and flags. → { "users": [...] }

PATCH /api/v1/admin/users/:id

Ban, promote, or set limits. → { "success": true, "updated": [...] }

{ "banned": true }
{ "is_admin": true }
{ "invite_limit": 5, "column_limit": null }

The limits are written as a pair, so send both or neither — one alone would reset the other. null is unlimited. An admin can't be banned and the last admin can't be demoted; both come back 400 with the reason.

A ban doesn't end that user's sessions or revoke their API tokens. That's existing behaviour, worth knowing before relying on this to cut someone off quickly.

GET /api/v1/admin/settings

Instance settings. → { "settings": { ... } }

Mail credentials are redacted to "__set__" or "": you can see whether a provider is configured, not what the key is. The admin page reads the real values behind a browser session; a bearer token is longer-lived and may sit in an agent's context.

PATCH /api/v1/admin/settings

Set the default per-user limits. null is unlimited.

{ "max_invites_per_user": 5, "max_columns_per_user": null }

Email configuration isn't settable here — it would mean accepting secrets over the API and being able to repoint the instance's outbound mail.

DELETE /api/v1/admin/blocks/:id and /admin/channels/:id

Moderation: remove something you don't own. → { "success": true }

A private channel, or a block inside one, is a 404. Moderation covers what is public; being an admin is not a way into someone's private collection.

Example

TOKEN="clsm_..."
BASE="https://your-host/api/v1"

# Create a channel
cid=$(curl -s -X POST "$BASE/channels" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Reading list","private":false}' | jq '.channel.id')

# Add a link block
curl -s -X POST "$BASE/channels/$cid/blocks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"type":"url","url":"https://are.na"}'

# List the channel's blocks
curl -s "$BASE/channels/$cid/blocks" -H "Authorization: Bearer $TOKEN"

Colosseum MCP Server

Colosseum exposes an MCP server so an MCP client (Claude Desktop, Claude Code, Cursor, etc.) can manage your channels and blocks — "add this link to my reading list," "summarize my design-inspo channel," and so on.

It's a remote MCP server: it runs in-process as part of the Next.js app (app/api/[transport]/route.ts), using the Streamable HTTP transport. There's nothing extra to deploy — if you can reach your Colosseum instance, you can reach its MCP server. Self-hosting via the included Dockerfile gets you MCP for free, with no extra container or process.

Connecting a client

  1. Create a token under Settings → API tokens. The plaintext is shown once — store it somewhere safe.
  2. Point your MCP client at https://your-host/api/mcp with that token as a bearer token. The exact config shape depends on the client:

Claude Code (claude mcp add or .mcp.json):

{
  "mcpServers": {
    "colosseum": {
      "type": "http",
      "url": "https://your-host/api/mcp",
      "headers": {
        "Authorization": "Bearer clsm_..."
      }
    }
  }
}

Claude Desktop (claude_desktop_config.json) — Desktop's built-in connector UI ("Settings → Connectors → Add custom connector") will prompt for the URL and let you add the header; the underlying config looks the same as above, under mcpServers.

For local development, use http://localhost:3000/api/mcp.

Authentication

Same token, same rules as the REST API: Authorization: Bearer clsm_.... A token grants the same access its owner has — it can read public channels and anything it owns, and write only what it owns. "Owns" includes the groups you belong to, up to the role you hold in each. An invalid or missing token gets a 401 before any tool runs, as does one belonging to a banned account.

Tools

One tool per REST endpoint, plus move_block, which has no REST equivalent. Reads are visible if a channel is public or you own it; writes require ownership. A group's channels count as yours on both counts.

ToolEquivalentNotes
whoamiGET /api/v1/meYour handle, and the block allowance create_block refuses on.
list_channelsGET /api/v1/channelsYours and your groups', each with the handle it lives under.
list_groupsGET /api/v1/groupsThe groups you're in, with your role in each.
leave_channelDELETE /api/v1/channels/:id/members/mechannelId. Not one you manage.
create_share_linkPOST /api/v1/channels/:id/share-linkschannelId, optional blockId, label, expiresInDays (null: never). Private channels only.
list_share_linksGET /api/v1/channels/:id/share-linkschannelId. Ids, labels and expiry; not the URLs.
revoke_share_linkDELETE /api/v1/share-links/:idid.
create_channelPOST /api/v1/channelstitle, optional description, private, owner.
get_channelGET /api/v1/channels/:idid.
update_channelPATCH /api/v1/channels/:idid + any of title/description/private.
delete_channelDELETE /api/v1/channels/:idid. Cascades to the channel's blocks.
list_blocksGET /api/v1/channels/:id/blockschannelId, optional limit.
create_blockPOST /api/v1/channels/:id/blockschannelId, type + the matching field, optional tags, detect.
get_blockGET /api/v1/blocks/:idid.
update_blockPATCH /api/v1/blocks/:idid + editable fields for that block's type, and tags.
delete_blockDELETE /api/v1/blocks/:idid.
move_block—id, channelId. You must own both channels.

create_block/update_block field rules match the REST API: exactly one of text/url/image for creation (matching type), and only that type's field (plus title/description) on update. A url block's preview screenshot captures in the background, exactly as it does over REST: the tool returns immediately with preview: null, and get_block reports the same three states the REST API documents — null while capturing, { "failed": true } after a permanent failure, { "image_url", "title" } once it lands.

create_block also takes pdf and video, each a URL the server fetches. There is no way to hand bytes to a tool: a tool call is JSON, and base64 inflates by a third, which on a 100MB video is the whole context window. A REST client with a local file posts multipart/form-data to POST /api/v1/channels/:id/blocks instead.

A url passed to create_block is ingested as what it points at — tweet, YouTube video or channel, Spotify item, GitHub repo or account, Instagram post, direct image — each becoming that kind of block with its details fetched and stored, exactly as a link pasted into the web app does. Anything else stays a plain link with a screenshot, and a failed lookup falls back to one too. The returned block's type is therefore often not url; detect: false forces a plain link.

tags are accepted on both and replace the whole list, so pass the ones you want to keep — [] on update_block clears them. They are alphanumeric with dashes: spaces become dashes, anything else is dropped, and duplicates are removed. The board filters on an exact match, so a tag written in any other shape would match nothing, which is why the rule is shared with the editor (lib/tags.ts) rather than restated here.

move_block only rewrites the block's channel_id, so the block keeps its id, created_at, tags, content, and any cached screenshot. update_block can't do this (channel_id isn't editable), and create-then-delete drops the timestamp and the screenshot.

copy_block is the other half of that pair: it leaves the original in place and makes a new block, with its own id and its own media reference, so the two can be deleted independently. It also asks less — read on the source rather than ownership — so a block from anyone's channel you can see may be copied into yours.

There is no tool to mint an API token, and that is deliberate rather than an oversight: a token able to mint tokens makes revocation unreliable, since revoking the one you know about does nothing about the ones it already made. Creating a token stays in the web app. Listing and revoking are here, because they only ever take capability away.

delete_group takes a confirm flag the other tools don't. Deleting a group deletes every channel it owns and every block in them, and nothing else in this API cascades that far — the flag is there so a model has to mean it.

A failed call (bad input, not found, not yours) comes back as an MCP tool error (isError: true) with a human-readable message — the agent sees it and can retry or explain, rather than the server crashing.

Design

The MCP route calls the same data-access layer (lib/colosseum/*) and authorization helpers (lib/colosseum/api-auth.ts) as the REST API, so there's one source of truth for "what can this token do" — no separate auth path to keep in sync. It uses mcp-handler to bridge the MCP SDK's Streamable HTTP transport into a Next.js route handler.

This intentionally differs from an earlier stdio-based design (spawn a local script per client, talking to the REST API over HTTP): folding MCP into the app itself means self-hosters don't run or deploy anything beyond the app they already have, and any client can connect over the network instead of needing a local checkout of this repo.