@alfe.ai/xero-mcp 0.3.21 → 0.4.1

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,102 @@
1
+ #!/usr/bin/env node
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ let node_fs = require("node:fs");
4
+ let node_module = require("node:module");
5
+ let node_path = require("node:path");
6
+ let node_url = require("node:url");
7
+ //#region src/child-shim.ts
8
+ /**
9
+ * Child bootstrap shim (runs INSIDE the spawned child process).
10
+ *
11
+ * The official `@xeroapi/xero-mcp-server` has no organisation selector: its
12
+ * `MCPXeroClient.updateTenants()` unconditionally assigns
13
+ * `this.tenantId = this.tenants[0].tenantId`, so a bearer token spanning several
14
+ * Xero organisations silently acts on whichever one Xero happens to return
15
+ * first. Every one of the child's tools resolves its organisation through that
16
+ * single field (`xeroClient.tenantId`), so pinning it here bounds the whole
17
+ * catalog.
18
+ *
19
+ * This shim imports the child's client module, re-pins `tenantId` to the
20
+ * explicitly selected organisation after each `updateTenants()`, and only then
21
+ * starts the child's real entrypoint.
22
+ *
23
+ * FAIL-CLOSED: every failure path throws instead of falling through to the
24
+ * child's first-organisation default. A missing selector, an unrecognised
25
+ * module shape, or a selector absent from the grant must stop the child, never
26
+ * silently target another organisation's books. Writing to the wrong company's
27
+ * ledger is the exact risk this boundary exists to prevent.
28
+ */
29
+ const CHILD_PACKAGE = "@xeroapi/xero-mcp-server";
30
+ function isRecord(value) {
31
+ return typeof value === "object" && value !== null;
32
+ }
33
+ function isPatchableClient(value) {
34
+ return isRecord(value) && typeof value.updateTenants === "function";
35
+ }
36
+ /**
37
+ * Resolve the child package root the same way the parent proxy does
38
+ * (`require.resolve` + `realpathSync`) so this shim and the child's own
39
+ * internal relative imports land on one module instance — and therefore one
40
+ * `xeroClient` singleton. A divergent specifier would yield a second, unpatched
41
+ * instance and defeat the pin.
42
+ */
43
+ function resolveChildRoot() {
44
+ return (0, node_fs.realpathSync)((0, node_path.dirname)((0, node_module.createRequire)(require("url").pathToFileURL(__filename).href).resolve(`${CHILD_PACKAGE}/package.json`)));
45
+ }
46
+ function childModuleUrl(root, relativePath) {
47
+ return (0, node_url.pathToFileURL)((0, node_path.join)(root, relativePath)).href;
48
+ }
49
+ /**
50
+ * Compared case-insensitively to match `validateXeroTenantId` in `boundary.ts`,
51
+ * which lowercases. Xero's `/connections` body is untouched by the child, so a
52
+ * non-lowercase UUID would otherwise verify in the parent and throw here.
53
+ */
54
+ function assertTenantInGrant(tenants, selectedTenantId) {
55
+ const list = Array.isArray(tenants) ? tenants : [];
56
+ const wanted = selectedTenantId.toLowerCase();
57
+ if (!list.some((entry) => isRecord(entry) && typeof entry.tenantId === "string" && entry.tenantId.toLowerCase() === wanted)) throw new Error(`Xero grant does not expose organisation ${selectedTenantId}; refusing to start`);
58
+ }
59
+ async function main() {
60
+ const selectedTenantId = process.env.XERO_TENANT_ID;
61
+ if (selectedTenantId === void 0 || selectedTenantId === "") throw new Error("XERO_TENANT_ID is required; refusing to start without an explicit organisation");
62
+ const root = resolveChildRoot();
63
+ const clientModule = await import(childModuleUrl(root, "dist/clients/xero-client.js"));
64
+ if (!isRecord(clientModule) || !isPatchableClient(clientModule.xeroClient)) throw new Error(`${CHILD_PACKAGE} did not export a patchable xeroClient; refusing to start`);
65
+ installTenantPin(clientModule.xeroClient, selectedTenantId);
66
+ await import(childModuleUrl(root, "dist/index.js"));
67
+ }
68
+ function installTenantPin(client, selectedTenantId) {
69
+ Object.defineProperty(client, "tenantId", {
70
+ get: () => selectedTenantId,
71
+ set: () => {},
72
+ configurable: false,
73
+ enumerable: true
74
+ });
75
+ const updateTenants = client.updateTenants.bind(client);
76
+ client.updateTenants = async () => {
77
+ const tenants = await updateTenants(false);
78
+ assertTenantInGrant(tenants, selectedTenantId);
79
+ return tenants;
80
+ };
81
+ }
82
+ /**
83
+ * Mirrors `isProcessEntrypoint` in `server.ts` rather than importing it: this
84
+ * module runs inside the spawned child and must not pull the proxy (and its
85
+ * AgentApiClient/config dependencies) in with it. Keeping the import inert also
86
+ * lets the pin helpers be unit-tested without starting a child.
87
+ */
88
+ function isProcessEntrypoint(argvPath, metaUrl) {
89
+ try {
90
+ if (argvPath === void 0 || argvPath === "") return false;
91
+ return (0, node_url.pathToFileURL)((0, node_fs.realpathSync)(argvPath)).href === (0, node_url.pathToFileURL)((0, node_fs.realpathSync)((0, node_url.fileURLToPath)(metaUrl))).href;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+ if (isProcessEntrypoint(process.argv[1], require("url").pathToFileURL(__filename).href)) main().catch((error) => {
97
+ console.error(`xero-mcp child shim: ${error instanceof Error ? error.message : String(error)}`);
98
+ process.exit(1);
99
+ });
100
+ //#endregion
101
+ exports.assertTenantInGrant = assertTenantInGrant;
102
+ exports.installTenantPin = installTenantPin;
@@ -0,0 +1,35 @@
1
+ //#region src/child-shim.d.ts
2
+ /**
3
+ * Child bootstrap shim (runs INSIDE the spawned child process).
4
+ *
5
+ * The official `@xeroapi/xero-mcp-server` has no organisation selector: its
6
+ * `MCPXeroClient.updateTenants()` unconditionally assigns
7
+ * `this.tenantId = this.tenants[0].tenantId`, so a bearer token spanning several
8
+ * Xero organisations silently acts on whichever one Xero happens to return
9
+ * first. Every one of the child's tools resolves its organisation through that
10
+ * single field (`xeroClient.tenantId`), so pinning it here bounds the whole
11
+ * catalog.
12
+ *
13
+ * This shim imports the child's client module, re-pins `tenantId` to the
14
+ * explicitly selected organisation after each `updateTenants()`, and only then
15
+ * starts the child's real entrypoint.
16
+ *
17
+ * FAIL-CLOSED: every failure path throws instead of falling through to the
18
+ * child's first-organisation default. A missing selector, an unrecognised
19
+ * module shape, or a selector absent from the grant must stop the child, never
20
+ * silently target another organisation's books. Writing to the wrong company's
21
+ * ledger is the exact risk this boundary exists to prevent.
22
+ */
23
+ interface PatchableClient {
24
+ tenantId: string;
25
+ updateTenants: (fullOrgDetails?: unknown) => Promise<unknown>;
26
+ }
27
+ /**
28
+ * Compared case-insensitively to match `validateXeroTenantId` in `boundary.ts`,
29
+ * which lowercases. Xero's `/connections` body is untouched by the child, so a
30
+ * non-lowercase UUID would otherwise verify in the parent and throw here.
31
+ */
32
+ declare function assertTenantInGrant(tenants: unknown, selectedTenantId: string): void;
33
+ declare function installTenantPin(client: PatchableClient, selectedTenantId: string): void;
34
+ //#endregion
35
+ export { assertTenantInGrant, installTenantPin };
@@ -0,0 +1,35 @@
1
+ //#region src/child-shim.d.ts
2
+ /**
3
+ * Child bootstrap shim (runs INSIDE the spawned child process).
4
+ *
5
+ * The official `@xeroapi/xero-mcp-server` has no organisation selector: its
6
+ * `MCPXeroClient.updateTenants()` unconditionally assigns
7
+ * `this.tenantId = this.tenants[0].tenantId`, so a bearer token spanning several
8
+ * Xero organisations silently acts on whichever one Xero happens to return
9
+ * first. Every one of the child's tools resolves its organisation through that
10
+ * single field (`xeroClient.tenantId`), so pinning it here bounds the whole
11
+ * catalog.
12
+ *
13
+ * This shim imports the child's client module, re-pins `tenantId` to the
14
+ * explicitly selected organisation after each `updateTenants()`, and only then
15
+ * starts the child's real entrypoint.
16
+ *
17
+ * FAIL-CLOSED: every failure path throws instead of falling through to the
18
+ * child's first-organisation default. A missing selector, an unrecognised
19
+ * module shape, or a selector absent from the grant must stop the child, never
20
+ * silently target another organisation's books. Writing to the wrong company's
21
+ * ledger is the exact risk this boundary exists to prevent.
22
+ */
23
+ interface PatchableClient {
24
+ tenantId: string;
25
+ updateTenants: (fullOrgDetails?: unknown) => Promise<unknown>;
26
+ }
27
+ /**
28
+ * Compared case-insensitively to match `validateXeroTenantId` in `boundary.ts`,
29
+ * which lowercases. Xero's `/connections` body is untouched by the child, so a
30
+ * non-lowercase UUID would otherwise verify in the parent and throw here.
31
+ */
32
+ declare function assertTenantInGrant(tenants: unknown, selectedTenantId: string): void;
33
+ declare function installTenantPin(client: PatchableClient, selectedTenantId: string): void;
34
+ //#endregion
35
+ export { assertTenantInGrant, installTenantPin };
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { realpathSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ //#region src/child-shim.ts
7
+ /**
8
+ * Child bootstrap shim (runs INSIDE the spawned child process).
9
+ *
10
+ * The official `@xeroapi/xero-mcp-server` has no organisation selector: its
11
+ * `MCPXeroClient.updateTenants()` unconditionally assigns
12
+ * `this.tenantId = this.tenants[0].tenantId`, so a bearer token spanning several
13
+ * Xero organisations silently acts on whichever one Xero happens to return
14
+ * first. Every one of the child's tools resolves its organisation through that
15
+ * single field (`xeroClient.tenantId`), so pinning it here bounds the whole
16
+ * catalog.
17
+ *
18
+ * This shim imports the child's client module, re-pins `tenantId` to the
19
+ * explicitly selected organisation after each `updateTenants()`, and only then
20
+ * starts the child's real entrypoint.
21
+ *
22
+ * FAIL-CLOSED: every failure path throws instead of falling through to the
23
+ * child's first-organisation default. A missing selector, an unrecognised
24
+ * module shape, or a selector absent from the grant must stop the child, never
25
+ * silently target another organisation's books. Writing to the wrong company's
26
+ * ledger is the exact risk this boundary exists to prevent.
27
+ */
28
+ const CHILD_PACKAGE = "@xeroapi/xero-mcp-server";
29
+ function isRecord(value) {
30
+ return typeof value === "object" && value !== null;
31
+ }
32
+ function isPatchableClient(value) {
33
+ return isRecord(value) && typeof value.updateTenants === "function";
34
+ }
35
+ /**
36
+ * Resolve the child package root the same way the parent proxy does
37
+ * (`require.resolve` + `realpathSync`) so this shim and the child's own
38
+ * internal relative imports land on one module instance — and therefore one
39
+ * `xeroClient` singleton. A divergent specifier would yield a second, unpatched
40
+ * instance and defeat the pin.
41
+ */
42
+ function resolveChildRoot() {
43
+ return realpathSync(dirname(createRequire(import.meta.url).resolve(`${CHILD_PACKAGE}/package.json`)));
44
+ }
45
+ function childModuleUrl(root, relativePath) {
46
+ return pathToFileURL(join(root, relativePath)).href;
47
+ }
48
+ /**
49
+ * Compared case-insensitively to match `validateXeroTenantId` in `boundary.ts`,
50
+ * which lowercases. Xero's `/connections` body is untouched by the child, so a
51
+ * non-lowercase UUID would otherwise verify in the parent and throw here.
52
+ */
53
+ function assertTenantInGrant(tenants, selectedTenantId) {
54
+ const list = Array.isArray(tenants) ? tenants : [];
55
+ const wanted = selectedTenantId.toLowerCase();
56
+ if (!list.some((entry) => isRecord(entry) && typeof entry.tenantId === "string" && entry.tenantId.toLowerCase() === wanted)) throw new Error(`Xero grant does not expose organisation ${selectedTenantId}; refusing to start`);
57
+ }
58
+ async function main() {
59
+ const selectedTenantId = process.env.XERO_TENANT_ID;
60
+ if (selectedTenantId === void 0 || selectedTenantId === "") throw new Error("XERO_TENANT_ID is required; refusing to start without an explicit organisation");
61
+ const root = resolveChildRoot();
62
+ const clientModule = await import(childModuleUrl(root, "dist/clients/xero-client.js"));
63
+ if (!isRecord(clientModule) || !isPatchableClient(clientModule.xeroClient)) throw new Error(`${CHILD_PACKAGE} did not export a patchable xeroClient; refusing to start`);
64
+ installTenantPin(clientModule.xeroClient, selectedTenantId);
65
+ await import(childModuleUrl(root, "dist/index.js"));
66
+ }
67
+ function installTenantPin(client, selectedTenantId) {
68
+ Object.defineProperty(client, "tenantId", {
69
+ get: () => selectedTenantId,
70
+ set: () => {},
71
+ configurable: false,
72
+ enumerable: true
73
+ });
74
+ const updateTenants = client.updateTenants.bind(client);
75
+ client.updateTenants = async () => {
76
+ const tenants = await updateTenants(false);
77
+ assertTenantInGrant(tenants, selectedTenantId);
78
+ return tenants;
79
+ };
80
+ }
81
+ /**
82
+ * Mirrors `isProcessEntrypoint` in `server.ts` rather than importing it: this
83
+ * module runs inside the spawned child and must not pull the proxy (and its
84
+ * AgentApiClient/config dependencies) in with it. Keeping the import inert also
85
+ * lets the pin helpers be unit-tested without starting a child.
86
+ */
87
+ function isProcessEntrypoint(argvPath, metaUrl) {
88
+ try {
89
+ if (argvPath === void 0 || argvPath === "") return false;
90
+ return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(fileURLToPath(metaUrl))).href;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ if (isProcessEntrypoint(process.argv[1], import.meta.url)) main().catch((error) => {
96
+ console.error(`xero-mcp child shim: ${error instanceof Error ? error.message : String(error)}`);
97
+ process.exit(1);
98
+ });
99
+ //#endregion
100
+ export { assertTenantInGrant, installTenantPin };
package/dist/server.cjs CHANGED
@@ -51,8 +51,9 @@ const CHILD_ENV_ALLOWLIST = [
51
51
  "https_proxy",
52
52
  "no_proxy"
53
53
  ];
54
- function buildChildEnvironment(accessToken, source = process.env) {
54
+ function buildChildEnvironment(accessToken, xeroTenantId, source = process.env) {
55
55
  const token = validateAccessToken(accessToken, false);
56
+ const selectedTenantId = validateXeroTenantId(xeroTenantId);
56
57
  const environment = Object.create(null);
57
58
  for (const name of CHILD_ENV_ALLOWLIST) {
58
59
  const value = source[name];
@@ -60,7 +61,8 @@ function buildChildEnvironment(accessToken, source = process.env) {
60
61
  }
61
62
  return {
62
63
  ...environment,
63
- XERO_CLIENT_BEARER_TOKEN: token
64
+ XERO_CLIENT_BEARER_TOKEN: token,
65
+ XERO_TENANT_ID: selectedTenantId
64
66
  };
65
67
  }
66
68
  function normalizeXeroAccount(value) {
@@ -176,10 +178,10 @@ async function verifyXeroTenantAccess(accessToken, xeroTenantId, fetchFn = fetch
176
178
  throw new Error("Xero connections response was invalid JSON");
177
179
  }
178
180
  if (!Array.isArray(parsed) || parsed.length > 50) throw new Error("Xero connections response was invalid");
179
- if (parsed.length !== 1) return "ambiguous";
180
- const only = parsed[0];
181
- if (!isRecord(only)) throw new Error("Xero connection entry was invalid");
182
- return validateXeroTenantId(only.tenantId) === selectedTenantId ? "verified" : "mismatch";
181
+ return parsed.map((entry) => {
182
+ if (!isRecord(entry)) throw new Error("Xero connection entry was invalid");
183
+ return validateXeroTenantId(entry.tenantId);
184
+ }).includes(selectedTenantId) ? "verified" : "mismatch";
183
185
  }
184
186
  function snapshotToolCatalog(tools) {
185
187
  return tools.map((tool) => structuredClone(tool));
@@ -367,9 +369,14 @@ function isRecord(value) {
367
369
  * Xero MCP proxy (Pattern A multi-connection boundary).
368
370
  *
369
371
  * The official child chooses the first organisation returned by Xero and does
370
- * not consume XERO_TENANT_ID. This proxy therefore starts a child only after a
371
- * bounded /connections preflight proves that the token exposes exactly one
372
- * organisation and that it equals the explicit xeroTenantId selector.
372
+ * not consume XERO_TENANT_ID. Children are therefore launched through
373
+ * `child-shim.ts`, which pins the child's `tenantId` to the explicitly selected
374
+ * organisation before starting it, and fails closed if it cannot.
375
+ *
376
+ * One OAuth grant may cover several organisations, so the proxy runs one child
377
+ * per selected organisation off a single shared token, keyed by xeroTenantId. A
378
+ * bounded /connections preflight proves the selected organisation is still in
379
+ * the grant before spawning; the first entry is never assumed.
373
380
  */
374
381
  const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
375
382
  const CHILD_PACKAGE = "@xeroapi/xero-mcp-server";
@@ -380,6 +387,23 @@ const SERVER_VERSION = validateSemver("xero-mcp package version", packageMetadat
380
387
  const CHILD_VERSION = validateExactChildVersion(packageMetadata.dependencies?.[CHILD_PACKAGE], childMetadata.version);
381
388
  const CHILD_BIN_PATH = resolveChildBin(childPackagePath, childMetadata.bin);
382
389
  const CHILD_PACKAGE_ROOT = (0, node_fs.realpathSync)((0, node_path.dirname)(childPackagePath));
390
+ /**
391
+ * The child is launched through our own shim rather than `CHILD_BIN_PATH`
392
+ * directly: the shim pins the organisation before starting the child's real
393
+ * entrypoint (see `child-shim.ts`). It is emitted alongside this module, so it
394
+ * resolves relative to the built file.
395
+ */
396
+ const CHILD_SHIM_PATH = (0, node_url.fileURLToPath)(new URL("./child-shim.js", require("url").pathToFileURL(__filename).href));
397
+ /**
398
+ * Ceiling on concurrently-running child processes, independent of
399
+ * `MAX_XERO_ACCOUNTS` (which bounds the accounts payload). Since one grant can
400
+ * now cover many organisations and each gets its own child, this bounds memory
401
+ * on the agent VM: a warmed child costs roughly 125 MB RSS, so an unbounded
402
+ * fan-out would OOM a 4 GB box that is also running the runtime and other MCP
403
+ * servers. Organisations beyond the ceiling are reported as `child_limit` by
404
+ * `xero_list_accounts` rather than dropped silently.
405
+ */
406
+ const MAX_CONCURRENT_CHILDREN = 8;
383
407
  const CHILD_START_TIMEOUT_MS = 3e4;
384
408
  const CHILD_CATALOG_TIMEOUT_MS = 15e3;
385
409
  const CHILD_CALL_TIMEOUT_MS = 6e4;
@@ -441,7 +465,7 @@ var XeroRuntime = class {
441
465
  if (Object.keys(prepared.forwarded).length !== 0) throw new Error("xero_refresh_token accepts only xeroTenantId");
442
466
  tenant = this.resolveTenant(prepared.xeroTenantId);
443
467
  } catch (error) {
444
- return publicErrorResult(error);
468
+ return this.tenantSelectionErrorResult(error);
445
469
  }
446
470
  try {
447
471
  await this.refreshTenant(tenant.xeroTenantId);
@@ -461,7 +485,7 @@ var XeroRuntime = class {
461
485
  tenant = this.resolveTenant(prepared.xeroTenantId);
462
486
  forwarded = prepared.forwarded;
463
487
  } catch (error) {
464
- return publicErrorResult(error);
488
+ return this.tenantSelectionErrorResult(error);
465
489
  }
466
490
  let result;
467
491
  try {
@@ -530,6 +554,11 @@ var XeroRuntime = class {
530
554
  this.allAccountsSnapshot.push(summary(account, false, "missing_access_token"));
531
555
  continue;
532
556
  }
557
+ if (this.tenants.size >= MAX_CONCURRENT_CHILDREN) {
558
+ log(`Child ceiling of ${String(MAX_CONCURRENT_CHILDREN)} reached; skipping Xero connection ${connectionLogId(account.connectionId)}`);
559
+ this.allAccountsSnapshot.push(summary(account, false, "child_limit"));
560
+ continue;
561
+ }
533
562
  const controller = new AbortController();
534
563
  let verification;
535
564
  try {
@@ -541,11 +570,11 @@ var XeroRuntime = class {
541
570
  continue;
542
571
  }
543
572
  if (verification !== "verified") {
544
- this.allAccountsSnapshot.push(summary(account, false, verification === "ambiguous" ? "ambiguous_token" : "tenant_mismatch"));
573
+ this.allAccountsSnapshot.push(summary(account, false, "tenant_mismatch"));
545
574
  continue;
546
575
  }
547
576
  try {
548
- const client = await withDeadline(this.spawnChild(account.accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero child startup");
577
+ const client = await withDeadline(this.spawnChild(account.accessToken, account.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero child startup");
549
578
  if (this.closed) {
550
579
  await closeChild(client);
551
580
  throw new Error("Xero runtime closed during child startup");
@@ -577,8 +606,34 @@ var XeroRuntime = class {
577
606
  const tenant = this.tenants.get(xeroTenantId);
578
607
  if (tenant !== void 0) return tenant;
579
608
  const known = this.allAccountsSnapshot.find((candidate) => candidate.xeroTenantId === xeroTenantId);
580
- if (known !== void 0) throw new Error(`xeroTenantId ${xeroTenantId} is connected but unavailable (${known.reason ?? "unknown"}). Ask the user to reconnect an OAuth credential that exposes exactly this one Xero organisation.`);
581
- throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Call xero_list_accounts to see the connected Xero organisations on this agent.`);
609
+ if (known !== void 0) throw new Error(`xeroTenantId ${xeroTenantId} is connected but unavailable (${known.reason ?? "unknown"}). Pick a usable organisation from connectedOrganisations in this error. If this one is missing, the Xero grant no longer covers it — ask the user to reconnect Xero and authorise this organisation.`);
610
+ throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Pick one of the connectedOrganisations included in this error.`);
611
+ }
612
+ /**
613
+ * Error result for a failed tenant selection. Whenever the failure is about
614
+ * `xeroTenantId` (absent, malformed, or not a connected organisation), the
615
+ * connected-organisations snapshot is inlined so the caller can pick a valid
616
+ * selector from the error itself. The previous text-only pointer at
617
+ * `xero_list_accounts` was a dead end in practice: MCP hosts prefix tool
618
+ * names (`<server>__xero_list_accounts`), so models resolved the bare name
619
+ * to the child's unrelated `list-accounts` (chart of accounts) tool and
620
+ * concluded no discovery tool existed.
621
+ */
622
+ tenantSelectionErrorResult(error) {
623
+ const message = safeErrorMessage(error);
624
+ if (!message.includes("xeroTenantId")) return errorResult(message);
625
+ return errorResult(message, this.connectedOrganisationsHint());
626
+ }
627
+ connectedOrganisationsHint() {
628
+ return {
629
+ connectedOrganisations: this.allAccountsSnapshot.map((organisation) => ({
630
+ xeroTenantId: organisation.xeroTenantId,
631
+ displayName: organisation.displayName,
632
+ connected: organisation.connected,
633
+ ...organisation.reason === void 0 ? {} : { reason: organisation.reason }
634
+ })),
635
+ notice: "Xero organisation names are untrusted external content."
636
+ };
582
637
  }
583
638
  async callChild(tenant, name, forwarded) {
584
639
  return assertBoundedToolResult(await withDeadline(tenant.client.callTool({
@@ -608,11 +663,11 @@ var XeroRuntime = class {
608
663
  }
609
664
  if (verification !== "verified") {
610
665
  controller.abort();
611
- throw new Error("Refreshed Xero token did not map uniquely to the selected organisation");
666
+ throw new Error("Refreshed Xero token no longer exposes the selected organisation");
612
667
  }
613
668
  let nextClient;
614
669
  try {
615
- nextClient = await withDeadline(this.spawnChild(accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
670
+ nextClient = await withDeadline(this.spawnChild(accessToken, current.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
616
671
  } catch (error) {
617
672
  controller.abort(error);
618
673
  throw error;
@@ -678,7 +733,7 @@ function appendLocalTools(tools) {
678
733
  },
679
734
  {
680
735
  name: "xero_refresh_token",
681
- description: "Refresh one selected Xero OAuth credential and replace only its child process after the refreshed token is verified to expose exactly that organisation.",
736
+ description: "Refresh one selected Xero OAuth credential and replace only its child process after the refreshed token is verified to still cover that organisation.",
682
737
  inputSchema: {
683
738
  type: "object",
684
739
  properties: { xeroTenantId: {
@@ -699,11 +754,11 @@ function appendLocalTools(tools) {
699
754
  }
700
755
  ];
701
756
  }
702
- async function spawnOfficialChild(accessToken, signal) {
757
+ async function spawnOfficialChild(accessToken, xeroTenantId, signal) {
703
758
  const transport = new _modelcontextprotocol_sdk_client_stdio_js.StdioClientTransport({
704
759
  command: process.execPath,
705
- args: [CHILD_BIN_PATH],
706
- env: buildChildEnvironment(accessToken),
760
+ args: [CHILD_SHIM_PATH],
761
+ env: buildChildEnvironment(accessToken, xeroTenantId),
707
762
  cwd: CHILD_PACKAGE_ROOT,
708
763
  stderr: "ignore"
709
764
  });
@@ -848,6 +903,7 @@ if (isProcessEntrypoint(process.argv[1], require("url").pathToFileURL(__filename
848
903
  //#endregion
849
904
  exports.CHILD_BIN_PATH = CHILD_BIN_PATH;
850
905
  exports.CHILD_PACKAGE_ROOT = CHILD_PACKAGE_ROOT;
906
+ exports.CHILD_SHIM_PATH = CHILD_SHIM_PATH;
851
907
  exports.CHILD_VERSION = CHILD_VERSION;
852
908
  exports.SERVER_VERSION = SERVER_VERSION;
853
909
  exports.XeroRuntime = XeroRuntime;
package/dist/server.d.cts CHANGED
@@ -29,7 +29,7 @@ interface ProxiedTool {
29
29
  annotations?: ChildToolDescriptor["annotations"];
30
30
  execution?: ChildToolDescriptor["execution"];
31
31
  }
32
- type XeroTenantVerification = "verified" | "ambiguous" | "mismatch";
32
+ type XeroTenantVerification = "verified" | "mismatch";
33
33
  type JsonPrimitive = null | boolean | number | string;
34
34
  type JsonValue = JsonPrimitive | JsonValue[] | {
35
35
  [key: string]: JsonValue;
@@ -40,6 +40,13 @@ declare const SERVER_VERSION: string;
40
40
  declare const CHILD_VERSION: string;
41
41
  declare const CHILD_BIN_PATH: string;
42
42
  declare const CHILD_PACKAGE_ROOT: string;
43
+ /**
44
+ * The child is launched through our own shim rather than `CHILD_BIN_PATH`
45
+ * directly: the shim pins the organisation before starting the child's real
46
+ * entrypoint (see `child-shim.ts`). It is emitted alongside this module, so it
47
+ * resolves relative to the built file.
48
+ */
49
+ declare const CHILD_SHIM_PATH: string;
43
50
  interface XeroApiClient {
44
51
  getXeroAccounts(): Promise<unknown>;
45
52
  refreshXeroAccountToken(accountIdentifier: string): Promise<unknown>;
@@ -55,7 +62,7 @@ interface XeroChildClient {
55
62
  close(): Promise<void>;
56
63
  }
57
64
  interface XeroProxyOptions {
58
- spawnChild?: (accessToken: string, signal: AbortSignal) => Promise<XeroChildClient>;
65
+ spawnChild?: (accessToken: string, xeroTenantId: string, signal: AbortSignal) => Promise<XeroChildClient>;
59
66
  verifyTenantAccess?: (accessToken: string, xeroTenantId: string, signal: AbortSignal) => Promise<XeroTenantVerification>;
60
67
  scheduleRefresh?: boolean;
61
68
  }
@@ -87,6 +94,18 @@ declare class XeroRuntime {
87
94
  private loadAccounts;
88
95
  private discoverChildCatalog;
89
96
  private resolveTenant;
97
+ /**
98
+ * Error result for a failed tenant selection. Whenever the failure is about
99
+ * `xeroTenantId` (absent, malformed, or not a connected organisation), the
100
+ * connected-organisations snapshot is inlined so the caller can pick a valid
101
+ * selector from the error itself. The previous text-only pointer at
102
+ * `xero_list_accounts` was a dead end in practice: MCP hosts prefix tool
103
+ * names (`<server>__xero_list_accounts`), so models resolved the bare name
104
+ * to the child's unrelated `list-accounts` (chart of accounts) tool and
105
+ * concluded no discovery tool existed.
106
+ */
107
+ private tenantSelectionErrorResult;
108
+ private connectedOrganisationsHint;
90
109
  private callChild;
91
110
  private refreshTenant;
92
111
  private refreshTenantInner;
@@ -105,4 +124,4 @@ interface RunningXeroProxy {
105
124
  declare function startServer(apiClient?: XeroApiClient, options?: XeroProxyOptions): Promise<RunningXeroProxy>;
106
125
  declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
107
126
  //#endregion
108
- export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, RunningXeroProxy, SERVER_VERSION, XeroApiClient, XeroChildClient, XeroProxyOptions, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
127
+ export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_SHIM_PATH, CHILD_VERSION, RunningXeroProxy, SERVER_VERSION, XeroApiClient, XeroChildClient, XeroProxyOptions, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
package/dist/server.d.ts CHANGED
@@ -29,7 +29,7 @@ interface ProxiedTool {
29
29
  annotations?: ChildToolDescriptor["annotations"];
30
30
  execution?: ChildToolDescriptor["execution"];
31
31
  }
32
- type XeroTenantVerification = "verified" | "ambiguous" | "mismatch";
32
+ type XeroTenantVerification = "verified" | "mismatch";
33
33
  type JsonPrimitive = null | boolean | number | string;
34
34
  type JsonValue = JsonPrimitive | JsonValue[] | {
35
35
  [key: string]: JsonValue;
@@ -40,6 +40,13 @@ declare const SERVER_VERSION: string;
40
40
  declare const CHILD_VERSION: string;
41
41
  declare const CHILD_BIN_PATH: string;
42
42
  declare const CHILD_PACKAGE_ROOT: string;
43
+ /**
44
+ * The child is launched through our own shim rather than `CHILD_BIN_PATH`
45
+ * directly: the shim pins the organisation before starting the child's real
46
+ * entrypoint (see `child-shim.ts`). It is emitted alongside this module, so it
47
+ * resolves relative to the built file.
48
+ */
49
+ declare const CHILD_SHIM_PATH: string;
43
50
  interface XeroApiClient {
44
51
  getXeroAccounts(): Promise<unknown>;
45
52
  refreshXeroAccountToken(accountIdentifier: string): Promise<unknown>;
@@ -55,7 +62,7 @@ interface XeroChildClient {
55
62
  close(): Promise<void>;
56
63
  }
57
64
  interface XeroProxyOptions {
58
- spawnChild?: (accessToken: string, signal: AbortSignal) => Promise<XeroChildClient>;
65
+ spawnChild?: (accessToken: string, xeroTenantId: string, signal: AbortSignal) => Promise<XeroChildClient>;
59
66
  verifyTenantAccess?: (accessToken: string, xeroTenantId: string, signal: AbortSignal) => Promise<XeroTenantVerification>;
60
67
  scheduleRefresh?: boolean;
61
68
  }
@@ -87,6 +94,18 @@ declare class XeroRuntime {
87
94
  private loadAccounts;
88
95
  private discoverChildCatalog;
89
96
  private resolveTenant;
97
+ /**
98
+ * Error result for a failed tenant selection. Whenever the failure is about
99
+ * `xeroTenantId` (absent, malformed, or not a connected organisation), the
100
+ * connected-organisations snapshot is inlined so the caller can pick a valid
101
+ * selector from the error itself. The previous text-only pointer at
102
+ * `xero_list_accounts` was a dead end in practice: MCP hosts prefix tool
103
+ * names (`<server>__xero_list_accounts`), so models resolved the bare name
104
+ * to the child's unrelated `list-accounts` (chart of accounts) tool and
105
+ * concluded no discovery tool existed.
106
+ */
107
+ private tenantSelectionErrorResult;
108
+ private connectedOrganisationsHint;
90
109
  private callChild;
91
110
  private refreshTenant;
92
111
  private refreshTenantInner;
@@ -105,4 +124,4 @@ interface RunningXeroProxy {
105
124
  declare function startServer(apiClient?: XeroApiClient, options?: XeroProxyOptions): Promise<RunningXeroProxy>;
106
125
  declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
107
126
  //#endregion
108
- export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, RunningXeroProxy, SERVER_VERSION, XeroApiClient, XeroChildClient, XeroProxyOptions, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
127
+ export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_SHIM_PATH, CHILD_VERSION, RunningXeroProxy, SERVER_VERSION, XeroApiClient, XeroChildClient, XeroProxyOptions, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
package/dist/server.js CHANGED
@@ -50,8 +50,9 @@ const CHILD_ENV_ALLOWLIST = [
50
50
  "https_proxy",
51
51
  "no_proxy"
52
52
  ];
53
- function buildChildEnvironment(accessToken, source = process.env) {
53
+ function buildChildEnvironment(accessToken, xeroTenantId, source = process.env) {
54
54
  const token = validateAccessToken(accessToken, false);
55
+ const selectedTenantId = validateXeroTenantId(xeroTenantId);
55
56
  const environment = Object.create(null);
56
57
  for (const name of CHILD_ENV_ALLOWLIST) {
57
58
  const value = source[name];
@@ -59,7 +60,8 @@ function buildChildEnvironment(accessToken, source = process.env) {
59
60
  }
60
61
  return {
61
62
  ...environment,
62
- XERO_CLIENT_BEARER_TOKEN: token
63
+ XERO_CLIENT_BEARER_TOKEN: token,
64
+ XERO_TENANT_ID: selectedTenantId
63
65
  };
64
66
  }
65
67
  function normalizeXeroAccount(value) {
@@ -175,10 +177,10 @@ async function verifyXeroTenantAccess(accessToken, xeroTenantId, fetchFn = fetch
175
177
  throw new Error("Xero connections response was invalid JSON");
176
178
  }
177
179
  if (!Array.isArray(parsed) || parsed.length > 50) throw new Error("Xero connections response was invalid");
178
- if (parsed.length !== 1) return "ambiguous";
179
- const only = parsed[0];
180
- if (!isRecord(only)) throw new Error("Xero connection entry was invalid");
181
- return validateXeroTenantId(only.tenantId) === selectedTenantId ? "verified" : "mismatch";
180
+ return parsed.map((entry) => {
181
+ if (!isRecord(entry)) throw new Error("Xero connection entry was invalid");
182
+ return validateXeroTenantId(entry.tenantId);
183
+ }).includes(selectedTenantId) ? "verified" : "mismatch";
182
184
  }
183
185
  function snapshotToolCatalog(tools) {
184
186
  return tools.map((tool) => structuredClone(tool));
@@ -366,9 +368,14 @@ function isRecord(value) {
366
368
  * Xero MCP proxy (Pattern A multi-connection boundary).
367
369
  *
368
370
  * The official child chooses the first organisation returned by Xero and does
369
- * not consume XERO_TENANT_ID. This proxy therefore starts a child only after a
370
- * bounded /connections preflight proves that the token exposes exactly one
371
- * organisation and that it equals the explicit xeroTenantId selector.
371
+ * not consume XERO_TENANT_ID. Children are therefore launched through
372
+ * `child-shim.ts`, which pins the child's `tenantId` to the explicitly selected
373
+ * organisation before starting it, and fails closed if it cannot.
374
+ *
375
+ * One OAuth grant may cover several organisations, so the proxy runs one child
376
+ * per selected organisation off a single shared token, keyed by xeroTenantId. A
377
+ * bounded /connections preflight proves the selected organisation is still in
378
+ * the grant before spawning; the first entry is never assumed.
372
379
  */
373
380
  const require = createRequire(import.meta.url);
374
381
  const CHILD_PACKAGE = "@xeroapi/xero-mcp-server";
@@ -379,6 +386,23 @@ const SERVER_VERSION = validateSemver("xero-mcp package version", packageMetadat
379
386
  const CHILD_VERSION = validateExactChildVersion(packageMetadata.dependencies?.[CHILD_PACKAGE], childMetadata.version);
380
387
  const CHILD_BIN_PATH = resolveChildBin(childPackagePath, childMetadata.bin);
381
388
  const CHILD_PACKAGE_ROOT = realpathSync(dirname(childPackagePath));
389
+ /**
390
+ * The child is launched through our own shim rather than `CHILD_BIN_PATH`
391
+ * directly: the shim pins the organisation before starting the child's real
392
+ * entrypoint (see `child-shim.ts`). It is emitted alongside this module, so it
393
+ * resolves relative to the built file.
394
+ */
395
+ const CHILD_SHIM_PATH = fileURLToPath(new URL("./child-shim.js", import.meta.url));
396
+ /**
397
+ * Ceiling on concurrently-running child processes, independent of
398
+ * `MAX_XERO_ACCOUNTS` (which bounds the accounts payload). Since one grant can
399
+ * now cover many organisations and each gets its own child, this bounds memory
400
+ * on the agent VM: a warmed child costs roughly 125 MB RSS, so an unbounded
401
+ * fan-out would OOM a 4 GB box that is also running the runtime and other MCP
402
+ * servers. Organisations beyond the ceiling are reported as `child_limit` by
403
+ * `xero_list_accounts` rather than dropped silently.
404
+ */
405
+ const MAX_CONCURRENT_CHILDREN = 8;
382
406
  const CHILD_START_TIMEOUT_MS = 3e4;
383
407
  const CHILD_CATALOG_TIMEOUT_MS = 15e3;
384
408
  const CHILD_CALL_TIMEOUT_MS = 6e4;
@@ -440,7 +464,7 @@ var XeroRuntime = class {
440
464
  if (Object.keys(prepared.forwarded).length !== 0) throw new Error("xero_refresh_token accepts only xeroTenantId");
441
465
  tenant = this.resolveTenant(prepared.xeroTenantId);
442
466
  } catch (error) {
443
- return publicErrorResult(error);
467
+ return this.tenantSelectionErrorResult(error);
444
468
  }
445
469
  try {
446
470
  await this.refreshTenant(tenant.xeroTenantId);
@@ -460,7 +484,7 @@ var XeroRuntime = class {
460
484
  tenant = this.resolveTenant(prepared.xeroTenantId);
461
485
  forwarded = prepared.forwarded;
462
486
  } catch (error) {
463
- return publicErrorResult(error);
487
+ return this.tenantSelectionErrorResult(error);
464
488
  }
465
489
  let result;
466
490
  try {
@@ -529,6 +553,11 @@ var XeroRuntime = class {
529
553
  this.allAccountsSnapshot.push(summary(account, false, "missing_access_token"));
530
554
  continue;
531
555
  }
556
+ if (this.tenants.size >= MAX_CONCURRENT_CHILDREN) {
557
+ log(`Child ceiling of ${String(MAX_CONCURRENT_CHILDREN)} reached; skipping Xero connection ${connectionLogId(account.connectionId)}`);
558
+ this.allAccountsSnapshot.push(summary(account, false, "child_limit"));
559
+ continue;
560
+ }
532
561
  const controller = new AbortController();
533
562
  let verification;
534
563
  try {
@@ -540,11 +569,11 @@ var XeroRuntime = class {
540
569
  continue;
541
570
  }
542
571
  if (verification !== "verified") {
543
- this.allAccountsSnapshot.push(summary(account, false, verification === "ambiguous" ? "ambiguous_token" : "tenant_mismatch"));
572
+ this.allAccountsSnapshot.push(summary(account, false, "tenant_mismatch"));
544
573
  continue;
545
574
  }
546
575
  try {
547
- const client = await withDeadline(this.spawnChild(account.accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero child startup");
576
+ const client = await withDeadline(this.spawnChild(account.accessToken, account.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero child startup");
548
577
  if (this.closed) {
549
578
  await closeChild(client);
550
579
  throw new Error("Xero runtime closed during child startup");
@@ -576,8 +605,34 @@ var XeroRuntime = class {
576
605
  const tenant = this.tenants.get(xeroTenantId);
577
606
  if (tenant !== void 0) return tenant;
578
607
  const known = this.allAccountsSnapshot.find((candidate) => candidate.xeroTenantId === xeroTenantId);
579
- if (known !== void 0) throw new Error(`xeroTenantId ${xeroTenantId} is connected but unavailable (${known.reason ?? "unknown"}). Ask the user to reconnect an OAuth credential that exposes exactly this one Xero organisation.`);
580
- throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Call xero_list_accounts to see the connected Xero organisations on this agent.`);
608
+ if (known !== void 0) throw new Error(`xeroTenantId ${xeroTenantId} is connected but unavailable (${known.reason ?? "unknown"}). Pick a usable organisation from connectedOrganisations in this error. If this one is missing, the Xero grant no longer covers it — ask the user to reconnect Xero and authorise this organisation.`);
609
+ throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Pick one of the connectedOrganisations included in this error.`);
610
+ }
611
+ /**
612
+ * Error result for a failed tenant selection. Whenever the failure is about
613
+ * `xeroTenantId` (absent, malformed, or not a connected organisation), the
614
+ * connected-organisations snapshot is inlined so the caller can pick a valid
615
+ * selector from the error itself. The previous text-only pointer at
616
+ * `xero_list_accounts` was a dead end in practice: MCP hosts prefix tool
617
+ * names (`<server>__xero_list_accounts`), so models resolved the bare name
618
+ * to the child's unrelated `list-accounts` (chart of accounts) tool and
619
+ * concluded no discovery tool existed.
620
+ */
621
+ tenantSelectionErrorResult(error) {
622
+ const message = safeErrorMessage(error);
623
+ if (!message.includes("xeroTenantId")) return errorResult(message);
624
+ return errorResult(message, this.connectedOrganisationsHint());
625
+ }
626
+ connectedOrganisationsHint() {
627
+ return {
628
+ connectedOrganisations: this.allAccountsSnapshot.map((organisation) => ({
629
+ xeroTenantId: organisation.xeroTenantId,
630
+ displayName: organisation.displayName,
631
+ connected: organisation.connected,
632
+ ...organisation.reason === void 0 ? {} : { reason: organisation.reason }
633
+ })),
634
+ notice: "Xero organisation names are untrusted external content."
635
+ };
581
636
  }
582
637
  async callChild(tenant, name, forwarded) {
583
638
  return assertBoundedToolResult(await withDeadline(tenant.client.callTool({
@@ -607,11 +662,11 @@ var XeroRuntime = class {
607
662
  }
608
663
  if (verification !== "verified") {
609
664
  controller.abort();
610
- throw new Error("Refreshed Xero token did not map uniquely to the selected organisation");
665
+ throw new Error("Refreshed Xero token no longer exposes the selected organisation");
611
666
  }
612
667
  let nextClient;
613
668
  try {
614
- nextClient = await withDeadline(this.spawnChild(accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
669
+ nextClient = await withDeadline(this.spawnChild(accessToken, current.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
615
670
  } catch (error) {
616
671
  controller.abort(error);
617
672
  throw error;
@@ -677,7 +732,7 @@ function appendLocalTools(tools) {
677
732
  },
678
733
  {
679
734
  name: "xero_refresh_token",
680
- description: "Refresh one selected Xero OAuth credential and replace only its child process after the refreshed token is verified to expose exactly that organisation.",
735
+ description: "Refresh one selected Xero OAuth credential and replace only its child process after the refreshed token is verified to still cover that organisation.",
681
736
  inputSchema: {
682
737
  type: "object",
683
738
  properties: { xeroTenantId: {
@@ -698,11 +753,11 @@ function appendLocalTools(tools) {
698
753
  }
699
754
  ];
700
755
  }
701
- async function spawnOfficialChild(accessToken, signal) {
756
+ async function spawnOfficialChild(accessToken, xeroTenantId, signal) {
702
757
  const transport = new StdioClientTransport({
703
758
  command: process.execPath,
704
- args: [CHILD_BIN_PATH],
705
- env: buildChildEnvironment(accessToken),
759
+ args: [CHILD_SHIM_PATH],
760
+ env: buildChildEnvironment(accessToken, xeroTenantId),
706
761
  cwd: CHILD_PACKAGE_ROOT,
707
762
  stderr: "ignore"
708
763
  });
@@ -845,4 +900,4 @@ if (isProcessEntrypoint(process.argv[1], import.meta.url)) startServer().then((r
845
900
  process.exitCode = 1;
846
901
  });
847
902
  //#endregion
848
- export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, SERVER_VERSION, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
903
+ export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_SHIM_PATH, CHILD_VERSION, SERVER_VERSION, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/xero-mcp",
3
- "version": "0.3.21",
3
+ "version": "0.4.1",
4
4
  "description": "Xero MCP proxy server — bridges the official @xeroapi/xero-mcp-server with Alfe OAuth credentials and automatic token refresh",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -21,7 +21,7 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.29.0",
23
23
  "@xeroapi/xero-mcp-server": "0.0.14",
24
- "@alfe.ai/agent-api-client": "0.17.1",
24
+ "@alfe.ai/agent-api-client": "0.18.0",
25
25
  "@alfe.ai/config": "0.4.1",
26
26
  "@alfe.ai/mcp-bundler": "0.4.1"
27
27
  },