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
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# Xero API Configuration for Custom Connections
XERO_CLIENT_ID=your_client_id_here
XERO_CLIENT_SECRET=your_client_secret_here
XERO_CLIENT_SECRET=your_client_secret_here

# Optional: space-separated OAuth scopes for custom connections (defaults match README).
# XERO_SCOPES=accounting.transactions accounting.contacts accounting.settings accounting.reports.read payroll.settings payroll.employees payroll.timesheets

# Optional: use a pre-obtained bearer token instead of client id/secret (takes precedence when set).
# XERO_CLIENT_BEARER_TOKEN=

# Optional: log to stderr how many MCP tools were registered vs omitted by XERO_SCOPES (custom connections only).
# XERO_MCP_LOG_SCOPE_FILTERING=1
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,38 @@ It is also the recommended approach if you are integrating this into 3rd party M

Set up a Custom Connection following these instructions: https://developer.xero.com/documentation/guides/oauth2/custom-connections/

Currently the following scopes are required for all sessions: [scopes](src/clients/xero-client.ts#L91-L92)
The following [default scopes](src/helpers/scopes.ts) are requested for all custom connection sessions when `XERO_SCOPES` is not set:

```
accounting.transactions
accounting.contacts
accounting.settings
accounting.reports.read
payroll.settings
payroll.employees
payroll.timesheets
```

You can override these by setting the `XERO_SCOPES` environment variable to a space-separated list of scopes.

When using **custom connections**, tools are **registered only if** your configured scopes satisfy each tool’s requirements (see table below). That avoids offering payroll (or other) tools when the access token would not have permission. Typos or unknown scope names log a warning but are still sent to the token endpoint.

Tool registration compares against **custom-connection style** names (e.g. `accounting.transactions`). If you set `XERO_SCOPES` to **granular** names from the bearer-token list below (e.g. `accounting.invoices` only), tools expecting `accounting.transactions` will not register even though your token may be valid—either include the matching broad scope or use bearer token mode (which registers all tools).

Set `XERO_MCP_LOG_SCOPE_FILTERING=1` to print how many tools were registered vs omitted (to **stderr**; safe for MCP stdio transports).

When using **bearer token** auth, `XERO_SCOPES` is not applied to token issuance (your token was created elsewhere). **All tools are registered** in that mode—ensure the token’s scopes match the APIs you use.

##### Scope groups and MCP tools

| Required scopes (all must be present) | MCP tools |
| --- | --- |
| `accounting.transactions` | Invoices, credit notes, quotes, payments, bank transactions, manual journals (list / create / update) |
| `accounting.contacts` | Contacts, contact groups (list / create / update) |
| `accounting.settings` | Accounts, items, tax rates, tracking categories and options, organisation details |
| `accounting.reports.read` | Profit and loss, balance sheet, trial balance, aged receivables / payables by contact |
| `payroll.settings` and `payroll.employees` | Payroll employees, employee leave, leave types, leave periods, leave balances |
| `payroll.settings` and `payroll.timesheets` | Payroll timesheets (list, get, create, delete, approve, revert, add/update lines) |

##### Integrating the MCP server with Claude Desktop

Expand All @@ -61,13 +92,16 @@ To add the MCP server to Claude go to Settings > Developer > Edit config and add
"args": ["-y", "@xeroapi/xero-mcp-server@latest"],
"env": {
"XERO_CLIENT_ID": "your_client_id_here",
"XERO_CLIENT_SECRET": "your_client_secret_here"
"XERO_CLIENT_SECRET": "your_client_secret_here",
"XERO_SCOPES": "accounting.transactions accounting.contacts accounting.settings accounting.reports.read"
}
}
}
}
```

The `XERO_SCOPES` variable is optional. If omitted, the default scopes listed above will be used.

NOTE: If you are using [Node Version Manager](https://github.com/nvm-sh/nvm) `"command": "npx"` section change it to be the full path to the executable, ie: `your_home_directory/.nvm/versions/node/v22.14.0/bin/npx` on Mac / Linux or `"your_home_directory\\.nvm\\versions\\node\\v22.14.0\\bin\\npx"` on Windows

#### 2. Bearer Token
Expand Down
8 changes: 0 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/clients/xero-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "xero-node";

import { ensureError } from "../helpers/ensure-error.js";
import { getConfiguredScopeString } from "../helpers/scopes.js";

dotenv.config();

Expand Down Expand Up @@ -89,8 +90,7 @@ class CustomConnectionsXeroClient extends MCPXeroClient {
}

public async getClientCredentialsToken(): Promise<TokenSet> {
const scope =
"accounting.transactions accounting.contacts accounting.settings accounting.reports.read payroll.settings payroll.employees payroll.timesheets";
const scope = getConfiguredScopeString();
const credentials = Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString("base64");
Expand Down
2 changes: 2 additions & 0 deletions src/helpers/create-xero-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export const CreateXeroTool =
description: string,
schema: Args,
handler: ToolCallback<Args>,
requiredScopes: string[] = [],
): (() => ToolDefinition<ZodRawShapeCompat>) =>
() => ({
name: name,
description: description,
schema: schema,
handler: handler,
requiredScopes,
});
122 changes: 122 additions & 0 deletions src/helpers/scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* OAuth scopes for custom connections and tool gating.
* @see https://developer.xero.com/documentation/guides/oauth2/scopes/
*/

/** Space-separated scopes requested for custom connections when `XERO_SCOPES` is unset. */
export const DEFAULT_SCOPE_STRING =
"accounting.transactions accounting.contacts accounting.settings accounting.reports.read payroll.settings payroll.employees payroll.timesheets";

/**
* Known Xero OAuth2 scope strings (custom-connection style + common granular bearer scopes).
* Unknown scopes from `XERO_SCOPES` are still passed through to the token request after a warning.
*/
export const VALID_SCOPES: ReadonlySet<string> = new Set([
// Accounting — custom connection / legacy
"accounting.transactions",
"accounting.transactions.read",
"accounting.contacts",
"accounting.settings",
"accounting.reports.read",
// Granular accounting (bearer / newer apps)
"accounting.invoices",
"accounting.invoices.read",
"accounting.payments",
"accounting.payments.read",
"accounting.banktransactions",
"accounting.banktransactions.read",
"accounting.manualjournals",
"accounting.manualjournals.read",
"accounting.reports.aged.read",
"accounting.reports.balancesheet.read",
"accounting.reports.profitandloss.read",
"accounting.reports.trialbalance.read",
// Payroll
"payroll.settings",
"payroll.employees",
"payroll.timesheets",
"payroll.payruns",
"payroll.payslip",
]);

const ENV_KEY = "XERO_SCOPES";

function splitScopeString(raw: string): string[] {
return raw
.trim()
.split(/\s+/)
.map((s) => s.trim())
.filter(Boolean);
}

/** True when using client id/secret (custom connections); false when bearer token overrides. */
export function isCustomConnectionsAuthMode(): boolean {
return !process.env.XERO_CLIENT_BEARER_TOKEN?.trim();
}

let cachedScopes: Set<string> | undefined;
let cachedScopeString: string | undefined;

function parseAndValidateScopeString(scopeString: string): Set<string> {
const parts = splitScopeString(scopeString);
if (parts.length === 0) {
throw new Error(
`No OAuth scopes after parsing (whitespace-only or invalid ${ENV_KEY}?). Provide a space-separated list or unset ${ENV_KEY} to use defaults.`,
);
}
const result = new Set<string>();
for (const scope of parts) {
if (!VALID_SCOPES.has(scope)) {
console.warn(
`[xero-mcp-server] Unknown OAuth scope "${scope}" (not in built-in allowlist). It will still be sent to Xero. Check for typos.`,
);
}
result.add(scope);
}
return result;
}

/**
* Parsed scopes for the current process. Used for custom-connection token requests and tool registration.
* Cached after first call.
*/
export function getConfiguredScopes(): Set<string> {
if (cachedScopes) {
return cachedScopes;
}
const raw = process.env[ENV_KEY]?.trim();
const scopeString = raw && raw.length > 0 ? raw : DEFAULT_SCOPE_STRING;
cachedScopes = parseAndValidateScopeString(scopeString);
cachedScopeString = [...cachedScopes].join(" ");
return cachedScopes;
}

/** Space-separated scopes for the identity server token request (custom connections). */
export function getConfiguredScopeString(): string {
if (cachedScopeString) {
return cachedScopeString;
}
getConfiguredScopes();
return cachedScopeString!;
}

/** Whether a tool's required scopes are all present in the configured set. */
export function scopesSatisfyTool(
configured: Set<string>,
requiredScopes: string[] | undefined,
): boolean {
if (!requiredScopes?.length) {
return true;
}
return requiredScopes.every((s) => configured.has(s));
}

/** Declarative scope groups for MCP tools (custom-connection scope names). */
export const ToolScopes = {
accountingTransactions: ["accounting.transactions"],
accountingContacts: ["accounting.contacts"],
accountingSettings: ["accounting.settings"],
accountingReportsRead: ["accounting.reports.read"],
payrollEmployees: ["payroll.settings", "payroll.employees"],
payrollTimesheets: ["payroll.settings", "payroll.timesheets"],
} as const satisfies Record<string, string[]>;
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node

import "dotenv/config";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { XeroMcpServer } from "./server/xero-mcp-server.js";
import { ToolFactory } from "./tools/tool-factory.js";
Expand Down
4 changes: 3 additions & 1 deletion src/tools/create/create-bank-transaction.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { CreateXeroTool } from "../../helpers/create-xero-tool.js";
import { createXeroBankTransaction } from "../../handlers/create-xero-bank-transaction.handler.js";
import { bankTransactionDeepLink } from "../../consts/deeplinks.js";
import { ToolScopes } from "../../helpers/scopes.js";

const lineItemSchema = z.object({
description: z.string(),
Expand Down Expand Up @@ -63,7 +64,8 @@ const CreateBankTransactionTool = CreateXeroTool(
},
],
};
}
},
ToolScopes.accountingTransactions
);

export default CreateBankTransactionTool;
2 changes: 2 additions & 0 deletions src/tools/create/create-contact.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";
import { DeepLinkType, getDeepLink } from "../../helpers/get-deeplink.js";
import { ensureError } from "../../helpers/ensure-error.js";
import { CreateXeroTool } from "../../helpers/create-xero-tool.js";
import { ToolScopes } from "../../helpers/scopes.js";

const CreateContactTool = CreateXeroTool(
"create-contact",
Expand Down Expand Up @@ -61,6 +62,7 @@ const CreateContactTool = CreateXeroTool(
};
}
},
ToolScopes.accountingContacts
);

export default CreateContactTool;
2 changes: 2 additions & 0 deletions src/tools/create/create-credit-note.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { createXeroCreditNote } from "../../handlers/create-xero-credit-note.handler.js";
import { DeepLinkType, getDeepLink } from "../../helpers/get-deeplink.js";
import { CreateXeroTool } from "../../helpers/create-xero-tool.js";
import { ToolScopes } from "../../helpers/scopes.js";

const lineItemSchema = z.object({
description: z.string(),
Expand Down Expand Up @@ -59,6 +60,7 @@ const CreateCreditNoteTool = CreateXeroTool(
],
};
},
ToolScopes.accountingTransactions
);

export default CreateCreditNoteTool;
2 changes: 2 additions & 0 deletions src/tools/create/create-invoice.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createXeroInvoice } from "../../handlers/create-xero-invoice.handler.js
import { DeepLinkType, getDeepLink } from "../../helpers/get-deeplink.js";
import { CreateXeroTool } from "../../helpers/create-xero-tool.js";
import { Invoice } from "xero-node";
import { ToolScopes } from "../../helpers/scopes.js";

const trackingSchema = z.object({
name: z.string().describe("The name of the tracking category. Can be obtained from the list-tracking-categories tool"),
Expand Down Expand Up @@ -85,6 +86,7 @@ const CreateInvoiceTool = CreateXeroTool(
],
};
},
ToolScopes.accountingTransactions
);

export default CreateInvoiceTool;
2 changes: 2 additions & 0 deletions src/tools/create/create-item.tool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { createXeroItem } from "../../handlers/create-xero-item.handler.js";
import { CreateXeroTool } from "../../helpers/create-xero-tool.js";
import { ToolScopes } from "../../helpers/scopes.js";

const purchaseDetailsSchema = z.object({
unitPrice: z.number(),
Expand Down Expand Up @@ -77,6 +78,7 @@ const CreateItemTool = CreateXeroTool(
],
};
},
ToolScopes.accountingSettings
);

export default CreateItemTool;
2 changes: 2 additions & 0 deletions src/tools/create/create-manual-journal.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createXeroManualJournal } from "../../handlers/create-xero-manual-journ
import { DeepLinkType, getDeepLink } from "../../helpers/get-deeplink.js";
import { ensureError } from "../../helpers/ensure-error.js";
import { LineAmountTypes, ManualJournal } from "xero-node";
import { ToolScopes } from "../../helpers/scopes.js";

const CreateManualJournalTool = CreateXeroTool(
"create-manual-journal",
Expand Down Expand Up @@ -145,6 +146,7 @@ const CreateManualJournalTool = CreateXeroTool(
};
}
},
ToolScopes.accountingTransactions
);

export default CreateManualJournalTool;
Loading