# Praeko MCP server

> Praeko's remote MCP server at praeko.app/mcp: 18 tools, a Bearer API key or OAuth sign-in, and a two-step confirmation before any post is published or changed.

Source: https://praeko.app/docs/en/mcp

One address, `https://praeko.app/mcp`, and one of two credentials: the same API key you use with the REST API, or your agent's OAuth sign-in. The agent reads these docs, looks up your account and, with your approval, creates, schedules, edits and cancels posts. Every write that publishes or changes content takes two steps: the first call does nothing.

18 tools · Streamable HTTP, POST only · Bearer key or OAuth · Two-step writes

## What it is

MCP (Model Context Protocol) is the standard way for an AI agent to call tools. Praeko's server runs on the same host as the API and keeps no session: each call is a JSON-RPC `POST` and the response is a JSON object. Under the hood, each tool calls the same REST route you would call, with the English contract (`X-Praeko-Contract: en`). So validation, scopes, workspace isolation and errors are the API's own, and field names match the [contract](https://praeko.app/docs/en/api).

| Use | Tools |
|---|---|
| Read the docs | `search_docs`, `read_page`: search and read routes (body, example, errors) and short guides, in their current version. |
| Look up the account | `list_channels`, `list_media`, `get_media`, `list_posts`, `get_calendar`, `get_insights`, `get_retention`, `get_usage`. |
| Act, always in two steps | `create_post`, `schedule_post`, `update_post`, `cancel_post`, `upload_media`, `create_idea`. |
| Upload a local file | `create_media_upload`, `complete_media_upload`: the server prepares the upload and the agent runs `curl` in its own shell (a single step: creating the slot publishes nothing). |

> **Left out on purpose** Webhooks, API keys, channel connection (signing in to the networks), publish now, comments and replies are not tools: they stay in the panel and the API. The file never goes through the conversation with the agent: with a shell, it uses `create_media_upload` (see "Local file"); without one, `upload_media` with a public URL.

## Two MCP servers: the remote one publishes, the local one makes the video

An agent can use both at once. This one, `praeko`, is the **remote** server: it runs at `praeko.app/mcp` and is the only one that creates, schedules, edits and cancels posts. `praeko-studio` is the **local** server of [Praeko Studio](https://praeko.app/docs/en/studio/mcp) (`@praeko/studio-mcp`): it runs on your machine over stdio, serves an agent without a terminal and **never publishes**. It needs Node, Chrome/Remotion to render and your own keys, all on your computer.

The full flow: create and render the video on the local server, upload the result with `studio_upload` (it returns only the `mediaId`, in two steps) and publish with `create_post` on this server, with your approval. Details and the list of the 14 local tools in [Studio in Claude Desktop: the local MCP server](https://praeko.app/docs/en/studio/mcp).

## Connect

The endpoint accepts two credentials, always in `Authorization: Bearer …` and on every call: an **API key** (`praeko_…`) that you create and paste into the agent's config, or an **OAuth token** (`mcpat_…`) that the agent gets by itself after you sign in and approve the connection. Both give the same tools, with the same scopes and the same two-step confirmation.

**Connection details**

```text
URL            https://praeko.app/mcp
Transport      Streamable HTTP (POST only; no session, no stream)
Auth           Authorization: Bearer praeko_…   (API key, on EVERY call; never in the URL)
               Authorization: Bearer mcpat_…    (OAuth token, obtained by the agent at sign-in)
OAuth          https://praeko.app/.well-known/oauth-protected-resource/mcp
```

| Credential | When to use it | How |
|---|---|---|
| API key | Terminal and editor agents that accept a header (Claude Code, Codex, Cursor, Gemini CLI, opencode and others), automations, and local file uploads from the shell. | Create the key and set the header (below). |
| OAuth sign-in | Connectors that only speak OAuth (Claude.ai, Claude Desktop and mobile), and any agent that would rather sign in than hold a key, such as Claude Code. | Give it only the URL; the agent opens the consent screen (below). |

### With an API key

Create the key in the panel (Developers, API keys) or with `praeko key create` (which needs an `admin` key). The `read` scope is enough to read; the tools that write need `write` or `admin`. Keep the key in an environment variable, never in a versioned file. Each agent registers the server its own way: pick yours in [AI agents](https://praeko.app/docs/en/agents) and copy the ready-made block.

### With sign-in (OAuth)

Add `https://praeko.app/mcp` to the agent with no header at all. The first call gets a `401` with `resource_metadata` in `WWW-Authenticate`; the agent discovers Praeko's authorization server and opens your browser. There is no key to copy or to leak.

1. **Add the URL**: In Claude.ai or Claude Desktop, a custom connector with `https://praeko.app/mcp`. In Claude Code, `claude mcp add --transport http praeko https://praeko.app/mcp`, then `/mcp`.
2. **Sign in to Praeko**: The browser opens the panel. If you are not signed in yet, sign in as usual (Google, password or passkey, plus two-step verification if it is on).
3. **Consent**: The "Connect {agent} to Praeko" screen shows the app name, the return address, the workspace and the access (Read or Write). You choose and click Allow or Deny.
4. **Connected**: The agent comes back with a token and starts calling tools. The connection shows up under Agent connections in the panel.

**Claude Code with sign-in, no key**

```bash
# registers the server with no header; sign-in happens in the browser
claude mcp add --transport http praeko https://praeko.app/mcp
# inside Claude Code: /mcp, pick praeko and sign in
```

| Topic | How it works |
|---|---|
| Access | **Read** = the `read` scope (the ten read and docs tools). **Write** = the `write` scope (all eighteen). `admin` is never granted over OAuth: API keys, channels, members and settings stay out of the agent's reach. The client asks for a ceiling and the screen lets you pick that access or less; with no request, Read applies. |
| Effective scope | The lower of what you granted and what your workspace role allows today. If you lose rights in the workspace, the agent loses them too. |
| Tokens | The `mcpat_…` access token lasts 1 hour; the `mcprt_…` refresh token lasts 30 days and is rotated on every use. Presenting a refresh token that was already used revokes the whole connection (the agent has to sign in again). The server stores only the hash. |
| Where the token works | Only on `/mcp`. The REST API and the panel refuse the OAuth token; use an API key for those. |
| Manage and revoke | In the panel, under Developers, API keys, section **Agent connections**: app, access, person and last use, with a Revoke button that cuts access immediately. Each person sees and revokes their own connections; a workspace admin sees and revokes all of them. Authorizing the same app again replaces the previous connection. |
| Standards | OAuth 2.1 with public clients only: authorization code with mandatory PKCE `S256` and `resource` (RFC 8707). Discovery through Protected Resource Metadata (`/.well-known/oauth-protected-resource/mcp`) and authorization server metadata (`/.well-known/oauth-authorization-server`). Client registration through a Client ID Metadata Document (the `client_id` is an `https` URL) or dynamic client registration (RFC 7591, `POST /oauth/register`). The authorization response includes `iss` (RFC 9207). |
| Redirect URI | `https`, or `http` only on `localhost`, `127.0.0.1` and `[::1]` (any port, as RFC 8252 allows). Custom schemes such as `cursor://` and `vscode://` are refused: use an API key with those clients. |

> **A fixed header wins over sign-in** In Claude Code, a configured `Authorization` header takes precedence over OAuth: if the key is refused, it does not fall back to sign-in. To use sign-in, register the server without `--header`. Sign-in from ChatGPT has not been checked against OpenAI's documentation yet.

### Authentication responses

| Situation | Response |
|---|---|
| No `Authorization` | `401` with `WWW-Authenticate: Bearer realm="Praeko MCP", scope="read write", resource_metadata="https://praeko.app/.well-known/oauth-protected-resource/mcp"`. |
| Unknown, revoked or expired key or token | `401` with `error="invalid_token"` and the same `resource_metadata` in `WWW-Authenticate`. |
| Scope too low for the tool | `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="write"` (plus `resource_metadata`). |
| Panel cookie | Not accepted here: only an API key or an OAuth token. |
| `GET`, `DELETE` or another method | `405` with `Allow: POST`. |
| An `Origin` that is neither Praeko nor localhost | `403` (DNS rebinding protection). Command-line clients do not send `Origin`. |
| Over 300 requests per minute | `429`. Same limit as the REST API, counted per API key or per OAuth connection. |

## Tools

Eighteen tools. The list the agent gets from `tools/list` depends on the scope: `read` sees the ten read and docs tools; `write` and `admin` see all eighteen (the server exposes nothing extra for `admin`: avoid `admin` for agents). The "What the agent gets" column is the literal description the server sends, in English; the two-step text that closes the six tools that publish or change content is right after the table. `create_media_upload` and `complete_media_upload` are the only writes without two steps (see "Local file").

| Tool | Scope and annotations | What the agent gets | Arguments |
|---|---|---|---|
| `search_docs` | (read) read-only | Search the Praeko API documentation (routes, fields, enums, errors, guides). Returns the best pages with a snippet; open one with read_page. Use it before building a create_post or update_post body. | `query` (required, up to 200 characters), `limit` (1 to 20, default 8) |
| `read_page` | (read) read-only | Read one documentation page in full (a route with its body schema, example and errors, or a guide). Ids come from search_docs: `route:posts.create`, `guide:overview`, `guide:mcp-two-step`... | `id` (required) |
| `list_channels` | (read) read-only | List the connected channels (one item per account; `default` marks the one used when a post item has no `channel`), with state and slots count. Use the channel ids in create_post. | none |
| `list_media` | (read) read-only | List the media library (last 100 items): id, type, name, state, size and duration. Use a ready media id in create_post. | `type` (`video`, `cover` or `image`) |
| `get_media` | (read) read-only | One media item of the library: state (uploading or ready), type, size and, when ready, publicUrl, durationMs, width and height. Use it to check a media id (for example after complete_media_upload) before create_post or schedule_post. | `mediaId` (required) |
| `list_posts` | (read) read-only | List posts, newest first (queue and drafts nearest first). Filters: state, channel, tag. | `state` (`queued`, `draft`, `sent`, `error`, `canceled`), `channel`, `tag`, `limit` (1 to 200), `offset` |
| `get_calendar` | (read) read-only | Scheduled items in a period plus the free weekly slots of each channel (default: next 14 days; at most 100 days). | `from`, `to` (ISO-8601 with time zone), `channel`, `tag` |
| `get_insights` | (read) read-only | Cards, top posts, per-channel results and a summary for a period (views, likes, comments, followers), optionally compared with the previous period. | `from`, `to`, `channel`, `tag`, `type`, `compare`, `sort`, `direction`, `page`, `limit` |
| `get_retention` | (read) read-only | Audience retention, impressions and CTR per post of the period (YouTube) and reach/watch time (Instagram). A missing number is null, never 0; `channels[].state` says whether each account can deliver them. | `from`, `to`, `channel`, `limit` |
| `get_usage` | (read) read-only | API usage of this workspace over the last N days (total, errors, p95, peak per minute vs the limit, by day and by key). MCP calls appear with the route prefix `mcp:`. | `days` (1 to 30, default 7) |
| `create_post` | (write) writes, open-world | Create a post with one item per network. Items are drafts by default or take the next free slot of the channel (queue: true). For a fixed publication time use schedule_post. For a single-photo Instagram post send kind image with media.images (one JPEG image media, optional alt text). Never publishes immediately. TWO-STEP (closing paragraph shared by the six two-step tools, right below the table) | `media`, `texts`, `items` (required); `kind` (`video` or `image`), `title`, `baseText`, `queue`, `draft`, `options`, `tags`, `confirm`. With `kind: image`, `media.images` carries the photo (one). **Refuses `scheduledFor`** |
| `schedule_post` | (write) writes, open-world | Create a post and publish it at a fixed time per item (scheduledFor, ISO-8601 with timezone: ALWAYS state the BRT time to the user as well; the panel runs on America/Sao_Paulo). The post goes out by itself at that time on the real account. For a single-photo Instagram post send kind image with media.images. TWO-STEP (closing paragraph shared by the six two-step tools, right below the table) | the same as `create_post`, but every item requires `scheduledFor`; no `queue` or `draft` |
| `update_post` | (write) writes, destructive, idempotent, open-world | Edit a post: title, texts, media, tags, items (mode, scheduledFor, queue, draft) or moveTo draft/queue. Send only what changes. WHAT CAN CHANGE, per network and when: (1) Any item still pending (draft or scheduled, not yet sent): time, mode, texts, queue/draft. (2) YouTube item ALREADY UPLOADED (the video sits in the channel as private with a future publish time, state "scheduled", or private without a time): it can still be RESCHEDULED (scheduledFor) and its title, description (texts.youtube), videoTags, categoryId and a not-yet-posted pinnedComment can change, until the video goes live. The server checks live on YouTube first; if the video is already public, the scheduled time already passed, the video was deleted, or Praeko did not upload it, nothing is written and the error names the reason and the manual alternative (YouTube Studio: https://studio.youtube.com/video/<videoId>/edit). The new time must be at least 5 minutes ahead; asking for the same time again changes nothing. A YouTube video never becomes public by this tool. (3) Instagram Reels in Buffer: text, time and draft only. (4) TikTok/Instagram items already sent or in the TikTok inbox cannot change. A request touching several items applies each one independently and the result lists `itemResults` (applied or not, and why): read it, nothing is skipped silently. TWO-STEP (closing paragraph shared by the six two-step tools, right below the table) The preview lists, per item, "will_change" or "cannot_change" with the reason. | `postId` (required); `title`, `texts`, `media`, `tags`, `items` (`id` or `network`, `mode`, `scheduledFor`, `queue`, `draft`, `videoTags`, `categoryId`), `moveTo` (`draft` or `queue`), `confirm` |
| `cancel_post` | (write) writes, destructive, idempotent, open-world | Cancel the pending items of a post (they will not be published; Instagram items scheduled in Buffer are removed there). Items already sent are kept. IMPORTANT for YouTube: a video Praeko already uploaded and scheduled (private with a future publish time) is NOT cancelled by this tool: it stays and YouTube will still publish it on time (the result says so). To stop it, either reschedule it with update_post, or manage it in YouTube Studio (https://studio.youtube.com/video/<videoId>/edit: remove the schedule to keep it private, or delete it). TWO-STEP (closing paragraph shared by the six two-step tools, right below the table) | `postId` (required), `confirm` |
| `upload_media` | (write) writes, open-world | Add a video, a cover or the photo of an image post (type image: JPEG only) to the media library by giving a PUBLIC https URL that the Praeko server downloads (video/mp4, video/quicktime, image/png, image/jpeg; up to 50 MB; private networks are refused). Files are never sent through MCP: for larger files use the REST/CLI upload and then use the media id. TWO-STEP (closing paragraph shared by the six two-step tools, right below the table) | `url` and `type` (`video`, `cover` or `image`) required; `name`, `confirm` |
| `create_media_upload` | (write) writes, open-world | Use this to publish a video, cover or photo (type image, JPEG) that is a file on the user's machine (you need a shell; with no shell, use upload_media with a public URL). The file never goes through MCP: this tool only creates the media slot and returns the exact upload command (curl; a loop of parts above 50 MiB). Steps: 1) call this with the real size in bytes (wc -c < file), type (video, cover or image), mime (video/mp4, video/quicktime, image/png, image/jpeg; an image type takes only image/jpeg); 2) run upload.command in the shell after replacing /path/to/file; the API key is read from the shell environment (PRAEKO_KEY): NEVER ask for the key, paste it in the chat, print it or write it to a file; 3) call complete_media_upload with the mediaId; 4) use that media id in create_post or schedule_post (their two-step preview and confirmation still protect the publication). Creating the slot publishes nothing, so there is no confirmation code. Limits: video up to 2 GiB, cover and image up to 20 MiB (Instagram itself takes photos up to 8 MB: that is checked when the post is created), 20 pending uploads per workspace, 60 per hour; an upload not finished in 24 hours is discarded. | `type` (`video`, `cover` or `image`), `name`, `mime` (`video/mp4`, `video/quicktime`, `image/png`, `image/jpeg`) and `size` in bytes, all required. **No `confirm`**: creating the upload slot publishes nothing |
| `complete_media_upload` | (write) writes, idempotent | Call this after running the upload command from create_media_upload. It finishes the upload (joins the parts of a large file; a single-PUT file is already complete), verifies it and returns status ready or uploading, with the media record: publicUrl, durationMs, width and height (read from the video header). Safe to call again. Then use the media id in create_post or schedule_post. | `mediaId` (required). **No `confirm`** |
| `create_idea` | (write) writes | Add a card to the idea board (the "Create" screen). Nothing is published. TWO-STEP (closing paragraph shared by the six two-step tools, right below the table) | `title` (required, up to 200 characters); `note`, `group` (`idea`, `scripting`, `ready`, `posted`), `tags`, `confirm` |

**Closing paragraph of the six two-step tools**

```text
TWO-STEP: the first call (without `confirm`) executes NOTHING: it returns a preview and a single-use `confirmation_code` (10 minutes). Show the preview (action, channels, time in BRT and UTC, media, text, warnings) to the user and WAIT for explicit approval. Only then call this tool again with the SAME arguments plus `confirm: "<code>"`. Changing any argument invalidates the code.
```

Annotations go in `tools/list` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) and the column spells them out: "read-only" is `readOnlyHint`, "destructive" is `destructiveHint`, "idempotent" is `idempotentHint` and "open-world" is `openWorldHint` (it talks to a social network or downloads from the internet). The client decides whether to trust them: the spec says to treat them as hints.

### What each write does

- `create_post`: a post with one item per network. Without `draft` or `queue`, it is created as a **draft**; with `queue: true`, it takes the channel's next free slot. **It never publishes immediately** and refuses `scheduledFor`.
- `schedule_post`: the same post, but `scheduledFor` (ISO-8601 with time zone) is required on every item and the fixed time applies. No `queue` or `draft`. The post goes out by itself, on the real account.
- `update_post`: edits what has not gone out yet (title, texts, media, tags, items, `moveTo`). A YouTube video that Praeko already uploaded and that is still private or scheduled can also change its time, title, description, `videoTags`, `categoryId` and a pinned comment not yet posted; mode, queue and draft do not change on it. The server checks the video live on YouTube before writing and, if it is already public, its time has passed or it was deleted, nothing is written and the error gives the reason and the YouTube Studio link. The new time must be at least 5 minutes ahead, and the tool never makes a video public. The preview lists `will_change` or `cannot_change` per item (in `itemChanges`, with the reason); the response carries `itemResults`, item by item. Send only what changes.
- `cancel_post`: cancels the pending items; what already went out stays. It does **not** stop a YouTube video that was already uploaded and scheduled (YouTube publishes it at the set time; the preview and the response warn you): move the time with `update_post` or handle it in YouTube Studio. The tool has no `dismiss` and never deletes a video; over REST, `DELETE /v1/posts/{id}?dismiss=true` removes the schedule (the video stays private) and `dismiss=true&delete_video=true` deletes the private video.
- `upload_media`: downloads a public `https` URL into the library (see below).
- `create_idea`: a card on the idea board. Nothing is published.

## Two-step writes

The confirmation lives on the server, not in the agent: even if the agent is set to approve everything, a write tool never runs on the first call. This holds the same for API keys and OAuth sign-in.

1. **Call without confirm**: The server validates and returns the preview. Nothing is saved, nothing is sent.
2. **The preview**: The exact action, channels with id and name, time in BRT and UTC, whether the media was found, texts and warnings, plus a `confirmation_code`.
3. **You approve**: The agent shows the preview and waits for your "yes".
4. **Call with confirm**: The same arguments plus `confirm: "<code>"`. Only now does it run.

**Step 1: the agent calls (no confirm)**

```json
{
  "name": "schedule_post",
  "arguments": {
    "title": "The test that only failed on Mondays",
    "media": {
      "video": "01KA3VQ7M2T9XE4HB8N5RZ6C0D"
    },
    "texts": {
      "youtube": {
        "title": "The test that only failed on Mondays",
        "text": "…"
      }
    },
    "items": [
      {
        "network": "youtube",
        "mode": "scheduled",
        "scheduledFor": "2026-11-13T20:30:00Z"
      }
    ]
  }
}
```

**Step 1: the preview (nothing was saved)**

```json
{
  "preview": true,
  "executed": false,
  "tool": "schedule_post",
  "action": "Create a post and schedule it at fixed times",
  "title": "The test that only failed on Mondays",
  "items": [
    {
      "network": "youtube",
      "channel": {
        "id": "…",
        "name": "My channel",
        "connected": true
      },
      "mode": "network default",
      "timing": "fixed_time",
      "scheduledFor": {
        "utc": "2026-11-13T20:30:00.000Z",
        "brt": "2026-11-13 17:30 (BRT, UTC-03:00)"
      }
    }
  ],
  "media": [
    {
      "id": "01KA3VQ7M2T9XE4HB8N5RZ6C0D",
      "found": true,
      "type": "video",
      "state": "ready"
    }
  ],
  "warnings": [
    "Confirming schedules a REAL publication on the connected account(s) at the time above. It can be edited or cancelled until then (update_post, cancel_post)."
  ],
  "confirmation_code": "K7MQ-2VXZ-9RT4",
  "expires_at": "2026-11-13T12:10:00.000Z",
  "expires_in_seconds": 600,
  "next_step": "NOTHING was done yet. Show this preview to the user and wait for approval. …"
}
```

**Step 2: the agent repeats with the code**

```json
{
  "name": "schedule_post",
  "arguments": {
    "title": "The test that only failed on Mondays",
    "media": {
      "video": "01KA3VQ7M2T9XE4HB8N5RZ6C0D"
    },
    "texts": {
      "youtube": {
        "title": "The test that only failed on Mondays",
        "text": "…"
      }
    },
    "items": [
      {
        "network": "youtube",
        "mode": "scheduled",
        "scheduledFor": "2026-11-13T20:30:00Z"
      }
    ],
    "confirm": "K7MQ-2VXZ-9RT4"
  }
}
```

**Step 2: result**

```json
{
  "preview": false,
  "executed": true,
  "tool": "schedule_post",
  "result": {
    "id": "…",
    "items": [
      {
        "network": "youtube",
        "state": "scheduled"
      }
    ]
  }
}
```

### The confirmation code

| Property | Value |
|---|---|
| Format | `XXXX-XXXX-XXXX`: 12 random characters (about 58 bits), for example `K7MQ-2VXZ-9RT4`. |
| Lifetime | 10 minutes (`expires_in_seconds: 600`). |
| Use | Single use. Two confirmations at the same time: only one runs. |
| Bound to | The workspace, the credential that asked for the preview (the API key or the OAuth connection: refreshing the token does not invalidate the code), the tool and the arguments (SHA-256 of the canonical JSON: key order does not matter). |
| Stored as | Only its hash, in the database. |
| Refused by validation | If the route refuses the request (4xx, nothing saved), the code becomes valid again until it expires: the user fixes what was missing (media still uploading, for example) and confirms again. |

Changing any argument, reusing the code, or using an expired code or one from another credential, tool or workspace fails. The response is a tool execution error (`isError: true`) with `error.code` `confirmation_invalid` and the reason in `error.reason` (the `message` is in English):

| `reason` | Meaning | What the agent does |
|---|---|---|
| `unknown_code` | The code does not exist in this workspace. | Call without `confirm` for a new preview. |
| `already_used` | The code was already used. | Ask for a new preview to repeat the action. |
| `expired` | The 10 minutes are up. | Ask for a new preview and show it to the user again. |
| `wrong_key` | The code was issued to another API key or another OAuth connection. | Ask for a preview with the current credential. |
| `wrong_tool` | The code belongs to another tool. | Use the tool that issued the code. |
| `arguments_changed` | The arguments differ from the ones in the preview. | Ask for a new preview with the final arguments. |

**Error example**

```json
{
  "error": {
    "code": "confirmation_invalid",
    "message": "this confirmation code expired (10 minutes). Request a new preview and show it to the user again.",
    "reason": "expired"
  }
}
```

## `upload_media`: media from a URL

The tool takes a public `https` URL and the server downloads the file. The file never goes through the conversation with the agent.

| Rule | Value |
|---|---|
| Types | `video`: `video/mp4`, `video/quicktime`. `cover`: `image/png`, `image/jpeg`. `image` (the photo of an image post): `image/jpeg` only. The type is checked on download (`unsupported_mime`). |
| Size | Up to 50 MB, checked in the header and during the download. Above that, `file_too_large`: use the local file upload below, or the multipart upload of the REST API, the CLI or the SDK. |
| Redirects | Up to 3 hops, and each hop goes through the same check again. |
| Refused addresses | `https` only, no user and password, no `localhost`, internal names, private, reserved or cloud-metadata IPs (the same barrier as webhooks). |

The Worker does not resolve DNS before downloading: against a public name that resolves to a private IP, the protection is Cloudflare's own network, which cannot reach anyone's private network.

## Local file: `create_media_upload` and `complete_media_upload`

The most common case for an agent with a shell (Claude Code, Codex, Gemini CLI, opencode, Cursor, Kimi, Cline): the video is on disk. MCP does not carry large files, so the server only prepares the upload and the agent's own `curl` sends it straight to the API, with the key that is already in the user's environment.

1. **create_media_upload**: The agent passes `type`, `name`, `mime` and the exact `size` in bytes (`wc -c < file`). The server creates the media and returns the `mediaId`, the ready `curl` command and the HTTP spec.
2. **Run the command**: The agent runs `upload.command` in the shell. The key comes from `$PRAEKO_KEY` (export it from `PRAEKO_API_KEY` if that is the name you use) and never shows up in the conversation.
3. **complete_media_upload**: Joins the parts (large file), verifies and returns `status: ready` with `publicUrl`, `durationMs`, `width` and `height`. Safe to repeat; `get_media` checks the state.
4. **create_post or schedule_post**: With the `mediaId`. These still take two steps: preview, your "yes", code.

| Size | How the file goes up |
|---|---|
| Up to 50 MiB | One `PUT` to `/v1/media/{id}/content`, with the file as the body. |
| Above 50 MiB (video up to 2 GiB) | 64 MiB parts to `PUT /v1/media/{id}/parts/{n}` (all equal, the last one smaller; each part can be retried). The returned command is already the `dd` and `curl` loop; the response also lists the parts with the URL of each. |

**create_media_upload response (file up to 50 MiB)**

```json
{
  "executed": true,
  "tool": "create_media_upload",
  "upload": {
    "mediaId": "01KB7J3V9Q2W8E5R1T6Y4N0M3P",
    "state": "uploading",
    "expectedSize": 48213377,
    "mime": "video/mp4",
    "expiresAt": "2026-11-08T12:00:00.000Z",
    "keyHandling": "The API key is already in the user's shell environment as PRAEKO_KEY (…)",
    "method": "single_put",
    "http": {
      "method": "PUT",
      "url": "https://praeko.app/v1/media/01KB7J3V9Q2W8E5R1T6Y4N0M3P/content",
      "headers": {
        "Authorization": "Bearer $PRAEKO_KEY",
        "Content-Type": "video/mp4"
      },
      "body": "the raw bytes of the file (exactly 48213377 bytes)"
    },
    "command": "curl -sS --fail-with-body -X PUT -H \"Authorization: Bearer $PRAEKO_KEY\" -H \"Content-Type: video/mp4\" -H \"Expect:\" -T \"/path/to/file\" \"https://praeko.app/v1/media/01KB7J3V9Q2W8E5R1T6Y4N0M3P/content\""
  },
  "next_step": "Run `upload.command` in the shell (replace /path/to/file; the key comes from the environment, do not print it), then call complete_media_upload with this mediaId, then create_post or schedule_post with media.video = mediaId (those keep their two-step preview/confirmation)."
}
```

### Why there is no confirmation code

Creating the upload slot publishes nothing, sends nothing to a network and changes no post. What protects the account is the next step, `create_post` or `schedule_post`, which still takes two steps. So these two tools are single-step, with limits instead of a code:

| Limit | Value |
|---|---|
| Scope | `write` (a `read`-only list does not show them). |
| Types | `video/mp4`, `video/quicktime` (video), `image/png`, `image/jpeg` (cover) and `image/jpeg` (image); the type must match `type`. |
| Size | Video up to 2 GiB, cover and image up to 20 MiB. The server refuses an upload whose size differs from the declared one (`size_mismatch`). |
| Pending uploads | At most 20 per workspace (`too_many_pending_uploads`, with the pending ids) and 60 created per hour (`rate_limited`). |
| Expiry | An upload not finished within 24 hours is discarded (the partial file is deleted and the media is removed). |
| Isolation | The media belongs to the credential's workspace; another workspace gets `media_not_found`. |
| The key | The `curl` uses an API key with the `write` scope, read from the shell environment (`$PRAEKO_KEY`). The tools never return the key, only the `$PRAEKO_KEY` placeholder, and tell the agent not to ask for it, print it or paste it in the conversation. |
| With OAuth sign-in | The OAuth token works only on `/mcp`, and the `curl` talks to the REST API: even when connected by sign-in, the shell needs a `write` key in `$PRAEKO_KEY`. Without one, use `upload_media` with a public URL. |

> **The client may ask you to approve the command** Claude Code asks for approval of a shell command that uses a variable such as `$PRAEKO_KEY` and, when non-interactive (`claude -p`), refuses anything not allowed in its permissions. Approve the `curl` in the session or allow it in the permissions. The returned command uses plain `$PRAEKO_KEY`, without the `${VAR:-…}` form that Claude Code blocks. This is the client's rule, not Praeko's.

> **Agent without a shell** Claude.ai, Claude Desktop and ChatGPT connectors have no shell and no disk access: use `upload_media` with a public `https` URL (up to 50 MB). Without a URL, upload through the CLI (`praeko post`), the SDK (`uploadFile`) or the REST API and hand over the media id.

## Protocol

The endpoint speaks two MCP eras at the same address, as the spec allows. Most clients today still use `2025-11-25`; `2026-07-28` (the newest when this was checked, Nov 7, 2026) has no `initialize` and no session.

|  | Modern era `2026-07-28` | Legacy era `2025-11-25`, `2025-06-18`, `2025-03-26` |
|---|---|---|
| How it is detected | `_meta["io.modelcontextprotocol/protocolVersion"]` in the body or `MCP-Protocol-Version: 2026-07-28` | Any other request. Without a version header, `2025-03-26` applies. |
| Opening | None. `server/discover` is optional. | `initialize`, a response with the requested version (or `2025-11-25`), `notifications/initialized` (`202`). |
| Required headers | `MCP-Protocol-Version` matching the body, `Mcp-Method` matching `method`, `Mcp-Name` matching `params.name` in `tools/call`. Mismatch: `400` and JSON-RPC `-32020`. | `MCP-Protocol-Version`, if sent, must be a known version (otherwise `400`). |
| Unknown version | `400` and `-32022`, with `data.supported` and `data.requested`. | Same. |
| Unknown method | `404` and `-32601`. | `200` and `-32601`. |
| Result | Includes `"resultType": "complete"`. | No `resultType`. |
| JSON-RPC batch | Refused (`400`). | Accepted. |

- The response is always `application/json` (the spec allows it instead of SSE). The server sends no `Mcp-Session-Id` and ignores `Accept`. Client notifications and responses: `202` with no body.
- `tools/call` returns `content` (text with the JSON) and `structuredContent` (the same object). There is no `outputSchema`.
- An unknown tool is a protocol error (`-32602`). Invalid arguments, business rules and API failures are tool execution errors (`isError: true`, with `structuredContent.error = { code, message, status, details? }`), so the model can correct itself.

**Try it with curl (legacy era, no session)**

```bash
curl -s https://praeko.app/mcp \
  -H "Authorization: Bearer $PRAEKO_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

## Security

| Safeguard | How it works |
|---|---|
| Server-side confirmation | No write that publishes or changes content runs without the second step. The code is single-use, lasts 10 minutes and is bound to the credential, the tool and the arguments. |
| Least privilege | A `read` key (or Read access, with sign-in) for an agent that only looks things up; `write` only for one that schedules. The scope is checked at `/mcp` and again on the REST route. |
| Explicit consent | The consent screen shows up on every authorization, even for a known app, and highlights when it asks for more access than before. It shows the return address and warns when the app is unknown or only has a local address. |
| Tool text is data | The server instructions tell the model to treat returned titles, comments and notes as user data, never as instructions. |
| Local file upload | Only an upload slot with limits (20 pending, 60 per hour, 24 h expiry, 2 GiB); the file goes from the agent's shell straight to the API. Publishing still takes two steps. |
| Credentials out of code | Keep the key in an environment variable. Keys and tokens never appear in responses, logs or the database (only their hash). If a key leaked, revoke it in API keys; a connection, in Agent connections. |
| Audit trail | Each MCP call becomes one row in Usage and log, with the route `mcp:tools/call create_post`, `mcp:tools/list` and so on, and the status; calls made through sign-in show up with the key `mcp-oauth`. Filter by the `mcp:` route in the panel or with `GET /v1/usage/log?route=mcp%3A`. |

## Clients checked

Tested with the real Claude Code: with an API key (initialization, `tools/list`, `list_channels` and the `create_idea` preview) and with OAuth sign-in (discovery, registration through the client metadata document, consent, connection and revocation). The blocks for the other agents in [AI agents](https://praeko.app/docs/en/agents) come from each vendor's documentation. Sign-in from ChatGPT has not been checked against a primary source.
