What it is
The Model Context Protocol is how AI agents plug into outside systems. Unsession's MCP server exposes 19 tools — ten read, nine write — over the hosted service and over any instance you host yourself.
Every tool dispatches into the same functions behind the REST API and the admin screens. There is no parallel implementation to drift: accepting a talk over MCP creates the session, mints the confirmation link and sends the decision email exactly the way the Submissions page does.
- No extra service to run. The endpoint is part of the worker — no SDK, no Durable Objects, no separate process, no additional dependency.
- Stateless. One
POST, one JSON-RPC response. No SSE stream, no session id to keep alive, nothing to reconnect. - Scoped like a person. A token is read-only or read-write, and optionally restricted to one event. A read-only token cannot even see that the write tools exist.
1 · Create an API token
When you do need one, mint it in the admin:
- Sign in and open Workspace → API (
/app/api). Owners and admins only. - Hit + New token and name it after the agent that will hold it — “Claude Code”, “Program bot”. The name shows up in the activity log on every write it makes.
- Pick a scope. Read only for an agent that answers questions about the CFP; Read & write for one that changes things.
- Optionally restrict it to one event. Everything outside that event then 404s, which is the safest default when you run several events from one workspace.
- Copy the secret. It looks like
uns_…and is shown exactly once — there is no recovery, only revoke-and-mint-again.
2 · Connect your agent
Most clients need only the endpoint URL. The server supports OAuth 2.1 with dynamic client registration, so an OAuth-capable client (claude.ai connectors, Claude Code, VS Code, Cursor and most modern MCP clients) registers itself and sends you to a consent page — sign in, pick a workspace and a scope, and the connection appears under API access like any other token. Only a client without OAuth support needs the fallback: a remote HTTP server with a custom header, the URL above plus Authorization: Bearer uns_….
Claude Code
One command — no token:
claude mcp add --transport http unsession https://unsession.dev/api/mcp
Run /mcp inside Claude Code, pick unsession and choose Authenticate — it registers via OAuth and opens the consent page. Add --scope user to make the server available in every project on your machine instead of just the current one. On a headless machine or in CI, use a static token instead: append --header "Authorization: Bearer uns_your_token_here".
Check it into a repo
For a shared repo, put the server in .mcp.json at the project root. Claude Code expands ${VAR} in both url and headers, so the file is safe to commit — each person exports their own token.
{
"mcpServers": {
"unsession": {
"type": "http",
"url": "https://unsession.dev/api/mcp",
"headers": {
"Authorization": "Bearer ${UNSESSION_TOKEN}"
}
}
}
}Claude apps (claude.ai and desktop)
Add it as a custom connector: Settings → Connectors → Add custom connector, paste the endpoint URL, and click Connect. Claude registers itself via OAuth and sends you to the Unsession consent page — sign in, pick the workspace and scope, and you’re connected. No token to paste; the connection shows up under API access and is revoked from there.
authorization with the value Bearer uns_your_token_here — including the word Bearer and the space, since Claude sends the value exactly as you type it.Cursor
Project-level .cursor/mcp.json, or ~/.cursor/mcp.json to have it everywhere:
{
"mcpServers": {
"unsession": {
"url": "https://unsession.dev/api/mcp",
"headers": {
"Authorization": "Bearer ${env:UNSESSION_TOKEN}"
}
}
}
}VS Code
.vscode/mcp.json, with the token as a prompted input so it never lands in the repo:
{
"inputs": [
{
"type": "promptString",
"id": "unsession-token",
"description": "Unsession API token",
"password": true
}
],
"servers": {
"unsession": {
"type": "http",
"url": "https://unsession.dev/api/mcp",
"headers": {
"Authorization": "Bearer ${input:unsession-token}"
}
}
}
}Any other client
Point it at the endpoint as a Streamable HTTP (sometimes streamable-http) server and give it the Authorization header. To check the credential before you wire anything up, ask the server for its tool list by hand:
curl -s https://unsession.dev/api/mcp \
-H "Authorization: Bearer uns_your_token_here" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'A list of tool names comes back as JSON. If you get {"ok":false,"error":"Unknown API token"} with a 401, the secret is wrong or revoked.
3 · Ask for something
Once connected, talk to your agent in plain language — it picks the tools. Things that work well on day one:
- “How many submissions are still in review for DevConf, and which tracks are thinnest?”
- “Show me the five highest-scoring proposals nobody has decided on yet.”
- “Which accepted speakers still haven’t uploaded slides? Assign them the slides task.”
- “Add a 30-minute sponsor session for Acme on day 2 at 14:00 in Studio B.”
- “Move the WASM talk to 11:00 and tell me who that affects.”
- “Diff the published agenda against our website copy and list what’s stale.”
event argument that accepts the slug or the id, and list_events is how the agent finds it.Tool reference
Read — any token
| Tool | What it returns |
|---|---|
| list_events | Events this token can see — id, name, slug, dates, timezone, venue, published. |
| get_event | One event with its rooms and taxonomies (Track / Format / Level options and their ids). |
| list_forms | An event’s submission forms — id, name, slug, status, open/close dates, public URL. |
| list_submissions | Submissions with answers, speakers, status and resolved track/format/level. Filters: status, form, track, free-text q. Cursor-paginated (default 100, max 500). |
| get_submission | One submission in full: answers, speakers, status, evaluation score summary, recent activity. |
| list_sessions | Sessions including schedule (day / start / end / room), type, status and publish flag. |
| get_session | One session — schedule, speakers, track/format, publish flag. |
| list_speakers | Speaker profiles — name, email, bio, job title, company, pronouns, links, headshot — with task progress counts. |
| get_agenda | The published public agenda, same shape as /{slug}/agenda.json. Fails while the agenda is unpublished. |
| list_tasks | Speaker and session task instances with status, due date and target. |
Write — READ · WRITE tokens only
| Tool | What it does |
|---|---|
| create_submission | Create a submission on a form on a speaker’s behalf (organizer-import semantics). Answer keys may be field ids or field labels; unmatched keys are reported back, not stored. |
| update_submission | Update title, abstract and/or answers. Answers merge; a null value removes a key. |
| decide_submission | SENDS EMAILAccept, decline or waitlist. Runs the real decision engine: flips the status, creates the public Session on accept, mints a 7-day confirmation link, and emails the speaker unless sendEmail is false. |
| create_session | Create a sponsor or service session. Talk sessions only ever arrive by accepting a submission. |
| update_session | SENDS EMAILEdit title, abstract, track/format/level, duration, room, publish flag, sponsor badge, or the slot. Moving a confirmed session emails its speakers a schedule notice and bumps the calendar-file sequence. |
| schedule_session | SENDS EMAILPut a session in a slot (day, startMin, optional room) or unschedule it. Same engine, same schedule notice. |
| update_speaker | Update a speaker profile — name, bio, job title, company, pronouns, links. May auto-complete an open “complete profile” task. |
| assign_task | SENDS EMAILAssign a task template to speakers or a session, or a one-off task to one speaker. Already-assigned and no-session speakers are skipped and reported. New assignments email each speaker a digest. |
| complete_task | Mark a task instance done as an organizer override. Idempotent. |
Deliberately out of scope in v1: form and schema editing, event creation, team management, and email template CRUD. Those have versioning and permission semantics that deserve their own design rather than a tool call.
Scopes, side effects & safety
Handing an agent write access to a live CFP is a real decision, so the server is built to make the blast radius legible.
- Read-only tokens don’t see write tools. They are filtered out of
tools/listentirely, so the agent never proposes an action it can’t take. If it calls one anyway, the result explains that the token is read-only rather than failing cryptically. - Event-restricted tokens can’t reach past their event. Everything else 404s.
- Every write is on the record. Activity entries name the actor
api:<token name>, so “who moved this session?” has the same answer for an agent as it does for a person. - Three tools send email.
decide_submission(decision email, suppressible withsendEmail: false),update_session/schedule_session(a schedule notice, but only when a confirmed session actually moves), andassign_task(an assignment digest for genuinely new assignments). Every other tool is silent. - Revoking is instant. Revoke on
/app/apiand the next request gets a 401. The page also shows each token’s last-used timestamp.
decide_submission tool applies the decision immediately — status flip, session copy, confirmation link and email — because a machine caller is being explicit. Use a read-only token if you want an agent that can recommend decisions but not make them.Host your own
Unsession is AGPL-3.0 and the hosted service at unsession.dev runs this repository unmodified. Self-hosting gives you the same MCP server on your own domain — it is part of the worker, so there is nothing extra to enable, deploy or pay for.
git clone https://github.com/cvolzer3/unsession cd unsession npm install npx wrangler d1 create unsession-db # copy the id into wrangler.jsonc npx wrangler r2 bucket create unsession-files npx wrangler d1 migrations apply unsession-db --remote npx wrangler deploy
Set vars.APP_ORIGIN in wrangler.jsonc to the origin your worker answers on, and point routes at your domain (or delete the block to use the workers.dev URL). Then:
- Your MCP endpoint is
https://your-domain/api/mcp— the same path on your origin. - Mint the token on your instance, at
https://your-domain/app/api. Tokens from the hosted service are meaningless on yours and vice versa. - Everything above works unchanged with your URL substituted. Nothing about the MCP server depends on the hosted deployment.
The full self-hosting notes — email sending through Cloudflare Email Service, sign-in, local development — are in the repository README.
Protocol details
Only needed if you are writing a client by hand. Everything is JSON-RPC 2.0 over a single POST; the server is stateless, so requests are independent and can be issued in any order.
| Method | Behaviour |
|---|---|
| initialize | Negotiates the protocol version (2025-06-18 or 2025-03-26) and returns capabilities { tools: {} } and serverInfo unsession/<version>. |
| notifications/* | Any notification (no id) — including notifications/initialized — gets 202 with an empty body. |
| ping | Returns an empty result. |
| tools/list | Lists the tools this token may call. Write tools are omitted entirely for read-only tokens. |
| tools/call | Runs a tool. Results come back as content: [{ type: "text", text: <JSON> }]. |
- Tool failures are results, not errors. A failed tool call returns
isError: truewith a human-readable message in the content, so the agent can read what went wrong and adapt. JSON-RPC error objects are reserved for protocol problems — unparseable body (-32700), malformed request (-32600), unknown method (-32601), unknown tool (-32602). - Notifications get 202 and an empty body. That includes
notifications/initialized. - Batch requests are rejected. Send one request object per POST.
- GET and DELETE return 405. There is no SSE stream to open and no session to delete.
- Auth failures are plain HTTP. A missing, unknown or revoked token gets a 401 with
{ ok: false, error }rather than a JSON-RPC envelope — the request never reaches the protocol layer.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_submissions",
"arguments": { "event": "devconf-2027", "status": "in_review", "limit": 50 }
}
}Troubleshooting
| Symptom | Cause |
|---|---|
| 401 Missing bearer token | The header never arrived. Check the client actually forwards custom headers, and that the value starts with Bearer and a space. |
| 401 Unknown API token | Wrong secret, or a token from a different instance. Mint a new one at /app/api. |
| 401 revoked | Someone revoked it. Tokens can’t be un-revoked — create a replacement. |
| Only ten tools listed | Read-only token. The nine write tools are hidden by design; mint a read-write one. |
| 405 Method not allowed | The client opened a GET/SSE stream. This server is POST-only and stateless. |
| 404 on a known event | The token is restricted to a different event. |
| get_agenda fails | That event’s agenda isn’t published yet. Publish it, or read sessions with list_sessions. |
The REST API
The same operations, for anything that isn’t an agent — scripts, integrations, a scheduled export. Unlike MCP, REST always authenticates with a token. Base URL https://unsession.dev/api/v1:
curl -H "Authorization: Bearer uns_your_token_here" https://unsession.dev/api/v1/events
Responses are { ok: true, data } or { ok: false, error }.
Read — any token
| Route | What it returns |
|---|---|
| GET /events | Events this token can see — id, name, slug, dates, timezone, venue, published. |
| GET /events/:event | One event with its rooms and taxonomies (Track / Format / Level options). |
| GET /events/:event/forms | Submission forms — id, name, slug, status, open/close dates, public URL. |
| GET /events/:event/submissions | Submissions with answers and speakers. Filters: status, form, track, free-text q. Cursor-paginated. |
| GET /submissions/:id | One submission in full: answers, speakers, status, score summary, recent activity. |
| GET /events/:event/sessions | Sessions including schedule (day / start / end / room), type and publish flag. |
| GET /sessions/:id | One session. |
| GET /events/:event/speakers | Speaker profiles — bio, pronouns, links, headshot — with task progress counts. |
| GET /events/:event/agenda | The published agenda, same shape as /{slug}/agenda.json. |
| GET /events/:event/tasks | Task instances with status, due date and speaker/session target. |
Write — READ · WRITE tokens only
| Route | What it does |
|---|---|
| POST /events/:event/submissions | Create a submission on a form on a speaker’s behalf. |
| PATCH /submissions/:id | Update title, abstract and/or answers. Answers merge; null removes a key. |
| POST /submissions/:id/decision | Accept, decline or waitlist. Runs the real decision engine and emails the speaker unless sendEmail is false. |
| POST /events/:event/sessions | Create a sponsor or service session. |
| PATCH /sessions/:id | Edit fields. { day, startMin, roomId } schedules; nulls unschedule; published toggles. |
| PATCH /speakers/:id | Update a speaker profile — name, bio, pronouns, links. |
| POST /tasks | Assign a template or one-off task to a speaker or session. |
| POST /tasks/:id/complete | Mark a task done as an organizer override. Idempotent. |
Full request and response shapes live in SPECS/C-api-mcp.md.