Skip to content

Commit 957560c

Browse files
feat: auth and get real data from a tenant
1 parent abda56d commit 957560c

File tree

7 files changed

+185
-44
lines changed

7 files changed

+185
-44
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
},
2828
"dependencies": {
2929
"@modelcontextprotocol/sdk": "^1.7.0",
30-
"xero-node": "^10.0.0"
30+
"dotenv": "^16.4.7",
31+
"open": "^10.1.0",
32+
"xero-node": "^10.0.0",
33+
"zod": "^3.24.2"
3134
},
3235
"devDependencies": {
3336
"@types/node": "^22.13.10",

src/Tools/Authenticate.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { XeroClientSession } from "../XeroApiClient.js";
2+
import { IMcpServerTool } from "./IMcpServerTool.js";
3+
import { z } from "zod";
4+
import http from "http";
5+
import open from "open";
6+
import { Result } from "@modelcontextprotocol/sdk/types.js";
7+
8+
export const AuthenticateTool: IMcpServerTool = {
9+
requestSchema: {
10+
name: "authenticate",
11+
description: "Authenticate with Xero using OAuth2",
12+
inputSchema: { type: "object", properties: {} },
13+
output: { content: [{ type: "text", text: z.string() }] },
14+
},
15+
requestHandler: async () => {
16+
const consentUrl = await XeroClientSession.xeroClient.buildConsentUrl();
17+
const server = http.createServer();
18+
server.listen(process.env.PORT || 5000);
19+
const authTask = new Promise<Result>((resolve, reject) => {
20+
server.on("request", async (req) => {
21+
if (req.url && req.url.includes("/callback")) {
22+
try {
23+
const tokenSet = await XeroClientSession.xeroClient.apiCallback(
24+
req.url
25+
);
26+
XeroClientSession.xeroClient.setTokenSet(tokenSet);
27+
await XeroClientSession.xeroClient.updateTenants();
28+
XeroClientSession.setActiveTenantId(
29+
XeroClientSession.xeroClient.tenants[0].tenantId
30+
);
31+
32+
resolve({
33+
content: [
34+
{
35+
type: "text",
36+
text: "Authenticated successfully",
37+
},
38+
],
39+
});
40+
} catch (error: any) {
41+
reject({
42+
content: [
43+
{
44+
type: "text",
45+
text: `Error authenticating user: ${error.message}`,
46+
},
47+
],
48+
});
49+
} finally {
50+
server.close();
51+
}
52+
}
53+
});
54+
});
55+
56+
open(consentUrl);
57+
return authTask;
58+
},
59+
};

src/Tools/IMcpServerTool.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Request, Result, Tool } from "@modelcontextprotocol/sdk/types.js";
2+
3+
export interface IMcpServerTool {
4+
requestSchema: Tool;
5+
requestHandler: (request: Request) => Promise<Result>;
6+
}

src/Tools/ListAccounts.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Account } from "xero-node";
2+
import { XeroClientSession } from "../XeroApiClient.js";
3+
import { IMcpServerTool } from "./IMcpServerTool.js";
4+
import { z } from "zod";
5+
6+
function formatAccountsResponse(accounts: Account[]): string {
7+
const results = [];
8+
results.push(`${accounts.length} accounts found:`);
9+
for (const account of accounts) {
10+
results.push(
11+
`- ${account.name} ${account.code || ""} ${account.status || ""} ${
12+
account.description || ""
13+
}`
14+
);
15+
}
16+
return results.join("\n");
17+
}
18+
19+
export const ListAccountsTool: IMcpServerTool = {
20+
requestSchema: {
21+
name: "list_accounts",
22+
description: "List all accounts",
23+
inputSchema: { type: "object", properties: {} },
24+
output: { content: [{ type: "text", text: z.string() }] },
25+
},
26+
requestHandler: async () => {
27+
const tenantId = XeroClientSession.activeTenantId();
28+
if (!tenantId) {
29+
throw new Error("No tenant selected");
30+
}
31+
const response =
32+
await XeroClientSession.xeroClient.accountingApi.getAccounts(tenantId);
33+
const accounts = response.body.accounts || [];
34+
return {
35+
content: [
36+
{
37+
type: "text",
38+
text: formatAccountsResponse(accounts),
39+
},
40+
],
41+
};
42+
},
43+
};

src/XeroApi/Account.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

src/XeroApiClient.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { XeroClient } from "xero-node";
2+
import "dotenv/config";
3+
4+
const client_id = process.env.XERO_CLIENT_ID;
5+
const client_secret = process.env.XERO_CLIENT_SECRET;
6+
const redirectUrl = process.env.XERO_REDIRECT_URI;
7+
const scopes =
8+
"offline_access openid profile email accounting.transactions accounting.budgets.read accounting.reports.read accounting.journals.read accounting.settings accounting.settings.read accounting.contacts accounting.contacts.read accounting.attachments accounting.attachments.read files files.read assets assets.read projects projects.read";
9+
10+
if (!client_id || !client_secret || !redirectUrl) {
11+
throw Error(
12+
"Environment Variables not all set - please check your .env file in the project root or create one!"
13+
);
14+
}
15+
16+
type XeroClientConfig = {
17+
clientId: string;
18+
clientSecret: string;
19+
redirectUrl: string;
20+
scopes: string[];
21+
};
22+
23+
class XeroApiClient {
24+
xeroClient: XeroClient;
25+
private _activeTenantId: string | undefined;
26+
27+
constructor(config: XeroClientConfig) {
28+
this.xeroClient = new XeroClient({
29+
clientId: config.clientId,
30+
clientSecret: config.clientSecret,
31+
redirectUris: [config.redirectUrl],
32+
scopes: config.scopes,
33+
});
34+
}
35+
36+
isAuthenticated() {
37+
return this.xeroClient.readTokenSet() ? true : false;
38+
}
39+
40+
activeTenantId() {
41+
return this._activeTenantId;
42+
}
43+
44+
setActiveTenantId(tenantId: string) {
45+
this._activeTenantId = tenantId;
46+
}
47+
}
48+
49+
export const XeroClientSession = new XeroApiClient({
50+
clientId: client_id,
51+
clientSecret: client_secret,
52+
redirectUrl: redirectUrl,
53+
scopes: scopes.split(" "),
54+
});

src/XeroMcpServer.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import {
33
CallToolRequestSchema,
44
ListToolsRequestSchema,
55
} from "@modelcontextprotocol/sdk/types.js";
6-
import { z } from "zod";
7-
import { getAccounts } from "./XeroApi/Account.js";
86
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7+
import { ListAccountsTool } from "./Tools/ListAccounts.js";
8+
import { AuthenticateTool } from "./Tools/Authenticate.js";
9+
import { XeroClientSession } from "./XeroApiClient.js";
910

1011
export class XeroMcpServer {
1112
private server: Server;
@@ -31,36 +32,31 @@ export class XeroMcpServer {
3132
console.error("Xero MCP server running on stdio");
3233
}
3334

34-
setupTools() {
35+
private setupTools() {
3536
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
3637
return {
37-
tools: [
38-
{
39-
name: "list_accounts",
40-
description: "List all accounts",
41-
inputSchema: { type: "object", properties: {} },
42-
output: { content: [{ type: "text", text: z.string() }] },
43-
},
44-
],
38+
tools: [AuthenticateTool.requestSchema, ListAccountsTool.requestSchema],
4539
};
4640
});
4741

4842
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
4943
const { name } = request.params;
5044
try {
45+
if (name === AuthenticateTool.requestSchema.name) {
46+
return await AuthenticateTool.requestHandler(request);
47+
} else if (!XeroClientSession.isAuthenticated()) {
48+
return {
49+
content: [
50+
{
51+
type: "text",
52+
text: "You must authenticate with Xero first",
53+
},
54+
],
55+
};
56+
}
5157
switch (name) {
52-
case "list_accounts":
53-
const accounts = (await getAccounts()).accounts || [];
54-
return {
55-
content: [
56-
{
57-
type: "text",
58-
text: `${accounts.length} accounts found\n${accounts.map(
59-
(account) => `${account.name} (${account.code})`
60-
)}`,
61-
},
62-
],
63-
};
58+
case ListAccountsTool.requestSchema.name:
59+
return await ListAccountsTool.requestHandler(request);
6460
default:
6561
throw new Error(`Unknown tool: ${name}`);
6662
}

0 commit comments

Comments
 (0)