Skip to content

Commit 0934496

Browse files
Support granular OAuth scopes for new Custom Connections (#139)
* add support for granular scopes * Update README and xero-client to support granular scopes for custom connections * Rename scope constants for clarity * Improve error handling for token retrieval in CustomConnectionsXeroClient * remove redundant scopes * Make custom connection scopes configurable (#136) * Make custom connections scopes configurable * update README * Update README.md with new release date --------- Co-authored-by: Amanda Ducrou <amanda@scopego.co>
1 parent b3b12d2 commit 0934496

2 files changed

Lines changed: 109 additions & 36 deletions

File tree

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,18 @@ It is also the recommended approach if you are integrating this into 3rd party M
4747

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

50-
Currently the following scopes are required for all sessions: [scopes](src/clients/xero-client.ts#L91-L92)
50+
##### Required Scopes
51+
52+
Custom connections require different scopes depending on when they were created. **All scopes in the relevant list must be added to your custom connection:**
53+
54+
| Custom Connection Created | Required Scopes |
55+
|---------------------------|-----------------|
56+
| Before Apr 29, 2026 | [SCOPES_V1](src/clients/xero-client.ts#L82-L90) (bundled permissions) |
57+
| From Apr 29, 2026 | [SCOPES_V2](src/clients/xero-client.ts#L93-L112) (granular permissions) |
58+
59+
> **Note:** The MCP server automatically tries V1 scopes first and falls back to V2 if needed.
60+
>
61+
> You can override these by setting the `XERO_SCOPES` environment variable to a space-separated list of scopes.
5162
5263
##### Integrating the MCP server with Claude Desktop
5364

@@ -61,13 +72,16 @@ To add the MCP server to Claude go to Settings > Developer > Edit config and add
6172
"args": ["-y", "@xeroapi/xero-mcp-server@latest"],
6273
"env": {
6374
"XERO_CLIENT_ID": "your_client_id_here",
64-
"XERO_CLIENT_SECRET": "your_client_secret_here"
75+
"XERO_CLIENT_SECRET": "your_client_secret_here",
76+
"XERO_SCOPES": "accounting.invoices accounting.contacts accounting.settings"
6577
}
6678
}
6779
}
6880
}
6981
```
7082

83+
The `XERO_SCOPES` variable is optional. If omitted, the default scopes listed above will be used.
84+
7185
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
7286

7387
#### 2. Bearer Token

src/clients/xero-client.ts

Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,34 @@ class CustomConnectionsXeroClient extends MCPXeroClient {
7878
private readonly clientId: string;
7979
private readonly clientSecret: string;
8080

81+
// Legacy scopes (deprecated but still supported for existing apps)
82+
private readonly XERO_DEFAULT_AUTH_SCOPES_V1 = [
83+
"accounting.transactions",
84+
"accounting.contacts",
85+
"accounting.settings",
86+
"accounting.reports.read",
87+
"payroll.settings",
88+
"payroll.employees",
89+
"payroll.timesheets",
90+
].join(" ");
91+
92+
// Granular scopes (required for new apps)
93+
private readonly XERO_DEFAULT_AUTH_SCOPES_V2 = [
94+
"accounting.invoices",
95+
"accounting.payments",
96+
"accounting.banktransactions",
97+
"accounting.manualjournals",
98+
"accounting.reports.aged.read",
99+
"accounting.reports.balancesheet.read",
100+
"accounting.reports.profitandloss.read",
101+
"accounting.reports.trialbalance.read",
102+
"accounting.contacts",
103+
"accounting.settings",
104+
"payroll.settings",
105+
"payroll.employees",
106+
"payroll.timesheets",
107+
].join(" ");
108+
81109
constructor(config: {
82110
clientId: string;
83111
clientSecret: string;
@@ -88,49 +116,80 @@ class CustomConnectionsXeroClient extends MCPXeroClient {
88116
this.clientSecret = config.clientSecret;
89117
}
90118

119+
private formatTokenError(error: unknown, context: string): Error {
120+
const axiosError = error as AxiosError;
121+
const data = axiosError.response?.data;
122+
const message =
123+
typeof data === "object" ? JSON.stringify(data) : data || axiosError.message;
124+
return new Error(`Failed to get Xero token${context}: ${message}`);
125+
}
126+
91127
public async getClientCredentialsToken(): Promise<TokenSet> {
92-
const scope =
93-
"accounting.transactions accounting.contacts accounting.settings accounting.reports.read payroll.settings payroll.employees payroll.timesheets";
128+
// If XERO_SCOPES is set, use that
129+
if (process.env.XERO_SCOPES) {
130+
try {
131+
return await this.requestToken(process.env.XERO_SCOPES);
132+
} catch (envError) {
133+
throw this.formatTokenError(envError, " with XERO_SCOPES");
134+
}
135+
}
136+
137+
// Else if XERO_SCOPES is not set, try V1 scopes first (for existing apps), fallback to V2 scopes (for new apps) only on invalid_scope error
138+
try {
139+
return await this.requestToken(this.XERO_DEFAULT_AUTH_SCOPES_V1);
140+
} catch (error) {
141+
const axiosError = error as AxiosError;
142+
const isInvalidScope =
143+
axiosError.response?.status === 400 &&
144+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
145+
(axiosError.response?.data as any)?.error === "invalid_scope";
146+
147+
if (!isInvalidScope) {
148+
throw this.formatTokenError(error, " with V1 scopes");
149+
}
150+
151+
try {
152+
return await this.requestToken(this.XERO_DEFAULT_AUTH_SCOPES_V2);
153+
} catch (v2Error) {
154+
throw this.formatTokenError(v2Error, " with V2 scopes");
155+
}
156+
}
157+
}
158+
159+
private async requestToken(scope: string): Promise<TokenSet> {
94160
const credentials = Buffer.from(
95161
`${this.clientId}:${this.clientSecret}`,
96162
).toString("base64");
97163

98-
try {
99-
const response = await axios.post(
100-
"https://identity.xero.com/connect/token",
101-
`grant_type=client_credentials&scope=${encodeURIComponent(scope)}`,
102-
{
103-
headers: {
104-
Authorization: `Basic ${credentials}`,
105-
"Content-Type": "application/x-www-form-urlencoded",
106-
Accept: "application/json",
107-
},
108-
},
109-
);
110-
111-
// Get the tenant ID from the connections endpoint
112-
const token = response.data.access_token;
113-
const connectionsResponse = await axios.get(
114-
"https://api.xero.com/connections",
115-
{
116-
headers: {
117-
Authorization: `Bearer ${token}`,
118-
Accept: "application/json",
119-
},
164+
const response = await axios.post(
165+
"https://identity.xero.com/connect/token",
166+
`grant_type=client_credentials&scope=${encodeURIComponent(scope)}`,
167+
{
168+
headers: {
169+
Authorization: `Basic ${credentials}`,
170+
"Content-Type": "application/x-www-form-urlencoded",
171+
Accept: "application/json",
120172
},
121-
);
173+
},
174+
);
122175

123-
if (connectionsResponse.data && connectionsResponse.data.length > 0) {
124-
this.tenantId = connectionsResponse.data[0].tenantId;
125-
}
176+
// Get the tenant ID from the connections endpoint
177+
const token = response.data.access_token;
178+
const connectionsResponse = await axios.get(
179+
"https://api.xero.com/connections",
180+
{
181+
headers: {
182+
Authorization: `Bearer ${token}`,
183+
Accept: "application/json",
184+
},
185+
},
186+
);
126187

127-
return response.data;
128-
} catch (error) {
129-
const axiosError = error as AxiosError;
130-
throw new Error(
131-
`Failed to get Xero token: ${axiosError.response?.data || axiosError.message}`,
132-
);
188+
if (connectionsResponse.data && connectionsResponse.data.length > 0) {
189+
this.tenantId = connectionsResponse.data[0].tenantId;
133190
}
191+
192+
return response.data;
134193
}
135194

136195
public async authenticate() {

0 commit comments

Comments
 (0)