Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,6 @@ dist

# Cursor IDE
.cursor/

# Xero web-auth persisted tokenset (contains refresh/access tokens)
.xero-tokenset.json
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This is a Model Context Protocol (MCP) server implementation for Xero. It provid

## Features

- Xero OAuth2 authentication with custom connections
- Xero OAuth2 authentication (custom connections, bearer token, or web authorization-code flow)
- Contact management
- Chart of Accounts management
- Invoice creation and management
Expand Down Expand Up @@ -36,7 +36,7 @@ NOTE: To use Payroll-specific queries, the region should be either NZ or UK.

### Authentication

There are 2 modes of authentication supported in the Xero MCP server:
There are 3 modes of authentication supported in the Xero MCP server: Custom Connections, Bearer Token, and Web (Authorization Code).

#### 1. Custom Connections

Expand Down Expand Up @@ -134,6 +134,60 @@ payroll.employees
payroll.timesheets
```

#### 3. Web (Authorization Code)

Standard OAuth2 Authorization Code flow with a refresh token, persisted to disk.
Use this when Custom Connections won't work (e.g. you need `offline_access` /
refresh-backed access, which is **not valid** for the `client_credentials`
grant Custom Connections use). A one-time browser consent is performed via the
bundled `npm run auth` CLI; afterwards the server runs headless and refreshes
the access token automatically.

**Setup**

1. In your app at <https://developer.xero.com> → **Configuration**, add the
redirect URI (default `http://localhost:5000/callback`).
2. Configure the env (MCP config or a local `.env`):

```json
{
"mcpServers": {
"xero": {
"command": "npx",
"args": ["-y", "@xeroapi/xero-mcp-server@latest"],
"env": {
"XERO_AUTH_MODE": "web",
"XERO_CLIENT_ID": "your_client_id_here",
"XERO_CLIENT_SECRET": "your_client_secret_here",
"XERO_REDIRECT_URI": "http://localhost:5000/callback",
"XERO_SCOPES": "offline_access accounting.transactions accounting.contacts accounting.attachments accounting.settings"
}
}
}
}
```

3. Run the one-time consent (from the cloned repo, with the same env set):

```bash
npm run auth
```

This opens the consent screen, captures the redirect on a temporary local
server, and writes the tokenset to `.xero-tokenset.json` (override with
`XERO_TOKENSET_PATH`). Re-authenticate at any time by deleting that file and
re-running `npm run auth`.

**Web-auth env vars**

| Var | Purpose | Default |
|-----|---------|---------|
| `XERO_AUTH_MODE` | `web` selects this mode | unset → custom/bearer |
| `XERO_REDIRECT_URI` | OAuth redirect (must be registered in Xero) | `http://localhost:5000/callback` |
| `XERO_SCOPES` | space-separated; **must include** `offline_access` | granular accounting scopes |
| `XERO_TOKENSET_PATH` | tokenset file location | `<repo>/.xero-tokenset.json` |
| `XERO_TENANT_ID` | pin a specific org | first connected tenant |


### Available MCP Commands

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
],
"scripts": {
"build": "tsc && shx chmod +x dist/*.js",
"auth": "npm run build && node dist/auth.js",
"prepare": "npm run build",
"watch": "tsc --watch",
"test": "vitest run",
Expand Down
104 changes: 104 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env node
import { exec } from "child_process";
import http from "http";
import { URL } from "url";

import dotenv from "dotenv";
import { XeroClient } from "xero-node";

import { buildWebAuthConfig } from "./clients/xero-client.js";
import { saveTokenSet } from "./clients/token-store.js";

dotenv.config();

// One-time consent runner for XERO_AUTH_MODE=web.
//
// Builds the consent URL, opens it in the browser, runs a short-lived local
// HTTP server on the redirect URI to capture the authorization code, exchanges
// it for a tokenset (incl. refresh token), resolves the tenant, and persists
// everything to disk so the MCP server can run headless afterwards.

function openBrowser(url: string): void {
const platform = process.platform;
const cmd =
platform === "darwin"
? `open "${url}"`
: platform === "win32"
? `start "" "${url}"`
: `xdg-open "${url}"`;
exec(cmd, (err) => {
if (err) {
// Non-fatal: the URL is also printed for manual opening.
}
});
}

async function main(): Promise<void> {
const config = buildWebAuthConfig();
const redirectUri = config.redirectUris![0];
const { hostname, port, pathname } = new URL(redirectUri);
const listenPort = port ? Number(port) : 80;

const xero = new XeroClient(config);
await xero.initialize();

const consentUrl = await xero.buildConsentUrl();

console.log("\nXero web-auth consent");
console.log("─".repeat(60));
console.log("Open this URL in your browser if it doesn't open automatically:\n");
console.log(consentUrl + "\n");
console.log(`Waiting for the redirect to ${redirectUri} ...\n`);

openBrowser(consentUrl);

await new Promise<void>((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
if (!req.url) return;
const requestUrl = new URL(req.url, `http://${hostname}:${listenPort}`);
if (requestUrl.pathname !== pathname) {
res.writeHead(404).end();
return;
}

const fullCallbackUrl = redirectUri + requestUrl.search;
const tokenSet = await xero.apiCallback(fullCallbackUrl);
// false = skip per-org detail fetch (that needs accounting.settings);
// /connections alone resolves the tenantId.
const tenants = await xero.updateTenants(false);
const tenant = tenants?.[0];

saveTokenSet({ ...tokenSet, tenantId: tenant?.tenantId });

res.writeHead(200, { "Content-Type": "text/html" }).end(
"<h2>Xero connected ✓</h2><p>You may close this tab and return to the terminal.</p>",
);

console.log("✓ Tokenset saved.");
if (tenant) {
console.log(`✓ Connected tenant: ${tenant.tenantName} (${tenant.tenantId})`);
}

server.close();
resolve();
} catch (error) {
res
.writeHead(500, { "Content-Type": "text/html" })
.end("<h2>Auth failed</h2><p>Check the terminal for details.</p>");
server.close();
reject(error);
}
});

server.on("error", reject);
server.listen(listenPort, hostname);
});
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error("\nAuth error:", error instanceof Error ? error.message : error);
process.exit(1);
});
60 changes: 60 additions & 0 deletions src/clients/token-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

import { TokenSetParameters } from "xero-node";

// A persisted tokenset is a standard OAuth TokenSet plus the tenant we resolved
// at consent time, so the MCP server can attach the correct org on every call
// without an extra round-trip.
export interface PersistedTokenSet extends TokenSetParameters {
tenantId?: string;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
* Resolve where the tokenset JSON lives. Override with XERO_TOKENSET_PATH;
* otherwise default to the repo root (two levels up from src/clients/dist).
*/
export function getTokenPath(): string {
if (process.env.XERO_TOKENSET_PATH) {
return path.resolve(process.env.XERO_TOKENSET_PATH);
}
// dist/clients/token-store.js -> repo root
return path.resolve(__dirname, "..", "..", ".xero-tokenset.json");
}

export function loadTokenSet(): PersistedTokenSet | null {
const file = getTokenPath();
if (!fs.existsSync(file)) {
return null;
}
try {
const raw = fs.readFileSync(file, "utf8");
return JSON.parse(raw) as PersistedTokenSet;
} catch {
return null;
}
}

export function saveTokenSet(tokenSet: PersistedTokenSet): void {
const file = getTokenPath();
fs.writeFileSync(file, JSON.stringify(tokenSet, null, 2), { mode: 0o600 });
// Tighten perms even if the file already existed with looser ones.
try {
fs.chmodSync(file, 0o600);
} catch {
// best-effort (e.g. unsupported FS)
}
}

export function clearTokenSet(): void {
const file = getTokenPath();
try {
fs.unlinkSync(file);
} catch {
// best-effort
}
}
121 changes: 111 additions & 10 deletions src/clients/xero-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,40 @@ import {
} from "xero-node";

import { ensureError } from "../helpers/ensure-error.js";
import { loadTokenSet, saveTokenSet } from "./token-store.js";

dotenv.config();

const client_id = process.env.XERO_CLIENT_ID;
const client_secret = process.env.XERO_CLIENT_SECRET;
const bearer_token = process.env.XERO_CLIENT_BEARER_TOKEN;
const grant_type = "client_credentials";
// "web" | "custom" | "bearer". Unset preserves the original behaviour
// (bearer if a bearer token is set, otherwise custom connections).
const auth_mode = (process.env.XERO_AUTH_MODE || "").toLowerCase();
const redirect_uri =
process.env.XERO_REDIRECT_URI || "http://localhost:5000/callback";

export const DEFAULT_WEB_AUTH_SCOPES =
"offline_access accounting.transactions accounting.contacts accounting.attachments accounting.settings";

/** Shared OAuth2 (authorization-code) client config for web-auth mode. */
export function buildWebAuthConfig(): IXeroClientConfig {
return {
clientId: client_id!,
clientSecret: client_secret!,
redirectUris: [redirect_uri],
scopes: (process.env.XERO_SCOPES || DEFAULT_WEB_AUTH_SCOPES).split(" "),
};
}

if (!bearer_token && (!client_id || !client_secret)) {
if (auth_mode === "web") {
if (!client_id || !client_secret) {
throw Error(
"XERO_AUTH_MODE=web requires XERO_CLIENT_ID and XERO_CLIENT_SECRET",
);
}
} else if (!bearer_token && (!client_id || !client_secret)) {
throw Error("Environment Variables not set - please check your .env file");
}

Expand Down Expand Up @@ -220,12 +245,88 @@ class BearerTokenXeroClient extends MCPXeroClient {
}
}

export const xeroClient = bearer_token
? new BearerTokenXeroClient({
bearerToken: bearer_token,
})
: new CustomConnectionsXeroClient({
clientId: client_id!,
clientSecret: client_secret!,
grantType: grant_type,
});
/**
* Standard OAuth2 Authorization Code flow ("web auth"), backed by a refresh
* token persisted on disk. The one-time consent is performed out-of-band by the
* `npm run auth` bootstrap CLI (src/auth.ts); this client only loads and
* refreshes the resulting tokenset.
*/
class WebAuthXeroClient extends MCPXeroClient {
// Skip re-reading/refreshing on every call while the access token is fresh.
private accessTokenExpiresAt = 0;

constructor() {
super(buildWebAuthConfig());
}

public async authenticate(): Promise<void> {
const now = Math.floor(Date.now() / 1000);
if (this.accessTokenExpiresAt - 60 > now && this.tenantId) {
return; // still valid
}

const saved = loadTokenSet();
if (!saved || !saved.refresh_token) {
throw new Error(
"No Xero tokenset found (web auth). Run `npm run auth` to complete consent.",
);
}

this.setTokenSet(saved);

const current = this.readTokenSet();
if (current.expired()) {
try {
await this.initialize();
const refreshed = await this.refreshToken();
saveTokenSet({ ...refreshed, tenantId: saved.tenantId });
this.accessTokenExpiresAt = refreshed.expires_at ?? 0;
} catch (error: unknown) {
const err = ensureError(error);
throw new Error(
`Failed to refresh Xero token: ${err.message}. The refresh token may be revoked or rotated — re-run \`npm run auth\`.`,
);
}
} else {
this.accessTokenExpiresAt = current.expires_at ?? 0;
}

// Resolve the tenant: explicit env override > persisted value > live lookup.
if (process.env.XERO_TENANT_ID) {
this.tenantId = process.env.XERO_TENANT_ID;
} else if (saved.tenantId) {
this.tenantId = saved.tenantId;
} else {
await this.updateTenants(false);
}
}
}

function createXeroClient(): MCPXeroClient {
switch (auth_mode) {
case "web":
return new WebAuthXeroClient();
case "bearer":
if (!bearer_token) {
throw Error("XERO_AUTH_MODE=bearer requires XERO_CLIENT_BEARER_TOKEN");
}
return new BearerTokenXeroClient({ bearerToken: bearer_token });
case "custom":
return new CustomConnectionsXeroClient({
clientId: client_id!,
clientSecret: client_secret!,
grantType: grant_type,
});
default:
// Unset: preserve original behaviour.
return bearer_token
? new BearerTokenXeroClient({ bearerToken: bearer_token })
: new CustomConnectionsXeroClient({
clientId: client_id!,
clientSecret: client_secret!,
grantType: grant_type,
});
}
}

export const xeroClient = createXeroClient();