@alfe.ai/myob-mcp 0.3.16 → 0.3.18

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.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/dist/server.js +142 -71
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @alfe.ai/myob-mcp
2
2
 
3
- MYOB Business MCP server direct API integration with Alfe OAuth credentials and automatic token refresh
3
+ MYOB Business MCP server with explicit multi-business selection, Alfe-managed
4
+ OAuth credentials, and per-Connection automatic token refresh.
4
5
 
5
6
  Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: build, deploy, and run agents with persistent memory, identity, integrations, and channels. See the [documentation](https://docs.alfe.ai) to get started.
6
7
 
@@ -10,6 +11,11 @@ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: buil
10
11
  npm install @alfe.ai/myob-mcp
11
12
  ```
12
13
 
14
+ Every MYOB API tool requires the `myobBusinessId` returned by
15
+ `myob_list_businesses`, so reads and financial writes cannot silently target a
16
+ default company file. Access tokens are refreshed independently for each
17
+ connected business.
18
+
13
19
  ## Links
14
20
 
15
21
  - 🌐 Website: <https://alfe.ai>
package/dist/server.js CHANGED
@@ -16,6 +16,7 @@ import { assertPatternA } from "@alfe.ai/mcp-bundler";
16
16
  * - x-myobapi-version: v2
17
17
  */
18
18
  const MYOB_API_BASE = "https://api.myob.com/accountright";
19
+ const MYOB_REQUEST_TIMEOUT_MS = 3e4;
19
20
  var MyobClient = class {
20
21
  accessToken;
21
22
  clientId;
@@ -31,7 +32,7 @@ var MyobClient = class {
31
32
  this.accessToken = accessToken;
32
33
  }
33
34
  get baseUrl() {
34
- return `${MYOB_API_BASE}/${this.businessId}`;
35
+ return `${MYOB_API_BASE}/${encodeURIComponent(this.businessId)}`;
35
36
  }
36
37
  headers() {
37
38
  return {
@@ -63,7 +64,8 @@ var MyobClient = class {
63
64
  const doFetch = async () => {
64
65
  const init = {
65
66
  method,
66
- headers: this.headers()
67
+ headers: this.headers(),
68
+ signal: AbortSignal.timeout(MYOB_REQUEST_TIMEOUT_MS)
67
69
  };
68
70
  if (body !== void 0) init.body = JSON.stringify(body);
69
71
  return fetch(url, init);
@@ -90,13 +92,36 @@ var MyobClient = class {
90
92
  * expects a raw Zod shape (`{ field: z.string() }`), NOT a JSON-Schema
91
93
  * object — passing JSON-Schema throws synchronously at registration time.
92
94
  */
93
- const myobBusinessIdField = z.string().describe("MYOB Business ID — use the value from myob_list_businesses to pick which connected business this call should target.");
95
+ const myobBusinessIdField = z.string().min(1).describe("MYOB Business ID — use the value from myob_list_businesses to pick which connected business this call should target.");
96
+ const myobResourceUidField = z.string().min(1).describe("MYOB resource UID (GUID)");
97
+ function myobResourcePath(collectionPath, uid) {
98
+ return `${collectionPath}/${encodeURIComponent(uid)}`;
99
+ }
100
+ const readOnlyToolAnnotations = {
101
+ readOnlyHint: true,
102
+ destructiveHint: false,
103
+ idempotentHint: true,
104
+ openWorldHint: true
105
+ };
106
+ const additiveToolAnnotations = {
107
+ readOnlyHint: false,
108
+ destructiveHint: false,
109
+ idempotentHint: false,
110
+ openWorldHint: true
111
+ };
112
+ const updateToolAnnotations = {
113
+ readOnlyHint: false,
114
+ destructiveHint: true,
115
+ idempotentHint: false,
116
+ openWorldHint: true
117
+ };
94
118
  //#endregion
95
119
  //#region src/tools/contacts.ts
96
120
  function registerContactTools(server, resolveClient) {
97
121
  const register = server.registerTool.bind(server);
98
122
  register("myob_list_customers", {
99
123
  description: "List customer contacts from a connected MYOB Business.",
124
+ annotations: readOnlyToolAnnotations,
100
125
  inputSchema: {
101
126
  myobBusinessId: myobBusinessIdField,
102
127
  filter: z.string().optional().describe("OData $filter expression (e.g. \"IsActive eq true\")"),
@@ -117,12 +142,13 @@ function registerContactTools(server, resolveClient) {
117
142
  });
118
143
  register("myob_get_customer", {
119
144
  description: "Get a specific customer contact by UID from a connected MYOB Business.",
145
+ annotations: readOnlyToolAnnotations,
120
146
  inputSchema: {
121
147
  myobBusinessId: myobBusinessIdField,
122
- uid: z.string().describe("The customer UID (GUID)")
148
+ uid: myobResourceUidField.describe("The customer UID (GUID)")
123
149
  }
124
150
  }, async (args) => {
125
- const result = await resolveClient(args.myobBusinessId).get(`/Contact/Customer/${args.uid}`);
151
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Contact/Customer", args.uid));
126
152
  return { content: [{
127
153
  type: "text",
128
154
  text: JSON.stringify(result, null, 2)
@@ -130,6 +156,7 @@ function registerContactTools(server, resolveClient) {
130
156
  });
131
157
  register("myob_create_customer", {
132
158
  description: "Create a new customer contact in a connected MYOB Business.",
159
+ annotations: additiveToolAnnotations,
133
160
  inputSchema: {
134
161
  myobBusinessId: myobBusinessIdField,
135
162
  CompanyName: z.string().optional().describe("Company name"),
@@ -155,13 +182,14 @@ function registerContactTools(server, resolveClient) {
155
182
  });
156
183
  register("myob_update_customer", {
157
184
  description: "Update an existing customer contact in a connected MYOB Business.",
185
+ annotations: updateToolAnnotations,
158
186
  inputSchema: {
159
187
  myobBusinessId: myobBusinessIdField,
160
- uid: z.string().describe("The customer UID (GUID) to update"),
188
+ uid: myobResourceUidField.describe("The customer UID (GUID) to update"),
161
189
  data: z.record(z.string(), z.unknown()).describe("Customer data fields to update")
162
190
  }
163
191
  }, async (args) => {
164
- const result = await resolveClient(args.myobBusinessId).put(`/Contact/Customer/${args.uid}`, args.data);
192
+ const result = await resolveClient(args.myobBusinessId).put(myobResourcePath("/Contact/Customer", args.uid), args.data);
165
193
  return { content: [{
166
194
  type: "text",
167
195
  text: JSON.stringify(result, null, 2)
@@ -169,6 +197,7 @@ function registerContactTools(server, resolveClient) {
169
197
  });
170
198
  register("myob_list_suppliers", {
171
199
  description: "List supplier contacts from a connected MYOB Business.",
200
+ annotations: readOnlyToolAnnotations,
172
201
  inputSchema: {
173
202
  myobBusinessId: myobBusinessIdField,
174
203
  filter: z.string().optional().describe("OData $filter expression"),
@@ -189,12 +218,13 @@ function registerContactTools(server, resolveClient) {
189
218
  });
190
219
  register("myob_get_supplier", {
191
220
  description: "Get a specific supplier contact by UID from a connected MYOB Business.",
221
+ annotations: readOnlyToolAnnotations,
192
222
  inputSchema: {
193
223
  myobBusinessId: myobBusinessIdField,
194
- uid: z.string().describe("The supplier UID (GUID)")
224
+ uid: myobResourceUidField.describe("The supplier UID (GUID)")
195
225
  }
196
226
  }, async (args) => {
197
- const result = await resolveClient(args.myobBusinessId).get(`/Contact/Supplier/${args.uid}`);
227
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Contact/Supplier", args.uid));
198
228
  return { content: [{
199
229
  type: "text",
200
230
  text: JSON.stringify(result, null, 2)
@@ -202,6 +232,7 @@ function registerContactTools(server, resolveClient) {
202
232
  });
203
233
  register("myob_create_supplier", {
204
234
  description: "Create a new supplier contact in a connected MYOB Business.",
235
+ annotations: additiveToolAnnotations,
205
236
  inputSchema: {
206
237
  myobBusinessId: myobBusinessIdField,
207
238
  CompanyName: z.string().optional().describe("Company name"),
@@ -232,6 +263,7 @@ function registerSalesTools(server, resolveClient) {
232
263
  const register = server.registerTool.bind(server);
233
264
  register("myob_list_invoices", {
234
265
  description: "List sale invoices from a connected MYOB Business.",
266
+ annotations: readOnlyToolAnnotations,
235
267
  inputSchema: {
236
268
  myobBusinessId: myobBusinessIdField,
237
269
  filter: z.string().optional().describe("OData $filter expression (e.g. \"Status eq 'Open'\")"),
@@ -252,12 +284,13 @@ function registerSalesTools(server, resolveClient) {
252
284
  });
253
285
  register("myob_get_invoice", {
254
286
  description: "Get a specific sale invoice by UID from a connected MYOB Business.",
287
+ annotations: readOnlyToolAnnotations,
255
288
  inputSchema: {
256
289
  myobBusinessId: myobBusinessIdField,
257
- uid: z.string().describe("The invoice UID (GUID)")
290
+ uid: myobResourceUidField.describe("The invoice UID (GUID)")
258
291
  }
259
292
  }, async (args) => {
260
- const result = await resolveClient(args.myobBusinessId).get(`/Sale/Invoice/${args.uid}`);
293
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Sale/Invoice", args.uid));
261
294
  return { content: [{
262
295
  type: "text",
263
296
  text: JSON.stringify(result, null, 2)
@@ -265,6 +298,7 @@ function registerSalesTools(server, resolveClient) {
265
298
  });
266
299
  register("myob_create_invoice", {
267
300
  description: "Create a new sale invoice in a connected MYOB Business. Provide customer, line items, and dates.",
301
+ annotations: additiveToolAnnotations,
268
302
  inputSchema: {
269
303
  myobBusinessId: myobBusinessIdField,
270
304
  data: z.record(z.string(), z.unknown()).describe("Full invoice data object including Customer, Date, Lines, etc.")
@@ -278,6 +312,7 @@ function registerSalesTools(server, resolveClient) {
278
312
  });
279
313
  register("myob_list_quotes", {
280
314
  description: "List sale quotes from a connected MYOB Business.",
315
+ annotations: readOnlyToolAnnotations,
281
316
  inputSchema: {
282
317
  myobBusinessId: myobBusinessIdField,
283
318
  filter: z.string().optional().describe("OData $filter expression"),
@@ -298,6 +333,7 @@ function registerSalesTools(server, resolveClient) {
298
333
  });
299
334
  register("myob_create_quote", {
300
335
  description: "Create a new sale quote in a connected MYOB Business.",
336
+ annotations: additiveToolAnnotations,
301
337
  inputSchema: {
302
338
  myobBusinessId: myobBusinessIdField,
303
339
  data: z.record(z.string(), z.unknown()).describe("Full quote data object including Customer, Date, Lines, etc.")
@@ -316,6 +352,7 @@ function registerPurchaseTools(server, resolveClient) {
316
352
  const register = server.registerTool.bind(server);
317
353
  register("myob_list_bills", {
318
354
  description: "List purchase bills from a connected MYOB Business.",
355
+ annotations: readOnlyToolAnnotations,
319
356
  inputSchema: {
320
357
  myobBusinessId: myobBusinessIdField,
321
358
  filter: z.string().optional().describe("OData $filter expression"),
@@ -336,12 +373,13 @@ function registerPurchaseTools(server, resolveClient) {
336
373
  });
337
374
  register("myob_get_bill", {
338
375
  description: "Get a specific purchase bill by UID from a connected MYOB Business.",
376
+ annotations: readOnlyToolAnnotations,
339
377
  inputSchema: {
340
378
  myobBusinessId: myobBusinessIdField,
341
- uid: z.string().describe("The bill UID (GUID)")
379
+ uid: myobResourceUidField.describe("The bill UID (GUID)")
342
380
  }
343
381
  }, async (args) => {
344
- const result = await resolveClient(args.myobBusinessId).get(`/Purchase/Bill/${args.uid}`);
382
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Purchase/Bill", args.uid));
345
383
  return { content: [{
346
384
  type: "text",
347
385
  text: JSON.stringify(result, null, 2)
@@ -349,6 +387,7 @@ function registerPurchaseTools(server, resolveClient) {
349
387
  });
350
388
  register("myob_create_bill", {
351
389
  description: "Create a new purchase bill in a connected MYOB Business.",
390
+ annotations: additiveToolAnnotations,
352
391
  inputSchema: {
353
392
  myobBusinessId: myobBusinessIdField,
354
393
  data: z.record(z.string(), z.unknown()).describe("Full bill data object including Supplier, Date, Lines, etc.")
@@ -367,6 +406,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
367
406
  const register = server.registerTool.bind(server);
368
407
  register("myob_list_accounts", {
369
408
  description: "List chart of accounts (ledger accounts) from a connected MYOB Business. NOT the connected businesses themselves — for that, use myob_list_businesses.",
409
+ annotations: readOnlyToolAnnotations,
370
410
  inputSchema: {
371
411
  myobBusinessId: myobBusinessIdField,
372
412
  filter: z.string().optional().describe("OData $filter expression (e.g. \"Type eq 'Bank'\")"),
@@ -387,12 +427,13 @@ function registerGeneralLedgerTools(server, resolveClient) {
387
427
  });
388
428
  register("myob_get_account", {
389
429
  description: "Get a specific general ledger account by UID from a connected MYOB Business.",
430
+ annotations: readOnlyToolAnnotations,
390
431
  inputSchema: {
391
432
  myobBusinessId: myobBusinessIdField,
392
- uid: z.string().describe("The account UID (GUID)")
433
+ uid: myobResourceUidField.describe("The account UID (GUID)")
393
434
  }
394
435
  }, async (args) => {
395
- const result = await resolveClient(args.myobBusinessId).get(`/GeneralLedger/Account/${args.uid}`);
436
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/GeneralLedger/Account", args.uid));
396
437
  return { content: [{
397
438
  type: "text",
398
439
  text: JSON.stringify(result, null, 2)
@@ -400,6 +441,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
400
441
  });
401
442
  register("myob_list_journal_entries", {
402
443
  description: "List journal transactions from a connected MYOB Business general ledger.",
444
+ annotations: readOnlyToolAnnotations,
403
445
  inputSchema: {
404
446
  myobBusinessId: myobBusinessIdField,
405
447
  filter: z.string().optional().describe("OData $filter expression"),
@@ -420,6 +462,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
420
462
  });
421
463
  register("myob_create_journal_entry", {
422
464
  description: "Create a new journal transaction in a connected MYOB Business.",
465
+ annotations: additiveToolAnnotations,
423
466
  inputSchema: {
424
467
  myobBusinessId: myobBusinessIdField,
425
468
  data: z.record(z.string(), z.unknown()).describe("Full journal transaction data object including Lines, DateOccurred, Memo, etc.")
@@ -433,6 +476,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
433
476
  });
434
477
  register("myob_list_tax_codes", {
435
478
  description: "List tax codes from a connected MYOB Business (read-only for MYOB Essentials).",
479
+ annotations: readOnlyToolAnnotations,
436
480
  inputSchema: {
437
481
  myobBusinessId: myobBusinessIdField,
438
482
  filter: z.string().optional().describe("OData $filter expression")
@@ -454,6 +498,7 @@ function registerInventoryTools(server, resolveClient) {
454
498
  const register = server.registerTool.bind(server);
455
499
  register("myob_list_items", {
456
500
  description: "List inventory items from a connected MYOB Business.",
501
+ annotations: readOnlyToolAnnotations,
457
502
  inputSchema: {
458
503
  myobBusinessId: myobBusinessIdField,
459
504
  filter: z.string().optional().describe("OData $filter expression"),
@@ -474,12 +519,13 @@ function registerInventoryTools(server, resolveClient) {
474
519
  });
475
520
  register("myob_get_item", {
476
521
  description: "Get a specific inventory item by UID from a connected MYOB Business.",
522
+ annotations: readOnlyToolAnnotations,
477
523
  inputSchema: {
478
524
  myobBusinessId: myobBusinessIdField,
479
- uid: z.string().describe("The item UID (GUID)")
525
+ uid: myobResourceUidField.describe("The item UID (GUID)")
480
526
  }
481
527
  }, async (args) => {
482
- const result = await resolveClient(args.myobBusinessId).get(`/Inventory/Item/${args.uid}`);
528
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Inventory/Item", args.uid));
483
529
  return { content: [{
484
530
  type: "text",
485
531
  text: JSON.stringify(result, null, 2)
@@ -487,6 +533,7 @@ function registerInventoryTools(server, resolveClient) {
487
533
  });
488
534
  register("myob_create_item", {
489
535
  description: "Create a new inventory item in a connected MYOB Business.",
536
+ annotations: additiveToolAnnotations,
490
537
  inputSchema: {
491
538
  myobBusinessId: myobBusinessIdField,
492
539
  data: z.record(z.string(), z.unknown()).describe("Full item data object including Name, Number, SellingDetails, BuyingDetails, etc.")
@@ -500,13 +547,14 @@ function registerInventoryTools(server, resolveClient) {
500
547
  });
501
548
  register("myob_update_item", {
502
549
  description: "Update an existing inventory item in a connected MYOB Business.",
550
+ annotations: updateToolAnnotations,
503
551
  inputSchema: {
504
552
  myobBusinessId: myobBusinessIdField,
505
- uid: z.string().describe("The item UID (GUID) to update"),
553
+ uid: myobResourceUidField.describe("The item UID (GUID) to update"),
506
554
  data: z.record(z.string(), z.unknown()).describe("Item data fields to update")
507
555
  }
508
556
  }, async (args) => {
509
- const result = await resolveClient(args.myobBusinessId).put(`/Inventory/Item/${args.uid}`, args.data);
557
+ const result = await resolveClient(args.myobBusinessId).put(myobResourcePath("/Inventory/Item", args.uid), args.data);
510
558
  return { content: [{
511
559
  type: "text",
512
560
  text: JSON.stringify(result, null, 2)
@@ -519,6 +567,7 @@ function registerPaymentTools(server, resolveClient) {
519
567
  const register = server.registerTool.bind(server);
520
568
  register("myob_list_customer_payments", {
521
569
  description: "List customer payments (receipts) from a connected MYOB Business.",
570
+ annotations: readOnlyToolAnnotations,
522
571
  inputSchema: {
523
572
  myobBusinessId: myobBusinessIdField,
524
573
  filter: z.string().optional().describe("OData $filter expression"),
@@ -539,6 +588,7 @@ function registerPaymentTools(server, resolveClient) {
539
588
  });
540
589
  register("myob_record_customer_payment", {
541
590
  description: "Record a customer payment against invoices in a connected MYOB Business.",
591
+ annotations: additiveToolAnnotations,
542
592
  inputSchema: {
543
593
  myobBusinessId: myobBusinessIdField,
544
594
  data: z.record(z.string(), z.unknown()).describe("Full customer payment data including Customer, Account, Invoices, AmountReceived, etc.")
@@ -552,6 +602,7 @@ function registerPaymentTools(server, resolveClient) {
552
602
  });
553
603
  register("myob_list_supplier_payments", {
554
604
  description: "List supplier payments from a connected MYOB Business.",
605
+ annotations: readOnlyToolAnnotations,
555
606
  inputSchema: {
556
607
  myobBusinessId: myobBusinessIdField,
557
608
  filter: z.string().optional().describe("OData $filter expression"),
@@ -572,6 +623,7 @@ function registerPaymentTools(server, resolveClient) {
572
623
  });
573
624
  register("myob_record_supplier_payment", {
574
625
  description: "Record a supplier payment against bills in a connected MYOB Business.",
626
+ annotations: additiveToolAnnotations,
575
627
  inputSchema: {
576
628
  myobBusinessId: myobBusinessIdField,
577
629
  data: z.record(z.string(), z.unknown()).describe("Full supplier payment data including Supplier, Account, Bills, AmountPaid, etc.")
@@ -585,6 +637,39 @@ function registerPaymentTools(server, resolveClient) {
585
637
  });
586
638
  }
587
639
  //#endregion
640
+ //#region src/token-refresh.ts
641
+ /**
642
+ * Coordinates MYOB refreshes without allowing credentials to cross business
643
+ * boundaries. Calls for the same Connection share one request; calls for
644
+ * different Connections remain independent.
645
+ */
646
+ var MyobTokenRefreshCoordinator = class {
647
+ inFlight = /* @__PURE__ */ new Map();
648
+ constructor(apiClient, targets) {
649
+ this.apiClient = apiClient;
650
+ this.targets = targets;
651
+ }
652
+ refreshBusiness(myobBusinessId) {
653
+ const target = this.targets.get(myobBusinessId);
654
+ if (!target) return Promise.reject(/* @__PURE__ */ new Error(`Unknown myobBusinessId: ${myobBusinessId}`));
655
+ const existing = this.inFlight.get(target.accountIdentifier);
656
+ if (existing) return existing;
657
+ const refresh = this.apiClient.refreshMYOBAccountToken(target.accountIdentifier).then(({ accessToken }) => {
658
+ target.client.updateToken(accessToken);
659
+ return accessToken;
660
+ }).finally(() => {
661
+ if (this.inFlight.get(target.accountIdentifier) === refresh) this.inFlight.delete(target.accountIdentifier);
662
+ });
663
+ this.inFlight.set(target.accountIdentifier, refresh);
664
+ return refresh;
665
+ }
666
+ async refreshAll() {
667
+ const businessIds = [...this.targets.keys()];
668
+ await Promise.all(businessIds.map((businessId) => this.refreshBusiness(businessId)));
669
+ return businessIds;
670
+ }
671
+ };
672
+ //#endregion
588
673
  //#region src/server.ts
589
674
  /**
590
675
  * MYOB Business MCP Server (Pattern A multi-account)
@@ -604,7 +689,6 @@ function registerPaymentTools(server, resolveClient) {
604
689
  */
605
690
  const clients = /* @__PURE__ */ new Map();
606
691
  let refreshTimer = null;
607
- let inFlightRefresh = null;
608
692
  const REFRESH_INTERVAL_MS = 900 * 1e3;
609
693
  const REFRESH_RETRY_MS = 60 * 1e3;
610
694
  function log(msg) {
@@ -638,55 +722,26 @@ function zodShapeToValidatable(name, shape) {
638
722
  * the valid set. This is the per-call dispatch contract for Pattern A.
639
723
  */
640
724
  function resolveMyobClient(myobBusinessId) {
641
- const c = clients.get(myobBusinessId);
642
- if (!c) throw new Error(`Unknown myobBusinessId: ${myobBusinessId}. Call myob_list_businesses to see the connected businesses on this agent.`);
643
- return c;
644
- }
645
- /**
646
- * Single fan-out point for token refresh. Used by:
647
- * - the scheduled timer (performRefresh)
648
- * - the `myob_refresh_token` tool
649
- * - every cached `MyobClient`'s `onRefresh` callback (on 401 retry)
650
- *
651
- * Concurrent callers share one in-flight `refreshMYOBToken()` request
652
- * via `inFlightRefresh`, and the resolved token is fanned out to every
653
- * cached client so siblings don't re-hit the network on their own 401s.
654
- *
655
- * v1 limitation: `apiClient.refreshMYOBToken()` refreshes the primary
656
- * connection's token only and we fan it across all clients. This works
657
- * as long as MYOB access tokens are interchangeable across businesses
658
- * sharing a partner appId (true today). Per-connection refresh is a v2
659
- * follow-up — see DEVELOPING.md.
660
- */
661
- async function refreshAndFanOut(apiClient) {
662
- if (inFlightRefresh) return inFlightRefresh;
663
- inFlightRefresh = (async () => {
664
- try {
665
- const { accessToken } = await apiClient.refreshMYOBToken();
666
- for (const client of clients.values()) client.updateToken(accessToken);
667
- return accessToken;
668
- } finally {
669
- inFlightRefresh = null;
670
- }
671
- })();
672
- return inFlightRefresh;
725
+ const account = clients.get(myobBusinessId);
726
+ if (!account) throw new Error(`Unknown myobBusinessId: ${myobBusinessId}. Call myob_list_businesses to see the connected businesses on this agent.`);
727
+ return account.client;
673
728
  }
674
- async function performRefresh(apiClient) {
729
+ async function performRefresh(tokenRefresher) {
675
730
  log("Refreshing MYOB access tokens across all connected businesses...");
676
- await refreshAndFanOut(apiClient);
677
- log("Token refresh applied to all clients");
731
+ const refreshed = await tokenRefresher.refreshAll();
732
+ log(`Refreshed ${String(refreshed.length)} MYOB business token(s)`);
678
733
  }
679
- function scheduleRefresh(apiClient) {
734
+ function scheduleRefresh(tokenRefresher) {
680
735
  if (refreshTimer) clearTimeout(refreshTimer);
681
736
  refreshTimer = setTimeout(() => {
682
737
  (async () => {
683
738
  try {
684
- await performRefresh(apiClient);
685
- scheduleRefresh(apiClient);
739
+ await performRefresh(tokenRefresher);
740
+ scheduleRefresh(tokenRefresher);
686
741
  } catch (err) {
687
742
  log(`Token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
688
743
  refreshTimer = setTimeout(() => {
689
- scheduleRefresh(apiClient);
744
+ scheduleRefresh(tokenRefresher);
690
745
  }, REFRESH_RETRY_MS);
691
746
  }
692
747
  })();
@@ -700,19 +755,26 @@ async function main() {
700
755
  });
701
756
  const { accounts } = await apiClient.getMYOBAccounts();
702
757
  if (accounts.length === 0) log("No MYOB businesses connected — server will start with myob_list_businesses + myob_refresh_token only");
758
+ const tokenRefresher = new MyobTokenRefreshCoordinator(apiClient, clients);
703
759
  for (const acct of accounts) {
704
760
  if (clients.has(acct.myobBusinessId)) {
705
761
  log(`Duplicate myobBusinessId ${acct.myobBusinessId} returned by getMYOBAccounts() — keeping the first cached client`);
706
762
  continue;
707
763
  }
708
- clients.set(acct.myobBusinessId, new MyobClient({
764
+ const client = new MyobClient({
709
765
  accessToken: acct.accessToken,
710
766
  clientId: acct.clientId,
711
767
  businessId: acct.myobBusinessId,
712
768
  onRefresh: async () => {
713
- return { accessToken: await refreshAndFanOut(apiClient) };
769
+ return { accessToken: await tokenRefresher.refreshBusiness(acct.myobBusinessId) };
714
770
  }
715
- }));
771
+ });
772
+ clients.set(acct.myobBusinessId, {
773
+ accountIdentifier: acct.accountIdentifier,
774
+ displayName: acct.displayName,
775
+ connectedAt: acct.connectedAt,
776
+ client
777
+ });
716
778
  log(`Cached client for business ${acct.myobBusinessId} (${acct.displayName ?? "no display name"})`);
717
779
  }
718
780
  const server = new McpServer({
@@ -733,23 +795,32 @@ async function main() {
733
795
  registerInventoryTools(server, resolveMyobClient);
734
796
  registerPaymentTools(server, resolveMyobClient);
735
797
  const registerTool = server.registerTool.bind(server);
736
- registerTool("myob_list_businesses", { description: "List the MYOB Business accounts (companies) the agent has connected. Returns one entry per OAuth connection — use the returned myobBusinessId values as the `myobBusinessId` selector arg on every other myob_* tool." }, () => {
737
- const summaries = accounts.map((a) => ({
738
- myobBusinessId: a.myobBusinessId,
739
- displayName: a.displayName,
740
- connectedAt: a.connectedAt
798
+ registerTool("myob_list_businesses", {
799
+ description: "List the MYOB Business accounts (companies) the agent has connected. Returns one entry per OAuth connection — use the returned myobBusinessId values as the `myobBusinessId` selector arg on every other myob_* tool.",
800
+ annotations: readOnlyToolAnnotations
801
+ }, () => {
802
+ const summaries = [...clients.entries()].map(([myobBusinessId, account]) => ({
803
+ myobBusinessId,
804
+ displayName: account.displayName,
805
+ connectedAt: account.connectedAt
741
806
  }));
742
807
  return { content: [{
743
808
  type: "text",
744
809
  text: JSON.stringify({ businesses: summaries }, null, 2)
745
810
  }] };
746
811
  });
747
- registerTool("myob_refresh_token", { description: "Force-refresh the MYOB OAuth2 access token. Affects every connected MYOB business (tokens are interchangeable across businesses sharing a partner appId). Use this if API calls are failing with authentication errors." }, async () => {
812
+ registerTool("myob_refresh_token", {
813
+ description: "Force-refresh every connected MYOB business. Each Connection is refreshed independently so credentials never cross business boundaries. Use this if API calls are failing with authentication errors.",
814
+ annotations: additiveToolAnnotations
815
+ }, async () => {
748
816
  try {
749
- await performRefresh(apiClient);
817
+ const refreshed = await tokenRefresher.refreshAll();
750
818
  return { content: [{
751
819
  type: "text",
752
- text: JSON.stringify({ refreshed: true })
820
+ text: JSON.stringify({
821
+ refreshed: true,
822
+ businesses: refreshed
823
+ })
753
824
  }] };
754
825
  } catch (err) {
755
826
  return {
@@ -765,7 +836,7 @@ async function main() {
765
836
  selector: "myobBusinessId",
766
837
  exempt: ["myob_list_businesses", "myob_refresh_token"]
767
838
  });
768
- scheduleRefresh(apiClient);
839
+ scheduleRefresh(tokenRefresher);
769
840
  const transport = new StdioServerTransport();
770
841
  await server.connect(transport);
771
842
  log(`MYOB MCP server running with ${String(accounts.length)} connected business(es) and Pattern A selector enforcement`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/myob-mcp",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "MYOB Business MCP server — direct API integration with Alfe OAuth credentials and automatic token refresh",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -19,9 +19,9 @@
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.29.0",
21
21
  "zod": "^4.0.5",
22
- "@alfe.ai/agent-api-client": "0.14.0",
23
- "@alfe.ai/config": "0.4.0",
24
- "@alfe.ai/mcp-bundler": "0.4.0"
22
+ "@alfe.ai/agent-api-client": "0.16.0",
23
+ "@alfe.ai/config": "0.4.1",
24
+ "@alfe.ai/mcp-bundler": "0.4.1"
25
25
  },
26
26
  "license": "UNLICENSED",
27
27
  "homepage": "https://alfe.ai",
@@ -37,6 +37,7 @@
37
37
  "scripts": {
38
38
  "build": "tsdown",
39
39
  "dev": "tsdown --watch",
40
+ "test": "vitest run",
40
41
  "typecheck": "tsc --noEmit",
41
42
  "lint": "eslint ."
42
43
  }