@alfe.ai/xero-mcp 0.3.21 → 0.4.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,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;
@@ -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,7 +606,7 @@ 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.`);
609
+ if (known !== void 0) throw new Error(`xeroTenantId ${xeroTenantId} is connected but unavailable (${known.reason ?? "unknown"}). Call xero_list_accounts for the organisations that are usable right now. If this one is missing, the Xero grant no longer covers it — ask the user to reconnect Xero and authorise this organisation.`);
581
610
  throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Call xero_list_accounts to see the connected Xero organisations on this agent.`);
582
611
  }
583
612
  async callChild(tenant, name, forwarded) {
@@ -608,11 +637,11 @@ var XeroRuntime = class {
608
637
  }
609
638
  if (verification !== "verified") {
610
639
  controller.abort();
611
- throw new Error("Refreshed Xero token did not map uniquely to the selected organisation");
640
+ throw new Error("Refreshed Xero token no longer exposes the selected organisation");
612
641
  }
613
642
  let nextClient;
614
643
  try {
615
- nextClient = await withDeadline(this.spawnChild(accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
644
+ nextClient = await withDeadline(this.spawnChild(accessToken, current.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
616
645
  } catch (error) {
617
646
  controller.abort(error);
618
647
  throw error;
@@ -678,7 +707,7 @@ function appendLocalTools(tools) {
678
707
  },
679
708
  {
680
709
  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.",
710
+ 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
711
  inputSchema: {
683
712
  type: "object",
684
713
  properties: { xeroTenantId: {
@@ -699,11 +728,11 @@ function appendLocalTools(tools) {
699
728
  }
700
729
  ];
701
730
  }
702
- async function spawnOfficialChild(accessToken, signal) {
731
+ async function spawnOfficialChild(accessToken, xeroTenantId, signal) {
703
732
  const transport = new _modelcontextprotocol_sdk_client_stdio_js.StdioClientTransport({
704
733
  command: process.execPath,
705
- args: [CHILD_BIN_PATH],
706
- env: buildChildEnvironment(accessToken),
734
+ args: [CHILD_SHIM_PATH],
735
+ env: buildChildEnvironment(accessToken, xeroTenantId),
707
736
  cwd: CHILD_PACKAGE_ROOT,
708
737
  stderr: "ignore"
709
738
  });
@@ -848,6 +877,7 @@ if (isProcessEntrypoint(process.argv[1], require("url").pathToFileURL(__filename
848
877
  //#endregion
849
878
  exports.CHILD_BIN_PATH = CHILD_BIN_PATH;
850
879
  exports.CHILD_PACKAGE_ROOT = CHILD_PACKAGE_ROOT;
880
+ exports.CHILD_SHIM_PATH = CHILD_SHIM_PATH;
851
881
  exports.CHILD_VERSION = CHILD_VERSION;
852
882
  exports.SERVER_VERSION = SERVER_VERSION;
853
883
  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
  }
@@ -105,4 +112,4 @@ interface RunningXeroProxy {
105
112
  declare function startServer(apiClient?: XeroApiClient, options?: XeroProxyOptions): Promise<RunningXeroProxy>;
106
113
  declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
107
114
  //#endregion
108
- export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, RunningXeroProxy, SERVER_VERSION, XeroApiClient, XeroChildClient, XeroProxyOptions, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
115
+ 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
  }
@@ -105,4 +112,4 @@ interface RunningXeroProxy {
105
112
  declare function startServer(apiClient?: XeroApiClient, options?: XeroProxyOptions): Promise<RunningXeroProxy>;
106
113
  declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
107
114
  //#endregion
108
- export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, RunningXeroProxy, SERVER_VERSION, XeroApiClient, XeroChildClient, XeroProxyOptions, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
115
+ 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;
@@ -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,7 +605,7 @@ 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.`);
608
+ if (known !== void 0) throw new Error(`xeroTenantId ${xeroTenantId} is connected but unavailable (${known.reason ?? "unknown"}). Call xero_list_accounts for the organisations that are usable right now. If this one is missing, the Xero grant no longer covers it — ask the user to reconnect Xero and authorise this organisation.`);
580
609
  throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Call xero_list_accounts to see the connected Xero organisations on this agent.`);
581
610
  }
582
611
  async callChild(tenant, name, forwarded) {
@@ -607,11 +636,11 @@ var XeroRuntime = class {
607
636
  }
608
637
  if (verification !== "verified") {
609
638
  controller.abort();
610
- throw new Error("Refreshed Xero token did not map uniquely to the selected organisation");
639
+ throw new Error("Refreshed Xero token no longer exposes the selected organisation");
611
640
  }
612
641
  let nextClient;
613
642
  try {
614
- nextClient = await withDeadline(this.spawnChild(accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
643
+ nextClient = await withDeadline(this.spawnChild(accessToken, current.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
615
644
  } catch (error) {
616
645
  controller.abort(error);
617
646
  throw error;
@@ -677,7 +706,7 @@ function appendLocalTools(tools) {
677
706
  },
678
707
  {
679
708
  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.",
709
+ 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
710
  inputSchema: {
682
711
  type: "object",
683
712
  properties: { xeroTenantId: {
@@ -698,11 +727,11 @@ function appendLocalTools(tools) {
698
727
  }
699
728
  ];
700
729
  }
701
- async function spawnOfficialChild(accessToken, signal) {
730
+ async function spawnOfficialChild(accessToken, xeroTenantId, signal) {
702
731
  const transport = new StdioClientTransport({
703
732
  command: process.execPath,
704
- args: [CHILD_BIN_PATH],
705
- env: buildChildEnvironment(accessToken),
733
+ args: [CHILD_SHIM_PATH],
734
+ env: buildChildEnvironment(accessToken, xeroTenantId),
706
735
  cwd: CHILD_PACKAGE_ROOT,
707
736
  stderr: "ignore"
708
737
  });
@@ -845,4 +874,4 @@ if (isProcessEntrypoint(process.argv[1], import.meta.url)) startServer().then((r
845
874
  process.exitCode = 1;
846
875
  });
847
876
  //#endregion
848
- export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, SERVER_VERSION, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };
877
+ 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.0",
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",