MCP server
Endpoint & transport
https://api.relay.lantean.tech/mcp Authorization: Bearer rly_live_…
| Property | Value |
|---|---|
| Transport | Streamable HTTP (POST, GET, DELETE on one URL). POST responses are plain JSON; the GET stream exists for protocol compliance but carries no server-initiated messages on this stateless server. |
| Session mode | Stateless — no Mcp-Session-Id, a fresh server instance per request |
| Auth | Bearer API key in the Authorization header (no OAuth flow) |
| Tools | 10, all with output schemas; every tool except auth_status also declares an input schema |
| Resources / prompts | None — the surface is tools only |
Because the server is stateless, there's no session to keep alive and no reconnect dance: every request is independently authenticated and independently isolated. Clients that only speak the deprecated HTTP+SSE transport, or that can't send a custom header, need the mcp-remote bridge shown under Claude apps below.
Connect a client
Create a key at API keys first — it's shown once. Every snippet below assumes it's in $RELAY_API_KEY.
Claude Code
claude mcp add --transport http relay https://api.relay.lantean.tech/mcp \ --header "Authorization: Bearer $RELAY_API_KEY" claude mcp list # should report: relay ✔ Connected
Claude apps (claude.ai and Claude Desktop)
Add Relay as a custom connector: Settings → Connectors → Add custom connector, paste the endpoint URL, and put Bearer $RELAY_API_KEY in the request-header field. Header authentication for custom connectors is rolling out gradually; if your account doesn't have it, use the local bridge below instead.
{
"mcpServers": {
"relay": {
"command": "npx",
"args": [
"-y", "mcp-remote", "https://api.relay.lantean.tech/mcp",
"--header", "Authorization: Bearer ${RELAY_API_KEY}"
],
"env": { "RELAY_API_KEY": "rly_live_…" }
}
}
}Cursor
{
"mcpServers": {
"relay": {
"url": "https://api.relay.lantean.tech/mcp",
"headers": { "Authorization": "Bearer rly_live_…" }
}
}
}VS Code
Put this in .vscode/mcp.json — VS Code prompts for the key on first use and keeps it out of the file. Headers set in a workspace .mcp.json are ignored, so use .vscode/mcp.json.
{
"inputs": [
{ "type": "promptString", "id": "relay-key", "description": "Relay API key", "password": true }
],
"servers": {
"relay": {
"type": "http",
"url": "https://api.relay.lantean.tech/mcp",
"headers": { "Authorization": "Bearer ${input:relay-key}" }
}
}
}Any other MCP client
{
"mcpServers": {
"relay": {
"type": "streamable-http",
"url": "https://api.relay.lantean.tech/mcp",
"headers": { "Authorization": "Bearer rly_live_…" }
}
}
}Verify it by hand
Because the server is stateless, you can list tools with one request — no initialize handshake required. Responses come back as plain JSON.
curl -X POST https://api.relay.lantean.tech/mcp \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'For interactive debugging, point the official inspector at the same URL: npx @modelcontextprotocol/inspector.
Tool reference
10 tools. Each declares a JSON Schema for its input and its output, so clients validate structured results rather than parsing prose.
auth_statusaccounts_list| Field | Type | Description |
|---|---|---|
| platform | string? | Filter by instagram or tiktok Example: instagram |
post_create| Field | Type | Description |
|---|---|---|
| targets | string[] required | Account IDs to post to (from accounts_list), up to 10 Example: ["9d1f…"] |
| caption | string required | Caption text, 1–2200 characters (ignored for stories) Example: "Fall drop is live — link in bio." |
| post_type | string? | feed | carousel | reel | story — inferred from media when omitted (stories never inferred) Example: reel |
| media | (string | object)[] | Media IDs or public URLs (JPEG images, MP4/MOV videos) — up to 10 when an Instagram account is targeted, up to 35 images for a TikTok-only photo post. Required unless draft is true, since neither platform has text-only posts. For a per-slide caption on an Instagram carousel, pass { id, caption } or { url, caption } instead of a bare string — exactly one of id or url Example: ["https://cdn.example.com/fall.jpg", { "id": "a1b2…", "caption": "Sand, our best seller." }] |
| share_to_feed | boolean? | Reels only: also show the reel on the profile grid (Instagram defaults to true) Example: false |
| first_comment | string? | Text posted as a comment on the post the moment it goes live, 1–2200 characters — the usual home for a hashtag block you want out of the caption. Posted once per Instagram target account (TikTok has no first comments, so it needs at least one Instagram target). Not valid on stories, which have no comments Example: "#fallcollection #newin #slowfashion" |
| tiktok | object? | TikTok settings, required when any target is a TikTok account: { mode: "direct" | "inbox" (default direct), privacy_level: PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY (required for direct, no default), allow_comment, allow_duet, allow_stitch (all default false; duet/stitch are video only), brand_organic (promotes your own brand), brand_content (paid partnership — never SELF_ONLY), is_aigc, caption (TikTok-only caption override), title (photo posts, ≤90), cover_timestamp_ms (video), photo_cover_index (photo), auto_add_music (photo) } Example: { "privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true } |
| schedule_at | string? | ISO 8601 timestamp with offset — omit to publish now Example: "2026-08-04T18:30:00Z" |
| draft | boolean? | Save as a draft instead of queueing it — nothing publishes until the user approves it on the preview page Example: true |
post_status| Field | Type | Description |
|---|---|---|
| post_id | string required | Post ID returned by post_create Example: "3f0c…" |
post_update| Field | Type | Description |
|---|---|---|
| post_id | string required | Post ID Example: "3f0c…" |
| caption | string? | Replacement caption for the post itself, 1–2200 characters — not a carousel slide's own caption |
| first_comment | string? | Replacement first comment, 1–2200 characters. Pass null to remove one that was set; omit the field to leave it as it is Example: "#fallcollection #newin" |
| tiktok | object? | Replacement TikTok settings — the whole object, same shape as post_create's tiktok. Omit to leave them as they are Example: { "privacy_level": "SELF_ONLY" } |
| schedule_at | string? | New ISO 8601 publish time Example: "2026-08-05T09:00:00Z" |
post_cancel| Field | Type | Description |
|---|---|---|
| post_id | string required | Post ID Example: "3f0c…" |
post_retry| Field | Type | Description |
|---|---|---|
| post_id | string required | ID of a failed or partly-published post Example: "3f0c…" |
posts_list| Field | Type | Description |
|---|---|---|
| status | string? | draft | scheduled | publishing | published | partly | failed | cancelled Example: scheduled |
| limit | number? | Posts per page, 1–100 (default 20) Example: 20 |
| cursor | string? | Opaque next_cursor from a previous page |
| from | string? | Only posts with schedule_at at or after this ISO 8601 timestamp Example: "2026-08-03T00:00:00Z" |
| to | string? | Only posts with schedule_at at or before this ISO 8601 timestamp Example: "2026-08-09T23:59:59Z" |
media_upload| Field | Type | Description |
|---|---|---|
| url | string? | Publicly reachable http(s) URL to fetch — pass exactly one of url or data Example: "https://cdn.example.com/fall.jpg" |
| data | string? | File content as raw base64 or a data: URI — pass exactly one of url or data Example: "data:image/jpeg;base64,/9j/4AAQ…" |
| content_type | string? | MIME type of data when it is raw base64 — image/jpeg, video/mp4, or video/quicktime (otherwise taken from the data: URI or sniffed from the bytes) Example: video/mp4 |
tiktok_creator_info| Field | Type | Description |
|---|---|---|
| account_id | string required | A TikTok account ID from accounts_list Example: "7c2a…" |
Behavior annotations
Every tool states all four MCP behavior hints explicitly. The protocol's defaults are pessimistic — an unannotated tool is assumed to be a destructive, non-idempotent write — so declaring them lets a client run the reads unattended and save confirmation prompts for the one tool that deserves it.
| Annotation | Meaning | Tools |
|---|---|---|
| readOnlyHint | Changes nothing. Safe to call freely. | auth_status, accounts_list, posts_list, post_status |
| destructiveHint | Irreversible. Worth a confirmation prompt. | post_cancel |
| idempotentHint | Calling again with the same arguments has no extra effect. | the read tools, plus post_update |
| openWorldHint | Touches something outside Relay — Instagram, or an arbitrary URL. | post_create, media_upload |
post_create is deliberately not idempotent: two identical calls create two posts. Retry-safety is available on the REST API through the Idempotency-Key header, which an MCP tool call has nowhere to carry.Results & errors
Every tool returns both a JSON text block and structuredContent matching its declared output schema — so a client can render the text and a program can read the fields.
{
"content": [{ "type": "text", "text": "{ \"post\": { … } }" }],
"structuredContent": {
"post": { "id": "3f0c…", "status": "scheduled", "targets": [ … ] }
}
}- Domain errors — an unknown account ID, a caption over 2,200 characters, the wrong media count for a post type — come back as a tool result with
isError: trueand a human-readable message. The agent can read the message and fix the call itself. - Input-validation failures (arguments that don't match a tool's schema) arrive the same way — an
isErrorresult describing the mismatch, not a protocol error. Unexpected server failures also come back asisErrorwith a genericInternal error — try again; the details are logged server-side, never forwarded. - Authentication failures are HTTP
401on the transport, before any tool runs. - Rate limits are HTTP
429withRetry-After— the same budgets as the REST API (120/minute per key, 300/minute per user), counted per JSON-RPC message: a batched request of N tool calls costs N, and batches are capped at 20 messages per request. - Oversized requests are HTTP
413before any tool runs: the whole JSON-RPC body is capped at 1 MB. That cap is what boundsmedia_upload's inlinedatato roughly 700 KB of file once base64 has added its third — anything larger has to go throughPOST /v1/uploads, whose URL you then pass as a media entry. - Publish failures are never tool errors.
post_createsucceeds as soon as the post is queued; Instagram's verdict arrives later on the target timeline, so the agent must callpost_statusto know the outcome.
Agent workflow
The server delivers this guidance to agents automatically via its initialize instructions — what you see below is exactly what a connected agent receives:
Relay publishes posts to Instagram and TikTok. The path that works, in order: auth_status → confirm the key works and something is connected accounts_list → resolve a handle like "@northlight.co" to an account id (each has a platform) tiktok_creator_info → TikTok targets only: which privacy levels and interactions that creator allows right now media_upload → optional: store a file once (public URL, or small inline base64), reuse it across posts post_create → queue the post (targets + caption + media [+ tiktok] [+ schedule_at]), or stage it with draft: true post_status → poll until every target is published or failed post_retry → re-queue a failed or partly-published post after fixing the cause Rules: - Never guess an account ID or a post ID — resolve them with accounts_list and posts_list. - Scheduling is not instant publishing: post_create without schedule_at still goes out on the next scheduler tick, within a minute. - After creating a post, wait ~30 seconds before the first post_status call. Video takes longer than images — minutes, not seconds. - Confirm with the user before post_cancel. - TikTok targets need a tiktok object. For mode "direct" (the default) tiktok.privacy_level is required and has no default: ask the user who should see the post, and offer only the levels tiktok_creator_info returns. Comments, duets and stitches stay off unless the user turns them on. Say whether the post promotes the user's own brand (brand_organic) or a third party (brand_content) — branded content cannot be SELF_ONLY. mode "inbox" sends it to the creator's TikTok inbox to finish in the app instead. - TikTok takes one video, or 1–35 JPEG images as a photo post; it has no stories and no first comments. A post that targets Instagram as well is held to Instagram's limits too (at most 10 items). - Posting to TikTok means the user agrees to TikTok's Music Usage Confirmation (and its Branded Content Policy when brand_content is set) — make sure they know before you publish. - Media over ~700KB cannot go inline: the whole MCP request is capped at 1MB and base64 costs a third on top. POST the raw bytes to /v1/uploads on the same host as this endpoint instead — your API key, the file's Content-Type (image/jpeg, video/mp4, or video/quicktime), and Content-Length set to the exact byte count, which is required. It answers 201 with upload.url, a public link you pass as a media entry to post_create or as media_upload's url, and upload.expires_at, usually an hour out. Create the post before then; the post keeps its own permanent copy, so scheduling weeks ahead is fine. - Do not publish without an explicit go-ahead. To get one, call post_create with draft: true and give the user the preview_url it returns — that page shows the post exactly as Instagram and TikTok will render it, with Publish and Schedule buttons. A draft never publishes on its own, so this is the safe default when the user has not already told you to send it.
Prompts to try
Posts an agent creates are tagged source: "mcp", so they're easy to spot in the dashboard and in GET https://api.relay.lantean.tech/v1/posts.