# Supplier MCP — ask your BTAB supplier account questions from your own AI agent

`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](https://modelcontextprotocol.io) 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

```jsonc
{
  "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:

```bash
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.
