# API keys Source: https://dev.magicpost.in/api-reference/api-keys Manage your Personal Access Tokens. Cookie session auth only. These endpoints power the **Settings → API & MCP** tab in the MagicPost web app. They use the existing cookie session, so they're **not callable from a script** — you can only manage your keys from the web UI. The endpoints are documented here for completeness. ## GET /api/v1/api-keys List all your keys (active + revoked). Never returns clear tokens. **Response** ```json theme={null} { "keys": [ { "id": "uuid", "prefix": "mp_abcdef", "name": "Claude Desktop", "scopes": ["mcp:v1"], "created_at": "2026-05-18T18:29:29Z", "last_used_at": "2026-05-18T18:29:42Z", "revoked_at": null } ] } ``` ## POST /api/v1/api-keys Create a new key. The clear token is returned **exactly once**. **Body** ```json theme={null} { "name": "My Cursor" } ``` * `name` (optional, max 64 chars) — friendly identifier shown in the UI **Response** (HTTP 201) ```json theme={null} { "key": { "id": "uuid", "prefix": "mp_xxx", "name": "My Cursor", ... }, "token": "mp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } ``` Constraints: * Maximum **10 active keys** per user (revoked keys don't count) * Returns 400 if you hit the cap ## DELETE /api/v1/api-keys/\ Revoke a key. Idempotent; revoking an already-revoked key returns 404. **Response** (HTTP 200) ```json theme={null} { "success": true } ``` Returns 404 if the key doesn't exist OR isn't owned by you (both surface as 404 to avoid leaking ownership info). ## GET /api/v1/auth/verify Validate a Bearer token. **PAT auth, not cookie auth** — this is the only key-management endpoint callable from a script. **Response** ```json theme={null} { "user_record_id": "recXXXXXXX", "email": "you@example.com", "scopes": ["mcp:v1"] } ``` Returns 401 if the token is invalid or revoked. # Metrics Source: https://dev.magicpost.in/api-reference/metrics Pull a compact analytics summary, trigger a fresh LinkedIn sync. ## GET /api/v1/metrics/summary Compact KPI snapshot + top N posts by impressions. **Query params** | Param | Type | Default | Values | | -------- | ------ | ------- | ----------------------------------------------- | | `period` | string | `30d` | `1d`, `7d`, `30d`, `90d`, `180d`, `365d`, `all` | | `top_n` | int | `5` | Clamped 1–20 | ```bash theme={null} curl "https://api.magicpost.in/api/v1/metrics/summary?period=30d&top_n=5" \ -H "Authorization: Bearer mp_xxx" ``` **Response** ```json theme={null} { "period": "30d", "profile": { "name": "Naïlé Titah", "headline": "CEO @MagicPost", "followers_total": 24174 }, "access": { "linkedin_connected": true, "impressions_access": true, "followers_data_available": true }, "followers": { "current_total": 24174, "gained": 327, "available_range_gained": 327, "data_available": true, "data_points": 30, "daily": [ { "date": "2026-07-26", "gained": 9 }, { "date": "2026-07-27", "gained": 14 } ], "coverage": { "complete": true, "date_from": "2026-06-28", "date_to": "2026-07-27" } }, "totals": { "posts_count": 10, "impressions": 56147, "likes": 740, "comments": 319, "reposts": 11 }, "averages_per_post": { "impressions": 5614.7, "likes": 74.0, "comments": 31.9, "reposts": 1.1 }, "top_posts": [ { "linkedin_share_urn": "urn:li:share:...", "post_url": "https://linkedin.com/feed/update/urn:li:...", "posted_at": "2026-05-12T07:30:00Z", "text_preview": "First 200 chars of the post...", "num_impressions": 12500, "num_likes": 180, "num_comments": 45, "num_reposts": 3 } ] } ``` `followers.gained` is the sum of LinkedIn's follower snapshots only when the whole selected period is covered. It is `null`, not `0`, when coverage is partial or unavailable. `followers.available_range_gained` contains the sum for the explicit `coverage.date_from` to `coverage.date_to` range. Use `coverage.complete` and `data_available` to distinguish full coverage, partial history, missing data, and measured zero growth. The `all` period cannot guarantee lifetime coverage and therefore returns `gained: null`. `followers.daily` contains the profile's total followers gained for each available day. These values are not attributed to individual posts. For a daily roll-up, request a multi-day period such as `7d` or `30d` once and use the returned points instead of making one `1d` request per day. ## GET /api/v1/linkedin-post-metrics List per-post metrics for the connected LinkedIn profile over an inclusive date range. This includes posts published natively on LinkedIn and posts published through MagicPost once they have been imported into MagicPost analytics. **Query params** | Param | Type | Default | Values | | ----------- | ------ | -------- | ------------------------------------------- | | `date_from` | string | required | ISO date (`YYYY-MM-DD`), inclusive | | `date_to` | string | required | ISO date (`YYYY-MM-DD`), inclusive | | `limit` | int | `50` | Clamped 1–100 | | `cursor` | string | — | Opaque cursor returned by the previous page | ```bash theme={null} curl "https://api.magicpost.in/api/v1/linkedin-post-metrics?date_from=2026-05-01&date_to=2026-06-30&limit=50" \ -H "Authorization: Bearer mp_xxx" ``` The response does not run an exact count over the LinkedIn posts table. Follow `next_cursor` while `has_more` is `true`. ```json theme={null} { "date_from": "2026-05-01", "date_to": "2026-06-30", "timezone": "UTC", "limit": 50, "has_more": false, "next_cursor": null, "posts": [ { "linkedin_activity_id": "7457337929711902720", "linkedin_post_id": "7457337929711902720", "post_url": "https://www.linkedin.com/feed/update/urn:li:activity:...", "posted_at": "2026-06-12T07:30:00Z", "text_preview": "First 200 characters...", "impressions": 12500, "impressions_available": true, "reactions": 180, "comments": 45, "reposts": 3, "impressions_updated_at": "2026-06-12T08:00:00Z" } ] } ``` `linkedin_activity_id` is the stable identifier used by the MagicPost LinkedIn analytics store. `linkedin_post_id` can be null for some native or company posts. Neither identifier is a MagicPost post UUID. `impressions_available` distinguishes an uncollected impression count (`null`) from a measured value of `0`. ## GET /api/v1/metrics/freshness Check the latest stored LinkedIn post-metrics timestamp without triggering a refresh. Use this endpoint before `/refresh-posts`. ```bash theme={null} curl "https://api.magicpost.in/api/v1/metrics/freshness?max_age_minutes=60" \ -H "Authorization: Bearer mp_xxx" ``` **Response** ```json theme={null} { "last_metrics_updated_at": "2026-08-05T08:12:00Z", "checked_at": "2026-08-05T08:42:00Z", "age_seconds": 1800, "max_age_minutes": 60, "data_available": true, "is_fresh": true, "should_refresh": false } ``` Skip the refresh when `should_refresh` is `false`. If no metrics timestamp is available, `data_available` is `false` and `should_refresh` is `true`. ## POST /api/v1/refresh-posts Trigger a fresh pull of LinkedIn data. **Rate-limited at 5 req/min.** ```bash theme={null} curl -X POST https://api.magicpost.in/api/v1/refresh-posts \ -H "Authorization: Bearer mp_xxx" \ -H "Content-Type: application/json" \ -d '{}' ``` **Response** ```json theme={null} { "success": true, "job_id": "uuid", "status": "pending" } ``` Use the returned `job_id` to poll the status endpoint until completion. ## GET /api/v1/refresh-posts/\ Poll refresh job status. ```bash theme={null} curl https://api.magicpost.in/api/v1/refresh-posts/uuid \ -H "Authorization: Bearer mp_xxx" ``` **Response** ```json theme={null} { "job_id": "uuid", "processed": 50, "total": 50, "status": "finished", "error": null } ``` `status` is one of `pending`, `running`, `finished`, `success`, `error`. Returns 404 if the job doesn't exist OR was started by another user. # Overview Source: https://dev.magicpost.in/api-reference/overview The MagicPost REST API — base URL, auth, response shape. The MagicPost REST API powers both the MagicPost web app and the MCP server. Use it directly when you want to script MagicPost operations without an LLM in the loop. ## Base URL ``` https://api.magicpost.in/api/v1 ``` (Staging: `https://apistaging.magicpost.in/api/v1`) ## Authentication All endpoints require a Bearer token in the `Authorization` header — see [Authentication](/essentials/authentication) for how to create one. ```bash theme={null} curl https://api.magicpost.in/api/v1/auth/verify \ -H "Authorization: Bearer mp_xxx" ``` There is one exception: the `/api/v1/api-keys/*` management endpoints (used by the MagicPost web UI) use **cookie session auth** instead. You can't use these from a script — generate keys from the web UI. ## Response shape Success responses are tailored to each endpoint. Error responses follow a common shape — see [Errors](/essentials/errors). ## Rate limits * **60 req/min** per token by default * **5 req/min** per token on `/refresh-posts` (it hits LinkedIn) Window resets at the start of every wall-clock minute. Exceeding returns HTTP 429. ## Endpoints Manage your tokens. Cookie auth only. Pull analytics summary, trigger refresh. List, fetch, create, update, delete (soft). Schedule, cancel, publish-now. ## OpenAPI An auto-generated OpenAPI 3 spec is in progress. In the meantime, the per-endpoint pages list path, method, params, and response shape. # Posts Source: https://dev.magicpost.in/api-reference/posts List, fetch, create, update your posts. ## GET /api/v1/posts List your posts by lifecycle status. **Query params** | Param | Type | Default | Values | | -------- | ------ | ----------- | --------------------------------- | | `status` | string | `scheduled` | `draft`, `scheduled`, `published` | | `limit` | int | `20` | Clamped 1–100 | | `offset` | int | `0` | Pagination | ```bash theme={null} curl "https://api.magicpost.in/api/v1/posts?status=scheduled&limit=10" \ -H "Authorization: Bearer mp_xxx" ``` **Response** ```json theme={null} { "status": "scheduled", "limit": 10, "offset": 0, "posts": [ { "id": "uuid", "post_content": "Multiple\n\nline post.", "created_at": "2026-05-12T10:00:00Z", "date_post": "2026-05-20", "hour_post": "14:00", "schedule_date": "2026-05-20T12:00:00Z", "schedule_timezone": "Europe/Paris", "planified_post": true, "published_post": false, "linked_in_url_post": null, "word_count": 152, "type": "Actionable", "category": "Education", "approved": null } ] } ``` ## GET /api/v1/posts/\ Fetch one post in full. 404 if not owned by you. ```bash theme={null} curl https://api.magicpost.in/api/v1/posts/uuid \ -H "Authorization: Bearer mp_xxx" ``` ## POST /api/v1/posts Create a draft post. **Body** ```json theme={null} { "content": "Hello LinkedIn world\n\nThis is line 2." } ``` * `content` (required) — post text. Line breaks (`\n`) are preserved verbatim and rendered on LinkedIn. This REST endpoint creates text-only drafts. MCP clients that support chat file references can then call `attach_media_to_post`; otherwise add images, videos, polls and `@mentions` from the MagicPost web UI. **Response** (HTTP 201) ```json theme={null} { "success": true, "post_id": "new-uuid", "created": true } ``` ## PATCH /api/v1/posts/\ Update the content of an existing post. **Body** ```json theme={null} { "content": "Updated text." } ``` **Response** ```json theme={null} { "success": true, "post_id": "uuid", "created": false } ``` Returns 404 if you don't own the post (IDOR guard). ## POST /api/v1/org/members/\/posts Create a draft owned by a member of the caller's organisation. The caller must be an organisation `super_admin`, and the target member must still belong to the same organisation. ```bash theme={null} curl -X POST \ https://api.magicpost.in/api/v1/org/members/member-uuid/posts \ -H "Authorization: Bearer mp_xxx" \ -H "Content-Type: application/json" \ -d '{"content":"A post drafted for this team member."}' ``` **Response** (HTTP 201) ```json theme={null} { "success": true, "post_id": "new-post-uuid", "member_user_id": "member-uuid", "created": true } ``` MCP clients should use `create_member_post`. To attach media, pass both the returned `post_id` and the same `member_user_id` to the appropriate media tool, then call `schedule_member_post`. # Scheduling Source: https://dev.magicpost.in/api-reference/scheduling Schedule, cancel, publish-now. ## POST /api/v1/posts/\/schedule Schedule a post for future publication on LinkedIn. **Body** | Field | Type | Default | Description | | -------------------- | -------------- | ---------------- | --------------------------------------------------------------------------------------- | | `at_iso` | string | — | ISO 8601 datetime. Naive (no offset) = interpreted in user TZ. With offset = converted. | | `timezone` | string \| null | user's stored TZ | IANA name override (`Europe/Paris`) | | `confirm_same_day` | bool | `true` | Warn if other posts exist that day | | `confirm_exact_slot` | bool | `true` | Warn if ±5min slot conflict (stronger) | | `approved` | bool \| null | `null` | Org approval workflow state | ```bash theme={null} curl -X POST https://api.magicpost.in/api/v1/posts/uuid/schedule \ -H "Authorization: Bearer mp_xxx" \ -H "Content-Type: application/json" \ -d '{"at_iso": "2026-06-01T14:00", "timezone": "Europe/Paris"}' ``` ### Success ```json theme={null} { "success": true, "utc_publish_date": "2026-06-01T12:00:00+00:00", "failed_urns": [] } ``` ### Same-day warning (HTTP 200) When `confirm_same_day: true` and other posts exist that day: ```json theme={null} { "success": false, "warning": "same_day_posts", "message": "User has 2 other post(s) scheduled on 2026-06-01 (Europe/Paris).", "existing": [ { "post_id": "abc", "preview": "5 leçons…", "date_post": "2026-06-01", "hour_post": "09:00" }, { "post_id": "def", "preview": "Mon erreur…", "date_post": "2026-06-01", "hour_post": "18:00" } ], "hint": "Ask the user to confirm, then retry with confirm_same_day=false." } ``` Re-send the request with `confirm_same_day: false` to force through. ### Exact-slot warning (HTTP 200) If another post is within ±5 min, this wins over `same_day_posts` (stronger signal): ```json theme={null} { "success": false, "warning": "exact_slot_conflict", "existing": [...], "hint": "..." } ``` ### LinkedIn token expired ```json theme={null} { "success": false, "error_type": "token_expired", "actionable": true, "hint": "Tell the user to reconnect LinkedIn from MagicPost > Settings." } ``` ### Other errors * `400 missing_data` — missing `at_iso` * `400 too_late` — more than 6 months in the future * `400 failed_tag` — `@mentions` couldn't be resolved (LinkedIn privacy) * `404 post_not_found` — IDOR or deleted ## POST /api/v1/posts/\/cancel-schedule Cancel a scheduled post. Idempotent — returns success even if not scheduled. ```bash theme={null} curl -X POST https://api.magicpost.in/api/v1/posts/uuid/cancel-schedule \ -H "Authorization: Bearer mp_xxx" ``` **Responses** * `200 { "success": true }` — scheduled, now cancelled * `200 { "success": true, "already_unscheduled": true }` — wasn't scheduled * `409 already_published` — can't cancel, already on LinkedIn ## POST /api/v1/posts/\/publish Publish a post **immediately**, regardless of any prior schedule. ```bash theme={null} curl -X POST https://api.magicpost.in/api/v1/posts/uuid/publish \ -H "Authorization: Bearer mp_xxx" ``` **Responses** * `200 { "success": true, ... }` — published, LinkedIn URL in response body * `400 token_expired` — `actionable: true` with reconnect hint * `409 already_published` * `404 post_not_found` # Authentication Source: https://dev.magicpost.in/essentials/authentication Personal Access Tokens (PATs) — how they work, where to use them, how to revoke them. Every call to `/api/v1/*` and every MCP tool invocation must carry a **Personal Access Token** (PAT) in the `Authorization` header: ```bash theme={null} Authorization: Bearer mp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ## How tokens look Tokens are formatted as `mp_<32 random URL-safe bytes>` — about 43 characters after the prefix. The full token is shown **exactly once** in the UI at creation time and is never displayed again. Only its `prefix` (e.g. `mp_abc123`) and metadata (name, created date, last used) are kept visible afterwards. Treat your tokens like passwords. Anyone with the token has the same access to your MagicPost account as you do via the API. ## Creating a token From the web app: **Settings → API & MCP → Create a key**. The dialog asks for an optional **name** (e.g. `Claude Desktop`, `My Cursor`, `Internal cron script`). The name has no functional effect — it just helps you tell tokens apart in the list and audit which integration was active when. You can hold up to **10 active tokens** per account. Revoked tokens don't count against the limit but remain visible in the history. ## Using a token ### With the MCP server In your MCP client config (Claude Desktop, Cursor, claude.ai), pass the token as a header via `mcp-remote`: ```json theme={null} { "mcpServers": { "magicpost": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.magicpost.in/mcp", "--header", "Authorization:Bearer mp_YOUR_TOKEN" ] } } } ``` ### With the REST API Just add the header to every request: ```bash theme={null} curl https://api.magicpost.in/api/v1/auth/verify \ -H "Authorization: Bearer mp_YOUR_TOKEN" ``` ```python theme={null} import os, httpx client = httpx.Client( base_url="https://api.magicpost.in/api/v1", headers={"Authorization": f"Bearer {os.environ['MAGICPOST_TOKEN']}"}, ) client.get("/posts", params={"status": "scheduled"}) ``` ## Revoking a token In **Settings → API & MCP**, hit **Revoke** on the token row. The change propagates within **60 seconds** (the MCP server caches token validity for that long, so a revoked token may still work for up to a minute after revocation). Revocation is irreversible. The MCP integration that uses that token will start receiving 401 responses — create a new token to replace it. ## Verifying a token The `GET /api/v1/auth/verify` endpoint is the canonical way to check whether a token is currently valid. It returns the user record id, email and granted scopes if the token is good: ```bash theme={null} curl https://api.magicpost.in/api/v1/auth/verify \ -H "Authorization: Bearer mp_xxx" ``` ```json theme={null} { "user_record_id": "recXXXXXXX", "email": "you@example.com", "scopes": ["mcp:v1"] } ``` ## Scopes All v1 tokens currently have a single scope: `mcp:v1`. It grants full access to every `/api/v1/*` endpoint of the owning user. Finer-grained scopes (e.g. read-only, or "post:write" only) will land in a future version — existing tokens will keep their full access at that point. ## Token security checklist * ✅ Store tokens in env vars or secret managers, never in client-side code * ✅ Use a different token per device or integration so you can revoke precisely if one leaks * ✅ Rotate tokens occasionally (every \~6 months for high-value automations) * ❌ Never commit tokens to git, even in a private repo * ❌ Never paste a token in a public log, screenshot, or support thread — revoke and recreate if you suspect leak # Errors & rate limits Source: https://dev.magicpost.in/essentials/errors Error shape, HTTP status mapping, retry policy. ## Error response shape All `/api/v1/*` errors return JSON with this shape: ```json theme={null} { "error": "Human-readable message", "error_type": "token_expired" } ``` Some errors carry extra fields: * **`actionable: true`** — the user can fix the cause themselves. Always present with a `hint`. Example: a LinkedIn token expired in MagicPost. * **`hint: "Tell the user to reconnect LinkedIn from MagicPost > Settings"`** — the exact message to surface in your UI / LLM conversation. * **`existing: [...]`** — present on schedule warnings; the list of posts already on the same day or in the same slot. ## Error type catalog | `error_type` | HTTP | What it means | | ------------------- | -------- | --------------------------------------------------------------------------------------------------------- | | `auth_required` | 401 | No Bearer token, or token doesn't start with `mp_` | | `auth_unavailable` | 503 | Auth service degraded (Supabase outage). Transient — retry with backoff. | | `user_not_found` | 401 | Token shape OK but unknown / revoked | | `rate_limited` | 429 | Too many requests in the current 1-minute window. See [rate limits](#rate-limits). | | `missing_data` | 400 | Required input field absent (e.g. `at_iso` for schedule) | | `empty_content` | 400 | Post content is empty / whitespace only | | `post_not_found` | 404 | Post doesn't exist OR is owned by another user. Both surface as 404 to avoid leaking ownership. | | `token_expired` | 400 | LinkedIn OAuth token (NOT your MagicPost PAT) expired. `actionable: true` — user must reconnect LinkedIn. | | `failed_tag` | 400 | One or more `@mentions` in the post can't be tagged (LinkedIn privacy settings). | | `too_late` | 400 | Trying to schedule more than 6 months in the future | | `already_published` | 409 | Trying to cancel-schedule or republish a post that already went out | | `db_error` | 500 | Internal DB issue. Detail in our logs / Slack, not exposed in response. | | `unknown_error` | 500 | Anything we didn't anticipate. Tracked in Sentry on our side. | | `network_error` | (varies) | The MCP server couldn't reach our API. Surfaced by the MCP tool layer, not by the API itself. | ## Schedule warnings — not errors These are returned as HTTP **200** with `success: false`. They're informational, not failures: | `warning` | When | | --------------------- | -------------------------------------------------------------------------------- | | `same_day_posts` | At least one other post is scheduled the same day in the user's timezone | | `exact_slot_conflict` | Another post is scheduled within ±5 minutes of the target slot (stronger signal) | To proceed despite the warning, re-call with `confirm_same_day: false` (and/or `confirm_exact_slot: false`). ## Rate limits Per-PAT, per-minute, fixed window: | Class | Limit | Endpoints | | --------- | ---------------- | --------------------------------------- | | Default | **60 req / min** | Everything by default | | Expensive | **5 req / min** | `/api/v1/refresh-posts` (hits LinkedIn) | When you exceed the limit, you get HTTP 429 with: ```json theme={null} { "error": "Rate limit exceeded (60 req/min for this endpoint class)", "error_type": "rate_limited", "current_count": 61, "limit": 60, "hint": "Wait until the start of the next minute and retry." } ``` The window resets at the start of every wall-clock minute. ## Retry policy | Error | Retry? | | ----------------------------------------------------- | -------------------------------------------------- | | `network_error` | Yes — exponential backoff, up to 2 retries | | 5xx (`db_error`, `unknown_error`, `auth_unavailable`) | Yes — backoff | | `rate_limited` | Yes, after waiting until the next minute | | 4xx (everything else) | **No** — the request is wrong, retrying won't help | The MCP server already implements this policy internally; your tools see only the final outcome. # Introduction Source: https://dev.magicpost.in/introduction MagicPost programmatic API and MCP server for LLM integrations. MagicPost exposes a **public REST API** (`/api/v1/*`) and an **MCP server** so you can manage LinkedIn posts, schedule them, and pull analytics from any program or LLM client. Connect Claude Desktop to your MagicPost account in 3 steps. The 31 tools available to your LLM client. Raw REST endpoints with curl examples. How API keys work, scopes, rotation. ## What you can do * **Generate posts** in your own voice or an imported creator's style * **Attach chat-provided media** to a draft before scheduling or publishing * **Schedule and publish posts** to LinkedIn (with same-day & exact-slot warnings) * **Pull a compact analytics summary** (top posts, totals, averages) * **List and edit your drafts** * **Find inspiration** from a global catalogue of high-performing posts * **Detect and manage leads** from your posts' engagement, scored against your ICPs * **Manage your org** as a super\_admin — teammates' analytics, posts, and scheduling * **Trigger a fresh sync** of your LinkedIn data and wait for completion Most operations are scoped to your own account. Org super\_admins can additionally read and act on their teammates' posts and analytics. ## Two ways to use it The MCP server at `mcp.magicpost.in` exposes the API as **tools** your LLM can call. The recommended way for users who want to operate MagicPost from inside a conversation. [→ See MCP setup](/mcp/overview) The same API is also reachable directly at `api.magicpost.in/api/v1/*` with a `Bearer mp_*` token. Use this when you don't need an LLM in the loop. [→ See API reference](/api-reference/overview) ## Status * **Base URL** (production): `https://api.magicpost.in` * **MCP endpoint**: `https://mcp.magicpost.in/mcp` * **Authentication**: Personal Access Tokens (PATs), generated from [your account settings](https://app.magicpost.in/account?tab=api-keys) * **Versioning**: only `v1` exists today. Breaking changes will ship under `v2`. # Attach media Source: https://dev.magicpost.in/mcp/media-attachments Attach a chat-provided or local image, video, GIF, or PDF to a MagicPost draft. MagicPost supports two media workflows: * `attach_media_to_post` copies a file reference supplied by a chat host such as claude.ai; * `prepare_post_media_upload` and `complete_post_media_upload` let code and CLI clients upload bytes from their local filesystem. Both workflows link one file to an existing draft. You can then schedule or publish the post with its media. Organisation `super_admins` can use the same workflows for a team member: 1. Call `create_member_post` with the member's `member_user_id`. 2. Pass the returned `post_id` and the same `member_user_id` to the media tool. 3. Call `schedule_member_post` with that member and post. ## Chat-host workflow Call `create_or_update_post` and keep the returned `post_id`. Add the file to the conversation, then call `attach_media_to_post` with the draft's `post_id`. For a member-owned draft, also pass `member_user_id`. You can provide optional alt text for an image. After the attachment succeeds, call `schedule_post` or `publish_post_now` with the same `post_id`. For example: > Create a LinkedIn draft from this text, attach the image in this > conversation with the alt text "Product analytics dashboard", then schedule > it for tomorrow at 14:00. Your MCP client must expose the uploaded file to the tool as a temporary file reference. If `attach_media_to_post` is not offered the file, create the draft in chat and add the media from the MagicPost web app. ## Local-file workflow Use this workflow with Claude Code or another MCP client that can read a local file and perform an HTTPS upload. Call `create_or_update_post` and keep the returned `post_id`. Call `prepare_post_media_upload` with the draft's `post_id`, the file's base name, and its MIME type. The tool returns an `upload_id` and a short-lived multipart `upload_request`. For a member-owned draft, also pass `member_user_id`. Send the local file to the signed URL exactly as described by `upload_request`. The URL expires after 15 minutes and must not be reused. Call `complete_post_media_upload` with the same `post_id` and `upload_id`. Pass the same `member_user_id` again for a member-owned draft. MagicPost validates the stored file before attaching it to the draft. Call `schedule_post` or `publish_post_now` with the draft's `post_id`. In Claude Code, a request can be as simple as: > Create a LinkedIn draft from this text, attach the local file at > `/absolute/path/to/product.png`, then schedule it for tomorrow at 14:00. Claude Code can read the local file, call the two MagicPost upload tools, and perform the signed HTTPS upload between them. For manual troubleshooting, the equivalent upload shape is: ```bash theme={null} curl --fail-with-body --request PUT \ --header 'x-upsert: false' \ --header 'cache-control: max-age=3600' \ --form 'cacheControl=3600' \ --form 'file=@/absolute/path/to/product.png;type=image/png' \ '' ``` ## Supported files | Media | Formats | Maximum size | | --------------- | -------------------------------------- | -----------: | | Images | JPEG, PNG, WebP, BMP, HEIC, HEIF, AVIF | 50 MB | | Animated images | GIF | 10 MB | | Videos | MP4, MOV, AVI, WebM, WMV | 200 MB | | Documents | PDF | 100 MB | Only one file can be attached to a post through either workflow. ## Draft requirements The target post must: * belong to your MagicPost account, or to a current member of your organisation when you are a `super_admin`; * be unpublished and not currently publishing; * be unscheduled; * have no poll; * have no existing media. If the draft is already scheduled, ask the client to cancel the schedule, attach the media, then schedule it again. The tool does not replace or remove existing media. ## Client compatibility MagicPost declares `media_file` as a file input for MCP hosts that support chat-provided files. File attachment behavior still depends on the host and its MCP integration. For claude.ai and compatible ChatGPT clients, attach the file to the same conversation and explicitly ask the assistant to use it with `attach_media_to_post`. Claude Code and CLI hosts do not pass local paths to a remote MCP server. They must use the `prepare_post_media_upload` / `complete_post_media_upload` workflow instead. A local path is read by the client, never by the MagicPost server. ## Privacy and transfer The temporary file URL supplied by a chat host is used only during the tool call. For local uploads, the signed storage URL expires after 15 minutes. MagicPost validates the file type, size, MIME type, and signature before linking the stored copy to your draft. Temporary and signed URLs are not stored in the post or included in tool audit data. The MCP cannot browse your MagicPost media library or retrieve media from past posts. ## Common errors | Error | What to do | | ----------------------------------------- | ----------------------------------------------------------------- | | File not available to the tool | Attach the file again in the current conversation and retry. | | Local path sent to `attach_media_to_post` | Use the prepare/complete local-file workflow. | | Signed upload expired | Call `prepare_post_media_upload` again to create a new upload. | | Upload validation failed | Verify the extension, MIME type, size, and actual file contents. | | Unsupported type or file too large | Convert or compress the file to a supported format and size. | | Post is scheduled | Cancel the schedule, attach the file, then schedule it again. | | Post already has media | Use a different draft or edit the media in the MagicPost web app. | | Post contains a poll | Remove the poll in the MagicPost web app or use a separate draft. | ## Tool details See the generated schemas for [`attach_media_to_post`](/mcp/tools#attach-media-to-post), [`prepare_post_media_upload`](/mcp/tools#prepare-post-media-upload), and [`complete_post_media_upload`](/mcp/tools#complete-post-media-upload). # MCP overview Source: https://dev.magicpost.in/mcp/overview What the MagicPost MCP server is and how it fits into your LLM client. The MagicPost MCP server lets any [Model Context Protocol](https://modelcontextprotocol.io) client — Claude Desktop, Cursor, claude.ai, custom agents — operate your MagicPost account from within a conversation, without leaving the chat. ## What it does * **Generates** posts in your own voice — or in an imported creator's writing style * **Reads** your analytics, lists your draft / scheduled / published posts * **Writes** new drafts, schedules posts (same-day & exact-slot warnings), cancels, publishes now * **Attaches media** supplied by the chat to a draft before scheduling or publishing * **Finds inspiration** in a global catalogue of high-performing posts * **Detects & manages leads** from your posts' engagement, scored against your ICPs and synced to your CRM * **Manages your org** (super\_admins) — read teammates' analytics and posts, schedule on their behalf * **Refreshes** your LinkedIn data and waits for completion It's a thin, stateless proxy over our [REST API](/api-reference/overview). Everything you can do with curl, you can do via the MCP — just from an LLM conversation. ## Architecture ``` ┌─────────────────────┐ │ MCP client │ Claude Desktop, Cursor, claude.ai, ... │ (your LLM tool) │ └──────────┬──────────┘ │ HTTPS, Authorization: Bearer mp_* ▼ ┌─────────────────────┐ │ mcp.magicpost.in │ Validates the PAT (cached 60s), │ (MCP server) │ forwards calls to api.magicpost.in └──────────┬──────────┘ │ HTTPS, same PAT ▼ ┌─────────────────────┐ │ api.magicpost.in │ Our Flask backend. │ /api/v1/* │ Runs your operation against Supabase, └─────────────────────┘ Airtable, LinkedIn OAuth tokens, etc. ``` ## Endpoint ``` https://mcp.magicpost.in/mcp ``` Transport: `streamable-http` (current MCP standard, stateless mode, JSON responses). ## Available tools The server exposes 31 tools — see the full [catalog](/mcp/tools). #### Generation & styles | Tool | Purpose | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `generate_post` | Generate a new LinkedIn post and persist it as a draft. | | `list_my_writing_styles` | List the LinkedIn profiles the user has imported as writing-style candidates, including which one is currently active in the UI. | | `find_writing_style_profile` | Search the global LinkedIn profile catalog by name or LinkedIn URL. | | `add_writing_style_profile` | Attach a LinkedIn profile to the user's account as a writing-style candidate, scraping its posts if needed. | #### Posts | Tool | Purpose | | ---------------------------- | ----------------------------------------------------------------------- | | `list_my_posts` | List the user's posts filtered by lifecycle status. | | `get_post` | Fetch the full record for a single MagicPost owned by the user. | | `create_or_update_post` | Create a new draft post (no post\_id) or update an existing one's text. | | `attach_media_to_post` | Attach one chat-provided image, video, or PDF to a draft. | | `prepare_post_media_upload` | Prepare a short-lived upload for a local image, video, or PDF. | | `complete_post_media_upload` | Validate an uploaded local file and attach it to its draft. | #### Scheduling | Tool | Purpose | | ----------------------- | ----------------------------------------------------- | | `schedule_post` | Schedule a post for future publication on LinkedIn. | | `cancel_scheduled_post` | Cancel a scheduled post. | | `publish_post_now` | Publish a post to LinkedIn immediately (no schedule). | #### Analytics | Tool | Purpose | | ---------------------------- | ---------------------------------------------------------------------- | | `list_linkedin_post_metrics` | List per-post LinkedIn metrics for an inclusive date range. | | `get_metrics_summary` | Get a compact summary of the user's LinkedIn analytics. | | `get_metrics_freshness` | Check when LinkedIn post metrics were last updated. | | `refresh_my_posts` | Trigger a fresh pull of the user's LinkedIn posts and wait until done. | #### Inspiration | Tool | Purpose | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `search_inspiration_posts` | Search the global LinkedIn inspiration catalogue (NOT the user's own posts) for high-performing examples to draw inspiration from. | #### Leads & CRM | Tool | Purpose | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_leads` | List the user's LinkedIn leads (prospects detected from the engagement on their posts and qualified against their ICPs). | | `get_lead` | Full detail of one lead: profile, interactions (likes/comments on the user's posts), qualification reasoning, icebreaker, and CRM sync state (`deliveries` to connected external CRMs). | | `list_icps` | List the user's ICPs (Ideal Customer Profiles). | | `get_leads_summary` | Leads quota status: trial state (is\_trial, trial\_days\_remaining, trial\_ends\_at), monthly quota (leads\_monthly\_included, leads\_consumed\_in\_period, leads\_remaining), top-ups (topup\_leads\_remaining, topups\_detail), and renewal date (period\_end). | | `get_detection_status` | Poll the status of running lead detections (they are async and typically take 2-15 minutes). | | `detect_leads` | Detect new leads from the engagement (likes + comments) on one of the user's LinkedIn posts. | | `update_lead` | Update a lead's pipeline fields. | #### Org / team | Tool | Purpose | | ------------------------- | ---------------------------------------------------------------- | | `list_org_members` | List the active members of your organisation. | | `get_org_metrics_summary` | Compact analytics for the org or one specific member. | | `list_org_member_posts` | List posts across the org, optionally scoped to one member. | | `get_org_member_post` | Fetch the full record of ONE post owned by a member of your org. | | `create_member_post` | Create a draft owned by a specific organisation member. | | `schedule_member_post` | Schedule a post on behalf of an org member. | ## What it does NOT do (yet) * **Media library browsing** — the MCP can attach a file supplied by the chat host or uploaded from a supported local client, but it cannot search or reuse media already stored in MagicPost. * **Multiple media files** — one image, video, GIF, or PDF can be attached to a draft. * **@mentions** that need LinkedIn tag resolution — the LLM cannot pick the right LinkedIn profile from a name. Finalize @mentions in the web UI. Most tools are scoped to your own account. Org `super_admins` additionally get read/scheduling access to their teammates' posts and analytics via the `*_org_*` / `*_member_*` tools. ## Next steps The flagship LLM client. Remote MCP with local-file media uploads. Same backend, IDE workflow. Inputs, outputs, examples for the 31 tools. Add a chat-provided or local image, video, GIF, or PDF before publishing. The REST endpoints behind the MCP. # Setup Claude Code Source: https://dev.magicpost.in/mcp/setup-claude-code Connect Claude Code to MagicPost and attach local media files. Claude Code connects directly to the remote MagicPost MCP server over Streamable HTTP. No local MCP bridge is required. ## Connect From a terminal, add MagicPost to the current project: ```bash theme={null} claude mcp add --transport http --scope local \ magicpost https://mcp.magicpost.in/mcp ``` Then start Claude Code and run: ```text theme={null} /mcp ``` Select MagicPost and complete the OAuth flow in your browser. Use `claude mcp list` or `/mcp` to verify that the server is connected. ## Attach a local file A remote MCP server cannot read a path on your machine. MagicPost therefore uses a signed two-step upload: 1. `prepare_post_media_upload` prepares a short-lived upload request. 2. Claude Code reads and uploads the local file over HTTPS. 3. `complete_post_media_upload` validates and attaches it to the draft. Ask Claude Code: > Create a LinkedIn post from this text, attach > `/absolute/path/to/product.png`, and schedule it for tomorrow at 09:00 > Europe/Paris. Claude Code should create the draft, prepare the upload, send the local bytes, complete the attachment, and then schedule the post. It should surface any same-day or exact-slot warning before forcing the schedule. Local paths only work through the prepare/complete workflow. Do not pass a path as `media_file` to `attach_media_to_post`; that tool expects a temporary file reference supplied by a compatible chat host. ## Supported media See [Attach media](/mcp/media-attachments) for supported formats, size limits, draft requirements, and troubleshooting. # Claude Desktop Source: https://dev.magicpost.in/mcp/setup-claude-desktop Connect Claude Desktop to MagicPost in 3 steps. Claude Desktop talks to MCP servers via stdio. Since MagicPost MCP is HTTP, we use the open-source [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge to convert stdio ↔ HTTP locally. ## Prerequisites * macOS, Windows or Linux with Claude Desktop installed * [Node.js](https://nodejs.org) 18+ available on your PATH (`npx` needs it) * A MagicPost API key — see [creating a key](/essentials/authentication#creating-a-token) ## Configure | OS | Path | | ------- | ----------------------------------------------------------------- | | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | | Linux | `~/.config/Claude/claude_desktop_config.json` | Open the file (create it if missing) and add a `mcpServers` entry: ```json theme={null} { "mcpServers": { "magicpost": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.magicpost.in/mcp", "--header", "Authorization:Bearer mp_YOUR_TOKEN_HERE" ] } } } ``` When you create the key from [Settings → API & MCP](https://app.magicpost.in/account?tab=api-keys), the dialog includes a copy-paste-ready version of this exact snippet with the token already filled in. On macOS, **Cmd+Q** (not just close window). MCP servers are only loaded at startup. ## Verify After relaunch, open a new conversation and click the tools indicator in the input bar. You should see `magicpost` listed with 31 tools available. Try: ``` List my scheduled posts on MagicPost ``` Claude will call `list_my_posts` and read back what's coming up. ## Troubleshooting 1. Open the Claude Desktop logs (Preferences → Developer → Open log directory) 2. Look for `mcp.log` or `claude_mcp.log` 3. Common causes: * JSON syntax error in `claude_desktop_config.json` — validate it with `python -m json.tool claude_desktop_config.json` * `npx` not on PATH — install Node via nvm or homebrew * Network blocked — confirm you can `curl https://mcp.magicpost.in/.well-known/oauth-protected-resource` The PAT is invalid or revoked. Generate a new one from [Settings → API & MCP](https://app.magicpost.in/account?tab=api-keys) and replace it in the config. The MCP bridge might have crashed silently. Restart Claude Desktop. If the issue persists, run the bridge manually to see its output: ```bash theme={null} npx -y mcp-remote https://mcp.magicpost.in/mcp \ --header "Authorization:Bearer mp_YOUR_TOKEN" ``` ## Privacy `mcp-remote` runs on **your machine** and forwards calls to `mcp.magicpost.in`. Your token never leaves your machine via a third party. The MagicPost MCP server validates it and acts on your behalf. # Cursor Source: https://dev.magicpost.in/mcp/setup-cursor Connect Cursor to MagicPost via MCP. Cursor 0.46+ ships with native MCP support. Both **stdio** and **streamable HTTP** transports are supported, so you have two options. ## Option A — Native HTTP (recommended) Cursor → Settings → **Features** → **MCP** → **+ Add new MCP server**. | Field | Value | | ----------- | ------------------------------------- | | **Name** | `magicpost` | | **Type** | `streamable-http` | | **URL** | `https://mcp.magicpost.in/mcp` | | **Headers** | `Authorization: Bearer mp_YOUR_TOKEN` | Cursor connects on save. You can see "31 tools loaded" in the server row. ## Option B — stdio bridge (Cursor \< 0.46) Same `mcp-remote` flow as [Claude Desktop](/mcp/setup-claude-desktop) — edit `~/.cursor/mcp.json`: ```json theme={null} { "mcpServers": { "magicpost": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.magicpost.in/mcp", "--header", "Authorization:Bearer mp_YOUR_TOKEN" ] } } } ``` ## Use it from the chat Open the Cursor chat (`Cmd+L`), make sure **Composer + agent mode** is enabled, and ask: ``` Summarize my MagicPost LinkedIn analytics for the last 30 days ``` Cursor will prompt to approve the `get_metrics_summary` tool call (you can auto-approve from the same Settings panel if you want). # Tools catalog Source: https://dev.magicpost.in/mcp/tools The MCP tools — inputs, outputs, examples. This page is auto-generated from the MCP server's `tools/list`. Run `scripts/sync-tools.py` after backend changes to refresh it. ## generate\_post Generate a new LinkedIn post and persist it as a draft. ## Inputs * subject: required. What the post should be about. Free text. * type: optional. High-level intent: 'actionable' → tips, methods, operational lessons 'aspirational' → success stories, positive messaging 'introspective' → reflections, takes, vented frustration 'promotional' → launches, lead magnets, valuesales When omitted, the type is auto-detected from the subject. * language: optional. Defaults to the user's UI preference. * tone: optional. Same vocabulary as the UI ('standard', 'friendly', etc). * hook: optional. A custom opening line. Inserted verbatim. * guideline: optional. Extra instructions appended to the prompt. * signature: optional. Custom signature. * writing\_style\_urn: optional. ## Writing style resolution * DEFAULT (writing\_style\_urn empty) — uses the user's configured style: if their UI has `profilePostsActive=true` with a profile URN attached, that profile's recent posts feed the prompt as few-shot examples. If `profilePostsActive=false`, no style is injected. * "Generate in MY style" → leave writing\_style\_urn empty. * "Generate in the style of ``" — multi-step flow: 1. Call list\_my\_writing\_styles() to check if that person is already imported on the user's account. 2. If not, call `find_writing_style_profile("")` to locate a candidate URN with metadata. Profiles with `ready_to_use=true` can be attached immediately. 3. Call add\_writing\_style\_profile(profile\_url) to attach the profile to the user's account. If the response is `status: 'scraping_in_progress'` or `'import_started'`, tell the user to wait 1-3 minutes and re-call add\_writing\_style\_profile (idempotent — it flips to 'ready' once enough posts are scraped). 4. Once `status: 'ready'` or `'already_imported'`, call `generate_post(..., writing_style_urn=)`. This overrides `profilePostsActive` for THIS call only. ## Rate limits Capped at 3 generations per minute, 20 per day, and 50 per week. A 429 response indicates which window was hit (rate\_limited\_generation) — surface the limit reset hint to the user verbatim. ## Errors that need user action * credits\_insufficient (402) → user needs to upgrade. * writing\_style\_not\_imported (400) → the URN wasn't on the user's account; route through the import flow first. * rate\_limited\_generation (429) → cap hit; surface counts + hint. | Input | Type | Default | Description | | ------------------- | ---------------- | ------- | ----------------- | | `subject` | `string` | — | Subject | | `type` | `string \| null` | null | Type | | `language` | `string \| null` | null | Language | | `tone` | `string \| null` | null | Tone | | `hook` | `string \| null` | null | Hook | | `guideline` | `string \| null` | null | Guideline | | `signature` | `string \| null` | null | Signature | | `writing_style_urn` | `string \| null` | null | Writing Style Urn | *** ## list\_my\_writing\_styles List the LinkedIn profiles the user has imported as writing-style candidates, including which one is currently active in the UI. Use this before find\_writing\_style\_profile when the user asks to generate "in the style of ``" — if the person is already in their imported list, skip search/import and pass the URN directly to generate\_post. ## Returns `{success, count, writing_styles: [WritingStyleEntry, ...]}` Each WritingStyleEntry has: * urn: opaque LinkedIn URN (use it as `writing_style_urn`) * display\_name: human-readable name * profile\_url: full LinkedIn URL (may be null on legacy entries) * is\_active: True for the currently active style (the UI default) * valid\_posts: how many of this profile's posts we have ingested * ready\_to\_use: True iff valid\_posts >= 30 (below that the generated style will be weak) *No inputs.* *** ## find\_writing\_style\_profile Search the global LinkedIn profile catalog by name or LinkedIn URL. Use this AFTER list\_my\_writing\_styles, when the person the user wants to mimic is not already imported on their account. The returned results indicate `ready_to_use` (true iff the profile already has > \= 30 valid posts ingested in our DB). * query: a person's full name (e.g. "Gary Vaynerchuk") OR a LinkedIn URL. Both shapes are handled. * limit: up to 25, default 10. ## Returns `{success, query, count, results: [...]}` Each result has: urn, full\_name, headline, profile\_url, followers, valid\_posts, ready\_to\_use. After picking a candidate, call add\_writing\_style\_profile(profile\_url) to attach it to the user's account. If the candidate's `ready_to_use` is false, the import will still succeed but the import endpoint will return `status: 'scraping_in_progress'` until enough posts are ingested (\~2 minutes). | Input | Type | Default | Description | | ------- | --------- | ------- | ----------- | | `query` | `string` | — | Query | | `limit` | `integer` | `10` | Limit | *** ## add\_writing\_style\_profile Attach a LinkedIn profile to the user's account as a writing-style candidate, scraping its posts if needed. Idempotent — re-calling with the same URL is safe. Use this exact re-call pattern when the response indicates scraping is in progress. ## Inputs * profile\_url: full LinkedIn URL ([https://www.linkedin.com/in/](https://www.linkedin.com/in/)...). ## Returns One of these `status` values (HTTP status varies): * 'already\_imported' (200) — URN was already on the user's account. Proceed straight to generate\_post. * 'ready' (200) — profile had >= 30 valid posts. Just attached. Proceed straight to generate\_post. * 'scraping\_in\_progress' (202) — profile is known but understocked. A scrape was queued; the URN is already attached. Tell the user to wait \~2 minutes and re-call this same tool — it will flip to 'ready' once enough posts are scraped. * 'import\_started' (202) — profile was unknown to our DB; an initial scrape was queued. Tell the user to wait \~3 minutes and re-call this tool. * 'quota\_exceeded' (402) — user's plan limit reached. Surface the hint to the user verbatim. * 'trial\_not\_eligible' (403) — trial accounts cannot import new writing styles. The user must upgrade. Generation in the user's own style is still available. * 'invalid\_url' (400) — URL doesn't resolve to a LinkedIn profile. * 'import\_failed' (4xx) — could not fetch the profile. Verify the URL is correct and the profile is public. Each response shape (when present) includes urn, display\_name, profile\_url, valid\_posts. Use the returned `urn` as `writing_style_urn` in generate\_post once status is `ready` or `already_imported`. | Input | Type | Default | Description | | ------------- | -------- | ------- | ----------- | | `profile_url` | `string` | — | Profile Url | *** ## list\_my\_posts List the user's posts filtered by lifecycle status. * 'draft': created but not scheduled and not yet published * 'scheduled': planified for future publication (sorted by schedule date asc) * 'published': already on LinkedIn (sorted by created\_at desc) Default is 'scheduled' because the most common LLM-driven question is "what's coming up?". When the user asks about drafts or published posts explicitly, pass the matching status — do NOT rely on the default. | Input | Type | Default | Description | | -------- | --------- | ------------- | ----------- | | `status` | `string` | `'scheduled'` | Status | | `limit` | `integer` | `20` | Limit | | `offset` | `integer` | `0` | Offset | *** ## get\_post Fetch the full record for a single MagicPost owned by the user. OWN POSTS ONLY. This tool returns `error_type='post_not_found'` (404) for any post you don't own — including posts of your org teammates, even if you are an org admin. To read a TEAMMATE's post, use `get_org_member_post(member_user_id=..., post_id=...)` instead. So if the post id came from `list_org_member_posts` or relates to another member, do NOT call this tool. `post_id` MUST be the MagicPost UUID (e.g. "16a63f9a-0e13-43eb-b502-..."). It is NOT the LinkedIn share URN exposed as `linkedin_share_urn` by `get_metrics_summary`. Passing a URN returns `error_type='invalid_post_id_format'` (400). To resolve a `linkedin_share_urn` into a MagicPost UUID: 1. Call `list_my_posts(status="published")`. 2. Find the row whose `linked_in_url_post` contains the URN. 3. Use that row's `id` here. Posts published outside MagicPost are not in this table and cannot be fetched with this tool. | Input | Type | Default | Description | | --------- | -------- | ------- | ----------- | | `post_id` | `string` | — | Post Id | *** ## create\_or\_update\_post Create a new draft post (no post\_id) or update an existing one's text. This tool only creates or updates text. To add an image, video, or PDF, use attach\_media\_to\_post for a chat-provided file, or the prepare\_post\_media\_upload / complete\_post\_media\_upload flow for a local file in a code or CLI client. On success returns `{success: True, post_id, created: bool}` — use `post_id` (NOT `id`) when chaining into schedule\_post or publish\_post\_now. | Input | Type | Default | Description | | --------- | ---------------- | ------- | ----------- | | `content` | `string` | — | Content | | `post_id` | `string \| null` | null | Post Id | *** ## attach\_media\_to\_post Attach one chat-provided image, video, or PDF to a draft. Call this after create\_or\_update\_post and before schedule\_post or publish\_post\_now. The post must be an unscheduled, unpublished draft, must not contain a poll, and must not already have media. For a draft created by `create_member_post`, pass the same `member_user_id` here, then use `schedule_member_post`. Only an org super\_admin can attach media to a member-owned draft. `media_file` is a temporary file reference supplied by the chat host. Never invent or reuse its `download_url`: it is consumed during this tool call and copied into permanent MagicPost storage. | Input | Type | Default | Description | | ---------------- | ---------------- | ------- | -------------- | | `post_id` | `string` | — | Post Id | | `media_file` | `FileReference` | — | | | `alt_text` | `string \| null` | null | Alt Text | | `member_user_id` | `string \| null` | null | Member User Id | *** ## prepare\_post\_media\_upload Prepare a short-lived upload for a local image, video, or PDF. Use this only when the user explicitly asks to attach a local file and the MCP client can upload local bytes over HTTPS, such as Claude Code. This remote MCP server cannot read a local file path. Pass only the file's base name and optional MIME type. The target post must be an owned, unscheduled, unpublished draft with no poll or existing media. On success, upload the local file bytes according to `upload_request` within `expires_in_seconds`, then call complete\_post\_media\_upload with the returned `upload_id`. The upload request uses multipart/form-data: * HTTP method: PUT * file field: `file` * additional field: `cacheControl=3600` * headers: `x-upsert: false` and `cache-control: max-age=3600` Do not pass a local path as `file_name`, and never reuse or disclose the signed upload URL after the upload completes. For a member-owned draft, pass the `member_user_id` returned by `list_org_members` and pass it again to complete\_post\_media\_upload. | Input | Type | Default | Description | | ---------------- | ---------------- | ------- | -------------- | | `post_id` | `string` | — | Post Id | | `file_name` | `string` | — | File Name | | `mime_type` | `string \| null` | null | Mime Type | | `member_user_id` | `string \| null` | null | Member User Id | *** ## complete\_post\_media\_upload Validate an uploaded local file and attach it to its draft. Call this only after prepare\_post\_media\_upload succeeded and the client uploaded the complete local file using the returned `upload_request`. The post\_id and upload\_id must match that preparation response. For a member-owned draft, pass the same `member_user_id` used during prepare\_post\_media\_upload. MagicPost verifies ownership, expiry, file size, MIME type, extension, and binary signature before attaching the media. Re-calling a successfully completed upload is safe and returns success. | Input | Type | Default | Description | | ---------------- | ---------------- | ------- | -------------- | | `post_id` | `string` | — | Post Id | | `upload_id` | `string` | — | Upload Id | | `alt_text` | `string \| null` | null | Alt Text | | `member_user_id` | `string \| null` | null | Member User Id | *** ## schedule\_post Schedule a post for future publication on LinkedIn. * at\_iso: ISO 8601 datetime. If naive (no offset), it is interpreted in the user's timezone. If it has an offset, it is converted. * timezone: optional IANA tz name override (e.g. 'Europe/Paris'). Falls back to the user's stored timezone, then UTC. * confirm\_same\_day: when True (default), the call returns 200 with `success: false, warning: 'same_day_posts'` if the user already has other posts scheduled on the same day. Show the warning to the user, and retry with confirm\_same\_day=false only after they confirm. * confirm\_exact\_slot: same behavior for posts scheduled within ±5 minutes (a stronger signal of a likely mistake). This check is evaluated FIRST and short-circuits the same-day check, so an exact-slot warning (`warning: 'exact_slot_conflict'`) will be returned alone even if there are other same-day posts. When LinkedIn token is expired, returns `actionable: true` with a hint — surface it to the user verbatim, do not retry blindly. | Input | Type | Default | Description | | -------------------- | ---------------- | ------- | ------------------ | | `post_id` | `string` | — | Post Id | | `at_iso` | `string` | — | At Iso | | `timezone` | `string \| null` | null | Timezone | | `confirm_same_day` | `boolean` | `true` | Confirm Same Day | | `confirm_exact_slot` | `boolean` | `true` | Confirm Exact Slot | *** ## cancel\_scheduled\_post Cancel a scheduled post. Idempotent: returns success even if the post is no longer scheduled. Returns 409 if the post is already published (cannot un-publish). | Input | Type | Default | Description | | --------- | -------- | ------- | ----------- | | `post_id` | `string` | — | Post Id | *** ## publish\_post\_now Publish a post to LinkedIn immediately (no schedule). On LinkedIn token expired, returns `actionable: true` with a reconnect hint. Returns 409 if the post is already published. | Input | Type | Default | Description | | --------- | -------- | ------- | ----------- | | `post_id` | `string` | — | Post Id | *** ## list\_linkedin\_post\_metrics List per-post LinkedIn metrics for an inclusive date range. Includes posts published natively on LinkedIn as well as posts published through MagicPost, provided they have been imported into MagicPost analytics. Use YYYY-MM-DD for `date_from` and `date_to`. Results are ordered newest first and paginated. When `has_more` is true, call this tool again with `cursor=next_cursor` and the same date range. Continue until `has_more` is false. `linkedin_activity_id` and `linkedin_post_id` are LinkedIn identifiers, NOT MagicPost UUIDs. They cannot be passed to post editing, scheduling, or publishing tools. Metrics that were not collected are null, not 0. | Input | Type | Default | Description | | ----------- | ---------------- | ------- | ----------- | | `date_from` | `string` | — | Date From | | `date_to` | `string` | — | Date To | | `limit` | `integer` | `50` | Limit | | `cursor` | `string \| null` | null | Cursor | *** ## get\_metrics\_summary Get a compact summary of the user's LinkedIn analytics. Returns top-line totals, follower growth for the selected period, and the top N posts by impressions. `followers.gained` is null when LinkedIn did not provide enough snapshots to cover the whole period. Check `followers.coverage`; `available_range_gained` is explicitly limited to the returned coverage dates. `followers.daily` returns compact `{date, gained}` points, so use one multi-day call instead of one request per day when building a roll-up. These are profile-level daily gains, not followers attributed to a specific post. Use `period` to scope the window. For most questions like "how did I perform recently?" use '30d'. Identifiers — IMPORTANT: `top_posts[].linkedin_share_urn` is the LinkedIn share URN, NOT the MagicPost UUID expected by `get_post`, `schedule_post`, `cancel_scheduled_post`, `publish_post_now`, or `create_or_update_post`. Passing it to those tools returns `error_type='invalid_post_id_format'`. To act on a top post, resolve the URN to a MagicPost UUID: 1. Call `list_my_posts(status="published")`. 2. Find the row whose `linked_in_url_post` contains the URN. 3. Use that row's `id` (UUID) with `get_post` / `schedule_post` / etc. Posts published outside MagicPost have no MagicPost UUID and cannot be acted on via the MCP tools. | Input | Type | Default | Description | | -------- | --------- | ------- | ----------- | | `period` | `string` | `'30d'` | Period | | `top_n` | `integer` | `5` | Top N | *** ## get\_metrics\_freshness Check when LinkedIn post metrics were last updated. Call this before `refresh_my_posts`. When `should_refresh` is false, reuse the stored metrics instead of starting another refresh. When no metrics timestamp exists, `data_available` is false and `should_refresh` is true. | Input | Type | Default | Description | | ----------------- | --------- | ------- | --------------- | | `max_age_minutes` | `integer` | `60` | Max Age Minutes | *** ## refresh\_my\_posts Trigger a fresh pull of the user's LinkedIn posts and wait until done. Check `get_metrics_freshness` first. If `should_refresh` is false, do not start another refresh unless the user explicitly requires a forced refresh. This tool polls the job status every 2 seconds until it completes or max\_wait\_seconds elapses. | Input | Type | Default | Description | | ------------------ | --------- | ------- | ---------------- | | `max_wait_seconds` | `integer` | `90` | Max Wait Seconds | *** ## search\_inspiration\_posts Search the global LinkedIn inspiration catalogue (NOT the user's own posts) for high-performing examples to draw inspiration from. Use this when the user asks things like: * "Find me top posts about \" * "Show me viral French posts on remote work this month" * "What's \ posting that gets traction?" ## Filters * query: free-text. Matches against post text + theme (PostgreSQL FTS). * creator\_name: partial match on poster's display name. ⚠️ ILIKE scan — slower than `poster_urn`. Prefer `poster_urn` when you already have it (e.g. from a previous result). * poster\_urn: exact LinkedIn URN — indexed, fast. * languages: e.g. \["French", "English"]. Use the pycountry English name (not ISO codes, not native names). * formats: subset of \{text, image, video, carousel, article}. * post\_types: subset of \{actionable, aspirational, introspective, promotional}. Case-insensitive on input; backend matches Capitalize. * days\_range: shortcut "posts from the last N days". Ignored when `date_from`/`date_to` are set. * date\_from / date\_to: ISO date strings (YYYY-MM-DD). Inclusive. * min\_likes / max\_likes / min\_words / max\_words: numeric bounds. Default minimums (20 likes, 80 words, 5 comments) are applied by the backend to keep the catalogue noise-free even when filters are sparse. ## Sorting * 'recent' → posted\_at DESC * 'top\_likes' → num\_likes DESC (default — best for inspiration) * 'top\_comments' → num\_comments DESC (proxy for discussion-driving) ## Pagination * page: 1-indexed. Start at 1, increment to walk through results. * limit: up to 12 per call. Default 10. The response includes `has_more` so you know when to stop. ## Returns `{success, page, per_page, total, has_more, count, posts, filters_applied}` Each post has: id, text (full), theme, type, format, language, posted\_at (ISO), num\_likes, num\_comments, num\_reposts, words, poster\_name, poster\_urn, poster\_headline, profile\_followers\_count, post\_url, image\_url, video\_url, document\_cover\_urls. ## Common workflows * "Get me 5 great hooks on \": query=\, limit=5, sort\_by='top\_likes'. * "Latest carousels by \": creator\_name=\, formats=\['carousel'], sort\_by='recent'. * "Inspiration close to what I'm writing": pass the user's draft theme as `query`, leave other filters open. | Input | Type | Default | Description | | -------------- | ----------------- | ------------- | ------------ | | `query` | `string \| null` | null | Query | | `creator_name` | `string \| null` | null | Creator Name | | `poster_urn` | `string \| null` | null | Poster Urn | | `languages` | `array \| null` | null | Languages | | `formats` | `array \| null` | null | Formats | | `post_types` | `array \| null` | null | Post Types | | `days_range` | `integer \| null` | null | Days Range | | `date_from` | `string \| null` | null | Date From | | `date_to` | `string \| null` | null | Date To | | `min_likes` | `integer \| null` | null | Min Likes | | `max_likes` | `integer \| null` | null | Max Likes | | `min_words` | `integer \| null` | null | Min Words | | `max_words` | `integer \| null` | null | Max Words | | `sort_by` | `string` | `'top_likes'` | Sort By | | `page` | `integer` | `1` | Page | | `limit` | `integer` | `10` | Limit | *** ## list\_leads List the user's LinkedIn leads (prospects detected from the engagement on their posts and qualified against their ICPs). ## Filters * icp\_id: scope the list to one ICP. When set, each lead embeds its qualification against that ICP (level A/B/C/D + icebreaker). Get valid ids from `list_icps`. * level: qualification level filter (A is the best fit, D the worst). Only effective when `icp_id` is also provided. * contact\_status: pipeline status slug. Defaults are 'not\_contacted', 'contacted', 'in\_discussion', 'to\_follow\_up', 'won', 'lost', 'not\_relevant' (users can customize them). ⚠️ A lead that was never touched has contact\_status null, and this filter only matches explicitly-set statuses — so filtering on 'not\_contacted' does NOT return untouched leads. To find leads the user hasn't contacted yet, list WITHOUT this filter and read each item's contact\_status (null or 'not\_contacted' = not contacted). * search: free-text match on the lead's name/headline. * favorite: True to show only favorited leads. ## Pagination * limit: up to 25 per call (default 20). * offset: 0-indexed. Use `offset` + `has_more` from the response to paginate. Returns `{items, count, limit, offset, has_more}`. | Input | Type | Default | Description | | ---------------- | ----------------- | ------- | -------------- | | `icp_id` | `string \| null` | null | Icp Id | | `level` | `string \| null` | null | Level | | `contact_status` | `string \| null` | null | Contact Status | | `search` | `string \| null` | null | Search | | `favorite` | `boolean \| null` | null | Favorite | | `limit` | `integer` | `20` | Limit | | `offset` | `integer` | `0` | Offset | *** ## get\_lead Full detail of one lead: profile, interactions (likes/comments on the user's posts), qualification reasoning, icebreaker, and CRM sync state (`deliveries` to connected external CRMs). Get `prospect_id` values from `list_leads`. | Input | Type | Default | Description | | ------------- | -------- | ------- | ----------- | | `prospect_id` | `string` | — | Prospect Id | *** ## list\_icps List the user's ICPs (Ideal Customer Profiles). Needed to get the `icp_id` values used by `list_leads` (scoping) and `detect_leads` (targeting). Each ICP describes the kind of prospect the user wants to detect; leads are scored A/B/C/D against it. *No inputs.* *** ## get\_leads\_summary Leads quota status: trial state (is\_trial, trial\_days\_remaining, trial\_ends\_at), monthly quota (leads\_monthly\_included, leads\_consumed\_in\_period, leads\_remaining), top-ups (topup\_leads\_remaining, topups\_detail), and renewal date (period\_end). Call this before `detect_leads` if the user asks how many credits they have left. *No inputs.* *** ## get\_detection\_status Poll the status of running lead detections (they are async and typically take 2-15 minutes). Returns `{in_progress, scoring_in_progress, all_terminal}`. `all_terminal: true` means every detection is done — the new leads are available via `list_leads`. *No inputs.* *** ## detect\_leads Detect new leads from the engagement (likes + comments) on one of the user's LinkedIn posts. ⚠️ CONSUMES LEAD CREDITS — 1 credit per scored prospect. The exact cost is NOT known upfront: every engager matching an ICP gets scored (potentially dozens on a popular post), so never promise a number. ⚠️ OTHER SIDE EFFECTS — a completed detection also: * pushes the qualified leads to the user's connected external CRMs (Pipedrive/HubSpot/Lemlist/Zapier) — an external write that is hard to undo; * sends a recap email if the user enabled it. The confirm-step response tells you which apply (`crm_destinations`, `email_recap_enabled`) — relay them to the user before confirming. Two-step confirmation flow: 1. First call with confirm=true (default): returns `success: false, warning: 'credits_consumption'` with the targeted ICPs, the remaining credits, `crm_destinations` (enabled CRM push rules; empty list = no CRM push) and `email_recap_enabled`. Show all of this to the user and ask them to confirm. 2. After the user confirms, retry with confirm=false to actually start the detection (returns `success: true` with a queued payload). * icp\_ids: optional list of ICP ids to score against (from `list_icps`). Defaults to all the user's active ICPs. * Detection runs in the background — poll `get_detection_status` afterwards. Returns `error_type: 'leads_quota_exhausted'` (402) when no credits remain. * Re-detecting an already-processed post is a NO-OP until its engagement has grown: the response comes back with `success: false` and `queued: 0` (`already_synced` in results\[]). Tell the user nothing was launched in that case — do not claim a detection started. * `results[]` can MIX queued and rejected ICPs (e.g. `queued: 2` with one `already_synced` entry). Report both to the user: which ICPs were launched and which were skipped, and why. | Input | Type | Default | Description | | ---------- | --------------- | ------- | ----------- | | `post_urn` | `string` | — | Post Urn | | `icp_ids` | `array \| null` | null | Icp Ids | | `confirm` | `boolean` | `true` | Confirm | *** ## update\_lead Update a lead's pipeline fields. At least one field is required. * contact\_status: pipeline status slug (see `list_leads` for the default values; users can customize them). * tags: full replacement of the lead's tag list. * comment: free-text note on the lead. * is\_favorite: star/unstar the lead. Only the provided fields are modified. Returns the updated lead. | Input | Type | Default | Description | | ---------------- | ----------------- | ------- | -------------- | | `prospect_id` | `string` | — | Prospect Id | | `contact_status` | `string \| null` | null | Contact Status | | `tags` | `array \| null` | null | Tags | | `comment` | `string \| null` | null | Comment | | `is_favorite` | `boolean \| null` | null | Is Favorite | *** ## list\_org\_members List the active members of your organisation. Only callable by org super\_admins. Returns name, email, status, whether each member is themselves an org\_admin, and whether their LinkedIn account is currently connected to MagicPost. Use this first when the user asks anything team-scoped, so you know the set of valid `member_user_id` values to pass to the other org tools. *No inputs.* *** ## get\_org\_metrics\_summary Compact analytics for the org or one specific member. * When `member_user_id` is omitted: returns one compact summary per team member (name, totals, follower growth, top posts). Use this for "how is the team doing?" questions. * When `member_user_id` is set: returns ONE detailed summary for that member, same shape as `get_metrics_summary` for the caller's own account. `top_n` defaults to 3 in team view (response stays token-efficient for an LLM); set higher when drilling into a single member. `summary.followers.gained` is null when LinkedIn did not provide enough snapshots to cover the whole period. Check follower `coverage`; `available_range_gained` only covers the returned dates. `summary.followers.daily` contains profile-level daily gains. Identifiers — IMPORTANT: `top_posts[].linkedin_share_urn` is the LinkedIn share URN, NOT the MagicPost UUID expected by `get_post`, `get_org_member_post`, `schedule_post`, `cancel_scheduled_post`, `publish_post_now`, `schedule_member_post`, or `create_or_update_post`. Passing it to those tools returns `error_type='invalid_post_id_format'`. To act on a top post, resolve the URN to a MagicPost UUID via `list_org_member_posts(member_user_id=..., status="published")` and match the URN against the row's `linked_in_url_post` field. Then read its full record with `get_org_member_post`. Posts published outside MagicPost have no MagicPost UUID and cannot be acted on. | Input | Type | Default | Description | | ---------------- | ---------------- | ------- | -------------- | | `period` | `string` | `'30d'` | Period | | `member_user_id` | `string \| null` | null | Member User Id | | `top_n` | `integer` | `3` | Top N | *** ## list\_org\_member\_posts List posts across the org, optionally scoped to one member. * When `member_user_id` is set: only that member's posts. Useful for "show me Alice's drafts" or "what is Bob scheduling next?". * When omitted: posts grouped by member (each with `limit` posts starting at `offset`). Use for "what's the team posting this week?". `status` is the lifecycle filter (`draft` / `scheduled` / `published`), same semantics as `list_my_posts`. Members and members' posts are returned even if the LLM client doesn't have a direct relationship with them — org-admin permission is sufficient. Each returned post row exposes its MagicPost UUID as `id`. To read ONE post's full record (full text, media, stats…), pass that `id` to `get_org_member_post(member_user_id=..., post_id=...)` — NOT to `get_post`, which only reads YOUR own posts and returns `post_not_found` for a teammate's post. | Input | Type | Default | Description | | ---------------- | ---------------- | ------------- | -------------- | | `member_user_id` | `string \| null` | null | Member User Id | | `status` | `string` | `'scheduled'` | Status | | `limit` | `integer` | `20` | Limit | | `offset` | `integer` | `0` | Offset | *** ## get\_org\_member\_post Fetch the full record of ONE post owned by a member of your org. Org-admin read counterpart to `get_post` (which only reads YOUR own posts). Use this when you already have a member's post UUID — typically from `list_org_member_posts` — and need its full content/metadata (full text, schedule date, LinkedIn URL, stats, media, etc.). Only callable by org super\_admins. Both arguments are REQUIRED: * `member_user_id`: the post owner's user id, exactly as returned by `list_org_members` (`user_record_id`) or `list_org_member_posts`. It must be a member of YOUR org. * `post_id`: the MagicPost UUID of the post (e.g. "df1cbd56-a7c0-41f4-b929-5d242bed12eb"), as returned in the `id` field by `list_org_member_posts`. This is NOT the `linkedin_share_urn` from `get_org_metrics_summary` — passing a URN returns `error_type='invalid_post_id_format'` (400). Typical chain: 1. `list_org_member_posts(member_user_id=..., status=...)` → read the target row's `id`. 2. `get_org_member_post(member_user_id=..., post_id=)`. Error shapes: * `error_type='org_admin_required'` (403): you are not an org admin. * `error_type='member_not_in_org'` (404): `member_user_id` is not in your org. * `error_type='post_not_found'` (404): the post does not exist, is deleted, or does NOT belong to `member_user_id` (IDOR guard — you cannot read another member's post by passing the wrong owner). Do NOT use `get_post` for a teammate's post — it is scoped to your own posts and will return `post_not_found` for anything you don't own. | Input | Type | Default | Description | | ---------------- | -------- | ------- | -------------- | | `member_user_id` | `string` | — | Member User Id | | `post_id` | `string` | — | Post Id | *** ## create\_member\_post Create a draft owned by a specific organisation member. Only callable by org super\_admins. Get `member_user_id` from `list_org_members`. The returned `post_id` belongs to that member and can be passed to `schedule_member_post`. To attach media first, call `attach_media_to_post` with both the returned `post_id` and the same `member_user_id`. For a local file in a code client, pass that member id through both `prepare_post_media_upload` and `complete_post_media_upload`. | Input | Type | Default | Description | | ---------------- | -------- | ------- | -------------- | | `member_user_id` | `string` | — | Member User Id | | `content` | `string` | — | Content | *** ## schedule\_member\_post Schedule a post on behalf of an org member. Only callable by org super\_admins. The post must belong to `member_user_id` (you can't move someone else's post by accident). Defaults: * `timezone` falls back to the member's stored userTimezone (NOT yours). Set it explicitly if you want to schedule in your own timezone instead. * `at_iso` is interpreted in that timezone unless the string already has an offset. Same warning flow as `schedule_post` (same\_day\_posts / exact\_slot\_conflict) — computed against the MEMBER's existing scheduled posts. Show the warning to the user, then retry with `confirm_*=false` to force through. If the member's LinkedIn token has expired, the response has `actionable: true` with a `hint` telling you to ASK THE MEMBER to reconnect — only they can, not you. Surface that hint verbatim. | Input | Type | Default | Description | | -------------------- | ---------------- | ------- | ------------------ | | `member_user_id` | `string` | — | Member User Id | | `post_id` | `string` | — | Post Id | | `at_iso` | `string` | — | At Iso | | `timezone` | `string \| null` | null | Timezone | | `confirm_same_day` | `boolean` | `true` | Confirm Same Day | | `confirm_exact_slot` | `boolean` | `true` | Confirm Exact Slot | # Quickstart Source: https://dev.magicpost.in/quickstart Connect Claude Desktop to your MagicPost account in 3 steps. By the end of this page, Claude Desktop can list your scheduled posts, draft new ones, attach supported files when the client provides them to the MCP, schedule them, and read your analytics — all from a normal conversation. ## 1. Create an API key Sign in to [app.magicpost.in](https://app.magicpost.in/account) and go to **Settings → API & MCP**. Click **Create a key**, name it (e.g. `Claude Desktop`), and submit. The cleartext token (`mp_...`) is shown **exactly once**. Copy it now — you won't be able to see it again. The reveal dialog also includes a ready-to-paste `claude_desktop_config.json` snippet. ## 2. Configure Claude Desktop Open `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS (or the equivalent on Windows/Linux). Paste this block (the create-key dialog has it pre-filled with your token): ```json theme={null} { "mcpServers": { "magicpost": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.magicpost.in/mcp", "--header", "Authorization:Bearer mp_YOUR_TOKEN_HERE" ] } } } ``` Claude Desktop talks to MCP servers over stdio. `mcp-remote` is a tiny npm bridge that proxies stdio ↔ HTTP so you can connect to remote MCP servers. It runs locally and only forwards your requests. Fully quit (Cmd+Q on macOS, not just close the window) and relaunch. A new MCP server `magicpost` should appear under the tools indicator. ## 3. Try it In any conversation, ask things like: * *"List my scheduled posts on MagicPost"* * *"Summarize my LinkedIn analytics for the last 30 days"* * *"Create a draft: 'My first post via Claude'"* * *"Create a draft from this text, attach this image, then schedule it for tomorrow at 14:00"* (attach the file to the conversation before sending) * *"Schedule post xyz for tomorrow 14:00"* (Claude will warn you if you already have other posts that day before going through) Media attachment depends on the MCP client exposing the uploaded file as a temporary file reference. If the client does not offer the file to the `attach_media_to_post` tool, create the draft in chat and add the media from the MagicPost web app. If Claude says it doesn't have access to your data, the MCP server probably didn't load — check the config file syntax and that `mcp-remote` is reachable (your machine needs `npx`). ## Next steps Full catalog of what the LLM can do. Supported formats, limits, and the complete workflow. Connect with OAuth and attach files from your local workspace. Same backend, different MCP client. Use curl or your own scripts. What to handle, what to retry.