@alfe.ai/myob-mcp 0.3.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.
@@ -0,0 +1,49 @@
1
+ //#region src/myob-client.d.ts
2
+ /**
3
+ * MYOB Business API client — wraps fetch with required headers,
4
+ * 401 retry logic, and token refresh.
5
+ *
6
+ * All MYOB API calls require:
7
+ * - Authorization: Bearer {accessToken}
8
+ * - x-myobapi-key: {clientId}
9
+ * - x-myobapi-version: v2
10
+ */
11
+ interface MyobClientConfig {
12
+ accessToken: string;
13
+ clientId: string;
14
+ businessId: string;
15
+ onRefresh: () => Promise<{
16
+ accessToken: string;
17
+ }>;
18
+ }
19
+ declare class MyobClient {
20
+ private accessToken;
21
+ private readonly clientId;
22
+ private readonly businessId;
23
+ private readonly onRefresh;
24
+ constructor(config: MyobClientConfig);
25
+ updateToken(accessToken: string): void;
26
+ private get baseUrl();
27
+ private headers;
28
+ get(path: string, params?: Record<string, string>): Promise<unknown>;
29
+ post(path: string, body: unknown): Promise<unknown>;
30
+ put(path: string, body: unknown): Promise<unknown>;
31
+ delete(path: string): Promise<unknown>;
32
+ private request;
33
+ }
34
+ //#endregion
35
+ //#region src/server.d.ts
36
+
37
+ interface MyobAccountSummary {
38
+ myobBusinessId: string;
39
+ displayName: string | null;
40
+ connectedAt: string;
41
+ }
42
+ /**
43
+ * Resolve a `myobBusinessId` selector to its `MyobClient`. Throws if
44
+ * unknown — the LLM should call `myob_list_businesses` to discover
45
+ * the valid set. This is the per-call dispatch contract for Pattern A.
46
+ */
47
+ declare function resolveMyobClient(myobBusinessId: string): MyobClient;
48
+ //#endregion
49
+ export { MyobAccountSummary, resolveMyobClient };
package/dist/server.js ADDED
@@ -0,0 +1,748 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { resolveConfig } from "@alfe.ai/config";
5
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
6
+ import { z } from "zod";
7
+ //#region src/myob-client.ts
8
+ /**
9
+ * MYOB Business API client — wraps fetch with required headers,
10
+ * 401 retry logic, and token refresh.
11
+ *
12
+ * All MYOB API calls require:
13
+ * - Authorization: Bearer {accessToken}
14
+ * - x-myobapi-key: {clientId}
15
+ * - x-myobapi-version: v2
16
+ */
17
+ const MYOB_API_BASE = "https://api.myob.com/accountright";
18
+ var MyobClient = class {
19
+ accessToken;
20
+ clientId;
21
+ businessId;
22
+ onRefresh;
23
+ constructor(config) {
24
+ this.accessToken = config.accessToken;
25
+ this.clientId = config.clientId;
26
+ this.businessId = config.businessId;
27
+ this.onRefresh = config.onRefresh;
28
+ }
29
+ updateToken(accessToken) {
30
+ this.accessToken = accessToken;
31
+ }
32
+ get baseUrl() {
33
+ return `${MYOB_API_BASE}/${this.businessId}`;
34
+ }
35
+ headers() {
36
+ return {
37
+ "Authorization": `Bearer ${this.accessToken}`,
38
+ "x-myobapi-key": this.clientId,
39
+ "x-myobapi-version": "v2",
40
+ "Content-Type": "application/json",
41
+ "Accept": "application/json"
42
+ };
43
+ }
44
+ async get(path, params) {
45
+ let url = `${this.baseUrl}${path}`;
46
+ if (params) {
47
+ const qs = new URLSearchParams(params).toString();
48
+ url += `?${qs}`;
49
+ }
50
+ return this.request("GET", url);
51
+ }
52
+ async post(path, body) {
53
+ return this.request("POST", `${this.baseUrl}${path}`, body);
54
+ }
55
+ async put(path, body) {
56
+ return this.request("PUT", `${this.baseUrl}${path}`, body);
57
+ }
58
+ async delete(path) {
59
+ return this.request("DELETE", `${this.baseUrl}${path}`);
60
+ }
61
+ async request(method, url, body) {
62
+ const doFetch = async () => {
63
+ const init = {
64
+ method,
65
+ headers: this.headers()
66
+ };
67
+ if (body !== void 0) init.body = JSON.stringify(body);
68
+ return fetch(url, init);
69
+ };
70
+ let response = await doFetch();
71
+ if (response.status === 401) {
72
+ this.accessToken = (await this.onRefresh()).accessToken;
73
+ response = await doFetch();
74
+ }
75
+ if (!response.ok) {
76
+ const errorBody = await response.text();
77
+ throw new Error(`MYOB API ${method} ${url} failed (${String(response.status)}): ${errorBody}`);
78
+ }
79
+ const text = await response.text();
80
+ if (!text) return {};
81
+ return JSON.parse(text);
82
+ }
83
+ };
84
+ //#endregion
85
+ //#region src/tools/shared.ts
86
+ /**
87
+ * Shared `myobBusinessId` selector field. Every credential-touching tool
88
+ * requires it (Pattern A per-call dispatch). `McpServer.registerTool`
89
+ * expects a raw Zod shape (`{ field: z.string() }`), NOT a JSON-Schema
90
+ * object — passing JSON-Schema throws synchronously at registration time.
91
+ */
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.");
93
+ //#endregion
94
+ //#region src/tools/contacts.ts
95
+ function registerContactTools(server, resolveClient) {
96
+ const register = server.registerTool.bind(server);
97
+ register("myob_list_customers", {
98
+ description: "List customer contacts from a connected MYOB Business.",
99
+ inputSchema: {
100
+ myobBusinessId: myobBusinessIdField,
101
+ filter: z.string().optional().describe("OData $filter expression (e.g. \"IsActive eq true\")"),
102
+ top: z.string().optional().describe("Max records to return (default 100)"),
103
+ skip: z.string().optional().describe("Number of records to skip for pagination")
104
+ }
105
+ }, async (args) => {
106
+ const client = resolveClient(args.myobBusinessId);
107
+ const params = {};
108
+ if (args.filter) params.$filter = args.filter;
109
+ if (args.top) params.$top = args.top;
110
+ if (args.skip) params.$skip = args.skip;
111
+ const result = await client.get("/Contact/Customer", Object.keys(params).length > 0 ? params : void 0);
112
+ return { content: [{
113
+ type: "text",
114
+ text: JSON.stringify(result, null, 2)
115
+ }] };
116
+ });
117
+ register("myob_get_customer", {
118
+ description: "Get a specific customer contact by UID from a connected MYOB Business.",
119
+ inputSchema: {
120
+ myobBusinessId: myobBusinessIdField,
121
+ uid: z.string().describe("The customer UID (GUID)")
122
+ }
123
+ }, async (args) => {
124
+ const result = await resolveClient(args.myobBusinessId).get(`/Contact/Customer/${args.uid}`);
125
+ return { content: [{
126
+ type: "text",
127
+ text: JSON.stringify(result, null, 2)
128
+ }] };
129
+ });
130
+ register("myob_create_customer", {
131
+ description: "Create a new customer contact in a connected MYOB Business.",
132
+ inputSchema: {
133
+ myobBusinessId: myobBusinessIdField,
134
+ CompanyName: z.string().optional().describe("Company name"),
135
+ FirstName: z.string().optional().describe("First name"),
136
+ LastName: z.string().optional().describe("Last name"),
137
+ IsIndividual: z.boolean().optional().describe("true for individual, false for company"),
138
+ DisplayID: z.string().optional().describe("Display ID (account number)"),
139
+ data: z.record(z.string(), z.unknown()).optional().describe("Full customer data object — overrides individual fields if provided")
140
+ }
141
+ }, async (args) => {
142
+ const client = resolveClient(args.myobBusinessId);
143
+ const body = args.data ?? {};
144
+ if (args.CompanyName && !body.CompanyName) body.CompanyName = args.CompanyName;
145
+ if (args.FirstName && !body.FirstName) body.FirstName = args.FirstName;
146
+ if (args.LastName && !body.LastName) body.LastName = args.LastName;
147
+ if (args.IsIndividual !== void 0 && body.IsIndividual === void 0) body.IsIndividual = args.IsIndividual;
148
+ if (args.DisplayID && !body.DisplayID) body.DisplayID = args.DisplayID;
149
+ const result = await client.post("/Contact/Customer", body);
150
+ return { content: [{
151
+ type: "text",
152
+ text: JSON.stringify(result, null, 2)
153
+ }] };
154
+ });
155
+ register("myob_update_customer", {
156
+ description: "Update an existing customer contact in a connected MYOB Business.",
157
+ inputSchema: {
158
+ myobBusinessId: myobBusinessIdField,
159
+ uid: z.string().describe("The customer UID (GUID) to update"),
160
+ data: z.record(z.string(), z.unknown()).describe("Customer data fields to update")
161
+ }
162
+ }, async (args) => {
163
+ const result = await resolveClient(args.myobBusinessId).put(`/Contact/Customer/${args.uid}`, args.data);
164
+ return { content: [{
165
+ type: "text",
166
+ text: JSON.stringify(result, null, 2)
167
+ }] };
168
+ });
169
+ register("myob_list_suppliers", {
170
+ description: "List supplier contacts from a connected MYOB Business.",
171
+ inputSchema: {
172
+ myobBusinessId: myobBusinessIdField,
173
+ filter: z.string().optional().describe("OData $filter expression"),
174
+ top: z.string().optional().describe("Max records to return"),
175
+ skip: z.string().optional().describe("Number of records to skip")
176
+ }
177
+ }, async (args) => {
178
+ const client = resolveClient(args.myobBusinessId);
179
+ const params = {};
180
+ if (args.filter) params.$filter = args.filter;
181
+ if (args.top) params.$top = args.top;
182
+ if (args.skip) params.$skip = args.skip;
183
+ const result = await client.get("/Contact/Supplier", Object.keys(params).length > 0 ? params : void 0);
184
+ return { content: [{
185
+ type: "text",
186
+ text: JSON.stringify(result, null, 2)
187
+ }] };
188
+ });
189
+ register("myob_get_supplier", {
190
+ description: "Get a specific supplier contact by UID from a connected MYOB Business.",
191
+ inputSchema: {
192
+ myobBusinessId: myobBusinessIdField,
193
+ uid: z.string().describe("The supplier UID (GUID)")
194
+ }
195
+ }, async (args) => {
196
+ const result = await resolveClient(args.myobBusinessId).get(`/Contact/Supplier/${args.uid}`);
197
+ return { content: [{
198
+ type: "text",
199
+ text: JSON.stringify(result, null, 2)
200
+ }] };
201
+ });
202
+ register("myob_create_supplier", {
203
+ description: "Create a new supplier contact in a connected MYOB Business.",
204
+ inputSchema: {
205
+ myobBusinessId: myobBusinessIdField,
206
+ CompanyName: z.string().optional().describe("Company name"),
207
+ FirstName: z.string().optional().describe("First name"),
208
+ LastName: z.string().optional().describe("Last name"),
209
+ IsIndividual: z.boolean().optional().describe("true for individual, false for company"),
210
+ DisplayID: z.string().optional().describe("Display ID"),
211
+ data: z.record(z.string(), z.unknown()).optional().describe("Full supplier data object")
212
+ }
213
+ }, async (args) => {
214
+ const client = resolveClient(args.myobBusinessId);
215
+ const body = args.data ?? {};
216
+ if (args.CompanyName && !body.CompanyName) body.CompanyName = args.CompanyName;
217
+ if (args.FirstName && !body.FirstName) body.FirstName = args.FirstName;
218
+ if (args.LastName && !body.LastName) body.LastName = args.LastName;
219
+ if (args.IsIndividual !== void 0 && body.IsIndividual === void 0) body.IsIndividual = args.IsIndividual;
220
+ if (args.DisplayID && !body.DisplayID) body.DisplayID = args.DisplayID;
221
+ const result = await client.post("/Contact/Supplier", body);
222
+ return { content: [{
223
+ type: "text",
224
+ text: JSON.stringify(result, null, 2)
225
+ }] };
226
+ });
227
+ }
228
+ //#endregion
229
+ //#region src/tools/sales.ts
230
+ function registerSalesTools(server, resolveClient) {
231
+ const register = server.registerTool.bind(server);
232
+ register("myob_list_invoices", {
233
+ description: "List sale invoices from a connected MYOB Business.",
234
+ inputSchema: {
235
+ myobBusinessId: myobBusinessIdField,
236
+ filter: z.string().optional().describe("OData $filter expression (e.g. \"Status eq 'Open'\")"),
237
+ top: z.string().optional().describe("Max records to return"),
238
+ skip: z.string().optional().describe("Number of records to skip")
239
+ }
240
+ }, async (args) => {
241
+ const client = resolveClient(args.myobBusinessId);
242
+ const params = {};
243
+ if (args.filter) params.$filter = args.filter;
244
+ if (args.top) params.$top = args.top;
245
+ if (args.skip) params.$skip = args.skip;
246
+ const result = await client.get("/Sale/Invoice", Object.keys(params).length > 0 ? params : void 0);
247
+ return { content: [{
248
+ type: "text",
249
+ text: JSON.stringify(result, null, 2)
250
+ }] };
251
+ });
252
+ register("myob_get_invoice", {
253
+ description: "Get a specific sale invoice by UID from a connected MYOB Business.",
254
+ inputSchema: {
255
+ myobBusinessId: myobBusinessIdField,
256
+ uid: z.string().describe("The invoice UID (GUID)")
257
+ }
258
+ }, async (args) => {
259
+ const result = await resolveClient(args.myobBusinessId).get(`/Sale/Invoice/${args.uid}`);
260
+ return { content: [{
261
+ type: "text",
262
+ text: JSON.stringify(result, null, 2)
263
+ }] };
264
+ });
265
+ register("myob_create_invoice", {
266
+ description: "Create a new sale invoice in a connected MYOB Business. Provide customer, line items, and dates.",
267
+ inputSchema: {
268
+ myobBusinessId: myobBusinessIdField,
269
+ data: z.record(z.string(), z.unknown()).describe("Full invoice data object including Customer, Date, Lines, etc.")
270
+ }
271
+ }, async (args) => {
272
+ const result = await resolveClient(args.myobBusinessId).post("/Sale/Invoice", args.data);
273
+ return { content: [{
274
+ type: "text",
275
+ text: JSON.stringify(result, null, 2)
276
+ }] };
277
+ });
278
+ register("myob_list_quotes", {
279
+ description: "List sale quotes from a connected MYOB Business.",
280
+ inputSchema: {
281
+ myobBusinessId: myobBusinessIdField,
282
+ filter: z.string().optional().describe("OData $filter expression"),
283
+ top: z.string().optional().describe("Max records to return"),
284
+ skip: z.string().optional().describe("Number of records to skip")
285
+ }
286
+ }, async (args) => {
287
+ const client = resolveClient(args.myobBusinessId);
288
+ const params = {};
289
+ if (args.filter) params.$filter = args.filter;
290
+ if (args.top) params.$top = args.top;
291
+ if (args.skip) params.$skip = args.skip;
292
+ const result = await client.get("/Sale/Quote", Object.keys(params).length > 0 ? params : void 0);
293
+ return { content: [{
294
+ type: "text",
295
+ text: JSON.stringify(result, null, 2)
296
+ }] };
297
+ });
298
+ register("myob_create_quote", {
299
+ description: "Create a new sale quote in a connected MYOB Business.",
300
+ inputSchema: {
301
+ myobBusinessId: myobBusinessIdField,
302
+ data: z.record(z.string(), z.unknown()).describe("Full quote data object including Customer, Date, Lines, etc.")
303
+ }
304
+ }, async (args) => {
305
+ const result = await resolveClient(args.myobBusinessId).post("/Sale/Quote", args.data);
306
+ return { content: [{
307
+ type: "text",
308
+ text: JSON.stringify(result, null, 2)
309
+ }] };
310
+ });
311
+ }
312
+ //#endregion
313
+ //#region src/tools/purchases.ts
314
+ function registerPurchaseTools(server, resolveClient) {
315
+ const register = server.registerTool.bind(server);
316
+ register("myob_list_bills", {
317
+ description: "List purchase bills from a connected MYOB Business.",
318
+ inputSchema: {
319
+ myobBusinessId: myobBusinessIdField,
320
+ filter: z.string().optional().describe("OData $filter expression"),
321
+ top: z.string().optional().describe("Max records to return"),
322
+ skip: z.string().optional().describe("Number of records to skip")
323
+ }
324
+ }, async (args) => {
325
+ const client = resolveClient(args.myobBusinessId);
326
+ const params = {};
327
+ if (args.filter) params.$filter = args.filter;
328
+ if (args.top) params.$top = args.top;
329
+ if (args.skip) params.$skip = args.skip;
330
+ const result = await client.get("/Purchase/Bill", Object.keys(params).length > 0 ? params : void 0);
331
+ return { content: [{
332
+ type: "text",
333
+ text: JSON.stringify(result, null, 2)
334
+ }] };
335
+ });
336
+ register("myob_get_bill", {
337
+ description: "Get a specific purchase bill by UID from a connected MYOB Business.",
338
+ inputSchema: {
339
+ myobBusinessId: myobBusinessIdField,
340
+ uid: z.string().describe("The bill UID (GUID)")
341
+ }
342
+ }, async (args) => {
343
+ const result = await resolveClient(args.myobBusinessId).get(`/Purchase/Bill/${args.uid}`);
344
+ return { content: [{
345
+ type: "text",
346
+ text: JSON.stringify(result, null, 2)
347
+ }] };
348
+ });
349
+ register("myob_create_bill", {
350
+ description: "Create a new purchase bill in a connected MYOB Business.",
351
+ inputSchema: {
352
+ myobBusinessId: myobBusinessIdField,
353
+ data: z.record(z.string(), z.unknown()).describe("Full bill data object including Supplier, Date, Lines, etc.")
354
+ }
355
+ }, async (args) => {
356
+ const result = await resolveClient(args.myobBusinessId).post("/Purchase/Bill", args.data);
357
+ return { content: [{
358
+ type: "text",
359
+ text: JSON.stringify(result, null, 2)
360
+ }] };
361
+ });
362
+ }
363
+ //#endregion
364
+ //#region src/tools/general-ledger.ts
365
+ function registerGeneralLedgerTools(server, resolveClient) {
366
+ const register = server.registerTool.bind(server);
367
+ register("myob_list_accounts", {
368
+ description: "List chart of accounts (ledger accounts) from a connected MYOB Business. NOT the connected businesses themselves — for that, use myob_list_businesses.",
369
+ inputSchema: {
370
+ myobBusinessId: myobBusinessIdField,
371
+ filter: z.string().optional().describe("OData $filter expression (e.g. \"Type eq 'Bank'\")"),
372
+ top: z.string().optional().describe("Max records to return"),
373
+ skip: z.string().optional().describe("Number of records to skip")
374
+ }
375
+ }, async (args) => {
376
+ const client = resolveClient(args.myobBusinessId);
377
+ const params = {};
378
+ if (args.filter) params.$filter = args.filter;
379
+ if (args.top) params.$top = args.top;
380
+ if (args.skip) params.$skip = args.skip;
381
+ const result = await client.get("/GeneralLedger/Account", Object.keys(params).length > 0 ? params : void 0);
382
+ return { content: [{
383
+ type: "text",
384
+ text: JSON.stringify(result, null, 2)
385
+ }] };
386
+ });
387
+ register("myob_get_account", {
388
+ description: "Get a specific general ledger account by UID from a connected MYOB Business.",
389
+ inputSchema: {
390
+ myobBusinessId: myobBusinessIdField,
391
+ uid: z.string().describe("The account UID (GUID)")
392
+ }
393
+ }, async (args) => {
394
+ const result = await resolveClient(args.myobBusinessId).get(`/GeneralLedger/Account/${args.uid}`);
395
+ return { content: [{
396
+ type: "text",
397
+ text: JSON.stringify(result, null, 2)
398
+ }] };
399
+ });
400
+ register("myob_list_journal_entries", {
401
+ description: "List journal transactions from a connected MYOB Business general ledger.",
402
+ inputSchema: {
403
+ myobBusinessId: myobBusinessIdField,
404
+ filter: z.string().optional().describe("OData $filter expression"),
405
+ top: z.string().optional().describe("Max records to return"),
406
+ skip: z.string().optional().describe("Number of records to skip")
407
+ }
408
+ }, async (args) => {
409
+ const client = resolveClient(args.myobBusinessId);
410
+ const params = {};
411
+ if (args.filter) params.$filter = args.filter;
412
+ if (args.top) params.$top = args.top;
413
+ if (args.skip) params.$skip = args.skip;
414
+ const result = await client.get("/GeneralLedger/JournalTransaction", Object.keys(params).length > 0 ? params : void 0);
415
+ return { content: [{
416
+ type: "text",
417
+ text: JSON.stringify(result, null, 2)
418
+ }] };
419
+ });
420
+ register("myob_create_journal_entry", {
421
+ description: "Create a new journal transaction in a connected MYOB Business.",
422
+ inputSchema: {
423
+ myobBusinessId: myobBusinessIdField,
424
+ data: z.record(z.string(), z.unknown()).describe("Full journal transaction data object including Lines, DateOccurred, Memo, etc.")
425
+ }
426
+ }, async (args) => {
427
+ const result = await resolveClient(args.myobBusinessId).post("/GeneralLedger/JournalTransaction", args.data);
428
+ return { content: [{
429
+ type: "text",
430
+ text: JSON.stringify(result, null, 2)
431
+ }] };
432
+ });
433
+ register("myob_list_tax_codes", {
434
+ description: "List tax codes from a connected MYOB Business (read-only for MYOB Essentials).",
435
+ inputSchema: {
436
+ myobBusinessId: myobBusinessIdField,
437
+ filter: z.string().optional().describe("OData $filter expression")
438
+ }
439
+ }, async (args) => {
440
+ const client = resolveClient(args.myobBusinessId);
441
+ const params = {};
442
+ if (args.filter) params.$filter = args.filter;
443
+ const result = await client.get("/GeneralLedger/TaxCode", Object.keys(params).length > 0 ? params : void 0);
444
+ return { content: [{
445
+ type: "text",
446
+ text: JSON.stringify(result, null, 2)
447
+ }] };
448
+ });
449
+ }
450
+ //#endregion
451
+ //#region src/tools/inventory.ts
452
+ function registerInventoryTools(server, resolveClient) {
453
+ const register = server.registerTool.bind(server);
454
+ register("myob_list_items", {
455
+ description: "List inventory items from a connected MYOB Business.",
456
+ inputSchema: {
457
+ myobBusinessId: myobBusinessIdField,
458
+ filter: z.string().optional().describe("OData $filter expression"),
459
+ top: z.string().optional().describe("Max records to return"),
460
+ skip: z.string().optional().describe("Number of records to skip")
461
+ }
462
+ }, async (args) => {
463
+ const client = resolveClient(args.myobBusinessId);
464
+ const params = {};
465
+ if (args.filter) params.$filter = args.filter;
466
+ if (args.top) params.$top = args.top;
467
+ if (args.skip) params.$skip = args.skip;
468
+ const result = await client.get("/Inventory/Item", Object.keys(params).length > 0 ? params : void 0);
469
+ return { content: [{
470
+ type: "text",
471
+ text: JSON.stringify(result, null, 2)
472
+ }] };
473
+ });
474
+ register("myob_get_item", {
475
+ description: "Get a specific inventory item by UID from a connected MYOB Business.",
476
+ inputSchema: {
477
+ myobBusinessId: myobBusinessIdField,
478
+ uid: z.string().describe("The item UID (GUID)")
479
+ }
480
+ }, async (args) => {
481
+ const result = await resolveClient(args.myobBusinessId).get(`/Inventory/Item/${args.uid}`);
482
+ return { content: [{
483
+ type: "text",
484
+ text: JSON.stringify(result, null, 2)
485
+ }] };
486
+ });
487
+ register("myob_create_item", {
488
+ description: "Create a new inventory item in a connected MYOB Business.",
489
+ inputSchema: {
490
+ myobBusinessId: myobBusinessIdField,
491
+ data: z.record(z.string(), z.unknown()).describe("Full item data object including Name, Number, SellingDetails, BuyingDetails, etc.")
492
+ }
493
+ }, async (args) => {
494
+ const result = await resolveClient(args.myobBusinessId).post("/Inventory/Item", args.data);
495
+ return { content: [{
496
+ type: "text",
497
+ text: JSON.stringify(result, null, 2)
498
+ }] };
499
+ });
500
+ register("myob_update_item", {
501
+ description: "Update an existing inventory item in a connected MYOB Business.",
502
+ inputSchema: {
503
+ myobBusinessId: myobBusinessIdField,
504
+ uid: z.string().describe("The item UID (GUID) to update"),
505
+ data: z.record(z.string(), z.unknown()).describe("Item data fields to update")
506
+ }
507
+ }, async (args) => {
508
+ const result = await resolveClient(args.myobBusinessId).put(`/Inventory/Item/${args.uid}`, args.data);
509
+ return { content: [{
510
+ type: "text",
511
+ text: JSON.stringify(result, null, 2)
512
+ }] };
513
+ });
514
+ }
515
+ //#endregion
516
+ //#region src/tools/payments.ts
517
+ function registerPaymentTools(server, resolveClient) {
518
+ const register = server.registerTool.bind(server);
519
+ register("myob_list_customer_payments", {
520
+ description: "List customer payments (receipts) from a connected MYOB Business.",
521
+ inputSchema: {
522
+ myobBusinessId: myobBusinessIdField,
523
+ filter: z.string().optional().describe("OData $filter expression"),
524
+ top: z.string().optional().describe("Max records to return"),
525
+ skip: z.string().optional().describe("Number of records to skip")
526
+ }
527
+ }, async (args) => {
528
+ const client = resolveClient(args.myobBusinessId);
529
+ const params = {};
530
+ if (args.filter) params.$filter = args.filter;
531
+ if (args.top) params.$top = args.top;
532
+ if (args.skip) params.$skip = args.skip;
533
+ const result = await client.get("/Sale/CustomerPayment", Object.keys(params).length > 0 ? params : void 0);
534
+ return { content: [{
535
+ type: "text",
536
+ text: JSON.stringify(result, null, 2)
537
+ }] };
538
+ });
539
+ register("myob_record_customer_payment", {
540
+ description: "Record a customer payment against invoices in a connected MYOB Business.",
541
+ inputSchema: {
542
+ myobBusinessId: myobBusinessIdField,
543
+ data: z.record(z.string(), z.unknown()).describe("Full customer payment data including Customer, Account, Invoices, AmountReceived, etc.")
544
+ }
545
+ }, async (args) => {
546
+ const result = await resolveClient(args.myobBusinessId).post("/Sale/CustomerPayment", args.data);
547
+ return { content: [{
548
+ type: "text",
549
+ text: JSON.stringify(result, null, 2)
550
+ }] };
551
+ });
552
+ register("myob_list_supplier_payments", {
553
+ description: "List supplier payments from a connected MYOB Business.",
554
+ inputSchema: {
555
+ myobBusinessId: myobBusinessIdField,
556
+ filter: z.string().optional().describe("OData $filter expression"),
557
+ top: z.string().optional().describe("Max records to return"),
558
+ skip: z.string().optional().describe("Number of records to skip")
559
+ }
560
+ }, async (args) => {
561
+ const client = resolveClient(args.myobBusinessId);
562
+ const params = {};
563
+ if (args.filter) params.$filter = args.filter;
564
+ if (args.top) params.$top = args.top;
565
+ if (args.skip) params.$skip = args.skip;
566
+ const result = await client.get("/Purchase/SupplierPayment", Object.keys(params).length > 0 ? params : void 0);
567
+ return { content: [{
568
+ type: "text",
569
+ text: JSON.stringify(result, null, 2)
570
+ }] };
571
+ });
572
+ register("myob_record_supplier_payment", {
573
+ description: "Record a supplier payment against bills in a connected MYOB Business.",
574
+ inputSchema: {
575
+ myobBusinessId: myobBusinessIdField,
576
+ data: z.record(z.string(), z.unknown()).describe("Full supplier payment data including Supplier, Account, Bills, AmountPaid, etc.")
577
+ }
578
+ }, async (args) => {
579
+ const result = await resolveClient(args.myobBusinessId).post("/Purchase/SupplierPayment", args.data);
580
+ return { content: [{
581
+ type: "text",
582
+ text: JSON.stringify(result, null, 2)
583
+ }] };
584
+ });
585
+ }
586
+ //#endregion
587
+ //#region src/server.ts
588
+ /**
589
+ * MYOB Business MCP Server (Pattern A multi-account)
590
+ *
591
+ * Custom MCP server implementing MYOB Business API tools directly
592
+ * (unlike the Xero proxy, which wraps a child MCP). Every tool that
593
+ * touches credentials requires the LLM to pass `myobBusinessId`
594
+ * explicitly — the server keeps one `MyobClient` per connected MYOB
595
+ * business and dispatches per-call.
596
+ *
597
+ * Pattern A locked in PR 7.1 of channels-and-credential-driven-
598
+ * integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
599
+ * contract; `services/connect/DEVELOPING.md` for the provider table.
600
+ *
601
+ * Architecture:
602
+ * OpenClaw ←(stdio)→ this server ←(https)→ MYOB Business API
603
+ */
604
+ const clients = /* @__PURE__ */ new Map();
605
+ let refreshTimer = null;
606
+ let inFlightRefresh = null;
607
+ const REFRESH_INTERVAL_MS = 900 * 1e3;
608
+ const REFRESH_RETRY_MS = 60 * 1e3;
609
+ function log(msg) {
610
+ process.stderr.write(`[myob-mcp-server] ${msg}\n`);
611
+ }
612
+ /**
613
+ * Resolve a `myobBusinessId` selector to its `MyobClient`. Throws if
614
+ * unknown — the LLM should call `myob_list_businesses` to discover
615
+ * the valid set. This is the per-call dispatch contract for Pattern A.
616
+ */
617
+ 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;
650
+ }
651
+ async function performRefresh(apiClient) {
652
+ log("Refreshing MYOB access tokens across all connected businesses...");
653
+ await refreshAndFanOut(apiClient);
654
+ log("Token refresh applied to all clients");
655
+ }
656
+ function scheduleRefresh(apiClient) {
657
+ if (refreshTimer) clearTimeout(refreshTimer);
658
+ refreshTimer = setTimeout(() => {
659
+ (async () => {
660
+ try {
661
+ await performRefresh(apiClient);
662
+ scheduleRefresh(apiClient);
663
+ } catch (err) {
664
+ log(`Token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
665
+ refreshTimer = setTimeout(() => {
666
+ scheduleRefresh(apiClient);
667
+ }, REFRESH_RETRY_MS);
668
+ }
669
+ })();
670
+ }, REFRESH_INTERVAL_MS);
671
+ }
672
+ async function main() {
673
+ const config = resolveConfig();
674
+ const apiClient = new AgentApiClient({
675
+ apiKey: config.apiKey,
676
+ apiUrl: config.apiUrl
677
+ });
678
+ const { accounts } = await apiClient.getMYOBAccounts();
679
+ if (accounts.length === 0) log("No MYOB businesses connected — server will start with myob_list_businesses + myob_refresh_token only");
680
+ for (const acct of accounts) {
681
+ if (clients.has(acct.myobBusinessId)) {
682
+ log(`Duplicate myobBusinessId ${acct.myobBusinessId} returned by getMYOBAccounts() — keeping the first cached client`);
683
+ continue;
684
+ }
685
+ clients.set(acct.myobBusinessId, new MyobClient({
686
+ accessToken: acct.accessToken,
687
+ clientId: acct.clientId,
688
+ businessId: acct.myobBusinessId,
689
+ onRefresh: async () => {
690
+ return { accessToken: await refreshAndFanOut(apiClient) };
691
+ }
692
+ }));
693
+ log(`Cached client for business ${acct.myobBusinessId} (${acct.displayName ?? "no display name"})`);
694
+ }
695
+ const server = new McpServer({
696
+ name: "myob-mcp-server",
697
+ version: "2.0.0"
698
+ });
699
+ registerContactTools(server, resolveMyobClient);
700
+ registerSalesTools(server, resolveMyobClient);
701
+ registerPurchaseTools(server, resolveMyobClient);
702
+ registerGeneralLedgerTools(server, resolveMyobClient);
703
+ registerInventoryTools(server, resolveMyobClient);
704
+ registerPaymentTools(server, resolveMyobClient);
705
+ 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
711
+ }));
712
+ return { content: [{
713
+ type: "text",
714
+ text: JSON.stringify({ businesses: summaries }, null, 2)
715
+ }] };
716
+ });
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 () => {
718
+ try {
719
+ await performRefresh(apiClient);
720
+ return { content: [{
721
+ type: "text",
722
+ text: JSON.stringify({ refreshed: true })
723
+ }] };
724
+ } catch (err) {
725
+ return {
726
+ content: [{
727
+ type: "text",
728
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
729
+ }],
730
+ isError: true
731
+ };
732
+ }
733
+ });
734
+ scheduleRefresh(apiClient);
735
+ const transport = new StdioServerTransport();
736
+ await server.connect(transport);
737
+ log(`MYOB MCP server running with ${String(accounts.length)} connected business(es) and Pattern A selector enforcement`);
738
+ }
739
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
740
+ if (refreshTimer) clearTimeout(refreshTimer);
741
+ process.exit(0);
742
+ });
743
+ main().catch((err) => {
744
+ log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
745
+ process.exit(1);
746
+ });
747
+ //#endregion
748
+ export { resolveMyobClient };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@alfe.ai/myob-mcp",
3
+ "version": "0.3.0",
4
+ "description": "MYOB Business MCP server — direct API integration with Alfe OAuth credentials and automatic token refresh",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "bin": {
8
+ "myob-mcp-server": "./dist/server.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/server.d.ts",
13
+ "import": "./dist/server.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
21
+ "zod": "^4.0.5",
22
+ "@alfe.ai/config": "0.1.0",
23
+ "@alfe.ai/agent-api-client": "0.3.0"
24
+ },
25
+ "license": "UNLICENSED",
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "dev": "tsdown --watch",
29
+ "typecheck": "tsc --noEmit",
30
+ "lint": "eslint ."
31
+ }
32
+ }