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://throughline-backend-idkf.onrender.com/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. 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. There's no OAuth authorization server behind this yet — a bearer key minted in Settings is the auth — so the response names no authorization_servers:
{ "resource": "https://throughline-backend-idkf.onrender.com/mcp",
"bearer_methods_supported": ["header"] }
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 |
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.