Skip to main content

MCP tools & contracts

The full catalogue of the Scripe MCP server’s 76 tools (39 reads, 37 writes including switch_workspace), 6 resources, and 3 prompts, and the contracts they share: scopes, two-phase confirmation, idempotency, inline file content, and progress streaming. For connecting a host and the workspace/project model, start at MCP. Tools mirror the REST resources — the MCP tool is a thin adapter over the same handler the REST endpoint calls, so any contract you’ve coded against /v1/* is preserved. The exceptions are the verbs that only make sense in a conversation (two-phase schedule_post and publish_post, the destroy verbs, the settings, posting-schedule and positioning writes, create_content_topic, generate_post_ideas, the community profile-list trio, and the per-post auto-engagement pair get_post_engagements / update_post_engagements), which ship on MCP only — with one exception: delete_source also has a REST route (DELETE /v1/sources/:sourceId), two-phase there too (§2.13). Most tools resolve a missing projectId from the connection’s default project; eleven require it explicitly — the split is in MCP §2.2.

1. Read tools

All read tools advertise readOnlyHint: true. The required scope must be on the OAuth grant; <resource>:write grants imply the matching read. (draft_positioning_from_website also advertises readOnlyHint: true but is listed with the write family in §2.10, because it exists to feed a write.) The subsections below are the read contracts an integrator (or an agent) needs to answer a user correctly. Each is shaped around the ask it serves.

1.1 A post’s status: two vocabularies, one truth

Every post row carries several status fields, because Scripe has two vocabularies for the same thing: The truth is the custom status (statusId), because that is what the product writes: publishing sets it in one indivisible UPDATE and the scheduler does the same. status is derived from that status’s category, and only falls back to the stored column for a post that carries no custom status at all. (Until 2026-08 this field served the stored column directly, and nothing had written published to it since 2025-12 — so every post published in that window read back draft, and list_posts({ status: "published" }) answered an empty list.) Two consequences worth knowing before you answer a user:
  • draft covers two columns. draft and inProgress both render as status: "draft", so a workspace with an “In Progress” and a “Good to go” column reports both as drafts. Read statusTitle when the user asks where a post is.
  • Filter on the axis you mean. status filters the lifecycle value (and draft therefore also returns the inProgress columns); statusCategory filters the board column and is the only way to ask for inProgress or review. Both match through the custom status, so a filter and a read can never disagree.

1.2 “Did my post go out?” — read delivery, never infer from status

A post the publisher has given up on keeps its scheduled status forever. The publish cron picks up a post whose scheduledAt is in the past and within the last 24 hours; outside that window it is skipped on every tick from then on, and nothing in the row changes. So two posts can be byte-identical on status + scheduledAt while only one of them will ever publish. Every post read therefore carries a delivery block with the verdict:
  • Never report a post as “on its way” from status alone. On production, 145 posts across 44 projects have been sitting scheduled and unsent for more than a day; 41 of them recorded no error at all, so the row contains no other evidence that anything went wrong.
  • delivery.detail is the sentence to relay. It names the failure and the repair. lastPublishError beside it is the raw message from the last attempt — useful, but not written for a user.
  • retryUntil is the deadline. While state is due the user can still fix the cause (usually reconnecting LinkedIn) and the post goes out by itself. After it, only schedule_post will.
  • Find them in one call: list_posts({ delivery: "missed" }). The five states partition the project, so an empty page really does mean nothing is stuck.
  • Then ask why. 90 of those 145 stranded posts sit on a project whose LinkedIn authorization was revoked — §1.4.

1.3 “What’s waiting for my approval?” — read review, never the board column

Every post read carries where the post stands in the approval workflow, and list_posts filters by it:
  • A review board column is a different question. Across production those columns hold 1,018 posts: 231 awaiting a decision, 104 already approved, 11 sent back, and 672 nobody was ever asked to look at. So statusCategory: "review" answers “what needs my approval?” 22.7% correctly. Filter by review: "pending" (the two combine).
  • A review belongs to a STAGE, not to a post. A post keeps the review rows of the columns it passed through — 2,144 of production’s 2,490 are at a stage their post has left, 313 of them still PENDING on an already-published post. Only the row for the post’s current column is reported here, which is why a published post reads not_requested.
  • reviewerUserId is a Clerk user id. list_team returns the same id as userId — resolve it there rather than showing the raw id.
  • Nothing on this surface can decide a review. Approving and rejecting happen in the dashboard; detail says so on every state that is waiting on a person.
  • requiredByWorkspace can be ENFORCED on this surface (since 2026-09; it mirrors get_workspace_context.requireReviewWorkflow). Two things have to be true before it refuses anything, and requiredByWorkspace: true on its own is only the first: the workspace must have set the flag AND be on a plan that includes content approvals (Advanced and Business among the plans sold today). Approving and rejecting are themselves plan-gated features, so in a workspace below that plan the flag is inert — nothing on this surface refuses, because the approval a refusal would send the user to get cannot be produced there at all. Do not branch on requiredByWorkspace alone: treat it as “this workspace may refuse an unreviewed post” and handle the 409 when it comes, rather than pre-emptively blocking a schedule the API would have accepted. Where the gate does bind, schedule_post and publish_post refuse a post without an approved review with 409 conflict (details.reason: review_not_requested / review_pending / review_rejected; details.reviewerUserId is always present — the Clerk id of the reviewer on the post’s current-stage review when one exists, pending or rejected, null otherwise — and the message itself never shows the raw id, so resolve it via list_team). A post the caller could not schedule anyway answers the ordinary not_found / forbidden_project, never a review state. create_post_draft / generate_post refuse a non-null scheduledFor outright there — a post that does not exist yet cannot have been reviewed, so create the draft first and schedule it after approval — and accept it normally in a workspace the gate does not bind on. A review approved at an earlier board column still counts after the post moves on (the dashboard’s approve-then-move flow). Approvals themselves still happen only in the dashboard. The REST write paths (POST /v1/posts, PATCH /v1/posts/:postId) deliberately do NOT enforce it — see Posts § review.

1.4 “Is my LinkedIn still connected?” — the linkedIn block

Every project read (get_project, list_projects, get_workspace_context) carries the publishing precondition:
  • Branch on canPublish / state, never on connectionStatus. connectionStatus is the stored health cache. On production it reads CONNECTED for 38 accounts that hold no access token at all, and UNKNOWN for 15,352 that were simply never connected. state applies token presence first.
  • This read never probes LinkedIn. It reports stored health as of lastValidatedAt. The live check runs inside schedule_post and publish_post, where a write is about to happen — the proposals return it (§8).
  • You cannot fix it. Reconnecting LinkedIn is an OAuth flow a human performs in the dashboard; reconnectUrl is where. Relay it rather than retrying — no call on this surface can succeed until they do.
  • A company page has no connection of its own. It publishes through the personal account it was linked to, so its linkedIn block (and its reconnectUrl) describe that account. null means the page was never linked to one, which is itself the answer.

1.5 “Find my post about X” — use q, never page the project

list_posts and list_notes take a free-text q. Use it for any ask whose subject is what the content is about.
  • An empty result means the words are absent, not the topic. This is a literal substring match with no stemming and no synonyms: pricing does not find priced. Retry with fewer or simpler words before concluding anything.
  • Paging is not a substitute. A project’s posts run into the thousands; hundreds of production projects hold more than the 200-row maximum page, so “read them all and look” cannot terminate. If q returns nothing, say so.
Not searchable this way: knowledge-base documents (semantic search only, through the dashboard’s assistant), ideas, and the calendar. search_media searches the image library and search_viral_posts searches other people’s posts — neither looks at the user’s own writing.

1.6 “How many …?” — read pagination.total, never add up pages

list_posts and list_notes report pagination.total: how many rows the filter matches, across every page.
  • limit: 1 is the right shape for a counting ask — you want the number, not the posts.
  • The count excludes the cursor, so it is the same number on page 1 and page 4. It is not “rows remaining”.
  • It counts what you filtered by, including q.
  • Never report the page size as the total — paging to find out costs every post body in the project (measured: 3 calls and ~52k tokens on a 128-draft project; the largest production project holds 7,874 posts).
The other list tools do not report a total yet — has_more is still the only signal there.

1.7 A list row’s content is an EXCERPT — get_post has the post

list_posts serves ~280 characters of each post body, cut on a word boundary, with contentTruncated: true beside it. A production post averages 1,078 characters, so a default page of 50 whole bodies is ~54,000 characters — roughly 13.5k tokens spent on “show me my drafts”.
  • Never quote a row whose contentTruncated is true back to the user as their post. It is an opening, not the text they wrote.
  • To read one post, call get_post — it always returns the whole body and costs one row.
  • content: "full" returns whole bodies for the page; pair it with a small limit. content: "none" omits the body entirely, which is the right shape for “what’s in my review column”.
  • When you pass q, the excerpt is centred on the first matching term, so the row shows why it matched. A row whose excerpt does not contain your search word matched on its title or its hook.
list_post_analytics uses the same three modes with the same default.

1.8 “What’s in my pipeline?” — one call, then open the column

list_post_statuses is the board: every kanban column the workspace runs, with postCount — how many posts sit in it right now — plus a project rollup:
  • One call answers the ask — not list_posts once per category.
  • counts.byCategory[c] is the same number list_posts({ statusCategory: c, limit: 1 }) reports as pagination.total. counts.total always equals the sum of byCategory plus uncategorized (posts whose column was deleted).
  • Without a projectId (REST callers only — the MCP tool defaults it) every postCount is null and counts is null: statuses are workspace-wide, posts are not.
  • Then open the column the user named: list_posts({ statusId: "22e0…", content: "none" }). A category is not a substitute — a workspace can run several columns inside one category. An id the workspace does not own is refused with a 400 invalid_request, never answered as an empty column. The same id is what update_post({ statusId }) moves a post to.

1.9 “What did I publish last week?” — a different date from dateFrom

A post has two dates, and they are days apart: createdAt is when the draft was written, publishedAt is when the LinkedIn copy went live. On production the gap averages 6.3 days, and 51% of the posts published in a given week were drafted before that week began. So dateFrom/dateTo — which filter the CREATION date — cannot answer “what did I publish last week”. Use the publication window:
  • publishedAt is the only field that dates a publish. scheduledAt is what somebody asked for and stays set on posts that never went out; updatedAt moves on any edit.
  • publishedAt: null on a published post means “moments ago”. The date comes from LinkedIn’s own measurement row, which lands within the hour. It is never guessed from the schedule.
  • publishedFrom/publishedTo only ever return published posts, and combine with every other filter.
  • The page is still ordered newest-DRAFTED first. Sort by publishedAt yourself before narrating “my last three posts”.
Then ask how it did. list_post_analytics measures the LinkedIn side, and its dateFrom/dateTo are publication dates too, so the same window lines the two up. Every measured row carries post_id — see §1.10.

1.10 “How did THIS post do?” — pass postId, never scan a window

list_post_analytics describes posts that are live on LinkedIn. That is not the same object as the Scripe draft it may have come from, so a row carries three ids and only one of them is actionable: post_id is null for any post not published through Scripe — imported history, posts written directly on LinkedIn. That is most of a mature account’s back catalogue, and it is the honest answer rather than an error. Use permalink to show the user the post, and content (with content: "full") as source text for a generate_post rewrite. When the user asks about one post, pass the id:
  • One call, one row, ~330 tokens — versus ~12k tokens per date-window page, and a window cannot work at all for a post published minutes ago, whose publishedAt is still null.
  • An array works too, up to 50 ids.
  • meta appears only when postId was passed, and it is what makes an empty answer readable: meta.unmeasured says which named post has no numbers and why — not_published (never went out) or not_measured (published, but LinkedIn’s sync has not landed; it usually does within the hour).
  • An id the project does not contain is a 400, not an empty page.
  • sort does not rank by rate. impressions ranks by views and engagement by total interactions; if the user meant rate, sort, then compare metrics.engagement_rate yourself.

1.11 What search_viral_posts searched, and what its numbers mean

“Viral” here means an outlier, not a big number. Every post in data beat its OWN author’s median engagement by at least meta.min_outlier_multiplier (2 by default), so a large account’s ordinary post is not reported as a hit. outlier_multiplier on each post is that ratio and is capped at 10 — a 10 means “at least 10x”. The search is bounded before it ranks: only posts published within meta.published_within_days (90 by default, max 180), in the resolved meta.language, above meta.min_engagement (100 by default), in any meta.media_formats that were asked for, excluding the project’s own author. A short or empty data may therefore be those filters at work rather than a topic nobody writes about.
  • meta.narrowed_to is present when you narrowed beyond the default and the answer is short, and it names the three numbers to widen. One of those filters may be why: offer to relax one rather than reporting the topic as uncovered, and do not assert the cause — the search cannot tell a thin topic from a filtered one. The recoveries are a lower minOutlierMultiplier (0 opts out of the outlier floor entirely and serves every post above minEngagement, scored or not), a wider publishedWithinDays, no mediaFormats, language: "all", or a lower minEngagement.
  • mediaFormats is how you ask for a shape. carousel is a swipeable deck (a document post), multi_image several photos in one post, image at most one; then video, text (no media), article (a shared link) and other. A deck is a small share of the corpus, so expect a shorter answer there than for an unfiltered search.
  • An outlier_multiplier of null means NOT SCORED, never “average” — the post is too fresh, below the scoring floor, or its author has no usable baseline. It is only reachable with minOutlierMultiplier: 0. Only outlier_baseline: "AUTHOR" supports a sentence about that author’s own usual performance; CORPUS compared the post to a corpus median instead.
  • meta.returned is how many posts came back, capped by limit (default 12, max 30). There is no paging and no corpus-wide total.
  • metrics.views is null, not 0, when unknown. Only a post’s own author can read its impressions, so LinkedIn withholds the count for roughly 97% of third-party posts. Report a null as unknown. metrics.reactions does not have to add up to total_engagement, because LinkedIn reports reaction types it does not break out.
  • Two of the three ids are links, and the third is not usable. permalink opens the post, author.profile_url opens its writer, and id is the internal feed row — no Scripe tool accepts it. To act on an angle you liked, feed the post’s content to generate_post as context.

1.12 The two engagement rates and the three click figures in get_analytics_report

daily[] is one row per day in the range, zero-filled, and every figure in a row is that day’s own — including engagementRate, which is that day’s engagement over that day’s impressions and is therefore 0 on a day with no impressions. cumulativeEngagementRate is the separate running figure: the rate from the start of the range through that day — what the in-app KPI card plots, deliberately carrying forward across quiet days. Read engagementRate to compare days, cumulativeEngagementRate for a trend line. Reading the running rate as a per-day figure is how a month with four posts appears to have had engagement on all thirty-one days. The CSV rendering of GET /v1/analytics/report carries both columns; the PDF’s Daily Metrics table shows the per-day rate only. get_analytics_report’s posts[] carries the same postId as §1.10, plus a ~100-character content excerpt with contentTruncated: true. The list is the genuine top 20 of the whole period, ranked by impressions in the database; postsTotal counts every post in the period and postsTruncated says the list is capped — page the full set with list_post_analytics. summary carries three click figures from two sources, and the dashboard never adds them up — report each under its own name: A reading off the funnel’s Tracked link clicks stage (labelled Link clicks before SCR-1719) is totalTrackedLinkClicks and nothing else; one off the Link clicks KPI card is totalLinkClicks. Neither is a component of the other, and totalPremiumCtaClicks belongs to neither. totalTrackedLinkClicks is null only when a report is built without a workspace, which never happens on this surface.

1.13 “When’s my next slot?” — say localTime, and ask again for the next one

get_next_free_slot is the one tool whose answer IS a time said out loud, so it hands back the resolved wall clock rather than the inputs to a conversion:
  • Do not convert iso yourself. Projects that never picked a calendar timezone store the legacy "CET" — an abbreviation that names UTC+1 but observes EU summer time, so a naive conversion is an hour off half the year. timezone is therefore always reported as the IANA name the stored zone is equivalent to.
  • “Free” is checked, so ask again for each post. A slot is free when no post is scheduled at that exact minute and nothing else on the calendar — a queue slot, a note, an idea placed on the board — reserves that day. Schedule the first post into the returned slot, then call again and it returns the next one.

1.14 Which clock get_posting_times answers on

An hour is not a value on its own — the same posts land on a different hour in every timezone — so the response always names the zone it used: The default is the project’s calendar timezone — the same zone list_calendar renders days in and get_next_free_slot reports. Quote timeZone whenever you name an hour back to a user. Pass timeZone only to deliberately answer for a different audience; it must be a full IANA name, and a name the server cannot resolve is rejected with invalid_request rather than silently answered in UTC. An absent hour was never tried. Only slots with count > 0 are returned — a bucket the project has never posted in carries no measurement, so serving it as {count: 0} would claim the hour performs badly. Say it is untested; and when totalPosts is small (below roughly three posts per slot), say the signal is thin rather than naming a best time.

1.15 “Is anything engaging as me?” — read effective, not policy

get_engagement_policy returns two shapes of the same three actions, and only one of them answers the question: policy: null is the normal state — roughly 98% of production projects have no row — and it is not “unconfigured, so anything might happen”: activation fails closed, so an actor with no policy row is declined. effective reports that as all-OFF with source: "no_policy", versus source: "policy" for stored defaults. Answer the user from effective and never infer from a null. The distinction is load-bearing. “Never answered” (no_policy) can still be seeded with the entitled defaults when the workspace upgrades its plan or reconnects LinkedIn; “answered, and said no” (a stored all-OFF row) may never be promoted back by anything. That is why update_engagement_policy writes no row when it disarms a project that has none: the ask is already true, and the placeholder would silently remove the project from seeding forever. The proposal says exactly that in its effect field before the user confirms. One direction only: the write can lower a default to OFF or REQUEST, never raise one to ON. A project you disarm here cannot be re-armed here — that is a dashboard action, and the proposal says so.

1.16 “Can I still generate posts this week?” — ask before you spend

Two 402s can refuse any AI verb (generate_post, generate_image, generate_carousel, add_to_knowledge_base, create_source_file): get_usage reports both before either fires — full field reference on Usage and limits.
  • Read canGenerate first. It is false exactly when the next AI job would be refused, and blockedBy names which limit. Deriving it from the meters is easy to get wrong twice over: a meter at 100% only blocks when ai.enforcement is block, and the daily cap compares spend + the job’s estimate, so 2¢ of headroom is already blocked for a 5¢ post generation.
  • Before a batch, check once. Ten generations that die on the sixth leave the user with half a plan and no explanation.
  • AI is a percentage, never money. The budget is denominated in provider cost, so the meaningful figure is how much of the allowance is gone. Storage is bytes; the daily cap is cents.
  • A company page has no budget of its own — it spends from the workspace pool, which ai.scope names.

1.17 Analytics across every workspace, in one call

Every other tool runs against one active workspace, and that is structural: switch_workspace moves the anchor, it does not widen it, and a project id from another workspace answers not_found on purpose. So “give me the numbers for all my clients” used to be a loop the model had to invent — switch_workspacelist_projectsget_analytics_overview, per client, then add the figures up in prose. Nothing told it to run that loop, so it usually answered from the default project and stopped. get_cross_workspace_analytics_overview and get_cross_workspace_analytics_report do the loop server-side. Neither takes projectId — they aggregate every project you can read in each workspace, which is the question they exist to answer — and neither requires (or performs) a workspace switch. Reading their answers correctly:
  • Every row is attributed. data.workspaces[].workspace carries the id, the display name, is_default, and reach: member (Clerk membership) or agency_owner (the workspace is billed to an agency the user owns — the grant the dashboard has always applied). Name the workspace beside every figure you report.
  • Read data.totals, never a sum of the rows. It is a fresh query over the union of every project in the response, because an engagement rate has no meaningful average. When it is null, report no total at all — meta.totals_omitted_reason says whether there was nothing to total or the union was too large for another pass.
  • meta.skipped is not zero engagement. It names reachable workspaces in which the caller can read no projects. That is missing visibility, not a quiet client.
  • meta.workspaces_reachable is your full reach, on both selectors — not the count you named, and not a truncation signal. On an explicit selection it normally exceeds meta.workspaces_returned while meta.truncated is false; read truncated for that.
  • meta.truncated means the response holds the first meta.max_workspaces reachable workspaces. Say so, and name the remaining workspaces explicitly in workspaceId to cover them — at most meta.max_workspaces per call, since an explicit list longer than the cap is refused rather than truncated (see Analytics).
  • meta.projects_truncated names any workspace holding more readable projects than one call measures, whose row — and its share of data.totals — therefore covers only its newest projects. Empty when every workspace fitted; measure those workspaces through get_analytics_overview with explicit projectIds.
  • A named workspace you cannot reach is refused (workspace_unavailable), never dropped — so a selection you asked for is either answered or reported, never silently narrowed.
  • The report variant is larger by design. Each entry’s report.posts[] holds only the top meta.posts_per_workspace of the period while report.postsTotal counts them all; page the rest with list_post_analytics, one workspace at a time.
The single-workspace tools are unchanged: use get_analytics_overview for one workspace or for specific projects.

2. Write tools

Annotations: readOnlyHint: false on every write except draft_positioning_from_website, which makes no persistent change and says so. destructiveHint: true on the destroy verbs (delete_idea, delete_knowledge_doc, delete_note, delete_post, delete_source, delete_media_asset) and on update_post_engagements, whose remove permanently deletes the engagement’s tracked links, their click history and their redirect-cache entries — and which, unlike the destroy verbs, is single-phase, so the hint is the host’s only cue to ask first. idempotentHint: true on the naturally idempotent writes — attach_media_to_post and attach_media_to_idea (full-state), cancel_job, schedule_idea (re-placing on the same day is a no-op), switch_workspace, update_engagement_policy, update_tone_of_voice, update_positioning, update_global_tone_of_voice, update_personal_dna, and the delete verbs whose replay reads as not_found / a no-op (delete_knowledge_doc, delete_note, delete_source, delete_media_asset, publish_post via its already_published short-circuit). delete_idea is not idempotent — its replay hard-deletes a cascade, so a second call is a different operation.

2.1 Idempotency

The resource-creating tools create_note, create_post_draft, create_source_text, create_source_file, add_to_knowledge_base, create_content_topic, add_profile_to_list and generate_post accept an optional idempotencyKey argument — the tool-argument equivalent of the REST Idempotency-Key header, since MCP has no header surface. Pass the same key when retrying a call that may already have succeeded and the original envelope is returned instead of a second resource. Reusing a key with different arguments returns idempotency_key_conflict. Omitting it is still safe. The server derives a fallback key from the tool arguments, scoped to a short window, so an agent that retries an identical call gets the original result rather than a duplicate. This matters most for create_post_draft with scheduledFor — duplicates there would each publish to LinkedIn. To deliberately create the same resource twice, wait out the window or pass distinct idempotencyKey values. update_post_engagements is dedup’d the same way but takes no idempotencyKey argument — only the derived fallback key. Its add entries mint rows, and only LIKE and REPOST are caught by the once-per-actor rule, so a retried call would otherwise queue the same comment twice under the acting brand’s LinkedIn identity. Because it changes rows rather than creating a resource, its replay is additionally checked against the current state: the stored envelope comes back only while the rows that call determined are still as it left them — every row it added still exists, every row it modified still carries the payload it set, every row it removed is still gone. If that is no longer true the call executes again. So a deliberate repeat inside the window is honoured rather than swallowed: add an entry, remove it, send the identical add again and the entry really is queued again, and re-sending a modify you have since reverted re-applies it. A genuine retry — one whose writes are all still in place — still replays without a second write. Only what the call itself set is compared, so a row moving through its lifecycle underneath you (an approval granted in the dashboard, the scheduler firing) does not turn a retry into a second write, and neither does an edit to a row the call never touched. The two generators have their own replay form: generate_image and generate_carousel take an idempotencyKey that dedups for 24 hours by attaching a retried call to the job already running instead of billing twice. create_media_asset needs no key at all — it dedups on the content itself (§2.5).

2.2 Two-phase confirmation

The irreversible verbs — publish_post, schedule_post, delete_post, delete_idea, delete_knowledge_doc, delete_source, delete_media_asset, permanent delete_note, update_tone_of_voice, update_engagement_policy, and update_posting_schedule, plus the positioning and personal-DNA writes when they would replace existing text — share one server-enforced contract:
  1. Propose. Call the tool without confirmationToken. Nothing executes. The result is a proposal — exactly what will happen, plus a confirmationToken and tokenExpiresAt (~5-minute TTL).
  2. Show the proposal to the user and get an explicit yes. This step is yours. The server cannot see it: both calls arrive from the model, so a host that chains them executes without any human in the loop.
  3. Execute. Call the tool again with the identical arguments plus the token.
Rules the server enforces, not suggestions:
  • The token is bound to the action, the acting user, the subject, and the proposed change — the arguments you sent. Confirm with different arguments than you proposed and the replay fails with invalid_request (details.reason: "confirmation_invalid"). Re-propose so the user sees what changed. An expired token fails the same way with details.reason: "confirmation_expired".
  • What that binding does not cover, for every verb except publish_post, is the subject drifting underneath you. Only publish_post also binds a hash of the post’s content, media, and engagement flags, so editing the post between its proposal and its confirm invalidates the token. Editing a note between a delete_note proposal and its confirm does not — that token binds { permanent }, not the note’s body. If the subject changing matters to your flow, re-read it before you confirm.
  • Four verbs additionally fold the surface into the bound action, so a token minted in chat cannot be redeemed over MCP or vice versa: publish_post, update_positioning, update_global_tone_of_voice and update_personal_dna. Every other verb’s token is not surface-scoped — update_tone_of_voice included, despite writing settings text.
  • Tokens are not single-use; duplicate protection comes from conditional writes on the subject (see §2.3), so a network retry of a confirmed call is safe.
The scope model backs this up: posts:publish, settings:write, and the *:destroy scopes are never implied by anything, so a grant that can propose a destructive action is always one a human consented to by name. Reversible paths deliberately stay cheap — delete_note’s default archive rides plain notes:write with no confirmation, and delete_post has no such half only because a post is not archivable anywhere in the product.

2.3 Publishing (publish_post)

publish_post makes an irreversible external write, so it carries the strictest version of the contract. Phase 1 — propose.
Nothing is published. The server re-reads the post, resolves the publishing identity, checks the per-user budget, renders the commentary, and pings LinkedIn. The proposal carries commentary (the exact string LinkedIn will render, footer included), postsAs + identityKind (personal profile vs company page), preflight (the live LinkedIn connection check), the post’s engagement flags, publishesThisWindow (e.g. "2/5"), irreversible: true, and the confirmationToken. preflight is reported, not enforced: anything other than "ok" means the LinkedIn connection is expired, revoked, or (for a company page) has no admin with a working connection — publishing anyway will fail at the LinkedIn call, so surface the warning and let the user reconnect first. Phase 2 — confirm. After an explicit yes, the same call plus the token. The server re-verifies everything it showed:
  1. Token binding — the content hash covers the rendered commentary, the media (a poll counts as media here), and the engagement flags, so a draft edited between the two calls (including via attach_media_to_post or update_post) fails closed rather than publishing something the human never saw.
  2. Every eligibility check re-runs: reshare, empty draft, existing LinkedIn URN, in-flight publish lock, published status, the workspace’s review requirement (§1.3), and the 5-per-24h budget.
  3. The publish itself is guarded twice: a stored LinkedIn URN short-circuits to result: "already_published" without contacting LinkedIn, and an in-flight lock returns conflict. There is no force/override argument.
  4. The post’s configured extras run — auto-like, employee advocacy, Slack notification, engagement activation. These are the same side effects a scheduled publish runs; an agent publish and a cron publish are deliberately the same operation.
  5. Success returns result: "published" with the LinkedIn URN.
Operational edges:
  • postId is the public post_… id — the one every other tool returns and accepts. The internal database id is not an input anywhere on this surface; passing one is not_found like any other unknown id. result.postId comes back in the same public form.
  • A publish that failed within the last 10 minutes blocks a retry with conflict — a failure after LinkedIn accepted the post is indistinguishable from one before it, so check the post on LinkedIn instead of retrying.
  • Exceeding the 5-per-24h budget returns rate_limited on both phases — schedule the post instead.
  • Reshares, empty drafts, and posts already live are refused with teaching errors.
  • Only treat a post as live once you have seen result: "published".
  • A video’s custom cover is fail-closed: a thumbnailKey LinkedIn will not take fails the whole publish rather than posting the video with another cover — see Media § attaching to a post.
  • A post that carries no media but does carry a link publishes with a LinkedIn link-preview card, built from the linked page’s Open Graph metadata. LinkedIn does not scrape URLs, so Scripe supplies the card; it is best-effort and not part of the proposal — a page with no og:title, or a thumbnail that cannot be fetched, simply publishes with a smaller card or none. A post that carries its own media is unaffected: media always wins over the link.
To publish at a future time, use schedule_post — a time in the request is a schedule, never a publish. To unschedule (revert to an unsent draft), update_post({ postId, scheduledFor: null }) — reversible, so no confirmation.

2.4 Media on a post, and on an idea (attach_media_to_post, attach_media_to_idea)

The media write is full-state: it replaces everything the post carried. So “add one more image” is a read-then-write, and the read is get_post, whose media block reports the post in exactly the shape this tool takes back. To add a second image, resend the first one by key:
Two branches, and picking the wrong one is refused rather than stored: key is a stored file key, and the publish step classifies it by extension to decide whether the asset goes to LinkedIn as an image, a document or a video. A key it cannot classify — an img_… id, a CDN URL — is rejected with unprocessable naming the branch that works. (It used to be accepted with a 200 and then fail on the publish cron hours later, with the post looking attached the whole time.) The response carries the post’s resulting media, because the resolution is not the identity: a library id comes back as the stored key you will need next time, and a .pdf sent under kind: "images" comes back as kind: "document" — which is how LinkedIn will publish it. A post the dashboard gave a LinkedIn poll refuses media with conflict and is left unchanged — a post publishes media or a poll, never both; see Media § Attaching to a post. kind: "none" on such a post is allowed and leaves the poll alone. Idea media (attach_media_to_idea) is the same full-state contract on a BOARD IDEA, with a narrower payload: library images only, as { "kind": "images", "images": [{ "assetId": "img_…" }] } (first = cover, max 20), or kind: "none" to clear. There is no key branch — an idea’s media is a library reference, not a stored post file — and no video/PDF kinds; the one non-image case is a single carousel that carries a cover image, attached alone: that cover becomes the media image and the idea’s visual format becomes CAROUSEL. That includes a carousel built by generate_carousel: the job renders a page-1 cover and re-hosts it to Scripe’s CDN, so a freshly finished deck may answer the transient processing refusal for the few seconds the re-host takes, then attaches like any image. A carousel generated before covers existed stored a rendered document with no durable cover, so attaching one of those is refused permanently (not_attachable), not temporarily; it is attachable to a POST either way. A multi-image stack needs an image-producing visual format (a CAROUSEL-format idea refuses a stack with the repair named), and an asset is otherwise refused until Scripe has re-hosted it on its CDN — an idea stores the image URL itself, so only the DURABLE one may be written onto a card, and status: "READY" is not the signal (a READY row can still be waiting on the re-host). That is stricter than attach_media_to_post, which stores a storage key and so attaches a just-created asset immediately; here, retry shortly (usually seconds) — but only when the refusal is the transient one, since a media with no durable image at all says so and will answer the same on every retry. Reference-only style picks (shown by get_idea as media.mode: "reference") are made in the Scripe app and are readable but not writable here — and because the write is full-state, it DISCARDS one the idea was carrying (with either kind), which nothing on this API can recreate. Clearing an idea that has media also clears its assetFormat, exactly like the idea page’s “remove media”; on an idea that carries none it changes nothing, because the visual format on its own is a brief label update_idea owns — the response’s wrote: false is how you tell that no-op from a real clear. The media URL fields get_idea returns (media.images[].imageUrl, media.styleImageUrl) are display links to the image bytes and need media:read alongside ideas:read; without it they come back null while the img_… ids, mode, style name and image count stay intact — images with a null imageUrl mean the link is withheld, not that the idea has no media (media: null is what that means). A reference pick often has no picture to link at all: the Scripe app draws the preview live from the customer’s own brand — their quote card carrying the idea’s hook, a carousel cover in their identity, their brand over one of their own photographs. Those come back with media.styleImageUrl: null whether or not you hold media:read, and the decision in media.brandPreview: kind (quote-card | carousel-cover | photo-overlay), the text the card carries, an optional designId / variant, and for a photo overlay a photoUrl — the one link in it, gated by media:read like the others and simply absent without that scope. It is a structure, not an image: there is nothing to fetch, and redrawing it takes the app’s renderers. The field is MCP-only — the REST detail read (GET /v1/ideas/{ideaId}) emits the properties its OpenAPI schema documents and no brand preview.

2.5 The user’s own image (create_media_asset)

attach_media_to_post takes an img_… library id or a key the post already carries. A photo the user just handed you is neither — create_media_asset is the step in between. The whole arc is two calls:
  • status: "PROCESSING" does not mean “wait”. The asset is attachable the instant it is returned — attach resolves the stored file, not the status. Cloudflare re-hosting and vision tagging run asynchronously; search_media (which lists READY rows only) shows it once they finish.
  • Two ways in, exactly one per call. content_base64 is simpler for SMALL files (≤ ~3 MB decoded — anything larger dies at the platform edge as a bare HTTP 413, so pre-check the size). Pass sha256 of the decoded bytes alongside when the host can compute it; a mismatch is rejected instead of storing corrupted bytes. uploadId — an upl_… handle from create_upload_url, after you have PUT the bytes to its signed URL — is the path for everything else, up to the full 25 MB image cap.
  • Images only. SVG is refused, and a PDF or an audio file is refused naming the tool that takes it (add_to_knowledge_base, create_source_file).
  • Retries are free. The same bytes, or the same upload handle, return the asset already created rather than a second copy — and re-sending bytes that were previously deleted revives the asset.
  • An upl_… handle whose bytes were never PUT is a 422 that says so, not a broken asset.
The undo is delete_media_asset (media:destroy, two-phase): the dashboard’s tombstone delete, so the image leaves the library and every future attachment while posts already carrying it keep their copy and a LinkedIn-synced photo is never re-imported. A foreign or unknown img_… id answers the same not_found either way. Deleting needs the OWNING project, attaching does not. Profile assignment makes an image reachable — enough for search_media to list it and for attach_media_to_post to use it — but the delete runs the dashboard’s own check against the project that owns the row. So search_media can hand back an img_… whose delete answers not_found; the refusal says the rule out loud, and a host should report it as an image owned by a different project rather than as one that does not exist. “Keep their copy” is bounded at 30 days. The delete stamps the row’s updatedAt, and the retention sweep erases the stored file and the CDN copy 30 days later — every embedded reference (saved carousel decks, saved templates, exported PDFs) then returns 404. A post scheduled to publish more than 30 days out loses its image. Posts already published to LinkedIn are unaffected, because LinkedIn hosts its own copy. Both phases of the tool say this, and kept.storedFileForExistingReferencesDays carries the number rather than a boolean, so a host cannot read it as permanent. And the erasure reaches every version, not only the current file. Every render of a generated asset repoints the row at a fresh storage key, so an asset edited more than once left one stored object per render. A nightly inventory sweep erases every media-library object no row references once it is older than the same 30-day window, working from the bucket itself rather than from anything a writer recorded — a superseded render is by definition older than its replacement, so all of them are gone within 30 days of the delete. Both phases say so, and a host may report the delete as total removal of the stored file and its history. The REST twin is POST /v1/media — see Media; deletion is MCP-only.

2.6 Removing a post (delete_post)

delete_post is two-phase and irreversible, and the two facts are connected: posts have no archive. A note archives by default and only confirms on the permanent path; a post has nowhere to go, so every post delete confirms. The first call returns a proposal, not a deletion:
Read linkedInPost before you show the user anything. “Delete my post” almost always means take it off LinkedIn, and this tool cannot do that: it removes Scripe’s copy. A published post stays live at the permalink, and its analytics history is kept but stops linking back to a Scripe post. Say so, and offer the LinkedIn link. What the confirmed delete removes, in one transaction: the post, its calendar slot, its labels, review rows, version history, variations, carousel deck, inline comments, and any armed auto-engagements. What it keeps: analytics rows, tracked links and notifications — records of something that really happened — and the idea, its attached source material and the topic the post was written from, whose pointer to the post is cleared rather than the row deleted. To take a post out of the schedule instead, update_post with scheduledFor: null is reversible and needs no confirmation.

2.7 The posting schedule (update_posting_schedule)

Two different asks share the word “schedule”, and they are different tools: The template is what get_next_free_slot walks, what the calendar draws as an open slot, and what the pillar rotation offers next. Most projects do not have one, which is why that tool commonly answers source: "fallback". Read it from get_settings, where the calendar timezone and week start already live:
slots[].time is a wall clock in effectiveTimezone — always an IANA name, so it can be converted safely. postsPerWeek is slots × their days, the number the user says out loud. The write is full-state. The slots you send become the whole week, so read the current ones and send them back with your edit applied. An empty array removes the schedule. One entry per time of day: daily 09:00 is one slot with seven days, not seven slots — sending the same time twice is refused rather than merged, because a merge would guess which day-set you meant. The first call writes nothing and returns the resulting week plus the diff:
Show the REMOVED lines. They are the only place a user finds out that a slot they set months ago is about to disappear, and a whole-template replace is exactly the shape where that happens by accident. The executed call answers with schedule (what is stored now), previous (what it displaced — enough to put it back) and changed. Pass timeZone (IANA) to change the zone the times are expressed in, or omit it to keep the project’s. Setting a template publishes nothing and does not move posts that are already scheduled.

2.8 Writing style preferences (update_tone_of_voice)

Three of the fields are stored as integers whose numbers are neither ordinal nor consistent with each other3 is the longest post length and the shortest sentence style. So the tool takes the word, and get_settings returns the word next to the number it read: The legal numbers are still accepted, and a word and its number bind to the same confirmation token. Any other number is refused: the columns have no scale between these values, so writing one changes nothing about generation while telling the user their settings changed. On the read side, get_settings’s context carries postLengthPreferenceLabel, formattingPreferenceLabel and emojiPreferenceLabel, derived through the same mapping the generators use — so a project holding a legacy value no picker offers still reads back as what it will actually produce. What “never chose” reads back as differs by project type: a company page stores 0 for “never set” and reads back moderate, the band it generates at; a personal-brand project stores 1 (its column default) and reads back none.

2.9 Engagement policy writes

update_engagement_policy is covered by its table row plus §1.15 — the read and the write share the same semantics, including the deliberate no-row disarm.

2.10 Positioning and the org-wide voice

Three tools write at WORKSPACE level, where every brand shares the result:
  • update_positioning writes the company and target-audience documents. A field that is still empty is written on the first call. A field that already holds text is written only via overwrite: true, which returns a PROPOSAL naming each field with its current and new text and writes nothing until the token is replayed. Without overwrite, such a field comes back in needsConfirmation with its current text — not a refusal, the cue to show the user what a replacement would cost. overwrite is inside the confirmation binding, and replaced returns the previous text once, since positioning has no version history.
  • update_global_tone_of_voice writes the organization-wide tone of voice: custom instructions injected into every generation for every brand, the shared footer, and whether brands may add instructions of their own. Business plan and above (plan_not_eligible names companyToneOfVoice below it). Same empty-vs-occupied rule; only the two TEXT fields ever need overwrite, and overwrite is per-CALL — set it and the whole change set, boolean switches included, becomes two-phase.
  • draft_positioning_from_website reads a company’s public site and drafts the positioning documents, returning wouldFill (empty fields — a direct write) and wouldReplace (fields that would go through the overwrite confirmation) in update_positioning’s own field names. It persists nothing — nothing changes until you call update_positioning with the parts the user accepted. Splitting the scrape from the write is what lets the write’s confirmation hash the exact text that lands.
A fourth write is per-brand, not workspace-wide:
  • update_personal_dna writes one project’s personal DNA — the person document behind the dashboard’s Brand → Positioning → “You” tab: who the author is (intro, 1–2 sentences), credibility points, opinions & hot takes, and personal stories (the list sections hold one entry per line, exactly as the dashboard stores them). projectId is required — this text becomes prompt content for the brand, so the model must name the project. The same empty-vs-occupied rule applies, with the same overwrite confirmation, needsConfirmation/current report and one-time replaced return. The read side is get_personal_dna, which does fall back to the default project and echoes project { id, name, fromDefault }.
All four writes are admin-only. The workspace-level read side is get_positioning.

2.11 Removing knowledge (delete_knowledge_doc)

Everything add_to_knowledge_base creates can be removed again, including a page ingested with type: "url". The delete is two-phase and takes the document’s chunks, their embeddings and its project-visibility assignments with it, in one transaction — so “take that page back out” actually stops it answering questions. One case refuses, and only one: a document that is one page of a dashboard sync (a website crawl, a Notion connection, or a GitHub, Google Drive, Granola or Post history synced source). Deleting a single page of a sync is pointless — the next sync run restores it — so the refusal names the sync instead:
Which dashboard control that means depends on the sync: a profile’s Post history folder is curated post by post, with Remove from knowledge base — see Synced knowledge sources. Sync membership is what a document’s folder says, not what its type says. type: "website" only means the text came from a web page, which is equally true of a one-off add_to_knowledge_base({ type: "url" }) — those have no folder, no sync behind them, and delete normally.

2.12 Generating for a company page

A workspace’s projects are not interchangeable. list_projects reports a type per project, and generate_post treats two of them differently: Pass the company page’s proj_… id and nothing else changes about the call. list_company_pages is where you find it: only a page with status: "activated" has an activatedProject.id; a discovered page has no project to write into yet. A company page’s language, post length, formatting and emoji preferences are stored on the page itself, not inherited from whichever personal brand discovered it — read them with get_settings({ projectId }) and set them with update_tone_of_voice({ projectId }), the same as any other project. If the page’s LinkedIn organisation details are missing (a partially connected page), generation fails unprocessable with a message saying to reconnect the page — not ai_error. Retrying will not help; fixing the connection will.

2.13 Removing a source (delete_source)

delete_source is two-phase and irreversible, and — uniquely among the destroy verbs — it also exists on REST (DELETE /v1/sources/:sourceId, same two phases, token via ?confirmationToken=). The proposal names the blast radius: the source row, the full transcript (paragraphs and sentences), its topics and their hooks, its knowledge-base copy (document, chunks, embeddings — knowledge search stops returning the content immediately), and the stored audio/video/file object, whose storage quota is released. Two things deliberately survive, and both the proposal and the executed response say so — do not present this as total erasure:
  • Posts generated from the source are kept. They are the user’s content, not the source’s child; only their link to the source goes stale. The proposal counts them (kept.derivedPosts); removing one is delete_post, its own two-phase decision.
  • The internal usage-accounting ledger survives — deleting it would silently rewrite historical cost reporting.
If any of those knowledge-base documents was shared with the whole company (workspace-wide), the delete additionally requires an admin of the workspace that document is shared with, acting through an OAuth grant — not necessarily the caller’s own, since moving a project between workspaces leaves its company-shared documents behind. That is the same authority rule delete_knowledge_doc applies to those rows, enforced in both phases. Without it the call fails admin_required and nothing is deleted; the proposal counts them in toDelete.workspaceSharedKnowledgeDocuments and warns that colleagues outside the project rely on them. Executing emits source.deleted (identity and last status only — the transcript is never in the payload; a receiver that wanted the content had source.created). Requires sources:destroy, which no alias and no sources:write grant ever implies. A token is bound to the source and the acting principal; a replay after success reads not_found and deletes nothing further.

2.14 Content topics (create_content_topic)

A content topic is one of the themes the workspace wants to be known for. The dashboard lists them under Content topics at Settings → Organization → Positioning; the older per-brand board called the same thing a topic group, and the tool’s description still carries that word so a model that learned the old name finds it. Scripe mines the LinkedIn corpus around each one and builds post ideas from it. Four nearby things it is not, each with its own tool:
  • the positioning documents (update_positioning) and the tone of voice (update_tone_of_voice, update_global_tone_of_voice) — free text, no topic list;
  • the topics a recording was cut into (tpc_… ids on get_source);
  • the legacy per-project pillars array get_settings still reports;
  • an idea (create_idea), which is one post-to-be rather than a theme.
The tool is workspace-scoped: a topic belongs to the workspace, not to a brand, and workspaceId may only name the connection’s active workspace (omit it and it does). Because one topic reaches every brand it is assigned to, the write needs a workspace-admin acting user on top of settings:write — the same bar the dashboard enforces. An API-key principal has no acting human and is therefore refused. profileIds is optional and takes public proj_… ids from list_projects. Each is re-authorized on its own; a project outside the workspace and an amplifier (which never owns content topics) both refuse the whole call with not_found. Omitting it creates the topic unassigned, and the response says so: an unassigned topic is listed but influences no generation until it is assigned. Creating is the only thing the surface does with content topics: there is no read tool, so a topic cannot be listed back, and assignment changes, renames, deletes and the relevance analysis stay in the dashboard. The response carries the new topic’s wct_… id, its title, searchTerm, status, order and the profiles it was assigned to. Creating twice with the same title creates two topics (the dashboard allows that too); retries are covered by the shared idempotency window (§2.1), so pass an idempotencyKey when retrying a call that may already have succeeded.

2.15 Post ideas from material (generate_post_ideas)

generate_post_ideas is the dashboard chat’s idea cards on MCP: the same producer, the same result. It turns material into For You ideas — the recommendations the dashboard shows on Explore → For you — and persists them to the brand’s idea pool, where they appear under the “Your files” source filter. Each idea carries a title, a hook, an angle, the postType and pillar it suggests, its funnelStage (the content lane, derived from the postType, and null for a format Scripe cannot place in one), a relevanceScore, the brief (the argument for the idea, as prose) and the citedPassages of the material that brief was built on. The response also echoes the project it resolved to (id, name, fromDefault), because projectId is optional here as it is for the whole idea family. Material comes from one of three places:
  • text — an article, notes, a transcript the user pasted, passed verbatim. Scripe cuts it into passages, scores each against the LinkedIn corpus and cites the ones an idea is built on.
  • knowledgeDocumentId — a kb_… document from list_knowledge. It is held to the same tenant rule the dashboard applies: the authorized brand’s own documents and its workspace’s; anything else answers not_found. When given, text is ignored.
  • neither — the ideas build on the brand’s strategy, knowledge base and the request alone.
There is no file upload on this tool. A recording goes through create_source_file; once processed its transcript is in the knowledge base, and from there it is a knowledgeDocumentId. count is optional and bounded (1-12); omit it unless the user named a number — the generator then picks a sensible three to six. request carries the user’s own words, and a number named there counts too. funnelStage is optional and takes one of REACH, TRUST or CONVERT — the content lane the user asked for BY NAME (“give me Convert ideas”). Pass it only then: the ideas are then confined to that lane’s post formats (every returned idea’s funnelStage reads that lane), while a run without it lets the brand’s strategy weights and the model choose. The lanes and what each is for are the same three the dashboard shows (Reach — top of funnel, Trust — middle, Convert — bottom); they are a mix, not a sequence. What it does not do: it never writes to the idea board. The chat card’s “Add” is create_idea here — call it with an idea’s title and hook to board one the user picked. It is not generate_post (a post) and not search_viral_posts (a read of the corpus). Cost and bounds: one premium reasoning call per invocation, so the tool draws from the job rate bucket (§5 in MCP), and on top of that the producer’s own per-brand hourly bound — shared with the dashboard chat — answers rate_limited when the brand has generated ideas too often in the last hour. A run that produced no usable idea (the model refused, or every candidate duplicated an idea the brand already holds) answers unprocessable with the reason. Retries are covered by the shared idempotency window (§2.1).

2.16 Community profile lists (list_profile_lists, get_profile_list, add_profile_to_list)

A profile list is a named watchlist of LinkedIn people whose posts Scripe collects, so the customer can read and engage with them from inside the product. The dashboard shows them under Community → Profiles. A list belongs to ONE brand, and a member is somebody else’s LinkedIn profile — never a Scripe brand. The sentence these exist for is “this hot lead just came in from the CRM — add them to my Prospects list”:

What profile accepts, and what it refuses

The LinkedIn profile URL is the one thing the caller has to supply. A full URL, a country-subdomain URL (de.linkedin.com/in/…), a URL carrying CRM tracking parameters, or a bare vanity handle all resolve to the same person — a paste straight out of a CRM record needs no cleaning up. Scripe cannot look a person up by NAME, so a name is not an input this tool can resolve. LinkedIn publishes no people-search API and Scripe’s own corpus is a corpus of creators, not of people — 14.8% of its rows share a display name with another row. A best match would put a stranger on a list the customer then engages from under their own LinkedIn identity. A name with a space or a non-ASCII character is refused (invalid_request) with what to supply instead; a single bare word is indistinguishable from a vanity handle and is treated as one, resolving to whoever owns it — so pass the URL or handle from the CRM record, never the person’s name. Company pages and email addresses are refused.

A person who has never posted is a member like any other

add_profile_to_list resolves a profile whether or not they have ever published, and reports it: postsCollected: false on the add, postsCollected: 0 on get_profile_list. That is an ordinary state, not a half-failure — most CRM leads read LinkedIn rather than write on it. They are watched from now on, and posts appear if and when they publish. Somebody already on the list answers added: false, alreadyMember: true with a note saying nothing changed — before any limit below is consulted, since nothing would be written. A profile that does NOT resolve is one of three things, and the answer says which: The middle row is what a non-existent handle usually produces: probed on 2026-09-17, /profile/detail for a made-up username answered HTTP 200 with {"success":false,"message":"error, try again later"} — the same status, shape and message as a throttled request — so Scripe cannot honestly claim either reading and says so.

Limits

Each added profile is a standing obligation — Scripe re-checks them for new posts until somebody removes them — so this verb is bounded more tightly than an ordinary write: Every one of them refuses the whole call and adds nothing; none truncates silently. The two caps are counted per add with no lock, so concurrent adds can overshoot one slightly — they are soft caps, not guaranteed maxima. get_profile_list returns at most 200 members, newest first. list.memberCount is always the list’s true count; a list holding more than that answers membersTruncated: true with a note saying how many are shown of how many there are.

Not in this family, by decision

There is no tool that likes, comments on or reposts. Every engagement in Scripe is an explicit human click in the dashboard (captain decision, 2026-09-01) and the data model admits nothing else — two-phase confirmation would not restore the human, since both calls are model-initiated (§3 in MCP). There is also no remove_profile_from_list (removal stops the collection, a destroy-family verb) and no create_profile_list (a model that cannot find a list would invent one). Removing a profile, creating a list and engaging all happen in the Scripe dashboard. Profile lists are a Business-plan feature; a workspace below it gets plan_not_eligible. Client accounts of an agency workspace cannot reach any of the three tools, matching the dashboard.

3. Tool error envelope

Failed tools return isError: true with structured content:
The envelope is identical to the REST error body, so one parser handles both surfaces. code is stable and shared with the error reference. Models react to the structured form; hosts that don’t parse it show the JSON-stringified text fallback.

3.1 Unknown arguments are refused, not ignored

Every tool’s input schema is closedtools/list advertises additionalProperties: false, and a call carrying a parameter the tool does not declare is rejected before the handler runs:
This is deliberate and it is the one place a tool answers outside the error envelope above: a schema rejection is raised by the MCP SDK before the request reaches Scripe, so it arrives as an isError text block with no code and is not audit-logged. The alternative was worse. Until 2026-08 an undeclared parameter was silently discarded, so list_posts({ q: "pricing" }) returned the newest 50 posts with a 200, and update_post({ postId, status: "published" }) returned a success envelope having changed nothing — a wrong answer that nothing downstream could tell from a right one. If you are porting an integration that relied on extra keys being tolerated, drop them; the refusal names every parameter the tool accepts. Nested object arguments follow the same rule only where they opt in: generate_post.source and generate_post.options reject unknown keys and name the object in the message. The add / modify entries of update_post_engagements do not — an unknown key inside one of those entries is still silently stripped, so validate them against the tool’s schema yourself. The REST API remains permissive today — an unknown query parameter or body key is ignored there, so port REST habits to MCP with care.

4. Inline file content (content_base64)

create_source_file, add_to_knowledge_base, and create_media_asset accept file content inline as base64, in addition to the two-step create_upload_url + signed-PUT flow. Inline is for small files only (the cap below) — it exists because most hosts cannot reliably drive a separate signed PUT from a tool call. For anything larger the two-step flow is the only path, and it is the preferred one whenever the host can drive it.
Limits: ~3 MB decoded per file (~4 MB on-wire as base64). The serving platform rejects request bodies at ~4.5 MB on the wire with a bare 413 FUNCTION_PAYLOAD_TOO_LARGE that never reaches the API — no error envelope, no docs_url — so pre-check the file size and use the two-step presigned-PUT path for anything larger rather than retrying inline. Scripe’s own cap sits just under that edge so a payload slightly over it still gets a teaching error naming the repair. The per-content-type caps from Uploads (image 25 MB, PDF/docx 100 MB, audio/video 500 MB) apply to the presigned-PUT path, which is the only way to send a file the inline cap does not fit. Idempotent by construction: the inline path derives the storage key from a hash of the content, so retries with an identical payload land on the same upload handle and the ingest workers de-duplicate — a tool retry never creates a duplicate source or knowledge row. Two-step form ({ "type": "file", "uploadId": "upl_…" }) is the required path for files over the inline cap, and what non-MCP integrations use.

5. Progress streaming

The async tools (create_source_file, add_to_knowledge_base, generate_post, generate_image, generate_carousel) stream MCP notifications/progress when the host opts in by passing a progressToken in the request _meta:
  • An initial 0-progress notification as soon as the tool sees a non-terminal job, updates every ~750 ms while the worker reports (capped at 60 notifications per request), and a final progress: 1 when the job reaches a terminal state (DONE/FAILED/CANCELLED). total: 1 is always set so hosts render percentages correctly.
  • The tool’s result returns after the final notification with the latest job state.
  • Cancellation: if the host sends notifications/cancelled, the tool stops streaming and returns the current job state. The underlying job keeps running — call cancel_job explicitly to stop the worker (hosts often cancel a call to start a follow-up, not to kill the job).
  • Hard cap: 18 seconds — deliberately under the route’s 25-second platform ceiling, so the answer has room to serialise and reach you. At the cap the tool returns whatever job state exists — for a post generation (32–35 s end to end) that is normally a RUNNING job. The job continues in the background: read data.id and poll get_job. (The cap used to equal the platform ceiling, so a streamed call could never answer — hosts got no JSON-RPC response at all and lost the job id with it. A RUNNING job you can poll is strictly better than a dead socket.)

6. Resources

scripe:// resources let the host attach a record for @-mention or inspection: All resources return mimeType: "application/json" with the same envelope the REST API uses. Resources are read-only; mutate via the matching write tool. They are read-on-demand — the server does not emit notifications/resources/updated, so re-read when you need fresh state.

7. Prompts

Slash-command-style entry points the host renders in its UI. They produce a single user-role message that primes the model with investigate-then-execute steps; they never call tools themselves. All arguments are strings (per MCP Prompt.arguments constraints); numeric ones (weeksOut, ageDays) are parsed server-side with sensible defaults.

8. Canonical flows

The server’s instructions steer models through these; they’re documented here so you can validate a host renders them correctly. Draft a post. generate_post with the user’s brief as a text source — the worker applies the project’s tone, pillars, voice samples, and knowledge base, so the brief shouldn’t over-prescribe. The model shows the draft and asks before scheduling anything. File post text the user wrote themselves. create_post_draft stores that text verbatim as a real post — it lands with the project’s posts, never on the idea board, and generate_post is the tool that AI-writes a draft instead. create_idea is the board card for something still to be written, so any “post draft” ask resolves to create_post_draft; when a request hands over finished post text without naming either (“send this to my Scripe project”), the model is instructed to ask which the user meant rather than guess. Capture a note for a future day. create_note with content and a resolved date — the note lands on that day in the dashboard calendar. Send a file to the knowledge base. add_to_knowledge_base with inline content_base64 for a small file — over the inline cap (§4), create_upload_url + PUT first and pass the uploadId — or type: "text" / "url" / "youtube". Omitting projectId uses the default project; explicitly passing projectId: null scopes it workspace-wide (“my org’s KB”). Returns a Job; progress streams. Ingest a source, then draft. create_source_file → wait for the job → get_source — once status is "Success" the response carries topics[], best-first by score (ranking is just the position in the served order, and fromSeconds says where the topic sits in the recording — an earlier chapter is not a better one). The model presents the topics, the user picks, then generate_post with source: { "type": "topic", "topicId": "tpc_…" }. The server reads that topic’s transcript chunk itself — the full transcript is deliberately never served over this API, so the topic id is the only way to generate from what the user actually said rather than from the summary about it. 404 means the topic’s source is outside your workspace; 422 means it belongs to a different project than the projectId you passed. Two things not to build on: hooks[] is empty for anything ingested through this API (hooks are a dashboard artefact, and post generation writes its own), and the flow is deliberately not auto-chained — a source can surface several distinct topics, and the user picks the angle. Turn a board idea into a post. Pass the id — never re-type the card into source.text:
An idea card is a brief, not a sentence — title, body, hook, topic, post format, visual format, capture evidence, attached material. The server renders the whole brief itself, so nothing is lost to a copy-paste, and two things follow that a caller cannot do any other way: the card’s pillar fills options.contentType when you did not send one (an explicit value always wins), and the generated post is linked back to the idea — the idea’s status is derived from the linked post, so this is what moves its status from inbox to in_production (when no post was linked to it yet — the card’s board column is not changed, since columns are customised per workspace and move only by hand), and what stops the next session seeing an untouched card and generating the same post again. Read it back with get_idea (linkedPost.id). Schedule. Two-phase schedule_post (§2.2). The proposal — the only thing the user sees before consenting — resolves everything:
  • scheduledFor comes back as an absolute UTC instant, and scheduledForLocal renders it in the project’s calendar timezone (timeZone) — e.g. Tue, 18 Aug 2026 09:00 (Europe/Berlin). Show scheduledForLocal; a raw instant is not something a user can check.
  • A timestamp with no offset (2026-08-18T09:00:00) is read in the project’s timezone, not UTC and not the server’s — that is what a user means by “9am”. Send an offset when you mean an absolute instant.
  • A time that cannot be scheduled is refused at the proposal, before any token exists: unparseable strings (resolve “next Tuesday” yourself — the server does no date math), a bare YYYY-MM-DD (it would schedule midnight; use get_next_free_slot for a sensible hour), and anything in the past. A proposal you can confirm is a proposal that will execute.
  • The LinkedIn connection is checked live at the proposal too, and the verdict comes back as a linkedIn block: preflight (ok | not_connected | token_invalid | no_company_page_admin), canSchedule, detail, and reconnectUrl. Branch on canSchedule — when it is false, confirming will fail with 409 conflict (details.reason, details.reconnectUrl, details.retryable: false), so relay detail and the link instead of asking the user to confirm; only a human can reconnect LinkedIn. You still get a confirmationToken, because reconnect-then-confirm is exactly the flow this enables; phase two re-runs the same check. (no_company_page_admin carries no reconnectUrl: its repair is on a different project — some admin’s own LinkedIn account.)
Execution verifies the token binding, re-runs the LinkedIn check, stamps the scheduled-category status with the calendar slot in one step, emits a post.scheduled webhook, and the publish cron posts to LinkedIn within ~2 minutes of the scheduled time. To reschedule, run the flow again; to unschedule, update_post({ scheduledFor: null }) (reversible — it emits post.unscheduled). Publish now. Two-phase publish_post (§2.3). Work the idea board. create_idea per idea (top of the inbox column) → schedule_idea for date-level planning → update_idea to edit → two-phase delete_idea to remove.