@alfe.ai/myob-mcp 0.3.15 → 0.3.17

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 +177 -72
  3. package/package.json +5 -3
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
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
4
5
  import { resolveConfig } from "@alfe.ai/config";
5
6
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
6
- import { z } from "zod";
7
+ import { assertPatternA } from "@alfe.ai/mcp-bundler";
7
8
  //#region src/myob-client.ts
8
9
  /**
9
10
  * MYOB Business API client — wraps fetch with required headers,
@@ -15,6 +16,7 @@ import { z } from "zod";
15
16
  * - x-myobapi-version: v2
16
17
  */
17
18
  const MYOB_API_BASE = "https://api.myob.com/accountright";
19
+ const MYOB_REQUEST_TIMEOUT_MS = 3e4;
18
20
  var MyobClient = class {
19
21
  accessToken;
20
22
  clientId;
@@ -30,7 +32,7 @@ var MyobClient = class {
30
32
  this.accessToken = accessToken;
31
33
  }
32
34
  get baseUrl() {
33
- return `${MYOB_API_BASE}/${this.businessId}`;
35
+ return `${MYOB_API_BASE}/${encodeURIComponent(this.businessId)}`;
34
36
  }
35
37
  headers() {
36
38
  return {
@@ -62,7 +64,8 @@ var MyobClient = class {
62
64
  const doFetch = async () => {
63
65
  const init = {
64
66
  method,
65
- headers: this.headers()
67
+ headers: this.headers(),
68
+ signal: AbortSignal.timeout(MYOB_REQUEST_TIMEOUT_MS)
66
69
  };
67
70
  if (body !== void 0) init.body = JSON.stringify(body);
68
71
  return fetch(url, init);
@@ -89,13 +92,36 @@ var MyobClient = class {
89
92
  * expects a raw Zod shape (`{ field: z.string() }`), NOT a JSON-Schema
90
93
  * object — passing JSON-Schema throws synchronously at registration time.
91
94
  */
92
- 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
+ };
93
118
  //#endregion
94
119
  //#region src/tools/contacts.ts
95
120
  function registerContactTools(server, resolveClient) {
96
121
  const register = server.registerTool.bind(server);
97
122
  register("myob_list_customers", {
98
123
  description: "List customer contacts from a connected MYOB Business.",
124
+ annotations: readOnlyToolAnnotations,
99
125
  inputSchema: {
100
126
  myobBusinessId: myobBusinessIdField,
101
127
  filter: z.string().optional().describe("OData $filter expression (e.g. \"IsActive eq true\")"),
@@ -116,12 +142,13 @@ function registerContactTools(server, resolveClient) {
116
142
  });
117
143
  register("myob_get_customer", {
118
144
  description: "Get a specific customer contact by UID from a connected MYOB Business.",
145
+ annotations: readOnlyToolAnnotations,
119
146
  inputSchema: {
120
147
  myobBusinessId: myobBusinessIdField,
121
- uid: z.string().describe("The customer UID (GUID)")
148
+ uid: myobResourceUidField.describe("The customer UID (GUID)")
122
149
  }
123
150
  }, async (args) => {
124
- const result = await resolveClient(args.myobBusinessId).get(`/Contact/Customer/${args.uid}`);
151
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Contact/Customer", args.uid));
125
152
  return { content: [{
126
153
  type: "text",
127
154
  text: JSON.stringify(result, null, 2)
@@ -129,6 +156,7 @@ function registerContactTools(server, resolveClient) {
129
156
  });
130
157
  register("myob_create_customer", {
131
158
  description: "Create a new customer contact in a connected MYOB Business.",
159
+ annotations: additiveToolAnnotations,
132
160
  inputSchema: {
133
161
  myobBusinessId: myobBusinessIdField,
134
162
  CompanyName: z.string().optional().describe("Company name"),
@@ -154,13 +182,14 @@ function registerContactTools(server, resolveClient) {
154
182
  });
155
183
  register("myob_update_customer", {
156
184
  description: "Update an existing customer contact in a connected MYOB Business.",
185
+ annotations: updateToolAnnotations,
157
186
  inputSchema: {
158
187
  myobBusinessId: myobBusinessIdField,
159
- uid: z.string().describe("The customer UID (GUID) to update"),
188
+ uid: myobResourceUidField.describe("The customer UID (GUID) to update"),
160
189
  data: z.record(z.string(), z.unknown()).describe("Customer data fields to update")
161
190
  }
162
191
  }, async (args) => {
163
- 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);
164
193
  return { content: [{
165
194
  type: "text",
166
195
  text: JSON.stringify(result, null, 2)
@@ -168,6 +197,7 @@ function registerContactTools(server, resolveClient) {
168
197
  });
169
198
  register("myob_list_suppliers", {
170
199
  description: "List supplier contacts from a connected MYOB Business.",
200
+ annotations: readOnlyToolAnnotations,
171
201
  inputSchema: {
172
202
  myobBusinessId: myobBusinessIdField,
173
203
  filter: z.string().optional().describe("OData $filter expression"),
@@ -188,12 +218,13 @@ function registerContactTools(server, resolveClient) {
188
218
  });
189
219
  register("myob_get_supplier", {
190
220
  description: "Get a specific supplier contact by UID from a connected MYOB Business.",
221
+ annotations: readOnlyToolAnnotations,
191
222
  inputSchema: {
192
223
  myobBusinessId: myobBusinessIdField,
193
- uid: z.string().describe("The supplier UID (GUID)")
224
+ uid: myobResourceUidField.describe("The supplier UID (GUID)")
194
225
  }
195
226
  }, async (args) => {
196
- const result = await resolveClient(args.myobBusinessId).get(`/Contact/Supplier/${args.uid}`);
227
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Contact/Supplier", args.uid));
197
228
  return { content: [{
198
229
  type: "text",
199
230
  text: JSON.stringify(result, null, 2)
@@ -201,6 +232,7 @@ function registerContactTools(server, resolveClient) {
201
232
  });
202
233
  register("myob_create_supplier", {
203
234
  description: "Create a new supplier contact in a connected MYOB Business.",
235
+ annotations: additiveToolAnnotations,
204
236
  inputSchema: {
205
237
  myobBusinessId: myobBusinessIdField,
206
238
  CompanyName: z.string().optional().describe("Company name"),
@@ -231,6 +263,7 @@ function registerSalesTools(server, resolveClient) {
231
263
  const register = server.registerTool.bind(server);
232
264
  register("myob_list_invoices", {
233
265
  description: "List sale invoices from a connected MYOB Business.",
266
+ annotations: readOnlyToolAnnotations,
234
267
  inputSchema: {
235
268
  myobBusinessId: myobBusinessIdField,
236
269
  filter: z.string().optional().describe("OData $filter expression (e.g. \"Status eq 'Open'\")"),
@@ -251,12 +284,13 @@ function registerSalesTools(server, resolveClient) {
251
284
  });
252
285
  register("myob_get_invoice", {
253
286
  description: "Get a specific sale invoice by UID from a connected MYOB Business.",
287
+ annotations: readOnlyToolAnnotations,
254
288
  inputSchema: {
255
289
  myobBusinessId: myobBusinessIdField,
256
- uid: z.string().describe("The invoice UID (GUID)")
290
+ uid: myobResourceUidField.describe("The invoice UID (GUID)")
257
291
  }
258
292
  }, async (args) => {
259
- const result = await resolveClient(args.myobBusinessId).get(`/Sale/Invoice/${args.uid}`);
293
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Sale/Invoice", args.uid));
260
294
  return { content: [{
261
295
  type: "text",
262
296
  text: JSON.stringify(result, null, 2)
@@ -264,6 +298,7 @@ function registerSalesTools(server, resolveClient) {
264
298
  });
265
299
  register("myob_create_invoice", {
266
300
  description: "Create a new sale invoice in a connected MYOB Business. Provide customer, line items, and dates.",
301
+ annotations: additiveToolAnnotations,
267
302
  inputSchema: {
268
303
  myobBusinessId: myobBusinessIdField,
269
304
  data: z.record(z.string(), z.unknown()).describe("Full invoice data object including Customer, Date, Lines, etc.")
@@ -277,6 +312,7 @@ function registerSalesTools(server, resolveClient) {
277
312
  });
278
313
  register("myob_list_quotes", {
279
314
  description: "List sale quotes from a connected MYOB Business.",
315
+ annotations: readOnlyToolAnnotations,
280
316
  inputSchema: {
281
317
  myobBusinessId: myobBusinessIdField,
282
318
  filter: z.string().optional().describe("OData $filter expression"),
@@ -297,6 +333,7 @@ function registerSalesTools(server, resolveClient) {
297
333
  });
298
334
  register("myob_create_quote", {
299
335
  description: "Create a new sale quote in a connected MYOB Business.",
336
+ annotations: additiveToolAnnotations,
300
337
  inputSchema: {
301
338
  myobBusinessId: myobBusinessIdField,
302
339
  data: z.record(z.string(), z.unknown()).describe("Full quote data object including Customer, Date, Lines, etc.")
@@ -315,6 +352,7 @@ function registerPurchaseTools(server, resolveClient) {
315
352
  const register = server.registerTool.bind(server);
316
353
  register("myob_list_bills", {
317
354
  description: "List purchase bills from a connected MYOB Business.",
355
+ annotations: readOnlyToolAnnotations,
318
356
  inputSchema: {
319
357
  myobBusinessId: myobBusinessIdField,
320
358
  filter: z.string().optional().describe("OData $filter expression"),
@@ -335,12 +373,13 @@ function registerPurchaseTools(server, resolveClient) {
335
373
  });
336
374
  register("myob_get_bill", {
337
375
  description: "Get a specific purchase bill by UID from a connected MYOB Business.",
376
+ annotations: readOnlyToolAnnotations,
338
377
  inputSchema: {
339
378
  myobBusinessId: myobBusinessIdField,
340
- uid: z.string().describe("The bill UID (GUID)")
379
+ uid: myobResourceUidField.describe("The bill UID (GUID)")
341
380
  }
342
381
  }, async (args) => {
343
- const result = await resolveClient(args.myobBusinessId).get(`/Purchase/Bill/${args.uid}`);
382
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Purchase/Bill", args.uid));
344
383
  return { content: [{
345
384
  type: "text",
346
385
  text: JSON.stringify(result, null, 2)
@@ -348,6 +387,7 @@ function registerPurchaseTools(server, resolveClient) {
348
387
  });
349
388
  register("myob_create_bill", {
350
389
  description: "Create a new purchase bill in a connected MYOB Business.",
390
+ annotations: additiveToolAnnotations,
351
391
  inputSchema: {
352
392
  myobBusinessId: myobBusinessIdField,
353
393
  data: z.record(z.string(), z.unknown()).describe("Full bill data object including Supplier, Date, Lines, etc.")
@@ -366,6 +406,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
366
406
  const register = server.registerTool.bind(server);
367
407
  register("myob_list_accounts", {
368
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,
369
410
  inputSchema: {
370
411
  myobBusinessId: myobBusinessIdField,
371
412
  filter: z.string().optional().describe("OData $filter expression (e.g. \"Type eq 'Bank'\")"),
@@ -386,12 +427,13 @@ function registerGeneralLedgerTools(server, resolveClient) {
386
427
  });
387
428
  register("myob_get_account", {
388
429
  description: "Get a specific general ledger account by UID from a connected MYOB Business.",
430
+ annotations: readOnlyToolAnnotations,
389
431
  inputSchema: {
390
432
  myobBusinessId: myobBusinessIdField,
391
- uid: z.string().describe("The account UID (GUID)")
433
+ uid: myobResourceUidField.describe("The account UID (GUID)")
392
434
  }
393
435
  }, async (args) => {
394
- const result = await resolveClient(args.myobBusinessId).get(`/GeneralLedger/Account/${args.uid}`);
436
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/GeneralLedger/Account", args.uid));
395
437
  return { content: [{
396
438
  type: "text",
397
439
  text: JSON.stringify(result, null, 2)
@@ -399,6 +441,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
399
441
  });
400
442
  register("myob_list_journal_entries", {
401
443
  description: "List journal transactions from a connected MYOB Business general ledger.",
444
+ annotations: readOnlyToolAnnotations,
402
445
  inputSchema: {
403
446
  myobBusinessId: myobBusinessIdField,
404
447
  filter: z.string().optional().describe("OData $filter expression"),
@@ -419,6 +462,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
419
462
  });
420
463
  register("myob_create_journal_entry", {
421
464
  description: "Create a new journal transaction in a connected MYOB Business.",
465
+ annotations: additiveToolAnnotations,
422
466
  inputSchema: {
423
467
  myobBusinessId: myobBusinessIdField,
424
468
  data: z.record(z.string(), z.unknown()).describe("Full journal transaction data object including Lines, DateOccurred, Memo, etc.")
@@ -432,6 +476,7 @@ function registerGeneralLedgerTools(server, resolveClient) {
432
476
  });
433
477
  register("myob_list_tax_codes", {
434
478
  description: "List tax codes from a connected MYOB Business (read-only for MYOB Essentials).",
479
+ annotations: readOnlyToolAnnotations,
435
480
  inputSchema: {
436
481
  myobBusinessId: myobBusinessIdField,
437
482
  filter: z.string().optional().describe("OData $filter expression")
@@ -453,6 +498,7 @@ function registerInventoryTools(server, resolveClient) {
453
498
  const register = server.registerTool.bind(server);
454
499
  register("myob_list_items", {
455
500
  description: "List inventory items from a connected MYOB Business.",
501
+ annotations: readOnlyToolAnnotations,
456
502
  inputSchema: {
457
503
  myobBusinessId: myobBusinessIdField,
458
504
  filter: z.string().optional().describe("OData $filter expression"),
@@ -473,12 +519,13 @@ function registerInventoryTools(server, resolveClient) {
473
519
  });
474
520
  register("myob_get_item", {
475
521
  description: "Get a specific inventory item by UID from a connected MYOB Business.",
522
+ annotations: readOnlyToolAnnotations,
476
523
  inputSchema: {
477
524
  myobBusinessId: myobBusinessIdField,
478
- uid: z.string().describe("The item UID (GUID)")
525
+ uid: myobResourceUidField.describe("The item UID (GUID)")
479
526
  }
480
527
  }, async (args) => {
481
- const result = await resolveClient(args.myobBusinessId).get(`/Inventory/Item/${args.uid}`);
528
+ const result = await resolveClient(args.myobBusinessId).get(myobResourcePath("/Inventory/Item", args.uid));
482
529
  return { content: [{
483
530
  type: "text",
484
531
  text: JSON.stringify(result, null, 2)
@@ -486,6 +533,7 @@ function registerInventoryTools(server, resolveClient) {
486
533
  });
487
534
  register("myob_create_item", {
488
535
  description: "Create a new inventory item in a connected MYOB Business.",
536
+ annotations: additiveToolAnnotations,
489
537
  inputSchema: {
490
538
  myobBusinessId: myobBusinessIdField,
491
539
  data: z.record(z.string(), z.unknown()).describe("Full item data object including Name, Number, SellingDetails, BuyingDetails, etc.")
@@ -499,13 +547,14 @@ function registerInventoryTools(server, resolveClient) {
499
547
  });
500
548
  register("myob_update_item", {
501
549
  description: "Update an existing inventory item in a connected MYOB Business.",
550
+ annotations: updateToolAnnotations,
502
551
  inputSchema: {
503
552
  myobBusinessId: myobBusinessIdField,
504
- uid: z.string().describe("The item UID (GUID) to update"),
553
+ uid: myobResourceUidField.describe("The item UID (GUID) to update"),
505
554
  data: z.record(z.string(), z.unknown()).describe("Item data fields to update")
506
555
  }
507
556
  }, async (args) => {
508
- 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);
509
558
  return { content: [{
510
559
  type: "text",
511
560
  text: JSON.stringify(result, null, 2)
@@ -518,6 +567,7 @@ function registerPaymentTools(server, resolveClient) {
518
567
  const register = server.registerTool.bind(server);
519
568
  register("myob_list_customer_payments", {
520
569
  description: "List customer payments (receipts) from a connected MYOB Business.",
570
+ annotations: readOnlyToolAnnotations,
521
571
  inputSchema: {
522
572
  myobBusinessId: myobBusinessIdField,
523
573
  filter: z.string().optional().describe("OData $filter expression"),
@@ -538,6 +588,7 @@ function registerPaymentTools(server, resolveClient) {
538
588
  });
539
589
  register("myob_record_customer_payment", {
540
590
  description: "Record a customer payment against invoices in a connected MYOB Business.",
591
+ annotations: additiveToolAnnotations,
541
592
  inputSchema: {
542
593
  myobBusinessId: myobBusinessIdField,
543
594
  data: z.record(z.string(), z.unknown()).describe("Full customer payment data including Customer, Account, Invoices, AmountReceived, etc.")
@@ -551,6 +602,7 @@ function registerPaymentTools(server, resolveClient) {
551
602
  });
552
603
  register("myob_list_supplier_payments", {
553
604
  description: "List supplier payments from a connected MYOB Business.",
605
+ annotations: readOnlyToolAnnotations,
554
606
  inputSchema: {
555
607
  myobBusinessId: myobBusinessIdField,
556
608
  filter: z.string().optional().describe("OData $filter expression"),
@@ -571,6 +623,7 @@ function registerPaymentTools(server, resolveClient) {
571
623
  });
572
624
  register("myob_record_supplier_payment", {
573
625
  description: "Record a supplier payment against bills in a connected MYOB Business.",
626
+ annotations: additiveToolAnnotations,
574
627
  inputSchema: {
575
628
  myobBusinessId: myobBusinessIdField,
576
629
  data: z.record(z.string(), z.unknown()).describe("Full supplier payment data including Supplier, Account, Bills, AmountPaid, etc.")
@@ -584,6 +637,39 @@ function registerPaymentTools(server, resolveClient) {
584
637
  });
585
638
  }
586
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
587
673
  //#region src/server.ts
588
674
  /**
589
675
  * MYOB Business MCP Server (Pattern A multi-account)
@@ -603,67 +689,59 @@ function registerPaymentTools(server, resolveClient) {
603
689
  */
604
690
  const clients = /* @__PURE__ */ new Map();
605
691
  let refreshTimer = null;
606
- let inFlightRefresh = null;
607
692
  const REFRESH_INTERVAL_MS = 900 * 1e3;
608
693
  const REFRESH_RETRY_MS = 60 * 1e3;
609
694
  function log(msg) {
610
695
  process.stderr.write(`[myob-mcp-server] ${msg}\n`);
611
696
  }
612
697
  /**
698
+ * Convert a McpServer.registerTool Zod raw shape into the JSON-Schema-ish
699
+ * {@link ValidatableTool} the Pattern A validator inspects. Direct-MCP
700
+ * servers register Zod shapes (not JSON Schema), so the guard maps each
701
+ * property's presence / optionality / string-ness across.
702
+ */
703
+ function zodShapeToValidatable(name, shape) {
704
+ const properties = {};
705
+ const required = [];
706
+ for (const [key, schema] of Object.entries(shape ?? {})) {
707
+ properties[key] = { type: schema instanceof z.ZodString ? "string" : "non-string" };
708
+ if (!schema.safeParse(void 0).success) required.push(key);
709
+ }
710
+ return {
711
+ name,
712
+ parameters: {
713
+ type: "object",
714
+ properties,
715
+ required
716
+ }
717
+ };
718
+ }
719
+ /**
613
720
  * Resolve a `myobBusinessId` selector to its `MyobClient`. Throws if
614
721
  * unknown — the LLM should call `myob_list_businesses` to discover
615
722
  * the valid set. This is the per-call dispatch contract for Pattern A.
616
723
  */
617
724
  function resolveMyobClient(myobBusinessId) {
618
- const c = clients.get(myobBusinessId);
619
- if (!c) throw new Error(`Unknown myobBusinessId: ${myobBusinessId}. Call myob_list_businesses to see the connected businesses on this agent.`);
620
- return c;
621
- }
622
- /**
623
- * Single fan-out point for token refresh. Used by:
624
- * - the scheduled timer (performRefresh)
625
- * - the `myob_refresh_token` tool
626
- * - every cached `MyobClient`'s `onRefresh` callback (on 401 retry)
627
- *
628
- * Concurrent callers share one in-flight `refreshMYOBToken()` request
629
- * via `inFlightRefresh`, and the resolved token is fanned out to every
630
- * cached client so siblings don't re-hit the network on their own 401s.
631
- *
632
- * v1 limitation: `apiClient.refreshMYOBToken()` refreshes the primary
633
- * connection's token only and we fan it across all clients. This works
634
- * as long as MYOB access tokens are interchangeable across businesses
635
- * sharing a partner appId (true today). Per-connection refresh is a v2
636
- * follow-up — see DEVELOPING.md.
637
- */
638
- async function refreshAndFanOut(apiClient) {
639
- if (inFlightRefresh) return inFlightRefresh;
640
- inFlightRefresh = (async () => {
641
- try {
642
- const { accessToken } = await apiClient.refreshMYOBToken();
643
- for (const client of clients.values()) client.updateToken(accessToken);
644
- return accessToken;
645
- } finally {
646
- inFlightRefresh = null;
647
- }
648
- })();
649
- 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;
650
728
  }
651
- async function performRefresh(apiClient) {
729
+ async function performRefresh(tokenRefresher) {
652
730
  log("Refreshing MYOB access tokens across all connected businesses...");
653
- await refreshAndFanOut(apiClient);
654
- log("Token refresh applied to all clients");
731
+ const refreshed = await tokenRefresher.refreshAll();
732
+ log(`Refreshed ${String(refreshed.length)} MYOB business token(s)`);
655
733
  }
656
- function scheduleRefresh(apiClient) {
734
+ function scheduleRefresh(tokenRefresher) {
657
735
  if (refreshTimer) clearTimeout(refreshTimer);
658
736
  refreshTimer = setTimeout(() => {
659
737
  (async () => {
660
738
  try {
661
- await performRefresh(apiClient);
662
- scheduleRefresh(apiClient);
739
+ await performRefresh(tokenRefresher);
740
+ scheduleRefresh(tokenRefresher);
663
741
  } catch (err) {
664
742
  log(`Token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
665
743
  refreshTimer = setTimeout(() => {
666
- scheduleRefresh(apiClient);
744
+ scheduleRefresh(tokenRefresher);
667
745
  }, REFRESH_RETRY_MS);
668
746
  }
669
747
  })();
@@ -677,25 +755,39 @@ async function main() {
677
755
  });
678
756
  const { accounts } = await apiClient.getMYOBAccounts();
679
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);
680
759
  for (const acct of accounts) {
681
760
  if (clients.has(acct.myobBusinessId)) {
682
761
  log(`Duplicate myobBusinessId ${acct.myobBusinessId} returned by getMYOBAccounts() — keeping the first cached client`);
683
762
  continue;
684
763
  }
685
- clients.set(acct.myobBusinessId, new MyobClient({
764
+ const client = new MyobClient({
686
765
  accessToken: acct.accessToken,
687
766
  clientId: acct.clientId,
688
767
  businessId: acct.myobBusinessId,
689
768
  onRefresh: async () => {
690
- return { accessToken: await refreshAndFanOut(apiClient) };
769
+ return { accessToken: await tokenRefresher.refreshBusiness(acct.myobBusinessId) };
691
770
  }
692
- }));
771
+ });
772
+ clients.set(acct.myobBusinessId, {
773
+ accountIdentifier: acct.accountIdentifier,
774
+ displayName: acct.displayName,
775
+ connectedAt: acct.connectedAt,
776
+ client
777
+ });
693
778
  log(`Cached client for business ${acct.myobBusinessId} (${acct.displayName ?? "no display name"})`);
694
779
  }
695
780
  const server = new McpServer({
696
781
  name: "myob-mcp-server",
697
782
  version: "2.0.0"
698
783
  });
784
+ const validatableTools = [];
785
+ const originalRegisterTool = server.registerTool.bind(server);
786
+ server.registerTool = (...args) => {
787
+ const [name, config] = args;
788
+ validatableTools.push(zodShapeToValidatable(name, config?.inputSchema));
789
+ return originalRegisterTool(...args);
790
+ };
699
791
  registerContactTools(server, resolveMyobClient);
700
792
  registerSalesTools(server, resolveMyobClient);
701
793
  registerPurchaseTools(server, resolveMyobClient);
@@ -703,23 +795,32 @@ async function main() {
703
795
  registerInventoryTools(server, resolveMyobClient);
704
796
  registerPaymentTools(server, resolveMyobClient);
705
797
  const registerTool = server.registerTool.bind(server);
706
- 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." }, () => {
707
- const summaries = accounts.map((a) => ({
708
- myobBusinessId: a.myobBusinessId,
709
- displayName: a.displayName,
710
- 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
711
806
  }));
712
807
  return { content: [{
713
808
  type: "text",
714
809
  text: JSON.stringify({ businesses: summaries }, null, 2)
715
810
  }] };
716
811
  });
717
- 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 () => {
718
816
  try {
719
- await performRefresh(apiClient);
817
+ const refreshed = await tokenRefresher.refreshAll();
720
818
  return { content: [{
721
819
  type: "text",
722
- text: JSON.stringify({ refreshed: true })
820
+ text: JSON.stringify({
821
+ refreshed: true,
822
+ businesses: refreshed
823
+ })
723
824
  }] };
724
825
  } catch (err) {
725
826
  return {
@@ -731,7 +832,11 @@ async function main() {
731
832
  };
732
833
  }
733
834
  });
734
- scheduleRefresh(apiClient);
835
+ assertPatternA(validatableTools, {
836
+ selector: "myobBusinessId",
837
+ exempt: ["myob_list_businesses", "myob_refresh_token"]
838
+ });
839
+ scheduleRefresh(tokenRefresher);
735
840
  const transport = new StdioServerTransport();
736
841
  await server.connect(transport);
737
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.15",
3
+ "version": "0.3.17",
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,8 +19,9 @@
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.29.0",
21
21
  "zod": "^4.0.5",
22
- "@alfe.ai/config": "0.3.0",
23
- "@alfe.ai/agent-api-client": "0.13.0"
22
+ "@alfe.ai/agent-api-client": "0.15.0",
23
+ "@alfe.ai/config": "0.4.1",
24
+ "@alfe.ai/mcp-bundler": "0.4.1"
24
25
  },
25
26
  "license": "UNLICENSED",
26
27
  "homepage": "https://alfe.ai",
@@ -36,6 +37,7 @@
36
37
  "scripts": {
37
38
  "build": "tsdown",
38
39
  "dev": "tsdown --watch",
40
+ "test": "vitest run",
39
41
  "typecheck": "tsc --noEmit",
40
42
  "lint": "eslint ."
41
43
  }