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 macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 325 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: 326 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 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 は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。325 のコネクター、うち 17 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。326 のコネクター、うち 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 などを含む 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 turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 325 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

@@ -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 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:325 个连接器,其中 17 个无需 API 密钥。 + AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:326 个连接器,其中 17 个无需 API 密钥。

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"