Skip to main content
Preview. /mcp/v3 is behind a feature flag and available to a limited production canary. Nothing on /mcp/v2 changes, and no migration is required or proposed. Unless Scope3 has enrolled your account, continue to build against v2.

One endpoint, not one per persona

v2 is persona-scoped: you pick /mcp/v2/buyer or /mcp/v2/storefront, and the URL decides what you can do. v3 inverts that. There is one endpoint, and the tools you see are resolved from the account you authenticated as. A seller account yields seller tools; a buyer account yields buyer tools. You do not choose a persona — you authenticate, and the surface follows. This matters because the persona split was never real to the person. One human might administer an organisation, own a storefront, and buy through two advertiser seats. In v2 that is three MCP servers. In v3 it is one connection and a switch_account.

Nouns crossed with a fixed verb set

v2 exposes a generic api_call tool over a large named-operation enum. It works, but it asks the model to pick an operation and then guess the body shape. v3 is the platform’s nouns crossed with a fixed verb set, so adding a capability means adding a field to a noun — never appending to an enum. Reads generalise safely and cannot corrupt anything. Writes carry a per-noun body, so each gets its own tool with its own schema.

Search documentation in two bounded steps

search reads account objects, the Interchange documentation, and the AdCP specification without making an agent choose a different tool for each source. A normal documentation result is deliberately a short discovery result. Each hit includes a citation URL and a stable document path:
Before answering with a procedure, public response field, safety step, identity rule, or authorization rule, read the selected page through the same verb. Keep the original question so search can return the most relevant full sections:
An exact-document read is resolved only against the indexed public docs. It returns at most four relevant sections under a bounded response, keeps their citations, and marks the read as terminal. Answer from those sections, or say that the page does not establish the requested fact; do not keep reformulating the same search. Arbitrary web URLs and internal repository paths are not readable through this field.

Orientation, and knowing you are in the wrong place

MCP negotiates the tool list once, at initialize. If switching accounts changes which tools exist, a client that missed notifications/tools/list_changed would be holding a stale list. v3 does not depend on that notification:
1

get_status always lists the accounts you can reach

Orientation never requires switching in order to discover what switching would give you. It returns the current account, what is blocking it, what to do next, and every other account reachable from here.
2

Every tool fails legibly in the wrong account

Calling a seller tool from a buyer account returns a typed error naming the exact switch_account call that reaches an account where it exists.
3

tools/list_changed is emitted anyway

Conforming clients get a clean surface. Clients that ignore it lose nothing, because the first two carry the weight.

Diagnostics are an include, not a tool

Health, sync history and capability rechecks are facts about an object, so they are an include on the object rather than a second tool name to learn:
A source can hold valid credentials and still be failing every call, so connection (do we hold usable credentials?) and health (is it working?) are reported side by side. They are different questions and they disagree often enough to matter. Inventory-source diagnostics follow the source that is actually connected. Managed ad-server rows use their real managedSa.connectionId; CitrusAd and other modular or feed-backed rows use modular readiness. If an older row has neither marker, get reports the requested include as unsupported instead of guessing an ESA id or hiding the missing evidence. The interactive work stays behind existing Pages:
  • connect_ad_server collects credentials and OAuth grants without placing either in model-visible arguments.
  • ad_server_source manages a connection and optionally accepts its numeric esaId as focus.
  • ad_server_diagnostics opens sync history and refresh diagnostics with the same optional focus.
  • source_diagnostics accepts the inventory source’s sourceId for call-level investigation.
Reads preserve any successful facts when another requested include is unavailable, so one provider failure does not erase the source or its other diagnostic evidence.

Storefront identity is a singleton

A seller account has one storefront, so read it without an id:
The projection includes its canonical operatorDomain, whether that domain is verified, effective and configured capabilities, the declared setupIntent, and the demand contact. Its read-only mode is "demo" or "live" when the demo lifecycle is observable, and "unknown" when that lifecycle route is feature-gated; unknown does not mean live. save_storefront is declarative: saving the current value returns "action": "unchanged" without writing.

What you declared versus what buyers can use

Two capability fields are returned, and they answer different questions:
  • configuredCapabilities — what you persisted. This is your declaration.
  • capabilities — what buyers can actually use right now. This is derived from your declaration plus your source topology and approval policy.
They can disagree, and when they do the projection says why: capabilitiesLocked is true when ad-server-backed sources hold every flag on, and capabilityDerivation names the owning field and the flags it decides — for example mediaBuyApproval deciding offersCampaignApproval on a composing storefront. A save that changed your declaration but not what buyers can use therefore reports both, rather than letting a successful write imply a capability buyers do not have. capabilities is a patch, not a replacement. Send only the flags you intend to change; the ones you omit keep their persisted values:
Turning a flag off on a locked storefront is refused with a validation error naming the flag. Remove the ad-server-backed source first if that is really what you want. When a lock is in force the reply says so even if your declaration and the effective set happen to agree, so you are never left reading a locked value as the declaration you made. setupIntent ("third_party_connect" or "sell_through_scope3") records how you have decided to sell — and it is not only a record, because it rewrites your flags. "sell_through_scope3" turns product composition on; "third_party_connect" turns all three off, withdrawing whatever you were advertising. So when you send it on its own, the reply names the flags it moved under presetApplied (turnedOn / turnedOff) rather than leaving that as a side effect you discover later. Send capabilities in the same call and your explicit flags win, and nothing is reported as a preset effect because nothing happened that you did not ask for. Re-declaring an intent whose flags already match is a no-op; re-declaring one whose flags have since drifted writes again, because the preset — not the intent alone — is what has to hold. It reads as null until you answer, which means “not answered yet”, not “pass-through”. A save writes identity before the declaration, so a refused domain change persists nothing. If the declaration is then rejected, the error says so under partialWrite instead of reading as a whole-call no-op: retry with only the capability declaration once it is valid. Changing operatorDomain can invalidate description, channels, membershipStatus, and website values curated for the previous identity. The write refuses to clear populated values and names them in a validation error. After reviewing that impact, explicitly acknowledge the reset:
save_storefront does not own those profile fields. If you need to preserve or replace them for the new operator, use PUT /storefront instead of confirming their removal through v3. The confirmation is forwarded only when the domain actually changes. Demo/live lifecycle, join links, sponsored buyer access, and payout destinations remain on their purpose-built Pages rather than becoming fields on save_storefront.

Everything waiting on you is one noun

A work item is something the platform is waiting on a seller for. There are three kinds and they read through one search:
That default means “still waiting on someone”, which is pending on a decision queue and OPEN/IN_PROGRESS/BLOCKED on a follow-up. Narrow with filter.workItemKind and filter.status. The two vocabularies stay distinct on purpose: a follow-up has no approved state and a creative review has no COMPLETED one. Ask for a status one stream cannot have and the response says which queue it did not search, rather than returning nothing and letting that read as “nothing is waiting”. The response echoes back only the filter you sent, so effectiveFilter is always valid to resend verbatim as filter. A sourceId is a top-level search argument rather than a member of filter, so it is echoed beside it as effectiveSourceId. What each stream was actually filtered by — including the defaults above — is reported separately under coverage.effectiveFilters. Follow-ups are held per inventory source, so a storefront-wide read fans out across your modular sources and reports how many of them it reached. Two limits are reported separately because they mean different things: sources the scan did not reach at all, and sources whose own read window filled up. A single queue reads up to 100 follow-ups at a time, and one that comes back full is listed under coverage.modularSources.exhaustedWindows — that queue is a floor, not a total, and narrowing with filter.status shows the rest. An empty or short result under either limit is never evidence that no work exists. To read one queue on its own, pass a sourceId together with filter.workItemKind: "modular_source":
A sourceId on its own is refused rather than half-applied: the approval queues are storefront-wide, so they would ignore it, and you would get a mixed result under a scope you believed covered all of it.

Deciding is declarative; the decision is not the delivery

save_work_item records an approve/reject decision, or completes a follow-up:
Saving the same decision twice returns "action": "unchanged" and writes nothing. A conflicting correction is refused rather than applied: the original decision and its evidence are preserved and the response tells you so. Read requiredResultFields from get or search before completing a follow-up, and never put a credential in result. Every work item carries a downstream block, because approved is not the same as delivered:
  • A completed modular_source follow-up applies its module side effects in the same transaction that completes it, so COMPLETED really is proof the effect landed.
  • An approved media buy with a forwardedAt timestamp was sent; that is not a guarantee every downstream leg accepted it.
  • An approved media buy with a null forwardedAt was never forwarded. That is reconciliation work, and the response names the approvals page as the next action — re-recording the approval will not send it.

What is deliberately not a save

Three neighbouring actions live on the approvals page (open_page) instead of being fields on save_work_item:
  • Getting an advisory AI evaluation. Each call costs money and returns a fresh verdict, and it records no decision — so it is not a state you can declare.
  • Reassigning an approval to a different person. Anything that changes who can act is a page, never a tool.
  • Retrying a forward. It re-sends the buy to your ad server and can override a terminal row, so calling it twice is not the same as calling it once.

Coverage is domains, properties, and a verdict

A seller account has one coverage set, so read it without an id:
The projection is the property roster: each declared publisher domain, the properties resolved under it, and the authorization state of both. Three things about it are contractual, because each one is a way an agent could otherwise mislead a seller:
  • The authorization verdict is a field, never an inference. Every domain carries authorizationStatus and authorizationReason; null means “not resolved yet”, which is not the same as authorized. Every property carries its own authorization with a grain — read Whether one property is authorized before presenting a positive as property-level proof.
  • An incomplete read says so. partialRoster is true when the response left rows out. While it is set, finding nothing is not evidence a domain or property is absent. Compare each domain’s propertyCount against the properties actually returned rather than reading an array length as a total.
  • coverageApplies is false for a storefront that sells through a platform account connection. Those storefronts read unauthorized forever and transact perfectly well; their verdicts are not a problem to fix.
search({ kind: "coverage" }) narrows the same data. Its query matches properties as well as domains, because “is The Verge in there” is a question about a property under vox.com, and filter.authorization narrows to one verdict.

Changing coverage without replacing it

save_coverage writes at three grains, and which one you use decides the blast radius: The incremental limit is 20 operations per call across all four fields combined, not 20 of each. Each named domain or property is its own declaration, applied one at a time, and declaring a domain also kicks its adagents.json resolution — the platform caps that at 20 per request precisely so a large set cannot stampede outbound fetches, and this limit is the same number for the same reason. Exceeding it is refused before anything is written, so you can split the call cleanly. Restating a large set is what domains is for: it is one request. Declaring the whole set is still the declarative way to state coverage, and re-stating the current set returns “nothing changed” rather than an error. But it is the destructive option, so it is not how you add or remove one domain: add and remove each issue one declaration and never rebuild the set, which means they cannot lose a change someone else made while you were reading, and cannot delete domains a bounded read did not show you. Removals are reported explicitly on every path — including the ones you did not name, on a domains replace — and each item reports its own outcome. A value that is not a usable publisher domain is named back to you too: an incremental edit reports it beside the items that did apply, while a domains replace refuses the whole call as an error — nothing is read or written, refused is invalid_domains, and invalid lists the values exactly as you typed them. Either way, only domains you declared are yours to un-declare. A domain we found for you follows its own source: remove returns a conflict for it, and a domains replace that leaves it out does not delete it — the response lists it under notSellerDeclared and says so, rather than counting it as removed. The removal report never names a domain that is still there. Domains are normalized the same way everywhere, so https://www.vox.com/tech and vox.com are one domain: the write reports the canonical form, which is the form a later coverage read returns. Every outcome list on a write comes back as { items, count, truncated } — including added, removed, declared, notSellerDeclared, declarationUnconfirmed, alreadyAbsentDomains, removalUncertain, declaredProperties, removedProperties, alreadyAbsentProperties, unresolved, verdictUnknown, unauthorized, invalid and failures. Treat the shape as the rule rather than the list as a closed set: any list-valued field a future response adds follows it too. A replace can touch thousands of domains, so items carries at most 50 while count is always the true number and truncated says the sample is short. Read count, never items.length. The text summary previews a handful and says how many more there were. Every response carries the same authorization block, including one that changed nothing and one that was refused:
  • coverageApplies — always present. When false, publisher authorization is not a concept for this storefront, unauthorized is empty by definition, and the summary says so instead of reporting verdicts as problems.
  • authorizationKnown — whether the read reached every domain the call was about, not whether each one has an answer. It stays true when a domain came back with no verdict yet, because that pending state is itself a fact and is reported in unresolved. It is false only when the read failed, or returned without one of the relevant domains — the cases where a list is missing rows rather than describing them.
  • unauthorized, unresolved, verdictUnknown — three distinct answers that must not be collapsed: the publisher said no, the publisher has not answered yet, and we never saw the domain. An empty unauthorized means “checked, none” only when authorizationKnown is true.
These are scoped to the domains the call was about — the set you declared on a replace, and on an incremental edit every domain an edit was dispatched against, whatever the outcome. A mixed call where one domain is declared and another comes back unconfirmed is about both, so the second still has to appear in verdictUnknown unless the read-back reached it. A replace never reports the verdict of a domain we discovered for you but you did not declare. A domains replace applies your declaration in full whether or not we could read the current set completely first — the set is yours to declare, so a bounded read is never a reason to refuse or skip the write. What a bounded read does change is the report, because added, removed and notSellerDeclared are computed by diffing against the rows we received. Two flags say which halves you can trust:
  • removalReportComplete: false — more domains may have been deleted than removed lists, because the ones past the limit were never compared.
  • additionReportComplete: falseadded is a set of candidates, not confirmed new domains. A domain you already had can sit past the limit and is indistinguishable from a brand-new one, so the list may overcount.
When either is false, rosterReadBounded is true, readableBeforeCount and reportedTotalBefore say how short the read was, and no exact post-write total is reported. When the read is complete both flags are true and every list is exact. A bounded read affects only what the response can claim, never what the write does to data you kept. A replace is a diff: a domain still in your declaration keeps its row and everything resolved under it — its properties, collections, creative formats, your own property claims, and its authorization verdict. Only the declared domains you dropped are deleted, and only genuinely new ones are created. Restating your existing set is a true no-op at the database, whether or not we could read all of it first. Only the domain list being short sets rosterReadBounded. A roster read of a network publisher routinely trims properties while returning every domain, and that does not make a domain-set decision unsafe — so a declaration matching your domains is still reported as a genuine no-op with no write, even when the property arrays behind it were bounded. partialRoster on a coverage read is the broader signal and still covers both. Retracting a property claim is idempotent: retracting one that is already gone is a successful no-change response listing it under alreadyAbsentProperties, not an error. That applies only when the roster says the claim is absent. Naming a publisher domain that does not exist is still a failure — your declaration was not applied and the name is probably wrong — and a publisher-origin property is a conflict, because the state you asked for was not reached. Declaring and retracting the same property in one call is refused outright: it would cancel itself out, and the two are matched on the key the roster derives (property id, else the first identifier, else the name). Declaring one domain succeeding does not by itself mean the domain is now yours, so a declaration lands in one of three lists:
  • declared — the resulting row is yours. This is the normal case, and the only one that counts toward changed.
  • notSellerDeclared — the domain is present but its row belongs to a source we found it from rather than to your declaration. Declaring it does not transfer it, and you will not be able to un-declare it later either.
  • declarationUnconfirmed — the write was accepted, but the publisher list came back truncated past this domain, so which of the two above applies was never observed. Do not treat these as yours; re-read coverage and check each one’s provenance.
Landing in the second or third list does not count toward changed. Those domains gave you nothing you did not already have, or something we could not confirm. Other edits in the same composite call still count normally, so a call that also declares a domain, retracts a claim or removes one reports changed: true — it is only when nothing in the call qualifies that the response says nothing changed. Whether we looked at authorization is a separate question from whether anything changed. If a call performed a roster read, the answer comes from that read however it turned out. If it dispatched writes but never had cause to read — an add-only call where every declaration landed unconfirmed or not-yours — authorizationKnown is false and those domains appear in verdictUnknown, because an empty unauthorized there means “we did not look”, not “they are fine”. Un-declaring one domain is idempotent the same way, and the underlying route cannot tell us which happened — it answers identically whether a row was deleted or was never there. So a removal lands in one of three lists: removed when we proved it was present beforehand, alreadyAbsentDomains when we proved it was not, and removalUncertain when the pre-check could not read enough of your set to say. Only the first counts toward changed; the desired state holds in all three, but a removal is only reported as a deletion when it demonstrably was one. declareProperties records a property before the publisher’s adagents.json declares it, so it stays at property grain: it needs a propertyId, an identifier, or a name, and the roster never invents identity. A publisher-origin declaration with the same key wins and the response says so (outcome: "already_resolved") — your claim is kept on that record either way. removeProperties retracts one claim; a publisher-origin property cannot be removed this way, because it follows the publisher’s file. Domain declarations in a call are applied before the property claims under them, so you can declare a domain and a property on it in one request.

Media buys read from whichever side you are on

media_buy is the one kind both sides of the marketplace read, and the account you are in decides which projection you get. You never pass a flag to say which one you meant.
Every buy that landed on your storefront — buys forwarded through the storefront routing layer and buys managed upstream by an ad-server source alike. managedBy says which. There is no parent object to load first.Narrow it with sourceId for one inventory source, or with filter.sellerStatus, filter.buyerCustomerId, filter.flightStartFrom and filter.flightStartTo. The two flight bounds are ISO 8601 instants2026-08-01T00:00:00Z, not 2026-08-01 — and a date-only value is rejected up front naming the field, rather than failing further down. Pass the returned nextCursor back as cursor while hasMore is true.Cursors are scoped to this read and fail closed: one that was hand-written, truncated, or minted by a different search kind is refused rather than quietly restarting at page one, which a caller working through a backlog would otherwise mistake for the next page. Paging is also safe against buys deleted mid-read — a page that loses a row to a concurrent delete still advances past it instead of replaying its neighbours.The buyer-side filters — filter.phase, filter.isPaused, filter.isArchived and filter.campaignId — do not apply here, and passing one is refused rather than quietly ignored, so a filtered list is never the full one wearing a filter’s name. The refusal names the seller equivalent to use instead.sellerStatus is a seller lifecycle viewpending_approval, forwarding, forward_failed, awaiting_source, rejected, canceled, booked, delivering, paused, completed. It is derived from approval and forwarding state and is deliberately not an AdCP media-buy status; the buyer’s phase answers a different question.
A seller list never silently reports itself as complete. If a source could not be reached, partial is true and warnings names it; if displayed statuses may be out of date, statusFreshness says how stale and why. Both appear in the prose as well as the payload.

One buy, and why it did or did not go through

In a seller account, get({ kind: "media_buy", id }) returns that buy’s exchange timeline scoped to your storefront: the approval row it rode, the stages it reached, and one entry per source leg with the source’s own identifiers, the Scope3 reference to quote alongside them, and the trafficker-grade highlights of what was sent — flight, budget, packages and targeting dimensions. Every stage carries the newest failure under it, with the structured error code and a recovery class that says whether a retry can ever succeed (transient, correctable, structural). You do not have to ask for diagnostics to find out why a buy stalled. Add include: ["diagnostics"] when you want the full observation trail behind each stage. Raw request and response payloads are not returned. They are counterparty documents of unbounded size, and the answer they were being read for is already computed as the highlights above. A buy with no leg on your storefront is NOT_FOUND, not a partial answer.

Testing your storefront is being a buyer

There is no seller-side “run a test campaign” verb, and there will not be one. The v2 plan/execute pair wrapped an ordinary buyer campaign run against your own storefront with a flag set — so in v3 you switch into a buyer account under the same organisation, use a sandbox advertiser, and drive the ordinary Campaign path. Sandbox isolation of advertiser, credentials and spend is enforced server-side rather than implied by a test-only tool, and the resulting buys show up in the seller reads above like any other. That vertical is still being built; until it lands, use the v2 test-campaign endpoints.

Asking for something the account cannot give you

An ask is what you are waiting on Scope3 for. save_ask covers everything no account can resolve on its own, and type says which part of Scope3 picks it up:

support

Something is broken or blocking, or you want a person. Reaches the Scope3 team the same way a problem reported in Murph chat does, and returns a reference id to quote when you follow up. When someone explicitly asks for a person, call save_ask immediately with what they already said; optional context such as what the problem blocks or its severity must not delay the request.

product

A capability that does not exist. If someone already asked, yours joins that entry rather than creating a duplicate. Only the title is recorded, so make the title carry the request.

supply

Inventory we do not carry. Name the domain in subject and the channel in channel — without the channel the ask cannot be matched to inventory. It joins the same record a supply request filed through the REST API creates, so filing it twice does not double-count the demand.

integration

A counterparty we do not connect to. Name the vendor in subject, for example "FreeWheel".

commercial

Pricing, terms, billing, or a rate-card exception. Kept separate from support so a commercial question does not enter the on-call queue’s severity and response-time maths as something on-call cannot fix.
type is optional. It is defined by who answers the ask, which is not something you can see from outside — so omit it if you are unsure and we will route it. Never hold an ask back while you decide which type it is. subject is only for supply and integration: it names the thing the ask is about. support, product and commercial asks are about a situation, so the title carries them. Tracking an ask is not a commitment to build or carry it. save_ask works from any account and filing needs only read permission — whoever runs into a wall is usually not whoever has permission to fix it. One exception, and it is about role rather than persona: a supply ask records a buyer asking for inventory to spend on, so it has to come from an account that can buy. Filing it from a seller account — or from one that has not been set up as a buyer or a seller yet — is refused, because it would enter the demand we match sellers against as a party who will never buy. If you are a seller who is blocked, that is a support ask; if you want more demand for your own inventory, that is not an ask at all — describe what you have and we match it. Every other type files from any account.

Reading asks from either persona

Buyer and seller accounts use the same read. List asks explicitly with search({ kind: "ask" }); narrow the result with filter.askType and filter.askState (open or closed). Ask titles can contain customer-supplied text, so asks do not join an unrelated bare search — name kind: "ask" when you intend to read them. Results are capped at 25 per page; pass nextCursor back as cursor when hasMore is true. Each indexed result carries the opaque id returned by save_ask, plus our status and your separate requester answer. Read one with get({ kind: "ask", id }). The id is a Scope3 UUID: it is never a Linear id, ticket key, or owning-store key, and an id from another account returns the same NOT_FOUND response as an id that does not exist.

Saying where you stand on an ask you already filed

Filing returns an askId. Pass it back as id with a requesterState to move your own side of the ask: An update carries id, requesterState and an optional note, and nothing else. title, type and severity are fixed once the ask is filed: severity drives our response clock and the title is what a recurring ask is recognised by, so rewriting either would restart a clock or detach the record from its own history. A note replaces the one on the ask; omit it to leave the existing note as it is. This door cannot blank one. Two rules on who may do this. Updating needs write permission, because it changes a record that already exists — filing a new one still needs only read. And only the person who filed an ask, or an account admin, can withdraw it; anyone with write access can say they are still blocked. Answers are yours alone: Scope3 staff and agents can file and read asks on your behalf, but they cannot write your side of one.

Paste the failing request

The fastest support ask is the one that carries the request that broke, headers and all. So every text field on save_ask is scanned on the way in and credential-shaped values — bearer tokens, API keys, JWTs, cloud access keys — are replaced with [REDACTED] before the ask is stored, forwarded to the Scope3 team, or recorded in the audit trail of the call that filed it. Only the credential is replaced: the surrounding sentence, your timestamps, ids and error text all arrive intact, because they are what makes the ask answerable. Treat it as a safety net rather than a licence — a redactor recognises known shapes, not every secret you could invent. Rotate anything you believe was exposed.

Limits

  • Structured responses over 200KB are truncated. A backstop against pathological payloads, not a size to design against.
  • Preview scope. The storefront account is built out first; buyer and organisation accounts follow. get_status and switch_account work in every account, including one that is not enrolled — so you are never stranded somewhere you cannot leave.

What is not changing

/mcp/v2/buyer and /mcp/v2/storefront stay supported. v3 is a facade over the same services, so it owes parity with what v2 can do — it is a different way in, not a different platform underneath. No deprecation is proposed.