@alfe.ai/xero-mcp 0.3.16 → 0.3.18

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/server.js CHANGED
@@ -1,367 +1,848 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { realpathSync } from "node:fs";
4
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
7
+ import { resolveConfig } from "@alfe.ai/config";
8
+ import { assertPatternA } from "@alfe.ai/mcp-bundler";
9
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
11
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
13
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
- import { resolveConfig } from "@alfe.ai/config";
8
- import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
- import { assertPatternA } from "@alfe.ai/mcp-bundler";
10
- //#region src/server.ts
11
- /**
12
- * Xero MCP Proxy Server (Pattern A multi-account)
13
- *
14
- * A thin proxy that fans out to one official `@xeroapi/xero-mcp-server`
15
- * child process per connected Xero tenant. Every credential-touching
16
- * tool requires the LLM to pass a `xeroTenantId` selector arg; the proxy
17
- * strips that arg, dispatches to the right child, and returns the result.
18
- *
19
- * Pattern A locked in PR 7.3 of channels-and-credential-driven-
20
- * integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
21
- * contract; `services/connect/DEVELOPING.md` for the provider table.
22
- *
23
- * Architecture:
24
- * OpenClaw ←(stdio)→ this proxy ←(stdio fan-out)→ N × @xeroapi/xero-mcp-server
25
- *
26
- * Token refresh is per-tenant: Xero access tokens are NOT interchangeable
27
- * across tenants on the same agent (unlike MYOB), so a 401 from one
28
- * tenant refreshes only that tenant's token via
29
- * `apiClient.refreshXeroAccountToken(xeroTenantId)` and respawns only
30
- * that tenant's child. Xero tokens expire every 30 minutes, so the
31
- * scheduled refresh path walks every cached tenant on a 25-minute
32
- * cadence.
33
- *
34
- * Uses the low-level Server class (not McpServer) because child tools
35
- * return JSON Schema objects — McpServer.registerTool requires Zod.
36
- */
37
- const tenants = /* @__PURE__ */ new Map();
38
- /** Full getXeroAccounts() snapshot — includes rows we couldn't spawn. */
39
- const allAccountsSnapshot = [];
40
- /** Cached, selector-injected tool list. */
41
- let cachedTools = [];
42
- let refreshTimer = null;
43
- /**
44
- * Collapse concurrent refreshes on a per-tenant basis. A burst of 401s
45
- * across the same tenant share one in-flight refresh call.
46
- */
47
- const inFlightRefreshByTenant = /* @__PURE__ */ new Map();
48
- const REFRESH_INTERVAL_MS = 1500 * 1e3;
49
- const REFRESH_RETRY_MS = 60 * 1e3;
50
- function log(msg) {
51
- process.stderr.write(`[xero-mcp-proxy] ${msg}\n`);
52
- }
53
- function resolveTenant(xeroTenantId) {
54
- if (!xeroTenantId) throw new Error("Missing required xeroTenantId argument. Call xero_list_accounts to see the connected Xero tenants and pass the xeroTenantId you want to target.");
55
- const t = tenants.get(xeroTenantId);
56
- if (!t) {
57
- const known = allAccountsSnapshot.find((s) => s.xeroTenantId === xeroTenantId);
58
- if (known && !known.connected) throw new Error(`xeroTenantId ${xeroTenantId} is connected on this agent but the proxy could not initialise a child server for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Xero tenant from the dashboard.`);
59
- throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Call xero_list_accounts to see the connected Xero tenants on this agent.`);
14
+ import { createHash } from "node:crypto";
15
+ const MAX_FORWARDED_ARGUMENT_BYTES = 2 * 1024 * 1024;
16
+ const MAX_TOOL_RESULT_BYTES = 5 * 1024 * 1024;
17
+ const MAX_SCHEMA_BYTES = 768 * 1024;
18
+ const MAX_CONNECTIONS_RESPONSE_BYTES = 128 * 1024;
19
+ const MAX_JSON_DEPTH = 16;
20
+ const MAX_JSON_NODES = 1e5;
21
+ const MAX_JSON_ARRAY_ITEMS = 5e3;
22
+ const MAX_ARGUMENT_STRING_CHARS = 15e5;
23
+ const MAX_TOKEN_CHARS = 32768;
24
+ const MAX_ERROR_CHARS = 2048;
25
+ const XERO_CONNECTIONS_TIMEOUT_MS = 15e3;
26
+ const XERO_TENANT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
27
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/u;
28
+ const UNSAFE_KEYS = new Set([
29
+ "__proto__",
30
+ "constructor",
31
+ "prototype"
32
+ ]);
33
+ const RESERVED_TOOLS = new Set(["xero_list_accounts", "xero_refresh_token"]);
34
+ const CHILD_ENV_ALLOWLIST = [
35
+ "HOME",
36
+ "TMPDIR",
37
+ "TMP",
38
+ "TEMP",
39
+ "LANG",
40
+ "LC_ALL",
41
+ "LC_CTYPE",
42
+ "TZ",
43
+ "SSL_CERT_FILE",
44
+ "SSL_CERT_DIR",
45
+ "NODE_EXTRA_CA_CERTS",
46
+ "HTTP_PROXY",
47
+ "HTTPS_PROXY",
48
+ "NO_PROXY",
49
+ "http_proxy",
50
+ "https_proxy",
51
+ "no_proxy"
52
+ ];
53
+ function buildChildEnvironment(accessToken, source = process.env) {
54
+ const token = validateAccessToken(accessToken, false);
55
+ const environment = Object.create(null);
56
+ for (const name of CHILD_ENV_ALLOWLIST) {
57
+ const value = source[name];
58
+ if (value !== void 0 && !containsControlCharacter(value)) environment[name] = value;
60
59
  }
61
- return t;
60
+ return {
61
+ ...environment,
62
+ XERO_CLIENT_BEARER_TOKEN: token
63
+ };
62
64
  }
63
- async function spawnChild(accessToken, xeroTenantId) {
64
- const transport = new StdioClientTransport({
65
- command: "npx",
66
- args: ["-y", "@xeroapi/xero-mcp-server"],
67
- env: {
68
- ...process.env,
69
- XERO_CLIENT_BEARER_TOKEN: accessToken,
70
- XERO_TENANT_ID: xeroTenantId
71
- }
72
- });
73
- const client = new Client({
74
- name: "xero-mcp-proxy",
75
- version: "2.0.0"
76
- });
77
- await client.connect(transport);
78
- return client;
65
+ function normalizeXeroAccount(value) {
66
+ if (!isRecord(value)) throw new Error("Xero account must be an object");
67
+ return {
68
+ connectionId: validateText("connectionId", value.connectionId, 128),
69
+ accountIdentifier: validateText("accountIdentifier", value.accountIdentifier, 512),
70
+ displayName: validateOptionalDisplayText("displayName", value.displayName, 512),
71
+ connectedAt: validateConnectedAt(value.connectedAt),
72
+ accessToken: validateAccessToken(value.accessToken, true),
73
+ xeroTenantId: validateXeroTenantId(value.xeroTenantId)
74
+ };
79
75
  }
80
- async function killAllChildren() {
81
- for (const t of tenants.values()) try {
82
- await t.client.close();
83
- } catch {}
84
- tenants.clear();
76
+ function readXeroAccountsResponse(value) {
77
+ if (!isRecord(value) || !Array.isArray(value.accounts)) throw new Error("Xero accounts response must contain an accounts array");
78
+ if (value.accounts.length > 50) throw new Error(`Xero accounts response exceeds ${String(50)} accounts`);
79
+ return value.accounts;
85
80
  }
86
- /**
87
- * Refresh a specific tenant's access token and atomically swap its
88
- * child server. Concurrent callers for the same tenant share one
89
- * in-flight refresh + respawn.
90
- */
91
- async function refreshTenant(apiClient, xeroTenantId) {
92
- const existing = inFlightRefreshByTenant.get(xeroTenantId);
93
- if (existing) return existing;
94
- const promise = (async () => {
95
- try {
96
- const { accessToken } = await apiClient.refreshXeroAccountToken(xeroTenantId);
97
- const newChild = await spawnChild(accessToken, xeroTenantId);
98
- const old = tenants.get(xeroTenantId);
99
- tenants.set(xeroTenantId, {
100
- xeroTenantId,
101
- displayName: old?.displayName ?? null,
102
- connectedAt: old?.connectedAt ?? "",
103
- accessToken,
104
- client: newChild
105
- });
106
- if (old) try {
107
- await old.client.close();
108
- } catch {}
109
- return accessToken;
110
- } finally {
111
- inFlightRefreshByTenant.delete(xeroTenantId);
112
- }
113
- })();
114
- inFlightRefreshByTenant.set(xeroTenantId, promise);
115
- return promise;
116
- }
117
- function scheduleRefresh(apiClient) {
118
- if (refreshTimer) clearTimeout(refreshTimer);
119
- refreshTimer = setTimeout(() => {
120
- (async () => {
121
- const tenantIds = Array.from(tenants.keys());
122
- if (tenantIds.length === 0) {
123
- refreshTimer = setTimeout(() => {
124
- scheduleRefresh(apiClient);
125
- }, REFRESH_INTERVAL_MS);
126
- return;
127
- }
128
- log(`Refreshing Xero access tokens across ${String(tenantIds.length)} tenant(s)...`);
129
- let anyFailed = false;
130
- for (const tid of tenantIds) try {
131
- await refreshTenant(apiClient, tid);
132
- } catch (err) {
133
- anyFailed = true;
134
- log(`Token refresh failed for tenant ${tid}: ${err instanceof Error ? err.message : String(err)}`);
135
- }
136
- refreshTimer = setTimeout(() => {
137
- scheduleRefresh(apiClient);
138
- }, anyFailed ? REFRESH_RETRY_MS : REFRESH_INTERVAL_MS);
139
- })();
140
- }, REFRESH_INTERVAL_MS);
81
+ function readRefreshedAccessToken(value) {
82
+ if (!isRecord(value)) throw new Error("Xero refresh response must be an object");
83
+ return validateAccessToken(value.accessToken, false);
141
84
  }
142
- /**
143
- * Inject a required `xeroTenantId` string property into a child tool's
144
- * inputSchema. The proxy strips this arg before forwarding to the child.
145
- */
146
85
  function injectTenantSelector(tool) {
147
- const original = tool.inputSchema ?? {};
148
- const originalProperties = original.properties ?? {};
149
- const originalRequired = Array.isArray(original.required) ? original.required : [];
150
- const injectedProperties = {
151
- ...originalProperties,
152
- xeroTenantId: {
153
- type: "string",
154
- description: "Xero tenantId — use the value from xero_list_accounts to pick which connected tenant this call should target."
155
- }
156
- };
157
- const injectedRequired = originalRequired.includes("xeroTenantId") ? originalRequired : ["xeroTenantId", ...originalRequired];
158
- return {
159
- name: tool.name,
160
- description: tool.description,
86
+ if (!isRecord(tool)) throw new Error("Child tool descriptor must be an object");
87
+ const name = validateToolName(tool.name);
88
+ if (RESERVED_TOOLS.has(name)) throw new Error(`Child tool collides with reserved proxy tool: ${name}`);
89
+ const original = cloneJsonRecord("tool input schema", tool.inputSchema, MAX_SCHEMA_BYTES, 5e5);
90
+ if (Object.hasOwn(original, "type") && original.type !== "object") throw new Error("Child tool input schema must describe an object");
91
+ const originalProperties = Object.hasOwn(original, "properties") ? cloneJsonRecord("tool schema properties", original.properties, MAX_SCHEMA_BYTES, 5e5) : Object.create(null);
92
+ const required = new Set(["xeroTenantId", ...validateRequired(original.required)]);
93
+ const result = {
94
+ name,
161
95
  inputSchema: {
162
96
  ...original,
163
- type: original.type ?? "object",
164
- properties: injectedProperties,
165
- required: injectedRequired
97
+ type: "object",
98
+ properties: {
99
+ ...originalProperties,
100
+ xeroTenantId: {
101
+ type: "string",
102
+ pattern: XERO_TENANT_ID_PATTERN.source,
103
+ maxLength: 36,
104
+ description: "Xero organisation tenant ID from xero_list_accounts. This required selector chooses the exact connected OAuth organisation."
105
+ }
106
+ },
107
+ required: [...required]
166
108
  }
167
109
  };
110
+ if (tool.title !== void 0) result.title = validateText("tool title", tool.title, 256);
111
+ if (tool.description !== void 0) result.description = validateDocumentationText("tool description", tool.description, 2e4);
112
+ if (tool.outputSchema !== void 0) result.outputSchema = cloneJsonRecord("tool output schema", tool.outputSchema, MAX_SCHEMA_BYTES, 5e5);
113
+ if (tool.annotations !== void 0) result.annotations = validateAnnotations(tool.annotations);
114
+ if (tool.execution !== void 0) result.execution = validateExecution(tool.execution);
115
+ return result;
168
116
  }
169
- async function main() {
170
- const config = resolveConfig();
171
- const apiClient = new AgentApiClient({
172
- apiKey: config.apiKey,
173
- apiUrl: config.apiUrl
117
+ function validateToolCatalog(tools) {
118
+ if (!Array.isArray(tools)) throw new Error("Child tool catalog must be an array");
119
+ if (tools.length > 150) throw new Error(`Child tool catalog exceeds ${String(150)} tools`);
120
+ const names = /* @__PURE__ */ new Set();
121
+ return tools.map((tool) => {
122
+ const proxied = injectTenantSelector(tool);
123
+ if (names.has(proxied.name)) throw new Error(`Child tool catalog contains duplicate name: ${proxied.name}`);
124
+ names.add(proxied.name);
125
+ return proxied;
174
126
  });
175
- const { accounts } = await apiClient.getXeroAccounts();
176
- if (accounts.length === 0) log("No Xero tenants connected — proxy will start with xero_list_accounts and xero_refresh_token only");
177
- for (const acct of accounts) {
178
- if (!acct.accessToken) {
179
- log(`Skipping tenant ${acct.xeroTenantId} — no access token`);
180
- allAccountsSnapshot.push({
181
- xeroTenantId: acct.xeroTenantId,
182
- displayName: acct.displayName,
183
- connectedAt: acct.connectedAt,
184
- connected: false,
185
- reason: "missing_access_token"
186
- });
187
- continue;
188
- }
189
- if (tenants.has(acct.xeroTenantId)) {
190
- log(`Duplicate xeroTenantId ${acct.xeroTenantId} returned by getXeroAccounts() — keeping the first cached child`);
191
- continue;
192
- }
193
- try {
194
- const client = await spawnChild(acct.accessToken, acct.xeroTenantId);
195
- tenants.set(acct.xeroTenantId, {
196
- xeroTenantId: acct.xeroTenantId,
197
- displayName: acct.displayName,
198
- connectedAt: acct.connectedAt,
199
- accessToken: acct.accessToken,
200
- client
201
- });
202
- allAccountsSnapshot.push({
203
- xeroTenantId: acct.xeroTenantId,
204
- displayName: acct.displayName,
205
- connectedAt: acct.connectedAt,
206
- connected: true
207
- });
208
- log(`Spawned child server for tenant ${acct.xeroTenantId} (${acct.displayName ?? "unnamed"})`);
209
- } catch (err) {
210
- const message = err instanceof Error ? err.message : String(err);
211
- log(`Failed to spawn child for tenant ${acct.xeroTenantId}: ${message}`);
212
- allAccountsSnapshot.push({
213
- xeroTenantId: acct.xeroTenantId,
214
- displayName: acct.displayName,
215
- connectedAt: acct.connectedAt,
216
- connected: false,
217
- reason: `spawn_failed: ${message}`
218
- });
127
+ }
128
+ function prepareForwardedArguments(value) {
129
+ const cloned = cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
130
+ const xeroTenantId = validateXeroTenantId(cloned.xeroTenantId);
131
+ const { xeroTenantId: _selector, ...forwarded } = cloned;
132
+ return {
133
+ xeroTenantId,
134
+ forwarded
135
+ };
136
+ }
137
+ function assertBoundedToolArguments(value) {
138
+ return cloneJsonRecord("tool arguments", value, MAX_FORWARDED_ARGUMENT_BYTES, MAX_ARGUMENT_STRING_CHARS);
139
+ }
140
+ function assertBoundedToolResult(value) {
141
+ return cloneJsonRecord("child tool result", value, MAX_TOOL_RESULT_BYTES, MAX_TOOL_RESULT_BYTES);
142
+ }
143
+ function childResultIsError(value) {
144
+ return value.isError === true;
145
+ }
146
+ function childResultIsUnauthorized(value) {
147
+ if (!childResultIsError(value)) return false;
148
+ const text = extractToolErrorText(value).toLowerCase();
149
+ return text.includes("401") || text.includes("unauthorized") || text.includes("authentication failed");
150
+ }
151
+ function extractToolErrorText(value) {
152
+ if (!Array.isArray(value.content)) return "";
153
+ return value.content.slice(0, 100).map((entry) => isRecord(entry) && typeof entry.text === "string" ? entry.text.slice(0, MAX_ERROR_CHARS) : "").join("\n").slice(0, MAX_ERROR_CHARS);
154
+ }
155
+ async function verifyXeroTenantAccess(accessToken, xeroTenantId, fetchFn = fetch, signal = AbortSignal.timeout(XERO_CONNECTIONS_TIMEOUT_MS)) {
156
+ const token = validateAccessToken(accessToken, false);
157
+ const selectedTenantId = validateXeroTenantId(xeroTenantId);
158
+ const response = await fetchFn("https://api.xero.com/connections", {
159
+ headers: {
160
+ Authorization: `Bearer ${token}`,
161
+ Accept: "application/json"
162
+ },
163
+ redirect: "error",
164
+ signal
165
+ });
166
+ if (!response.ok) {
167
+ await response.body?.cancel().catch(() => void 0);
168
+ throw new Error(`Xero connections request failed with HTTP ${String(response.status)}`);
169
+ }
170
+ const text = await readResponseText(response, MAX_CONNECTIONS_RESPONSE_BYTES);
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(text);
174
+ } catch {
175
+ throw new Error("Xero connections response was invalid JSON");
176
+ }
177
+ 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";
182
+ }
183
+ function snapshotToolCatalog(tools) {
184
+ return tools.map((tool) => structuredClone(tool));
185
+ }
186
+ function safeErrorMessage(error, secrets = []) {
187
+ let message = (error instanceof Error ? error.message : String(error)).slice(0, MAX_ERROR_CHARS);
188
+ for (const secret of secrets) if (secret.length >= 4) message = message.split(secret).join("[REDACTED]");
189
+ return flattenControls(message).replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/giu, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/giu, "https://[REDACTED]@").replace(/\balfe_[A-Za-z0-9_-]{8,}/gu, "[REDACTED]").slice(0, MAX_ERROR_CHARS);
190
+ }
191
+ function connectionLogId(value) {
192
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
193
+ }
194
+ async function withDeadline(promise, timeoutMs, label) {
195
+ let timer;
196
+ try {
197
+ return await Promise.race([promise, new Promise((_resolve, reject) => {
198
+ timer = setTimeout(() => {
199
+ reject(/* @__PURE__ */ new Error(`${label} timed out`));
200
+ }, timeoutMs);
201
+ timer.unref();
202
+ })]);
203
+ } finally {
204
+ if (timer !== void 0) clearTimeout(timer);
205
+ }
206
+ }
207
+ function validateXeroTenantId(value) {
208
+ if (typeof value !== "string" || !XERO_TENANT_ID_PATTERN.test(value)) throw new Error("xeroTenantId must be a canonical Xero organisation UUID");
209
+ return value.toLowerCase();
210
+ }
211
+ function validateAccessToken(value, allowEmpty) {
212
+ if (allowEmpty && value === "") return "";
213
+ if (typeof value !== "string" || value.length < 8 || value.length > MAX_TOKEN_CHARS || value !== value.trim() || containsControlCharacter(value)) throw new Error("Xero access token is invalid");
214
+ return value;
215
+ }
216
+ function validateConnectedAt(value) {
217
+ const checked = validateText("connectedAt", value, 64);
218
+ const parsed = new Date(checked);
219
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== checked) throw new Error("connectedAt must be a canonical ISO date-time");
220
+ return checked;
221
+ }
222
+ function validateToolName(value) {
223
+ if (typeof value !== "string" || !TOOL_NAME_PATTERN.test(value)) throw new Error("Child tool name is invalid");
224
+ return value;
225
+ }
226
+ function validateText(label, value, maxChars) {
227
+ if (typeof value !== "string" || value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
228
+ return value;
229
+ }
230
+ function validateOptionalDisplayText(label, value, maxChars) {
231
+ if (value === null) return null;
232
+ if (typeof value !== "string" || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must be null or at most ${String(maxChars)} non-control characters`);
233
+ return value;
234
+ }
235
+ function validateDocumentationText(label, value, maxChars) {
236
+ if (typeof value !== "string" || value.length < 1 || value.length > maxChars || Array.from(value).some((character) => {
237
+ const codePoint = character.codePointAt(0) ?? 0;
238
+ return codePoint < 32 && character !== "\n" && character !== "\r" && character !== " " || codePoint === 127;
239
+ })) throw new Error(`${label} must contain 1 to ${String(maxChars)} safe characters`);
240
+ return value;
241
+ }
242
+ function validateRequired(value) {
243
+ if (value === void 0) return [];
244
+ if (!Array.isArray(value) || value.length > 150) throw new Error("tool schema required must be an array");
245
+ const result = [];
246
+ for (const entry of value) {
247
+ if (typeof entry !== "string" || entry.length < 1 || entry.length > 128) throw new Error("tool schema required contains an invalid property name");
248
+ if (!result.includes(entry)) result.push(entry);
249
+ }
250
+ return result;
251
+ }
252
+ function validateAnnotations(value) {
253
+ if (!isRecord(value)) throw new Error("tool annotations must be an object");
254
+ const result = {};
255
+ if (value.title !== void 0) result.title = validateText("annotation title", value.title, 256);
256
+ for (const key of [
257
+ "readOnlyHint",
258
+ "destructiveHint",
259
+ "idempotentHint",
260
+ "openWorldHint"
261
+ ]) {
262
+ const hint = value[key];
263
+ if (hint !== void 0) {
264
+ if (typeof hint !== "boolean") throw new Error(`tool annotation ${key} must be boolean`);
265
+ result[key] = hint;
219
266
  }
220
267
  }
221
- const firstTenant = tenants.values().next().value;
222
- if (firstTenant) {
223
- const { tools } = await firstTenant.client.listTools();
224
- cachedTools = tools.map(injectTenantSelector);
225
- log(`Child MCP server provides ${String(cachedTools.length)} tools (selector injected)`);
226
- } else {
227
- cachedTools = [];
228
- log("No child server available — only xero_list_accounts and xero_refresh_token will be exposed");
268
+ return result;
269
+ }
270
+ function validateExecution(value) {
271
+ if (!isRecord(value)) throw new Error("tool execution metadata must be an object");
272
+ const taskSupport = value.taskSupport;
273
+ if (taskSupport !== void 0 && taskSupport !== "optional" && taskSupport !== "required" && taskSupport !== "forbidden") throw new Error("tool execution taskSupport is invalid");
274
+ return taskSupport === void 0 ? {} : { taskSupport };
275
+ }
276
+ async function readResponseText(response, maxBytes) {
277
+ const declared = response.headers.get("content-length");
278
+ if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > maxBytes)) {
279
+ await response.body?.cancel().catch(() => void 0);
280
+ throw new Error("Xero connections response exceeds the byte limit");
229
281
  }
230
- cachedTools.push({
231
- name: "xero_list_accounts",
232
- description: "List the Xero tenants the agent has connected. Returns one entry per OAuth connection — use the returned xeroTenantId values as the `xeroTenantId` selector arg on every other Xero tool.",
233
- inputSchema: {
234
- type: "object",
235
- properties: {}
282
+ if (response.body === null) return "";
283
+ const reader = response.body.getReader();
284
+ const decoder = new TextDecoder();
285
+ let bytes = 0;
286
+ let text = "";
287
+ try {
288
+ for (;;) {
289
+ const result = await reader.read();
290
+ if (result.done) break;
291
+ if (result.value === void 0) throw new Error("Xero response stream returned no bytes");
292
+ bytes += result.value.byteLength;
293
+ if (bytes > maxBytes) {
294
+ await reader.cancel("response too large").catch(() => void 0);
295
+ throw new Error("Xero connections response exceeds the byte limit");
296
+ }
297
+ text += decoder.decode(result.value, { stream: true });
236
298
  }
299
+ text += decoder.decode();
300
+ return text;
301
+ } finally {
302
+ reader.releaseLock();
303
+ }
304
+ }
305
+ function cloneJsonRecord(label, value, maxBytes, maxStringChars) {
306
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
307
+ const cloned = cloneRecord(label, value, 0, { nodes: 0 }, maxStringChars);
308
+ if (Buffer.byteLength(JSON.stringify(cloned), "utf8") > maxBytes) throw new Error(`${label} exceeds the ${String(maxBytes)} byte limit`);
309
+ return cloned;
310
+ }
311
+ function cloneJson(label, value, depth, budget, maxStringChars) {
312
+ budget.nodes += 1;
313
+ if (budget.nodes > MAX_JSON_NODES) throw new Error(`${label} contains too many values`);
314
+ if (depth > MAX_JSON_DEPTH) throw new Error(`${label} exceeds the JSON depth limit`);
315
+ if (value === null || typeof value === "boolean") return value;
316
+ if (typeof value === "number") {
317
+ if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
318
+ return value;
319
+ }
320
+ if (typeof value === "string") {
321
+ if (value.length > maxStringChars) throw new Error(`${label} contains an oversized string`);
322
+ return value;
323
+ }
324
+ if (Array.isArray(value)) {
325
+ if (value.length > MAX_JSON_ARRAY_ITEMS) throw new Error(`${label} contains an oversized array`);
326
+ return value.map((entry) => cloneJson(label, entry, depth + 1, budget, maxStringChars));
327
+ }
328
+ if (!isRecord(value)) throw new Error(`${label} contains a non-JSON value`);
329
+ return cloneRecord(label, value, depth, budget, maxStringChars);
330
+ }
331
+ function cloneRecord(label, value, depth, budget, maxStringChars) {
332
+ const result = Object.create(null);
333
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
334
+ if (key.length < 1 || key.length > 256 || UNSAFE_KEYS.has(key) || descriptor.get !== void 0 || descriptor.set !== void 0) throw new Error(`${label} contains an unsafe property`);
335
+ result[key] = cloneJson(label, descriptor.value, depth + 1, budget, maxStringChars);
336
+ }
337
+ return result;
338
+ }
339
+ function containsControlCharacter(value) {
340
+ return Array.from(value).some((character) => {
341
+ const codePoint = character.codePointAt(0) ?? 0;
342
+ return codePoint < 32 || codePoint === 127;
237
343
  });
238
- cachedTools.push({
239
- name: "xero_refresh_token",
240
- description: "Force-refresh the Xero OAuth2 access token for a specific connected tenant. Use this if Xero API calls are failing with authentication errors. Each Xero tenant has its own access token — pass the tenant you want to refresh.",
241
- inputSchema: {
242
- type: "object",
243
- properties: { xeroTenantId: {
244
- type: "string",
245
- description: "Xero tenantId — use the value from xero_list_accounts."
246
- } },
247
- required: ["xeroTenantId"]
344
+ }
345
+ function flattenControls(value) {
346
+ let output = "";
347
+ let previousWasControl = false;
348
+ for (const character of value) {
349
+ const codePoint = character.codePointAt(0) ?? 0;
350
+ const isControl = codePoint < 32 || codePoint === 127;
351
+ if (isControl) {
352
+ if (!previousWasControl) output += " ";
353
+ } else output += character;
354
+ previousWasControl = isControl;
355
+ }
356
+ return output;
357
+ }
358
+ function isRecord(value) {
359
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
360
+ const prototype = Object.getPrototypeOf(value);
361
+ return prototype === Object.prototype || prototype === null;
362
+ }
363
+ //#endregion
364
+ //#region src/server.ts
365
+ /**
366
+ * Xero MCP proxy (Pattern A multi-connection boundary).
367
+ *
368
+ * 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.
372
+ */
373
+ const require = createRequire(import.meta.url);
374
+ const CHILD_PACKAGE = "@xeroapi/xero-mcp-server";
375
+ const packageMetadata = require("../package.json");
376
+ const childPackagePath = require.resolve(`${CHILD_PACKAGE}/package.json`);
377
+ const childMetadata = require(childPackagePath);
378
+ const SERVER_VERSION = validateSemver("xero-mcp package version", packageMetadata.version);
379
+ const CHILD_VERSION = validateExactChildVersion(packageMetadata.dependencies?.[CHILD_PACKAGE], childMetadata.version);
380
+ const CHILD_BIN_PATH = resolveChildBin(childPackagePath, childMetadata.bin);
381
+ const CHILD_PACKAGE_ROOT = realpathSync(dirname(childPackagePath));
382
+ const CHILD_START_TIMEOUT_MS = 3e4;
383
+ const CHILD_CATALOG_TIMEOUT_MS = 15e3;
384
+ const CHILD_CALL_TIMEOUT_MS = 6e4;
385
+ const CHILD_CLOSE_TIMEOUT_MS = 5e3;
386
+ const TOKEN_REFRESH_TIMEOUT_MS = 3e4;
387
+ const REFRESH_INTERVAL_MS = 1500 * 1e3;
388
+ const REFRESH_RETRY_MS = 60 * 1e3;
389
+ var XeroRuntime = class {
390
+ tenants = /* @__PURE__ */ new Map();
391
+ claimedTenantIds = /* @__PURE__ */ new Map();
392
+ allAccountsSnapshot = [];
393
+ catalogToolNames = /* @__PURE__ */ new Set();
394
+ inFlightRefreshByTenant = /* @__PURE__ */ new Map();
395
+ cachedTools = [];
396
+ refreshTimer;
397
+ closePromise;
398
+ closed = false;
399
+ spawnChild;
400
+ verifyTenant;
401
+ shouldScheduleRefresh;
402
+ constructor(apiClient, options = {}) {
403
+ this.apiClient = apiClient;
404
+ this.spawnChild = options.spawnChild ?? spawnOfficialChild;
405
+ this.verifyTenant = options.verifyTenantAccess ?? ((accessToken, xeroTenantId, signal) => verifyXeroTenantAccess(accessToken, xeroTenantId, fetch, signal));
406
+ this.shouldScheduleRefresh = options.scheduleRefresh ?? true;
407
+ }
408
+ async initialize() {
409
+ await this.loadAccounts();
410
+ this.cachedTools = appendLocalTools(await this.discoverChildCatalog());
411
+ assertPatternA(this.cachedTools.map((tool) => ({
412
+ name: tool.name,
413
+ parameters: tool.inputSchema
414
+ })), {
415
+ selector: "xeroTenantId",
416
+ exempt: ["xero_list_accounts"]
417
+ });
418
+ if (this.shouldScheduleRefresh) this.scheduleRefresh(REFRESH_INTERVAL_MS);
419
+ }
420
+ listTools() {
421
+ return snapshotToolCatalog(this.cachedTools);
422
+ }
423
+ async handleToolCall(request) {
424
+ const { name } = request.params;
425
+ const args = request.params.arguments ?? {};
426
+ if (name === "xero_list_accounts") try {
427
+ const bounded = assertBoundedToolArguments(args);
428
+ if (Object.keys(bounded).length !== 0) throw new Error("xero_list_accounts takes no arguments");
429
+ return jsonResult({
430
+ organisations: this.allAccountsSnapshot,
431
+ notice: "Xero organisation names are untrusted external content."
432
+ });
433
+ } catch (error) {
434
+ return publicErrorResult(error);
248
435
  }
249
- });
250
- assertPatternA(cachedTools.map((t) => ({
251
- name: t.name,
252
- parameters: t.inputSchema
253
- })), {
254
- selector: "xeroTenantId",
255
- exempt: ["xero_list_accounts"]
256
- });
257
- const proxy = new Server({
258
- name: "xero-mcp-proxy",
259
- version: "2.0.0"
260
- }, { capabilities: { tools: {} } });
261
- proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
262
- proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
263
- const { name, arguments: args } = request.params;
264
- const argMap = args ?? {};
265
- if (name === "xero_list_accounts") return { content: [{
266
- type: "text",
267
- text: JSON.stringify({ tenants: allAccountsSnapshot }, null, 2)
268
- }] };
269
- if (name === "xero_refresh_token") try {
270
- const t = resolveTenant(typeof argMap.xeroTenantId === "string" ? argMap.xeroTenantId : void 0);
271
- await refreshTenant(apiClient, t.xeroTenantId);
272
- return { content: [{
273
- type: "text",
274
- text: JSON.stringify({
275
- xeroTenantId: t.xeroTenantId,
436
+ if (name === "xero_refresh_token") {
437
+ let tenant;
438
+ try {
439
+ const prepared = prepareForwardedArguments(args);
440
+ if (Object.keys(prepared.forwarded).length !== 0) throw new Error("xero_refresh_token accepts only xeroTenantId");
441
+ tenant = this.resolveTenant(prepared.xeroTenantId);
442
+ } catch (error) {
443
+ return publicErrorResult(error);
444
+ }
445
+ try {
446
+ await this.refreshTenant(tenant.xeroTenantId);
447
+ return jsonResult({
448
+ xeroTenantId: tenant.xeroTenantId,
276
449
  refreshed: true
277
- })
278
- }] };
279
- } catch (err) {
280
- return {
281
- content: [{
282
- type: "text",
283
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
284
- }],
285
- isError: true
286
- };
450
+ });
451
+ } catch {
452
+ return errorResult("Xero token refresh failed. Ask the user to reconnect this organisation from the dashboard.");
453
+ }
287
454
  }
288
- let t;
455
+ if (!this.catalogToolNames.has(name)) return errorResult(`Unknown Xero tool: ${name}`);
456
+ let tenant;
457
+ let forwarded;
289
458
  try {
290
- t = resolveTenant(typeof argMap.xeroTenantId === "string" ? argMap.xeroTenantId : void 0);
291
- } catch (err) {
292
- return {
293
- content: [{
294
- type: "text",
295
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
296
- }],
297
- isError: true
298
- };
459
+ const prepared = prepareForwardedArguments(args);
460
+ tenant = this.resolveTenant(prepared.xeroTenantId);
461
+ forwarded = prepared.forwarded;
462
+ } catch (error) {
463
+ return publicErrorResult(error);
299
464
  }
300
- const { xeroTenantId: _ignored, ...forwarded } = argMap;
301
465
  let result;
302
466
  try {
303
- result = await t.client.callTool({
304
- name,
305
- arguments: forwarded
467
+ result = await this.callChild(tenant, name, forwarded);
468
+ } catch {
469
+ return errorResult("The selected Xero organisation could not complete the tool call. Retry, then reconnect it if the failure persists.", {
470
+ xeroTenantId: tenant.xeroTenantId,
471
+ tool: name
472
+ });
473
+ }
474
+ if (childResultIsUnauthorized(result)) try {
475
+ await this.refreshTenant(tenant.xeroTenantId);
476
+ const refreshed = this.resolveTenant(tenant.xeroTenantId);
477
+ result = await this.callChild(refreshed, name, forwarded);
478
+ } catch {
479
+ return errorResult("Xero authentication failed and the credential could not be refreshed. Ask the user to reconnect this organisation from the dashboard.", {
480
+ xeroTenantId: tenant.xeroTenantId,
481
+ tool: name
306
482
  });
307
- } catch (err) {
308
- return {
309
- content: [{
310
- type: "text",
311
- text: JSON.stringify({
312
- xeroTenantId: t.xeroTenantId,
313
- tool: name,
314
- error: err instanceof Error ? err.message : String(err)
315
- })
316
- }],
317
- isError: true
318
- };
319
483
  }
320
- if (result.isError) {
321
- const errorText = result.content.map((c) => c.text ?? "").join("\n");
322
- if (errorText.includes("401") || errorText.toLowerCase().includes("unauthorized")) {
323
- log(`401 from tenant ${t.xeroTenantId}, refreshing and retrying...`);
324
- try {
325
- await refreshTenant(apiClient, t.xeroTenantId);
326
- const refreshed = tenants.get(t.xeroTenantId);
327
- if (!refreshed) throw new Error("Tenant disappeared after refresh");
328
- return await refreshed.client.callTool({
329
- name,
330
- arguments: forwarded
331
- });
332
- } catch (refreshErr) {
333
- return {
334
- content: [{
335
- type: "text",
336
- text: JSON.stringify({
337
- xeroTenantId: t.xeroTenantId,
338
- error: `Token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`
339
- })
340
- }],
341
- isError: true
342
- };
484
+ if (childResultIsError(result)) return errorResult(extractToolErrorText(result) || "Xero rejected the tool request", {
485
+ xeroTenantId: tenant.xeroTenantId,
486
+ tool: name
487
+ }, [tenant.accessToken]);
488
+ return result;
489
+ }
490
+ async close() {
491
+ if (this.closePromise !== void 0) return this.closePromise;
492
+ this.closed = true;
493
+ if (this.refreshTimer !== void 0) clearTimeout(this.refreshTimer);
494
+ this.refreshTimer = void 0;
495
+ this.closePromise = (async () => {
496
+ const current = [...this.tenants.values()];
497
+ this.tenants.clear();
498
+ await Promise.all(current.map(async ({ client }) => closeChild(client)));
499
+ await Promise.allSettled([...this.inFlightRefreshByTenant.values()]);
500
+ })();
501
+ return this.closePromise;
502
+ }
503
+ async loadAccounts() {
504
+ const rawAccounts = readXeroAccountsResponse(await this.apiClient.getXeroAccounts());
505
+ if (rawAccounts.length === 0) log("No Xero organisations connected; starting in discovery-only mode");
506
+ for (const rawAccount of rawAccounts) {
507
+ let account;
508
+ try {
509
+ account = normalizeXeroAccount(rawAccount);
510
+ } catch (error) {
511
+ log(`Rejected invalid Xero connection ${typeof rawAccount.connectionId === "string" ? connectionLogId(rawAccount.connectionId) : "invalid"}: ${safeErrorMessage(error)}`);
512
+ this.allAccountsSnapshot.push({
513
+ xeroTenantId: null,
514
+ displayName: null,
515
+ connectedAt: null,
516
+ connected: false,
517
+ reason: "invalid_account"
518
+ });
519
+ continue;
520
+ }
521
+ if (this.claimedTenantIds.has(account.xeroTenantId)) {
522
+ log(`Rejected duplicate Xero tenant from connection ${connectionLogId(account.connectionId)}`);
523
+ this.allAccountsSnapshot.push(summary(account, false, "duplicate_tenant"));
524
+ continue;
525
+ }
526
+ this.claimedTenantIds.set(account.xeroTenantId, account.connectionId);
527
+ if (account.accessToken.length === 0) {
528
+ log(`Missing token for Xero connection ${connectionLogId(account.connectionId)}`);
529
+ this.allAccountsSnapshot.push(summary(account, false, "missing_access_token"));
530
+ continue;
531
+ }
532
+ const controller = new AbortController();
533
+ let verification;
534
+ try {
535
+ verification = await withDeadline(this.verifyTenant(account.accessToken, account.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero tenant verification");
536
+ } catch (error) {
537
+ controller.abort(error);
538
+ log(`Tenant verification failed for Xero connection ${connectionLogId(account.connectionId)}: ${safeErrorMessage(error, [account.accessToken])}`);
539
+ this.allAccountsSnapshot.push(summary(account, false, "verification_failed"));
540
+ continue;
541
+ }
542
+ if (verification !== "verified") {
543
+ this.allAccountsSnapshot.push(summary(account, false, verification === "ambiguous" ? "ambiguous_token" : "tenant_mismatch"));
544
+ continue;
545
+ }
546
+ try {
547
+ const client = await withDeadline(this.spawnChild(account.accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero child startup");
548
+ if (this.closed) {
549
+ await closeChild(client);
550
+ throw new Error("Xero runtime closed during child startup");
343
551
  }
552
+ this.tenants.set(account.xeroTenantId, {
553
+ ...account,
554
+ client
555
+ });
556
+ this.allAccountsSnapshot.push(summary(account, true));
557
+ log(`Spawned child for Xero connection ${connectionLogId(account.connectionId)}`);
558
+ } catch (error) {
559
+ controller.abort(error);
560
+ log(`Child startup failed for Xero connection ${connectionLogId(account.connectionId)}: ${safeErrorMessage(error, [account.accessToken])}`);
561
+ this.allAccountsSnapshot.push(summary(account, false, "spawn_failed"));
344
562
  }
345
563
  }
346
- return result;
347
- });
348
- scheduleRefresh(apiClient);
349
- const transport = new StdioServerTransport();
350
- await proxy.connect(transport);
351
- log(`Proxy running with ${String(tenants.size)} connected tenant(s) and Pattern A selector enforcement`);
352
- }
353
- for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
354
- if (refreshTimer) clearTimeout(refreshTimer);
355
- killAllChildren().then(() => {
356
- process.exit(0);
564
+ }
565
+ async discoverChildCatalog() {
566
+ for (const tenant of this.tenants.values()) try {
567
+ const tools = validateToolCatalog((await withDeadline(tenant.client.listTools(), CHILD_CATALOG_TIMEOUT_MS, "Xero child tool discovery")).tools);
568
+ for (const tool of tools) this.catalogToolNames.add(tool.name);
569
+ return tools;
570
+ } catch (error) {
571
+ log(`Tool discovery failed for Xero connection ${connectionLogId(tenant.connectionId)}: ${safeErrorMessage(error, [tenant.accessToken])}`);
572
+ }
573
+ return [];
574
+ }
575
+ resolveTenant(xeroTenantId) {
576
+ const tenant = this.tenants.get(xeroTenantId);
577
+ if (tenant !== void 0) return tenant;
578
+ 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.`);
581
+ }
582
+ async callChild(tenant, name, forwarded) {
583
+ return assertBoundedToolResult(await withDeadline(tenant.client.callTool({
584
+ name,
585
+ arguments: forwarded
586
+ }), CHILD_CALL_TIMEOUT_MS, `Xero ${name}`));
587
+ }
588
+ refreshTenant(xeroTenantId) {
589
+ const inFlight = this.inFlightRefreshByTenant.get(xeroTenantId);
590
+ if (inFlight !== void 0) return inFlight;
591
+ const current = this.resolveTenant(xeroTenantId);
592
+ const promise = this.refreshTenantInner(current).finally(() => {
593
+ if (this.inFlightRefreshByTenant.get(xeroTenantId) === promise) this.inFlightRefreshByTenant.delete(xeroTenantId);
594
+ });
595
+ this.inFlightRefreshByTenant.set(xeroTenantId, promise);
596
+ return promise;
597
+ }
598
+ async refreshTenantInner(current) {
599
+ const accessToken = readRefreshedAccessToken(await withDeadline(this.apiClient.refreshXeroAccountToken(current.accountIdentifier), TOKEN_REFRESH_TIMEOUT_MS, "Xero token refresh"));
600
+ const controller = new AbortController();
601
+ let verification;
602
+ try {
603
+ verification = await withDeadline(this.verifyTenant(accessToken, current.xeroTenantId, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed tenant verification");
604
+ } catch (error) {
605
+ controller.abort(error);
606
+ throw error;
607
+ }
608
+ if (verification !== "verified") {
609
+ controller.abort();
610
+ throw new Error("Refreshed Xero token did not map uniquely to the selected organisation");
611
+ }
612
+ let nextClient;
613
+ try {
614
+ nextClient = await withDeadline(this.spawnChild(accessToken, controller.signal), CHILD_START_TIMEOUT_MS, "Xero refreshed child startup");
615
+ } catch (error) {
616
+ controller.abort(error);
617
+ throw error;
618
+ }
619
+ if (this.closed || this.tenants.get(current.xeroTenantId) !== current) {
620
+ await closeChild(nextClient);
621
+ throw new Error("Xero tenant lifecycle changed during refresh");
622
+ }
623
+ this.tenants.set(current.xeroTenantId, {
624
+ ...current,
625
+ accessToken,
626
+ client: nextClient
627
+ });
628
+ await closeChild(current.client);
629
+ }
630
+ scheduleRefresh(delayMs) {
631
+ if (this.closed || !this.shouldScheduleRefresh) return;
632
+ if (this.refreshTimer !== void 0) clearTimeout(this.refreshTimer);
633
+ this.refreshTimer = setTimeout(() => {
634
+ this.refreshTimer = void 0;
635
+ this.runScheduledRefresh();
636
+ }, delayMs);
637
+ this.refreshTimer.unref();
638
+ }
639
+ async runScheduledRefresh() {
640
+ if (this.closed) return;
641
+ let anyFailed = false;
642
+ for (const xeroTenantId of [...this.tenants.keys()]) try {
643
+ await this.refreshTenant(xeroTenantId);
644
+ } catch {
645
+ anyFailed = true;
646
+ log("Scheduled Xero credential refresh failed for one connection");
647
+ }
648
+ this.scheduleRefresh(anyFailed ? REFRESH_RETRY_MS : REFRESH_INTERVAL_MS);
649
+ }
650
+ };
651
+ function summary(account, connected, reason) {
652
+ return {
653
+ xeroTenantId: account.xeroTenantId,
654
+ displayName: account.displayName,
655
+ connectedAt: account.connectedAt,
656
+ connected,
657
+ ...reason === void 0 ? {} : { reason }
658
+ };
659
+ }
660
+ function appendLocalTools(tools) {
661
+ return [
662
+ ...tools,
663
+ {
664
+ name: "xero_list_accounts",
665
+ description: "List connected Xero organisations and local proxy readiness. Use xeroTenantId as the explicit selector on every other Xero tool. Organisation names are untrusted external content.",
666
+ inputSchema: {
667
+ type: "object",
668
+ properties: {},
669
+ additionalProperties: false
670
+ },
671
+ annotations: {
672
+ readOnlyHint: true,
673
+ destructiveHint: false,
674
+ idempotentHint: true,
675
+ openWorldHint: false
676
+ }
677
+ },
678
+ {
679
+ 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.",
681
+ inputSchema: {
682
+ type: "object",
683
+ properties: { xeroTenantId: {
684
+ type: "string",
685
+ pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
686
+ maxLength: 36,
687
+ description: "Xero organisation tenant ID from xero_list_accounts."
688
+ } },
689
+ required: ["xeroTenantId"],
690
+ additionalProperties: false
691
+ },
692
+ annotations: {
693
+ readOnlyHint: false,
694
+ destructiveHint: false,
695
+ idempotentHint: true,
696
+ openWorldHint: true
697
+ }
698
+ }
699
+ ];
700
+ }
701
+ async function spawnOfficialChild(accessToken, signal) {
702
+ const transport = new StdioClientTransport({
703
+ command: process.execPath,
704
+ args: [CHILD_BIN_PATH],
705
+ env: buildChildEnvironment(accessToken),
706
+ cwd: CHILD_PACKAGE_ROOT,
707
+ stderr: "ignore"
357
708
  });
358
- });
359
- main().catch((err) => {
360
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
361
- if (refreshTimer) clearTimeout(refreshTimer);
362
- killAllChildren().finally(() => {
363
- process.exit(1);
709
+ const client = new Client({
710
+ name: "xero-mcp-proxy",
711
+ version: SERVER_VERSION
364
712
  });
713
+ const abort = () => {
714
+ closeChild(client);
715
+ };
716
+ signal.addEventListener("abort", abort, { once: true });
717
+ try {
718
+ await client.connect(transport);
719
+ if (signal.aborted) throw new Error("Xero child startup was aborted");
720
+ return client;
721
+ } catch (error) {
722
+ await closeChild(client);
723
+ throw error;
724
+ } finally {
725
+ signal.removeEventListener("abort", abort);
726
+ }
727
+ }
728
+ async function closeChild(client) {
729
+ await withDeadline(client.close(), CHILD_CLOSE_TIMEOUT_MS, "Xero child close").catch(() => void 0);
730
+ }
731
+ async function createProxyServer(apiClient, options = {}) {
732
+ const runtime = new XeroRuntime(apiClient, options);
733
+ try {
734
+ await runtime.initialize();
735
+ } catch (error) {
736
+ await runtime.close();
737
+ throw error;
738
+ }
739
+ const server = new Server({
740
+ name: "xero-mcp-proxy",
741
+ version: SERVER_VERSION
742
+ }, { capabilities: { tools: {} } });
743
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: runtime.listTools() }));
744
+ server.setRequestHandler(CallToolRequestSchema, (request) => runtime.handleToolCall(request));
745
+ return {
746
+ server,
747
+ runtime
748
+ };
749
+ }
750
+ async function startServer(apiClient, options = {}) {
751
+ let client = apiClient;
752
+ if (client === void 0) {
753
+ const config = resolveConfig();
754
+ client = new AgentApiClient({
755
+ apiKey: config.apiKey,
756
+ apiUrl: config.apiUrl
757
+ });
758
+ }
759
+ const { server, runtime } = await createProxyServer(client, options);
760
+ let closePromise;
761
+ const close = () => {
762
+ closePromise ??= Promise.all([runtime.close(), withDeadline(server.close(), CHILD_CLOSE_TIMEOUT_MS, "Xero proxy close").catch(() => void 0)]).then(() => void 0);
763
+ return closePromise;
764
+ };
765
+ try {
766
+ await server.connect(new StdioServerTransport());
767
+ log(`Proxy running with ${String(runtime.listTools().length - 2)} child tool(s)`);
768
+ return {
769
+ server,
770
+ runtime,
771
+ close
772
+ };
773
+ } catch (error) {
774
+ await close();
775
+ throw error;
776
+ }
777
+ }
778
+ function isProcessEntrypoint(argvPath, metaUrl) {
779
+ try {
780
+ if (!argvPath) return false;
781
+ return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(fileURLToPath(metaUrl))).href;
782
+ } catch {
783
+ return false;
784
+ }
785
+ }
786
+ function resolveChildBin(packagePath, bin) {
787
+ let relativeBin;
788
+ if (typeof bin === "string") relativeBin = bin;
789
+ else if (bin !== null && typeof bin === "object" && !Array.isArray(bin)) relativeBin = bin["xero-mcp-server"];
790
+ if (typeof relativeBin !== "string" || relativeBin.length === 0 || isAbsolute(relativeBin)) throw new Error(`${CHILD_PACKAGE} package bin path is invalid`);
791
+ const packageRoot = realpathSync(dirname(packagePath));
792
+ const binPath = realpathSync(resolve(packageRoot, relativeBin));
793
+ const childRelative = relative(packageRoot, binPath);
794
+ if (childRelative === "" || childRelative.startsWith("..") || isAbsolute(childRelative)) throw new Error(`${CHILD_PACKAGE} package bin resolves outside its package`);
795
+ return binPath;
796
+ }
797
+ function validateExactChildVersion(declared, installed) {
798
+ const declaredVersion = validateSemver(`${CHILD_PACKAGE} dependency`, declared);
799
+ const installedVersion = validateSemver(`${CHILD_PACKAGE} installed version`, installed);
800
+ if (declaredVersion !== installedVersion) throw new Error(`${CHILD_PACKAGE} installed version ${installedVersion} does not match exact dependency ${declaredVersion}`);
801
+ return declaredVersion;
802
+ }
803
+ function validateSemver(label, value) {
804
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value)) throw new Error(`${label} is invalid`);
805
+ return value;
806
+ }
807
+ function jsonResult(data) {
808
+ return { content: [{
809
+ type: "text",
810
+ text: JSON.stringify(data)
811
+ }] };
812
+ }
813
+ function publicErrorResult(error) {
814
+ return errorResult(safeErrorMessage(error));
815
+ }
816
+ function errorResult(error, context = {}, secrets = []) {
817
+ return {
818
+ content: [{
819
+ type: "text",
820
+ text: JSON.stringify({
821
+ ...context,
822
+ error: safeErrorMessage(error, secrets)
823
+ })
824
+ }],
825
+ isError: true
826
+ };
827
+ }
828
+ function log(message) {
829
+ process.stderr.write(`[xero-mcp-proxy] ${message}\n`);
830
+ }
831
+ if (isProcessEntrypoint(process.argv[1], import.meta.url)) startServer().then((running) => {
832
+ let shutdownPromise;
833
+ const shutdown = () => {
834
+ if (shutdownPromise !== void 0) return;
835
+ shutdownPromise = running.close().catch((error) => {
836
+ log(`Failed to close cleanly: ${safeErrorMessage(error)}`);
837
+ }).then(() => {
838
+ process.exitCode = 0;
839
+ });
840
+ };
841
+ process.once("SIGTERM", shutdown);
842
+ process.once("SIGINT", shutdown);
843
+ }).catch((error) => {
844
+ log(`Fatal: ${safeErrorMessage(error)}`);
845
+ process.exitCode = 1;
365
846
  });
366
847
  //#endregion
367
- export {};
848
+ export { CHILD_BIN_PATH, CHILD_PACKAGE_ROOT, CHILD_VERSION, SERVER_VERSION, XeroRuntime, createProxyServer, isProcessEntrypoint, startServer };