# MCP server

`POST /mcp` is live — an MCP (Model Context Protocol) server onto the
[REST API](api.html)'s own surface: the same [Throughline access
keys](keys.html), 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](api.html#two-shapes-of-key) reads and writes across
the whole production, scoped by `read`/`write`; an
[engagement-bound](api.html#two-shapes-of-key) 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](#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`:

```json
{ "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](api.html#post-turnovers-id-submissions)
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": {}}
```

```json
{"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": {}}}
```

```json
{"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 `error`
  object 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.
