Skip to main content
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 <other person>” — 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("<name or URL>") 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=<the returned 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.

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 <person>” — 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).

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

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.

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.

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.

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.

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.

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.

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.

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.

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).

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.

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.

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.

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.

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.

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 <topic>”
  • “Show me viral French posts on remote work this month”
  • “What’s <creator> 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 <topic>”: query=<topic>, limit=5, sort_by=‘top_likes’.
  • “Latest carousels by <creator>”: creator_name=<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.

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}.

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.

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.

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.

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.

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.

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=<that 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.

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.

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.