From 9399b2e947e463a261db047cfd5426da57d537ba Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Thu, 8 Oct 2026 06:48:28 +1100 Subject: [PATCH 1/3] Add read-only Xero accounting adapter (#150) --- CITATION.cff | 2 +- README.de.md | 6 +- README.ja.md | 6 +- README.md | 6 +- README.zh-CN.md | 6 +- glama.json | 2 +- package.json | 2 +- packages/backend/src/adapters/catalog.ts | 2 + packages/backend/src/adapters/intl/xero.json | 185 +++++++++++++ .../src/adapters/intl/xero.live.spec.ts | 253 ++++++++++++++++++ .../frontend/public/logos/connectors/xero.svg | 1 + server.json | 2 +- 12 files changed, 457 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/adapters/intl/xero.json create mode 100644 packages/backend/src/adapters/intl/xero.live.spec.ts create mode 100644 packages/frontend/public/logos/connectors/xero.svg diff --git a/CITATION.cff b/CITATION.cff index e683380e..5e3bd7ec 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ abstract: > server that turns REST/OpenAPI, SOAP/WSDL and GraphQL APIs, SQL/NoSQL databases and other MCP servers into tools for AI clients such as Claude, ChatGPT, Google Gemini, GitHub Copilot and Cursor, without writing code. - It ships 319 pre-built adapters, with a focus on ERP and e-commerce + It ships 320 pre-built adapters, with a focus on ERP and e-commerce systems (SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO, etc.), a per-workspace knowledge graph served over MCP, per-tool response mapping, diff --git a/README.de.md b/README.de.md index 03bb2c9c..82bd0fc9 100644 --- a/README.de.md +++ b/README.de.md @@ -1,5 +1,5 @@

- AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 319 Connectors, 17 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 320 Connectors, 17 davon ohne API-Schlüssel.

AnythingMCP: selbst gehostetes MCP-Gateway

@@ -19,7 +19,7 @@

AnythingMCP ist ein quelloffenes, selbst gehostetes MCP-Gateway, das jedes REST-/OpenAPI-, SOAP-, GraphQL-, OData- oder SQL-System in MCP-Tools für Claude, ChatGPT und Copilot verwandelt, ohne dass du einen MCP-Server programmierst.
- Es bringt 319 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 17 davon kommen ohne API-Schlüssel aus. + Es bringt 320 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 17 davon kommen ohne API-Schlüssel aus.

@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec ## Connector-Katalog -319 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. +320 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. | Kategorie | Beispiele | |---|---| diff --git a/README.ja.md b/README.ja.md index e78205b5..5404a76f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。319 のコネクター、うち 17 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。320 のコネクター、うち 17 は API キー不要。

AnythingMCP:セルフホスト型 MCP ゲートウェイ

@@ -19,7 +19,7 @@

AnythingMCP は、オープンソースのセルフホスト型 MCP ゲートウェイです。MCP サーバーを書かずに、REST/OpenAPI、SOAP、GraphQL、OData、SQL のあらゆるシステムを Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- SAP、Etsy、weclapp、Amazon Seller などを含む 319 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。 + SAP、Etsy、weclapp、Amazon Seller などを含む 320 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。

@@ -111,7 +111,7 @@ amd64 ではイメージの取得に約 30 秒、その 24 秒後に API が利 ## コネクターカタログ -319 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 +320 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 | カテゴリー | 例 | |---|---| diff --git a/README.md b/README.md index bc126467..6c5ace3c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 319 connectors, 16 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 320 connectors, 16 of them with no API key.

AnythingMCP: self-hosted MCP gateway

@@ -19,7 +19,7 @@

AnythingMCP is an open-source, self-hosted MCP gateway that turns any REST/OpenAPI, SOAP, GraphQL, OData or SQL system into MCP tools for Claude, ChatGPT and Copilot, without writing an MCP server.
- It ships 319 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 17 of them need no API key. + It ships 320 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 17 of them need no API key.

@@ -103,7 +103,7 @@ Tools register at runtime, without a restart. Per-connector `{{VAR}}` values are ## Connector catalog -319 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. +320 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. | Category | Examples | |---|---| diff --git a/README.zh-CN.md b/README.zh-CN.md index 7a3bfd65..ebee26da 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,5 @@

- AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:319 个连接器,其中 17 个无需 API 密钥。 + AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:320 个连接器,其中 17 个无需 API 密钥。

AnythingMCP:自行托管的 MCP 网关

@@ -19,7 +19,7 @@

AnythingMCP 是一个开源、可自行托管的 MCP 网关,无需编写 MCP 服务器,即可将任意 REST/OpenAPI、SOAP、GraphQL、OData 或 SQL 系统转化为 Claude、ChatGPT 和 Copilot 可用的 MCP 工具。
- 它自带 319 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 17 个无需 API 密钥。 + 它自带 320 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 17 个无需 API 密钥。

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 319 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 320 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/glama.json b/glama.json index bdf01077..a9ccacd6 100644 --- a/glama.json +++ b/glama.json @@ -3,5 +3,5 @@ "maintainers": [ "keysersoft" ], - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 319 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 320 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." } diff --git a/package.json b/package.json index 1039f31f..2775fccd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.19.0", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 319 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 320 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 84b1ed08..d403d808 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -301,6 +301,7 @@ import * as workable from './intl/workable.json'; import * as worksection from './intl/worksection.json'; import * as wrike from './intl/wrike.json'; import * as wufoo from './intl/wufoo.json'; +import * as xero from './intl/xero.json'; import * as youcom from './intl/youcom.json'; import * as youtrack from './intl/youtrack.json'; import * as youtubeData from './intl/youtube-data.json'; @@ -829,6 +830,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ worksection as unknown as AdapterDefinition, wrike as unknown as AdapterDefinition, wufoo as unknown as AdapterDefinition, + xero as unknown as AdapterDefinition, youcom as unknown as AdapterDefinition, youtrack as unknown as AdapterDefinition, youtubeData as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/xero.json b/packages/backend/src/adapters/intl/xero.json new file mode 100644 index 00000000..2b630c32 --- /dev/null +++ b/packages/backend/src/adapters/intl/xero.json @@ -0,0 +1,185 @@ +{ + "slug": "xero", + "name": "Xero", + "description": "Read a Xero organisation's invoices, bills, contacts, chart of accounts, Trial Balance and Profit and Loss reports. 9 GET-only tools with OAuth2 and a fixed tenant ID.", + "instructions": "This connector reads the Xero Accounting API for one organisation. All nine tools use GET; no tool creates, updates or deletes accounting data.\n\n**Setup**\nInstallation requires the organisation's tenant UUID. If you are starting with a new Xero app and no token, obtain it through the external Postman authorisation below before installing this connector.\n1. At https://developer.xero.com/app/manage create an OAuth2 Web app using the authorisation code flow. Register `https://cloud.anythingmcp.com/api/mcp-oauth/callback` for AnythingMCP Cloud, or `/api/mcp-oauth/callback` for a self-hosted server. The redirect URI must match exactly. Copy the Client ID and Client Secret into `XERO_CLIENT_ID` and `XERO_CLIENT_SECRET`.\n2. To obtain your first access token, also register `https://oauth.pstmn.io/v1/browser-callback` on that Xero app. In Postman Desktop create a GET request to `https://api.xero.com/connections`. On its Authorization tab select OAuth 2.0, grant type Authorization Code and Authorize using browser. Set that Postman callback URL, Auth URL `https://login.xero.com/identity/connect/authorize`, Access Token URL `https://identity.xero.com/connect/token`, your app's Client ID and Client Secret, the read-only scope string below, a random State, and Client Authentication **Send as Basic Auth header**. Select **Get New Access Token**, sign in, choose the intended organisation and select **Use Token**. Keep Share Token off. Then send the GET Connections request. See https://learning.postman.com/docs/use/send-requests/authorization/oauth-20/ and Xero's walkthrough at https://github.com/XeroAPI/xero-postman-oauth2; use this connector's current read-only scopes rather than the walkthrough's older scope examples.\n3. If you already have an authorised OAuth session for the same app, you can skip the Postman token setup and make `GET https://api.xero.com/connections` with `Authorization: Bearer `. In either case choose the entry with the intended `tenantName` and `tenantType: ORGANISATION`, and copy its `tenantId` into `XERO_TENANT_ID`. The connection's `id` and `authEventId` are different values and must not be used. See https://developer.xero.com/documentation/guides/oauth2/tenants/. Connections is outside this connector's Accounting base URL, so it is documented here instead of exposed as a tool.\n4. Leave `XERO_REFRESH_TOKEN` empty, install, then open the connector and click **Authorize with Provider**. Sign in to Xero and connect the same organisation selected above. AnythingMCP exchanges the code at Xero Identity using HTTP Basic client authentication and stores the tokens. Stop using the Postman bootstrap tokens; AnythingMCP maintains its own tokens.\n5. Run `xero_get_organisation` with `{}` and check the returned organisation name before reading its accounts or transactions. Install a separate connector for each organisation.\n\n**Read-only scopes**\n`openid offline_access accounting.invoices.read accounting.contacts.read accounting.settings.read accounting.reports.trialbalance.read accounting.reports.profitandloss.read`. These are the current granular scopes; deprecated `accounting.transactions.read` and `accounting.reports.read` are not requested. Identity scopes are limited to `openid` and `offline_access`. Xero consent is additive: a token previously granted write permissions keeps them. To reduce permissions, revoke the old connection in Xero and authorise again with these scopes. See https://developer.xero.com/documentation/guides/oauth2/scopes/.\n\n**Tokens and tenant header**\nEvery Accounting call includes `xero-tenant-id: XERO_TENANT_ID` and a bearer access token. Access tokens last 30 minutes; `offline_access` supplies refresh tokens, which expire after 60 days without renewal. AnythingMCP refreshes automatically and saves replacement refresh tokens. You may supply `XERO_REFRESH_TOKEN` only if you already obtained a current token for the same app and read-only consent. Do not share a rotating refresh token between integrations. Changing the tenant ID changes the organisation every tool reads.\n\n**Lists and identifiers**\nInvoices include sales invoices (ACCREC) and purchase bills (ACCPAY). Invoices and contacts use 1-based `page` and `pageSize`, defaulting to page 1 with 100 records. Request subsequent pages until the nested Invoices or Contacts array is empty or shorter than pageSize. Accounts returns the chart of accounts without pagination. `where` uses Xero filter syntax, for example `Type==\"ACCREC\"` or `ContactStatus==\"ACTIVE\"`; `order` accepts values such as `InvoiceNumber ASC` and `Name ASC`. Get tools require the UUID returned by the corresponding list, not an invoice number, contact name or account code. List and detail responses retain Xero's `Invoices`, `Contacts`, `Accounts` and `Organisations` envelopes.\n\n**Reports**\nPass `YYYY-MM-DD` dates: Trial Balance uses `date`; Profit and Loss uses `fromDate` and `toDate` with fromDate no later than toDate. `paymentsOnly` defaults to false for accrual reports; set true for cash reports. Profit and Loss can compare 1 to 12 periods using `periods` and `timeframe` (MONTH, QUARTER or YEAR). Reports retain the `Reports` envelope and nested Rows/Cells; financial values may be strings. The authorising Xero user needs access to reports.\n\n**Errors and limits**\nFor 401 or 403 check consent, token expiry, Xero user permissions and whether the tenant is connected to this app. On HTTP 429, pause requests to that tenant for the `Retry-After` seconds; inspect `X-Rate-Limit-Problem` and retry with backoff instead of immediately paging again. Limits depend on the app tier: https://developer.xero.com/documentation/guides/oauth2/limits/.\n\n**Cloud and self-hosted**\nBoth use Xero's public HTTPS Accounting and Identity endpoints with the same scopes and tenant header; only the registered callback differs. Live Xero authorisation and API calls have not been verified for this adapter.", + "region": "intl", + "category": "accounting", + "icon": "xero", + "docsUrl": "https://developer.xero.com/documentation/api/accounting/overview/", + "requiredEnvVars": ["XERO_CLIENT_ID", "XERO_CLIENT_SECRET", "XERO_TENANT_ID"], + "optionalEnvVars": ["XERO_REFRESH_TOKEN"], + "envVarMeta": { + "XERO_CLIENT_ID": { + "label": "Client ID", + "kind": "credential", + "help": "Xero Developer > My Apps > your OAuth2 Web app > Configuration > Client ID.", + "link": "https://developer.xero.com/app/manage" + }, + "XERO_CLIENT_SECRET": { + "label": "Client secret", + "kind": "credential", + "help": "Generate a client secret for the same Xero OAuth2 Web app." + }, + "XERO_TENANT_ID": { + "label": "Organisation tenant ID", + "kind": "setting", + "help": "The tenantId UUID of the intended ORGANISATION from GET https://api.xero.com/connections. Do not use the connection id. Obtain it before installing and verify the organisation with xero_get_organisation.", + "link": "https://developer.xero.com/documentation/guides/oauth2/tenants/", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "patternMessage": "Enter the organisation's tenantId as a UUID." + }, + "XERO_REFRESH_TOKEN": { + "label": "Refresh token", + "kind": "credential", + "help": "Leave empty for Authorize with Provider. Supply only a current refresh token for this app with read-only consent; AnythingMCP stores rotated replacements.", + "advanced": true + } + }, + "connector": { + "name": "Xero Accounting API", + "type": "REST", + "baseUrl": "https://api.xero.com/api.xro/2.0", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{XERO_CLIENT_ID}}", + "clientSecret": "{{XERO_CLIENT_SECRET}}", + "refreshToken": "{{XERO_REFRESH_TOKEN}}", + "grant": "refresh_token", + "authorizationUrl": "https://login.xero.com/identity/connect/authorize", + "tokenUrl": "https://identity.xero.com/connect/token", + "tokenAuthMethod": "client_secret_basic", + "scopes": "openid offline_access accounting.invoices.read accounting.contacts.read accounting.settings.read accounting.reports.trialbalance.read accounting.reports.profitandloss.read", + "extraHeaders": { "xero-tenant-id": "{{XERO_TENANT_ID}}" } + }, + "headers": { "Accept": "application/json" }, + "healthcheckPath": "/Organisation" + }, + "probe": { "tool": "xero_get_organisation" }, + "tools": [ + { + "name": "xero_get_organisation", + "description": "Read the configured organisation's name, base currency and settings. Call with {} to verify authentication and the tenant selection before reading financial data.", + "parameters": { "type": "object", "properties": {}, "additionalProperties": false, "examples": [{}] }, + "endpointMapping": { "method": "GET", "path": "/Organisation" } + }, + { + "name": "xero_list_invoices", + "description": "List sales invoices and purchase bills with totals, status and line items, one page at a time. Filter with where, for example Type==\"ACCREC\" for sales invoices or Type==\"ACCPAY\" for bills. Returns Xero's response envelope; invoice records are in the Invoices array.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "minimum": 1, "default": 1, "description": "1-based page number." }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 100, "default": 100, "description": "Records per page, up to 100." }, + "where": { "type": "string", "description": "Xero filter expression, for example Type==\"ACCREC\" AND Status==\"AUTHORISED\"." }, + "order": { "type": "string", "description": "Xero sort expression, for example InvoiceNumber ASC." } + }, + "additionalProperties": false, + "examples": [{ "page": 1, "pageSize": 100, "where": "Type==\"ACCREC\"", "order": "InvoiceNumber ASC" }] + }, + "endpointMapping": { + "method": "GET", "path": "/Invoices", + "queryParams": { "page": "$page", "pageSize": "$pageSize", "where": "$where", "order": "$order" } + } + }, + { + "name": "xero_get_invoice", + "description": "Read one invoice or bill with its line items, amounts and payment details. Use InvoiceID from xero_list_invoices, not InvoiceNumber.", + "parameters": { + "type": "object", + "properties": { "invoiceId": { "type": "string", "format": "uuid", "description": "InvoiceID UUID returned by xero_list_invoices." } }, + "required": ["invoiceId"], "additionalProperties": false, + "examples": [{ "invoiceId": "11111111-1111-4111-8111-111111111111" }] + }, + "endpointMapping": { "method": "GET", "path": "/Invoices/{invoiceId}", "encodePathParams": true } + }, + { + "name": "xero_list_contacts", + "description": "List customer and supplier contacts one page at a time. Search by name or email, filter with where and optionally include archived contacts. Returns Xero's response envelope; contact records are in the Contacts array.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "minimum": 1, "default": 1, "description": "1-based page number." }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 100, "default": 100, "description": "Records per page, up to 100." }, + "searchTerm": { "type": "string", "description": "Case-insensitive search across Name, FirstName, LastName, ContactNumber and EmailAddress." }, + "where": { "type": "string", "description": "Xero filter expression, for example ContactStatus==\"ACTIVE\"." }, + "order": { "type": "string", "description": "Xero sort expression, for example Name ASC." }, + "includeArchived": { "type": "boolean", "description": "Include contacts with ARCHIVED status when true." } + }, + "additionalProperties": false, + "examples": [{ "page": 1, "pageSize": 100, "searchTerm": "Example", "order": "Name ASC", "includeArchived": false }] + }, + "endpointMapping": { + "method": "GET", "path": "/Contacts", + "queryParams": { "page": "$page", "pageSize": "$pageSize", "searchTerm": "$searchTerm", "where": "$where", "order": "$order", "includeArchived": "$includeArchived" } + } + }, + { + "name": "xero_get_contact", + "description": "Read one customer or supplier contact, including its addresses, contact people and balances. Use ContactID from xero_list_contacts.", + "parameters": { + "type": "object", + "properties": { "contactId": { "type": "string", "format": "uuid", "description": "ContactID UUID returned by xero_list_contacts." } }, + "required": ["contactId"], "additionalProperties": false, + "examples": [{ "contactId": "22222222-2222-4222-8222-222222222222" }] + }, + "endpointMapping": { "method": "GET", "path": "/Contacts/{contactId}", "encodePathParams": true } + }, + { + "name": "xero_list_accounts", + "description": "Read the chart of accounts with account codes, names, types, tax types and status. Accounts is not paginated; optionally filter or sort the Accounts array.", + "parameters": { + "type": "object", + "properties": { + "where": { "type": "string", "description": "Xero filter expression, for example Status==\"ACTIVE\"." }, + "order": { "type": "string", "description": "Xero sort expression, for example Code ASC." } + }, + "additionalProperties": false, + "examples": [{ "where": "Status==\"ACTIVE\"", "order": "Code ASC" }] + }, + "endpointMapping": { "method": "GET", "path": "/Accounts", "queryParams": { "where": "$where", "order": "$order" } } + }, + { + "name": "xero_get_account", + "description": "Read one account's code, name, type, tax type and status. Use AccountID from xero_list_accounts, not its chart-of-accounts code.", + "parameters": { + "type": "object", + "properties": { "accountId": { "type": "string", "format": "uuid", "description": "AccountID UUID returned by xero_list_accounts." } }, + "required": ["accountId"], "additionalProperties": false, + "examples": [{ "accountId": "33333333-3333-4333-8333-333333333333" }] + }, + "endpointMapping": { "method": "GET", "path": "/Accounts/{accountId}", "encodePathParams": true } + }, + { + "name": "xero_get_trial_balance", + "description": "Read the Trial Balance as at a specified date, including debit, credit and year-to-date values. Returns Xero's Reports with nested Rows and Cells. Accrual by default; paymentsOnly=true requests cash basis.", + "parameters": { + "type": "object", + "properties": { + "date": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "Report date in YYYY-MM-DD format." }, + "paymentsOnly": { "type": "boolean", "default": false, "description": "True for cash basis; false for accrual basis." } + }, + "required": ["date"], "additionalProperties": false, + "examples": [{ "date": "2026-06-30", "paymentsOnly": false }] + }, + "endpointMapping": { "method": "GET", "path": "/Reports/TrialBalance", "queryParams": { "date": "$date", "paymentsOnly": "$paymentsOnly" } } + }, + { + "name": "xero_get_profit_and_loss", + "description": "Read income, expenses and profit for a date range. Returns Xero's Reports with nested Rows and Cells. Accrual by default; optionally request cash basis and comparison periods.", + "parameters": { + "type": "object", + "properties": { + "fromDate": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "Start date in YYYY-MM-DD format; no later than toDate." }, + "toDate": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "End date in YYYY-MM-DD format." }, + "paymentsOnly": { "type": "boolean", "default": false, "description": "True for cash basis; false for accrual basis." }, + "periods": { "type": "integer", "minimum": 1, "maximum": 12, "description": "Number of comparison periods, from 1 to 12. Use with timeframe." }, + "timeframe": { "type": "string", "enum": ["MONTH", "QUARTER", "YEAR"], "description": "Comparison period size. Use with periods." } + }, + "required": ["fromDate", "toDate"], "additionalProperties": false, + "examples": [{ "fromDate": "2026-07-01", "toDate": "2026-09-30", "paymentsOnly": false, "periods": 1, "timeframe": "QUARTER" }] + }, + "endpointMapping": { "method": "GET", "path": "/Reports/ProfitAndLoss", "queryParams": { "fromDate": "$fromDate", "toDate": "$toDate", "paymentsOnly": "$paymentsOnly", "periods": "$periods", "timeframe": "$timeframe" } } + } + ] +} diff --git a/packages/backend/src/adapters/intl/xero.live.spec.ts b/packages/backend/src/adapters/intl/xero.live.spec.ts new file mode 100644 index 00000000..f477c645 --- /dev/null +++ b/packages/backend/src/adapters/intl/xero.live.spec.ts @@ -0,0 +1,253 @@ +import * as adapter from './xero.json'; +import { AdapterDefinition, getAdapter } from '../catalog'; +import { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { interpolateDeep } from '../../common/env-interpolation.util'; +import * as outboundHttp from '../../common/outbound-http'; +import * as ssrf from '../../common/ssrf.util'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; +import { AxiosResponse } from 'axios'; +import { AdaptersService } from '../adapters.service'; +import { ConnectorsService } from '../../connectors/connectors.service'; +import { McpOAuthCallbackController } from '../../connectors/mcp-oauth-callback.controller'; +import { decrypt } from '../../common/crypto/encryption.util'; +import { computeSetupState } from '../../connectors/connector-setup-status.util'; + +const a = adapter as unknown as AdapterDefinition & { probe: { tool: string } }; +const tenantId = '44444444-4444-4444-8444-444444444444'; +const expectedPaths = [ + '/Organisation', + '/Invoices', + '/Invoices/11111111-1111-4111-8111-111111111111', + '/Contacts', + '/Contacts/22222222-2222-4222-8222-222222222222', + '/Accounts', + '/Accounts/33333333-3333-4333-8333-333333333333', + '/Reports/TrialBalance', + '/Reports/ProfitAndLoss', +]; + +describe('Xero adapter: static conformance', () => { + it('registers a REST accounting adapter for the official Accounting base URL', () => { + expect(getAdapter('xero')).toMatchObject({ region: 'intl', category: 'accounting' }); + expect(a.connector.type).toBe('REST'); + expect(a.connector.baseUrl).toBe('https://api.xero.com/api.xro/2.0'); + }); + + it('uses authorisation code and rotating refresh tokens with Basic client authentication', () => { + expect(a.requiredEnvVars).toEqual(['XERO_CLIENT_ID', 'XERO_CLIENT_SECRET', 'XERO_TENANT_ID']); + expect(a.optionalEnvVars).toEqual(['XERO_REFRESH_TOKEN']); + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig).toMatchObject({ + clientId: '{{XERO_CLIENT_ID}}', + clientSecret: '{{XERO_CLIENT_SECRET}}', + refreshToken: '{{XERO_REFRESH_TOKEN}}', + grant: 'refresh_token', + authorizationUrl: 'https://login.xero.com/identity/connect/authorize', + tokenUrl: 'https://identity.xero.com/connect/token', + tokenAuthMethod: 'client_secret_basic', + extraHeaders: { 'xero-tenant-id': '{{XERO_TENANT_ID}}' }, + }); + expect(String(a.connector.authConfig?.scopes).split(' ').sort()).toEqual([ + 'openid', 'offline_access', 'accounting.invoices.read', 'accounting.contacts.read', + 'accounting.settings.read', 'accounting.reports.trialbalance.read', + 'accounting.reports.profitandloss.read', + ].sort()); + }); + + it('has nine GET-only tools with examples and a parameter-free organisation probe', () => { + expect(a.tools).toHaveLength(9); + for (const tool of a.tools) { + expect(tool.endpointMapping.method).toBe('GET'); + expect(tool.endpointMapping.path).toMatch(/^\/(Organisation|Invoices|Contacts|Accounts|Reports\/)/); + expect(tool.endpointMapping.bodyMapping).toBeUndefined(); + expect(tool.endpointMapping.bodyTemplate).toBeUndefined(); + expect(tool.endpointMapping.headers).toBeUndefined(); + expect(tool.parameters.examples).toEqual(expect.arrayContaining([expect.any(Object)])); + expect(tool.parameters.additionalProperties).toBe(false); + } + const probe = a.tools.find((tool) => tool.name === a.probe.tool)!; + expect(probe.name).toBe('xero_get_organisation'); + expect(probe.parameters.required ?? []).toEqual([]); + expect(probe.parameters.examples).toEqual([{}]); + expect(a.connector.healthcheckPath).toBe('/Organisation'); + }); + + it('documents callbacks, tenant selection, granular consent and rate limiting', () => { + expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback'); + expect(a.instructions).toContain('/api/mcp-oauth/callback'); + expect(a.instructions).toContain('GET https://api.xero.com/connections'); + expect(a.instructions).toContain('tenantId'); + expect(a.instructions).toContain('Retry-After'); + expect(a.instructions).toContain('consent is additive'); + }); +}); + +describe('Xero adapter: REST request mapping', () => { + let engine: RestEngine; + let send: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(ssrf, 'assertSafeOutboundUrl').mockResolvedValue(undefined); + send = jest.spyOn(outboundHttp, 'outboundRequest').mockResolvedValue({ + data: { Status: 'OK' }, status: 200, headers: {}, + } as AxiosResponse); + engine = new RestEngine( + { getAccessToken: jest.fn().mockResolvedValue('synthetic-access-token') } as unknown as OAuth2TokenService, + {} as LoginTokenService, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + const config = { + ...a.connector, + authConfig: interpolateDeep(a.connector.authConfig, { + XERO_CLIENT_ID: 'synthetic-client', XERO_CLIENT_SECRET: 'synthetic-secret', + XERO_REFRESH_TOKEN: '', XERO_TENANT_ID: tenantId, + }), + }; + + it.each(a.tools.map((tool, index) => ({ tool, expectedPath: expectedPaths[index] })))( + 'sends $tool.name with bearer auth and the resolved tenant header', async ({ tool, expectedPath }) => { + const example = (tool.parameters.examples as Record[])[0]; + await engine.execute(config, tool.endpointMapping as { method: string }, applySchemaDefaults(tool.parameters, example)); + const request = send.mock.calls[0][0]; + expect(request).toMatchObject({ + method: 'GET', url: a.connector.baseUrl + expectedPath, + headers: { Authorization: 'Bearer synthetic-access-token', 'xero-tenant-id': tenantId, Accept: 'application/json' }, + }); + expect(request.data).toBeUndefined(); + expect(JSON.stringify(request)).not.toContain('{{'); + expect(request.url).not.toContain('synthetic-'); + }, + ); + + it.each(['xero_list_invoices', 'xero_list_contacts'])('bounds an empty %s call to the first page', async (name) => { + const tool = a.tools.find((item) => item.name === name)!; + await engine.execute(config, tool.endpointMapping as { method: string }, applySchemaDefaults(tool.parameters, {})); + expect(send.mock.calls[0][0].params).toEqual({ page: 1, pageSize: 100 }); + }); + + it('keeps filters as query values and does not paginate accounts', async () => { + const tool = a.tools.find((item) => item.name === 'xero_list_accounts')!; + const params = { where: 'Name=="Example & Co"', order: 'Code ASC' }; + await engine.execute(config, tool.endpointMapping as { method: string }, params); + expect(send.mock.calls[0][0].params).toEqual(params); + }); + + it.each([ + ['xero_list_invoices', { page: 2, pageSize: 50, where: 'Type=="ACCREC"', order: 'InvoiceNumber ASC' }], + ['xero_list_contacts', { page: 2, pageSize: 50, searchTerm: 'Example & Co', where: 'ContactStatus=="ACTIVE"', order: 'Name ASC', includeArchived: false }], + ])('maps all exposed %s list queries', async (name, params) => { + const tool = a.tools.find((item) => item.name === name)!; + await engine.execute(config, tool.endpointMapping as { method: string }, params as Record); + expect(send.mock.calls[0][0].params).toEqual(params); + }); + + it.each([ + ['xero_list_invoices', { Invoices: [{ InvoiceID: 'synthetic-invoice' }] }], + ['xero_list_contacts', { Contacts: [{ ContactID: 'synthetic-contact' }] }], + ])('preserves the %s response envelope', async (name, envelope) => { + send.mockResolvedValueOnce({ data: envelope, status: 200, headers: {} } as AxiosResponse); + const tool = a.tools.find((item) => item.name === name)!; + await expect(engine.execute(config, tool.endpointMapping as { method: string }, {})).resolves.toEqual(envelope); + }); + + it.each([ + ['xero_get_trial_balance', { date: '2026-06-30', paymentsOnly: false }], + ['xero_get_profit_and_loss', { fromDate: '2026-07-01', toDate: '2026-09-30', paymentsOnly: false, periods: 1, timeframe: 'QUARTER' }], + ])('maps %s report dates and preserves accrual basis', async (name, params) => { + const tool = a.tools.find((item) => item.name === name)!; + await engine.execute(config, tool.endpointMapping as { method: string }, params as Record); + expect(send.mock.calls[0][0].params).toEqual(params); + }); + + it.each([ + ['xero_get_invoice', 'invoiceId', '/Invoices'], + ['xero_get_contact', 'contactId', '/Contacts'], + ['xero_get_account', 'accountId', '/Accounts'], + ])('encodes %s identifiers so they cannot change the path', async (name, parameter, path) => { + const tool = a.tools.find((item) => item.name === name)!; + await engine.execute(config, tool.endpointMapping as { method: string }, { [parameter]: 'id/other?query#fragment' }); + expect(send.mock.calls[0][0].url).toBe(a.connector.baseUrl + path + '/id%2Fother%3Fquery%23fragment'); + }); + + it.each([undefined, ''])('installs and authorises with optional refresh token %s', async (refreshToken) => { + const encryptionKey = 'x'.repeat(48); + const settings = { get: (key: string) => key === 'ENCRYPTION_KEY' ? encryptionKey : undefined }; + let row: any; + const prisma = { + connector: { + create: jest.fn(async ({ data }) => (row = { id: 'xero-test', ...data })), + findUnique: jest.fn(async () => row), + update: jest.fn(async ({ data }) => (row = { ...row, ...data })), + }, + mcpTool: { createMany: jest.fn(async ({ data }) => ({ count: data.length })) }, + }; + const registry = { reloadConnectorTools: jest.fn().mockResolvedValue(undefined) }; + engine = new RestEngine(new OAuth2TokenService(prisma as any, settings as any), {} as LoginTokenService); + const connectors = Object.assign(Object.create(ConnectorsService.prototype), { + encryptionKey, prisma, restEngine: engine, + findById: jest.fn(async () => row), findByIdInternal: jest.fn(async () => row), + }) as ConnectorsService; + const service = new AdaptersService(prisma as any, registry as any, settings as any, connectors); + const credentials = { + XERO_CLIENT_ID: 'synthetic-client', XERO_CLIENT_SECRET: 'synthetic-secret', XERO_TENANT_ID: tenantId, + ...(refreshToken === undefined ? {} : { XERO_REFRESH_TOKEN: refreshToken }), + }; + const installed = await service.importAdapter('xero', 'user-1', 'org-1', credentials); + const initial = JSON.parse(decrypt(row.authConfig, encryptionKey)); + expect(installed).toMatchObject({ toolsCreated: 9, probe: null }); + expect(computeSetupState({ ...row, authConfig: initial })).toEqual({ status: 'needs_authorization', missing: [] }); + expect(send).not.toHaveBeenCalled(); + + const oauth = { + takePendingFlow: jest.fn().mockResolvedValue({ flow: { + ...initial, connectorId: row.id, userId: 'user-1', codeVerifier: 'synthetic-verifier', + redirectUri: 'https://cloud.anythingmcp.com/api/mcp-oauth/callback', + } }), + exchangeCodeForTokens: jest.fn().mockResolvedValue({ + accessToken: 'synthetic-authorised-token', refreshToken: 'synthetic-rotated-token', expiresIn: 1800, + }), + }; + const callback = new McpOAuthCallbackController( + oauth as any, connectors, {} as any, prisma as any, registry as any, settings as any, {} as any, + ); + await callback.complete({ user: { sub: 'user-1' } }, { state: 'synthetic-state', code: 'synthetic-code' }); + const saved = JSON.parse(decrypt(row.authConfig, encryptionKey)); + expect(saved).toMatchObject({ + accessToken: 'synthetic-authorised-token', refreshToken: 'synthetic-rotated-token', + extraHeaders: { 'xero-tenant-id': tenantId }, tokenAuthMethod: 'client_secret_basic', + }); + expect(computeSetupState({ ...row, authConfig: saved })).toEqual({ status: 'ready', missing: [] }); + await expect(connectors.testConnection(row.id)).resolves.toMatchObject({ ok: true }); + const probe = a.tools.find((item) => item.name === a.probe.tool)!; + await engine.execute({ ...a.connector, authConfig: saved }, probe.endpointMapping as { method: string }, {}); + expect(send).toHaveBeenCalledTimes(2); + for (const [request] of send.mock.calls) { + expect(request).toMatchObject({ + method: 'GET', url: a.connector.baseUrl + '/Organisation', + headers: { Authorization: 'Bearer synthetic-authorised-token', 'xero-tenant-id': tenantId }, + }); + } + }); +}); + +// Opt-in only, using an already issued read-only access token for a demo organisation. +// RUN_XERO_LIVE=1 XERO_ACCESS_TOKEN=... XERO_TENANT_ID=... npm test -w packages/backend -- xero.live.spec.ts +const live = process.env.RUN_XERO_LIVE === '1' ? describe : describe.skip; +live('Xero adapter: live read-only probe', () => { + it('reads the selected organisation', async () => { + const token = process.env.XERO_ACCESS_TOKEN; + const tenant = process.env.XERO_TENANT_ID; + if (!token || !tenant) throw new Error('Set XERO_ACCESS_TOKEN and XERO_TENANT_ID for RUN_XERO_LIVE=1'); + const response = await outboundHttp.outboundRequest({ + method: 'GET', url: a.connector.baseUrl + '/Organisation', timeout: 30000, + headers: { Authorization: `Bearer ${token}`, 'xero-tenant-id': tenant, Accept: 'application/json' }, + }); + expect(response.status).toBe(200); + expect(response.data.Organisations).toHaveLength(1); + }, 35000); +}); diff --git a/packages/frontend/public/logos/connectors/xero.svg b/packages/frontend/public/logos/connectors/xero.svg new file mode 100644 index 00000000..88dbc2fd --- /dev/null +++ b/packages/frontend/public/logos/connectors/xero.svg @@ -0,0 +1 @@ +Xero \ No newline at end of file diff --git a/server.json b/server.json index ce9fdb8e..61430a07 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.HelpCode-ai/anythingmcp", "title": "AnythingMCP", - "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 319 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 320 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github" From bd2ae4decdf2c66a14df5f0cf8adea3b7b817d1c Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Thu, 8 Oct 2026 19:42:41 +1100 Subject: [PATCH 2/3] Make the Xero tenant ID optional and add xero_list_connections The tenant is chosen after authorisation, so it moves from the OAuth extraHeaders to each Accounting tool's headers, and the new tool reads https://api.xero.com/connections without it. A tool header that is only an empty variable now keeps its placeholder, so the call is refused with the variable's name instead of sending a blank header. --- packages/backend/src/adapters/intl/xero.json | 41 ++++---- .../src/adapters/intl/xero.live.spec.ts | 93 ++++++++++++------- .../src/common/env-interpolation.util.spec.ts | 16 ++++ .../src/common/env-interpolation.util.ts | 27 +++++- 4 files changed, 126 insertions(+), 51 deletions(-) diff --git a/packages/backend/src/adapters/intl/xero.json b/packages/backend/src/adapters/intl/xero.json index 2b630c32..b8da667a 100644 --- a/packages/backend/src/adapters/intl/xero.json +++ b/packages/backend/src/adapters/intl/xero.json @@ -1,14 +1,14 @@ { "slug": "xero", "name": "Xero", - "description": "Read a Xero organisation's invoices, bills, contacts, chart of accounts, Trial Balance and Profit and Loss reports. 9 GET-only tools with OAuth2 and a fixed tenant ID.", - "instructions": "This connector reads the Xero Accounting API for one organisation. All nine tools use GET; no tool creates, updates or deletes accounting data.\n\n**Setup**\nInstallation requires the organisation's tenant UUID. If you are starting with a new Xero app and no token, obtain it through the external Postman authorisation below before installing this connector.\n1. At https://developer.xero.com/app/manage create an OAuth2 Web app using the authorisation code flow. Register `https://cloud.anythingmcp.com/api/mcp-oauth/callback` for AnythingMCP Cloud, or `/api/mcp-oauth/callback` for a self-hosted server. The redirect URI must match exactly. Copy the Client ID and Client Secret into `XERO_CLIENT_ID` and `XERO_CLIENT_SECRET`.\n2. To obtain your first access token, also register `https://oauth.pstmn.io/v1/browser-callback` on that Xero app. In Postman Desktop create a GET request to `https://api.xero.com/connections`. On its Authorization tab select OAuth 2.0, grant type Authorization Code and Authorize using browser. Set that Postman callback URL, Auth URL `https://login.xero.com/identity/connect/authorize`, Access Token URL `https://identity.xero.com/connect/token`, your app's Client ID and Client Secret, the read-only scope string below, a random State, and Client Authentication **Send as Basic Auth header**. Select **Get New Access Token**, sign in, choose the intended organisation and select **Use Token**. Keep Share Token off. Then send the GET Connections request. See https://learning.postman.com/docs/use/send-requests/authorization/oauth-20/ and Xero's walkthrough at https://github.com/XeroAPI/xero-postman-oauth2; use this connector's current read-only scopes rather than the walkthrough's older scope examples.\n3. If you already have an authorised OAuth session for the same app, you can skip the Postman token setup and make `GET https://api.xero.com/connections` with `Authorization: Bearer `. In either case choose the entry with the intended `tenantName` and `tenantType: ORGANISATION`, and copy its `tenantId` into `XERO_TENANT_ID`. The connection's `id` and `authEventId` are different values and must not be used. See https://developer.xero.com/documentation/guides/oauth2/tenants/. Connections is outside this connector's Accounting base URL, so it is documented here instead of exposed as a tool.\n4. Leave `XERO_REFRESH_TOKEN` empty, install, then open the connector and click **Authorize with Provider**. Sign in to Xero and connect the same organisation selected above. AnythingMCP exchanges the code at Xero Identity using HTTP Basic client authentication and stores the tokens. Stop using the Postman bootstrap tokens; AnythingMCP maintains its own tokens.\n5. Run `xero_get_organisation` with `{}` and check the returned organisation name before reading its accounts or transactions. Install a separate connector for each organisation.\n\n**Read-only scopes**\n`openid offline_access accounting.invoices.read accounting.contacts.read accounting.settings.read accounting.reports.trialbalance.read accounting.reports.profitandloss.read`. These are the current granular scopes; deprecated `accounting.transactions.read` and `accounting.reports.read` are not requested. Identity scopes are limited to `openid` and `offline_access`. Xero consent is additive: a token previously granted write permissions keeps them. To reduce permissions, revoke the old connection in Xero and authorise again with these scopes. See https://developer.xero.com/documentation/guides/oauth2/scopes/.\n\n**Tokens and tenant header**\nEvery Accounting call includes `xero-tenant-id: XERO_TENANT_ID` and a bearer access token. Access tokens last 30 minutes; `offline_access` supplies refresh tokens, which expire after 60 days without renewal. AnythingMCP refreshes automatically and saves replacement refresh tokens. You may supply `XERO_REFRESH_TOKEN` only if you already obtained a current token for the same app and read-only consent. Do not share a rotating refresh token between integrations. Changing the tenant ID changes the organisation every tool reads.\n\n**Lists and identifiers**\nInvoices include sales invoices (ACCREC) and purchase bills (ACCPAY). Invoices and contacts use 1-based `page` and `pageSize`, defaulting to page 1 with 100 records. Request subsequent pages until the nested Invoices or Contacts array is empty or shorter than pageSize. Accounts returns the chart of accounts without pagination. `where` uses Xero filter syntax, for example `Type==\"ACCREC\"` or `ContactStatus==\"ACTIVE\"`; `order` accepts values such as `InvoiceNumber ASC` and `Name ASC`. Get tools require the UUID returned by the corresponding list, not an invoice number, contact name or account code. List and detail responses retain Xero's `Invoices`, `Contacts`, `Accounts` and `Organisations` envelopes.\n\n**Reports**\nPass `YYYY-MM-DD` dates: Trial Balance uses `date`; Profit and Loss uses `fromDate` and `toDate` with fromDate no later than toDate. `paymentsOnly` defaults to false for accrual reports; set true for cash reports. Profit and Loss can compare 1 to 12 periods using `periods` and `timeframe` (MONTH, QUARTER or YEAR). Reports retain the `Reports` envelope and nested Rows/Cells; financial values may be strings. The authorising Xero user needs access to reports.\n\n**Errors and limits**\nFor 401 or 403 check consent, token expiry, Xero user permissions and whether the tenant is connected to this app. On HTTP 429, pause requests to that tenant for the `Retry-After` seconds; inspect `X-Rate-Limit-Problem` and retry with backoff instead of immediately paging again. Limits depend on the app tier: https://developer.xero.com/documentation/guides/oauth2/limits/.\n\n**Cloud and self-hosted**\nBoth use Xero's public HTTPS Accounting and Identity endpoints with the same scopes and tenant header; only the registered callback differs. Live Xero authorisation and API calls have not been verified for this adapter.", + "description": "Read a Xero organisation's invoices, bills, contacts, chart of accounts, Trial Balance and Profit and Loss reports. 10 GET-only tools with OAuth2; xero_list_connections finds the tenant ID after authorisation.", + "instructions": "This connector reads the Xero Accounting API for one organisation. All ten tools use GET; no tool creates, updates or deletes accounting data.\n\n**Setup**\n1. At https://developer.xero.com/app/manage create an OAuth2 Web app using the authorisation code flow. Register `https://cloud.anythingmcp.com/api/mcp-oauth/callback` for AnythingMCP Cloud, or `/api/mcp-oauth/callback` for a self-hosted server. The redirect URI must match exactly. Copy the Client ID and Client Secret into `XERO_CLIENT_ID` and `XERO_CLIENT_SECRET`.\n2. Leave `XERO_TENANT_ID` and `XERO_REFRESH_TOKEN` empty, install, then open the connector and click **Authorize with Provider**. Sign in to Xero and connect the intended organisation. AnythingMCP exchanges the code at Xero Identity using HTTP Basic client authentication and stores the tokens.\n3. Run `xero_list_connections` with `{}`. Choose the entry with the intended `tenantName` and `tenantType: ORGANISATION`, and copy its `tenantId` into `XERO_TENANT_ID`. The connection's `id` and `authEventId` are different values and must not be used. See https://developer.xero.com/documentation/guides/oauth2/tenants/. Until `XERO_TENANT_ID` is set, the other tools stop before calling Xero and name the missing variable.\n4. Run `xero_get_organisation` with `{}` and check the returned organisation name before reading its accounts or transactions. Install a separate connector for each organisation.\n\nIf you prefer to know the tenant ID before installing, read it with Postman instead: also register `https://oauth.pstmn.io/v1/browser-callback` on the Xero app. In Postman Desktop create a GET request to `https://api.xero.com/connections`. On its Authorization tab select OAuth 2.0, grant type Authorization Code and Authorize using browser. Set that Postman callback URL, Auth URL `https://login.xero.com/identity/connect/authorize`, Access Token URL `https://identity.xero.com/connect/token`, your app's Client ID and Client Secret, the read-only scope string below, a random State, and Client Authentication **Send as Basic Auth header**. Select **Get New Access Token**, sign in, choose the intended organisation and select **Use Token**. Keep Share Token off. Then send the GET Connections request and copy the `tenantId` as in step 3. See https://learning.postman.com/docs/use/send-requests/authorization/oauth-20/ and Xero's walkthrough at https://github.com/XeroAPI/xero-postman-oauth2; use this connector's current read-only scopes rather than the walkthrough's older scope examples. Stop using the Postman tokens once AnythingMCP is authorised; it maintains its own.\n\n**Read-only scopes**\n`openid offline_access accounting.invoices.read accounting.contacts.read accounting.settings.read accounting.reports.trialbalance.read accounting.reports.profitandloss.read`. These are the current granular scopes; deprecated `accounting.transactions.read` and `accounting.reports.read` are not requested. Identity scopes are limited to `openid` and `offline_access`. Xero consent is additive: a token previously granted write permissions keeps them. To reduce permissions, revoke the old connection in Xero and authorise again with these scopes. See https://developer.xero.com/documentation/guides/oauth2/scopes/.\n\n**Tokens and tenant header**\nEvery Accounting call includes `xero-tenant-id: XERO_TENANT_ID` and a bearer access token; `xero_list_connections` sends only the bearer token. Access tokens last 30 minutes; `offline_access` supplies refresh tokens, which expire after 60 days without renewal. AnythingMCP refreshes automatically and saves replacement refresh tokens. You may supply `XERO_REFRESH_TOKEN` only if you already obtained a current token for the same app and read-only consent. Do not share a rotating refresh token between integrations. Changing the tenant ID changes the organisation every tool reads.\n\n**Lists and identifiers**\nInvoices include sales invoices (ACCREC) and purchase bills (ACCPAY). Invoices and contacts use 1-based `page` and `pageSize`, defaulting to page 1 with 100 records. Request subsequent pages until the nested Invoices or Contacts array is empty or shorter than pageSize. Accounts returns the chart of accounts without pagination. `where` uses Xero filter syntax, for example `Type==\"ACCREC\"` or `ContactStatus==\"ACTIVE\"`; `order` accepts values such as `InvoiceNumber ASC` and `Name ASC`. Get tools require the UUID returned by the corresponding list, not an invoice number, contact name or account code. List and detail responses retain Xero's `Invoices`, `Contacts`, `Accounts` and `Organisations` envelopes.\n\n**Reports**\nPass `YYYY-MM-DD` dates: Trial Balance uses `date`; Profit and Loss uses `fromDate` and `toDate` with fromDate no later than toDate. `paymentsOnly` defaults to false for accrual reports; set true for cash reports. Profit and Loss can compare 1 to 12 periods using `periods` and `timeframe` (MONTH, QUARTER or YEAR). Reports retain the `Reports` envelope and nested Rows/Cells; financial values may be strings. The authorising Xero user needs access to reports.\n\n**Errors and limits**\nFor 401 or 403 check consent, token expiry, Xero user permissions and whether the tenant is connected to this app. On HTTP 429, pause requests to that tenant for the `Retry-After` seconds; inspect `X-Rate-Limit-Problem` and retry with backoff instead of immediately paging again. Limits depend on the app tier: https://developer.xero.com/documentation/guides/oauth2/limits/.\n\n**Cloud and self-hosted**\nBoth use Xero's public HTTPS Accounting and Identity endpoints with the same scopes and tenant header; only the registered callback differs. Live Xero authorisation and API calls have not been verified for this adapter.", "region": "intl", "category": "accounting", "icon": "xero", "docsUrl": "https://developer.xero.com/documentation/api/accounting/overview/", - "requiredEnvVars": ["XERO_CLIENT_ID", "XERO_CLIENT_SECRET", "XERO_TENANT_ID"], - "optionalEnvVars": ["XERO_REFRESH_TOKEN"], + "requiredEnvVars": ["XERO_CLIENT_ID", "XERO_CLIENT_SECRET"], + "optionalEnvVars": ["XERO_TENANT_ID", "XERO_REFRESH_TOKEN"], "envVarMeta": { "XERO_CLIENT_ID": { "label": "Client ID", @@ -24,7 +24,7 @@ "XERO_TENANT_ID": { "label": "Organisation tenant ID", "kind": "setting", - "help": "The tenantId UUID of the intended ORGANISATION from GET https://api.xero.com/connections. Do not use the connection id. Obtain it before installing and verify the organisation with xero_get_organisation.", + "help": "Leave empty until after Authorize with Provider, then run xero_list_connections and paste the tenantId of the intended ORGANISATION, not the connection id. The Accounting tools need it; verify the organisation with xero_get_organisation.", "link": "https://developer.xero.com/documentation/guides/oauth2/tenants/", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "patternMessage": "Enter the organisation's tenantId as a UUID." @@ -49,19 +49,24 @@ "authorizationUrl": "https://login.xero.com/identity/connect/authorize", "tokenUrl": "https://identity.xero.com/connect/token", "tokenAuthMethod": "client_secret_basic", - "scopes": "openid offline_access accounting.invoices.read accounting.contacts.read accounting.settings.read accounting.reports.trialbalance.read accounting.reports.profitandloss.read", - "extraHeaders": { "xero-tenant-id": "{{XERO_TENANT_ID}}" } + "scopes": "openid offline_access accounting.invoices.read accounting.contacts.read accounting.settings.read accounting.reports.trialbalance.read accounting.reports.profitandloss.read" }, "headers": { "Accept": "application/json" }, - "healthcheckPath": "/Organisation" + "healthcheckPath": "https://api.xero.com/connections" }, - "probe": { "tool": "xero_get_organisation" }, + "probe": { "tool": "xero_list_connections" }, "tools": [ + { + "name": "xero_list_connections", + "description": "List the Xero organisations this authorisation can read, with tenantId, tenantName and tenantType. Call with {} after Authorize with Provider, then copy the intended ORGANISATION's tenantId (not its connection id) into XERO_TENANT_ID.", + "parameters": { "type": "object", "properties": {}, "additionalProperties": false, "examples": [{}] }, + "endpointMapping": { "method": "GET", "path": "https://api.xero.com/connections" } + }, { "name": "xero_get_organisation", "description": "Read the configured organisation's name, base currency and settings. Call with {} to verify authentication and the tenant selection before reading financial data.", "parameters": { "type": "object", "properties": {}, "additionalProperties": false, "examples": [{}] }, - "endpointMapping": { "method": "GET", "path": "/Organisation" } + "endpointMapping": { "method": "GET", "path": "/Organisation", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" } } }, { "name": "xero_list_invoices", @@ -78,7 +83,7 @@ "examples": [{ "page": 1, "pageSize": 100, "where": "Type==\"ACCREC\"", "order": "InvoiceNumber ASC" }] }, "endpointMapping": { - "method": "GET", "path": "/Invoices", + "method": "GET", "path": "/Invoices", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "queryParams": { "page": "$page", "pageSize": "$pageSize", "where": "$where", "order": "$order" } } }, @@ -91,7 +96,7 @@ "required": ["invoiceId"], "additionalProperties": false, "examples": [{ "invoiceId": "11111111-1111-4111-8111-111111111111" }] }, - "endpointMapping": { "method": "GET", "path": "/Invoices/{invoiceId}", "encodePathParams": true } + "endpointMapping": { "method": "GET", "path": "/Invoices/{invoiceId}", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "encodePathParams": true } }, { "name": "xero_list_contacts", @@ -110,7 +115,7 @@ "examples": [{ "page": 1, "pageSize": 100, "searchTerm": "Example", "order": "Name ASC", "includeArchived": false }] }, "endpointMapping": { - "method": "GET", "path": "/Contacts", + "method": "GET", "path": "/Contacts", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "queryParams": { "page": "$page", "pageSize": "$pageSize", "searchTerm": "$searchTerm", "where": "$where", "order": "$order", "includeArchived": "$includeArchived" } } }, @@ -123,7 +128,7 @@ "required": ["contactId"], "additionalProperties": false, "examples": [{ "contactId": "22222222-2222-4222-8222-222222222222" }] }, - "endpointMapping": { "method": "GET", "path": "/Contacts/{contactId}", "encodePathParams": true } + "endpointMapping": { "method": "GET", "path": "/Contacts/{contactId}", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "encodePathParams": true } }, { "name": "xero_list_accounts", @@ -137,7 +142,7 @@ "additionalProperties": false, "examples": [{ "where": "Status==\"ACTIVE\"", "order": "Code ASC" }] }, - "endpointMapping": { "method": "GET", "path": "/Accounts", "queryParams": { "where": "$where", "order": "$order" } } + "endpointMapping": { "method": "GET", "path": "/Accounts", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "queryParams": { "where": "$where", "order": "$order" } } }, { "name": "xero_get_account", @@ -148,7 +153,7 @@ "required": ["accountId"], "additionalProperties": false, "examples": [{ "accountId": "33333333-3333-4333-8333-333333333333" }] }, - "endpointMapping": { "method": "GET", "path": "/Accounts/{accountId}", "encodePathParams": true } + "endpointMapping": { "method": "GET", "path": "/Accounts/{accountId}", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "encodePathParams": true } }, { "name": "xero_get_trial_balance", @@ -162,7 +167,7 @@ "required": ["date"], "additionalProperties": false, "examples": [{ "date": "2026-06-30", "paymentsOnly": false }] }, - "endpointMapping": { "method": "GET", "path": "/Reports/TrialBalance", "queryParams": { "date": "$date", "paymentsOnly": "$paymentsOnly" } } + "endpointMapping": { "method": "GET", "path": "/Reports/TrialBalance", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "queryParams": { "date": "$date", "paymentsOnly": "$paymentsOnly" } } }, { "name": "xero_get_profit_and_loss", @@ -179,7 +184,7 @@ "required": ["fromDate", "toDate"], "additionalProperties": false, "examples": [{ "fromDate": "2026-07-01", "toDate": "2026-09-30", "paymentsOnly": false, "periods": 1, "timeframe": "QUARTER" }] }, - "endpointMapping": { "method": "GET", "path": "/Reports/ProfitAndLoss", "queryParams": { "fromDate": "$fromDate", "toDate": "$toDate", "paymentsOnly": "$paymentsOnly", "periods": "$periods", "timeframe": "$timeframe" } } + "endpointMapping": { "method": "GET", "path": "/Reports/ProfitAndLoss", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "queryParams": { "fromDate": "$fromDate", "toDate": "$toDate", "paymentsOnly": "$paymentsOnly", "periods": "$periods", "timeframe": "$timeframe" } } } ] } diff --git a/packages/backend/src/adapters/intl/xero.live.spec.ts b/packages/backend/src/adapters/intl/xero.live.spec.ts index f477c645..6c6d3327 100644 --- a/packages/backend/src/adapters/intl/xero.live.spec.ts +++ b/packages/backend/src/adapters/intl/xero.live.spec.ts @@ -1,7 +1,7 @@ import * as adapter from './xero.json'; import { AdapterDefinition, getAdapter } from '../catalog'; import { applySchemaDefaults } from '../../common/schema-defaults.util'; -import { interpolateDeep } from '../../common/env-interpolation.util'; +import { interpolateConnectorConfig, interpolateDeep } from '../../common/env-interpolation.util'; import * as outboundHttp from '../../common/outbound-http'; import * as ssrf from '../../common/ssrf.util'; import { RestEngine } from '../../connectors/engines/rest.engine'; @@ -16,7 +16,10 @@ import { computeSetupState } from '../../connectors/connector-setup-status.util' const a = adapter as unknown as AdapterDefinition & { probe: { tool: string } }; const tenantId = '44444444-4444-4444-8444-444444444444'; +const connectionsUrl = 'https://api.xero.com/connections'; +const tenantHeader = { 'xero-tenant-id': '{{XERO_TENANT_ID}}' }; const expectedPaths = [ + connectionsUrl, '/Organisation', '/Invoices', '/Invoices/11111111-1111-4111-8111-111111111111', @@ -36,8 +39,8 @@ describe('Xero adapter: static conformance', () => { }); it('uses authorisation code and rotating refresh tokens with Basic client authentication', () => { - expect(a.requiredEnvVars).toEqual(['XERO_CLIENT_ID', 'XERO_CLIENT_SECRET', 'XERO_TENANT_ID']); - expect(a.optionalEnvVars).toEqual(['XERO_REFRESH_TOKEN']); + expect(a.requiredEnvVars).toEqual(['XERO_CLIENT_ID', 'XERO_CLIENT_SECRET']); + expect(a.optionalEnvVars).toEqual(['XERO_TENANT_ID', 'XERO_REFRESH_TOKEN']); expect(a.connector.authType).toBe('OAUTH2'); expect(a.connector.authConfig).toMatchObject({ clientId: '{{XERO_CLIENT_ID}}', @@ -47,8 +50,9 @@ describe('Xero adapter: static conformance', () => { authorizationUrl: 'https://login.xero.com/identity/connect/authorize', tokenUrl: 'https://identity.xero.com/connect/token', tokenAuthMethod: 'client_secret_basic', - extraHeaders: { 'xero-tenant-id': '{{XERO_TENANT_ID}}' }, }); + // The tenant is chosen after authorisation, so it cannot gate the whole connector. + expect(a.connector.authConfig).not.toHaveProperty('extraHeaders'); expect(String(a.connector.authConfig?.scopes).split(' ').sort()).toEqual([ 'openid', 'offline_access', 'accounting.invoices.read', 'accounting.contacts.read', 'accounting.settings.read', 'accounting.reports.trialbalance.read', @@ -56,28 +60,34 @@ describe('Xero adapter: static conformance', () => { ].sort()); }); - it('has nine GET-only tools with examples and a parameter-free organisation probe', () => { - expect(a.tools).toHaveLength(9); + it('has ten GET-only tools with examples and a parameter-free connections probe', () => { + expect(a.tools).toHaveLength(10); for (const tool of a.tools) { expect(tool.endpointMapping.method).toBe('GET'); - expect(tool.endpointMapping.path).toMatch(/^\/(Organisation|Invoices|Contacts|Accounts|Reports\/)/); expect(tool.endpointMapping.bodyMapping).toBeUndefined(); expect(tool.endpointMapping.bodyTemplate).toBeUndefined(); - expect(tool.endpointMapping.headers).toBeUndefined(); expect(tool.parameters.examples).toEqual(expect.arrayContaining([expect.any(Object)])); expect(tool.parameters.additionalProperties).toBe(false); + if (tool.name === 'xero_list_connections') { + expect(tool.endpointMapping.path).toBe(connectionsUrl); + expect(tool.endpointMapping.headers).toBeUndefined(); + } else { + expect(tool.endpointMapping.path).toMatch(/^\/(Organisation|Invoices|Contacts|Accounts|Reports\/)/); + expect(tool.endpointMapping.headers).toEqual(tenantHeader); + } } const probe = a.tools.find((tool) => tool.name === a.probe.tool)!; - expect(probe.name).toBe('xero_get_organisation'); + expect(probe.name).toBe('xero_list_connections'); expect(probe.parameters.required ?? []).toEqual([]); expect(probe.parameters.examples).toEqual([{}]); - expect(a.connector.healthcheckPath).toBe('/Organisation'); + expect(a.connector.healthcheckPath).toBe(connectionsUrl); }); it('documents callbacks, tenant selection, granular consent and rate limiting', () => { expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback'); expect(a.instructions).toContain('/api/mcp-oauth/callback'); - expect(a.instructions).toContain('GET https://api.xero.com/connections'); + expect(a.instructions).toContain('xero_list_connections'); + expect(a.instructions).toContain(connectionsUrl); expect(a.instructions).toContain('tenantId'); expect(a.instructions).toContain('Retry-After'); expect(a.instructions).toContain('consent is additive'); @@ -105,19 +115,25 @@ describe('Xero adapter: REST request mapping', () => { ...a.connector, authConfig: interpolateDeep(a.connector.authConfig, { XERO_CLIENT_ID: 'synthetic-client', XERO_CLIENT_SECRET: 'synthetic-secret', - XERO_REFRESH_TOKEN: '', XERO_TENANT_ID: tenantId, + XERO_REFRESH_TOKEN: '', }), }; + // The MCP and Run Test paths resolve tool-level {{VAR}} before the engine sees the mapping. + const mapped = (tool: (typeof a.tools)[number], envVars: Record = { XERO_TENANT_ID: tenantId }) => + interpolateConnectorConfig(a.connector, tool.endpointMapping as { method: string; path: string }, envVars) + .endpointMapping as { method: string }; it.each(a.tools.map((tool, index) => ({ tool, expectedPath: expectedPaths[index] })))( - 'sends $tool.name with bearer auth and the resolved tenant header', async ({ tool, expectedPath }) => { + 'sends $tool.name with bearer auth and the tenant header it needs', async ({ tool, expectedPath }) => { const example = (tool.parameters.examples as Record[])[0]; - await engine.execute(config, tool.endpointMapping as { method: string }, applySchemaDefaults(tool.parameters, example)); + await engine.execute(config, mapped(tool), applySchemaDefaults(tool.parameters, example)); const request = send.mock.calls[0][0]; + const accounting = expectedPath !== connectionsUrl; expect(request).toMatchObject({ - method: 'GET', url: a.connector.baseUrl + expectedPath, - headers: { Authorization: 'Bearer synthetic-access-token', 'xero-tenant-id': tenantId, Accept: 'application/json' }, + method: 'GET', url: accounting ? a.connector.baseUrl + expectedPath : connectionsUrl, + headers: { Authorization: 'Bearer synthetic-access-token', Accept: 'application/json' }, }); + expect(request.headers['xero-tenant-id']).toBe(accounting ? tenantId : undefined); expect(request.data).toBeUndefined(); expect(JSON.stringify(request)).not.toContain('{{'); expect(request.url).not.toContain('synthetic-'); @@ -126,14 +142,14 @@ describe('Xero adapter: REST request mapping', () => { it.each(['xero_list_invoices', 'xero_list_contacts'])('bounds an empty %s call to the first page', async (name) => { const tool = a.tools.find((item) => item.name === name)!; - await engine.execute(config, tool.endpointMapping as { method: string }, applySchemaDefaults(tool.parameters, {})); + await engine.execute(config, mapped(tool), applySchemaDefaults(tool.parameters, {})); expect(send.mock.calls[0][0].params).toEqual({ page: 1, pageSize: 100 }); }); it('keeps filters as query values and does not paginate accounts', async () => { const tool = a.tools.find((item) => item.name === 'xero_list_accounts')!; const params = { where: 'Name=="Example & Co"', order: 'Code ASC' }; - await engine.execute(config, tool.endpointMapping as { method: string }, params); + await engine.execute(config, mapped(tool), params); expect(send.mock.calls[0][0].params).toEqual(params); }); @@ -142,7 +158,7 @@ describe('Xero adapter: REST request mapping', () => { ['xero_list_contacts', { page: 2, pageSize: 50, searchTerm: 'Example & Co', where: 'ContactStatus=="ACTIVE"', order: 'Name ASC', includeArchived: false }], ])('maps all exposed %s list queries', async (name, params) => { const tool = a.tools.find((item) => item.name === name)!; - await engine.execute(config, tool.endpointMapping as { method: string }, params as Record); + await engine.execute(config, mapped(tool), params as Record); expect(send.mock.calls[0][0].params).toEqual(params); }); @@ -152,7 +168,7 @@ describe('Xero adapter: REST request mapping', () => { ])('preserves the %s response envelope', async (name, envelope) => { send.mockResolvedValueOnce({ data: envelope, status: 200, headers: {} } as AxiosResponse); const tool = a.tools.find((item) => item.name === name)!; - await expect(engine.execute(config, tool.endpointMapping as { method: string }, {})).resolves.toEqual(envelope); + await expect(engine.execute(config, mapped(tool), {})).resolves.toEqual(envelope); }); it.each([ @@ -160,7 +176,7 @@ describe('Xero adapter: REST request mapping', () => { ['xero_get_profit_and_loss', { fromDate: '2026-07-01', toDate: '2026-09-30', paymentsOnly: false, periods: 1, timeframe: 'QUARTER' }], ])('maps %s report dates and preserves accrual basis', async (name, params) => { const tool = a.tools.find((item) => item.name === name)!; - await engine.execute(config, tool.endpointMapping as { method: string }, params as Record); + await engine.execute(config, mapped(tool), params as Record); expect(send.mock.calls[0][0].params).toEqual(params); }); @@ -170,11 +186,11 @@ describe('Xero adapter: REST request mapping', () => { ['xero_get_account', 'accountId', '/Accounts'], ])('encodes %s identifiers so they cannot change the path', async (name, parameter, path) => { const tool = a.tools.find((item) => item.name === name)!; - await engine.execute(config, tool.endpointMapping as { method: string }, { [parameter]: 'id/other?query#fragment' }); + await engine.execute(config, mapped(tool), { [parameter]: 'id/other?query#fragment' }); expect(send.mock.calls[0][0].url).toBe(a.connector.baseUrl + path + '/id%2Fother%3Fquery%23fragment'); }); - it.each([undefined, ''])('installs and authorises with optional refresh token %s', async (refreshToken) => { + it.each([undefined, ''])('installs without a tenant, authorises, then finds and uses it (refresh token %s)', async (refreshToken) => { const encryptionKey = 'x'.repeat(48); const settings = { get: (key: string) => key === 'ENCRYPTION_KEY' ? encryptionKey : undefined }; let row: any; @@ -194,12 +210,12 @@ describe('Xero adapter: REST request mapping', () => { }) as ConnectorsService; const service = new AdaptersService(prisma as any, registry as any, settings as any, connectors); const credentials = { - XERO_CLIENT_ID: 'synthetic-client', XERO_CLIENT_SECRET: 'synthetic-secret', XERO_TENANT_ID: tenantId, + XERO_CLIENT_ID: 'synthetic-client', XERO_CLIENT_SECRET: 'synthetic-secret', XERO_TENANT_ID: '', ...(refreshToken === undefined ? {} : { XERO_REFRESH_TOKEN: refreshToken }), }; const installed = await service.importAdapter('xero', 'user-1', 'org-1', credentials); const initial = JSON.parse(decrypt(row.authConfig, encryptionKey)); - expect(installed).toMatchObject({ toolsCreated: 9, probe: null }); + expect(installed).toMatchObject({ toolsCreated: 10, probe: null }); expect(computeSetupState({ ...row, authConfig: initial })).toEqual({ status: 'needs_authorization', missing: [] }); expect(send).not.toHaveBeenCalled(); @@ -219,19 +235,32 @@ describe('Xero adapter: REST request mapping', () => { const saved = JSON.parse(decrypt(row.authConfig, encryptionKey)); expect(saved).toMatchObject({ accessToken: 'synthetic-authorised-token', refreshToken: 'synthetic-rotated-token', - extraHeaders: { 'xero-tenant-id': tenantId }, tokenAuthMethod: 'client_secret_basic', + tokenAuthMethod: 'client_secret_basic', }); expect(computeSetupState({ ...row, authConfig: saved })).toEqual({ status: 'ready', missing: [] }); await expect(connectors.testConnection(row.id)).resolves.toMatchObject({ ok: true }); - const probe = a.tools.find((item) => item.name === a.probe.tool)!; - await engine.execute({ ...a.connector, authConfig: saved }, probe.endpointMapping as { method: string }, {}); + const tool = (name: string) => a.tools.find((item) => item.name === name)!.endpointMapping as { method: string; path: string }; + await connectors.executeConnectorCall(row, tool(a.probe.tool), {}, a.probe.tool); expect(send).toHaveBeenCalledTimes(2); for (const [request] of send.mock.calls) { - expect(request).toMatchObject({ - method: 'GET', url: a.connector.baseUrl + '/Organisation', - headers: { Authorization: 'Bearer synthetic-authorised-token', 'xero-tenant-id': tenantId }, - }); + expect(request).toMatchObject({ method: 'GET', url: connectionsUrl, headers: { Authorization: 'Bearer synthetic-authorised-token' } }); + expect(request.headers).not.toHaveProperty('xero-tenant-id'); + } + + // Accounting tools refuse to run, naming the variable, until the tenant is set. + const { XERO_TENANT_ID: _empty, ...withoutTenant } = row.envVars; + for (const envVars of [row.envVars, withoutTenant]) { + await expect(connectors.executeConnectorCall({ ...row, envVars }, tool('xero_get_organisation'), {}, 'xero_get_organisation')) + .rejects.toThrow(/^The connector behind xero_get_organisation is missing a value for XERO_TENANT_ID. The request was not sent/); } + expect(send).toHaveBeenCalledTimes(2); + + await connectors.executeConnectorCall({ ...row, envVars: { ...row.envVars, XERO_TENANT_ID: tenantId } }, tool('xero_get_organisation'), {}, 'xero_get_organisation'); + expect(send).toHaveBeenCalledTimes(3); + expect(send.mock.calls[2][0]).toMatchObject({ + method: 'GET', url: a.connector.baseUrl + '/Organisation', + headers: { Authorization: 'Bearer synthetic-authorised-token', 'xero-tenant-id': tenantId }, + }); }); }); diff --git a/packages/backend/src/common/env-interpolation.util.spec.ts b/packages/backend/src/common/env-interpolation.util.spec.ts index b898b200..de885c4f 100644 --- a/packages/backend/src/common/env-interpolation.util.spec.ts +++ b/packages/backend/src/common/env-interpolation.util.spec.ts @@ -104,6 +104,22 @@ describe('EnvInterpolation', () => { expect(result.endpointMapping.path).toBeUndefined(); expect(result.config.baseUrl).toBe('https://v3.football.api-sports.io'); }); + + it('keeps the placeholder of a tool header that is only an empty variable', () => { + const mapping = { + method: 'GET', + path: '/Organisation', + headers: { 'xero-tenant-id': '{{XERO_TENANT_ID}}', 'X-Note': 'id {{XERO_TENANT_ID}}', 'X-Key': '{{TOKEN}}' }, + }; + const empty = interpolateConnectorConfig({ baseUrl: 'https://api.example.com' }, mapping, { ...envVars, XERO_TENANT_ID: ' ' }); + expect(empty.endpointMapping.headers).toEqual({ + 'xero-tenant-id': '{{XERO_TENANT_ID}}', + 'X-Note': 'id ', + 'X-Key': 'secret-token-123', + }); + const set = interpolateConnectorConfig({ baseUrl: 'https://api.example.com' }, mapping, { ...envVars, XERO_TENANT_ID: 'tenant-1' }); + expect(set.endpointMapping.headers!['xero-tenant-id']).toBe('tenant-1'); + }); }); // ── ReDoS regression ────────────────────────────────────────────────────── diff --git a/packages/backend/src/common/env-interpolation.util.ts b/packages/backend/src/common/env-interpolation.util.ts index 6c67d37f..fe14f6ef 100644 --- a/packages/backend/src/common/env-interpolation.util.ts +++ b/packages/backend/src/common/env-interpolation.util.ts @@ -187,8 +187,33 @@ export function interpolateConnectorConfig( }) : undefined, headers: endpointMapping.headers - ? interpolateDeep(endpointMapping.headers, envVars, options) + ? interpolateToolHeaders(endpointMapping.headers, envVars, options) : undefined, }, }; } + +/** A value that is exactly one `{{VAR}}` placeholder. */ +const ONLY_VARIABLE = /^\{\{([^{}]+)\}\}$/; + +/** + * Tool headers, interpolated like the rest of the mapping, except that a + * header whose whole value is one variable set to an empty string keeps its + * placeholder. The install form saves an optional field left empty as "", + * which would otherwise go out as a blank header (Xero's tenant ID before + * it is chosen); kept, it is refused with the variable's name, like one that + * was never set. + */ +function interpolateToolHeaders( + headers: Record, + envVars: Record, + options?: InterpolateOptions, +): Record { + const resolved = interpolateDeep(headers, envVars, options); + for (const [key, value] of Object.entries(headers)) { + const name = typeof value === 'string' ? ONLY_VARIABLE.exec(value)?.[1].trim() : undefined; + if (!name || (options?.reservedPrefix && name.startsWith(options.reservedPrefix))) continue; + if (typeof envVars[name] === 'string' && envVars[name].trim() === '') resolved[key] = value; + } + return resolved; +} From 4bb8f6bb741e28355b1e7f8daa466759638cf18a Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Fri, 9 Oct 2026 07:31:36 +1100 Subject: [PATCH 3/3] Correct keyless connector count for #150 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a02524c1..225f695b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 326 connectors, 16 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 326 connectors, 17 of them with no API key.

AnythingMCP: self-hosted MCP gateway