diff --git a/.gitignore b/.gitignore index 28262fea..1471aced 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ dist # Cursor IDE .cursor/ + +# Xero web-auth persisted tokenset (contains refresh/access tokens) +.xero-tokenset.json diff --git a/README.md b/README.md index 40044369..be5f0b0e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 → **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 | `/.xero-tokenset.json` | +| `XERO_TENANT_ID` | pin a specific org | first connected tenant | + ### Available MCP Commands diff --git a/package.json b/package.json index 590fcb76..00899583 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 00000000..8ee70797 --- /dev/null +++ b/src/auth.ts @@ -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 { + 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((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( + "

Xero connected ✓

You may close this tab and return to the terminal.

", + ); + + 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("

Auth failed

Check the terminal for details.

"); + 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); + }); diff --git a/src/clients/token-store.ts b/src/clients/token-store.ts new file mode 100644 index 00000000..443d2437 --- /dev/null +++ b/src/clients/token-store.ts @@ -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 + } +} diff --git a/src/clients/xero-client.ts b/src/clients/xero-client.ts index 2a0097c3..42ae5ef8 100644 --- a/src/clients/xero-client.ts +++ b/src/clients/xero-client.ts @@ -8,6 +8,7 @@ import { } from "xero-node"; import { ensureError } from "../helpers/ensure-error.js"; +import { loadTokenSet, saveTokenSet } from "./token-store.js"; dotenv.config(); @@ -15,8 +16,32 @@ 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"); } @@ -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 { + 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();