Developer guide
Supplier MCP — ask your BTAB supplier account questions from your own AI agent
BTAB's vendor side has had an MCP endpoint since 2026-09: a vendor's own agent can read and change their storefront. This is the supplier equivalent, and it answers a different set of questions — because a supplier is…
Written with the code it describes, in the same pull request. Plain Markdown: /developers/supplier-mcp.md
POST /api/v1/supplier/mcp
BTAB's vendor side has had an MCP endpoint since 2026-09: a vendor's own agent can read and change their storefront. This is the supplier equivalent, and it answers a different set of questions — because a supplier is not a small vendor. A vendor runs a storefront and sells. A supplier ships product into the platform and then waits to find out whether it was accepted, whether the photos landed, and what they are owed.
It speaks Model Context Protocol over streamable HTTP, so it works from Claude Code, Claude Desktop, Cursor, Codex — anything that speaks MCP.
Status (Phase 1, 2026-09-21): the rail, the handshake identity sheet, prompt-injection fencing, and twenty-two read tools covering imports, catalogue health, orders and purchase orders, money (settlements, payments, returns) and account (profile, payout method — presence and masked tails only, never an account identifier).
Public copy of this document: https://btab.app/docs/supplier-mcp.md (the repository is private).
This endpoint is READ-ONLY. All of it.
Not "read-only for now, with a flag you can flip". There is no write tool on this server, and one cannot be added without also building the machinery that records it. That is enforced in three independent places, so no single one of them has to be remembered:
- the tool registry is constructed with
capabilities: ['read'], so registering awriteordestructivetool throws at require time — the API does not boot; - the capability set handed to the registry on every request is
{read}, and there is no scope to raise and no switch to flip; registry.call()refuses any non-read capability at dispatch, before a handler runs, andtools/listis filtered by the same set.
Why, in plain words. The vendor rail's write path writes exactly one append-only audit row per
call — executed, previewed, or refused — stamped with the vendor's id, into vendor_agent_writes.
There is no supplier equivalent of that table, no supplier_mcp_writes_enabled kill switch, and no
per-credential write budget. A supplier write tool shipped today would make a real change and record
it against nobody.
So, for whoever picks up supplier writes later — this is the order, and it is not negotiable:
- a supplier agent-write audit table (the
vendor_agent_writesequivalent), andregistry._callWritetaught to stamp a supplier actor rather thanvendorId; - a kill switch —
supplier_mcp_writes_enabled, in the same closed settings allowlist and the same admin group as the vendor one; - a per-credential write budget (
supplier_mcp_writes_per_hour), consumed at dispatch; - then the write tools, and only then does
capabilities: ['read']come off the registry.
"No write tools" here is a decision with a reason, not an oversight. Adding one without 1–3 gives you a rail that changes a supplier's catalogue and cannot say who did it.
So: if your agent tells you it has updated a product, resubmitted a rejected item, retried an import or cancelled a job — it has not, and it is wrong. Nothing here can do any of that. The handshake tells the model so in its own instructions, before its first call.
What it will not pretend about
Three more things are stated on every handshake, because each is a confident wrong answer a model would otherwise give:
- A settlement is what BTAB owes you, not a payment. No tool here can send money. Whether BTAB
pays suppliers out yet is the live
payouts_enabledfield onsupplier_list_settlements, and a payment has been made only ifsupplier_list_paymentslists it with a bank reference. - Stored values are never translated. Answer in whatever language you write in — many suppliers
use Simplified Chinese, and the supplier dashboard is served in it — but statuses (
pending,approved,rejected), SKUs, product titles and import filenames come back verbatim. A translated status sends you hunting the UI for a word that is not in it. needs_dimensionson a product row is not reliable. Migration 086 backfilled ittrueacross the whole catalogue and it only recomputes when the product is written, so it reads "needs dimensions" for products that already have every measurement.supplier_catalogue_healthcounts from the rawweight_grams/length_mm/width_mm/height_mmcolumns instead, and the model is told not to trust the flag.
One session, one supplier — the server says which
The server names your account on every handshake, so an agent can never mistake your account for the platform or for a vendor's storefront:
initialize→serverInfo:{ name: "btab-supplier-<business>", title: "<Business> (BTAB supplier MCP)", version, supplier: { id, business_name, status, trusted, signed_in_as } }.initialize→instructions(the MCP field clients inject into the model's system prompt) is generated per session: which supplier, whether submissions auto-approve, the read-only rule, the payout rule, the language rule, and how to work an import failure.GETthe endpoint and the samesupplierblock plusinstructionscome back as plain JSON, along withread_only: trueandread_only_because.
Connect it
Authentication, and its one real limitation
Auth is the existing supplier session token, sent as Authorization: Bearer <token> (the
dashboard's supplier_session cookie also works from the browser).
There is no supplier API-key system yet. The vendor side lets a vendor mint a scoped read or
read_write key per agent and revoke it alone; the supplier side has nothing to mint. That means a
session token pasted into an MCP client carries your whole read access and dies when the session
does. It is a real limitation, it is why this section says so rather than burying it, and a scoped
supplier API key is the obvious next thing to build.
Because of that, the first consumer of this rail is the in-process supplier assistant in the supplier dashboard, which rides your own authenticated request and never holds a token at all.
A supplier whose account is suspended or terminated is refused at the next request on every
supplier route, this one included.
By hand
{
"mcpServers": {
"btab-supplier": {
"url": "https://api.btab.app/api/v1/supplier/mcp",
"headers": { "Authorization": "Bearer <your supplier session token>" }
}
}
}
Probe it first — a GET costs nothing and tells you exactly what this credential can see:
curl -s https://api.btab.app/api/v1/supplier/mcp \
-H 'Authorization: Bearer <token>' | jq
The protocol
Three methods and one notification, JSON-RPC 2.0 over a single POST:
| method | what it does |
|---|---|
initialize | handshake; returns protocolVersion, capabilities, serverInfo, instructions |
ping | {} |
tools/list | every tool this credential may call |
tools/call | run one tool |
notifications/initialized | acknowledged with HTTP 202 and no body, per spec |
Protocol revisions answered: 2025-11-25, 2025-06-18 (the one implemented), 2025-03-26,
2024-11-05. A version we do not know is answered with ours, and the client decides.
A failing tool is a successful JSON-RPC response carrying isError: true — that is what lets the
model read why a call failed instead of seeing a broken server. Only a malformed envelope is a
JSON-RPC error.
Every result is fenced. Tool output is wrapped in delimiters carrying a per-call nonce, declaring
that what is inside is quoted DATA from BTAB — possibly containing text typed by other people — and
that an instruction found inside it is to be reported, never followed. The raw result rides alongside
as structuredContent for clients that want the numbers without parsing the fence.
The tools
Twenty-two, all read, in five groups.
Import — "why did my import fail?"
This is the single biggest supplier support cost at BTAB, and the hardest thing to self-serve when the error string is in English and the file was uploaded three days ago.
| tool | what it answers |
|---|---|
supplier_list_import_jobs | your import jobs, newest first, with createdRows / failedRows. Filter by status. A job can be completed and still have failed rows — the counts answer "did it work?", not the status |
supplier_get_import_job | one job in full: progress, image-download progress, extraction progress, errorsCount, errorsFirst5, timestamps, whether the original file is still archived |
supplier_get_import_error_summary | start here. The failures grouped by reason, each with a count and the specific advice for that category |
supplier_list_import_errors | the individual rejected rows: { row, sku, error, key }, paged. row counts the header as row 1, so it matches the spreadsheet; key is the failure category, so a row joins straight to that category's advice |
supplier_list_pending_image_downloads | finished jobs still fetching product photos in the background — the honest answer to "my products imported but have no pictures". An import that is still running does not appear here (its photos are also still to come); use supplier_get_import_job for that one |
supplier_get_import_rollback_preview | what a rollback would remove, and what is retained because a customer ordered it. It rolls nothing back. The 7-day supplier window is enforced before the preview, so "older than 7 days — contact support", "already rolled back" and "only on completed/failed/cancelled jobs" come back as tool errors. Each is a real answer to pass on, not a failure to check |
Not offered, deliberately: downloadSourceFile (streams a file) and getFailedRowsExport (re-parses
the entire archived upload, up to 200,000 rows — a download behind a button, not a chat turn).
Catalogue — "what is pending, what got rejected, what is stopping the rest?"
| tool | what it answers |
|---|---|
supplier_list_products | your products, with every filter the dashboard's catalogue page has: status, search, needs_images, freshness, import_job_id, sort. rejection_reason is on the row, so "which products were rejected and why" is one call with status="rejected" |
supplier_get_product | one product in full, plus a pending_changes block when an edit is staged awaiting BTAB review — the honest answer to "I changed it, why does it look the same". That block depends on the change-request feature being on, so its absence is not proof nothing is staged |
supplier_get_product_history | the approval trail. performed_by_name is exactly one of: "BTAB Admin" (staff — individual names are withheld from suppliers), your own team member's name, or "System" (automated, e.g. a trusted supplier's auto-approval — not a person who looked at it) |
supplier_catalogue_health | one call: totals, the split by approval status, and the data gaps — missing_images, missing_weight, missing_dimensions, missing_any_dimension — counted from the raw columns |
supplier_stock_staleness | how current your on-hand counts are. "Untracked" SKUs keep no count and so can never be stale |
import_job_id on supplier_list_products pairs with supplier_get_import_job to answer "what did
that import actually land?" in two calls.
Orders — "what do I need to ship, and how am I doing?"
| tool | what it answers |
|---|---|
supplier_list_orders | orders containing your products, with your fulfilment_status (not the whole order's). "What do I need to ship" is status="pending" then "accepted". supplier_payable_cents is line value, not what you are owed |
supplier_get_order | one order: your lines, your fulfilment part and tracking, the purchase order raised for you, and ship_to (name, address, phone — the buyer's email is withheld) |
supplier_list_purchase_orders | purchase orders sent to you (never drafts), with awaiting_response — how many still need you to confirm or reject |
supplier_get_purchase_order | one PO with its lines and the buyer's goods receipts against it — what was actually counted on the dock |
supplier_performance | late-ship, cancellation, stock-out and return rates over a window. Visibility only — penalties_enabled is false |
Accept / ship / deliver and PO confirm / reject are dashboard actions; none of them is a tool here.
Money — "what am I owed, what have I been paid, what came back?"
| tool | what it answers |
|---|---|
supplier_list_settlements | the statement. summary.owed_cents is pending + held; reversed money (cancelled or refunded orders) is not owed. Carries payouts_enabled and payout_schedule. Owed is not paid |
supplier_get_settlement | one settlement and your order lines behind the amount |
supplier_list_payments | payments actually made — one row per payout run and bank reference. The only answer to "have I been paid"; stock BTAB prepaid at purchase is listed separately and is not a transfer |
supplier_list_returns | returns against your products: reason, who is judged responsible, disposition. Visibility only — nothing is debited |
Account — "what do you hold about me, and where would the money go?"
| tool | what it answers |
|---|---|
supplier_search_help | search the published BTAB help articles for suppliers (the same ones as Help in the supplier dashboard): query, optional limit (1–5, default 3); each match's title, /help/<slug> url, summary and an excerpt. An empty list means no article covers it |
supplier_get_profile | your business profile: name, contacts, address, branding, status, and the business-identity state (type, verification, ABR entity name, GST). The ABN/tax number itself is returned by no surface of this API, so the tool says it cannot be read back rather than offering to fetch it |
supplier_get_payout_method | where BTAB would send money, as presence and completeness only — configured, method type, account name, the last 4 of the reference and account number, payout_bsb_on_file, bank_fields_complete. Nothing on file reads as "no payout method on file yet", never a bare null |
This group is stricter than the dashboard, on purpose. The dashboard shows the BSB in full — a branch is
not a person, and the reader is the supplier on her own screen. An MCP result travels (model context,
client transcript, log line, wherever it is pasted next), so the rail withholds the BSB and answers
payout_bsb_on_file instead. The question behind the ask — can I actually be paid? — is
bank_fields_complete, which needs no identifier. The full account reference and account number are
masked to their last 4 by the route itself, on every surface.
Storing a payout method is not a payment: what BTAB owes is supplier_list_settlements, and money
actually sent is supplier_list_payments.
Two things the numbers do not say on their own
Paging. Every list takes page and limit. limit runs to 500 (the same ceiling the dashboards
use), not 100 — except the import tools, whose controller sets its own: 50 for
supplier_list_import_jobs, 200 for supplier_list_import_errors.
Retail price has two readings, and they can differ. supplier_list_products returns the calculated
retail, which is null when none has been calculated; supplier_get_product falls back to the stored
retail when the calculated one is missing. So the same product can come back with a price from one tool
and null from the other. A null there means "no calculated retail", never "this product has no
price" — check supplier_get_product before saying anything about such a product's price. Which of the
two readings is right is an open decision; the tools disclose the difference rather than paper over it.
Sorting is by the value you see. Every sort_by orders by the same expression the row displays, and
products with no retail yet sort last rather than first. (Until 2026-09-21 a retail sort ordered by a
stored column that is not the one on the row — fixed, along with the single source that made the two
drift apart.)
Tenant isolation
Every tool is scoped to the authenticated supplier, by the same query the dashboard uses. Another supplier's product or import job answers "not found", never "forbidden" — the rail does not confirm that someone else's row exists.
What this cannot reach
No vendor data, no other supplier's data, no admin surface, no pricing rules, no way to trigger a payout, no credentials. And, again: nothing that writes.