Skip to content
WorkRoad home
REST API

MCP protocol reference

Transport, tool arguments, responses, OAuth and protocol errors.

Transport and request limits

The server speaks MCP's Streamable HTTP transport without sessions. Every call is a single POST that answers with JSON, so there is no event stream to hold open.

Send Accept: application/json, text/event-stream on POST requests. The MCP SDK requires both values even though this server returns JSON. Accept: */* returns a JSON 406 error.

Every authenticated MCP POST, including initialize, tools/list and ping, counts toward a separate 120-request per minute window per key or connected app. Tool calls also use the REST read or write window. Exceeding the request window returns HTTP 429 with Retry-After. Tools that run WorkRoad's AI also count toward the user's AI rate limit and 10 calls a minute per key or app. On the free plan, each tool call that runs an operation also takes one of the account's monthly requests; protocol messages such as initialize and tools/list don't.

What the server tells the agent

On initialize the server sends instructions: what WorkRoad's records are, where to start (getToday, search), where to look up how WorkRoad works (searchDocs, getDoc), the flashcard practice loop, the practice test, planning and course-building flows, and the rules for ids, paging, errors and the AI allowance. They also tell it to write notes, cards, tests and tasks itself and save them with the plain tools, and that a task's type is the kind of work while its subject is the course. Clients such as Claude Code give these to the model before its first tool call.

Each tool's description holds the operation's summary and description, then, where it helps, the tool to call next, how to page, the errors the agent can act on, and the required scopes. Its annotations say what it does to the user's data: readOnlyHint for reads, destructiveHint for writes that can't be taken back (deleting a set or a file, replacing a note's content, undoing a review), idempotentHint for calls that change nothing more when repeated with the same arguments (answering a practice card, grading a review, applying a generation), and openWorldHint for tools that read a connected external calendar. Moving a note to the trash is not destructive, since restoreNote brings it back.

Arguments and results

A tool's arguments are the operation's path parameters, query parameters and body fields, all at the top level. getTask takes { "taskId": "..." }, and updateTask takes taskId next to the fields to change. Its input schema has every field's type and limits.

Write tools that create something (the POST operations) also take an optional idempotencyKey. It works like the REST Idempotency-Key header: retry with the same key and arguments and you get the first result back, without creating anything twice. See Idempotency.

A successful call returns one text block containing the HTTP status, optional retry timing and REST response body, as { "status": 201, "body": { "data": { ... } } }. The server omits structuredContent, so clients that pass only text to the model still receive the full body. The status matters where an operation answers two ways, such as createReview, which returns 200 instead of 201 for a review it already recorded.

A call the API refuses comes back as a tool error, not a protocol error, so the agent can read it and correct itself. The text block holds the status and the REST error envelope:

{
   "status": 403,
   "error": { "code": "forbidden", "message": "This API key does not grant this operation." }
}

Invalid arguments fail the way the matching REST request would. A bad body field returns 400 invalid_request with the failing fields in details, a bad query argument such as a limit of 500 returns 400 invalid_query, and a malformed path id returns 404 not_found. The error codes are the same as for REST.

When the REST response would carry a Retry-After header, the text block adds retryAfter in seconds, as in { "status": 429, "retryAfter": 3, "error": { ... } }. Wait that long before calling again. Successful calls carry it too: getJob on a pending or running job returns { "status": 200, "retryAfter": 5, "body": { "data": { ... } } }.

Authentication and protocol errors

/api/mcp takes two kinds of credential in Authorization: Bearer <token>: an API key, or an OAuth access token from a connected app. /api/test/mcp and the REST API take API keys only.

A request without a valid credential gets a JSON 401 with a Bearer challenge that names the resource metadata and the scopes to ask for, which leave out deleting and the AI allowance:

WWW-Authenticate: Bearer realm="workroad-api", error="invalid_token", resource_metadata="https://workroad.app/.well-known/oauth-protected-resource/api/mcp", scope="tasks:view tasks:create …"

error="invalid_token" appears only when a credential was sent. An MCP client that supports OAuth follows the challenge:

StepWhere
Protected resource metadata (RFC 9728)/.well-known/oauth-protected-resource/api/mcp (also at /.well-known/oauth-protected-resource)
Authorization server metadata (RFC 8414), issuer https://workroad.app/.well-known/oauth-authorization-server
Dynamic client registration (RFC 7591)POST /api/oauth/register
Authorization, with the user's consent/api/oauth/authorize, then the consent page /oauth/authorize
Token (authorization_code, refresh_token)POST /api/oauth/token
Revocation (RFC 7009)POST /api/oauth/revoke

The authorization server follows OAuth 2.1:

  • PKCE with S256 is required; plain and requests without a challenge are refused.
  • Redirect URIs are matched exactly, except that a loopback http://127.0.0.1 or http://localhost redirect may use any port. Registered redirects must use https or http on loopback, at most 5 of them, each up to 512 characters. The one exception is Cursor's documented cursor://anysphere.cursor-mcp/oauth/callback.
  • Clients register as public (token_endpoint_auth_method: none) or with a secret (client_secret_post or client_secret_basic). A registration that leaves the method out gets none, not RFC 7591's client_secret_basic default, and the response says so. A client that hasn't been approved by anyone within an hour is deleted. A public registration for ChatGPT's or Claude's shared callback with the same redirects as one in the last 12 hours gets that client back, and such a shared client is kept for a day.
  • Access tokens last an hour. Refresh tokens last 30 days and rotate: each refresh returns a new pair. The same client presenting a spent refresh token or authorization code again within 30 seconds (for a code, with the same verifier) gets the same response back and nothing new is issued, so a retried or raced request doesn't disconnect it. Any later use ends the connection and revokes every token it issued.
  • Tokens are issued for the resource https://workroad.app/api/mcp only, and a resource parameter (RFC 8707) naming anything else is refused.
  • Scopes are the API-key scopes except webhooks:manage. Without a scope parameter the app asks for all of them. offline_access, openid, profile and email are accepted and ignored: every authorization gets a refresh token, and nothing here is OpenID Connect. A token keeps the scopes it was issued with: approving more later gives new tokens more, not the ones already out. Approving again only adds: the new tokens get everything the connection holds.
  • The authorization response carries iss (RFC 9207).
  • An app may only call itself ChatGPT, OpenAI, Claude, Anthropic or WorkRoad when its redirects are that product's own callbacks or only loopback, however the name is spelled. An unverified app is shown by the host its access goes to; its name, kept only if it is plain ASCII after NFKC normalization, appears second as what it calls itself.
  • Approval grants what the app asked for except the *:delete scopes and the ones that spend the AI allowance (copilot:use, generation:start, tests:generate, tests:attempt): the user ticks those on the consent screen. A tools/call the token's scopes don't cover returns HTTP 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…" naming the scopes the token holds plus the ones it lacks, so the client can ask again without losing any. An approval that would leave the app with no scopes at all is refused.
  • A user can connect at most 20 apps on WorkRoad Plus and 1 on the free plan. Reconnecting an app that is already connected doesn't add one, and apps connected beyond the cap stay connected. Changing or resetting the password disconnects all of them, and connecting one sends the account an email.

Every authorization shows the consent screen to a signed-in user. It names ChatGPT or Claude only when the redirect is that product's own documented callback; any other app appears as unverified, with the address its access goes to. Admins impersonating a user can't approve one, and an account with a second factor confirms it first if it hasn't recently. A token's scopes are checked against the connection on every call, so disconnecting in Settings → Connected apps takes effect on the next request.

A tools/call request must supply an object for arguments, or omit it for an empty object. Explicit null, arrays and scalar values return JSON-RPC -32602 with a readable message. Valid argument objects with invalid operation fields retain the 400 tool-error behavior described above.

Uploading files

The agent uploads a file itself; the MCP server never handles file contents. createNoteFile returns the file's id, a signed upload.url and the upload.fields. The agent then POSTs the file to upload.url as multipart/form-data, with every field from upload.fields in order and the file last as file, and calls completeNoteFile inside the same 10 minutes. Uploading files has the details and limits.