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 genericapi_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:
search can return the most relevant full sections:
Orientation, and knowing you are in the wrong place
MCP negotiates the tool list once, atinitialize. 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 aninclude on the object rather than a second tool name to learn:
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_servercollects credentials and OAuth grants without placing either in model-visible arguments.ad_server_sourcemanages a connection and optionally accepts its numericesaIdas focus.ad_server_diagnosticsopens sync history and refresh diagnostics with the same optional focus.source_diagnosticsaccepts the inventory source’ssourceIdfor call-level investigation.
Storefront identity is a singleton
A seller account has one storefront, so read it without an id: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.
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:
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: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":
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:
"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_sourcefollow-up applies its module side effects in the same transaction that completes it, soCOMPLETEDreally is proof the effect landed. - An approved media buy with a
forwardedAttimestamp was sent; that is not a guarantee every downstream leg accepted it. - An approved media buy with a null
forwardedAtwas never forwarded. That is reconciliation work, and the response names theapprovalspage as the next action — re-recording the approval will not send it.
What is deliberately not a save
Three neighbouring actions live on theapprovals 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 authorization verdict is a field, never an inference. Every domain
carries
authorizationStatusandauthorizationReason;nullmeans “not resolved yet”, which is not the same as authorized. Every property carries its ownauthorizationwith agrain— read Whether one property is authorized before presenting a positive as property-level proof. - An incomplete read says so.
partialRosteristruewhen the response left rows out. While it is set, finding nothing is not evidence a domain or property is absent. Compare each domain’spropertyCountagainst the properties actually returned rather than reading an array length as a total. coverageAppliesisfalsefor 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. Whenfalse, publisher authorization is not a concept for this storefront,unauthorizedis 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 staystruewhen a domain came back with no verdict yet, because that pending state is itself a fact and is reported inunresolved. It isfalseonly 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 emptyunauthorizedmeans “checked, none” only whenauthorizationKnownistrue.
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 thanremovedlists, because the ones past the limit were never compared.additionReportComplete: false—addedis 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.
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 towardchanged.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.
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.
- Seller account
- Buyer account
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 instants
— 2026-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 view —
pending_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.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 withsearch({ 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 anaskId. 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 onsave_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_statusandswitch_accountwork 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.