diff --git a/CITATION.cff b/CITATION.cff
index 14b808c6..e1964621 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 325 pre-built adapters, with a focus on ERP and e-commerce
+ It ships 326 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 e1fb2d71..0c07ae9b 100644
--- a/README.de.md
+++ b/README.de.md
@@ -1,5 +1,5 @@
-
+
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 325 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 17 davon kommen ohne API-Schlüssel aus.
+ Es bringt 326 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
-325 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.
+326 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 3c9525cf..7aed9e2a 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -1,5 +1,5 @@
-
+
AnythingMCP:セルフホスト型 MCP ゲートウェイ
@@ -19,7 +19,7 @@
AnythingMCP は、オープンソースのセルフホスト型 MCP ゲートウェイです。MCP サーバーを書かずに、REST/OpenAPI、SOAP、GraphQL、OData、SQL のあらゆるシステムを Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- SAP、Etsy、weclapp、Amazon Seller などを含む 325 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。
+ SAP、Etsy、weclapp、Amazon Seller などを含む 326 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。
@@ -111,7 +111,7 @@ amd64 ではイメージの取得に約 30 秒、その 24 秒後に API が利
## コネクターカタログ
-325 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。
+326 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。
| カテゴリー | 例 |
|---|---|
diff --git a/README.md b/README.md
index c3db31ac..53e92e72 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
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 325 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 17 of them need no API key.
+ It ships 326 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
-325 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages.
+326 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 0ecd7f6b..793eb8a8 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,5 +1,5 @@
-
+
AnythingMCP:自行托管的 MCP 网关
@@ -19,7 +19,7 @@
AnythingMCP 是一个开源、可自行托管的 MCP 网关,无需编写 MCP 服务器,即可将任意 REST/OpenAPI、SOAP、GraphQL、OData 或 SQL 系统转化为 Claude、ChatGPT 和 Copilot 可用的 MCP 工具。
- 它自带 325 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 17 个无需 API 密钥。
+ 它自带 326 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 17 个无需 API 密钥。
@@ -111,7 +111,7 @@ docker compose up -d
## 连接器目录
-共 325 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。
+共 326 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。
| 类别 | 示例 |
|---|---|
diff --git a/glama.json b/glama.json
index 35dd2f13..b60bdefe 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. 325 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. 326 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 784340b5..824e958f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "anythingmcp",
"version": "0.20.0",
- "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 325 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. 326 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 9c7ba1ef..c91eba80 100644
--- a/packages/backend/src/adapters/catalog.ts
+++ b/packages/backend/src/adapters/catalog.ts
@@ -306,6 +306,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';
@@ -844,6 +845,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..b8da667a
--- /dev/null
+++ b/packages/backend/src/adapters/intl/xero.json
@@ -0,0 +1,190 @@
+{
+ "slug": "xero",
+ "name": "Xero",
+ "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"],
+ "optionalEnvVars": ["XERO_TENANT_ID", "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": "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."
+ },
+ "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"
+ },
+ "headers": { "Accept": "application/json" },
+ "healthcheckPath": "https://api.xero.com/connections"
+ },
+ "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", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" } }
+ },
+ {
+ "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", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" },
+ "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}", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "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", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" },
+ "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}", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "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", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "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}", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "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", "headers": { "xero-tenant-id": "{{XERO_TENANT_ID}}" }, "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", "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
new file mode 100644
index 00000000..6c6d3327
--- /dev/null
+++ b/packages/backend/src/adapters/intl/xero.live.spec.ts
@@ -0,0 +1,282 @@
+import * as adapter from './xero.json';
+import { AdapterDefinition, getAdapter } from '../catalog';
+import { applySchemaDefaults } from '../../common/schema-defaults.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';
+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 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',
+ '/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']);
+ 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}}',
+ 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',
+ });
+ // 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',
+ 'accounting.reports.profitandloss.read',
+ ].sort());
+ });
+
+ 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.bodyMapping).toBeUndefined();
+ expect(tool.endpointMapping.bodyTemplate).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_list_connections');
+ expect(probe.parameters.required ?? []).toEqual([]);
+ expect(probe.parameters.examples).toEqual([{}]);
+ 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('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');
+ });
+});
+
+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: '',
+ }),
+ };
+ // 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 tenant header it needs', async ({ tool, expectedPath }) => {
+ const example = (tool.parameters.examples as Record[])[0];
+ 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: 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-');
+ },
+ );
+
+ 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, 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, mapped(tool), 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, mapped(tool), 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, mapped(tool), {})).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, mapped(tool), 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, 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 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;
+ 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: '',
+ ...(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: 10, 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',
+ tokenAuthMethod: 'client_secret_basic',
+ });
+ expect(computeSetupState({ ...row, authConfig: saved })).toEqual({ status: 'ready', missing: [] });
+ await expect(connectors.testConnection(row.id)).resolves.toMatchObject({ ok: true });
+ 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: 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 },
+ });
+ });
+});
+
+// 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/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;
+}
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 45bd44e7..2247584e 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. 325 connectors: SAP, ERP.",
+ "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 326 connectors: SAP, ERP.",
"repository": {
"url": "https://github.com/HelpCode-ai/anythingmcp",
"source": "github"