@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.
- package/dist/child-shim.cjs +102 -0
- package/dist/child-shim.d.cts +35 -0
- package/dist/child-shim.d.ts +35 -0
- package/dist/child-shim.js +100 -0
- package/dist/server.cjs +77 -21
- package/dist/server.d.cts +22 -3
- package/dist/server.d.ts +22 -3
- package/dist/server.js +77 -22
- package/package.json +2 -2
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
371
|
-
*
|
|
372
|
-
* organisation
|
|
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
|
|
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
|
|
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,
|
|
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"}).
|
|
581
|
-
throw new Error(`Unknown xeroTenantId: ${xeroTenantId}.
|
|
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
|
|
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
|
|
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: [
|
|
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" | "
|
|
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" | "
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
370
|
-
*
|
|
371
|
-
* organisation
|
|
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
|
|
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
|
|
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,
|
|
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"}).
|
|
580
|
-
throw new Error(`Unknown xeroTenantId: ${xeroTenantId}.
|
|
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
|
|
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
|
|
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: [
|
|
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
|
+
"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.
|
|
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
|
},
|