beeswax-mcp 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,9 +6,13 @@ for creative businesses. It lets Claude and other MCP clients query your invoice
6
6
  quotes, expenses, payments, journals, time entries, milestones, products & services
7
7
  and tax returns.
8
8
 
9
- It is **read-mostly**: writes are limited to manual journals and tax-return fields,
10
- and each write is gated behind its own token scope — everything else is strictly
11
- read-only.
9
+ It is **read-mostly**: writes are limited to quotes, manual journals, tax-return
10
+ fields and the products & services catalogue, and each write is gated behind its
11
+ own token scope — everything else is strictly read-only.
12
+
13
+ **Nothing here sends anything to your clients.** The quote tools create and edit
14
+ drafts; emailing a quote, and marking one accepted, stay human actions in the
15
+ Beeswax web UI, and the API exposes no route for either.
12
16
 
13
17
  ## Quick start
14
18
 
@@ -23,7 +27,7 @@ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
23
27
  "mcpServers": {
24
28
  "beeswax": {
25
29
  "command": "npx",
26
- "args": ["-y", "beeswax-mcp"],
30
+ "args": ["-y", "beeswax-mcp@latest"],
27
31
  "env": {
28
32
  "BEESWAX_API_TOKEN": "your-token"
29
33
  }
@@ -35,13 +39,14 @@ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
35
39
  ### Claude Code
36
40
 
37
41
  ```bash
38
- claude mcp add beeswax --env BEESWAX_API_TOKEN=your-token -- npx -y beeswax-mcp
42
+ claude mcp add beeswax --env BEESWAX_API_TOKEN=your-token -- npx -y beeswax-mcp@latest
39
43
  ```
40
44
 
41
45
  ### Any other MCP client
42
46
 
43
- Run `npx -y beeswax-mcp` as a stdio server with `BEESWAX_API_TOKEN` set in the
44
- environment.
47
+ Run `npx -y beeswax-mcp@latest` as a stdio server with `BEESWAX_API_TOKEN` set in
48
+ the environment. (`@latest` matters: without it, npx caches the first download
49
+ forever and you silently fall behind releases.)
45
50
 
46
51
  ## Getting an API token
47
52
 
@@ -50,11 +55,17 @@ Super Admin only; requires the accounting add-on). The token is bound to one
50
55
  account.
51
56
 
52
57
  **Grant only the scopes you need.** Tick individual read scopes
53
- (`invoices:read`, `payments:read`, `transactions:read`, `journal_entries:read`,
54
- `tax_returns:read`, `events:read`, `milestones:read`, `projects:read`,
55
- `time_entries:read`, `transaction_templates:read`, …) rather than `all` — `all` grants read **and** write. Add
56
- `journal_entries:write` / `tax_returns:write` only if the agent should be able to
57
- use the write tools.
58
+ (`invoices:read`, `quotes:read`, `payments:read`, `transactions:read`,
59
+ `journal_entries:read`, `tax_returns:read`, `events:read`, `milestones:read`,
60
+ `projects:read`, `time_entries:read`, `transaction_templates:read`,
61
+ `transaction_accounts:read`, …) rather than `all` `all` grants read **and**
62
+ write. Add `quotes:write`, `journal_entries:write`, `tax_returns:write` or
63
+ `transaction_templates:write` only if the agent should be able to use the
64
+ matching write tools.
65
+
66
+ To build quotes you need `quotes:write` plus the three lookups a quote is
67
+ assembled from: `companies:read` (the client), `projects:read` (the project) and
68
+ `transaction_accounts:read` (the chart of accounts each line bills to).
58
69
 
59
70
  **Keep the token secret.** It sits in plain text in your client's config file, and
60
71
  anyone who can read that file can read your account data. Revoke tokens you no
@@ -67,21 +78,36 @@ longer use from the same settings page.
67
78
  | `BEESWAX_API_TOKEN` | yes | — |
68
79
  | `BEESWAX_BASE_URL` | no | `https://app.beeswaxapp.com/new_api/v1` |
69
80
 
81
+ ### Versions & updates
82
+
83
+ `npx -y beeswax-mcp@latest --version` prints the current release. The server
84
+ identifies itself to the API (`User-Agent: beeswax-mcp/<version>`), and every API
85
+ response advertises the newest release; when this copy is behind, the assistant is
86
+ asked — once per session — to tell the user, with a link to the update guide at
87
+ <https://www.beeswaxapp.com/support/mcp-setup#updating>. `check_beeswax_connection`
88
+ reports the same on demand.
89
+
70
90
  ## Tools
71
91
 
72
92
  | Tool | Endpoint |
73
93
  |---|---|
94
+ | `check_beeswax_connection` | `/meta` — connection health check: connector vs latest version, account, user, token scopes (no scope required) |
74
95
  | `list_invoices`, `get_invoice`, `list_invoice_transactions` | `/invoices` |
75
96
  | `list_quotes`, `get_quote`, `list_quote_transactions` | `/quotes` |
97
+ | `create_quote` (write), `update_quote` (write), `finalise_quote` (write), `delete_quote` (write) | `/quotes` |
98
+ | `list_quote_groups`, `add_quote_group` (write), `update_quote_group` (write), `delete_quote_group` (write), `reorder_quote_groups` (write) | `/quotes/:id/transaction_groups` |
99
+ | `add_quote_line` (write), `update_quote_line` (write), `delete_quote_line` (write), `reorder_quote_lines` (write) | `/quotes/:id/transaction_groups/:group_id/transactions` |
76
100
  | `list_expenses`, `get_expense`, `list_expense_transactions` | `/expenses` |
77
101
  | `query_payments`, `get_payment`, `list_payment_transactions` | `/payments` |
78
102
  | `query_journal_entries`, `get_journal_entry`, `list_journal_entry_transactions` | `/journal_entries` |
79
103
  | `list_time_entries`, `get_time_entry` | `/time_entries` |
80
104
  | `list_events`, `get_event` | `/events` (visibility-filtered) |
81
105
  | `list_milestones`, `get_milestone` | `/milestones` |
82
- | `get_project` | `/active_account/projects/:id` |
106
+ | `list_projects`, `get_project` | `/active_account/projects` |
107
+ | `list_transaction_accounts` | `/active_account/transaction_accounts` |
108
+ | `list_companies` | `/active_account/companies` |
83
109
  | `list_task_files`, `list_task_file_versions` | `/tasks/:id/files` (+ per-file version history) |
84
- | `list_products_services`, `get_product_service` | `/transaction_templates` |
110
+ | `list_products_services`, `get_product_service`, `create_product_service` (write), `update_product_service` (write), `delete_product_service` (write) | `/transaction_templates` |
85
111
  | `list_manual_journals`, `get_manual_journal`, `create_manual_journal` (write), `finalise_manual_journal` (write) | `/raw_journal_entries` |
86
112
  | `list_tax_returns`, `get_tax_return`, `get_tax_return_field_transactions`, `list_tax_return_findings`, `update_tax_return_field` (write), `confirm_tax_return_section` (write) | `/tax_returns` |
87
113
 
@@ -99,9 +125,40 @@ longer use from the same settings page.
99
125
  - **Dates.** `from` / `to` accept ISO (`2026-01-01`) or loose strings
100
126
  (`Jan 1 2026`); they are normalized to `YYYY-MM-DD` before being sent.
101
127
  - **Products & services.** Beeswax stores the catalogue as *transaction
102
- templates*; the tools filter with `kind: "product"` / `"service"` and need the
103
- `transaction_templates:read` scope. Internal system templates are excluded
104
- unless `system: "true"`.
128
+ templates*. `kind` is the **buy/sell mode**, not a product-vs-service label:
129
+ `"sell"` offers the item on invoices and quotes, `"buy"` on expenses,
130
+ `"buy_and_sell"` on both, and that is what decides which line-item picker
131
+ shows it. (`"product"` / `"service"` are a legacy classification carried by a
132
+ handful of old rows and honoured by no picker — filter with `side: "sell"` /
133
+ `"buy"` for "what can I put on this document?", and each item reports its
134
+ `sides` on read.) Writes require the account explicitly: the income account an
135
+ item is sold to, plus the expense account it is bought to for `"buy"` and
136
+ `"buy_and_sell"`. Nothing is defaulted, and the sell/buy accounts are
137
+ type-checked, so an item cannot land on a wrong-side or another tenant's
138
+ account. `update_product_service` changes only the fields you pass — use
139
+ `active: false` to archive rather than deleting. System templates are excluded
140
+ from listings unless `system: "true"` and are never writable, and publishing an
141
+ item to the account's public rates card stays a human action in the web UI.
142
+ Reads need `transaction_templates:read`, writes `transaction_templates:write`.
143
+ - **Pricing a quote line from a product.** Give a line a
144
+ `transaction_template_id` and it inherits the product's rate, income account,
145
+ tax, wording and unit for everything you did not set yourself — an explicit
146
+ value always wins — and takes the product's `gantt_color` on the quote's Gantt
147
+ preview, which is what gives a long quote one colour per discipline instead of
148
+ a rotating default. On `update_quote_line` the link is all that changes: the
149
+ line keeps its numbers, so an already-priced quote can be re-pointed at the
150
+ catalogue without moving a total. Pass `null` to detach.
151
+ - **Quotes.** `create_quote` takes the whole document in one call — header,
152
+ sections and every line — because a real quote is several phases and dozens of
153
+ lines. Sections (`groups`) are the phases and keep the order you give them, as
154
+ do the lines inside them. A line gives `quantity` and `unit_amount` (the
155
+ per-unit price); Beeswax computes the line total and works out the debit/credit
156
+ side from the account's classification, so you never pass debit or credit.
157
+ Quotes land as **drafts** unless you pass `status: "finalised"` — and neither
158
+ state sends anything to the client. Ids must belong to your account: resolve
159
+ them first with `list_companies`, `list_projects` and
160
+ `list_transaction_accounts`. Needs `quotes:write`; a quote marked accepted in
161
+ the UI is locked and refuses further edits.
105
162
  - **Manual journals.** Balanced double-entry enforced by the API (≥ 2 lines, one of
106
163
  debit/credit per line, debits = credits). Created finalised by default; pass
107
164
  `status: "draft"` and use `finalise_manual_journal` for a review step. Needs
package/dist/client.js CHANGED
@@ -4,13 +4,28 @@
4
4
  // - Bearer-token auth (from BEESWAX_API_TOKEN)
5
5
  // - collapsing pagination into "fetch every page"
6
6
  // - turning non-2xx responses into clear Error messages
7
+ import { VERSION, isNewerVersion } from "./version.js";
8
+ const USER_AGENT = `beeswax-mcp/${VERSION}`;
9
+ const LATEST_VERSION_HEADER = "x-beeswax-mcp-latest-version";
7
10
  export class BeeswaxClient {
8
11
  token;
9
12
  baseUrl;
13
+ // Set once the API advertises a newer connector release than the one running.
14
+ updateAvailable = null;
10
15
  constructor(baseUrl, token) {
11
16
  this.token = token;
12
17
  this.baseUrl = baseUrl.replace(/\/+$/, "");
13
18
  }
19
+ get origin() {
20
+ return this.baseUrl;
21
+ }
22
+ // Every response carries X-Beeswax-MCP-Latest-Version — remember when we're behind.
23
+ noteLatestVersion(res) {
24
+ const latest = res.headers.get(LATEST_VERSION_HEADER);
25
+ if (latest && isNewerVersion(latest, VERSION)) {
26
+ this.updateAvailable = { running: VERSION, latest };
27
+ }
28
+ }
14
29
  async request(path, params = {}) {
15
30
  const url = new URL(this.baseUrl + path);
16
31
  for (const [key, value] of Object.entries(params)) {
@@ -21,12 +36,17 @@ export class BeeswaxClient {
21
36
  let res;
22
37
  try {
23
38
  res = await fetch(url, {
24
- headers: { Authorization: `Bearer ${this.token}`, Accept: "application/json" },
39
+ headers: {
40
+ Authorization: `Bearer ${this.token}`,
41
+ Accept: "application/json",
42
+ "User-Agent": USER_AGENT,
43
+ },
25
44
  });
26
45
  }
27
46
  catch (err) {
28
47
  throw new Error(`Could not reach Beeswax at ${url.origin}: ${err.message}`);
29
48
  }
49
+ this.noteLatestVersion(res);
30
50
  const text = await res.text();
31
51
  let body;
32
52
  try {
@@ -79,6 +99,7 @@ export class BeeswaxClient {
79
99
  Authorization: `Bearer ${this.token}`,
80
100
  Accept: "application/json",
81
101
  "Content-Type": "application/json",
102
+ "User-Agent": USER_AGENT,
82
103
  },
83
104
  body: JSON.stringify(body),
84
105
  });
@@ -86,6 +107,7 @@ export class BeeswaxClient {
86
107
  catch (err) {
87
108
  throw new Error(`Could not reach Beeswax at ${url.origin}: ${err.message}`);
88
109
  }
110
+ this.noteLatestVersion(res);
89
111
  const text = await res.text();
90
112
  let parsed;
91
113
  try {
package/dist/index.js CHANGED
@@ -4,6 +4,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { BeeswaxClient } from "./client.js";
6
6
  import { TOOLS } from "./tools.js";
7
+ import { VERSION } from "./version.js";
8
+ // `npx -y beeswax-mcp@latest --version` must work with no token set.
9
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
10
+ console.log(`beeswax-mcp ${VERSION}`);
11
+ process.exit(0);
12
+ }
7
13
  const token = process.env.BEESWAX_API_TOKEN;
8
14
  const baseUrl = process.env.BEESWAX_BASE_URL ?? "https://app.beeswaxapp.com/new_api/v1";
9
15
  if (!token) {
@@ -14,10 +20,13 @@ if (!token) {
14
20
  }
15
21
  const client = new BeeswaxClient(baseUrl, token);
16
22
  const toolsByName = new Map(TOOLS.map((tool) => [tool.name, tool]));
17
- const server = new Server({ name: "beeswax-mcp", version: "0.2.1" }, { capabilities: { tools: {} } });
23
+ const server = new Server({ name: "beeswax-mcp", version: VERSION }, { capabilities: { tools: {} } });
18
24
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
19
25
  tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
20
26
  }));
27
+ // Mention an available update at most once per session (check_beeswax_connection
28
+ // always reports it regardless — this is just the unprompted nudge).
29
+ let updateNoticeShown = false;
21
30
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
22
31
  const tool = toolsByName.get(request.params.name);
23
32
  if (!tool) {
@@ -25,7 +34,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
25
34
  }
26
35
  try {
27
36
  const result = await tool.handler(client, request.params.arguments ?? {});
28
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
37
+ let text = JSON.stringify(result, null, 2);
38
+ const update = client.updateAvailable;
39
+ if (update && !updateNoticeShown && request.params.name !== "check_beeswax_connection") {
40
+ updateNoticeShown = true;
41
+ text +=
42
+ `\n\n[beeswax-mcp] Update available: this connector is v${update.running}, ` +
43
+ `v${update.latest} is out and may have tools or fields this version lacks. ` +
44
+ `Tell the user, and point them to ` +
45
+ `https://www.beeswaxapp.com/support/mcp-setup#updating for the one-minute update steps.`;
46
+ }
47
+ return { content: [{ type: "text", text }] };
29
48
  }
30
49
  catch (err) {
31
50
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
@@ -34,7 +53,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
34
53
  async function main() {
35
54
  await server.connect(new StdioServerTransport());
36
55
  // stderr only — stdout is the MCP protocol channel.
37
- console.error(`beeswax-mcp ready → ${baseUrl}; ${TOOLS.length} tools (read + tax-return write)`);
56
+ console.error(`beeswax-mcp v${VERSION} ready → ${baseUrl}; ${TOOLS.length} tools`);
38
57
  }
39
58
  main().catch((err) => {
40
59
  console.error("Fatal:", err);
package/dist/tools.js CHANGED
@@ -1,4 +1,29 @@
1
1
  import { normalizeDate } from "./client.js";
2
+ import { VERSION, isNewerVersion } from "./version.js";
3
+ // The writable half of a catalogue item. `id` is the route, not a body field,
4
+ // and anything undefined is left out so a PATCH only changes what was asked for.
5
+ function productAttributes(args) {
6
+ const attributes = {
7
+ title: args.title,
8
+ description: args.description,
9
+ kind: args.kind,
10
+ unit: args.unit,
11
+ item_category: args.item_category,
12
+ sell_amount: args.sell_amount,
13
+ sell_transaction_account_id: args.sell_transaction_account_id,
14
+ sell_tax_id: args.sell_tax_id,
15
+ buy_amount: args.buy_amount,
16
+ buy_transaction_account_id: args.buy_transaction_account_id,
17
+ buy_tax_id: args.buy_tax_id,
18
+ gantt_color: args.gantt_color,
19
+ active: args.active,
20
+ };
21
+ for (const key of Object.keys(attributes)) {
22
+ if (attributes[key] === undefined)
23
+ delete attributes[key];
24
+ }
25
+ return attributes;
26
+ }
2
27
  const SYSTEM_PROP = {
3
28
  system: {
4
29
  type: "string",
@@ -68,6 +93,31 @@ function accountingTools(resource, singular, label, listName, opts = {}) {
68
93
  ];
69
94
  }
70
95
  export const TOOLS = [
96
+ {
97
+ name: "check_beeswax_connection",
98
+ description: "Health-check the Beeswax connection: reports the connector version you are running, the latest released version " +
99
+ "(and whether an update is available, with the update guide URL), plus which Beeswax account and user the API token " +
100
+ "belongs to and the scopes it grants. Use this whenever the user asks whether their Beeswax connection is working, " +
101
+ "up to date, what version they are on, which account is connected, or what this integration is allowed to do. " +
102
+ "Needs no scopes — any valid token works.",
103
+ inputSchema: { type: "object", properties: {} },
104
+ handler: async (client) => {
105
+ const meta = await client.getOne("/meta", "");
106
+ const latest = meta?.latest_mcp_version ?? null;
107
+ const updateAvailable = latest ? isNewerVersion(latest, VERSION) : false;
108
+ return {
109
+ connector_version: VERSION,
110
+ latest_version: latest,
111
+ up_to_date: latest ? !updateAvailable : null,
112
+ update_guide: updateAvailable ? "https://www.beeswaxapp.com/support/mcp-setup#updating" : undefined,
113
+ account: meta?.account,
114
+ user: meta?.user,
115
+ token_scopes: meta?.scopes,
116
+ server_time: meta?.server_time,
117
+ api_base_url: client.origin,
118
+ };
119
+ },
120
+ },
71
121
  ...accountingTools("invoices", "invoice", "invoices", "list_invoices", { payable: true }),
72
122
  ...accountingTools("quotes", "quote", "quotes", "list_quotes"),
73
123
  ...accountingTools("expenses", "expense", "expense documents", "list_expenses", { payable: true }),
@@ -154,9 +204,15 @@ export const TOOLS = [
154
204
  inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] },
155
205
  handler: (client, args) => client.getOne(`/milestones/${args.id}`, "milestone"),
156
206
  },
207
+ {
208
+ name: "list_projects",
209
+ description: "List the account's active projects. Use this to resolve a project name to the project_id that create_quote and the other document tools need.",
210
+ inputSchema: { type: "object", properties: {} },
211
+ handler: (client) => client.getList("/active_account/projects", "projects"),
212
+ },
157
213
  {
158
214
  name: "get_project",
159
- description: "Get a single project by id.",
215
+ description: "Get a single project by id. Requires a token with projects:read.",
160
216
  inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] },
161
217
  handler: (client, args) => client.getOne(`/active_account/projects/${args.id}`, "project"),
162
218
  },
@@ -186,8 +242,13 @@ export const TOOLS = [
186
242
  properties: {
187
243
  kind: {
188
244
  type: "string",
189
- enum: ["product", "service"],
190
- description: "Only products, or only services/activities. Omit for both.",
245
+ enum: ["sell", "buy", "buy_and_sell", "product", "service"],
246
+ description: "Exact match on the item's buy/sell mode. 'product' and 'service' are a legacy classification that only a handful of old rows still carry — for \"what can I put on an invoice?\" use `side` instead.",
247
+ },
248
+ side: {
249
+ type: "string",
250
+ enum: ["sell", "buy"],
251
+ description: "What a line-item picker would offer on that side of a document: 'sell' = invoices and quotes (kind sell + buy_and_sell), 'buy' = expenses (kind buy + buy_and_sell). This is usually the filter you want.",
191
252
  },
192
253
  active: { type: "boolean", description: "Filter by active flag (false = archived items)." },
193
254
  query: { type: "string", description: "Case-insensitive substring match on title or description." },
@@ -200,6 +261,7 @@ export const TOOLS = [
200
261
  },
201
262
  handler: (client, args) => client.fetchAll("/transaction_templates", "transaction_templates", {
202
263
  kind: args.kind,
264
+ side: args.side,
203
265
  active: args.active,
204
266
  query: args.query,
205
267
  system: args.system,
@@ -211,6 +273,139 @@ export const TOOLS = [
211
273
  inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] },
212
274
  handler: (client, args) => client.getOne(`/transaction_templates/${args.id}`, "transaction_template"),
213
275
  },
276
+ // ── Catalogue authoring ──────────────────────────────────────────────
277
+ // `kind` is the buy/sell MODE, not a product-vs-service label. It decides
278
+ // which line-item picker offers the item, so it is an enum here rather than a
279
+ // free string: an item written with the legacy 'product' / 'service' values
280
+ // appears in NO picker and can never be put on a document.
281
+ {
282
+ name: "create_product_service",
283
+ description: "Add a product or service to the account's catalogue (a 'transaction template') — the priced line items that can then be dropped onto invoices, quotes and expenses. " +
284
+ "`kind` is the BUY/SELL MODE and decides where the item can be used: 'sell' offers it on invoices and quotes, 'buy' on expenses, 'buy_and_sell' on both. It is not a product-vs-service label — there is no such setting. " +
285
+ "You must name the income account the item is sold to (and, for 'buy'/'buy_and_sell', the expense account it is bought to). Beeswax will NOT pick one for you: the web form's habit of silently defaulting to the alphabetically-first account is what mis-filed 40 of one account's 82 products. Resolve ids with list_transaction_accounts — the sell account must be an income account and the buy account an expense account, or the call is refused. " +
286
+ "Titles must be unique within the account, and prices default to 0 if you omit them, so pass them explicitly. Requires a token with transaction_templates:write.",
287
+ inputSchema: {
288
+ type: "object",
289
+ properties: {
290
+ title: { type: "string", description: "Catalogue name, e.g. 'Shaker Door — Painted'. Must be unique in the account." },
291
+ kind: {
292
+ type: "string",
293
+ enum: ["sell", "buy", "buy_and_sell"],
294
+ description: "Buy/sell mode: 'sell' = offered on invoices and quotes, 'buy' = offered on expenses, 'buy_and_sell' = both.",
295
+ },
296
+ unit: {
297
+ type: "string",
298
+ enum: ["Units", "Hours", "Days"],
299
+ description: "How the item is quantified on a document line.",
300
+ },
301
+ item_category: {
302
+ type: "string",
303
+ description: "Category this item is filed under, by name — an existing one is reused, a new name creates it. Read the existing names off list_products_services first so you don't invent a near-duplicate.",
304
+ },
305
+ sell_transaction_account_id: {
306
+ type: "integer",
307
+ description: "Income account the item is sold to. Required. Must be an income account from list_transaction_accounts.",
308
+ },
309
+ buy_transaction_account_id: {
310
+ type: "integer",
311
+ description: "Expense account the item is bought to. Required for kind 'buy' and 'buy_and_sell'. Must be an expense account.",
312
+ },
313
+ sell_amount: { type: "number", description: "Sell price per unit. Defaults to 0 — pass it." },
314
+ buy_amount: { type: "number", description: "Buy price per unit. Defaults to 0." },
315
+ sell_tax_id: { type: "integer", description: "Tax code applied when the item is sold." },
316
+ buy_tax_id: { type: "integer", description: "Tax code applied when the item is bought." },
317
+ description: { type: "string", description: "Longer description shown on documents." },
318
+ item_category_id: { type: "integer", description: "Category by id instead of by name. Takes precedence over item_category." },
319
+ active: { type: "boolean", description: "Defaults to true. false files it as archived." },
320
+ physical_resource: { type: "boolean", description: "true for equipment, rooms and other bookable physical resources." },
321
+ gantt_color: { type: "string", description: "Custom colour as #RRGGBB." },
322
+ },
323
+ required: ["title", "kind", "unit", "item_category", "sell_transaction_account_id"],
324
+ },
325
+ handler: (client, args) => client.mutate("POST", "/transaction_templates", {
326
+ transaction_template: {
327
+ title: args.title,
328
+ kind: args.kind,
329
+ unit: args.unit,
330
+ description: args.description,
331
+ item_category: args.item_category,
332
+ item_category_id: args.item_category_id,
333
+ sell_amount: args.sell_amount,
334
+ buy_amount: args.buy_amount,
335
+ sell_transaction_account_id: args.sell_transaction_account_id,
336
+ buy_transaction_account_id: args.buy_transaction_account_id,
337
+ sell_tax_id: args.sell_tax_id,
338
+ buy_tax_id: args.buy_tax_id,
339
+ active: args.active,
340
+ physical_resource: args.physical_resource,
341
+ gantt_color: args.gantt_color,
342
+ },
343
+ }),
344
+ },
345
+ {
346
+ name: "update_product_service",
347
+ description: "Update one catalogue item (product or service). ONLY the fields you pass change — everything else, including accounts already on the row, is left alone. " +
348
+ "This is the tool for correcting an item filed against the wrong income or expense account, repricing it, or archiving it with active:false (the reversible alternative to deleting). " +
349
+ "The sell account must be an income account and the buy account an expense account, both from this Beeswax account, or the call is refused. Changing `kind` to include 'buy' requires an expense account. " +
350
+ "System items maintained by Beeswax cannot be edited, and publishing an item to your public rates card is not available here — that stays a human action in the Beeswax UI. Requires transaction_templates:write.",
351
+ inputSchema: {
352
+ type: "object",
353
+ properties: {
354
+ id: { type: "integer", description: "Product / service id — see list_products_services." },
355
+ title: { type: "string" },
356
+ kind: {
357
+ type: "string",
358
+ enum: ["sell", "buy", "buy_and_sell"],
359
+ description: "Buy/sell mode: where the item is offered. 'sell' = invoices and quotes, 'buy' = expenses, 'buy_and_sell' = both.",
360
+ },
361
+ unit: { type: "string", enum: ["Units", "Hours", "Days"] },
362
+ item_category: { type: "string", description: "Re-file under this category name (created if new)." },
363
+ item_category_id: { type: "integer", description: "Re-file by category id. Takes precedence over item_category." },
364
+ sell_transaction_account_id: { type: "integer", description: "Income account the item is sold to." },
365
+ buy_transaction_account_id: { type: "integer", description: "Expense account the item is bought to." },
366
+ sell_amount: { type: "number" },
367
+ buy_amount: { type: "number" },
368
+ sell_tax_id: { type: "integer" },
369
+ buy_tax_id: { type: "integer" },
370
+ description: { type: "string" },
371
+ active: { type: "boolean", description: "false archives the item — it stays on existing documents but is no longer offered." },
372
+ physical_resource: { type: "boolean" },
373
+ gantt_color: { type: "string", description: "#RRGGBB." },
374
+ },
375
+ required: ["id"],
376
+ },
377
+ handler: (client, args) => client.mutate("PATCH", `/transaction_templates/${args.id}`, {
378
+ transaction_template: {
379
+ title: args.title,
380
+ kind: args.kind,
381
+ unit: args.unit,
382
+ description: args.description,
383
+ item_category: args.item_category,
384
+ item_category_id: args.item_category_id,
385
+ sell_amount: args.sell_amount,
386
+ buy_amount: args.buy_amount,
387
+ sell_transaction_account_id: args.sell_transaction_account_id,
388
+ buy_transaction_account_id: args.buy_transaction_account_id,
389
+ sell_tax_id: args.sell_tax_id,
390
+ buy_tax_id: args.buy_tax_id,
391
+ active: args.active,
392
+ physical_resource: args.physical_resource,
393
+ gantt_color: args.gantt_color,
394
+ },
395
+ }),
396
+ },
397
+ {
398
+ name: "delete_product_service",
399
+ description: "Permanently delete a catalogue item. This is NOT a soft delete and cannot be undone. " +
400
+ "Prefer update_product_service with active:false, which archives the item — it disappears from the pickers, keeps its history, and can be switched back on. " +
401
+ "The API refuses to delete an item that any document or task still points at, an item Beeswax maintains itself, or the account's last active hourly item (time tracking needs one). Requires transaction_templates:write.",
402
+ inputSchema: {
403
+ type: "object",
404
+ properties: { id: { type: "integer", description: "Product / service id." } },
405
+ required: ["id"],
406
+ },
407
+ handler: (client, args) => client.mutate("DELETE", `/transaction_templates/${args.id}`),
408
+ },
214
409
  // ── Tax returns (Australian company / sole-trader) ──────────────────
215
410
  {
216
411
  name: "list_tax_returns",
@@ -290,6 +485,189 @@ export const TOOLS = [
290
485
  },
291
486
  handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/sections/${args.section_key}/confirm`, {}),
292
487
  },
488
+ {
489
+ name: "unconfirm_tax_return_section",
490
+ description: "Unlock a previously-confirmed tax-return section for editing again. Requires a token with the tax_returns:write scope.",
491
+ inputSchema: {
492
+ type: "object",
493
+ properties: {
494
+ id: { type: "integer", description: "Tax return id." },
495
+ section_key: { type: "string", description: "Section key to unlock." },
496
+ },
497
+ required: ["id", "section_key"],
498
+ },
499
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/sections/${args.section_key}/unconfirm`, {}),
500
+ },
501
+ {
502
+ name: "tax_return_preflight_check",
503
+ description: "Run the pre-creation reconciliation checks for a financial year (BAS periods locked, bank accounts reconciled, unallocated transactions, unmapped accounts). Blockers must be fixed in Beeswax before a tax return can be created; warnings are informational.",
504
+ inputSchema: {
505
+ type: "object",
506
+ properties: {
507
+ financial_year: { type: "integer", description: "Financial year ending, e.g. 2025 for FY2024-25." },
508
+ },
509
+ required: ["financial_year"],
510
+ },
511
+ handler: (client, args) => client.getOne(`/tax_returns/preflight_check?financial_year=${args.financial_year}`, ""),
512
+ },
513
+ {
514
+ name: "create_tax_return",
515
+ description: "Create a draft tax return for a financial year and auto-populate it from the ledger. Runs the preflight reconciliation checks first and fails with the blockers if any exist. return_type defaults to the account's business structure. Requires a token with the tax_returns:write scope.",
516
+ inputSchema: {
517
+ type: "object",
518
+ properties: {
519
+ financial_year: { type: "integer", description: "Financial year ending, e.g. 2025 for FY2024-25." },
520
+ return_type: { type: "string", description: "Optional: sole_trader or company." },
521
+ },
522
+ required: ["financial_year"],
523
+ },
524
+ handler: (client, args) => client.mutate("POST", "/tax_returns", {
525
+ financial_year: args.financial_year,
526
+ return_type: args.return_type,
527
+ }),
528
+ },
529
+ {
530
+ name: "repopulate_tax_return",
531
+ description: "Refresh a draft tax return's auto-filled figures from the latest ledger data (after new transactions or mapping changes). Manual overrides are preserved. Requires a token with the tax_returns:write scope.",
532
+ inputSchema: {
533
+ type: "object",
534
+ properties: { id: { type: "integer", description: "Tax return id." } },
535
+ required: ["id"],
536
+ },
537
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/repopulate`, {}),
538
+ },
539
+ {
540
+ name: "request_tax_return_ai_review",
541
+ description: "Start an AI review of the tax return. The review runs in the background — poll get_tax_return (latest_review) or list_tax_return_findings to see the outcome. Requires a token with the tax_returns:write scope.",
542
+ inputSchema: {
543
+ type: "object",
544
+ properties: { id: { type: "integer", description: "Tax return id." } },
545
+ required: ["id"],
546
+ },
547
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/review`, {}),
548
+ },
549
+ {
550
+ name: "resolve_tax_return_finding",
551
+ description: "Resolve one pending AI review finding: accept (applies the suggested value to the field), reject, or defer. Every pending finding in a section must be resolved before that section can be confirmed. Requires a token with the tax_returns:write scope.",
552
+ inputSchema: {
553
+ type: "object",
554
+ properties: {
555
+ id: { type: "integer", description: "Tax return id." },
556
+ finding_id: { type: "integer", description: "Finding id (from list_tax_return_findings)." },
557
+ resolution: { type: "string", enum: ["accept", "reject", "defer"], description: "How to resolve the finding." },
558
+ },
559
+ required: ["id", "finding_id", "resolution"],
560
+ },
561
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/findings/${args.finding_id}/resolve`, {
562
+ resolution: args.resolution,
563
+ }),
564
+ },
565
+ {
566
+ name: "finalise_tax_return",
567
+ description: "Finalise a tax return once every section is confirmed. A finalised return is locked against edits (use unfinalise_tax_return to unlock it). This does NOT lodge the return with the ATO. Requires a token with the tax_returns:write scope.",
568
+ inputSchema: {
569
+ type: "object",
570
+ properties: { id: { type: "integer", description: "Tax return id." } },
571
+ required: ["id"],
572
+ },
573
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/finalise`, {}),
574
+ },
575
+ {
576
+ name: "list_tax_return_field_notes",
577
+ description: "The note timeline for one tax-return field, oldest first — who wrote what, when, and from where (web, api, or assistant). This is the durable record of WHY a figure is what it is; read it before changing a field that has notes.",
578
+ inputSchema: {
579
+ type: "object",
580
+ properties: {
581
+ id: { type: "integer", description: "Tax return id." },
582
+ section_key: { type: "string" },
583
+ field_key: { type: "string" },
584
+ },
585
+ required: ["id", "section_key", "field_key"],
586
+ },
587
+ handler: (client, args) => client.getOne(`/tax_returns/${args.id}/sections/${args.section_key}/fields/${args.field_key}/notes`, ""),
588
+ },
589
+ {
590
+ name: "add_tax_return_field_note",
591
+ description: "Append a dated, attributed note to a tax-return field's timeline — a decision, its reasoning, a follow-up, or context for next year. Notes are never deleted (you can correct your own with edit_tax_return_field_note). Recorded as written by the assistant. Requires tax_returns:write.",
592
+ inputSchema: {
593
+ type: "object",
594
+ properties: {
595
+ id: { type: "integer", description: "Tax return id." },
596
+ section_key: { type: "string" },
597
+ field_key: { type: "string" },
598
+ text: { type: "string", description: "The note (up to 4000 characters)." },
599
+ },
600
+ required: ["id", "section_key", "field_key", "text"],
601
+ },
602
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/sections/${args.section_key}/fields/${args.field_key}/notes`, {
603
+ text: args.text,
604
+ source: "mcp",
605
+ }),
606
+ },
607
+ {
608
+ name: "edit_tax_return_field_note",
609
+ description: "Correct a field note you wrote earlier (a typo, a wrong figure). The note keeps its place in the timeline and is marked as edited. You can only edit notes written under this token's user. Requires tax_returns:write.",
610
+ inputSchema: {
611
+ type: "object",
612
+ properties: {
613
+ id: { type: "integer", description: "Tax return id." },
614
+ section_key: { type: "string" },
615
+ note_id: { type: "integer" },
616
+ text: { type: "string", description: "The corrected note text." },
617
+ },
618
+ required: ["id", "section_key", "note_id", "text"],
619
+ },
620
+ handler: (client, args) => client.mutate("PATCH", `/tax_returns/${args.id}/sections/${args.section_key}/notes/${args.note_id}`, { text: args.text }),
621
+ },
622
+ {
623
+ name: "get_tax_return_conversation",
624
+ description: "The discussion thread on a tax return — the same conversation the business owner, bookkeeper and accountant see in Beeswax. Read it to pick up questions, decisions and context before working on the return.",
625
+ inputSchema: {
626
+ type: "object",
627
+ properties: {
628
+ id: { type: "integer", description: "Tax return id." },
629
+ limit: { type: "integer", description: "Most recent messages to return (default 100)." },
630
+ },
631
+ required: ["id"],
632
+ },
633
+ handler: (client, args) => client.getOne(`/tax_returns/${args.id}/conversation?limit=${args.limit ?? 100}`, ""),
634
+ },
635
+ {
636
+ name: "post_tax_return_message",
637
+ description: "Post a message into the tax return's discussion thread, visible to everyone on the return in Beeswax. Use it to ask the owner or accountant a question, report what you did, or flag something for a human decision. Requires tax_returns:write.",
638
+ inputSchema: {
639
+ type: "object",
640
+ properties: {
641
+ id: { type: "integer", description: "Tax return id." },
642
+ body: { type: "string" },
643
+ },
644
+ required: ["id", "body"],
645
+ },
646
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/conversation/messages`, { body: args.body }),
647
+ },
648
+ {
649
+ name: "compare_tax_returns",
650
+ description: "Field-by-field comparison of a tax return against the same-type return for another financial year: prior value, current value, delta and % change per field. Fields blank in both years are omitted.",
651
+ inputSchema: {
652
+ type: "object",
653
+ properties: {
654
+ id: { type: "integer", description: "Tax return id (the current-year return)." },
655
+ financial_year: { type: "integer", description: "The other financial year to compare against, e.g. 2024." },
656
+ },
657
+ required: ["id", "financial_year"],
658
+ },
659
+ handler: (client, args) => client.getOne(`/tax_returns/${args.id}/compare?financial_year=${args.financial_year}`, ""),
660
+ },
661
+ {
662
+ name: "unfinalise_tax_return",
663
+ description: "Unlock a finalised (not lodged) tax return back to draft for further editing. Requires a token with the tax_returns:write scope.",
664
+ inputSchema: {
665
+ type: "object",
666
+ properties: { id: { type: "integer", description: "Tax return id." } },
667
+ required: ["id"],
668
+ },
669
+ handler: (client, args) => client.mutate("POST", `/tax_returns/${args.id}/unfinalise`, {}),
670
+ },
293
671
  // ── Manual / general journal entries (RawJournalEntry) ──────────────────
294
672
  {
295
673
  name: "list_manual_journals",
@@ -360,4 +738,379 @@ export const TOOLS = [
360
738
  },
361
739
  handler: (client, args) => client.mutate("POST", `/raw_journal_entries/${args.id}/finalise`, {}),
362
740
  },
741
+ // ── Reference data ───────────────────────────────────────────────────
742
+ // A quote cannot be built without these: every line needs a chart-of-accounts
743
+ // id, and the quote itself needs a client id.
744
+ {
745
+ name: "list_transaction_accounts",
746
+ description: "List the account's chart of accounts — the transaction_account ids every document line has to point at. " +
747
+ "`account_type` (income / expense / asset / loan / equity / transfer) tells you which side a line will post on: a quote line against an income account posts as a credit. " +
748
+ "Use this to resolve an account name to an id before calling create_quote. Inactive accounts are hidden unless you pass active:\"all\". " +
749
+ "Requires a token with transaction_accounts:read.",
750
+ inputSchema: {
751
+ type: "object",
752
+ properties: {
753
+ account_type: {
754
+ type: "string",
755
+ enum: ["income", "expense", "asset", "loan", "equity", "transfer"],
756
+ description: "Filter to one ledger classification.",
757
+ },
758
+ active: { type: "string", enum: ["true", "false", "all"], description: "Default 'true' — active accounts only." },
759
+ bank: { type: "boolean", description: "true returns only bank accounts." },
760
+ },
761
+ },
762
+ handler: (client, args) => client.getList("/active_account/transaction_accounts", "transaction_accounts", {
763
+ account_type: args.account_type,
764
+ active: args.active,
765
+ bank: args.bank,
766
+ }),
767
+ },
768
+ {
769
+ name: "list_companies",
770
+ description: "List the account's clients and suppliers. Use this to resolve a client name to the company_id create_quote needs. " +
771
+ "Fetches every page. Requires a token with companies:read.",
772
+ inputSchema: {
773
+ type: "object",
774
+ properties: {
775
+ role: { type: "string", enum: ["client", "supplier"], description: "Filter to one role. A quote is addressed to a client." },
776
+ name: { type: "string", description: "Case-insensitive substring match on the company name." },
777
+ },
778
+ },
779
+ handler: (client, args) => client.fetchAll("/active_account/companies", "companies", { role: args.role, name: args.name }),
780
+ },
781
+ // ── Quote authoring ──────────────────────────────────────────────────
782
+ // The boundary here is deliberate: create and edit, never send. There is no
783
+ // tool that emails a quote to a client or marks one accepted — those stay
784
+ // human actions in the Beeswax web UI, and the API exposes no route for them.
785
+ {
786
+ name: "create_quote",
787
+ description: "Create a whole quote in one call — header, sections, and each section's lines. A realistic quote is several phases and dozens of lines, so build the entire structure in this single call rather than adding lines one at a time. " +
788
+ "PRICE THE LINES FROM THE CATALOGUE where you can: give a line a transaction_template_id (see list_products_services, create_product_service) and it inherits that product's rate, income account, tax and unit, and takes the product's colour on the quote's Gantt preview — which is what makes a long quote's chart readable, one colour per discipline. " +
789
+ "Sections (`groups`) are the phases of the quote and keep the order you give them; so do the lines within each section. " +
790
+ "LANDS AS A DRAFT unless you pass status:\"finalised\". Creating a quote never emails anyone — sending it to the client, and marking it accepted, stay human actions in the Beeswax UI. " +
791
+ "Rules enforced by the API: project_id and company_id are required and must belong to your account (a quote is reachable through its project, so it cannot be created without one); every line needs a transaction_account_id from your chart of accounts, unless it names a product that carries one; any tax_id or transaction_template_id must be yours too; quantity cannot be negative. " +
792
+ "Resolve ids first with list_companies, list_projects and list_transaction_accounts. " +
793
+ "`unit_amount` is the PER-UNIT price — the line total is unit_amount x quantity — and Beeswax works out the debit/credit side from the account's classification, so you never set debit or credit yourself. " +
794
+ "Requires a token with the quotes:write scope.",
795
+ inputSchema: {
796
+ type: "object",
797
+ properties: {
798
+ project_id: { type: "integer", description: "Project the quote belongs to. Required." },
799
+ company_id: { type: "integer", description: "Client the quote is addressed to. Required — see list_companies." },
800
+ title: { type: "string", description: "Quote title, e.g. 'Cambridge Market Admin App Website'." },
801
+ content: { type: "string", description: "Free-text body / scope notes shown on the quote." },
802
+ sent_on: { type: "string", description: "Document date, e.g. '2026-08-24'. Defaults to today." },
803
+ status: {
804
+ type: "string",
805
+ enum: ["draft", "finalised"],
806
+ description: "Default 'draft'. 'finalised' moves it out of draft. Neither one sends anything to the client.",
807
+ },
808
+ tax_inclusive: { type: "boolean", description: "Whether the line amounts already include tax." },
809
+ default_transaction_account_id: { type: "integer", description: "Fallback account for lines that omit one." },
810
+ default_tax_id: { type: "integer", description: "Fallback tax code for the quote." },
811
+ groups: {
812
+ type: "array",
813
+ description: "The sections (phases) of the quote, in the order they should appear.",
814
+ items: {
815
+ type: "object",
816
+ properties: {
817
+ name: { type: "string", description: "Section name, e.g. 'Phase 1 — Discovery'." },
818
+ start_on: { type: "string", description: "Optional section start date. If you date the sections they must run in order." },
819
+ lines: {
820
+ type: "array",
821
+ description: "The lines in this section, in order.",
822
+ items: {
823
+ type: "object",
824
+ properties: {
825
+ description: { type: "string", description: "What the line is for." },
826
+ transaction_account_id: { type: "integer", description: "Chart-of-accounts id — the discipline/revenue line this bills to. Not needed if the product supplies one." },
827
+ transaction_template_id: {
828
+ type: "integer",
829
+ description: "Product/service from the catalogue to price this line from — see list_products_services. " +
830
+ "The line inherits the product's rate, income account, tax, wording and unit for everything you do not set yourself, and takes the product's colour on the Gantt preview. Anything you DO set here wins over the product's default.",
831
+ },
832
+ quantity: { type: "number", description: "Defaults to 1. Cannot be negative." },
833
+ unit_amount: { type: "number", description: "Per-unit price. Line total = unit_amount x quantity. Omit to use the product's rate." },
834
+ kind: { type: "string", description: "Unit label shown on the quote, e.g. 'Days' or 'Units'." },
835
+ tax_id: { type: "integer", description: "Tax code for this line." },
836
+ },
837
+ anyOf: [{ required: ["transaction_account_id"] }, { required: ["transaction_template_id"] }],
838
+ },
839
+ },
840
+ },
841
+ required: ["name"],
842
+ },
843
+ },
844
+ },
845
+ required: ["project_id", "company_id", "title"],
846
+ },
847
+ handler: (client, args) => client.mutate("POST", "/quotes", {
848
+ quote: {
849
+ project_id: args.project_id,
850
+ company_id: args.company_id,
851
+ title: args.title,
852
+ content: args.content,
853
+ sent_on: normalizeDate(args.sent_on),
854
+ status: args.status,
855
+ tax_inclusive: args.tax_inclusive,
856
+ default_transaction_account_id: args.default_transaction_account_id,
857
+ default_tax_id: args.default_tax_id,
858
+ groups: (args.groups ?? []).map((group) => ({
859
+ ...group,
860
+ start_on: normalizeDate(group.start_on),
861
+ })),
862
+ },
863
+ }),
864
+ },
865
+ {
866
+ name: "update_quote",
867
+ description: "Update a quote's HEADER — title, body text, date, client, project, tax settings. Sections and lines are not touched, so this can never silently drop line items; use the section and line tools for structural edits. " +
868
+ "Only the fields you pass are changed. Refused once the quote has been marked accepted in the UI. Requires quotes:write.",
869
+ inputSchema: {
870
+ type: "object",
871
+ properties: {
872
+ id: { type: "integer", description: "Quote id." },
873
+ title: { type: "string" },
874
+ content: { type: "string" },
875
+ sent_on: { type: "string", description: "Document date, e.g. '2026-08-24'." },
876
+ company_id: { type: "integer" },
877
+ project_id: { type: "integer" },
878
+ tax_inclusive: { type: "boolean" },
879
+ default_transaction_account_id: { type: "integer" },
880
+ default_tax_id: { type: "integer" },
881
+ },
882
+ required: ["id"],
883
+ },
884
+ handler: (client, args) => client.mutate("PATCH", `/quotes/${args.id}`, {
885
+ quote: {
886
+ title: args.title,
887
+ content: args.content,
888
+ sent_on: normalizeDate(args.sent_on),
889
+ company_id: args.company_id,
890
+ project_id: args.project_id,
891
+ tax_inclusive: args.tax_inclusive,
892
+ default_transaction_account_id: args.default_transaction_account_id,
893
+ default_tax_id: args.default_tax_id,
894
+ },
895
+ }),
896
+ },
897
+ {
898
+ name: "finalise_quote",
899
+ description: "Move a DRAFT quote to finalised. This is internal only — it does NOT email the quote to the client and does not mark it accepted; both of those stay human actions in the Beeswax UI. " +
900
+ "Call it once the draft has been reviewed. Requires quotes:write.",
901
+ inputSchema: {
902
+ type: "object",
903
+ properties: { id: { type: "integer", description: "Quote id (must be a draft)." } },
904
+ required: ["id"],
905
+ },
906
+ handler: (client, args) => client.mutate("POST", `/quotes/${args.id}/finalise`, {}),
907
+ },
908
+ {
909
+ name: "delete_quote",
910
+ description: "Delete a quote (a soft delete — it can be restored in Beeswax). Refused once the quote has been marked accepted. Requires quotes:write.",
911
+ inputSchema: {
912
+ type: "object",
913
+ properties: { id: { type: "integer", description: "Quote id." } },
914
+ required: ["id"],
915
+ },
916
+ handler: (client, args) => client.mutate("DELETE", `/quotes/${args.id}`),
917
+ },
918
+ // ── Quote sections and lines (incremental edits) ──────────────────────
919
+ {
920
+ name: "list_quote_groups",
921
+ description: "List a quote's sections (phases) in order, each with its own lines and section total. Use this to see the structure before editing it — the ids returned are what the section and line tools need. Requires quotes:read.",
922
+ inputSchema: {
923
+ type: "object",
924
+ properties: { quote_id: { type: "integer" } },
925
+ required: ["quote_id"],
926
+ },
927
+ handler: (client, args) => client.getList(`/quotes/${args.quote_id}/transaction_groups`, "transaction_groups"),
928
+ },
929
+ {
930
+ name: "add_quote_group",
931
+ description: "Append a section (phase) to a quote. `lines` may be supplied to create the section and its lines in one call. " +
932
+ "The section goes on the end; use reorder_quote_groups to move it. Refused once the quote is accepted. Requires quotes:write.",
933
+ inputSchema: {
934
+ type: "object",
935
+ properties: {
936
+ quote_id: { type: "integer" },
937
+ name: { type: "string", description: "Section name, e.g. 'Phase 3 — Launch'." },
938
+ start_on: { type: "string", description: "Optional section start date." },
939
+ lines: {
940
+ type: "array",
941
+ description: "Lines to create in the new section, in order.",
942
+ items: {
943
+ type: "object",
944
+ properties: {
945
+ description: { type: "string" },
946
+ transaction_account_id: { type: "integer", description: "Must belong to your account." },
947
+ quantity: { type: "number" },
948
+ unit_amount: { type: "number", description: "Per-unit price." },
949
+ kind: { type: "string" },
950
+ tax_id: { type: "integer" },
951
+ },
952
+ required: ["transaction_account_id"],
953
+ },
954
+ },
955
+ },
956
+ required: ["quote_id", "name"],
957
+ },
958
+ handler: (client, args) => client.mutate("POST", `/quotes/${args.quote_id}/transaction_groups`, {
959
+ transaction_group: { name: args.name, start_on: normalizeDate(args.start_on), lines: args.lines },
960
+ }),
961
+ },
962
+ {
963
+ name: "update_quote_group",
964
+ description: "Rename a quote section or change its start date. Lines are untouched — use the line tools for those. Requires quotes:write.",
965
+ inputSchema: {
966
+ type: "object",
967
+ properties: {
968
+ quote_id: { type: "integer" },
969
+ group_id: { type: "integer", description: "Section id — see list_quote_groups." },
970
+ name: { type: "string" },
971
+ start_on: { type: "string" },
972
+ },
973
+ required: ["quote_id", "group_id"],
974
+ },
975
+ handler: (client, args) => client.mutate("PATCH", `/quotes/${args.quote_id}/transaction_groups/${args.group_id}`, {
976
+ transaction_group: { name: args.name, start_on: normalizeDate(args.start_on) },
977
+ }),
978
+ },
979
+ {
980
+ name: "delete_quote_group",
981
+ description: "Delete a quote section AND every line in it. Requires quotes:write.",
982
+ inputSchema: {
983
+ type: "object",
984
+ properties: { quote_id: { type: "integer" }, group_id: { type: "integer" } },
985
+ required: ["quote_id", "group_id"],
986
+ },
987
+ handler: (client, args) => client.mutate("DELETE", `/quotes/${args.quote_id}/transaction_groups/${args.group_id}`),
988
+ },
989
+ {
990
+ name: "reorder_quote_groups",
991
+ description: "Reorder a quote's sections. Pass the section ids in the order you want them; ids you leave out keep their existing relative order and follow on. " +
992
+ "Every id must belong to this quote. If the sections carry start dates, an order that puts those dates out of sequence is rejected and nothing moves. Requires quotes:write.",
993
+ inputSchema: {
994
+ type: "object",
995
+ properties: {
996
+ quote_id: { type: "integer" },
997
+ group_ids: { type: "array", items: { type: "integer" }, description: "Section ids in the desired order." },
998
+ },
999
+ required: ["quote_id", "group_ids"],
1000
+ },
1001
+ // Unwrapped to a bare array so it matches list_quote_groups — the reorder
1002
+ // tools are list-shaped reads of the new order, not single-resource writes.
1003
+ handler: (client, args) => client
1004
+ .mutate("POST", `/quotes/${args.quote_id}/transaction_groups/reorder`, { group_ids: args.group_ids })
1005
+ .then((body) => body?.transaction_groups ?? body),
1006
+ },
1007
+ {
1008
+ name: "add_quote_line",
1009
+ description: "Append one line to a quote section. For building a quote from scratch use create_quote instead — a hundred separate calls is the wrong shape. " +
1010
+ "`unit_amount` is the per-unit price; the line total is unit_amount x quantity. Beeswax derives the debit/credit side from the account, so never pass debit or credit. " +
1011
+ "Naming a transaction_template_id prices the line from that catalogue product — its rate, income account, tax, wording and unit fill in whatever you do not set yourself, and anything you do set wins. " +
1012
+ "transaction_account_id, any tax_id and any transaction_template_id must belong to your account. Requires quotes:write.",
1013
+ inputSchema: {
1014
+ type: "object",
1015
+ properties: {
1016
+ quote_id: { type: "integer" },
1017
+ group_id: { type: "integer", description: "Section to append to — see list_quote_groups." },
1018
+ description: { type: "string" },
1019
+ transaction_account_id: { type: "integer", description: "Not needed if the product supplies one." },
1020
+ transaction_template_id: {
1021
+ type: "integer",
1022
+ description: "Product/service to price this line from — see list_products_services.",
1023
+ },
1024
+ quantity: { type: "number", description: "Defaults to 1. Cannot be negative." },
1025
+ unit_amount: { type: "number", description: "Per-unit price. Omit to use the product's rate." },
1026
+ kind: { type: "string", description: "Unit label, e.g. 'Days'." },
1027
+ tax_id: { type: "integer" },
1028
+ },
1029
+ anyOf: [
1030
+ { required: ["quote_id", "group_id", "transaction_account_id"] },
1031
+ { required: ["quote_id", "group_id", "transaction_template_id"] },
1032
+ ],
1033
+ },
1034
+ handler: (client, args) => client.mutate("POST", `/quotes/${args.quote_id}/transaction_groups/${args.group_id}/transactions`, {
1035
+ transaction: {
1036
+ description: args.description,
1037
+ transaction_account_id: args.transaction_account_id,
1038
+ transaction_template_id: args.transaction_template_id,
1039
+ quantity: args.quantity,
1040
+ unit_amount: args.unit_amount,
1041
+ kind: args.kind,
1042
+ tax_id: args.tax_id,
1043
+ },
1044
+ }),
1045
+ },
1046
+ {
1047
+ name: "update_quote_line",
1048
+ description: "Change one line on a quote. Only the fields you pass are changed — anything omitted is left alone. " +
1049
+ "That holds for transaction_template_id too: attaching a product here links the line to the catalogue and takes the product's Gantt colour, but does NOT reprice it or move its account — use this to point an existing, correctly-priced quote at the catalogue without touching a single number. " +
1050
+ "Pass transaction_template_id: null to detach the product. To price a line FROM a product instead, set the product on a new line (add_quote_line) or clear the fields you want inherited. Requires quotes:write.",
1051
+ inputSchema: {
1052
+ type: "object",
1053
+ properties: {
1054
+ quote_id: { type: "integer" },
1055
+ group_id: { type: "integer" },
1056
+ line_id: { type: "integer", description: "Line id — see list_quote_groups or list_quote_transactions." },
1057
+ description: { type: "string" },
1058
+ transaction_account_id: { type: "integer" },
1059
+ transaction_template_id: {
1060
+ type: ["integer", "null"],
1061
+ description: "Catalogue product this line bills against. Linking one here changes nothing else on the line; null detaches it.",
1062
+ },
1063
+ quantity: { type: "number" },
1064
+ unit_amount: { type: "number", description: "Per-unit price." },
1065
+ kind: { type: "string" },
1066
+ tax_id: { type: "integer" },
1067
+ },
1068
+ required: ["quote_id", "group_id", "line_id"],
1069
+ },
1070
+ handler: (client, args) => client.mutate("PATCH", `/quotes/${args.quote_id}/transaction_groups/${args.group_id}/transactions/${args.line_id}`, {
1071
+ transaction: {
1072
+ description: args.description,
1073
+ transaction_account_id: args.transaction_account_id,
1074
+ // Deliberately not stripped when null — JSON.stringify drops the key
1075
+ // when it is undefined (omitted), and an explicit null is how a
1076
+ // caller detaches the product.
1077
+ transaction_template_id: args.transaction_template_id,
1078
+ quantity: args.quantity,
1079
+ unit_amount: args.unit_amount,
1080
+ kind: args.kind,
1081
+ tax_id: args.tax_id,
1082
+ },
1083
+ }),
1084
+ },
1085
+ {
1086
+ name: "delete_quote_line",
1087
+ description: "Delete one line from a quote section. Requires quotes:write.",
1088
+ inputSchema: {
1089
+ type: "object",
1090
+ properties: {
1091
+ quote_id: { type: "integer" },
1092
+ group_id: { type: "integer" },
1093
+ line_id: { type: "integer" },
1094
+ },
1095
+ required: ["quote_id", "group_id", "line_id"],
1096
+ },
1097
+ handler: (client, args) => client.mutate("DELETE", `/quotes/${args.quote_id}/transaction_groups/${args.group_id}/transactions/${args.line_id}`),
1098
+ },
1099
+ {
1100
+ name: "reorder_quote_lines",
1101
+ description: "Reorder the lines within one quote section. Pass the line ids in the order you want them; ids you leave out keep their existing relative order and follow on. Every id must belong to that section. Requires quotes:write.",
1102
+ inputSchema: {
1103
+ type: "object",
1104
+ properties: {
1105
+ quote_id: { type: "integer" },
1106
+ group_id: { type: "integer" },
1107
+ transaction_ids: { type: "array", items: { type: "integer" }, description: "Line ids in the desired order." },
1108
+ },
1109
+ required: ["quote_id", "group_id", "transaction_ids"],
1110
+ },
1111
+ // Unwrapped to a bare array, matching list_quote_transactions.
1112
+ handler: (client, args) => client
1113
+ .mutate("POST", `/quotes/${args.quote_id}/transaction_groups/${args.group_id}/transactions/reorder`, { transaction_ids: args.transaction_ids })
1114
+ .then((body) => body?.transactions ?? body),
1115
+ },
363
1116
  ];
@@ -0,0 +1,34 @@
1
+ // Single source of truth for the running version: package.json, read at
2
+ // startup. Never hand-type the version anywhere else — the 0.5.0/0.6.0
3
+ // handshake drift came from exactly that.
4
+ //
5
+ // Layouts this must resolve in:
6
+ // npm/dist: dist/version.js → ../package.json
7
+ // .mcpb bundle: server/version.js → ../package.json (build-mcpb.sh
8
+ // stages package.json beside server/)
9
+ import { readFileSync } from "node:fs";
10
+ function readVersion() {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
+ if (typeof pkg.version === "string" && pkg.version)
14
+ return pkg.version;
15
+ }
16
+ catch {
17
+ // fall through
18
+ }
19
+ return "0.0.0"; // unresolvable — better a sentinel than a lie
20
+ }
21
+ export const VERSION = readVersion();
22
+ // "1.2.3" → is `candidate` strictly newer than `running`? Non-semver input → false.
23
+ export function isNewerVersion(candidate, running) {
24
+ const parse = (v) => v.trim().match(/^(\d+)\.(\d+)\.(\d+)/)?.slice(1, 4).map(Number);
25
+ const a = parse(candidate);
26
+ const b = parse(running);
27
+ if (!a || !b)
28
+ return false;
29
+ for (let i = 0; i < 3; i++) {
30
+ if (a[i] !== b[i])
31
+ return a[i] > b[i];
32
+ }
33
+ return false;
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "beeswax-mcp",
3
- "version": "0.2.1",
4
- "description": "Official MCP server for Beeswax (beeswaxapp.com) — query invoices, quotes, expenses, payments, journals, time entries, projects and tax returns from Claude and other MCP clients.",
3
+ "version": "1.0.0",
4
+ "description": "Official MCP server for Beeswax (beeswaxapp.com) — query invoices, quotes, expenses, payments, journals, time entries, projects, products & services and tax returns from Claude and other MCP clients, and build quotes priced from your catalogue.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.beeswaxapp.com/support/mcp-setup",
7
7
  "bugs": {
@@ -32,7 +32,9 @@
32
32
  "build:mcpb": "bash scripts/build-mcpb.sh",
33
33
  "start": "node dist/index.js",
34
34
  "dev": "tsc --watch",
35
- "prepublishOnly": "npm run build"
35
+ "prepublishOnly": "npm run build",
36
+ "release": "bash scripts/release.sh",
37
+ "smoke": "node scripts/smoke.mjs"
36
38
  },
37
39
  "dependencies": {
38
40
  "@modelcontextprotocol/sdk": "^1.0.0"