MCP server
POST /mcp is live — an MCP (Model Context Protocol) server onto the REST API's own surface: the same Throughline access keys, the same scopes, the same clamped answers. It isn't a second API. The MCP layer resolves your bearer key and calls the identical function the matching REST endpoint calls — nothing about what a key may see changes depending on which door you come through, and a refusal on one is worded and coded identically on the other.
Endpoint
POST https://mcp.criticalperiod.co/mcp
Authorization: Bearer tl_your_access_key_here
Content-Type: application/json
Streamable HTTP: one JSON-RPC 2.0 message per request body, one transport, no SSE fallback, no batching — send a single JSON-RPC object, not an array. A request body over 12MB is refused before it's even parsed.
Authentication
Whatever your key can do over REST, it can do here, and nothing more. A production-wide key reads and writes across the whole production, scoped by read/write; an engagement-bound vendor key reaches only the bundle/turnover/media/purchase-order tools, clamped to its own engagement. An app connected by signing in reads what a production-wide key reads, unless the person who connected it is an outside company's seat: then it meets the door that company's engagement-bound key meets, and reads only the scene and character bundles, for what the seat was given (a sign-in reads no turnovers and cannot be granted read:money). Revoking a key from Settings bites on the very next call, MCP included.
Two tiers of method:
initialize,tools/list,resources/list,prompts/list,prompts/get— any live key. These only describe what exists, the same way an OpenAPI document describes routes it doesn't let you call.tools/call,resources/read— need the specific scope the equivalent REST endpoint needs. A missing scope, an engagement door, or a tenancy-crossed id refuses with the identical HTTP status and message the REST call would give — see Errors below.
Discovery
GET /.well-known/oauth-protected-resource
Public, no key required. Protected-resource metadata (RFC 9728) so an MCP/OAuth-aware client can register against this resource ahead of time. Two ways in, and the metadata names both: an OAuth flow against the authorization server at api.criticalperiod.co (dynamic client registration, PKCE — connect from any MCP-aware client and sign in as yourself), or a bearer access key minted in Settings, presented in the header:
{ "resource": "https://mcp.criticalperiod.co/mcp",
"bearer_methods_supported": ["header"],
"authorization_servers": ["https://api.criticalperiod.co"] }
Tools
Fifteen tools — the REST read surface, both bundles, turnovers and turnover submissions. The ten plain-read ones refuse an engagement-bound key outright, identically to their REST endpoints; the five marked engagement-aware work for either shape of key — unclamped for a production-wide key, clamped to its own reach for a vendor key.
REST endpoints with no tool yet: /script-changes, /people, /jobs, the two /media reads, and the three write endpoints — POST /assets, POST /assets/{node_id}/notes and POST /approvals/{id}/decide.
| Tool | Scope needed | Same as |
|---|---|---|
get_production | read | GET /production |
list_departments | read | GET /departments |
get_department (slug) | read | GET /departments/{slug} |
list_scenes | read | GET /scenes |
list_assets (department?, kind?, status?) | read | GET /assets |
get_asset (node_id) | read | GET /assets/{node_id} |
get_schedule (episode_id?) | read | GET /schedule |
list_deliveries | read | GET /deliveries |
list_approvals (status?) | read | GET /approvals |
list_activity (limit?) | read | GET /activity |
get_purchase_orders | read:money, engagement-aware | GET /purchase-orders |
get_scene_bundle (node_id) | read, engagement-aware | GET /bundles/scene/{node_id} |
get_character_bundle (node_id) | read, engagement-aware | GET /bundles/character/{node_id} |
list_turnovers | read, engagement-aware | GET /turnovers |
submit_turnover (delivery_id, title?, note?, files) | submit:turnover, engagement-aware | POST /turnovers/{id}/submissions |
Signed in as yourself, you read what Throughline shows you. Connect with the OAuth flow and every tool answers for you. get_schedule gives the days you can see and would be sent (on an episodic show, the days your episodes shoot), and each day lists only the scenes you can see. get_asset won't open an item you can't open. The lists match the app: list_assets and list_activity still show an item you can't open by name, the way your item list and activity feed do, but the item itself stays closed. Someone from an outside company connected this way reads the days, deliveries and approvals they were given, plus the two bundles for what they were given. An access key reads the production-wide answer, as in the table.
An app connected with your own sign-in can't send turnovers or read purchase orders, and asked to, it says so plainly: "You can't send turnovers from a connected app." (A key that holds submit:turnover can send them.)
submit_turnover's files travel as base64 — JSON-RPC has no multipart — one object per file: {"filename": "shot_010.mov", "content_base64": "…"}. For a large upload, the REST endpoint is the better choice: it streams the bytes instead of holding the whole request as one JSON string in memory.
Search is deliberately not a tool here. Every tool above is one /api/v1 endpoint's own function, called unchanged — and the in-product search screen has no /api/v1 endpoint behind it. This layer never invents a surface REST doesn't already expose, which is the same rule that keeps it from inventing an authorization decision.
Resources
Six stable documents — the singleton reads a client wants as ambient context rather than a call with arguments. Each needs read, same door as its matching tool above.
| URI | Same as |
|---|---|
throughline://production | get_production |
throughline://departments | list_departments |
throughline://schedule | get_schedule |
throughline://script | list_scenes |
throughline://deliveries | list_deliveries |
throughline://approvals | list_approvals |
Prompts
Four canned questions — pure text templates with no data access of their own, telling a client's model which tool to call and how to read the answer.
| Prompt | Arguments | Points at |
|---|---|---|
scene_status | node_id | get_scene_bundle |
pending_turnovers | — | list_turnovers |
whats_changed | limit? | list_activity |
how_to_submit_work | delivery_id | submit_turnover |
Example session
POST /mcp
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
{"jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
"serverInfo": {"name": "throughline", "version": "1.0.0"}
}}
POST /mcp
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "get_production", "arguments": {}}}
{"jsonrpc": "2.0", "id": 2, "result": {
"content": [{"type": "text", "text": "{\n \"id\": 1,\n \"title\": \"Example Production\", …\n}"}],
"structuredContent": {"id": 1, "title": "Example Production", "…": "…"},
"isError": false
}}
A tool's own document rides twice in the result: as a text block (always present) and, when the underlying value is an object rather than a list, a second time as structuredContent.
Errors
Two shapes, deliberately different:
- Auth, scope, engagement-door and tenancy refusals are REST's own bytes, unwrapped — the whole HTTP response is the identical status code and
{"detail": "…"}body the matching REST call would give. - JSON-RPC's own protocol errors — an unknown method, an unknown tool/resource/prompt name, a malformed argument — get a JSON-RPC
errorobject instead, because there's no REST call for those to match:
error.code | Meaning |
|---|---|
-32700 | Parse error — the body isn't valid JSON |
-32600 | Invalid Request — missing method, or a batched array (not supported) |
-32601 | Method, tool, resource, or prompt not found |
-32602 | Invalid params — a required argument is missing or the wrong shape |
For example, calling submit_turnover with a key that lacks the submit:turnover scope returns a plain HTTP 403 with {"detail": "This key does not have the 'submit:turnover' scope."} — not a JSON-RPC error envelope, and the identical body POST /v1/turnovers/{id}/submissions would give the same key.