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:

  1. the tool registry is constructed with capabilities: ['read'], so registering a write or destructive tool throws at require time — the API does not boot;
  2. the capability set handed to the registry on every request is {read}, and there is no scope to raise and no switch to flip;
  3. registry.call() refuses any non-read capability at dispatch, before a handler runs, and tools/list is 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:

  1. a supplier agent-write audit table (the vendor_agent_writes equivalent), and registry._callWrite taught to stamp a supplier actor rather than vendorId;
  2. a kill switch — supplier_mcp_writes_enabled, in the same closed settings allowlist and the same admin group as the vendor one;
  3. a per-credential write budget (supplier_mcp_writes_per_hour), consumed at dispatch;
  4. 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_enabled field on supplier_list_settlements, and a payment has been made only if supplier_list_payments lists 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_dimensions on a product row is not reliable. Migration 086 backfilled it true across 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_health counts from the raw weight_grams / length_mm / width_mm / height_mm columns 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.
  • GET the endpoint and the same supplier block plus instructions come back as plain JSON, along with read_only: true and read_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:

methodwhat it does
initializehandshake; returns protocolVersion, capabilities, serverInfo, instructions
ping{}
tools/listevery tool this credential may call
tools/callrun one tool
notifications/initializedacknowledged 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.

toolwhat it answers
supplier_list_import_jobsyour 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_jobone job in full: progress, image-download progress, extraction progress, errorsCount, errorsFirst5, timestamps, whether the original file is still archived
supplier_get_import_error_summarystart here. The failures grouped by reason, each with a count and the specific advice for that category
supplier_list_import_errorsthe 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_downloadsfinished 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_previewwhat 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?"

toolwhat it answers
supplier_list_productsyour 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_productone 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_historythe 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_healthone 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_stalenesshow 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?"

toolwhat it answers
supplier_list_ordersorders 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_orderone 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_orderspurchase orders sent to you (never drafts), with awaiting_response — how many still need you to confirm or reject
supplier_get_purchase_orderone PO with its lines and the buyer's goods receipts against it — what was actually counted on the dock
supplier_performancelate-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?"

toolwhat it answers
supplier_list_settlementsthe 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_settlementone settlement and your order lines behind the amount
supplier_list_paymentspayments 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_returnsreturns 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?"

toolwhat it answers
supplier_search_helpsearch 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_profileyour 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_methodwhere 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.