@alfe.ai/xero-mcp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.d.ts +1 -0
- package/dist/server.js +359 -0
- package/package.json +32 -0
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
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
|
+
//#region src/server.ts
|
|
10
|
+
/**
|
|
11
|
+
* Xero MCP Proxy Server (Pattern A multi-account)
|
|
12
|
+
*
|
|
13
|
+
* A thin proxy that fans out to one official `@xeroapi/xero-mcp-server`
|
|
14
|
+
* child process per connected Xero tenant. Every credential-touching
|
|
15
|
+
* tool requires the LLM to pass a `xeroTenantId` selector arg; the proxy
|
|
16
|
+
* strips that arg, dispatches to the right child, and returns the result.
|
|
17
|
+
*
|
|
18
|
+
* Pattern A locked in PR 7.3 of channels-and-credential-driven-
|
|
19
|
+
* integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
|
|
20
|
+
* contract; `services/connect/DEVELOPING.md` for the provider table.
|
|
21
|
+
*
|
|
22
|
+
* Architecture:
|
|
23
|
+
* OpenClaw ←(stdio)→ this proxy ←(stdio fan-out)→ N × @xeroapi/xero-mcp-server
|
|
24
|
+
*
|
|
25
|
+
* Token refresh is per-tenant: Xero access tokens are NOT interchangeable
|
|
26
|
+
* across tenants on the same agent (unlike MYOB), so a 401 from one
|
|
27
|
+
* tenant refreshes only that tenant's token via
|
|
28
|
+
* `apiClient.refreshXeroAccountToken(xeroTenantId)` and respawns only
|
|
29
|
+
* that tenant's child. Xero tokens expire every 30 minutes, so the
|
|
30
|
+
* scheduled refresh path walks every cached tenant on a 25-minute
|
|
31
|
+
* cadence.
|
|
32
|
+
*
|
|
33
|
+
* Uses the low-level Server class (not McpServer) because child tools
|
|
34
|
+
* return JSON Schema objects — McpServer.registerTool requires Zod.
|
|
35
|
+
*/
|
|
36
|
+
const tenants = /* @__PURE__ */ new Map();
|
|
37
|
+
/** Full getXeroAccounts() snapshot — includes rows we couldn't spawn. */
|
|
38
|
+
const allAccountsSnapshot = [];
|
|
39
|
+
/** Cached, selector-injected tool list. */
|
|
40
|
+
let cachedTools = [];
|
|
41
|
+
let refreshTimer = null;
|
|
42
|
+
/**
|
|
43
|
+
* Collapse concurrent refreshes on a per-tenant basis. A burst of 401s
|
|
44
|
+
* across the same tenant share one in-flight refresh call.
|
|
45
|
+
*/
|
|
46
|
+
const inFlightRefreshByTenant = /* @__PURE__ */ new Map();
|
|
47
|
+
const REFRESH_INTERVAL_MS = 1500 * 1e3;
|
|
48
|
+
const REFRESH_RETRY_MS = 60 * 1e3;
|
|
49
|
+
function log(msg) {
|
|
50
|
+
process.stderr.write(`[xero-mcp-proxy] ${msg}\n`);
|
|
51
|
+
}
|
|
52
|
+
function resolveTenant(xeroTenantId) {
|
|
53
|
+
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.");
|
|
54
|
+
const t = tenants.get(xeroTenantId);
|
|
55
|
+
if (!t) {
|
|
56
|
+
const known = allAccountsSnapshot.find((s) => s.xeroTenantId === xeroTenantId);
|
|
57
|
+
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.`);
|
|
58
|
+
throw new Error(`Unknown xeroTenantId: ${xeroTenantId}. Call xero_list_accounts to see the connected Xero tenants on this agent.`);
|
|
59
|
+
}
|
|
60
|
+
return t;
|
|
61
|
+
}
|
|
62
|
+
async function spawnChild(accessToken, xeroTenantId) {
|
|
63
|
+
const transport = new StdioClientTransport({
|
|
64
|
+
command: "npx",
|
|
65
|
+
args: ["-y", "@xeroapi/xero-mcp-server"],
|
|
66
|
+
env: {
|
|
67
|
+
...process.env,
|
|
68
|
+
XERO_CLIENT_BEARER_TOKEN: accessToken,
|
|
69
|
+
XERO_TENANT_ID: xeroTenantId
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const client = new Client({
|
|
73
|
+
name: "xero-mcp-proxy",
|
|
74
|
+
version: "2.0.0"
|
|
75
|
+
});
|
|
76
|
+
await client.connect(transport);
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
async function killAllChildren() {
|
|
80
|
+
for (const t of tenants.values()) try {
|
|
81
|
+
await t.client.close();
|
|
82
|
+
} catch {}
|
|
83
|
+
tenants.clear();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Refresh a specific tenant's access token and atomically swap its
|
|
87
|
+
* child server. Concurrent callers for the same tenant share one
|
|
88
|
+
* in-flight refresh + respawn.
|
|
89
|
+
*/
|
|
90
|
+
async function refreshTenant(apiClient, xeroTenantId) {
|
|
91
|
+
const existing = inFlightRefreshByTenant.get(xeroTenantId);
|
|
92
|
+
if (existing) return existing;
|
|
93
|
+
const promise = (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const { accessToken } = await apiClient.refreshXeroAccountToken(xeroTenantId);
|
|
96
|
+
const newChild = await spawnChild(accessToken, xeroTenantId);
|
|
97
|
+
const old = tenants.get(xeroTenantId);
|
|
98
|
+
tenants.set(xeroTenantId, {
|
|
99
|
+
xeroTenantId,
|
|
100
|
+
displayName: old?.displayName ?? null,
|
|
101
|
+
connectedAt: old?.connectedAt ?? "",
|
|
102
|
+
accessToken,
|
|
103
|
+
client: newChild
|
|
104
|
+
});
|
|
105
|
+
if (old) try {
|
|
106
|
+
await old.client.close();
|
|
107
|
+
} catch {}
|
|
108
|
+
return accessToken;
|
|
109
|
+
} finally {
|
|
110
|
+
inFlightRefreshByTenant.delete(xeroTenantId);
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
inFlightRefreshByTenant.set(xeroTenantId, promise);
|
|
114
|
+
return promise;
|
|
115
|
+
}
|
|
116
|
+
function scheduleRefresh(apiClient) {
|
|
117
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
118
|
+
refreshTimer = setTimeout(() => {
|
|
119
|
+
(async () => {
|
|
120
|
+
const tenantIds = Array.from(tenants.keys());
|
|
121
|
+
if (tenantIds.length === 0) {
|
|
122
|
+
refreshTimer = setTimeout(() => {
|
|
123
|
+
scheduleRefresh(apiClient);
|
|
124
|
+
}, REFRESH_INTERVAL_MS);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
log(`Refreshing Xero access tokens across ${String(tenantIds.length)} tenant(s)...`);
|
|
128
|
+
let anyFailed = false;
|
|
129
|
+
for (const tid of tenantIds) try {
|
|
130
|
+
await refreshTenant(apiClient, tid);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
anyFailed = true;
|
|
133
|
+
log(`Token refresh failed for tenant ${tid}: ${err instanceof Error ? err.message : String(err)}`);
|
|
134
|
+
}
|
|
135
|
+
refreshTimer = setTimeout(() => {
|
|
136
|
+
scheduleRefresh(apiClient);
|
|
137
|
+
}, anyFailed ? REFRESH_RETRY_MS : REFRESH_INTERVAL_MS);
|
|
138
|
+
})();
|
|
139
|
+
}, REFRESH_INTERVAL_MS);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Inject a required `xeroTenantId` string property into a child tool's
|
|
143
|
+
* inputSchema. The proxy strips this arg before forwarding to the child.
|
|
144
|
+
*/
|
|
145
|
+
function injectTenantSelector(tool) {
|
|
146
|
+
const original = tool.inputSchema ?? {};
|
|
147
|
+
const originalProperties = original.properties ?? {};
|
|
148
|
+
const originalRequired = Array.isArray(original.required) ? original.required : [];
|
|
149
|
+
const injectedProperties = {
|
|
150
|
+
...originalProperties,
|
|
151
|
+
xeroTenantId: {
|
|
152
|
+
type: "string",
|
|
153
|
+
description: "Xero tenantId — use the value from xero_list_accounts to pick which connected tenant this call should target."
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const injectedRequired = originalRequired.includes("xeroTenantId") ? originalRequired : ["xeroTenantId", ...originalRequired];
|
|
157
|
+
return {
|
|
158
|
+
name: tool.name,
|
|
159
|
+
description: tool.description,
|
|
160
|
+
inputSchema: {
|
|
161
|
+
...original,
|
|
162
|
+
type: original.type ?? "object",
|
|
163
|
+
properties: injectedProperties,
|
|
164
|
+
required: injectedRequired
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async function main() {
|
|
169
|
+
const config = resolveConfig();
|
|
170
|
+
const apiClient = new AgentApiClient({
|
|
171
|
+
apiKey: config.apiKey,
|
|
172
|
+
apiUrl: config.apiUrl
|
|
173
|
+
});
|
|
174
|
+
const { accounts } = await apiClient.getXeroAccounts();
|
|
175
|
+
if (accounts.length === 0) log("No Xero tenants connected — proxy will start with xero_list_accounts and xero_refresh_token only");
|
|
176
|
+
for (const acct of accounts) {
|
|
177
|
+
if (!acct.accessToken) {
|
|
178
|
+
log(`Skipping tenant ${acct.xeroTenantId} — no access token`);
|
|
179
|
+
allAccountsSnapshot.push({
|
|
180
|
+
xeroTenantId: acct.xeroTenantId,
|
|
181
|
+
displayName: acct.displayName,
|
|
182
|
+
connectedAt: acct.connectedAt,
|
|
183
|
+
connected: false,
|
|
184
|
+
reason: "missing_access_token"
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (tenants.has(acct.xeroTenantId)) {
|
|
189
|
+
log(`Duplicate xeroTenantId ${acct.xeroTenantId} returned by getXeroAccounts() — keeping the first cached child`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const client = await spawnChild(acct.accessToken, acct.xeroTenantId);
|
|
194
|
+
tenants.set(acct.xeroTenantId, {
|
|
195
|
+
xeroTenantId: acct.xeroTenantId,
|
|
196
|
+
displayName: acct.displayName,
|
|
197
|
+
connectedAt: acct.connectedAt,
|
|
198
|
+
accessToken: acct.accessToken,
|
|
199
|
+
client
|
|
200
|
+
});
|
|
201
|
+
allAccountsSnapshot.push({
|
|
202
|
+
xeroTenantId: acct.xeroTenantId,
|
|
203
|
+
displayName: acct.displayName,
|
|
204
|
+
connectedAt: acct.connectedAt,
|
|
205
|
+
connected: true
|
|
206
|
+
});
|
|
207
|
+
log(`Spawned child server for tenant ${acct.xeroTenantId} (${acct.displayName ?? "unnamed"})`);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
210
|
+
log(`Failed to spawn child for tenant ${acct.xeroTenantId}: ${message}`);
|
|
211
|
+
allAccountsSnapshot.push({
|
|
212
|
+
xeroTenantId: acct.xeroTenantId,
|
|
213
|
+
displayName: acct.displayName,
|
|
214
|
+
connectedAt: acct.connectedAt,
|
|
215
|
+
connected: false,
|
|
216
|
+
reason: `spawn_failed: ${message}`
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const firstTenant = tenants.values().next().value;
|
|
221
|
+
if (firstTenant) {
|
|
222
|
+
const { tools } = await firstTenant.client.listTools();
|
|
223
|
+
cachedTools = tools.map(injectTenantSelector);
|
|
224
|
+
log(`Child MCP server provides ${String(cachedTools.length)} tools (selector injected)`);
|
|
225
|
+
} else {
|
|
226
|
+
cachedTools = [];
|
|
227
|
+
log("No child server available — only xero_list_accounts and xero_refresh_token will be exposed");
|
|
228
|
+
}
|
|
229
|
+
cachedTools.push({
|
|
230
|
+
name: "xero_list_accounts",
|
|
231
|
+
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.",
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: "object",
|
|
234
|
+
properties: {}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
cachedTools.push({
|
|
238
|
+
name: "xero_refresh_token",
|
|
239
|
+
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.",
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: "object",
|
|
242
|
+
properties: { xeroTenantId: {
|
|
243
|
+
type: "string",
|
|
244
|
+
description: "Xero tenantId — use the value from xero_list_accounts."
|
|
245
|
+
} },
|
|
246
|
+
required: ["xeroTenantId"]
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
const proxy = new Server({
|
|
250
|
+
name: "xero-mcp-proxy",
|
|
251
|
+
version: "2.0.0"
|
|
252
|
+
}, { capabilities: { tools: {} } });
|
|
253
|
+
proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
|
|
254
|
+
proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
255
|
+
const { name, arguments: args } = request.params;
|
|
256
|
+
const argMap = args ?? {};
|
|
257
|
+
if (name === "xero_list_accounts") return { content: [{
|
|
258
|
+
type: "text",
|
|
259
|
+
text: JSON.stringify({ tenants: allAccountsSnapshot }, null, 2)
|
|
260
|
+
}] };
|
|
261
|
+
if (name === "xero_refresh_token") try {
|
|
262
|
+
const t = resolveTenant(typeof argMap.xeroTenantId === "string" ? argMap.xeroTenantId : void 0);
|
|
263
|
+
await refreshTenant(apiClient, t.xeroTenantId);
|
|
264
|
+
return { content: [{
|
|
265
|
+
type: "text",
|
|
266
|
+
text: JSON.stringify({
|
|
267
|
+
xeroTenantId: t.xeroTenantId,
|
|
268
|
+
refreshed: true
|
|
269
|
+
})
|
|
270
|
+
}] };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return {
|
|
273
|
+
content: [{
|
|
274
|
+
type: "text",
|
|
275
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
276
|
+
}],
|
|
277
|
+
isError: true
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
let t;
|
|
281
|
+
try {
|
|
282
|
+
t = resolveTenant(typeof argMap.xeroTenantId === "string" ? argMap.xeroTenantId : void 0);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return {
|
|
285
|
+
content: [{
|
|
286
|
+
type: "text",
|
|
287
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
|
|
288
|
+
}],
|
|
289
|
+
isError: true
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const { xeroTenantId: _ignored, ...forwarded } = argMap;
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = await t.client.callTool({
|
|
296
|
+
name,
|
|
297
|
+
arguments: forwarded
|
|
298
|
+
});
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
content: [{
|
|
302
|
+
type: "text",
|
|
303
|
+
text: JSON.stringify({
|
|
304
|
+
xeroTenantId: t.xeroTenantId,
|
|
305
|
+
tool: name,
|
|
306
|
+
error: err instanceof Error ? err.message : String(err)
|
|
307
|
+
})
|
|
308
|
+
}],
|
|
309
|
+
isError: true
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (result.isError) {
|
|
313
|
+
const errorText = result.content.map((c) => c.text ?? "").join("\n");
|
|
314
|
+
if (errorText.includes("401") || errorText.toLowerCase().includes("unauthorized")) {
|
|
315
|
+
log(`401 from tenant ${t.xeroTenantId}, refreshing and retrying...`);
|
|
316
|
+
try {
|
|
317
|
+
await refreshTenant(apiClient, t.xeroTenantId);
|
|
318
|
+
const refreshed = tenants.get(t.xeroTenantId);
|
|
319
|
+
if (!refreshed) throw new Error("Tenant disappeared after refresh");
|
|
320
|
+
return await refreshed.client.callTool({
|
|
321
|
+
name,
|
|
322
|
+
arguments: forwarded
|
|
323
|
+
});
|
|
324
|
+
} catch (refreshErr) {
|
|
325
|
+
return {
|
|
326
|
+
content: [{
|
|
327
|
+
type: "text",
|
|
328
|
+
text: JSON.stringify({
|
|
329
|
+
xeroTenantId: t.xeroTenantId,
|
|
330
|
+
error: `Token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`
|
|
331
|
+
})
|
|
332
|
+
}],
|
|
333
|
+
isError: true
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
});
|
|
340
|
+
scheduleRefresh(apiClient);
|
|
341
|
+
const transport = new StdioServerTransport();
|
|
342
|
+
await proxy.connect(transport);
|
|
343
|
+
log(`Proxy running with ${String(tenants.size)} connected tenant(s) and Pattern A selector enforcement`);
|
|
344
|
+
}
|
|
345
|
+
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
|
|
346
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
347
|
+
killAllChildren().then(() => {
|
|
348
|
+
process.exit(0);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
main().catch((err) => {
|
|
352
|
+
log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
353
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
354
|
+
killAllChildren().finally(() => {
|
|
355
|
+
process.exit(1);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
//#endregion
|
|
359
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfe.ai/xero-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Xero MCP proxy server — bridges the official @xeroapi/xero-mcp-server with Alfe OAuth credentials and automatic token refresh",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"xero-mcp-proxy": "./dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/server.d.ts",
|
|
13
|
+
"import": "./dist/server.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": ">=1.24.0",
|
|
21
|
+
"@xeroapi/xero-mcp-server": "^0.0.14",
|
|
22
|
+
"@alfe.ai/config": "0.1.0",
|
|
23
|
+
"@alfe.ai/agent-api-client": "0.3.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"dev": "tsdown --watch",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"lint": "eslint ."
|
|
31
|
+
}
|
|
32
|
+
}
|