Breww MCP server
by dragosh29
README.md
# Breww MCP server
An [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT and other MCP clients work with a [Breww](https://breww.com) brewery account: products and stock, production batches, trade customers, orders (which in Breww are also the invoices) and deliveries, and (when enabled) creating a sales order. It is built from Breww's public API documentation: the OpenAPI 3.0.3 document at `https://breww.com/api/schema/` ("Breww public API", version "v0.1.0 beta") and the guide at `https://breww.com/docs/breww-public-api/`.
Once it's connected, someone at the brewery can ask things like:
- "Which pubs are overdue to reorder, and who looks after them?"
- "What does the Red Lion owe us, and which invoices are past due?"
- "What's in the fermenters right now, and what's planned for next week?"
- "How much Maris Otter do we have, and where is it?"
- "What's going out on Friday's deliveries?"
- With writes enabled: "Draft an order for the Red Lion: three firkins of Session Pale and two cases of Hazy IPA."
## Tools
| Tool | What it does | API calls |
|---|---|---|
| `list_products` | Sellable products (cask, keg, smallpack, multi-pack, stock item, service) with code, type, price and the drinks they contain. Filters: name, code, type, tag (by name, assumed; see Status); obsolete products left out unless asked for. | `GET /products/` |
| `get_product` | One product: barcode, volumes, duty, the drinks and stock items it is made of, tags, custom fields. | `GET /products/{id}/` |
| `stock_levels` | Current stock of stock items (ingredients, packaging, chemicals, guest beer, merchandise), summed per item and location, optionally with the individual lots (with a location filter, the lot counts say which cover that location and which every location); and the stock of finished products as Breww reports it. Filters: stock item, location, batch code, expiry date, product name. | `GET /stock-received/?is_empty=False`, `GET /products/?include_fields=quantity_in_stock_in_format` |
| `list_batches` | Drink batches (brews) with drink, status, dates, volumes and current vessel. Filters: status, drink, batch reference, start and planned dates. | `GET /drink-batches/` |
| `get_batch` | One batch: brew type, ABV, starting and final gravity, volumes, vessel and fill, recipe version. | `GET /drink-batches/{id}/` |
| `list_customers` | Customers, leads and suppliers (one list in Breww) with last order, predicted next order, cadence, average order value and Breww's churn and overdue probabilities. Filters: name, entity type, last order date, next expected order, churn and overdue probability, sales person, tag (by name, assumed). | `GET /customers-suppliers/` |
| `get_customer` | One customer in full, with its most recent orders. | `GET /customers-suppliers/{id}/`, `GET /orders/?customer={id}&ordering=-issue_date` |
| `list_orders` | Orders/invoices with status, payment status, dates, value, total and amount due. Filters: customer, order status, payment status, source, issue and due dates, amount due, number, PO number. | `GET /orders/` |
| `get_order` | One order/invoice with its lines, adjustment lines (deposits, delivery), totals and delivery. | `GET /orders/{id}/` |
| `list_deliveries` | Deliveries, collections, courier shipments and uplifts ("fulfillments") with date, customer, order, dispatch and completion, drop window and lines. Filters: date range, type, completed, failed, run, courier, order. | `GET /fulfillments/` |
| `create_order` | Creates a sales order, as a draft by default, with Breww's automatic customer emails suppressed by default. Fetches the customer and the products first and refuses locally if the record is not a customer, order processing is blocked (or draft-only and a confirmed order was asked for), or a product is unknown, obsolete or "packaged only". Only registered when writes are enabled. | `GET /customers-suppliers/{id}/`, `GET /products/?id__in=…`, `POST /orders/` |
Every tool refuses arguments it does not know (a misspelt filter name is an error, not an unfiltered list) and text filters that are empty after trimming.
`list_products`, `list_batches`, `list_customers`, `list_orders` and `list_deliveries` also take `ordering` (the API's documented sort parameter: a field name, `-` for descending, several separated by commas), `max_results` and `page`.
There is no separate `list_invoices`: Breww's `GET /orders/` is documented as "API endpoint to manage your orders/invoices" and returns `PaginatedInvoiceList`, so `list_orders` with `order_status` `invoiced` is that list.
Not covered on purpose: the other 121 of the spec's 132 operations, including accountancy sync, customer payments and payments, credit notes, purchase orders and supplier invoices, users, CRM activities and deals, fermentation readings, vessels, containers and ingredient batches, and every `PUT`, `PATCH` and `DELETE`.
## Setup
Requires Node 18 or later.
```bash
npm install
npm run build
```
You need an API key. In Breww, an Admin goes to **Settings > Breww Apps & API**, creates a **Private** app, then creates an API key on the app's page. Keys start with `BRW.` and are sent as `Authorization: Bearer <key>`. Each key has one access level, chosen when it is created and not changeable afterwards: **Read only** (GET, HEAD and OPTIONS only) or **Full access**. A Read only key makes writes impossible whatever this server allows; use one unless you want `create_order`. Breww reviews every app, private ones included; the docs say apps "can be used prior to the review", and that a rejected app's requests are refused.
**Claude Desktop:** add this to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"breww": {
"command": "node",
"args": ["/absolute/path/to/breww-mcp/dist/index.js"],
"env": { "BREWW_API_KEY": "BRW.your-key" }
}
}
}
```
**Claude Code:**
```bash
claude mcp add breww -e BREWW_API_KEY=BRW.your-key -- node /absolute/path/to/breww-mcp/dist/index.js
```
| Variable | Required | Meaning |
|---|---|---|
| `BREWW_API_KEY` | yes | Your API key, sent as a Bearer token. A value pasted with its `Bearer ` prefix is accepted. |
| `BREWW_ALLOW_WRITES` | no | `true` to register `create_order`. Off by default. |
| `BREWW_REQUESTS_PER_MINUTE` | no | How many requests this server allows itself per minute, 1 to 600. Defaults to 60, Breww's documented limit; lower it if other integrations use the same account. |
| `BREWW_TOOL_BUDGET_S` | no | Time budget per tool call in seconds, above 0 and at most 55. Defaults to 45 (see Safety defaults). Lowered by the tests. |
| `BREWW_BASE_URL` | no | Defaults to `https://breww.com/api`. Used by the tests. Must not contain a username or password; the server refuses to start if it does. |
## Safety defaults
- Read-only unless `BREWW_ALLOW_WRITES=true`. Read tools carry the MCP `readOnlyHint` annotation; `create_order` is marked as a non-idempotent, non-destructive write. With a Read only key Breww refuses the `POST` (the docs: "A 403 (or similar) on POST, PUT, PATCH or DELETE from a read-only key is expected behaviour") and the error says a Full access key is needed.
- `create_order` creates a **draft** unless asked for `confirmed`; it never invoices. It sends `allow_automatic_emailing_to_customer: false` unless `email_customer` is true (the API's own default is true, so Breww's configured customer emails would otherwise go out).
- Personal data is only returned when a tool is called with `include_contact_details=true`: customers' primary email, phone number, billing and delivery addresses, billing name override, tax number and custom fields; the names, job titles, emails and phones of a customer's contacts (by default only their number is returned); an order's contact email, billing and delivery addresses and its invoice PDF and customer-facing links (the documents carry the addresses; they are never downloaded); a delivery's full address, phone number, coordinates and delivery-note and invoice PDF links (by default only the town); and staff names (sales person, batch creator, order creator, delivery completed by), which are returned as user IDs by default.
- In free text (customer names, comments and alert notes, order notes and private notes, order line notes, batch references, delivery instructions, stock lot notes and the names and values of product custom fields) email addresses are replaced with `[email redacted]`, phone-number-like sequences with `[phone redacted]` and UK postcodes with `[postcode redacted]` by default; the raw text comes with `include_contact_details`. Nothing else is taken out of free text: a person's name or a street address typed into a comment or delivery instruction ("Call Sam", "12 Mill Lane") is returned as written. The phone match is a heuristic (the same as the Signable server's): international numbers written with `+` or `00` (including `+44 (0)7700 …`), UK numbers with a bracketed area code such as `(0117) 496 0000`, and UK-style `0…` numbers of 9 to 11 digits with spaces, dots or hyphens between groups. The postcode match is a heuristic too, on the shape of a UK postcode in either case (`BS3 4QT`, `bs34qt`); a short code of the same shape, such as `FV1 2HL`, is redacted as well. Product custom fields are always redacted (the product tools have no switch). Breww's error messages are redacted the same way before they are passed on, and the API key is replaced with `[redacted]` in any error text that echoes it.
- Never returned, even on request: payment records (`payments_refunds`), payment integration data (`payment_integration_details`) and whether a customer pays by direct debit. No bank or card endpoint is called.
- IDs must be positive whole numbers (every ID used here is an integer key in the spec) and are checked before any call. Date filters must be real calendar dates (2026-02-30 is refused). Filters typed `date-time` accept a date alone, which is sent as the start of that day in UTC (`2026-09-01` becomes `2026-09-01T00:00:00Z`). `ordering` must be field names separated by commas.
- Every tool call has one time budget, 45 seconds by default (`BREWW_TOOL_BUDGET_S`), shared by all its requests and waits, because the MCP SDK's default request timeout is 60 seconds and one call can make several requests (`create_order` makes three, a list up to ten pages). A retry wait or a wait for the rate window that would end past the budget is not started, a request still unanswered at the budget is abandoned, and the call ends with an error that says so, before the MCP client gives up. `POST /orders/` is only sent if at least 15 seconds of the budget are left (a third of the budget, if that is shorter), so an order is not sent that the server might have to abandon; if it is abandoned in flight anyway, the error says the order may exist and to check `list_orders` first. A list that runs out of time after its first page returns the pages it has, with a note and the `next_page` to continue from. A call cancelled by the MCP client ends its waits at once, aborts a request in flight and sends nothing more, so a `POST /orders/` that has not been sent by then is not sent (one already sent may still be processed by Breww).
- Rate limits, as Breww documents them: 60 requests per minute and 5,000 per day; above them the API answers `429` with the message `Request was throttled.` and possibly the wait "in both the message and in a `Retry-After` HTTP header". This server keeps itself under the minute limit with a sliding one-minute window (`BREWW_REQUESTS_PER_MINUTE`, default 60); if the window is full and the next slot is more than 15 seconds away, the call fails at once with the wait in the message rather than hanging. The daily limit is not tracked. A 429 is retried at most twice for any method, including `POST /orders/`, on the assumption that a throttled request was not processed (see Status), waiting for `Retry-After` (whole or fractional seconds, or an HTTP-date; 2 s then 4 s when the header is absent or unreadable). Each wait is capped at 10 seconds; if Breww asks for a longer wait the call gives up at once and says how long to wait.
- 502, 503 and 504 are retried the same way for `GET` only; after three failures the error says the service may be unavailable, without the gateway's HTML. A `POST /orders/` is never retried after a gateway error, because the order may already exist; the error says to check `list_orders` first.
- A 200 whose body is not a JSON object (a proxy or a login page, or JSON `null`, a string or a list) is an error that names `BREWW_BASE_URL` and describes the body by type and size, or by JSON kind, without quoting it. A rejected key produces a message that says where keys are created.
## Tests
```bash
npm test
```
The test suite:
1. Validates every fixture record against the component schemas in Breww's published OpenAPI spec (`Product`, `Location`, `StockReceived`, `DrinkBatch`, `Customer` for 230 records, `Invoice` with `SaleBasic` lines, `Fulfillment`) with Ajv's draft-04 dialect (OpenAPI 3.0 uses draft-04's boolean `exclusiveMinimum`/`exclusiveMaximum`) and `ajv-formats`, plus a key-by-key walk that fails on any field the spec does not declare. The spec is downloaded from `breww.com/api/schema/` to `spec.yaml` on the first run. The spec needs translating before Ajv can read it; the translations, in `test/schema.mjs`, are: (1) `nullable: true` becomes "or null"; (2) `type: "decimal"` (not a JSON Schema type; 27 properties such as `StockReceived.current_quantity`) becomes "a decimal written as a string, or a number"; (3) `type: "array", format: "binary"` with an object `example` (34 properties such as `Product.liquid_volume_gross`, example `{"litre": 100, "us_gallon": 26.4…}`) is validated against the shape of its example, since the spec's type contradicts its own example; (4) `format: "decimal"` on a string becomes a numeric pattern; (5) for request bodies, `readOnly` properties are dropped from `properties` and `required`, as OpenAPI 3.0 prescribes; (6) the `time` format accepts a time without a zone (`09:00:00`). Negative controls check that the translated schemas still reject a string ID, a wrong example shape, a null in a non-nullable field and a non-numeric decimal, and that the key walk reports undeclared keys at any depth.
2. Starts a local mock of the API under `/api` that serves those fixtures with the documented paging (`page`, `page_size` defaulting to 50 and capped at 200, `count`/`next`/`previous`/`results`, `next` null on the last page), Bearer-key auth (401 for a missing or wrong key; a Read only key may `GET` but gets 403 on `POST`; the spec's second scheme, `tokenAuth` with a `Token` prefix, is not modelled, because whether a `BRW.` key works under it is undocumented and the server always sends `Bearer`), 404 for unknown IDs, the `run` and `courier` delivery filters matching nothing (the `Fulfillment` schema has neither field, so no fixture delivery can carry one), the conditionally included `quantity_in_stock_in_format` only when `include_fields` asks for it, and a one-off 429 with `Retry-After: 1` on `GET /drink-batches/`. The mock's list, detail and created-order (201) responses are validated against the response schemas the spec names for each operation. Breww publishes no error schema, so the 429 body is checked against a schema written from the documented message (`Request was throttled.`; the `detail` key holding it is an assumption) and the 400 body against a schema written from the release note of 2026-08-15, whose example is read from the spec and must pass verbatim; the mock's 400 for an unknown product is exactly that example. The 401, 403 and 404 bodies (`{"detail": …}`) are placeholders with no documented shape and are not validated.
3. Starts the built server and drives it over stdio with the official MCP client: 35 checks, plus 7 on the built modules directly (listed after this step). They cover tools/list and annotations; every read tool; paging across pages 1 and 2 of 200 to the documented end (`next` null), whole-page continuation with `next_page`, a page past the end reported as such, and `page_size` following `max_results`; every filter each tool offers passed through exactly as the spec names it (`status__in`, `beer`, `batch_ref__contains`, `datetime_started__gte`/`__lt`, `planned_start_date__gte`/`__lte`, `name__contains`, `code`, `type`, `tags`, `obsolete`, `is_empty=False`, `stock_item`, `batch_code__contains`, `expiry_date__lte`, `include_fields`, `entity_type__bitand`, `last_order_date__lt`/`__gte`, `next_expected_order__lte`, `prob_churned__gte`, `prob_overdue__gte`, `sales_person`, `customer`, `order_status__in`, `payment_status__in`, `source__in`, `issue_date__gte`/`__lte`, `due_date__lt`, `amount_due__gt`, `number`, `po_number__contains`, `date_scheduled__gte`/`__lte`, `type__in`, `completed`, `failed`, `run`, `courier`, `invoice`, `ordering`), with `__in` values comma-separated as the spec's `explode: false` says and date-only values completed to date-times; stock summed per item and location with the location filter applied locally; redaction of emails, phone numbers, addresses, contacts, tax number, custom fields (names and values) and staff names by default and their return on request, with the national, `+44 (0)`, bracketed and `00`-prefixed phone forms and postcodes redacted in free text, and the street of a delivery instruction shown to be returned as written; payment records never returned, even on request; the write gate with the variable unset and set to `false`; the `POST /orders/` body validated against `InvoiceCreate` and each line against `SaleInlineCreate` (readOnly fields left out), drafts and suppressed customer emails by default; the local refusals (blocked, draft-only and non-customer records, obsolete, packaged-only and unknown products, a discount without its type) with no `POST` made; a 400 in the documented index-keyed shape flattened into the message, and an error text that echoes the key, an email and a phone number passed on with all three redacted; the 403 from a Read only key on `POST` and the different 403 message on a `GET`; a 400 body with twelve messages cut to ten; bad IDs, impossible dates, malformed `ordering`, text filters that are only spaces or empty, and unknown arguments rejected before any call; the 404 message, and a 404 on page 1 of a list reported as not found rather than as a page past the end; an empty page ending a list walk even when `next` is set; the 429 retry waiting for `Retry-After` in the seconds, fractional-seconds and HTTP-date forms, and 2 s then 4 s when the header is missing or unreadable, giving up after three attempts on a persistent 429 and at once on a `Retry-After` above the cap, a 429 on `POST /orders/` retried once; a 502 retried for `GET` and never for `POST /orders/`; a 504 then two 503s on a `GET` reported with advice; a 200 whose body is HTML, JSON `null`, a string or a list reported without quoting it; a `create_order` cancelled by the MCP client during a retry wait, with no further request and no `POST`; with a server whose budget is lowered to 3 seconds, `create_order` with 429s armed on all three of its requests ending in an error inside the budget with no `POST`, the `POST` not started when less than the write reserve is left, a `POST` still unanswered at the deadline abandoned with the "may already have been created" warning, and a list stopped for time after page 1 returning that page with `next_page`; the 401 message without the key; a key pasted with `Bearer ` sent once; and that every request used `Authorization: Bearer <key>`, a documented method and path, and only query parameters the spec documents for that operation (`include_fields` excepted, which the guide documents in prose for `/products/`).
The 7 direct checks: the client-side rate window on the built client with a 1-second window (three requests go at once, the fourth waits for the window), with a one-minute window (the second request past a limit of one is refused at once, without a request), and with six concurrent requests against a limit of two per second (no one-second span carries more than two); the retry delay (a `Retry-After` of exactly 10 seconds is honoured, 10.5 gives up, 2 s then 4 s without a readable header, an HTTP-date) and that the default time budget is at most 55 seconds; a cancellation ending a 5-second retry wait at once, with no further request, and aborting a request in flight; `redactDeep` cutting lists nested deeper than 20 levels; the delivery type labels compared with the ones the spec documents; and start-up refused (a separate process) without a key, with an out-of-range `BREWW_REQUESTS_PER_MINUTE` or `BREWW_TOOL_BUDGET_S`, or with credentials in `BREWW_BASE_URL`. The MCP clients in step 3 that make most of the calls run with `BREWW_REQUESTS_PER_MINUTE=600`, because the suite makes more than 60 requests through one server process; the clients for the write gate, the Read only key and the wrong key run with the default of 60.
## Status
This is a working prototype. It has **not yet been run against the live API**, because it was built without a Breww account. Everything below is taken from the published spec and guide and should be confirmed on a real account (Breww offers a free trial; whether a trial account can create a Private app and key is unconfirmed):
- Authentication end to end with a real `BRW.` key, and the bodies of 401, 403 and 404 responses, which are not documented (the mock uses `{"detail": …}`; the server passes on a `detail` string, or else the string values of a JSON error body flattened as for the documented 400 shape, at most ten, redacted; a non-JSON error body is never quoted). That a Read only key's `POST` is answered with 403 (the guide says "403 (or similar)").
- The 429: the key that holds `Request was throttled.`, the form of `Retry-After`, and whether a throttled `POST /orders/` is never processed (the server assumes so and retries it at most twice). Whether the 60-per-minute limit is per key or per account: this server counts only its own requests, per process.
- Paging: what the API does with `page_size` above 200 (the mock caps it at 200) and with a page past the end (the mock answers 404 "Invalid page.", a guess; the server stops at `next` null on its own, and a 404 on a later page asked for by the caller is reported as a page that does not exist). Which fields `ordering` accepts on each endpoint; the documented examples are `number`, `issue_date` and `value` on orders.
- `quantity_in_stock_in_format`: the guide names it as a conditionally included field on `/products/`, but the spec does not define it, so its shape is unknown. The mock returns a plain number; the server passes whatever comes back through untouched.
- Value formats the spec leaves unclear: whether `type: "decimal"` values (stock quantities and prices) come as strings or numbers (the fixtures use strings such as `"125.500"`, which the server converts to numbers); whether the `array`/`binary` fields (volumes, weights, gravities, ABV, current vessel, delivery address) really are objects shaped like their examples (the server reads `litre`, `kg`, `value_decimal`, `reading_unit`, `name`, `city` and so on from them); whether `time` values carry a zone.
- Fields the spec marks as required and not nullable that a live account may leave empty: `DrinkBatch.third_party_brewery` (so every fixture batch has a partner brewery), `DrinkBatch.current_vessel_info`, and `Fulfillment.completed_by`/`completed_date` on deliveries not yet completed (the server shows them only when `completed` is true). Also `Customer.entity_type`, whose enum lists only single flags (1, 2, 4, 8, 16, 32) although its description says flags combine (a customer and supplier is 3); the fixtures follow the enum, and the server decodes the value as a bit field.
- Filter semantics: whether `tags` (on products and customers) takes a tag's name, as the server assumes and the mock implements, or a slug or an ID, and whether it matches exactly (the spec gives the parameter no description); what `run` and `courier` on deliveries match (the `Fulfillment` schema returns neither field, so the fixtures cannot show it); whether `name__contains` and the other `__contains` filters are case-sensitive; whether `entity_type__bitand=1` matches every record with that flag set (assumed); whether booleans are read as `true`/`false` (sent for `obsolete`, `completed`, `failed`) and `False` (sent for `is_empty`, as that endpoint's description writes it); whether a date-time filter reads the `Z` of `2026-09-01T00:00:00Z` as UTC.
- `GET /stock-received/` covers stock items (ingredients, packaging, chemicals, guest beer, merchandise). It has no location parameter, so the location filter is applied after fetching; on a large account `max_results` may need raising.
- `create_order`: that `POST /orders/` accepts the body as sent (`customer`, `order_status` 1 or 2, `allow_automatic_emailing_to_customer`, optional dates, PO number and notes, and lines with `product`, `quantity` and optionally `product_original_unit_value`, `discount`, `discount_type`, `product_name`); what number Breww gives a draft; which fields the 201 response carries (the mock follows `InvoiceCreate`); whether `GET /products/?id__in=…` returns obsolete products (the local check assumes it does, and otherwise reports an obsolete product as not found); and that `block_order_processing` 2 and 3 mean what their labels say.
- The order detail path: the spec types `/orders/{id}/`'s parameter as a string matching `^[^/]+$` with no description; the server sends the integer `Invoice.id`.
- Soft-deleted records are never asked for: the guide describes `include_soft_deleted=true`, but none of the operations used here lists it as a parameter.
- The spec is a beta ("There may be breaking changes made to the schema/structure at any time"), with a breaking change to validation errors as recent as 2026-08-15.
## Going to production
This version runs locally over stdio with the brewery's own API key. For breweries to connect from claude.ai or ChatGPT without handling keys, the next step is a remote server (Streamable HTTP) behind OAuth, hosted by Breww. Breww already runs an OAuth2 Authorization Code server with PKCE, `read` and `write` scopes, one-hour access tokens and rotating 30-day refresh tokens for public apps, so the remote server can use each brewery's own grant. Then a listing in the Claude and ChatGPT connector directories.
## Licence
MIT. Built by Alexandru Dragoș (alexandru.dragos96@gmail.com) with an AI agent (Claude) working under his direction.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues