diff --git a/CITATION.cff b/CITATION.cff
index 1376e1832..c132ff7f2 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 331 pre-built adapters, with a focus on ERP and e-commerce
+ It ships 338 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 b07b352e8..998b5afa8 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 331 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 17 davon kommen ohne API-Schlüssel aus.
+ Es bringt 338 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
-331 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.
+338 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 0176e1903..876e04fb5 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 などを含む 331 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。
+ SAP、Etsy、weclapp、Amazon Seller などを含む 338 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。
@@ -111,7 +111,7 @@ amd64 ではイメージの取得に約 30 秒、その 24 秒後に API が利
## コネクターカタログ
-331 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。
+338 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。
| カテゴリー | 例 |
|---|---|
diff --git a/README.md b/README.md
index 7087e09e0..67351ebbd 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 331 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 17 of them need no API key.
+ It ships 338 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
-331 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages.
+338 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 aa44e1749..69bf7e45f 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 工具。
- 它自带 331 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 17 个无需 API 密钥。
+ 它自带 338 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 17 个无需 API 密钥。
@@ -111,7 +111,7 @@ docker compose up -d
## 连接器目录
-共 331 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。
+共 338 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。
| 类别 | 示例 |
|---|---|
diff --git a/docs/tool-definition.md b/docs/tool-definition.md
index 7f1510640..8cb5a7978 100644
--- a/docs/tool-definition.md
+++ b/docs/tool-definition.md
@@ -339,6 +339,27 @@ instead of a text field containing the URL. Declare the parameter with `"type":
"format": "uri"` so the model knows to pass a link. `__file` in a `form-urlencoded` body is a
configuration error — that encoding cannot carry a file at all.
+### JSON-encoded fields (`__json`)
+
+Some APIs take a method's arguments as one field holding JSON text, e.g. a form body
+`method=getOrders¶meters={"order_id":42}`. Wrap that field in a `__json` marker and it goes
+out as the JSON text of its resolved value:
+
+```json
+{
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getOrders",
+ "parameters": { "__json": { "order_id": "$order_id", "status_id": "$status_id" } }
+ }
+}
+```
+
+Arguments the caller leaves out are dropped first, so the example sends `{"order_id":42}` when only
+`order_id` is given, and `{}` when nothing is. `{ "__json": "$order" }` passes a whole object
+argument through. The marker works in JSON bodies too, where the field becomes a string. Only
+markers written in the tool's own mapping count; one inside a caller's argument is an ordinary object.
+
### XML bodies
APIs that take an XML request (TallyPrime, SOAP-like endpoints) get their body
diff --git a/glama.json b/glama.json
index b8dd177cc..c9569136c 100644
--- a/glama.json
+++ b/glama.json
@@ -3,5 +3,5 @@
"maintainers": [
"helpcodeai"
],
- "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 331 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. 338 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 b0402699a..e492d730d 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. 331 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. 338 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 41b3e290a..cc95c1f0a 100644
--- a/packages/backend/src/adapters/catalog.ts
+++ b/packages/backend/src/adapters/catalog.ts
@@ -90,6 +90,7 @@ import * as attio from './intl/attio.json';
import * as autotask from './intl/autotask.json';
import * as bamboohr from './intl/bamboohr.json';
import * as basecamp from './intl/basecamp.json';
+import * as baselinker from './intl/baselinker.json';
import * as baserow from './intl/baserow.json';
import * as beehiiv from './intl/beehiiv.json';
import * as bigcommerce from './intl/bigcommerce.json';
@@ -170,6 +171,7 @@ import * as height from './intl/height.json';
import * as helium10 from './intl/helium10.json';
import * as helpScout from './intl/help-scout.json';
import * as hetznerCloud from './intl/hetzner-cloud.json';
+import * as hubspot from './intl/hubspot.json';
import * as hunter from './intl/hunter.json';
import * as idealista from './intl/idealista.json';
import * as inoreader from './intl/inoreader.json';
@@ -182,6 +184,7 @@ import * as jev from './intl/jev.json';
import * as kashflow from './intl/kashflow.json';
import * as keycrm from './intl/keycrm.json';
import * as klaviyo from './intl/klaviyo.json';
+import * as kommo from './intl/kommo.json';
import * as koreaLaw from './intl/korea-law.json';
import * as kustomer from './intl/kustomer.json';
import * as lemlist from './intl/lemlist.json';
@@ -204,10 +207,14 @@ import * as mautic from './intl/mautic.json';
import * as medium from './intl/medium.json';
import * as messagebird from './intl/messagebird.json';
import * as microsoftBookings from './intl/microsoft-bookings.json';
+import * as microsoftExcel from './intl/microsoft-excel.json';
+import * as microsoftOnedrive from './intl/microsoft-onedrive.json';
import * as microsoftOutlook from './intl/microsoft-outlook.json';
+import * as microsoftSharepoint from './intl/microsoft-sharepoint.json';
import * as microsoftTeams from './intl/microsoft-teams.json';
import * as mintlify from './intl/mintlify.json';
import * as mollie from './intl/mollie.json';
+import * as monday from './intl/monday.json';
import * as mongodb from './intl/mongodb.json';
import * as mssql from './intl/mssql.json';
import * as mysql from './intl/mysql.json';
@@ -245,6 +252,7 @@ import * as printify from './intl/printify.json';
import * as procore from './intl/procore.json';
import * as promUa from './intl/prom-ua.json';
import * as proxmox from './intl/proxmox.json';
+import * as quickbooksOnline from './intl/quickbooks-online.json';
import * as recurly from './intl/recurly.json';
import * as reddit from './intl/reddit.json';
import * as redditAds from './intl/reddit-ads.json';
@@ -639,6 +647,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [
autotask as unknown as AdapterDefinition,
bamboohr as unknown as AdapterDefinition,
basecamp as unknown as AdapterDefinition,
+ baselinker as unknown as AdapterDefinition,
baserow as unknown as AdapterDefinition,
beehiiv as unknown as AdapterDefinition,
bigcommerce as unknown as AdapterDefinition,
@@ -719,6 +728,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [
helium10 as unknown as AdapterDefinition,
helpScout as unknown as AdapterDefinition,
hetznerCloud as unknown as AdapterDefinition,
+ hubspot as unknown as AdapterDefinition,
hunter as unknown as AdapterDefinition,
idealista as unknown as AdapterDefinition,
inoreader as unknown as AdapterDefinition,
@@ -731,6 +741,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [
kashflow as unknown as AdapterDefinition,
keycrm as unknown as AdapterDefinition,
klaviyo as unknown as AdapterDefinition,
+ kommo as unknown as AdapterDefinition,
koreaLaw as unknown as AdapterDefinition,
kustomer as unknown as AdapterDefinition,
lemlist as unknown as AdapterDefinition,
@@ -753,10 +764,14 @@ const RAW_ADAPTERS: AdapterDefinition[] = [
medium as unknown as AdapterDefinition,
messagebird as unknown as AdapterDefinition,
microsoftBookings as unknown as AdapterDefinition,
+ microsoftExcel as unknown as AdapterDefinition,
+ microsoftOnedrive as unknown as AdapterDefinition,
microsoftOutlook as unknown as AdapterDefinition,
+ microsoftSharepoint as unknown as AdapterDefinition,
microsoftTeams as unknown as AdapterDefinition,
mintlify as unknown as AdapterDefinition,
mollie as unknown as AdapterDefinition,
+ monday as unknown as AdapterDefinition,
mongodb as unknown as AdapterDefinition,
mssql as unknown as AdapterDefinition,
mysql as unknown as AdapterDefinition,
@@ -794,6 +809,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [
procore as unknown as AdapterDefinition,
promUa as unknown as AdapterDefinition,
proxmox as unknown as AdapterDefinition,
+ quickbooksOnline as unknown as AdapterDefinition,
recurly as unknown as AdapterDefinition,
reddit as unknown as AdapterDefinition,
redditAds as unknown as AdapterDefinition,
diff --git a/packages/backend/src/adapters/intl/baselinker.json b/packages/backend/src/adapters/intl/baselinker.json
new file mode 100644
index 000000000..fc9dfcbf0
--- /dev/null
+++ b/packages/backend/src/adapters/intl/baselinker.json
@@ -0,0 +1,799 @@
+{
+ "slug": "baselinker",
+ "name": "Base (Baselinker)",
+ "description": "Let your AI work in Base (Baselinker): find orders across shops and marketplaces, check statuses and shipments, move orders between statuses, and look up products, prices and stock per warehouse. One account, with its API token.",
+ "instructions": "## Base (Baselinker)\nThis connector works with one Base account (formerly Baselinker) through its **API** (`https://api.baselinker.com/connector.php`): orders with statuses, sources, event journal and shipments, product catalogs (inventories) with products, prices and stock, warehouses and couriers. It authenticates with the account's **API token** in the `X-BLToken` header.\n\n**Setup (about 2 minutes)**\n1. In Base open **Account & other → My account → API**.\n2. Generate a token (or copy the existing one) and paste it into `BASELINKER_TOKEN`.\n3. To use `baselinker_get_journal_list`, enable the order journal in the same API settings; otherwise it returns an empty list.\n4. A token sees the whole account. Generating a new token invalidates the old one.\n\n**Read-only setup**: switch off `baselinker_set_order_status`. `baselinker_add_order`, `baselinker_update_inventory_products_stock` and `baselinker_create_package` are installed switched off: they create orders, overwrite stock that is pushed to your shops and marketplaces, and order paid courier labels.\n\n**How calls work**: every tool posts one API method (`method`) with its arguments as JSON (`parameters`). Ids are integers. Dates are Unix timestamps in seconds. Order statuses are account-specific ids from `baselinker_get_order_status_list`. Stock and locations are keyed by warehouse `_`, e.g. `bl_123`, from `baselinker_get_inventory_warehouses`; prices by price group id. `baselinker_get_inventory_products_data` returns `products` as an object keyed by product id.\n\n**Pagination**: `baselinker_get_orders` returns at most 100 orders, oldest first. For the next batch set `date_confirmed_from` to the last order's `date_confirmed` + 1 and repeat until fewer than 100 come back. Product list and stock tools return 1000 products per `page`, starting at 1. The journal covers only the last 3 days; continue from the last event id with `last_log_id`.\n\n**Rate limit**: 100 requests per minute per account. Exceeding it blocks the API for the account for a while, so prefer filters (status, source, date) over paging through everything, and avoid tight loops.\n\n**Errors**: Base answers HTTP 200 with `\"status\": \"ERROR\"`, an `error_code` and an `error_message`; the connector reports these as errors. `ERROR_BAD_TOKEN`: the token is wrong or was regenerated. `ERROR_USER_ACCOUNT_BLOCKED`: the account or API access is blocked, often after exceeding the request limit; wait before retrying. `ERROR_UNKNOWN_METHOD`, `ERROR_PARSE_JSON_PARAMETERS` or a message naming a field: wrong input. Partial success is possible: `baselinker_update_inventory_products_stock` lists per-product problems under `warnings`.\n\n**Out of scope here**: invoices and receipts, returns, CRM clients, product creation and editing, price updates, documents, purchase orders and labels download.",
+ "unlisted": true,
+ "region": "intl",
+ "category": "e-commerce",
+ "icon": "baselinker",
+ "docsUrl": "https://api.baselinker.com/",
+ "requiredEnvVars": [
+ "BASELINKER_TOKEN"
+ ],
+ "envVarMeta": {
+ "BASELINKER_TOKEN": {
+ "label": "API token",
+ "kind": "credential",
+ "secret": true,
+ "help": "Base panel → Account & other → My account → API → generate or copy the token.",
+ "link": "https://api.baselinker.com/",
+ "pattern": "^\\S+$",
+ "patternMessage": "Paste only the token, without spaces."
+ }
+ },
+ "verifyHints": {
+ "401": "Base refused the token (ERROR_BAD_TOKEN). Copy it again from Account & other → My account → API; generating a new token invalidates the old one.",
+ "403": "Base reports the account or its API access as blocked. This often follows exceeding 100 requests per minute; wait a few minutes, then try again.",
+ "rate_limited": "Base allows 100 requests per minute per account. Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "baselinker_get_order_status_list"
+ },
+ "prerequisites": "A Base (Baselinker) account and access to Account & other → My account → API to generate the API token.",
+ "connector": {
+ "name": "Base (Baselinker) API",
+ "type": "REST",
+ "baseUrl": "https://api.baselinker.com",
+ "authType": "API_KEY",
+ "authConfig": {
+ "headerName": "X-BLToken",
+ "apiKey": "{{BASELINKER_TOKEN}}"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "config": {
+ "errorWhen": [
+ {
+ "path": "status",
+ "equals": "ERROR",
+ "messagePath": [
+ "error_code",
+ "error_message"
+ ],
+ "messageMatches": "^ERROR_(BAD_TOKEN|EMPTY_TOKEN|NO_TOKEN)\\b",
+ "status": 401
+ },
+ {
+ "path": "status",
+ "equals": "ERROR",
+ "messagePath": [
+ "error_code",
+ "error_message"
+ ],
+ "messageMatches": "limit exceeded|too many requests",
+ "status": 429
+ },
+ {
+ "path": "status",
+ "equals": "ERROR",
+ "messagePath": [
+ "error_code",
+ "error_message"
+ ],
+ "messageMatches": "^ERROR_USER_ACCOUNT_BLOCKED\\b",
+ "status": 403
+ },
+ {
+ "path": "status",
+ "equals": "ERROR",
+ "messagePath": [
+ "error_code",
+ "error_message"
+ ],
+ "messageMatches": "_NOT_FOUND\\b",
+ "status": 404
+ },
+ {
+ "path": "status",
+ "equals": "ERROR",
+ "messagePath": [
+ "error_code",
+ "error_message"
+ ],
+ "status": 400
+ }
+ ]
+ }
+ },
+ "tools": [
+ {
+ "name": "baselinker_get_order_status_list",
+ "description": "List the order statuses defined in the order manager: id, name, color, group id and the name shown to customers. Status ids are what getOrders filters and setOrderStatus take.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getOrderStatusList",
+ "parameters": {
+ "__json": {}
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_order_sources",
+ "description": "List order sources grouped by type (personal, shop, allegro, amazon, ebay and other marketplaces): per source its id and name. The type matches order_source in orders, the id order_source_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getOrderSources",
+ "parameters": {
+ "__json": {}
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_orders",
+ "description": "Get orders from the order manager with buyer, delivery and invoice data, payment, status, products and prices. At most 100 orders per call, oldest first: to page, call again with date_confirmed_from set to the last order's date_confirmed plus 1 until fewer than 100 come back. Dates are Unix timestamps in seconds.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "integer",
+ "description": "Get this one order."
+ },
+ "date_confirmed_from": {
+ "type": "integer",
+ "description": "Orders confirmed at or after this Unix time (seconds). The recommended paging key."
+ },
+ "date_from": {
+ "type": "integer",
+ "description": "Orders created at or after this Unix time (seconds)."
+ },
+ "id_from": {
+ "type": "integer",
+ "description": "Orders with an id from this number on."
+ },
+ "status_id": {
+ "type": "integer",
+ "description": "Only orders in this status (id from baselinker_get_order_status_list)."
+ },
+ "filter_email": {
+ "type": "string",
+ "description": "Only orders with this buyer email."
+ },
+ "filter_order_source": {
+ "type": "string",
+ "description": "Only orders from this source type, e.g. \"shop\", \"allegro\", \"amazon\", \"ebay\", \"personal\"."
+ },
+ "filter_order_source_id": {
+ "type": "integer",
+ "description": "Only orders from this source id (with filter_order_source)."
+ },
+ "filter_shop_order_id": {
+ "type": "integer",
+ "description": "Only the order with this id in the online shop."
+ },
+ "filter_external_order_id": {
+ "type": "string",
+ "description": "Only the order with this marketplace order id."
+ },
+ "get_unconfirmed_orders": {
+ "type": "boolean",
+ "description": "true to include unconfirmed (possibly incomplete) orders (default false)."
+ },
+ "include_custom_extra_fields": {
+ "type": "boolean",
+ "description": "true to add custom extra field values."
+ },
+ "include_commissions": {
+ "type": "boolean",
+ "description": "true to add marketplace commission data."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getOrders",
+ "parameters": {
+ "__json": {
+ "order_id": "$order_id",
+ "date_confirmed_from": "$date_confirmed_from",
+ "date_from": "$date_from",
+ "id_from": "$id_from",
+ "status_id": "$status_id",
+ "filter_email": "$filter_email",
+ "filter_order_source": "$filter_order_source",
+ "filter_order_source_id": "$filter_order_source_id",
+ "filter_shop_order_id": "$filter_shop_order_id",
+ "filter_external_order_id": "$filter_external_order_id",
+ "get_unconfirmed_orders": "$get_unconfirmed_orders",
+ "include_custom_extra_fields": "$include_custom_extra_fields",
+ "include_commissions": "$include_commissions"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_journal_list",
+ "description": "List order events of the last 3 days (created, confirmed, paid, status changed, package created, ...) with event id, order id, type and date, to see what changed since a known event. Must be enabled in the account's API settings, otherwise the list is empty.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "last_log_id": {
+ "type": "integer",
+ "description": "Only events after this event id (the last id you processed)."
+ },
+ "logs_types": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only these event types, e.g. [1, 18]: 1 created, 2 confirmed, 3 paid, 9 package created, 18 status changed, 22 package status changed."
+ },
+ "order_id": {
+ "type": "integer",
+ "description": "Only events of this order."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getJournalList",
+ "parameters": {
+ "__json": {
+ "last_log_id": "$last_log_id",
+ "logs_types": "$logs_types",
+ "order_id": "$order_id"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_order_packages",
+ "description": "List the shipments created for an order: package id, consignment (tracking) number, courier code, tracking status (0 unknown, 1 label created, 2 shipped, 4 out for delivery, 5 delivered, 6 return, 8 waiting at point, 9 lost, 10 canceled), tracking URL and whether it is a return.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "integer",
+ "description": "Order id."
+ }
+ },
+ "required": [
+ "order_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getOrderPackages",
+ "parameters": {
+ "__json": {
+ "order_id": "$order_id"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_set_order_status",
+ "description": "Move an order to another status. Status changes can start automatic actions configured in the account (customer emails, invoices, marketplace updates), so show the user the order and target status and wait for confirmation.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "integer",
+ "description": "Order id."
+ },
+ "status_id": {
+ "type": "integer",
+ "description": "Target status id from baselinker_get_order_status_list."
+ }
+ },
+ "required": [
+ "order_id",
+ "status_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "setOrderStatus",
+ "parameters": {
+ "__json": {
+ "order_id": "$order_id",
+ "status_id": "$status_id"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true
+ }
+ },
+ {
+ "name": "baselinker_add_order",
+ "description": "Create an order in the order manager, with buyer, delivery and invoice data and products. Returns the new order_id. Installed switched off: turn it on only if the AI should create orders, and confirm every order with the user.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order": {
+ "type": "object",
+ "description": "The order. Required: order_status_id, date_add (Unix seconds), currency (e.g. \"EUR\"), payment_method, payment_method_cod (bool), paid (bool), user_comments, admin_comments, email, phone, user_login, delivery_method, delivery_price, delivery_fullname, delivery_company, delivery_address, delivery_postcode, delivery_city, delivery_state, delivery_country_code, delivery_point_id, delivery_point_name, delivery_point_address, delivery_point_postcode, delivery_point_city, invoice_fullname, invoice_company, invoice_nip, invoice_address, invoice_postcode, invoice_city, invoice_state, invoice_country_code, want_invoice (bool), extra_field_1, extra_field_2 (empty strings where unknown). Optional: custom_source_id, custom_extra_fields. products: [{storage: \"db\", storage_id: 0, product_id, variant_id, name, sku, ean, location, warehouse_id, attributes, price_brutto, tax_rate, quantity, weight}]."
+ }
+ },
+ "required": [
+ "order"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "addOrder",
+ "parameters": {
+ "__json": "$order"
+ }
+ }
+ }
+ },
+ {
+ "name": "baselinker_get_inventories",
+ "description": "List the product catalogs (inventories): inventory id, name, description, languages, price group ids and default price group, warehouse ids and default warehouse, and whether it is the default catalog.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getInventories",
+ "parameters": {
+ "__json": {}
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_inventory_warehouses",
+ "description": "List warehouses: type (bl for own warehouses, shop, warehouse, fulfillment), id, name, address, whether stock may be edited by hand and whether it is the default. Stock keys elsewhere are \"_\", e.g. bl_123.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getInventoryWarehouses",
+ "parameters": {
+ "__json": {}
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_inventory_products_list",
+ "description": "List or search products of a catalog with id, parent id, SKU, EAN, name, gross prices per price group and stock per warehouse. 1000 products per page; page from 1 until fewer come back.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "inventory_id": {
+ "type": "integer",
+ "description": "Catalog (inventory) id from baselinker_get_inventories."
+ },
+ "filter_id": {
+ "type": "integer",
+ "description": "Only this product id."
+ },
+ "filter_category_id": {
+ "type": "integer",
+ "description": "Only products in this category."
+ },
+ "filter_ean": {
+ "type": "string",
+ "description": "Only products with this EAN (additional EANs included)."
+ },
+ "filter_sku": {
+ "type": "string",
+ "description": "Only products with this SKU."
+ },
+ "filter_name": {
+ "type": "string",
+ "description": "Products whose name contains this text."
+ },
+ "filter_price_from": {
+ "type": "number",
+ "description": "Minimum price."
+ },
+ "filter_price_to": {
+ "type": "number",
+ "description": "Maximum price."
+ },
+ "filter_stock_from": {
+ "type": "integer",
+ "description": "Minimum stock."
+ },
+ "filter_stock_to": {
+ "type": "integer",
+ "description": "Maximum stock."
+ },
+ "filter_sort": {
+ "type": "string",
+ "enum": [
+ "id ASC",
+ "id DESC"
+ ],
+ "description": "Sort order."
+ },
+ "include_variants": {
+ "type": "boolean",
+ "description": "true to list variants too."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1 (1000 products per page)."
+ }
+ },
+ "required": [
+ "inventory_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getInventoryProductsList",
+ "parameters": {
+ "__json": {
+ "inventory_id": "$inventory_id",
+ "filter_id": "$filter_id",
+ "filter_category_id": "$filter_category_id",
+ "filter_ean": "$filter_ean",
+ "filter_sku": "$filter_sku",
+ "filter_name": "$filter_name",
+ "filter_price_from": "$filter_price_from",
+ "filter_price_to": "$filter_price_to",
+ "filter_stock_from": "$filter_stock_from",
+ "filter_stock_to": "$filter_stock_to",
+ "filter_sort": "$filter_sort",
+ "include_variants": "$include_variants",
+ "page": "$page"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_inventory_products_data",
+ "description": "Get full data of selected products: SKU, EAN, tax rate, weight and size, category, manufacturer, prices per price group, stock and locations per warehouse, names and descriptions (text_fields), images, links to shops and variants.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "inventory_id": {
+ "type": "integer",
+ "description": "Catalog (inventory) id from baselinker_get_inventories."
+ },
+ "products": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Product ids, e.g. [1234, 5678]. Keep it to about 100 per call."
+ },
+ "include_suppliers": {
+ "type": "boolean",
+ "description": "true to add supplier data."
+ },
+ "include_relations": {
+ "type": "boolean",
+ "description": "true to add related, substitute and cross-sell products."
+ }
+ },
+ "required": [
+ "inventory_id",
+ "products"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getInventoryProductsData",
+ "parameters": {
+ "__json": {
+ "inventory_id": "$inventory_id",
+ "products": "$products",
+ "include_suppliers": "$include_suppliers",
+ "include_relations": "$include_relations"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_inventory_products_stock",
+ "description": "Get stock (and reservations, where enabled) per warehouse for the products of a catalog, variants included, optionally filtered. 1000 products per page.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "inventory_id": {
+ "type": "integer",
+ "description": "Catalog (inventory) id from baselinker_get_inventories."
+ },
+ "filter_id": {
+ "type": "integer",
+ "description": "Only this product id."
+ },
+ "filter_sku": {
+ "type": "string",
+ "description": "Only products with this SKU."
+ },
+ "filter_ean": {
+ "type": "string",
+ "description": "Only products with this EAN."
+ },
+ "filter_name": {
+ "type": "string",
+ "description": "Products whose name contains this text."
+ },
+ "filter_category_id": {
+ "type": "integer",
+ "description": "Only products in this category."
+ },
+ "filter_stock_from": {
+ "type": "integer",
+ "description": "Minimum stock."
+ },
+ "filter_stock_to": {
+ "type": "integer",
+ "description": "Maximum stock."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1 (1000 products per page)."
+ }
+ },
+ "required": [
+ "inventory_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getInventoryProductsStock",
+ "parameters": {
+ "__json": {
+ "inventory_id": "$inventory_id",
+ "filter_id": "$filter_id",
+ "filter_sku": "$filter_sku",
+ "filter_ean": "$filter_ean",
+ "filter_name": "$filter_name",
+ "filter_category_id": "$filter_category_id",
+ "filter_stock_from": "$filter_stock_from",
+ "filter_stock_to": "$filter_stock_to",
+ "page": "$page"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_update_inventory_products_stock",
+ "description": "Set the stock of products or variants in a catalog's own warehouses to absolute quantities (not a delta); connected shops and marketplaces pick the new stock up. Up to 1000 products per call. Returns the number updated and warnings per product. Installed switched off: turn it on only if the AI should change stock, and confirm the quantities with the user.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "inventory_id": {
+ "type": "integer",
+ "description": "Catalog (inventory) id from baselinker_get_inventories."
+ },
+ "products": {
+ "type": "object",
+ "description": "Product or variant id mapped to new stock per warehouse, e.g. {\"1234\": {\"bl_1\": 10}, \"5678\": {\"bl_1\": 0, \"bl_2\": 4}}. Warehouse keys from baselinker_get_inventory_warehouses; warehouses created for shops or wholesalers cannot be set.",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "number"
+ }
+ }
+ }
+ },
+ "required": [
+ "inventory_id",
+ "products"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "updateInventoryProductsStock",
+ "parameters": {
+ "__json": {
+ "inventory_id": "$inventory_id",
+ "products": "$products"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "destructiveHint": true,
+ "idempotentHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_couriers_list",
+ "description": "List the couriers available in the account, with the courier code (e.g. dpd, inpost, gls) and name. The code is what package tools take.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getCouriersList",
+ "parameters": {
+ "__json": {}
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_get_courier_fields",
+ "description": "Get the form fields a courier needs to create a shipment (id, name, type, options, defaults) and the package fields (weight, size), plus whether it supports multi-parcel shipments. Needed before baselinker_create_package.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "courier_code": {
+ "type": "string",
+ "description": "Courier code from baselinker_get_couriers_list."
+ }
+ },
+ "required": [
+ "courier_code"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "getCourierFields",
+ "parameters": {
+ "__json": {
+ "courier_code": "$courier_code"
+ }
+ }
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "baselinker_create_package",
+ "description": "Create a shipment with a courier for an order, which orders a real label and may be charged by the courier. Returns package id and consignment number. Installed switched off: turn it on only if the AI should book shipments, and confirm every shipment with the user.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "integer",
+ "description": "Order id."
+ },
+ "courier_code": {
+ "type": "string",
+ "description": "Courier code from baselinker_get_couriers_list."
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "Courier account id; the first account when left out."
+ },
+ "fields": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Form fields from baselinker_get_courier_fields as [{\"id\": \"service\", \"value\": \"...\"}]; repeat an id for each checked option. Dates as Unix time."
+ },
+ "packages": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Parcels with the package fields, weight in kg and sizes in cm, e.g. [{\"weight\": 1.5, \"length\": 30, \"width\": 20, \"height\": 10}]."
+ }
+ },
+ "required": [
+ "order_id",
+ "courier_code",
+ "packages"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/connector.php",
+ "bodyEncoding": "form-urlencoded",
+ "bodyMapping": {
+ "method": "createPackage",
+ "parameters": {
+ "__json": {
+ "order_id": "$order_id",
+ "courier_code": "$courier_code",
+ "account_id": "$account_id",
+ "fields": "$fields",
+ "packages": "$packages"
+ }
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/baselinker.live.spec.ts b/packages/backend/src/adapters/intl/baselinker.live.spec.ts
new file mode 100644
index 000000000..513afabcd
--- /dev/null
+++ b/packages/backend/src/adapters/intl/baselinker.live.spec.ts
@@ -0,0 +1,293 @@
+import * as http from 'node:http';
+import { AddressInfo } from 'node:net';
+import * as adapterJson from './baselinker.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import {
+ assertNoResponseBodyError,
+ describeErrorWhenProblems,
+ ResponseBodyError,
+} from '../../connectors/engines/response-error.util';
+
+/**
+ * Static checks and request-shape checks against a local server always run.
+ * The live block runs read-only methods through the real RestEngine when a
+ * token is set (mind the 100 requests per minute):
+ * BASELINKER_TOKEN=1-23-ABC npx jest src/adapters/intl/baselinker.live.spec.ts
+ * BASELINKER_LIVE_WRITE=1 also adds an order whose buyer is "AnythingMCP test",
+ * moves it to its own status again, and deletes it at the end.
+ */
+
+type Mapping = { method: string; path: string; bodyEncoding?: string; bodyMapping: { method: string; parameters: { __json: unknown } } };
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ annotations?: Record;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: Mapping;
+};
+const a = adapterJson as unknown as {
+ unlisted?: boolean;
+ instructions: string;
+ requiredEnvVars: string[];
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ config: { errorWhen: unknown };
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const rules = a.connector.config.errorWhen;
+const statusOf = (body: unknown): number | null => {
+ try {
+ assertNoResponseBodyError(body, rules);
+ return null;
+ } catch (e) {
+ expect(e).toBeInstanceOf(ResponseBodyError);
+ return (e as ResponseBodyError).status;
+ }
+};
+const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService);
+const OFF = ['baselinker_add_order', 'baselinker_create_package', 'baselinker_update_inventory_products_stock'];
+const WRITES = [...OFF, 'baselinker_set_order_status'].sort();
+
+describe('baselinker adapter: static spec conformance', () => {
+ it('is unlisted until verified against a real account', () => {
+ expect(a.unlisted).toBe(true);
+ });
+
+ it('sends the token in X-BLToken', () => {
+ expect(a.connector.baseUrl).toBe('https://api.baselinker.com');
+ expect(a.connector.authType).toBe('API_KEY');
+ expect(a.connector.authConfig).toEqual({ headerName: 'X-BLToken', apiKey: '{{BASELINKER_TOKEN}}' });
+ expect(a.requiredEnvVars).toEqual(['BASELINKER_TOKEN']);
+ });
+
+ it('posts every method to connector.php as method plus JSON parameters', () => {
+ for (const t of a.tools) {
+ expect(t.name.startsWith('baselinker_')).toBe(true);
+ const m = t.endpointMapping;
+ expect(`${t.name}:${m.method} ${m.path} ${m.bodyEncoding}`).toBe(`${t.name}:POST /connector.php form-urlencoded`);
+ expect(Object.keys(m.bodyMapping)).toEqual(['method', 'parameters']);
+ expect(m.bodyMapping.method).toMatch(/^(get|set|add|update|create)[A-Z]\w+$/);
+ expect(m.bodyMapping.parameters).toHaveProperty('__json');
+ const expected = t.name.replace(/^baselinker_/, '').replace(/_([a-z])/g, (_x, c: string) => c.toUpperCase());
+ expect(m.bodyMapping.method.toLowerCase()).toBe(expected.toLowerCase());
+ }
+ });
+
+ it('marks reads read-only and switches off the risky writes', () => {
+ const writes = a.tools.filter((t) => !t.annotations?.readOnlyHint).map((t) => t.name).sort();
+ expect(writes).toEqual(WRITES);
+ for (const t of a.tools.filter((x) => x.annotations?.readOnlyHint)) expect(t.endpointMapping.bodyMapping.method).toMatch(/^get/);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual(OFF);
+ expect(tool('baselinker_set_order_status').description).toMatch(/confirm/i);
+ });
+
+ it('probes with the status list, which needs no argument', () => {
+ expect(a.probe.tool).toBe('baselinker_get_order_status_list');
+ expect(tool('baselinker_get_order_status_list').parameters.required).toBeUndefined();
+ });
+
+ it('has well-formed errorWhen rules that classify ERROR bodies', () => {
+ expect(describeErrorWhenProblems(rules)).toEqual([]);
+ expect(statusOf({ status: 'SUCCESS', orders: [] })).toBeNull();
+ expect(statusOf({ status: 'ERROR', error_code: 'ERROR_BAD_TOKEN', error_message: 'Invalid user token' })).toBe(401);
+ expect(statusOf({ status: 'ERROR', error_code: 'ERROR_USER_ACCOUNT_BLOCKED', error_message: 'Query limit exceeded, API blocked until 2026-10-10 12:00:00' })).toBe(429);
+ expect(statusOf({ status: 'ERROR', error_code: 'ERROR_USER_ACCOUNT_BLOCKED', error_message: 'Account blocked' })).toBe(403);
+ expect(statusOf({ status: 'ERROR', error_code: 'ERROR_ORDER_NOT_FOUND', error_message: 'Order not found' })).toBe(404);
+ expect(statusOf({ status: 'ERROR', error_code: 'ERROR_PARSE_JSON_PARAMETERS', error_message: '' })).toBe(400);
+ expect(statusOf({ status: 'ERROR', error_code: 'ERROR_UNKNOWN_METHOD', error_message: 'An unknown method has been used' })).toBe(400);
+ });
+
+ it('documents the token path, paging, the rate limit and the read-only setup', () => {
+ expect(a.instructions).toContain('Account & other → My account → API');
+ expect(a.instructions).toContain('100 requests per minute');
+ expect(a.instructions).toMatch(/date_confirmed_from.*\+ 1/);
+ expect(a.instructions).toContain('Read-only setup');
+ expect(JSON.stringify(adapterJson)).not.toContain('—');
+ });
+});
+
+describe('baselinker adapter: requests through the real engine', () => {
+ let server: http.Server;
+ let origin: string;
+ let reply: unknown = { status: 'SUCCESS', orders: [] };
+ const seen: Array<{ url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ seen.push({ url: req.url, headers: req.headers, body });
+ // Base labels its JSON answers text/html.
+ res.setHeader('Content-Type', 'text/html; charset=UTF-8');
+ res.end(JSON.stringify(reply));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+ afterAll(() => new Promise((r) => server.close(() => r())));
+ beforeEach(() => {
+ seen.length = 0;
+ reply = { status: 'SUCCESS', orders: [] };
+ });
+
+ const run = (name: string, params: Record) =>
+ engine().execute(
+ { baseUrl: origin, authType: 'API_KEY', authConfig: { headerName: 'X-BLToken', apiKey: '1-23-ABC' }, headers: a.connector.headers, errorWhen: rules },
+ tool(name).endpointMapping,
+ params,
+ );
+
+ it('getOrders goes out as a form with the given filters as JSON, and the text/html answer is parsed', async () => {
+ const res = await run('baselinker_get_orders', { date_confirmed_from: 1759276800, status_id: 7, get_unconfirmed_orders: false });
+ expect(res).toEqual({ status: 'SUCCESS', orders: [] });
+ const [req] = seen;
+ expect(req.url).toBe('/connector.php');
+ expect(req.headers['x-bltoken']).toBe('1-23-ABC');
+ expect(req.headers['content-type']).toContain('application/x-www-form-urlencoded');
+ const form = new URLSearchParams(req.body);
+ expect(form.get('method')).toBe('getOrders');
+ expect(JSON.parse(form.get('parameters')!)).toEqual({ date_confirmed_from: 1759276800, status_id: 7, get_unconfirmed_orders: false });
+ });
+
+ it('a method without arguments sends parameters {}', async () => {
+ await run('baselinker_get_inventories', {});
+ expect(new URLSearchParams(seen[0].body).get('parameters')).toBe('{}');
+ });
+
+ it('stock updates keep the product to warehouse map intact', async () => {
+ await run('baselinker_update_inventory_products_stock', { inventory_id: 3, products: { '1234': { bl_1: 10 }, '5678': { bl_1: 0 } } });
+ expect(JSON.parse(new URLSearchParams(seen[0].body).get('parameters')!)).toEqual({
+ inventory_id: 3,
+ products: { '1234': { bl_1: 10 }, '5678': { bl_1: 0 } },
+ });
+ });
+
+ it('addOrder passes the whole order object through', async () => {
+ const order = { order_status_id: 1, date_add: 1759276800, currency: 'EUR', products: [{ name: 'Mug', quantity: 1, price_brutto: 9.5 }] };
+ await run('baselinker_add_order', { order });
+ expect(JSON.parse(new URLSearchParams(seen[0].body).get('parameters')!)).toEqual(order);
+ });
+
+ it('an ERROR body becomes a failed call', async () => {
+ reply = { status: 'ERROR', error_code: 'ERROR_BAD_TOKEN', error_message: 'Invalid user token' };
+ await expect(run('baselinker_get_order_status_list', {})).rejects.toMatchObject({ status: 401 });
+ });
+});
+
+const TOKEN = process.env.BASELINKER_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('baselinker adapter: live calls', () => {
+ const config = () => ({
+ baseUrl: a.connector.baseUrl,
+ authType: 'API_KEY',
+ authConfig: { headerName: 'X-BLToken', apiKey: TOKEN as string },
+ headers: a.connector.headers,
+ errorWhen: rules,
+ });
+ const run = (name: string, params: Record = {}): Promise =>
+ engine().execute(config(), tool(name).endpointMapping, params);
+
+ let statuses: Array<{ id: number }> = [];
+
+ it('reads statuses (the probe), sources and couriers', async () => {
+ const s = await run('baselinker_get_order_status_list');
+ expect(s.status).toBe('SUCCESS');
+ statuses = s.statuses;
+ expect((await run('baselinker_get_order_sources')).status).toBe('SUCCESS');
+ expect(Array.isArray((await run('baselinker_get_couriers_list')).couriers)).toBe(true);
+ }, 30000);
+
+ it('reads recent orders and their packages', async () => {
+ const res = await run('baselinker_get_orders', { date_confirmed_from: Math.floor(Date.now() / 1000) - 30 * 86400 });
+ expect(Array.isArray(res.orders)).toBe(true);
+ expect(res.orders.length).toBeLessThanOrEqual(100);
+ if (res.orders[0]) {
+ const p = await run('baselinker_get_order_packages', { order_id: res.orders[0].order_id });
+ expect(Array.isArray(p.packages)).toBe(true);
+ }
+ }, 30000);
+
+ it('reads catalogs, warehouses, a product page, product data and stock', async () => {
+ const inv = await run('baselinker_get_inventories');
+ expect(Array.isArray(inv.inventories)).toBe(true);
+ expect((await run('baselinker_get_inventory_warehouses')).status).toBe('SUCCESS');
+ const inventoryId = inv.inventories[0]?.inventory_id;
+ if (!inventoryId) return;
+ const list = await run('baselinker_get_inventory_products_list', { inventory_id: inventoryId, page: 1 });
+ expect(list.status).toBe('SUCCESS');
+ const ids = Object.keys(list.products ?? {}).slice(0, 2).map(Number);
+ if (ids.length) {
+ const data = await run('baselinker_get_inventory_products_data', { inventory_id: inventoryId, products: ids });
+ expect(Object.keys(data.products)).toEqual(expect.arrayContaining(ids.map(String)));
+ }
+ const stock = await run('baselinker_get_inventory_products_stock', { inventory_id: inventoryId, page: 1 });
+ expect(stock.status).toBe('SUCCESS');
+ }, 60000);
+
+ it('a wrong token surfaces as a 401 error, not a 200 body', async () => {
+ await expect(
+ engine().execute({ ...config(), authConfig: { headerName: 'X-BLToken', apiKey: '0-0-WRONG' } }, tool('baselinker_get_inventories').endpointMapping, {}),
+ ).rejects.toMatchObject({ status: 401 });
+ }, 30000);
+
+ (process.env.BASELINKER_LIVE_WRITE === '1' ? it : it.skip)(
+ 'adds an "AnythingMCP test" order, sets its status and deletes it',
+ async () => {
+ const statusId = statuses[0]?.id ?? (await run('baselinker_get_order_status_list')).statuses[0].id;
+ let orderId: number | undefined;
+ try {
+ const blank = Object.fromEntries(
+ ['payment_method', 'user_comments', 'admin_comments', 'phone', 'user_login', 'delivery_method', 'delivery_company', 'delivery_address',
+ 'delivery_postcode', 'delivery_city', 'delivery_state', 'delivery_country_code', 'delivery_point_id', 'delivery_point_name',
+ 'delivery_point_address', 'delivery_point_postcode', 'delivery_point_city', 'invoice_fullname', 'invoice_company', 'invoice_nip',
+ 'invoice_address', 'invoice_postcode', 'invoice_city', 'invoice_state', 'invoice_country_code', 'extra_field_1', 'extra_field_2'].map((k) => [k, '']),
+ );
+ const added = await run('baselinker_add_order', {
+ order: {
+ ...blank,
+ order_status_id: statusId,
+ date_add: Math.floor(Date.now() / 1000),
+ currency: 'EUR',
+ payment_method_cod: false,
+ paid: false,
+ email: 'anythingmcp-test@example.com',
+ delivery_fullname: 'AnythingMCP test',
+ delivery_price: 0,
+ want_invoice: false,
+ products: [{ storage: 'db', storage_id: 0, product_id: '', variant_id: 0, name: 'AnythingMCP test item', sku: '', ean: '', price_brutto: 1, tax_rate: 0, quantity: 1, weight: 0 }],
+ },
+ });
+ orderId = added.order_id;
+ expect(orderId).toBeTruthy();
+ await run('baselinker_set_order_status', { order_id: orderId, status_id: statusId });
+ const got = await run('baselinker_get_orders', { order_id: orderId });
+ expect(got.orders[0].delivery_fullname).toBe('AnythingMCP test');
+ } finally {
+ if (orderId) {
+ await engine().execute(
+ config(),
+ { ...tool('baselinker_get_orders').endpointMapping, bodyMapping: { method: 'deleteOrders', parameters: { __json: { order_ids: [orderId] } } } },
+ {},
+ );
+ }
+ }
+ },
+ 60000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/hubspot.json b/packages/backend/src/adapters/intl/hubspot.json
new file mode 100644
index 000000000..31c893e94
--- /dev/null
+++ b/packages/backend/src/adapters/intl/hubspot.json
@@ -0,0 +1,850 @@
+{
+ "slug": "hubspot",
+ "name": "HubSpot",
+ "description": "Let your AI work in HubSpot CRM: search and update contacts, companies, deals and tickets, see who is linked to what, check pipelines and owners, and log notes and tasks. One account, through a service key you create yourself.",
+ "instructions": "## HubSpot\nThis connector works with one HubSpot account through the **CRM API** (`https://api.hubapi.com`, date-versioned API 2026-09): search, read, create and update contacts, companies, deals, tickets and other CRM objects, list and create associations, read owners, pipelines and properties, and add notes and tasks. It authenticates with a **service key** (or a legacy private app token) that you create in your own account.\n\n**Setup (super admin or a user with Developer tools access, about 5 minutes)**\n1. In HubSpot open **Development** in the left sidebar, then **Keys → Service keys**, and click **Create service key**.\n2. Name it (for example \"AnythingMCP\"), click **Add new scope** and tick the scopes below, then **Update** and **Create**.\n3. Open the key, click **Show**, then **Copy**, and paste it into `HUBSPOT_ACCESS_TOKEN`. Keys start with `pat-` (for example `pat-eu1-...`).\n4. Older accounts may still use a **legacy private app** (Development → Legacy apps, previously Settings → Integrations → Private Apps): its access token works the same way.\n\n**Scopes**\n- Read: `crm.objects.contacts.read` (also covers notes and tasks), `crm.objects.companies.read`, `crm.objects.deals.read`, `crm.objects.tickets.read`, `crm.objects.owners.read`.\n- Write: `crm.objects.contacts.write` (also needed for notes and tasks), `crm.objects.companies.write`, `crm.objects.deals.write`, `crm.objects.tickets.write`.\n- Read-only setup: give the key only the read scopes and switch off `hubspot_create_object`, `hubspot_update_object`, `hubspot_associate_records`, `hubspot_create_note` and `hubspot_create_task`. `hubspot_archive_object` is installed switched off.\n\n**Ids and properties**: every record has a numeric id (`hs_object_id`), passed as a string. Object types are plural: `contacts`, `companies`, `deals`, `tickets`, `notes`, `tasks`; custom objects use their id such as `2-1234567`. Property values are strings keyed by internal name (`firstname`, `dealstage`, `hubspot_owner_id`); `hubspot_list_properties` gives names, types and dropdown options. Dates are `YYYY-MM-DD` or Unix milliseconds, timestamps ISO 8601. Deal stages and ticket statuses are stage ids from `hubspot_list_pipelines`, not labels. Owners come from `hubspot_list_owners`.\n\n**Search**: `hubspot_search_objects` takes free text, filter groups (AND inside a group, OR between groups, at most 5 groups and 18 filters) and one sort. To find records linked to another, filter on `associations.contact` (or `.company`, `.deal`, `.ticket`) with `EQ` and the id. New or changed records can take a few seconds to show up.\n\n**Associations**: `hubspot_get_object` with `associations=deals` returns linked ids; `hubspot_get_associations` lists them with labels. `hubspot_associate_records` links two records with the default type. On create, `associations` needs a type id: note to contact 202, company 190, deal 214, ticket 228; task to contact 204, company 192, deal 216, ticket 230; contact to company 1; deal to contact 3, deal to company 5; ticket to contact 16, ticket to company 26.\n\n**Pagination**: lists and searches return `paging.next.after`; pass it as `after` with the same other arguments until it is missing. Lists allow 100 per page, search 200 per page and 10,000 results per query.\n\n**Rate limits**: 100 requests per 10 seconds per key on Free and Starter (higher on Professional and Enterprise), 250,000 a day per account, and 5 search requests per second. Over a limit HubSpot answers 429; wait a few seconds.\n\n**Errors**: 401 means the key is wrong, expired or was rotated: copy it again. 403 names the missing scope: add it to the key under Edit. 404 is a wrong id or object type. 400 `VALIDATION_ERROR` names the property or filter that is wrong, for example an unknown property name or a dropdown value that is not an option. \"The following required properties were missing\" lists properties the account made mandatory: accounts with data privacy (GDPR) settings on require `hs_legal_basis` on every new contact.\n\n**Out of scope here**: marketing emails, forms, lists, workflows, files, quotes and line item pricing, merges and batch writes.",
+ "region": "intl",
+ "category": "crm",
+ "icon": "hubspot",
+ "docsUrl": "https://developers.hubspot.com/docs/api-reference/latest/overview",
+ "requiredEnvVars": [
+ "HUBSPOT_ACCESS_TOKEN"
+ ],
+ "envVarMeta": {
+ "HUBSPOT_ACCESS_TOKEN": {
+ "label": "Service key",
+ "kind": "credential",
+ "secret": true,
+ "help": "HubSpot → Development → Keys → Service keys → your key → Show → Copy. A legacy private app's access token works too.",
+ "link": "https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/account-service-keys",
+ "pattern": "^pat-[a-z0-9]+-\\S+$",
+ "patternMessage": "Paste the key as shown by HubSpot, starting with pat- (for example pat-eu1-...), without the word Bearer."
+ }
+ },
+ "verifyHints": {
+ "401": "HubSpot refused the key. Copy it again from Development → Keys → Service keys; a rotated or deleted key stops working.",
+ "403": "The key lacks a scope this call needs. Open the key, click Edit, add the scope named in the error (for example crm.objects.owners.read) and save.",
+ "rate_limited": "HubSpot is rate limiting this key (100 requests per 10 seconds on Free and Starter, 5 searches per second). Wait a few seconds and try again."
+ },
+ "probe": {
+ "tool": "hubspot_list_owners",
+ "params": {
+ "limit": 1
+ }
+ },
+ "prerequisites": "A HubSpot account (the free CRM works) and a super admin or a user with Developer tools access, who creates a service key with the CRM scopes listed below (about 5 minutes).",
+ "connector": {
+ "name": "HubSpot CRM API",
+ "type": "REST",
+ "baseUrl": "https://api.hubapi.com",
+ "authType": "BEARER_TOKEN",
+ "authConfig": {
+ "token": "{{HUBSPOT_ACCESS_TOKEN}}"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/crm/owners/2026-09?limit=1"
+ },
+ "tools": [
+ {
+ "name": "hubspot_list_owners",
+ "description": "List the HubSpot users who can own records (owners): owner id, user id, email, first and last name, teams and whether archived. The owner id is what hubspot_owner_id properties hold. Pages with after.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string",
+ "description": "Only the owner with this email address."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 500,
+ "default": 100,
+ "description": "Owners per page, 1 to 500 (default 100)."
+ },
+ "after": {
+ "type": "string",
+ "description": "paging.next.after from the previous response, to fetch the next page."
+ },
+ "archived": {
+ "type": "boolean",
+ "description": "true to list only deactivated owners (default false)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/crm/owners/2026-09",
+ "queryParams": {
+ "email": "$email",
+ "limit": "$limit",
+ "after": "$after",
+ "archived": "$archived"
+ }
+ }
+ },
+ {
+ "name": "hubspot_get_account",
+ "description": "Get the HubSpot account the token belongs to: portal id (Hub ID), account type, time zone and UTC offset, company currency and additional currencies, and the data hosting location (na1, eu1).",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/account-info/2026-09/details"
+ }
+ },
+ {
+ "name": "hubspot_search_objects",
+ "description": "Search CRM records of one object type (contacts, companies, deals, tickets, notes, tasks, ...) by free text and/or property filters, with sorting. Returns total, the matching records with the requested properties, and paging.next.after. Up to 200 per page and 10,000 results per query; search allows 5 requests per second.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "query": {
+ "type": "string",
+ "description": "Free text searched across the object's default text properties (for contacts: name, email, phone, company)."
+ },
+ "filter_groups": {
+ "type": "array",
+ "description": "Property filters. Filters inside one group are combined with AND, groups with OR; at most 5 groups and 18 filters. Example: [{\"filters\": [{\"propertyName\": \"dealstage\", \"operator\": \"EQ\", \"value\": \"closedwon\"}, {\"propertyName\": \"closedate\", \"operator\": \"GTE\", \"value\": \"2026-01-01\"}]}]. Operators: EQ, NEQ, LT, LTE, GT, GTE, BETWEEN (value + highValue), IN / NOT_IN (values array, lowercase for strings), HAS_PROPERTY, NOT_HAS_PROPERTY, CONTAINS_TOKEN (wildcards *), NOT_CONTAINS_TOKEN. Use the pseudo-property associations.contact (or .company, .deal, .ticket) with EQ and a record id to find records associated with it.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "filters": {
+ "type": "array",
+ "description": "Filters combined with AND.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "propertyName": {
+ "type": "string",
+ "description": "Internal property name, e.g. email, dealstage, hubspot_owner_id."
+ },
+ "operator": {
+ "type": "string",
+ "enum": [
+ "EQ",
+ "NEQ",
+ "LT",
+ "LTE",
+ "GT",
+ "GTE",
+ "BETWEEN",
+ "IN",
+ "NOT_IN",
+ "HAS_PROPERTY",
+ "NOT_HAS_PROPERTY",
+ "CONTAINS_TOKEN",
+ "NOT_CONTAINS_TOKEN"
+ ],
+ "description": "Comparison operator."
+ },
+ "value": {
+ "type": "string",
+ "description": "Value to compare with. Dates as YYYY-MM-DD or Unix milliseconds."
+ },
+ "highValue": {
+ "type": "string",
+ "description": "Upper bound for BETWEEN."
+ },
+ "values": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Values for IN and NOT_IN."
+ }
+ },
+ "required": [
+ "propertyName",
+ "operator"
+ ]
+ }
+ }
+ },
+ "required": [
+ "filters"
+ ]
+ }
+ },
+ "properties": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Property names to return, e.g. [\"dealname\", \"amount\", \"dealstage\", \"hubspot_owner_id\"]. Without it only the object's default properties come back."
+ },
+ "sorts": {
+ "type": "array",
+ "description": "One sort rule, e.g. [{\"propertyName\": \"createdate\", \"direction\": \"DESCENDING\"}]. Default: oldest first.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "propertyName": {
+ "type": "string",
+ "description": "Property to sort by."
+ },
+ "direction": {
+ "type": "string",
+ "enum": [
+ "ASCENDING",
+ "DESCENDING"
+ ],
+ "description": "Sort direction."
+ }
+ },
+ "required": [
+ "propertyName"
+ ]
+ }
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 200,
+ "default": 20,
+ "description": "Records per page, 1 to 200 (default 20)."
+ },
+ "after": {
+ "type": "string",
+ "description": "paging.next.after from the previous response, to fetch the next page with the same filters."
+ }
+ },
+ "required": [
+ "object_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/crm/objects/2026-09/{object_type}/search",
+ "bodyMapping": {
+ "query": "$query",
+ "filterGroups": "$filter_groups",
+ "properties": "$properties",
+ "sorts": "$sorts",
+ "limit": "$limit",
+ "after": "$after"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "hubspot_list_objects",
+ "description": "List CRM records of one object type page by page, oldest id first, with the chosen properties and optionally the ids of associated records. Returns results and paging.next.after. Use hubspot_search_objects to filter or sort.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "properties": {
+ "type": "string",
+ "description": "Comma-separated property names to return, e.g. \"firstname,lastname,email,lifecyclestage\". Without it HubSpot returns a small default set. hubspot_list_properties lists the names."
+ },
+ "associations": {
+ "type": "string",
+ "description": "Comma-separated object types whose associated record ids to include, e.g. \"companies,deals\"."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 20,
+ "description": "Records per page, 1 to 100 (default 20)."
+ },
+ "after": {
+ "type": "string",
+ "description": "paging.next.after from the previous response, to fetch the next page."
+ },
+ "archived": {
+ "type": "boolean",
+ "description": "true to list only archived (deleted) records (default false)."
+ }
+ },
+ "required": [
+ "object_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/crm/objects/2026-09/{object_type}",
+ "queryParams": {
+ "properties": "$properties",
+ "associations": "$associations",
+ "limit": "$limit",
+ "after": "$after",
+ "archived": "$archived"
+ }
+ }
+ },
+ {
+ "name": "hubspot_get_object",
+ "description": "Get one CRM record by id (or by a unique property such as a contact's email with id_property), with the chosen properties, optional property history, and the ids of associated records.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "object_id": {
+ "type": "string",
+ "description": "Record id (hs_object_id), e.g. \"51\". With id_property, the value of that property instead, e.g. an email address."
+ },
+ "id_property": {
+ "type": "string",
+ "description": "Look the record up by this unique property instead of its id, e.g. \"email\" for contacts."
+ },
+ "properties": {
+ "type": "string",
+ "description": "Comma-separated property names to return, e.g. \"firstname,lastname,email,lifecyclestage\". Without it HubSpot returns a small default set. hubspot_list_properties lists the names."
+ },
+ "properties_with_history": {
+ "type": "string",
+ "description": "Comma-separated properties to return with their previous values, e.g. \"dealstage\"."
+ },
+ "associations": {
+ "type": "string",
+ "description": "Comma-separated object types whose associated record ids to include, e.g. \"companies,deals\"."
+ }
+ },
+ "required": [
+ "object_type",
+ "object_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/crm/objects/2026-09/{object_type}/{object_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "idProperty": "$id_property",
+ "properties": "$properties",
+ "propertiesWithHistory": "$properties_with_history",
+ "associations": "$associations"
+ }
+ }
+ },
+ {
+ "name": "hubspot_create_object",
+ "description": "Create a CRM record (contact, company, deal, ticket, ...) with property values, optionally associated with existing records. Returns the new record with its id. Show the user what will be created and wait for confirmation; never retry a successful create.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "properties": {
+ "type": "object",
+ "description": "Property values as strings, keyed by internal name. Contacts: {\"email\": \"ana@example.com\", \"firstname\": \"Ana\", \"lastname\": \"Ruiz\"}; accounts with data privacy (GDPR) settings on also require hs_legal_basis, e.g. \"Legitimate interest – prospect/lead\" (allowed values from hubspot_list_properties). Companies: {\"name\": \"Acme\", \"domain\": \"acme.com\"}. Deals: {\"dealname\": \"Acme renewal\", \"amount\": \"5000\", \"pipeline\": \"default\", \"dealstage\": \"\", \"closedate\": \"2026-12-31\"}. Tickets: {\"subject\": \"...\", \"hs_pipeline\": \"0\", \"hs_pipeline_stage\": \"1\"}.",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "associations": {
+ "type": "array",
+ "description": "Optional records to associate on creation. Each entry: {\"to\": {\"id\": \"\"}, \"types\": [{\"associationCategory\": \"HUBSPOT_DEFINED\", \"associationTypeId\": }]}. See the instructions for the common type ids.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "to": {
+ "type": "object",
+ "description": "The record to associate with: {\"id\": \"123\"}.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Record id."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "types": {
+ "type": "array",
+ "description": "Association types, usually one: {\"associationCategory\": \"HUBSPOT_DEFINED\", \"associationTypeId\": 202}.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "associationCategory": {
+ "type": "string",
+ "enum": [
+ "HUBSPOT_DEFINED",
+ "USER_DEFINED",
+ "INTEGRATOR_DEFINED"
+ ],
+ "description": "HUBSPOT_DEFINED for the standard types, USER_DEFINED for custom labels."
+ },
+ "associationTypeId": {
+ "type": "integer",
+ "description": "Association type id, e.g. 202 for note to contact."
+ }
+ },
+ "required": [
+ "associationCategory",
+ "associationTypeId"
+ ]
+ }
+ }
+ },
+ "required": [
+ "to",
+ "types"
+ ]
+ },
+ "default": []
+ }
+ },
+ "required": [
+ "object_type",
+ "properties"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/crm/objects/2026-09/{object_type}",
+ "bodyMapping": {
+ "properties": "$properties",
+ "associations": "$associations"
+ }
+ }
+ },
+ {
+ "name": "hubspot_update_object",
+ "description": "Change property values of one CRM record; only the given properties change, and an empty string clears a value. Moving a deal to another stage is {\"dealstage\": \"\"}. Returns the updated record. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "object_id": {
+ "type": "string",
+ "description": "Record id, or the value of id_property."
+ },
+ "id_property": {
+ "type": "string",
+ "description": "Address the record by this unique property instead of its id, e.g. \"email\"."
+ },
+ "properties": {
+ "type": "object",
+ "description": "Property values to set, as strings, e.g. {\"lifecyclestage\": \"customer\", \"phone\": \"+49 761 123456\"}.",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "object_type",
+ "object_id",
+ "properties"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/crm/objects/2026-09/{object_type}/{object_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "idProperty": "$id_property"
+ },
+ "bodyMapping": {
+ "properties": "$properties"
+ }
+ }
+ },
+ {
+ "name": "hubspot_archive_object",
+ "description": "Archive (delete) one CRM record: it moves to the recycling bin, where it can be restored for 90 days. Installed switched off; turn it on only if the AI should delete records, and confirm every deletion with the user.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "object_id": {
+ "type": "string",
+ "description": "Id of the record to archive."
+ }
+ },
+ "required": [
+ "object_type",
+ "object_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/crm/objects/2026-09/{object_type}/{object_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "hubspot_get_associations",
+ "description": "List the records of one type associated with a record, with their association types and labels, e.g. all deals of a company or all contacts of a deal. Returns toObjectId per association; long lists page with after.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "from_object_type": {
+ "type": "string",
+ "description": "Type of the record you start from, e.g. companies."
+ },
+ "object_id": {
+ "type": "string",
+ "description": "Id of that record."
+ },
+ "to_object_type": {
+ "type": "string",
+ "description": "Type of the associated records to list, e.g. deals."
+ },
+ "after": {
+ "type": "string",
+ "description": "paging.next.after of the result, to fetch the next page."
+ }
+ },
+ "required": [
+ "from_object_type",
+ "object_id",
+ "to_object_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/crm/associations/2026-09/{from_object_type}/{to_object_type}/batch/read",
+ "bodyMapping": {
+ "inputs": [
+ {
+ "id": "$object_id",
+ "after": "$after"
+ }
+ ]
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "hubspot_associate_records",
+ "description": "Link two existing records with HubSpot's default (unlabeled) association, e.g. a contact to a company or a deal to a contact. Linking an already linked pair changes nothing. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "from_object_type": {
+ "type": "string",
+ "description": "Type of the first record, e.g. contacts."
+ },
+ "from_object_id": {
+ "type": "string",
+ "description": "Id of the first record."
+ },
+ "to_object_type": {
+ "type": "string",
+ "description": "Type of the second record, e.g. companies."
+ },
+ "to_object_id": {
+ "type": "string",
+ "description": "Id of the second record."
+ }
+ },
+ "required": [
+ "from_object_type",
+ "from_object_id",
+ "to_object_type",
+ "to_object_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/crm/objects/2026-09/{from_object_type}/{from_object_id}/associations/default/{to_object_type}/{to_object_id}"
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true
+ }
+ },
+ {
+ "name": "hubspot_list_pipelines",
+ "description": "List the pipelines of deals or tickets with their stages: pipeline id and label, and per stage its id, label, display order and metadata (deal stages carry probability, ticket stages ticketState OPEN or CLOSED). Stage ids are what dealstage and hs_pipeline_stage hold.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "enum": [
+ "deals",
+ "tickets"
+ ],
+ "description": "deals or tickets."
+ }
+ },
+ "required": [
+ "object_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/crm/pipelines/2026-09/{object_type}"
+ }
+ },
+ {
+ "name": "hubspot_list_properties",
+ "description": "List the properties of an object type, default and custom: internal name, label, type, field type, group, description, whether read-only, and the allowed options of dropdown and checkbox properties. Use it to find the internal names for search filters and writes.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_type": {
+ "type": "string",
+ "description": "CRM object type, plural: contacts, companies, deals, tickets, notes, tasks, calls, meetings, emails, products, line_items, leads, or a custom object's id (e.g. 2-1234567)."
+ },
+ "archived": {
+ "type": "boolean",
+ "description": "true to list only archived properties (default false)."
+ }
+ },
+ "required": [
+ "object_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/crm/properties/2026-09/{object_type}",
+ "queryParams": {
+ "archived": "$archived"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "mode": "jmespath",
+ "expression": "{results: results[].{name: name, label: label, type: type, fieldType: fieldType, groupName: groupName, description: description, readOnly: modificationMetadata.readOnlyValue, calculated: calculated, options: options[].{label: label, value: value}}}"
+ }
+ }
+ },
+ {
+ "name": "hubspot_create_note",
+ "description": "Add a note to the CRM timeline, associated with contacts, companies, deals or tickets. Returns the note with its id. Confirm the text with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "body": {
+ "type": "string",
+ "description": "Note text; simple HTML such as , , is allowed. Up to 65,536 characters."
+ },
+ "timestamp": {
+ "type": "string",
+ "description": "When the note happened, ISO 8601 (e.g. 2026-10-10T09:30:00Z) or Unix milliseconds. Sets its place on the timeline."
+ },
+ "owner_id": {
+ "type": "string",
+ "description": "Owner id from hubspot_list_owners, shown as the note's author."
+ },
+ "associations": {
+ "type": "array",
+ "description": "Optional records to associate on creation. Each entry: {\"to\": {\"id\": \"\"}, \"types\": [{\"associationCategory\": \"HUBSPOT_DEFINED\", \"associationTypeId\": }]}. See the instructions for the common type ids.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "to": {
+ "type": "object",
+ "description": "The record to associate with: {\"id\": \"123\"}.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Record id."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "types": {
+ "type": "array",
+ "description": "Association types, usually one: {\"associationCategory\": \"HUBSPOT_DEFINED\", \"associationTypeId\": 202}.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "associationCategory": {
+ "type": "string",
+ "enum": [
+ "HUBSPOT_DEFINED",
+ "USER_DEFINED",
+ "INTEGRATOR_DEFINED"
+ ],
+ "description": "HUBSPOT_DEFINED for the standard types, USER_DEFINED for custom labels."
+ },
+ "associationTypeId": {
+ "type": "integer",
+ "description": "Association type id, e.g. 202 for note to contact."
+ }
+ },
+ "required": [
+ "associationCategory",
+ "associationTypeId"
+ ]
+ }
+ }
+ },
+ "required": [
+ "to",
+ "types"
+ ]
+ },
+ "default": []
+ }
+ },
+ "required": [
+ "body",
+ "timestamp"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/crm/objects/2026-09/notes",
+ "bodyMapping": {
+ "properties": {
+ "hs_note_body": "$body",
+ "hs_timestamp": "$timestamp",
+ "hubspot_owner_id": "$owner_id"
+ },
+ "associations": "$associations"
+ }
+ }
+ },
+ {
+ "name": "hubspot_create_task",
+ "description": "Create a task (to-do, call or email reminder) with a due date, optionally assigned to an owner and associated with records. Returns the task with its id. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "description": "Task title."
+ },
+ "due": {
+ "type": "string",
+ "description": "Due date and time, ISO 8601 (e.g. 2026-10-15T09:00:00Z) or Unix milliseconds."
+ },
+ "body": {
+ "type": "string",
+ "description": "Task notes."
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "NOT_STARTED",
+ "COMPLETED"
+ ],
+ "description": "Task status (default NOT_STARTED)."
+ },
+ "priority": {
+ "type": "string",
+ "enum": [
+ "LOW",
+ "MEDIUM",
+ "HIGH"
+ ],
+ "description": "Task priority."
+ },
+ "task_type": {
+ "type": "string",
+ "enum": [
+ "TODO",
+ "CALL",
+ "EMAIL"
+ ],
+ "description": "Task type (default TODO)."
+ },
+ "owner_id": {
+ "type": "string",
+ "description": "Owner id from hubspot_list_owners the task is assigned to."
+ },
+ "associations": {
+ "type": "array",
+ "description": "Optional records to associate on creation. Each entry: {\"to\": {\"id\": \"\"}, \"types\": [{\"associationCategory\": \"HUBSPOT_DEFINED\", \"associationTypeId\": }]}. See the instructions for the common type ids.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "to": {
+ "type": "object",
+ "description": "The record to associate with: {\"id\": \"123\"}.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Record id."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "types": {
+ "type": "array",
+ "description": "Association types, usually one: {\"associationCategory\": \"HUBSPOT_DEFINED\", \"associationTypeId\": 202}.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "associationCategory": {
+ "type": "string",
+ "enum": [
+ "HUBSPOT_DEFINED",
+ "USER_DEFINED",
+ "INTEGRATOR_DEFINED"
+ ],
+ "description": "HUBSPOT_DEFINED for the standard types, USER_DEFINED for custom labels."
+ },
+ "associationTypeId": {
+ "type": "integer",
+ "description": "Association type id, e.g. 202 for note to contact."
+ }
+ },
+ "required": [
+ "associationCategory",
+ "associationTypeId"
+ ]
+ }
+ }
+ },
+ "required": [
+ "to",
+ "types"
+ ]
+ },
+ "default": []
+ }
+ },
+ "required": [
+ "subject",
+ "due"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/crm/objects/2026-09/tasks",
+ "bodyMapping": {
+ "properties": {
+ "hs_task_subject": "$subject",
+ "hs_timestamp": "$due",
+ "hs_task_body": "$body",
+ "hs_task_status": "$status",
+ "hs_task_priority": "$priority",
+ "hs_task_type": "$task_type",
+ "hubspot_owner_id": "$owner_id"
+ },
+ "associations": "$associations"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/hubspot.live.spec.ts b/packages/backend/src/adapters/intl/hubspot.live.spec.ts
new file mode 100644
index 000000000..89180df5b
--- /dev/null
+++ b/packages/backend/src/adapters/intl/hubspot.live.spec.ts
@@ -0,0 +1,302 @@
+import * as http from 'node:http';
+import { AddressInfo } from 'node:net';
+import * as adapterJson from './hubspot.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+
+/**
+ * Static checks and request-shape checks against a local server always run.
+ * The live block runs read-only calls through the real RestEngine when a key
+ * is set:
+ * HUBSPOT_ACCESS_TOKEN=pat-eu1-... npx jest src/adapters/intl/hubspot.live.spec.ts
+ * HUBSPOT_LIVE_WRITE=1 also creates a contact named "AnythingMCP test" with a
+ * note and a task, updates it, and archives all three at the end.
+ */
+
+type Mapping = {
+ method: string;
+ path: string;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ encodePathParams?: boolean;
+};
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ annotations?: Record;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: Mapping;
+};
+const a = adapterJson as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string; params?: Record };
+ connector: { type: string; baseUrl: string; authType: string; authConfig: Record; headers: Record };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService);
+const WRITES = [
+ 'hubspot_archive_object',
+ 'hubspot_associate_records',
+ 'hubspot_create_note',
+ 'hubspot_create_object',
+ 'hubspot_create_task',
+ 'hubspot_update_object',
+];
+
+describe('hubspot adapter: static spec conformance', () => {
+ it('is listed (verified live on 10 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ });
+
+ it('sends the service key as a Bearer token to api.hubapi.com', () => {
+ expect(a.connector.type).toBe('REST');
+ expect(a.connector.baseUrl).toBe('https://api.hubapi.com');
+ expect(a.connector.authType).toBe('BEARER_TOKEN');
+ expect(a.connector.authConfig).toEqual({ token: '{{HUBSPOT_ACCESS_TOKEN}}' });
+ expect(a.requiredEnvVars).toEqual(['HUBSPOT_ACCESS_TOKEN']);
+ expect(a.envVarMeta.HUBSPOT_ACCESS_TOKEN.secret).toBe(true);
+ const pattern = new RegExp(a.envVarMeta.HUBSPOT_ACCESS_TOKEN.pattern);
+ // Built at runtime so secret scanners do not take the sample for a real key.
+ expect(pattern.test(['pat', 'eu1', 'x'.repeat(8), 'y'.repeat(4)].join('-'))).toBe(true);
+ expect(pattern.test('Bearer pat-eu1-123')).toBe(false);
+ });
+
+ it('probes with one owner, a cheap read', () => {
+ expect(a.probe).toEqual({ tool: 'hubspot_list_owners', params: { limit: 1 } });
+ expect(tool('hubspot_list_owners').endpointMapping).toMatchObject({ method: 'GET', path: '/crm/owners/2026-09' });
+ });
+
+ it('uses the 2026-09 date-versioned paths everywhere', () => {
+ for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.path}`).toMatch(/:\/[a-z-]+\/(?:[a-z]+\/)?2026-09(\/|$)/);
+ });
+
+ it('prefixes every tool and keeps writes to the expected set', () => {
+ for (const t of a.tools) expect(t.name.startsWith('hubspot_')).toBe(true);
+ const writes = a.tools
+ .filter((t) => t.endpointMapping.method !== 'GET' && !t.annotations?.readOnlyHint)
+ .map((t) => t.name)
+ .sort();
+ expect(writes).toEqual(WRITES);
+ for (const name of WRITES.filter((n) => n !== 'hubspot_archive_object')) {
+ expect(tool(name).description).toMatch(/confirm/i);
+ }
+ });
+
+ it('marks the POST-based reads read-only', () => {
+ expect(tool('hubspot_search_objects').annotations).toEqual({ readOnlyHint: true });
+ expect(tool('hubspot_get_associations').annotations).toEqual({ readOnlyHint: true });
+ });
+
+ it('installs only the archive tool switched off', () => {
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['hubspot_archive_object']);
+ expect(tool('hubspot_archive_object').endpointMapping.method).toBe('DELETE');
+ expect(a.tools.filter((t) => t.endpointMapping.method === 'DELETE')).toHaveLength(1);
+ });
+
+ it('pages with after and caps limits at the documented maxima', () => {
+ const caps: Record = { hubspot_search_objects: 200, hubspot_list_objects: 100, hubspot_list_owners: 500 };
+ for (const [name, max] of Object.entries(caps)) {
+ const props = tool(name).parameters.properties as Record;
+ expect(props.limit.maximum).toBe(max);
+ expect(props.after).toBeDefined();
+ }
+ expect(a.instructions).toContain('paging.next.after');
+ });
+
+ it('lists properties without a name filter: that query parameter picks definition fields, not properties', () => {
+ expect(tool('hubspot_list_properties').parameters.properties).not.toHaveProperty('properties');
+ expect(tool('hubspot_list_properties').endpointMapping.queryParams).toEqual({ archived: '$archived' });
+ });
+
+ it('tells the model about hs_legal_basis on accounts with data privacy settings', () => {
+ expect(a.instructions).toContain('hs_legal_basis');
+ expect(JSON.stringify(tool('hubspot_create_object').parameters)).toContain('hs_legal_basis');
+ });
+
+ it('documents scopes, the read-only setup and the association type ids', () => {
+ for (const scope of ['crm.objects.contacts.read', 'crm.objects.owners.read', 'crm.objects.deals.write', 'crm.objects.tickets.read']) {
+ expect(a.instructions).toContain(scope);
+ }
+ expect(a.instructions).toContain('Read-only setup');
+ expect(a.instructions).toMatch(/note to contact 202/);
+ expect(a.instructions).toMatch(/Development.*Keys → Service keys/);
+ });
+
+ it('has no em dash in anything a user or model reads', () => {
+ expect(JSON.stringify(adapterJson)).not.toContain('—');
+ });
+});
+
+describe('hubspot adapter: requests through the real engine', () => {
+ let server: http.Server;
+ let origin: string;
+ const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body });
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ results: [], id: '1' }));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+ afterAll(() => new Promise((r) => server.close(() => r())));
+ beforeEach(() => (seen.length = 0));
+
+ const run = (name: string, params: Record) =>
+ engine().execute(
+ { baseUrl: origin, authType: 'BEARER_TOKEN', authConfig: { token: 'pat-eu1-test' }, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ params,
+ );
+
+ it('search posts filter groups, properties, sorts and paging as JSON', async () => {
+ await run('hubspot_search_objects', {
+ object_type: 'deals',
+ filter_groups: [{ filters: [{ propertyName: 'dealstage', operator: 'EQ', value: 'closedwon' }] }],
+ properties: ['dealname', 'amount'],
+ sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }],
+ limit: 50,
+ after: '50',
+ });
+ const [req] = seen;
+ expect(req.method).toBe('POST');
+ expect(req.url).toBe('/crm/objects/2026-09/deals/search');
+ expect(req.headers.authorization).toBe('Bearer pat-eu1-test');
+ expect(req.headers['user-agent']).toBe('AnythingMCP');
+ expect(JSON.parse(req.body)).toEqual({
+ filterGroups: [{ filters: [{ propertyName: 'dealstage', operator: 'EQ', value: 'closedwon' }] }],
+ properties: ['dealname', 'amount'],
+ sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }],
+ limit: 50,
+ after: '50',
+ });
+ });
+
+ it('reads a contact by email with idProperty, percent-encoding the id', async () => {
+ await run('hubspot_get_object', { object_type: 'contacts', object_id: 'ana@example.com', id_property: 'email', associations: 'companies' });
+ expect(seen[0].url).toBe('/crm/objects/2026-09/contacts/ana%40example.com?idProperty=email&associations=companies');
+ });
+
+ it('creates a note with its timestamp and an association', async () => {
+ const associations = [{ to: { id: '51' }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 202 }] }];
+ await run('hubspot_create_note', { body: 'Called, wants an offer', timestamp: '2026-10-10T09:30:00Z', associations });
+ expect(seen[0].url).toBe('/crm/objects/2026-09/notes');
+ expect(JSON.parse(seen[0].body)).toEqual({
+ properties: { hs_note_body: 'Called, wants an offer', hs_timestamp: '2026-10-10T09:30:00Z' },
+ associations,
+ });
+ });
+
+ it('reads associations with a batch input of one id', async () => {
+ await run('hubspot_get_associations', { from_object_type: 'companies', object_id: '9', to_object_type: 'deals' });
+ expect(seen[0].url).toBe('/crm/associations/2026-09/companies/deals/batch/read');
+ expect(JSON.parse(seen[0].body)).toEqual({ inputs: [{ id: '9' }] });
+ });
+
+ it('links two records with the default association and no body', async () => {
+ await run('hubspot_associate_records', { from_object_type: 'contacts', from_object_id: '1', to_object_type: 'companies', to_object_id: '2' });
+ expect(seen[0].method).toBe('PUT');
+ expect(seen[0].url).toBe('/crm/objects/2026-09/contacts/1/associations/default/companies/2');
+ });
+});
+
+const TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('hubspot adapter: live calls', () => {
+ const run = (name: string, params: Record = {}, mapping?: Mapping): Promise =>
+ engine().execute(
+ { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers },
+ mapping ?? tool(name).endpointMapping,
+ params,
+ );
+
+ it('lists owners (the probe) and reads the account', async () => {
+ const owners = await run('hubspot_list_owners', { limit: 1 });
+ expect(Array.isArray(owners.results)).toBe(true);
+ const account = await run('hubspot_get_account');
+ expect(account.portalId).toBeTruthy();
+ }, 30000);
+
+ it('lists and searches contacts and reads one with associations', async () => {
+ const list = await run('hubspot_list_objects', { object_type: 'contacts', limit: 2, properties: 'email,firstname' });
+ expect(Array.isArray(list.results)).toBe(true);
+ const search = await run('hubspot_search_objects', {
+ object_type: 'contacts',
+ limit: 2,
+ sorts: [{ propertyName: 'createdate', direction: 'DESCENDING' }],
+ });
+ expect(typeof search.total).toBe('number');
+ const id = list.results[0]?.id;
+ if (!id) return;
+ const one = await run('hubspot_get_object', { object_type: 'contacts', object_id: id, associations: 'companies' });
+ expect(one.id).toBe(id);
+ }, 30000);
+
+ it('lists deal pipelines and contact properties', async () => {
+ const pipelines = await run('hubspot_list_pipelines', { object_type: 'deals' });
+ expect(Array.isArray(pipelines.results)).toBe(true);
+ const props = await run('hubspot_list_properties', { object_type: 'contacts' });
+ expect(props.results.map((p: { name: string }) => p.name)).toEqual(expect.arrayContaining(['email', 'lifecyclestage']));
+ }, 30000);
+
+ (process.env.HUBSPOT_LIVE_WRITE === '1' ? it : it.skip)(
+ 'creates, updates and archives an "AnythingMCP test" contact with a note and a task',
+ async () => {
+ const archive = (type: string, id: string) =>
+ run('hubspot_archive_object', { object_type: type, object_id: id });
+ const created: Array<[string, string]> = [];
+ try {
+ const contact = await run('hubspot_create_object', {
+ object_type: 'contacts',
+ // hs_legal_basis is mandatory on accounts with data privacy (GDPR) settings on.
+ properties: {
+ firstname: 'AnythingMCP',
+ lastname: 'test',
+ email: `anythingmcp-test-${Date.now()}@example.com`,
+ hs_legal_basis: 'Legitimate interest – prospect/lead',
+ },
+ });
+ created.push(['contacts', contact.id]);
+ const updated = await run('hubspot_update_object', { object_type: 'contacts', object_id: contact.id, properties: { jobtitle: 'AnythingMCP test' } });
+ expect(updated.properties.jobtitle).toBe('AnythingMCP test');
+ const to = [{ to: { id: contact.id }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 202 }] }];
+ const note = await run('hubspot_create_note', { body: 'AnythingMCP test note', timestamp: new Date().toISOString(), associations: to });
+ created.push(['notes', note.id]);
+ const task = await run('hubspot_create_task', {
+ subject: 'AnythingMCP test task',
+ due: new Date(Date.now() + 86400000).toISOString(),
+ associations: [{ to: { id: contact.id }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 204 }] }],
+ });
+ created.push(['tasks', task.id]);
+ const assoc = await run('hubspot_get_associations', { from_object_type: 'contacts', object_id: contact.id, to_object_type: 'notes' });
+ expect(JSON.stringify(assoc)).toContain(String(note.id));
+ } finally {
+ // Archive everything that was created, even after a failure, and say so if any of it stays behind.
+ const leftovers: string[] = [];
+ for (const [type, id] of created.reverse()) {
+ await archive(type, id).catch(() => leftovers.push(`${type}/${id}`));
+ }
+ expect(leftovers).toEqual([]);
+ }
+ },
+ 60000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/kommo.json b/packages/backend/src/adapters/intl/kommo.json
new file mode 100644
index 000000000..8cc6b578c
--- /dev/null
+++ b/packages/backend/src/adapters/intl/kommo.json
@@ -0,0 +1,924 @@
+{
+ "slug": "kommo",
+ "name": "Kommo",
+ "description": "Let your AI work in Kommo CRM: find and update leads across pipelines, look up contacts and companies, follow tasks and notes, and log new tasks and notes. One account, with a long-lived token from a private integration.",
+ "instructions": "## Kommo\nThis connector works with one Kommo CRM account through the **API v4** (`https://.kommo.com/api/v4`): leads with pipelines and stages, contacts, companies, custom fields, tasks, notes and users. It can create and update leads, create contacts and tasks, and add notes. It authenticates with a **long-lived token** of a private integration in your own account.\n\n**Setup (account administrator, about 3 minutes)**\n1. In Kommo open **Settings → Integrations** and click **Create integration** (top right). Choose a private integration, leave **Redirect URL** empty, keep the access scopes you need and **Save**.\n2. Open the integration's **Keys and scopes** tab and click **Generate long-lived token**. Pick an expiry date and copy the token right away: Kommo shows it only once.\n3. Paste it into `KOMMO_ACCESS_TOKEN`, and your account's subdomain into `KOMMO_SUBDOMAIN`: for `https://acme.kommo.com` enter `acme`.\n4. The token acts as the administrator who created it. Revoke it on the integration's **Authorization** tab; when it expires, generate a new one.\n\n**Read-only setup**: Kommo tokens cannot be limited to reading, so switch off `kommo_create_lead`, `kommo_update_lead`, `kommo_create_contact`, `kommo_create_task` and `kommo_add_note`. Nothing in this connector deletes data.\n\n**Ids and values**: every id is an integer. Lead stages are `status_id` values inside a `pipeline_id` from `kommo_list_pipelines`; 142 is Closed-won and 143 Closed-lost in every pipeline. Responsible users come from `kommo_list_users`. Phone, email and all other extra data live in `custom_fields_values`: `[{\"field_id\": 123, \"values\": [{\"value\": \"...\"}]}]`, select options by `enum_id`, contact phone and email by `field_code` PHONE or EMAIL with `enum_code` such as WORK; `kommo_list_custom_fields` lists ids and options. Dates are Unix timestamps in seconds. Linked entities are in `_embedded`; add `with=contacts` (leads) or `with=leads` (contacts) to get them.\n\n**Pagination**: list tools take `page` (from 1) and `limit` (up to 250). The response's `_links.next` shows that more exists; an empty answer (HTTP 204, no body) means there are no results or no more pages.\n\n**Rate limits**: at most 7 requests per second per account. Over it Kommo answers 429; repeated violations block the IP address with 403 for every call. Avoid paging through all leads when a filter or search does the job.\n\n**Errors**: 401 means the token is wrong, expired or revoked: generate a new one. 402 means the account's subscription has lapsed. 403 can mean the token's user lacks rights or the IP was blocked after too many requests. 404 is a wrong id or subdomain. 400 lists the invalid fields under `validation-errors`, for example a stage that does not belong to the pipeline or a custom field value of the wrong type.\n\n**Out of scope here**: deleting entities, unsorted (incoming) leads, chats and messages, lists (catalogs), webhooks and Salesbot.",
+ "region": "intl",
+ "category": "crm",
+ "icon": "kommo",
+ "docsUrl": "https://developers.kommo.com/reference/getting-started-with-your-api",
+ "requiredEnvVars": [
+ "KOMMO_SUBDOMAIN",
+ "KOMMO_ACCESS_TOKEN"
+ ],
+ "envVarMeta": {
+ "KOMMO_SUBDOMAIN": {
+ "label": "Kommo subdomain",
+ "kind": "address",
+ "help": "The first part of your account address: acme for https://acme.kommo.com. A pasted full address is shortened to it.",
+ "example": "acme"
+ },
+ "KOMMO_ACCESS_TOKEN": {
+ "label": "Long-lived token",
+ "kind": "credential",
+ "secret": true,
+ "help": "Kommo → Settings → Integrations → your private integration → Keys and scopes → Generate long-lived token. It is shown only once.",
+ "link": "https://developers.kommo.com/docs/long-lived-token",
+ "pattern": "^(?!Bearer\\s)\\S+$",
+ "patternMessage": "Paste only the token, without spaces or the word Bearer."
+ }
+ },
+ "verifyHints": {
+ "401": "Kommo refused the token. It may be wrong, expired or revoked: generate a new long-lived token under Settings → Integrations → your integration → Keys and scopes.",
+ "402": "Kommo reports that the account's subscription has expired. Renew it in Kommo, then try again.",
+ "403": "Kommo denied access: the token's user lacks rights, or the IP address was blocked after too many requests. Wait a few minutes and try again.",
+ "404": "No Kommo account answers at this subdomain. Use the first part of your address, e.g. acme for https://acme.kommo.com.",
+ "rate_limited": "Kommo allows 7 requests per second. Wait a moment and try again."
+ },
+ "probe": {
+ "tool": "kommo_get_account"
+ },
+ "prerequisites": "A Kommo account (a trial works) and an account administrator, who creates a private integration and generates its long-lived token (about 3 minutes).",
+ "connector": {
+ "name": "Kommo API v4",
+ "type": "REST",
+ "baseUrl": "https://{{KOMMO_SUBDOMAIN}}.kommo.com/api/v4",
+ "authType": "BEARER_TOKEN",
+ "authConfig": {
+ "token": "{{KOMMO_ACCESS_TOKEN}}"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/account"
+ },
+ "tools": [
+ {
+ "name": "kommo_get_account",
+ "description": "Get the Kommo account: id, name, subdomain, current user id, country, currency, language, and with the default extras its task types (ids for tasks) and date and time settings.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "with": {
+ "type": "string",
+ "default": "task_types,datetime_settings",
+ "description": "Comma-separated extras: task_types, users_groups, datetime_settings, version, entity_names (default task_types,datetime_settings)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/account",
+ "queryParams": {
+ "with": "$with"
+ }
+ }
+ },
+ {
+ "name": "kommo_list_leads",
+ "description": "List or search leads (deals): id, name, price, pipeline and stage (status_id), responsible user, dates, tags, custom fields and, with with=contacts, linked contacts. Free-text query, filters by pipeline, stage, responsible user and dates, sorting. Up to 250 per page; an empty answer means no more pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against the lead's filled fields (name, custom fields, linked contact data)."
+ },
+ "pipeline_id": {
+ "type": "integer",
+ "description": "Only leads in this pipeline."
+ },
+ "status_pipeline_id": {
+ "type": "integer",
+ "description": "With status_id: the pipeline of that stage. Filters one stage of one pipeline."
+ },
+ "status_id": {
+ "type": "integer",
+ "description": "With status_pipeline_id: only leads in this stage."
+ },
+ "responsible_user_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only entries whose responsible user is one of these user ids."
+ },
+ "created_from": {
+ "type": "integer",
+ "description": "Created at or after this Unix time (seconds)."
+ },
+ "created_to": {
+ "type": "integer",
+ "description": "Created at or before this Unix time (seconds)."
+ },
+ "updated_from": {
+ "type": "integer",
+ "description": "Updated at or after this Unix time (seconds)."
+ },
+ "updated_to": {
+ "type": "integer",
+ "description": "Updated at or before this Unix time (seconds)."
+ },
+ "closed_from": {
+ "type": "integer",
+ "description": "Closed at or after this Unix time (seconds)."
+ },
+ "closed_to": {
+ "type": "integer",
+ "description": "Closed at or before this Unix time (seconds)."
+ },
+ "ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only these lead ids."
+ },
+ "order_by_updated_at": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "description": "Sort by last update."
+ },
+ "order_by_created_at": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "description": "Sort by creation."
+ },
+ "with": {
+ "type": "string",
+ "description": "Comma-separated extras: contacts, loss_reason, source, catalog_elements."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/leads",
+ "queryParams": {
+ "query": "$query",
+ "filter[pipeline_id][]": "$pipeline_id",
+ "filter[statuses][0][pipeline_id]": "$status_pipeline_id",
+ "filter[statuses][0][status_id]": "$status_id",
+ "filter[responsible_user_id][]": "$responsible_user_ids",
+ "filter[created_at][from]": "$created_from",
+ "filter[created_at][to]": "$created_to",
+ "filter[updated_at][from]": "$updated_from",
+ "filter[updated_at][to]": "$updated_to",
+ "filter[closed_at][from]": "$closed_from",
+ "filter[closed_at][to]": "$closed_to",
+ "filter[id][]": "$ids",
+ "order[updated_at]": "$order_by_updated_at",
+ "order[created_at]": "$order_by_created_at",
+ "with": "$with",
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ },
+ {
+ "name": "kommo_get_lead",
+ "description": "Get one lead by id with its name, price, pipeline, stage, responsible user, dates, tags, custom field values, linked company and, by default, linked contacts.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "lead_id": {
+ "type": "integer",
+ "description": "Lead id."
+ },
+ "with": {
+ "type": "string",
+ "default": "contacts",
+ "description": "Comma-separated extras: contacts, loss_reason, source, catalog_elements (default contacts)."
+ }
+ },
+ "required": [
+ "lead_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/leads/{lead_id}",
+ "queryParams": {
+ "with": "$with"
+ }
+ }
+ },
+ {
+ "name": "kommo_create_lead",
+ "description": "Create a lead, optionally in a given pipeline and stage, with price, responsible user, custom fields, tags and links to existing contacts and a company. Returns the new lead id. Show the user what will be created and wait for confirmation; never retry a successful create.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Lead name."
+ },
+ "price": {
+ "type": "integer",
+ "description": "Sale value in the account currency."
+ },
+ "pipeline_id": {
+ "type": "integer",
+ "description": "Pipeline id from kommo_list_pipelines (default: main pipeline)."
+ },
+ "status_id": {
+ "type": "integer",
+ "description": "Stage id in that pipeline (default: its first stage)."
+ },
+ "responsible_user_id": {
+ "type": "integer",
+ "description": "Responsible user id from kommo_list_users (default: the token's user)."
+ },
+ "custom_fields_values": {
+ "type": "array",
+ "description": "Custom field values: [{\"field_id\": 123, \"values\": [{\"value\": \"text or number\"}]}]; select fields take [{\"enum_id\": 456}]; contact phone and email use {\"field_code\": \"PHONE\", \"values\": [{\"value\": \"+1 555 0100\", \"enum_code\": \"WORK\"}]}. Field ids from kommo_list_custom_fields.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "tags": {
+ "type": "array",
+ "description": "Tags to add, e.g. [{\"name\": \"vip\"}] or [{\"id\": 12}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "contact_ids": {
+ "type": "array",
+ "description": "Contacts to link, e.g. [{\"id\": 123, \"is_main\": true}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "company_ids": {
+ "type": "array",
+ "description": "Company to link, e.g. [{\"id\": 456}] (a lead has at most one).",
+ "items": {
+ "type": "object"
+ }
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/leads",
+ "bodyMapping": [
+ {
+ "name": "$name",
+ "price": "$price",
+ "pipeline_id": "$pipeline_id",
+ "status_id": "$status_id",
+ "responsible_user_id": "$responsible_user_id",
+ "custom_fields_values": "$custom_fields_values",
+ "_embedded": {
+ "tags": "$tags",
+ "contacts": "$contact_ids",
+ "companies": "$company_ids"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "kommo_update_lead",
+ "description": "Change a lead: name, price, stage or pipeline (moving it through the funnel; Closed-won is status 142, Closed-lost 143), responsible user, loss reason, custom fields or tags. Only the given fields change. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "lead_id": {
+ "type": "integer",
+ "description": "Lead id."
+ },
+ "name": {
+ "type": "string",
+ "description": "New name."
+ },
+ "price": {
+ "type": "integer",
+ "description": "New sale value."
+ },
+ "pipeline_id": {
+ "type": "integer",
+ "description": "Pipeline id; send with status_id when moving to another pipeline."
+ },
+ "status_id": {
+ "type": "integer",
+ "description": "Stage id from kommo_list_pipelines; 142 closes as won, 143 as lost."
+ },
+ "responsible_user_id": {
+ "type": "integer",
+ "description": "New responsible user id."
+ },
+ "loss_reason_id": {
+ "type": "integer",
+ "description": "Loss reason id, when closing as lost."
+ },
+ "custom_fields_values": {
+ "type": "array",
+ "description": "Custom field values: [{\"field_id\": 123, \"values\": [{\"value\": \"text or number\"}]}]; select fields take [{\"enum_id\": 456}]; contact phone and email use {\"field_code\": \"PHONE\", \"values\": [{\"value\": \"+1 555 0100\", \"enum_code\": \"WORK\"}]}. Field ids from kommo_list_custom_fields.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "tags_to_add": {
+ "type": "array",
+ "description": "Tags to add, e.g. [{\"name\": \"vip\"}] or [{\"id\": 12}].",
+ "items": {
+ "type": "object"
+ }
+ }
+ },
+ "required": [
+ "lead_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/leads/{lead_id}",
+ "bodyMapping": {
+ "name": "$name",
+ "price": "$price",
+ "pipeline_id": "$pipeline_id",
+ "status_id": "$status_id",
+ "responsible_user_id": "$responsible_user_id",
+ "loss_reason_id": "$loss_reason_id",
+ "custom_fields_values": "$custom_fields_values",
+ "tags_to_add": "$tags_to_add"
+ }
+ }
+ },
+ {
+ "name": "kommo_list_contacts",
+ "description": "List or search contacts: id, name, first and last name, responsible user, dates, tags, custom fields (phone and email are custom fields) and, with with=leads, linked leads. Up to 250 per page; an empty answer means no more pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against names and custom field values such as phone or email."
+ },
+ "ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only these contact ids."
+ },
+ "responsible_user_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only entries whose responsible user is one of these user ids."
+ },
+ "updated_from": {
+ "type": "integer",
+ "description": "Updated at or after this Unix time (seconds)."
+ },
+ "updated_to": {
+ "type": "integer",
+ "description": "Updated at or before this Unix time (seconds)."
+ },
+ "order_by_updated_at": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "description": "Sort by last update."
+ },
+ "with": {
+ "type": "string",
+ "description": "Comma-separated extras: leads, catalog_elements."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/contacts",
+ "queryParams": {
+ "query": "$query",
+ "filter[id][]": "$ids",
+ "filter[responsible_user_id][]": "$responsible_user_ids",
+ "filter[updated_at][from]": "$updated_from",
+ "filter[updated_at][to]": "$updated_to",
+ "order[updated_at]": "$order_by_updated_at",
+ "with": "$with",
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ },
+ {
+ "name": "kommo_get_contact",
+ "description": "Get one contact by id with names, responsible user, tags, custom field values (phones, emails, position), linked companies and, by default, linked leads.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_id": {
+ "type": "integer",
+ "description": "Contact id."
+ },
+ "with": {
+ "type": "string",
+ "default": "leads",
+ "description": "Comma-separated extras: leads, catalog_elements (default leads)."
+ }
+ },
+ "required": [
+ "contact_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/contacts/{contact_id}",
+ "queryParams": {
+ "with": "$with"
+ }
+ }
+ },
+ {
+ "name": "kommo_create_contact",
+ "description": "Create a contact with name, responsible user, phone, email and other custom fields, and tags. Returns the new contact id; link it to a lead with kommo_create_lead or in Kommo. Confirm with the user before writing; Kommo does not check for duplicates here, so search first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Full name (or set first_name and last_name)."
+ },
+ "first_name": {
+ "type": "string",
+ "description": "First name."
+ },
+ "last_name": {
+ "type": "string",
+ "description": "Last name."
+ },
+ "responsible_user_id": {
+ "type": "integer",
+ "description": "Responsible user id (default: the token's user)."
+ },
+ "custom_fields_values": {
+ "type": "array",
+ "description": "Custom field values: [{\"field_id\": 123, \"values\": [{\"value\": \"text or number\"}]}]; select fields take [{\"enum_id\": 456}]; contact phone and email use {\"field_code\": \"PHONE\", \"values\": [{\"value\": \"+1 555 0100\", \"enum_code\": \"WORK\"}]}. Field ids from kommo_list_custom_fields.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "tags": {
+ "type": "array",
+ "description": "Tags to add, e.g. [{\"name\": \"vip\"}] or [{\"id\": 12}].",
+ "items": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/contacts",
+ "bodyMapping": [
+ {
+ "name": "$name",
+ "first_name": "$first_name",
+ "last_name": "$last_name",
+ "responsible_user_id": "$responsible_user_id",
+ "custom_fields_values": "$custom_fields_values",
+ "_embedded": {
+ "tags": "$tags"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "kommo_list_companies",
+ "description": "List or search companies: id, name, responsible user, dates, tags, custom fields and, with with=leads,contacts, linked leads and contacts. Up to 250 per page; an empty answer means no more pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against the name and custom field values."
+ },
+ "ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only these company ids."
+ },
+ "responsible_user_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only entries whose responsible user is one of these user ids."
+ },
+ "updated_from": {
+ "type": "integer",
+ "description": "Updated at or after this Unix time (seconds)."
+ },
+ "with": {
+ "type": "string",
+ "description": "Comma-separated extras: leads, contacts, catalog_elements."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/companies",
+ "queryParams": {
+ "query": "$query",
+ "filter[id][]": "$ids",
+ "filter[responsible_user_id][]": "$responsible_user_ids",
+ "filter[updated_at][from]": "$updated_from",
+ "with": "$with",
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ },
+ {
+ "name": "kommo_list_pipelines",
+ "description": "List the lead pipelines with their stages: pipeline id, name, whether main or archived, and per stage (in _embedded.statuses) its id, name, sort order, color and type. Every pipeline also has the system stages 142 (Closed-won) and 143 (Closed-lost).",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/leads/pipelines"
+ }
+ },
+ {
+ "name": "kommo_list_custom_fields",
+ "description": "List the custom fields of leads, contacts or companies: id, name, code (PHONE, EMAIL for contacts), type and, for select fields, the enum options with their ids. Use them to read and write custom_fields_values.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "leads",
+ "contacts",
+ "companies"
+ ],
+ "description": "Which entity's fields to list."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ },
+ "required": [
+ "entity_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/{entity_type}/custom_fields",
+ "queryParams": {
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ },
+ {
+ "name": "kommo_list_tasks",
+ "description": "List tasks: id, text, type, deadline (complete_till), whether completed, result, responsible user and the lead, contact or company it belongs to. Filter by responsible user, state, type or entity. Up to 250 per page; an empty answer means no more pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "responsible_user_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only entries whose responsible user is one of these user ids."
+ },
+ "is_completed": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1
+ ],
+ "description": "1 for completed tasks, 0 for open ones."
+ },
+ "task_type_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only these task types (1 follow-up, 2 meeting, others from kommo_get_account)."
+ },
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "leads",
+ "contacts",
+ "companies"
+ ],
+ "description": "Only tasks of this entity type (needed with entity_ids)."
+ },
+ "entity_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Only tasks of these entities."
+ },
+ "order_by_complete_till": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "description": "Sort by deadline."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/tasks",
+ "queryParams": {
+ "filter[responsible_user_id][]": "$responsible_user_ids",
+ "filter[is_completed]": "$is_completed",
+ "filter[task_type][]": "$task_type_ids",
+ "filter[entity_type]": "$entity_type",
+ "filter[entity_id][]": "$entity_ids",
+ "order[complete_till]": "$order_by_complete_till",
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ },
+ {
+ "name": "kommo_create_task",
+ "description": "Create a task with a deadline, linked to a lead, contact or company, for a responsible user. Returns the new task id. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "Task text."
+ },
+ "complete_till": {
+ "type": "integer",
+ "description": "Deadline as Unix time in seconds."
+ },
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "leads",
+ "contacts",
+ "companies"
+ ],
+ "description": "Type of the entity the task belongs to."
+ },
+ "entity_id": {
+ "type": "integer",
+ "description": "Id of that entity."
+ },
+ "task_type_id": {
+ "type": "integer",
+ "description": "Task type: 1 follow-up (default), 2 meeting, or a custom type id from kommo_get_account."
+ },
+ "responsible_user_id": {
+ "type": "integer",
+ "description": "Responsible user id (default: the token's user)."
+ },
+ "duration": {
+ "type": "integer",
+ "description": "Duration in seconds, for meetings."
+ }
+ },
+ "required": [
+ "text",
+ "complete_till"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/tasks",
+ "bodyMapping": [
+ {
+ "text": "$text",
+ "complete_till": "$complete_till",
+ "entity_type": "$entity_type",
+ "entity_id": "$entity_id",
+ "task_type_id": "$task_type_id",
+ "responsible_user_id": "$responsible_user_id",
+ "duration": "$duration"
+ }
+ ]
+ }
+ },
+ {
+ "name": "kommo_list_notes",
+ "description": "Read the notes (timeline entries) of one lead, contact or company: id, type (common text note, call_in, call_out, sms, service messages, ...), params with the text or call data, author and dates. Up to 250 per page; an empty answer means no more pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "leads",
+ "contacts",
+ "companies"
+ ],
+ "description": "Type of the entity."
+ },
+ "entity_id": {
+ "type": "integer",
+ "description": "Id of the entity."
+ },
+ "note_types": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only these note types, e.g. [\"common\", \"call_in\", \"call_out\"]."
+ },
+ "order_by_updated_at": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "description": "Sort by last update."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ },
+ "required": [
+ "entity_type",
+ "entity_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/{entity_type}/{entity_id}/notes",
+ "queryParams": {
+ "filter[note_type][]": "$note_types",
+ "order[updated_at]": "$order_by_updated_at",
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ },
+ {
+ "name": "kommo_add_note",
+ "description": "Add a text note to the timeline of a lead, contact or company. Returns the new note id. Confirm the text with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "leads",
+ "contacts",
+ "companies"
+ ],
+ "description": "Type of the entity."
+ },
+ "entity_id": {
+ "type": "integer",
+ "description": "Id of the entity."
+ },
+ "text": {
+ "type": "string",
+ "description": "Note text."
+ }
+ },
+ "required": [
+ "entity_type",
+ "entity_id",
+ "text"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/{entity_type}/notes",
+ "bodyMapping": [
+ {
+ "entity_id": "$entity_id",
+ "note_type": "common",
+ "params": {
+ "text": "$text"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "kommo_list_users",
+ "description": "List the account's users: id, name, email, language and rights, and with with=role,group their role and group. User ids are what responsible_user_id fields hold. Up to 250 per page.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "with": {
+ "type": "string",
+ "description": "Comma-separated extras: role, group, phone_number."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 250,
+ "default": 50,
+ "description": "Entries per page, 1 to 250 (default 50)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/users",
+ "queryParams": {
+ "with": "$with",
+ "page": "$page",
+ "limit": "$limit"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/kommo.live.spec.ts b/packages/backend/src/adapters/intl/kommo.live.spec.ts
new file mode 100644
index 000000000..297b21520
--- /dev/null
+++ b/packages/backend/src/adapters/intl/kommo.live.spec.ts
@@ -0,0 +1,232 @@
+import * as http from 'node:http';
+import { AddressInfo } from 'node:net';
+import * as adapterJson from './kommo.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { interpolateDeep } from '../../common/env-interpolation.util';
+
+/**
+ * Static checks and request-shape checks against a local server always run.
+ * The live block runs read-only calls through the real RestEngine with a
+ * long-lived token (Kommo allows 7 requests per second):
+ * KOMMO_SUBDOMAIN=acme KOMMO_ACCESS_TOKEN=eyJ... npx jest src/adapters/intl/kommo.live.spec.ts
+ * KOMMO_LIVE_WRITE=1 also creates a lead named "AnythingMCP test" with a note
+ * and a task. The API cannot delete leads, so at the end the task is closed
+ * and the lead is moved to Closed-lost (143).
+ */
+
+type Mapping = {
+ method: string;
+ path: string;
+ queryParams?: Record;
+ // An array for the create tools: Kommo takes a list of entities.
+ bodyMapping?: Record;
+};
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: Mapping;
+};
+const a = adapterJson as unknown as {
+ unlisted?: boolean;
+ instructions: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string };
+ connector: { baseUrl: string; authType: string; authConfig: Record; headers: Record };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService);
+const WRITES = ['kommo_add_note', 'kommo_create_contact', 'kommo_create_lead', 'kommo_create_task', 'kommo_update_lead'];
+
+describe('kommo adapter: static spec conformance', () => {
+ it('is listed (verified live on 10 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ });
+
+ it('calls API v4 on the account subdomain with the long-lived token as Bearer', () => {
+ expect(a.connector.baseUrl).toBe('https://{{KOMMO_SUBDOMAIN}}.kommo.com/api/v4');
+ expect(interpolateDeep(a.connector.baseUrl, { KOMMO_SUBDOMAIN: 'acme' })).toBe('https://acme.kommo.com/api/v4');
+ expect(a.connector.authType).toBe('BEARER_TOKEN');
+ expect(a.connector.authConfig).toEqual({ token: '{{KOMMO_ACCESS_TOKEN}}' });
+ expect(a.requiredEnvVars).toEqual(['KOMMO_SUBDOMAIN', 'KOMMO_ACCESS_TOKEN']);
+ expect(a.envVarMeta.KOMMO_SUBDOMAIN.kind).toBe('address');
+ expect(a.envVarMeta.KOMMO_ACCESS_TOKEN.secret).toBe(true);
+ });
+
+ it('probes with the account, which needs no argument', () => {
+ expect(a.probe.tool).toBe('kommo_get_account');
+ expect(tool('kommo_get_account').endpointMapping).toMatchObject({ method: 'GET', path: '/account' });
+ });
+
+ it('prefixes every tool, writes only through the expected tools and never deletes', () => {
+ for (const t of a.tools) expect(t.name.startsWith('kommo_')).toBe(true);
+ const writes = a.tools.filter((t) => t.endpointMapping.method !== 'GET').map((t) => t.name).sort();
+ expect(writes).toEqual(WRITES);
+ for (const name of WRITES) expect(tool(name).description).toMatch(/confirm/i);
+ expect(a.tools.some((t) => t.endpointMapping.method === 'DELETE')).toBe(false);
+ expect(a.tools.filter((t) => t.enabled === false)).toEqual([]);
+ });
+
+ it('creates entities with the array body the API expects', () => {
+ for (const name of ['kommo_create_lead', 'kommo_create_contact', 'kommo_create_task', 'kommo_add_note']) {
+ expect(Array.isArray(tool(name).endpointMapping.bodyMapping)).toBe(true);
+ }
+ expect(Array.isArray(tool('kommo_update_lead').endpointMapping.bodyMapping)).toBe(false);
+ });
+
+ it('pages lists with page and limit up to 250', () => {
+ for (const t of a.tools.filter((x) => x.endpointMapping.method === 'GET' && x.parameters.properties?.limit)) {
+ expect(t.parameters.properties!.limit.maximum).toBe(250);
+ expect(t.endpointMapping.queryParams).toMatchObject({ page: '$page', limit: '$limit' });
+ }
+ expect(a.instructions).toMatch(/HTTP 204/);
+ });
+
+ it('documents setup, rate limit, closed stages and the read-only setup', () => {
+ expect(a.instructions).toContain('Settings → Integrations');
+ expect(a.instructions).toContain('Generate long-lived token');
+ expect(a.instructions).toContain('7 requests per second');
+ expect(a.instructions).toMatch(/142 is Closed-won and 143 Closed-lost/);
+ expect(a.instructions).toContain('Read-only setup');
+ expect(JSON.stringify(adapterJson)).not.toContain('—');
+ });
+});
+
+describe('kommo adapter: requests through the real engine', () => {
+ let server: http.Server;
+ let origin: string;
+ const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body });
+ res.setHeader('Content-Type', 'application/hal+json');
+ res.end(JSON.stringify({ _embedded: { leads: [{ id: 1 }] } }));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+ afterAll(() => new Promise((r) => server.close(() => r())));
+ beforeEach(() => (seen.length = 0));
+
+ const run = (name: string, params: Record) =>
+ engine().execute(
+ { baseUrl: `${origin}/api/v4`, authType: 'BEARER_TOKEN', authConfig: { token: 'tok' }, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ params,
+ );
+
+ it('serializes lead filters in the bracket form Kommo reads, arrays as repeated keys', async () => {
+ await run('kommo_list_leads', { query: 'acme', pipeline_id: 7, status_pipeline_id: 7, status_id: 70, responsible_user_ids: [1, 2], ids: [5], limit: 50 });
+ const url = new URL(seen[0].url!, origin);
+ expect(url.pathname).toBe('/api/v4/leads');
+ expect(seen[0].headers.authorization).toBe('Bearer tok');
+ expect(url.searchParams.get('query')).toBe('acme');
+ expect(url.searchParams.getAll('filter[pipeline_id][]')).toEqual(['7']);
+ expect(url.searchParams.get('filter[statuses][0][pipeline_id]')).toBe('7');
+ expect(url.searchParams.get('filter[statuses][0][status_id]')).toBe('70');
+ expect(url.searchParams.getAll('filter[responsible_user_id][]')).toEqual(['1', '2']);
+ expect(url.searchParams.getAll('filter[id][]')).toEqual(['5']);
+ expect(url.searchParams.get('limit')).toBe('50');
+ });
+
+ it('creates a lead as a one-element array with linked contacts', async () => {
+ await run('kommo_create_lead', { name: 'AnythingMCP test', price: 100, contact_ids: [{ id: 3 }] });
+ expect(seen[0].method).toBe('POST');
+ expect(JSON.parse(seen[0].body)).toEqual([{ name: 'AnythingMCP test', price: 100, _embedded: { contacts: [{ id: 3 }] } }]);
+ });
+
+ it('adds a common note to the entity type in the path', async () => {
+ await run('kommo_add_note', { entity_type: 'leads', entity_id: 9, text: 'Called back' });
+ expect(seen[0].url).toBe('/api/v4/leads/notes');
+ expect(JSON.parse(seen[0].body)).toEqual([{ entity_id: 9, note_type: 'common', params: { text: 'Called back' } }]);
+ });
+
+ it('updates one lead with a PATCH object', async () => {
+ await run('kommo_update_lead', { lead_id: 9, status_id: 143 });
+ expect(seen[0].method).toBe('PATCH');
+ expect(seen[0].url).toBe('/api/v4/leads/9');
+ expect(JSON.parse(seen[0].body)).toEqual({ status_id: 143 });
+ });
+});
+
+const SUBDOMAIN = process.env.KOMMO_SUBDOMAIN;
+const TOKEN = process.env.KOMMO_ACCESS_TOKEN;
+const live = SUBDOMAIN && TOKEN ? describe : describe.skip;
+
+live('kommo adapter: live calls', () => {
+ const config = () => ({
+ baseUrl: interpolateDeep(a.connector.baseUrl, { KOMMO_SUBDOMAIN: SUBDOMAIN as string }),
+ authType: 'BEARER_TOKEN',
+ authConfig: { token: TOKEN as string },
+ headers: a.connector.headers,
+ });
+ const run = (name: string, params: Record = {}, mapping?: Mapping): Promise =>
+ engine().execute(config(), mapping ?? tool(name).endpointMapping, params);
+
+ it('reads the account (the probe), users and pipelines', async () => {
+ const account = await run('kommo_get_account', { with: 'task_types' });
+ expect(account.subdomain).toBe(SUBDOMAIN);
+ const users = await run('kommo_list_users', { limit: 5 });
+ expect(Array.isArray(users._embedded.users)).toBe(true);
+ const pipelines = await run('kommo_list_pipelines');
+ expect(Array.isArray(pipelines._embedded.pipelines)).toBe(true);
+ }, 30000);
+
+ it('lists leads, contacts, companies, custom fields and tasks', async () => {
+ // An empty list is HTTP 204 with no body.
+ const leads = await run('kommo_list_leads', { limit: 2, with: 'contacts' });
+ const leadId = leads?._embedded?.leads?.[0]?.id;
+ if (leadId) expect((await run('kommo_get_lead', { lead_id: leadId })).id).toBe(leadId);
+ const contacts = await run('kommo_list_contacts', { limit: 2 });
+ const contactId = contacts?._embedded?.contacts?.[0]?.id;
+ if (contactId) expect((await run('kommo_get_contact', { contact_id: contactId })).id).toBe(contactId);
+ await run('kommo_list_companies', { limit: 2 });
+ const fields = await run('kommo_list_custom_fields', { entity_type: 'contacts' });
+ expect(Array.isArray(fields._embedded.custom_fields)).toBe(true);
+ await run('kommo_list_tasks', { limit: 2, is_completed: 0 });
+ }, 60000);
+
+ (process.env.KOMMO_LIVE_WRITE === '1' ? it : it.skip)(
+ 'creates an "AnythingMCP test" lead with a note and a task, then closes both',
+ async () => {
+ let leadId: number | undefined;
+ let taskId: number | undefined;
+ try {
+ const created = await run('kommo_create_lead', { name: 'AnythingMCP test', price: 1, tags: [{ name: 'anythingmcp-test' }] });
+ leadId = created._embedded.leads[0].id;
+ await run('kommo_add_note', { entity_type: 'leads', entity_id: leadId, text: 'AnythingMCP test note' });
+ const notes = await run('kommo_list_notes', { entity_type: 'leads', entity_id: leadId });
+ expect(JSON.stringify(notes)).toContain('AnythingMCP test note');
+ const task = await run('kommo_create_task', {
+ text: 'AnythingMCP test task',
+ complete_till: Math.floor(Date.now() / 1000) + 86400,
+ entity_type: 'leads',
+ entity_id: leadId,
+ });
+ taskId = task._embedded.tasks[0].id;
+ const renamed = await run('kommo_update_lead', { lead_id: leadId, name: 'AnythingMCP test (updated)' });
+ expect(renamed.id).toBe(leadId);
+ } finally {
+ if (taskId) {
+ await run('', {}, { method: 'PATCH', path: `/tasks/${taskId}`, bodyMapping: { is_completed: true, result: { text: 'AnythingMCP test cleanup' } } }).catch(() => undefined);
+ }
+ if (leadId) await run('kommo_update_lead', { lead_id: leadId, status_id: 143 });
+ }
+ },
+ 60000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/microsoft-excel.json b/packages/backend/src/adapters/intl/microsoft-excel.json
new file mode 100644
index 000000000..08fcfdd42
--- /dev/null
+++ b/packages/backend/src/adapters/intl/microsoft-excel.json
@@ -0,0 +1,667 @@
+{
+ "slug": "microsoft-excel",
+ "name": "Excel (Microsoft 365)",
+ "description": "Let your AI work with Excel workbooks in OneDrive and SharePoint: list sheets, read any range or a sheet's used range, write values and formulas, read and append table rows, add sheets and create tables. Microsoft 365 through the Microsoft Graph workbook API, signed in with your Microsoft account.",
+ "instructions": "## Excel\nThis connector reads and writes Excel workbooks (.xlsx) stored in OneDrive or SharePoint through the **Microsoft Graph workbook API** (`https://graph.microsoft.com/v1.0`): worksheets, cell ranges, the used range of a sheet, Excel tables and their rows. It works on the file in the cloud, as the user who authorized the connector; Excel for the web and desktop see the changes. Work or school accounts (Microsoft 365) are fully supported; personal OneDrive supports most read and range tools but not table rows.\n\n**Setup (one-time, about 10 minutes)**\n1. Open https://entra.microsoft.com (or https://portal.azure.com) → **Applications → App registrations → New registration**.\n2. Name it (e.g. \"AnythingMCP Microsoft 365\") and choose the **supported account types**:\n - *Accounts in this organizational directory only* (single tenant): only your organisation's users can sign in. Set `MICROSOFT_TENANT_ID` to the **Directory (tenant) ID** shown on the app's Overview page (a GUID), or to your domain such as `contoso.onmicrosoft.com`.\n - *Accounts in any organizational directory* (multi-tenant): set `MICROSOFT_TENANT_ID` to `organizations`.\n - *... and personal Microsoft accounts*: set `MICROSOFT_TENANT_ID` to `common` (work, school and personal accounts) or `consumers` (personal accounts only).\n The value must match the app's account type, otherwise Microsoft refuses the sign-in (AADSTS50194 or AADSTS50020).\n3. Under **Redirect URI** pick platform **Web** and enter `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. It must match character for character (AADSTS50011 otherwise).\n4. **API permissions → Add a permission → Microsoft Graph → Delegated permissions**: add `offline_access`, `User.Read`, `Files.ReadWrite`.\n5. **Certificates & secrets → New client secret**. Copy the secret **Value** (not the Secret ID); it is shown once. Note its expiry date: when it expires the connector stops working until you paste a new one.\n6. Paste the **Application (client) ID** into `MICROSOFT_CLIENT_ID`, the secret value into `MICROSOFT_CLIENT_SECRET`, the tenant value from step 2 into `MICROSOFT_TENANT_ID`, and install.\n7. On the connector page click **Authorize with Provider** and sign in with the account whose workbooks the AI should work with. The refresh token is stored encrypted and access tokens (about 1 hour) are renewed automatically.\n\n**One Entra app for all Microsoft connectors**: the app registration made for the Outlook connector works here as well. Open it, add the delegated permissions from step 4 under **API permissions**, keep the same redirect URI, and reuse the same `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` and `MICROSOFT_TENANT_ID`. Each connector asks only for its own scopes when you authorize it. The permissions per connector:\n- Outlook: `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`\n- OneDrive: `Files.ReadWrite`\n- Excel: `Files.ReadWrite` (plus `Files.ReadWrite.All` for workbooks in SharePoint or shared with the user)\n- SharePoint: `Sites.ReadWrite.All` and `Files.ReadWrite.All`\nEvery connector also needs `offline_access` and `User.Read`.\n\n**Admin consent**: these delegated permissions normally do not need an administrator, but many organisations turn off user consent. If the sign-in shows \"Need admin approval\" (AADSTS65001 or AADSTS90094), a tenant administrator opens the app registration → **API permissions → Grant admin consent for ** once; afterwards every user can authorize. Each connector still works only with what the account that signed in may see.\n\n**Scopes**: `offline_access User.Read Files.ReadWrite`. `offline_access` is what makes Microsoft issue a refresh token. `Files.ReadWrite` covers workbooks in the user's own OneDrive. For workbooks in SharePoint libraries or shared by other people, add `Files.ReadWrite.All` to the app registration and replace `Files.ReadWrite` with it in the connector's OAuth settings, then authorize again. Even reading a workbook needs write permission, because Graph opens it in an Excel session. To keep the AI from changing workbooks, disable `excel_update_range`, `excel_add_worksheet`, `excel_add_table_rows`, `excel_create_table`.\n\n**Switched off at install**: `excel_clear_range` and `excel_delete_worksheet`. Turn them on per tool if the AI needs them.\n\n**Finding a workbook**: every tool takes the workbook's `drive_id` and `item_id`.\n- In the user's OneDrive: `excel_find_workbooks` (search) or `excel_get_workbook_by_path` (e.g. `Finance/Budget 2026.xlsx`) return `id` and `parentReference.driveId`.\n- In SharePoint: use the SharePoint connector's `sharepoint_list_drives` and `sharepoint_search_drive` for the library's drive id and the file's id.\nOnly `.xlsx` (and `.xlsm`, `.xlsb`) files work; `.xls` and `.csv` answer an error. Then call `excel_list_worksheets` to learn the sheet names.\n\n**Reading**: `excel_read_range` reads an address on one sheet (`A1:D50`, `A:C`, `B2`); `excel_read_used_range` reads everything that holds values and tells the sheet's size. `values` are a list of rows; numbers come as numbers and dates as Excel serial numbers (days since 30 December 1899): ask for `text` in `fields` to get them as displayed. There is no pagination: read big sheets in blocks of rows (`A1:Z500`, `A501:Z1000`), keeping each answer under the 200 KB cap. Tables: `excel_list_tables`, `excel_list_table_columns`, `excel_list_table_rows` (paged with `top` and `skip`).\n\n**Writing**\n- `excel_update_range` overwrites a range with `values` or `formulas` (English function names and commas, e.g. `=SUM(B2:B20)`), optionally with `number_format`. The arrays must match the range's shape exactly.\n- `excel_add_table_rows` appends rows to a table; it is the safest way to add records, because the table grows and keeps its formulas and formats. Send many rows in one call.\n- `excel_add_worksheet` adds a tab; `excel_create_table` turns a range into a table.\nChanges are saved to the file immediately. If someone has the file open in Excel desktop with unsaved changes, or a large workbook is still loading, Graph can answer 409 or 504: wait a moment and retry.\n\n**Limits**: Graph handles workbooks up to about 100 MB; requests time out after about 30 seconds, so keep ranges moderate. Excel throttles per workbook; a throttled call answers 429 with Retry-After.\n\n**Common errors**\n- 401 `InvalidAuthenticationToken`: the authorization was revoked or the client secret expired. Paste a new secret if needed and click **Authorize with Provider** again.\n- 403 `accessDenied`: a permission is missing, or the workbook belongs to someone else and the scope lacks `Files.ReadWrite.All`.\n- 404 `itemNotFound`: wrong drive or item id, or a worksheet or table name that does not exist (names are case-sensitive in some places; check `excel_list_worksheets`).\n- 400 `InvalidArgument`: an address Excel cannot read, or values whose shape does not match the range. 400 `ItemAlreadyExists`: a sheet with that name exists.\n- `UnsupportedWorkbook` or `NotSupported`: the file is not .xlsx, is password protected, or is too large for the workbook API.\n\n**Out of scope here**: creating new workbook files, charts, pivot tables, formatting beyond number formats, named items, and workbook sessions (every call is saved directly).",
+ "region": "intl",
+ "category": "productivity",
+ "icon": "microsoft-excel",
+ "docsUrl": "https://learn.microsoft.com/en-us/graph/api/resources/excel",
+ "requiredEnvVars": [
+ "MICROSOFT_CLIENT_ID",
+ "MICROSOFT_CLIENT_SECRET",
+ "MICROSOFT_TENANT_ID"
+ ],
+ "appRegistrationUrl": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "envVarMeta": {
+ "MICROSOFT_CLIENT_ID": {
+ "label": "Application (client) ID",
+ "kind": "credential",
+ "help": "Microsoft Entra admin center → App registrations → your app → Overview: Application (client) ID. The app registration made for the Outlook connector works here too.",
+ "link": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "example": "00001111-aaaa-2222-bbbb-3333cccc4444",
+ "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": "The Application (client) ID is a GUID like 00001111-aaaa-2222-bbbb-3333cccc4444."
+ },
+ "MICROSOFT_CLIENT_SECRET": {
+ "label": "Client secret value",
+ "kind": "credential",
+ "help": "Same app → Certificates & secrets → New client secret: copy the Value column (shown once), not the Secret ID.",
+ "link": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
+ },
+ "MICROSOFT_TENANT_ID": {
+ "label": "Tenant",
+ "kind": "setting",
+ "help": "Single-tenant app: the Directory (tenant) ID from the app's Overview page (or your domain, e.g. contoso.onmicrosoft.com). Multi-tenant app: organizations. App that also accepts personal accounts: common (or consumers for personal accounts only).",
+ "example": "organizations",
+ "pattern": "^(common|organizations|consumers|[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}|[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+)$",
+ "patternMessage": "Use a tenant GUID, a verified domain such as contoso.onmicrosoft.com, or one of common, organizations, consumers."
+ }
+ },
+ "verifyHints": {
+ "401": "Microsoft refused the access token. Click Authorize with Provider again; if the client secret has expired, create a new one in the app registration first.",
+ "403": "The app lacks a Graph permission or consent for it. Add Files.ReadWrite (or Files.ReadWrite.All) and User.Read as delegated permissions, grant admin consent if your organisation requires it, and authorize again.",
+ "404": "Microsoft Graph found no OneDrive for this account. Work or school accounts need a OneDrive licence, and the user must have opened OneDrive once.",
+ "rate_limited": "Microsoft Graph is throttling this account or workbook. Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "excel_get_my_drive"
+ },
+ "prerequisites": "An app registration in Microsoft Entra (free, about 10 minutes) with the redirect URI below, the delegated Graph permissions `offline_access`, `User.Read` and `Files.ReadWrite` (`Files.ReadWrite.All` for workbooks in SharePoint), and a client secret. The app registration of the Outlook connector can be reused: add the permission to it. Some organisations only let an admin grant consent.",
+ "connector": {
+ "name": "Excel (Microsoft Graph)",
+ "type": "REST",
+ "baseUrl": "https://graph.microsoft.com/v1.0",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "clientId": "{{MICROSOFT_CLIENT_ID}}",
+ "clientSecret": "{{MICROSOFT_CLIENT_SECRET}}",
+ "authorizationUrl": "https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/authorize",
+ "tokenUrl": "https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/token",
+ "scopes": "offline_access User.Read Files.ReadWrite"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/me/drive"
+ },
+ "tools": [
+ {
+ "name": "excel_get_my_drive",
+ "description": "Get the signed-in user's OneDrive: its id (the drive_id for workbooks stored there), type and owner. A cheap check that the connector works.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive",
+ "queryParams": {
+ "$select": "id,driveType,name,owner,webUrl"
+ }
+ }
+ },
+ {
+ "name": "excel_find_workbooks",
+ "description": "Search the user's OneDrive for workbooks by name or content, e.g. \"budget\" or \"xlsx\". Returns id, name, parentReference.driveId (the drive_id the other tools need), size, dates and webUrl. Only .xlsx files work with the workbook tools. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against file names, metadata and content, e.g. \"budget 2026\". Write a single quote as two (''), e.g. \"O''Brien\"."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/root/search(q='{query}')",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,lastModifiedDateTime,lastModifiedBy,parentReference,file",
+ "$top": "$top",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "excel_get_workbook_by_path",
+ "description": "Look up a workbook in the user's OneDrive by path, e.g. \"Documents/Budget 2026.xlsx\", and return its id and parentReference.driveId for the other tools.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path from the top of the OneDrive without a leading slash, e.g. \"Finance/Budget 2026.xlsx\". Names containing #, % or ? cannot be addressed by path: use excel_find_workbooks."
+ }
+ },
+ "required": [
+ "path"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/root:/{path}",
+ "queryParams": {
+ "$select": "id,name,size,webUrl,lastModifiedDateTime,parentReference,file"
+ }
+ }
+ },
+ {
+ "name": "excel_list_worksheets",
+ "description": "List the worksheets (tabs) of a workbook: id, name, position (0-based) and visibility. Call it first to learn the sheet names.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,position,visibility"
+ }
+ }
+ },
+ {
+ "name": "excel_read_range",
+ "description": "Read a range of one worksheet, e.g. \"A1:D20\": returns address and values (a list of rows, numbers as numbers, dates as serial numbers) by default, plus text, formulas or numberFormat when asked through fields. Output over 200 KB is cut and marked _truncated: read big sheets in blocks of rows.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "worksheet": {
+ "type": "string",
+ "description": "Worksheet name (e.g. \"Sheet1\", \"Sales 2026\") or id, from excel_list_worksheets."
+ },
+ "address": {
+ "type": "string",
+ "description": "Cell range in A1 notation without the sheet name, e.g. \"A1:D20\", \"B2\", \"A:C\" (whole columns) or \"2:5\" (whole rows)."
+ },
+ "fields": {
+ "type": "string",
+ "description": "Range properties to return, comma-separated: address, values, text (as displayed), formulas, numberFormat, valueTypes, rowCount, columnCount, rowIndex, columnIndex.",
+ "default": "address,values,rowCount,columnCount"
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "worksheet",
+ "address"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets/{worksheet}/range(address='{address}')",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "$fields"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ },
+ {
+ "name": "excel_read_used_range",
+ "description": "Read the part of a worksheet that holds values (its used range): address (e.g. Sheet1!A1:F120), rowCount, columnCount and values. Use it to learn a sheet's size before reading blocks. Output over 200 KB is cut and marked _truncated.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "worksheet": {
+ "type": "string",
+ "description": "Worksheet name (e.g. \"Sheet1\", \"Sales 2026\") or id, from excel_list_worksheets."
+ },
+ "fields": {
+ "type": "string",
+ "description": "Range properties to return, comma-separated: address, values, text (as displayed), formulas, numberFormat, valueTypes, rowCount, columnCount, rowIndex, columnIndex.",
+ "default": "address,values,rowCount,columnCount"
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "worksheet"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets/{worksheet}/usedRange(valuesOnly=true)",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "$fields"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ },
+ {
+ "name": "excel_update_range",
+ "description": "Write into a range, overwriting what is there: values, or formulas (e.g. [[\"=SUM(B2:B20)\"]]), and optionally numberFormat. The arrays must have exactly the range's rows and columns (\"A1:B2\" takes 2 rows of 2). Returns the range with its new values.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "worksheet": {
+ "type": "string",
+ "description": "Worksheet name (e.g. \"Sheet1\", \"Sales 2026\") or id, from excel_list_worksheets."
+ },
+ "address": {
+ "type": "string",
+ "description": "Cell range in A1 notation without the sheet name, e.g. \"A1:D20\", \"B2\", \"A:C\" (whole columns) or \"2:5\" (whole rows)."
+ },
+ "values": {
+ "type": "array",
+ "description": "Rows of cell values, e.g. [[\"Date\", \"Amount\"], [\"2026-10-09\", 120.5]]. Each inner list is one row and every row must have the same number of cells. A value starting with = is entered as a formula.",
+ "items": {
+ "type": "array",
+ "items": {
+ "description": "A cell value: string, number, boolean or null."
+ }
+ }
+ },
+ "formulas": {
+ "type": "array",
+ "description": "Rows of formulas in English notation, e.g. [[\"=A2*1.19\"]], instead of values.",
+ "items": {
+ "type": "array",
+ "items": {
+ "description": "A formula or value."
+ }
+ }
+ },
+ "number_format": {
+ "type": "array",
+ "description": "Rows of Excel number formats, e.g. [[\"0.00\", \"dd/mm/yyyy\"]].",
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "A number format code."
+ }
+ }
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "worksheet",
+ "address"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets/{worksheet}/range(address='{address}')",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "values": "$values",
+ "formulas": "$formulas",
+ "numberFormat": "$number_format"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ },
+ {
+ "name": "excel_add_worksheet",
+ "description": "Add a worksheet (tab) at the end of the workbook. Returns its id, name, position and visibility.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the new sheet, unique in the workbook, at most 31 characters, without : \\ / ? * [ ]."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets/add",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name"
+ }
+ }
+ },
+ {
+ "name": "excel_list_tables",
+ "description": "List the tables (formatted Excel tables) of a workbook: id, name, showHeaders, showTotals and style. Table rows are read and appended with excel_list_table_rows and excel_add_table_rows.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/tables",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,showHeaders,showTotals,style"
+ }
+ }
+ },
+ {
+ "name": "excel_list_table_columns",
+ "description": "List a table's columns: id, name (the header text) and index. Use it to put values in the right order before adding rows.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "table": {
+ "type": "string",
+ "description": "Table name (e.g. \"Table1\") or id, from excel_list_tables."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "table"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/tables/{table}/columns",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,index"
+ }
+ }
+ },
+ {
+ "name": "excel_list_table_rows",
+ "description": "Read the data rows of a table (without the header): each row's index and values. Page with top and skip. Output over 200 KB is cut and marked _truncated.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "table": {
+ "type": "string",
+ "description": "Table name (e.g. \"Table1\") or id, from excel_list_tables."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Rows to return.",
+ "minimum": 1
+ },
+ "skip": {
+ "type": "integer",
+ "description": "Rows to skip from the top of the table, for the next page.",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "table"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/tables/{table}/rows",
+ "encodePathParams": true,
+ "queryParams": {
+ "$top": "$top",
+ "$skip": "$skip"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ },
+ {
+ "name": "excel_add_table_rows",
+ "description": "Append rows at the end of a table (or insert them at a 0-based index); the table grows and formulas and formats of the table columns carry over. values is a list of rows with one value per table column. Add many rows in one call rather than one at a time. Work or school accounts only.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "table": {
+ "type": "string",
+ "description": "Table name (e.g. \"Table1\") or id, from excel_list_tables."
+ },
+ "values": {
+ "type": "array",
+ "description": "Rows of cell values, e.g. [[\"Date\", \"Amount\"], [\"2026-10-09\", 120.5]]. Each inner list is one row and every row must have the same number of cells. A value starting with = is entered as a formula.",
+ "items": {
+ "type": "array",
+ "items": {
+ "description": "A cell value: string, number, boolean or null."
+ }
+ }
+ },
+ "index": {
+ "type": "integer",
+ "description": "Optional 0-based position among the data rows; rows below move down. Default: the end.",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "table",
+ "values"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/tables/{table}/rows",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "values": "$values",
+ "index": "$index"
+ }
+ }
+ },
+ {
+ "name": "excel_create_table",
+ "description": "Turn a range into an Excel table, e.g. address \"Sheet1!A1:D1\" with has_headers true to start an empty table from a header row. Returns the table's id and name (Table1, Table2...).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "address": {
+ "type": "string",
+ "description": "Range with the sheet name, e.g. \"Sheet1!A1:D1\" or \"'Sales 2026'!A1:F50\"."
+ },
+ "has_headers": {
+ "type": "boolean",
+ "description": "true when the first row of the range holds the column names."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "address",
+ "has_headers"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/tables/add",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "address": "$address",
+ "hasHeaders": "$has_headers"
+ }
+ }
+ },
+ {
+ "name": "excel_clear_range",
+ "description": "Clear a range: its contents, its formats, or both. Cannot be undone through the API, only from the file's version history. Installed switched off.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "worksheet": {
+ "type": "string",
+ "description": "Worksheet name (e.g. \"Sheet1\", \"Sales 2026\") or id, from excel_list_worksheets."
+ },
+ "address": {
+ "type": "string",
+ "description": "Cell range in A1 notation without the sheet name, e.g. \"A1:D20\", \"B2\", \"A:C\" (whole columns) or \"2:5\" (whole rows)."
+ },
+ "apply_to": {
+ "type": "string",
+ "description": "What to clear: \"Contents\" (values and formulas), \"Formats\" or \"All\" (default).",
+ "enum": [
+ "All",
+ "Contents",
+ "Formats"
+ ],
+ "default": "All"
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "worksheet",
+ "address"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets/{worksheet}/range(address='{address}')/clear",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "applyTo": "$apply_to"
+ }
+ },
+ "enabled": false
+ },
+ {
+ "name": "excel_delete_worksheet",
+ "description": "Delete a worksheet with everything on it. Cannot be undone through the API, only from the file's version history. Installed switched off.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Drive id of the workbook: parentReference.driveId from excel_find_workbooks or excel_get_workbook_by_path, or the id from excel_get_my_drive for the user's own OneDrive. For a SharePoint library, the drive id from the SharePoint connector's sharepoint_list_drives."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Drive item id of the .xlsx file, from excel_find_workbooks or excel_get_workbook_by_path."
+ },
+ "worksheet": {
+ "type": "string",
+ "description": "Worksheet name (e.g. \"Sheet1\", \"Sales 2026\") or id, from excel_list_worksheets."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "worksheet"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/drives/{drive_id}/items/{item_id}/workbook/worksheets/{worksheet}",
+ "encodePathParams": true
+ },
+ "enabled": false
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/microsoft-excel.live.spec.ts b/packages/backend/src/adapters/intl/microsoft-excel.live.spec.ts
new file mode 100644
index 000000000..368971642
--- /dev/null
+++ b/packages/backend/src/adapters/intl/microsoft-excel.live.spec.ts
@@ -0,0 +1,331 @@
+import axios from 'axios';
+import * as adapter from './microsoft-excel.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { applyResponseTransform, validateTransform } from '../../connectors/response-transform.util';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the Excel (Microsoft Graph workbook API)
+ * adapter:
+ *
+ * 1. Static: always runs. Pins the Entra OAuth setup shared with Outlook,
+ * every workbook call addressed as /drives/{drive_id}/items/{item_id}
+ * (so OneDrive and SharePoint workbooks work alike), the range function
+ * syntax with a percent-encoded address and sheet name, the `$select`
+ * that keeps range answers to address and values by default, the 200 KB
+ * cap on reads, the write bodies, and which tools install switched off
+ * (clear range, delete worksheet).
+ *
+ * 2. Live: skipped unless MS_GRAPH_ACCESS_TOKEN is set (a delegated Graph
+ * token with Files.ReadWrite, e.g. from Graph Explorer). Reads use the
+ * first .xlsx found in the user's OneDrive:
+ *
+ * MS_GRAPH_ACCESS_TOKEN=eyJ... npx jest src/adapters/intl/microsoft-excel.live.spec.ts
+ *
+ * With EXCEL_LIVE_WRITE=1 and EXCEL_TEST_ITEM_ID (the drive item id of a
+ * workbook you own; EXCEL_TEST_DRIVE_ID too when it is not in your own
+ * OneDrive), a sheet "AnythingMCP test " is added to that
+ * workbook, written, turned into a table that gets rows, read back, and
+ * deleted in `finally`. The rest of the workbook is not touched.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ // The outbound helper also calls axios.getUri, getAdapter and friends:
+ // keep every real static, only the call itself is mocked.
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ encodePathParams?: boolean;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ responseMapping?: Record;
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const BASE = 'https://graph.microsoft.com/v1.0';
+const WB = `${BASE}/drives/b!d1/items/01X/workbook`;
+const ids = { drive_id: 'b!d1', item_id: '01X' };
+const newEngine = () =>
+ new RestEngine(
+ { getAccessToken: jest.fn().mockResolvedValue('test-token') } as unknown as OAuth2TokenService,
+ {} as LoginTokenService,
+ );
+const connectorConfig = () => ({
+ baseUrl: a.connector.baseUrl,
+ authType: a.connector.authType,
+ authConfig: { ...a.connector.authConfig },
+ headers: { ...a.connector.headers },
+});
+// Arguments as DynamicMcpTools hands them to the engine: schema defaults filled in.
+const call = (name: string, params: Record) =>
+ newEngine().execute(connectorConfig(), tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params));
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('microsoft-excel adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 10 Oct 2026), and says up front that it needs an Entra app', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/Entra/);
+ expect(a.prerequisites).toMatch(/Outlook connector can be reused/);
+ });
+
+ it('signs in against the configured tenant with the Outlook variables and Files.ReadWrite', () => {
+ expect(a.connector.authType).toBe('OAUTH2');
+ expect(a.requiredEnvVars).toEqual(['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET', 'MICROSOFT_TENANT_ID']);
+ expect(a.connector.authConfig).toEqual({
+ clientId: '{{MICROSOFT_CLIENT_ID}}',
+ clientSecret: '{{MICROSOFT_CLIENT_SECRET}}',
+ authorizationUrl: 'https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/authorize',
+ tokenUrl: 'https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/token',
+ scopes: 'offline_access User.Read Files.ReadWrite',
+ });
+ expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback');
+ expect(a.instructions).toMatch(/Files\.ReadWrite\.All/);
+ });
+
+ it('probes with /me/drive, which needs no arguments', () => {
+ expect(a.probe.tool).toBe('excel_get_my_drive');
+ expect(tool('excel_get_my_drive').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/me/drive');
+ });
+
+ it('prefixes every tool with excel_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^excel_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('addresses every workbook call by drive and item id, percent-encoded', () => {
+ for (const t of a.tools.filter((x) => x.endpointMapping.path.includes('/workbook'))) {
+ expect(t.endpointMapping.path.startsWith('/drives/{drive_id}/items/{item_id}/workbook/')).toBe(true);
+ expect(t.endpointMapping.encodePathParams).toBe(true);
+ expect(t.parameters.required).toEqual(expect.arrayContaining(['drive_id', 'item_id']));
+ }
+ });
+
+ it('installs clear range and delete worksheet switched off', () => {
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual(['excel_clear_range', 'excel_delete_worksheet']);
+ expect(a.instructions).toMatch(/Switched off at install\*\*: `excel_clear_range` and `excel_delete_worksheet`/);
+ expect(annotationsOf(tool('excel_clear_range')).destructiveHint).toBe(true);
+ expect(annotationsOf(tool('excel_delete_worksheet')).destructiveHint).toBe(true);
+ });
+
+ it('marks reads read-only and overwriting a range destructive', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'excel_find_workbooks',
+ 'excel_get_my_drive',
+ 'excel_get_workbook_by_path',
+ 'excel_list_table_columns',
+ 'excel_list_table_rows',
+ 'excel_list_tables',
+ 'excel_list_worksheets',
+ 'excel_read_range',
+ 'excel_read_used_range',
+ ]);
+ expect(annotationsOf(tool('excel_update_range')).destructiveHint).toBe(true);
+ expect(annotationsOf(tool('excel_add_table_rows')).destructiveHint).toBe(false);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bexcel_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bexcel_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(5);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ it.each([
+ ['excel_get_my_drive', {}, 'GET', `${BASE}/me/drive`],
+ ['excel_find_workbooks', { query: 'budget' }, 'GET', `${BASE}/me/drive/root/search(q='budget')`],
+ ['excel_get_workbook_by_path', { path: 'Finance/Budget 2026.xlsx' }, 'GET', `${BASE}/me/drive/root:/Finance/Budget 2026.xlsx`],
+ ['excel_list_worksheets', ids, 'GET', `${WB}/worksheets`],
+ ['excel_read_range', { ...ids, worksheet: 'Sales 2026', address: 'A1:D20' }, 'GET', `${WB}/worksheets/Sales%202026/range(address='A1%3AD20')`],
+ ['excel_read_used_range', { ...ids, worksheet: 'Sheet1' }, 'GET', `${WB}/worksheets/Sheet1/usedRange(valuesOnly=true)`],
+ ['excel_update_range', { ...ids, worksheet: 'Sheet1', address: 'B2', values: [[1]] }, 'PATCH', `${WB}/worksheets/Sheet1/range(address='B2')`],
+ ['excel_add_worksheet', { ...ids, name: 'New' }, 'POST', `${WB}/worksheets/add`],
+ ['excel_list_tables', ids, 'GET', `${WB}/tables`],
+ ['excel_list_table_columns', { ...ids, table: 'Table1' }, 'GET', `${WB}/tables/Table1/columns`],
+ ['excel_list_table_rows', { ...ids, table: 'Table1' }, 'GET', `${WB}/tables/Table1/rows`],
+ ['excel_add_table_rows', { ...ids, table: 'Table1', values: [[1, 2]] }, 'POST', `${WB}/tables/Table1/rows`],
+ ['excel_create_table', { ...ids, address: 'Sheet1!A1:B1', has_headers: true }, 'POST', `${WB}/tables/add`],
+ ['excel_clear_range', { ...ids, worksheet: 'Sheet1', address: 'A2:Z' }, 'POST', `${WB}/worksheets/Sheet1/range(address='A2%3AZ')/clear`],
+ ['excel_delete_worksheet', { ...ids, worksheet: '{00000000-0001-0000-0100-000000000000}' }, 'DELETE', `${WB}/worksheets/%7B00000000-0001-0000-0100-000000000000%7D`],
+ ])('%s sends %s to the right Graph URL', async (name, params, method, url) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({ method, url, headers: expect.objectContaining({ Authorization: 'Bearer test-token' }) }),
+ );
+ });
+
+ it('asks for address and values by default and for more on request', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('excel_read_range', { ...ids, worksheet: 'Sheet1', address: 'A1:B2' });
+ expect(sent().params).toEqual({ $select: 'address,values,rowCount,columnCount' });
+ mockedAxios.mockClear();
+ await call('excel_read_used_range', { ...ids, worksheet: 'Sheet1', fields: 'address,text' });
+ expect(sent().params).toEqual({ $select: 'address,text' });
+ });
+
+ it('writes values, formulas and number formats as given', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('excel_update_range', {
+ ...ids,
+ worksheet: 'Sheet1',
+ address: 'A1:B1',
+ formulas: [['=1+1', '=A1*2']],
+ number_format: [['0.00', '0.00']],
+ });
+ expect(sent().data).toEqual({ formulas: [['=1+1', '=A1*2']], numberFormat: [['0.00', '0.00']] });
+ });
+
+ it('adds table rows at the end unless an index is given, and creates tables from an address', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('excel_add_table_rows', { ...ids, table: 'Table1', values: [['a', 1], ['b', 2]] });
+ expect(sent().data).toEqual({ values: [['a', 1], ['b', 2]] });
+ mockedAxios.mockClear();
+ await call('excel_add_table_rows', { ...ids, table: 'Table1', values: [['c', 3]], index: 0 });
+ expect(sent().data).toEqual({ values: [['c', 3]], index: 0 });
+ mockedAxios.mockClear();
+ await call('excel_create_table', { ...ids, address: "'Sales 2026'!A1:C1", has_headers: true });
+ expect(sent().data).toEqual({ address: "'Sales 2026'!A1:C1", hasHeaders: true });
+ });
+
+ it('pages table rows with $top and $skip', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('excel_list_table_rows', { ...ids, table: 'Table1', top: 100, skip: 200 });
+ expect(sent().params).toEqual({ $top: 100, $skip: 200 });
+ });
+
+ it('caps range and row reads at 200 KB', () => {
+ for (const name of ['excel_read_range', 'excel_read_used_range', 'excel_list_table_rows', 'excel_update_range']) {
+ const mapping = tool(name).responseMapping!;
+ expect(validateTransform(mapping.transform)).toBeNull();
+ const big = applyResponseTransform({ values: [['x'.repeat(300_000)]] }, mapping);
+ expect(big.truncated).toBe(true);
+ expect(applyResponseTransform({ values: [[1]] }, mapping).value).toEqual({ values: [[1]] });
+ }
+ });
+});
+
+const TOKEN = process.env.MS_GRAPH_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('microsoft-excel adapter: live Graph workbook API', () => {
+ beforeAll(() => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ });
+ const run = (name: string, params: Record = {}): Promise =>
+ new RestEngine({} as OAuth2TokenService, {} as LoginTokenService)
+ .execute(
+ { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ )
+ .then((raw) => applyResponseTransform(raw, tool(name).responseMapping).value);
+
+ it('finds a workbook and reads its sheets, used range, a range and its tables', async () => {
+ const drive = await run('excel_get_my_drive');
+ expect(typeof drive.id).toBe('string');
+ const hits = await run('excel_find_workbooks', { query: 'xlsx', top: 25 });
+ const book = hits.value.find((f: { name: string }) => /\.xlsx$/i.test(f.name));
+ if (!book) return;
+ const wb = { drive_id: book.parentReference.driveId, item_id: book.id };
+ const sheets = await run('excel_list_worksheets', wb);
+ expect(sheets.value.length).toBeGreaterThan(0);
+ const sheet = sheets.value[0].name;
+ const used = await run('excel_read_used_range', { ...wb, worksheet: sheet });
+ expect(used.address ?? used._truncated).toBeTruthy();
+ const range = await run('excel_read_range', { ...wb, worksheet: sheet, address: 'A1:C3', fields: 'address,values,text' });
+ expect(range.values ?? range._truncated).toBeTruthy();
+ const tables = await run('excel_list_tables', wb);
+ expect(Array.isArray(tables.value)).toBe(true);
+ if (tables.value.length) {
+ const cols = await run('excel_list_table_columns', { ...wb, table: tables.value[0].name });
+ expect(Array.isArray(cols.value)).toBe(true);
+ const rows = await run('excel_list_table_rows', { ...wb, table: tables.value[0].name, top: 5 });
+ expect(Array.isArray(rows.value) || rows._truncated).toBeTruthy();
+ }
+ }, 120_000);
+
+ const itemId = process.env.EXCEL_TEST_ITEM_ID;
+ (process.env.EXCEL_LIVE_WRITE === '1' && itemId ? it : it.skip)(
+ 'adds a test sheet, writes it, makes a table with rows, reads it back and deletes the sheet',
+ async () => {
+ const driveId = process.env.EXCEL_TEST_DRIVE_ID || (await run('excel_get_my_drive')).id;
+ const wb = { drive_id: driveId, item_id: itemId };
+ const name = `AnythingMCP test ${Date.now() % 1_000_000}`;
+ const sheet = await run('excel_add_worksheet', { ...wb, name });
+ expect(sheet.name).toBe(name);
+ try {
+ await run('excel_update_range', { ...wb, worksheet: name, address: 'A1:C2', values: [['Item', 'Qty', 'Total'], ['Grüße', 2, null]] });
+ await run('excel_update_range', { ...wb, worksheet: name, address: 'C2', formulas: [['=B2*10']] });
+ const read = await run('excel_read_range', { ...wb, worksheet: name, address: 'A1:C2' });
+ expect(read.values).toEqual([['Item', 'Qty', 'Total'], ['Grüße', 2, 20]]);
+
+ const table = await run('excel_create_table', { ...wb, address: `'${name}'!A1:C2`, has_headers: true });
+ await run('excel_add_table_rows', { ...wb, table: table.name, values: [['a', 1, 'x'], ['b', 2, 'y']] });
+ const rows = await run('excel_list_table_rows', { ...wb, table: table.name });
+ expect(rows.value.map((r: { values: unknown[][] }) => r.values[0][0])).toEqual(['Grüße', 'a', 'b']);
+ const used = await run('excel_read_used_range', { ...wb, worksheet: name });
+ expect(used.rowCount).toBe(4);
+ } finally {
+ await run('excel_delete_worksheet', { ...wb, worksheet: name });
+ }
+ },
+ 180_000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/microsoft-onedrive.json b/packages/backend/src/adapters/intl/microsoft-onedrive.json
new file mode 100644
index 000000000..f937b18dc
--- /dev/null
+++ b/packages/backend/src/adapters/intl/microsoft-onedrive.json
@@ -0,0 +1,794 @@
+{
+ "slug": "microsoft-onedrive",
+ "name": "OneDrive (Microsoft 365)",
+ "description": "Let your AI work in OneDrive: browse and search files and folders, see details and who has access, create folders, upload and update text files, copy, rename and move. OneDrive for work or school and personal OneDrive through Microsoft Graph, signed in with your Microsoft account.",
+ "instructions": "## OneDrive\nThis connector works with the signed-in user's OneDrive through **Microsoft Graph v1.0** (`https://graph.microsoft.com/v1.0`, `/me/drive`): browse folders, find files, read details and who has access, create folders, upload and replace small text files, copy, rename and move. It works with OneDrive for work or school (Microsoft 365) and, depending on the app registration, with personal OneDrive. Everything runs as the user who authorized the connector: the AI sees and changes exactly what that user can.\n\n**Setup (one-time, about 10 minutes)**\n1. Open https://entra.microsoft.com (or https://portal.azure.com) → **Applications → App registrations → New registration**.\n2. Name it (e.g. \"AnythingMCP Microsoft 365\") and choose the **supported account types**:\n - *Accounts in this organizational directory only* (single tenant): only your organisation's users can sign in. Set `MICROSOFT_TENANT_ID` to the **Directory (tenant) ID** shown on the app's Overview page (a GUID), or to your domain such as `contoso.onmicrosoft.com`.\n - *Accounts in any organizational directory* (multi-tenant): set `MICROSOFT_TENANT_ID` to `organizations`.\n - *... and personal Microsoft accounts*: set `MICROSOFT_TENANT_ID` to `common` (work, school and personal accounts) or `consumers` (personal accounts only).\n The value must match the app's account type, otherwise Microsoft refuses the sign-in (AADSTS50194 or AADSTS50020).\n3. Under **Redirect URI** pick platform **Web** and enter `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. It must match character for character (AADSTS50011 otherwise).\n4. **API permissions → Add a permission → Microsoft Graph → Delegated permissions**: add `offline_access`, `User.Read`, `Files.ReadWrite`.\n5. **Certificates & secrets → New client secret**. Copy the secret **Value** (not the Secret ID); it is shown once. Note its expiry date: when it expires the connector stops working until you paste a new one.\n6. Paste the **Application (client) ID** into `MICROSOFT_CLIENT_ID`, the secret value into `MICROSOFT_CLIENT_SECRET`, the tenant value from step 2 into `MICROSOFT_TENANT_ID`, and install.\n7. On the connector page click **Authorize with Provider** and sign in with the account whose OneDrive the AI should work with. The refresh token is stored encrypted and access tokens (about 1 hour) are renewed automatically.\n\n**One Entra app for all Microsoft connectors**: the app registration made for the Outlook connector works here as well. Open it, add the delegated permissions from step 4 under **API permissions**, keep the same redirect URI, and reuse the same `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` and `MICROSOFT_TENANT_ID`. Each connector asks only for its own scopes when you authorize it. The permissions per connector:\n- Outlook: `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`\n- OneDrive: `Files.ReadWrite`\n- Excel: `Files.ReadWrite` (plus `Files.ReadWrite.All` for workbooks in SharePoint or shared with the user)\n- SharePoint: `Sites.ReadWrite.All` and `Files.ReadWrite.All`\nEvery connector also needs `offline_access` and `User.Read`.\n\n**Admin consent**: these delegated permissions normally do not need an administrator, but many organisations turn off user consent. If the sign-in shows \"Need admin approval\" (AADSTS65001 or AADSTS90094), a tenant administrator opens the app registration → **API permissions → Grant admin consent for ** once; afterwards every user can authorize. Each connector still works only with what the account that signed in may see.\n\n**Scopes**: `offline_access User.Read Files.ReadWrite`. `offline_access` is what makes Microsoft issue a refresh token; without it the connector stops an hour after authorizing. `Files.ReadWrite` covers the user's own OneDrive. To open files that other people shared with the user, add `Files.ReadWrite.All` to the app registration and replace `Files.ReadWrite` with it in the connector's OAuth settings, then authorize again. For a read-only setup, use `offline_access User.Read Files.Read` (or `Files.Read.All`), authorize again, and disable `onedrive_create_folder`, `onedrive_upload_text_file`, `onedrive_replace_file_content`, `onedrive_copy_item`, `onedrive_rename_item`, `onedrive_move_item`.\n\n**Switched off at install**: `onedrive_delete_item`, `onedrive_create_sharing_link` and `onedrive_invite`. Sharing gives other people access to the user's files, and a document the AI reads could ask it to share something, so turn these on per tool only if the AI should share or delete. Deleted items go to the OneDrive recycle bin and can be restored there.\n\n**Ids and paths**: every file and folder has an `id` (in list and search results). The top folder is `root`. The `_by_path` tools take a path from the top of the OneDrive without a leading slash, e.g. `Documents/Reports 2026`; names containing `#`, `%` or `?` cannot be addressed by path, use the id tools for them. `parentReference` names an item's folder (`id`, `path`) and drive (`driveId`).\n\n**Reading content**: this connector does not download files. Graph answers a download with a redirect to the whole file and does not promise to honour a byte range, so a single large file could exhaust the server. Give the user the item's `webUrl` to open it. For cell values of Excel workbooks use the Microsoft Excel connector, which reads them through the workbook API.\n\n**Writing**\n- `onedrive_upload_text_file` creates a text file in a folder in one call (up to about 4 MB); the extension decides the file type (`.txt`, `.md`, `.csv`, `.json`, `.html`, `.xml`). With `conflict_behavior` `fail` (default) an existing file is never overwritten; `rename` stores the new one as `name 1.txt`.\n- `onedrive_replace_file_content` overwrites an existing text file; the previous content stays in its version history. Office documents, PDFs and images cannot be created or edited here.\n- `onedrive_copy_item` is asynchronous: Graph accepts the request without a body and the copy appears in the target folder shortly after. It needs the target's drive id (`onedrive_get_drive` → `id`) and folder id.\n- `onedrive_move_item` moves within the same OneDrive; `onedrive_rename_item` renames in place.\n\n**Sharing**: `onedrive_list_permissions` shows who has access and which sharing links exist. `onedrive_create_sharing_link` (switched off) makes a view or edit link for `anonymous` (anyone with the link, where the organisation allows it), `organization` or `users`. `onedrive_invite` (switched off) grants named people `read` or `write` access and can email them.\n\n**Search**: `onedrive_search` searches names, metadata and content in the user's own OneDrive. `onedrive_search_shared` also returns files shared with the user; such hits carry `remoteItem`, which `onedrive_get_shared_item` opens. Graph's separate *recent* and *shared with me* lists are deprecated and stop returning data in November 2026, so this connector has no tools for them.\n\n**Pagination**: list and search responses carry `@odata.nextLink` while more results exist. Call the same tool again with the same arguments and `skip_token` set to the `$skiptoken` (or `skipToken`) value in that link. `top` sets the page size.\n\n**Rate limits**: Microsoft Graph throttles per user and per app. A throttled call answers 429 or 503 with Retry-After; wait and retry.\n\n**Common errors**\n- 401 `InvalidAuthenticationToken`: the authorization was revoked or the client secret expired. Paste a new secret if needed and click **Authorize with Provider** again.\n- 403 `accessDenied`: a permission from step 4 is missing or was not consented, or the user may not change that item. A write on a read-only setup also ends here.\n- 404 `itemNotFound`: the id or path is wrong, the item was deleted, or it lives in another drive (use `onedrive_get_shared_item`).\n- 409 `nameAlreadyExists`: an item with that name exists in the folder; pick another name or `conflict_behavior` `rename`.\n- 423 `resourceLocked`: the file is open for editing or checked out. 507 `quotaLimitReached`: the OneDrive is full.\n- 400 `invalidRequest`: often a name with a character OneDrive does not allow (`\" * : < > ? / \\ |`).\n\n**Out of scope here**: downloading file content, uploading binary files or files over 4 MB, restoring versions, emptying the recycle bin, other users' OneDrives and SharePoint document libraries (use the Microsoft SharePoint connector).",
+ "region": "intl",
+ "category": "storage",
+ "icon": "microsoft-onedrive",
+ "docsUrl": "https://learn.microsoft.com/en-us/graph/api/resources/onedrive",
+ "requiredEnvVars": [
+ "MICROSOFT_CLIENT_ID",
+ "MICROSOFT_CLIENT_SECRET",
+ "MICROSOFT_TENANT_ID"
+ ],
+ "appRegistrationUrl": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "envVarMeta": {
+ "MICROSOFT_CLIENT_ID": {
+ "label": "Application (client) ID",
+ "kind": "credential",
+ "help": "Microsoft Entra admin center → App registrations → your app → Overview: Application (client) ID. The app registration made for the Outlook connector works here too.",
+ "link": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "example": "00001111-aaaa-2222-bbbb-3333cccc4444",
+ "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": "The Application (client) ID is a GUID like 00001111-aaaa-2222-bbbb-3333cccc4444."
+ },
+ "MICROSOFT_CLIENT_SECRET": {
+ "label": "Client secret value",
+ "kind": "credential",
+ "help": "Same app → Certificates & secrets → New client secret: copy the Value column (shown once), not the Secret ID.",
+ "link": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
+ },
+ "MICROSOFT_TENANT_ID": {
+ "label": "Tenant",
+ "kind": "setting",
+ "help": "Single-tenant app: the Directory (tenant) ID from the app's Overview page (or your domain, e.g. contoso.onmicrosoft.com). Multi-tenant app: organizations. App that also accepts personal accounts: common (or consumers for personal accounts only).",
+ "example": "organizations",
+ "pattern": "^(common|organizations|consumers|[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}|[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+)$",
+ "patternMessage": "Use a tenant GUID, a verified domain such as contoso.onmicrosoft.com, or one of common, organizations, consumers."
+ }
+ },
+ "verifyHints": {
+ "401": "Microsoft refused the access token. Click Authorize with Provider again; if the client secret has expired, create a new one in the app registration first.",
+ "403": "The app lacks a Graph permission or consent for it. Add Files.ReadWrite and User.Read as delegated permissions, grant admin consent if your organisation requires it, and authorize again.",
+ "404": "Microsoft Graph found no OneDrive for this account. Work or school accounts need a OneDrive licence, and the user must have opened OneDrive once.",
+ "rate_limited": "Microsoft Graph is throttling this account. Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "onedrive_get_drive"
+ },
+ "prerequisites": "An app registration in Microsoft Entra (free, about 10 minutes) with the redirect URI below, the delegated Graph permissions `offline_access`, `User.Read` and `Files.ReadWrite`, and a client secret. The app registration of the Outlook connector can be reused: add the permission to it. Some organisations only let an admin grant consent.",
+ "connector": {
+ "name": "OneDrive (Microsoft Graph)",
+ "type": "REST",
+ "baseUrl": "https://graph.microsoft.com/v1.0",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "clientId": "{{MICROSOFT_CLIENT_ID}}",
+ "clientSecret": "{{MICROSOFT_CLIENT_SECRET}}",
+ "authorizationUrl": "https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/authorize",
+ "tokenUrl": "https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/token",
+ "scopes": "offline_access User.Read Files.ReadWrite"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/me/drive"
+ },
+ "tools": [
+ {
+ "name": "onedrive_get_drive",
+ "description": "Get the signed-in user's OneDrive: drive id, type (personal, business), owner, storage quota (total, used, remaining, deleted, state) and webUrl. The drive id is needed by onedrive_copy_item and by the Microsoft Excel connector.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive",
+ "queryParams": {
+ "$select": "id,driveType,name,owner,quota,webUrl"
+ }
+ }
+ },
+ {
+ "name": "onedrive_list_root",
+ "description": "List the files and folders at the top of the user's OneDrive: id, name, size, dates, who changed it last, folder child count or file mimeType, and webUrl. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "orderby": {
+ "type": "string",
+ "description": "Sort order, e.g. \"name\", \"lastModifiedDateTime desc\" or \"size desc\". OneDrive for work or school sorts only by some properties and answers 400 for others."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/root/children",
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared",
+ "$top": "$top",
+ "$orderby": "$orderby",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "onedrive_list_folder",
+ "description": "List the files and folders inside one folder, addressed by its id. Returns the same fields as onedrive_list_root. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Id of the folder, from a list or search result (or \"root\")."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "orderby": {
+ "type": "string",
+ "description": "Sort order, e.g. \"name\", \"lastModifiedDateTime desc\" or \"size desc\". OneDrive for work or school sorts only by some properties and answers 400 for others."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/items/{item_id}/children",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared",
+ "$top": "$top",
+ "$orderby": "$orderby",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "onedrive_list_folder_by_path",
+ "description": "List the files and folders inside a folder addressed by its path from the top of the OneDrive, e.g. \"Documents/Reports\". Returns the same fields as onedrive_list_root. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path relative to the top of the OneDrive, without a leading slash, e.g. \"Documents/Reports 2026\" or \"Documents/notes.txt\". Names containing #, % or ? cannot be addressed by path: use the id tools."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "orderby": {
+ "type": "string",
+ "description": "Sort order, e.g. \"name\", \"lastModifiedDateTime desc\" or \"size desc\". OneDrive for work or school sorts only by some properties and answers 400 for others."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "path"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/root:/{path}:/children",
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared",
+ "$top": "$top",
+ "$orderby": "$orderby",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "onedrive_get_item",
+ "description": "Get one file's or folder's metadata by id: name, size, dates, creator and last modifier, parent folder (parentReference with driveId, id and path), file mimeType and hashes or folder child count, sharing state and webUrl. Does not return the content.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/items/{item_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,description,createdBy,fileSystemInfo,eTag,cTag,specialFolder,root"
+ }
+ }
+ },
+ {
+ "name": "onedrive_get_item_by_path",
+ "description": "Get one file's or folder's metadata by its path from the top of the OneDrive, e.g. \"Documents/Budget 2026.xlsx\". Returns the same fields as onedrive_get_item, including the id the other tools need.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path relative to the top of the OneDrive, without a leading slash, e.g. \"Documents/Reports 2026\" or \"Documents/notes.txt\". Names containing #, % or ? cannot be addressed by path: use the id tools."
+ }
+ },
+ "required": [
+ "path"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/root:/{path}",
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,description,createdBy,fileSystemInfo,eTag,cTag,specialFolder,root"
+ }
+ }
+ },
+ {
+ "name": "onedrive_get_shared_item",
+ "description": "Get the metadata of an item in another drive, typically a file someone shared with the user: pass remoteItem.parentReference.driveId and remoteItem.id from onedrive_search_shared. Needs Files.Read.All or Files.ReadWrite.All in the connector's scopes.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "The drive the item lives in (remoteItem.parentReference.driveId), e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "The item's id in that drive (remoteItem.id)."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,description,createdBy,fileSystemInfo,eTag,cTag,specialFolder,root"
+ }
+ }
+ },
+ {
+ "name": "onedrive_search",
+ "description": "Search the user's own OneDrive for files and folders whose name, metadata or content matches the text. Returns id, name, size, dates, parentReference and webUrl per hit. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against file names, metadata and content, e.g. \"budget 2026\". Write a single quote as two (''), e.g. \"O''Brien\"."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/root/search(q='{query}')",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,searchResult",
+ "$top": "$top",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "onedrive_search_shared",
+ "description": "Search the user's OneDrive and the files other people shared with the user. Hits stored elsewhere carry remoteItem (with parentReference.driveId and id): open them with onedrive_get_shared_item. Finding shared files needs Files.Read.All or Files.ReadWrite.All in the scopes. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against file names, metadata and content, e.g. \"budget 2026\". Write a single quote as two (''), e.g. \"O''Brien\"."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/search(q='{query}')",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,searchResult",
+ "$top": "$top",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "onedrive_create_folder",
+ "description": "Create a folder inside a parent folder (default: the top of the OneDrive). Returns the new folder's id, name, parentReference and webUrl. With conflict_behavior \"fail\" (default) Graph answers 409 when the name exists.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Folder name. Not allowed: \" * : < > ? / \\ |."
+ },
+ "parent_id": {
+ "type": "string",
+ "description": "Id of the folder: from a list or search result, or \"root\" for the top of the OneDrive.",
+ "default": "root"
+ },
+ "conflict_behavior": {
+ "type": "string",
+ "description": "What to do when an item with that name already exists: \"fail\" (default) or \"rename\" (Graph appends a number).",
+ "enum": [
+ "fail",
+ "rename"
+ ],
+ "default": "fail"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/me/drive/items/{parent_id}/children",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name",
+ "folder": {},
+ "@microsoft.graph.conflictBehavior": "$conflict_behavior"
+ }
+ }
+ },
+ {
+ "name": "onedrive_upload_text_file",
+ "description": "Create a new text file (.txt, .md, .csv, .json, .html, .xml...) with the given content in a folder, in one call, up to about 4 MB. Returns the new file's id, name, size and webUrl. With conflict_behavior \"fail\" (default) an existing file of that name is left alone and Graph answers 409.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "file_name": {
+ "type": "string",
+ "description": "File name with extension, e.g. \"Meeting notes.md\", \"export.csv\". The extension decides the file type. Not allowed: \" * : < > ? / \\ |."
+ },
+ "content": {
+ "type": "string",
+ "description": "The full text content (UTF-8), not empty, at most about 4 MB.",
+ "minLength": 1
+ },
+ "parent_id": {
+ "type": "string",
+ "description": "Id of the folder: from a list or search result, or \"root\" for the top of the OneDrive.",
+ "default": "root"
+ },
+ "conflict_behavior": {
+ "type": "string",
+ "description": "What to do when an item with that name already exists: \"fail\" (default) or \"rename\" (Graph appends a number).",
+ "enum": [
+ "fail",
+ "rename"
+ ],
+ "default": "fail"
+ }
+ },
+ "required": [
+ "file_name",
+ "content"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/me/drive/items/{parent_id}:/{file_name}:/content",
+ "encodePathParams": true,
+ "headers": {
+ "Content-Type": "text/plain"
+ },
+ "queryParams": {
+ "@microsoft.graph.conflictBehavior": "$conflict_behavior"
+ },
+ "bodyMapping": {
+ "__raw": "$content"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ }
+ },
+ {
+ "name": "onedrive_replace_file_content",
+ "description": "Overwrite the content of an existing text file with new text (up to about 4 MB). OneDrive keeps the previous content in the file's version history. Returns the file's id, name, size and webUrl. Only for text files: binary files would be replaced with text.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "content": {
+ "type": "string",
+ "description": "The full text content (UTF-8), not empty, at most about 4 MB.",
+ "minLength": 1
+ }
+ },
+ "required": [
+ "item_id",
+ "content"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/me/drive/items/{item_id}/content",
+ "encodePathParams": true,
+ "headers": {
+ "Content-Type": "text/plain"
+ },
+ "bodyMapping": {
+ "__raw": "$content"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ }
+ },
+ {
+ "name": "onedrive_copy_item",
+ "description": "Copy a file or folder (with its contents) into a folder, optionally under a new name. Runs asynchronously: Graph accepts the request with an empty answer and the copy appears shortly after; check with onedrive_list_folder. Needs the target drive id (onedrive_get_drive) and folder id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "destination_drive_id": {
+ "type": "string",
+ "description": "Drive id of the target folder: the id from onedrive_get_drive for the user's own OneDrive."
+ },
+ "destination_folder_id": {
+ "type": "string",
+ "description": "Id of the target folder, from a list result or onedrive_get_item_by_path. For the top folder, use the real id that onedrive_get_item returns for item_id \"root\"."
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the copy (default: the original's name)."
+ },
+ "conflict_behavior": {
+ "type": "string",
+ "description": "When the name exists in the target: \"fail\" (default) or \"rename\". Personal OneDrive ignores it.",
+ "enum": [
+ "fail",
+ "rename"
+ ]
+ }
+ },
+ "required": [
+ "item_id",
+ "destination_drive_id",
+ "destination_folder_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/me/drive/items/{item_id}/copy",
+ "encodePathParams": true,
+ "queryParams": {
+ "@microsoft.graph.conflictBehavior": "$conflict_behavior"
+ },
+ "bodyMapping": {
+ "parentReference": {
+ "driveId": "$destination_drive_id",
+ "id": "$destination_folder_id"
+ },
+ "name": "$name"
+ }
+ }
+ },
+ {
+ "name": "onedrive_rename_item",
+ "description": "Rename a file or folder in place. Keep the extension for files. Returns the item's id, name, parentReference and webUrl.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "name": {
+ "type": "string",
+ "description": "New name. Not allowed: \" * : < > ? / \\ |."
+ }
+ },
+ "required": [
+ "item_id",
+ "name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/me/drive/items/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ },
+ "annotations": {
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "onedrive_move_item",
+ "description": "Move a file or folder into another folder of the same OneDrive, optionally renaming it on the way. Returns the item's id, name, new parentReference and webUrl.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "new_parent_id": {
+ "type": "string",
+ "description": "Id of the target folder (a list or search result, or onedrive_get_item_by_path)."
+ },
+ "name": {
+ "type": "string",
+ "description": "Optional new name."
+ }
+ },
+ "required": [
+ "item_id",
+ "new_parent_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/me/drive/items/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "parentReference": {
+ "id": "$new_parent_id"
+ },
+ "name": "$name"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ },
+ "annotations": {
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "onedrive_delete_item",
+ "description": "Delete a file or folder (a folder with its contents). It goes to the OneDrive recycle bin and can be restored from there by the user. Installed switched off.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/me/drive/items/{item_id}",
+ "encodePathParams": true
+ },
+ "enabled": false
+ },
+ {
+ "name": "onedrive_list_permissions",
+ "description": "List who has access to a file or folder: each permission's id, roles (read, write, owner), the people or groups granted (grantedToV2, grantedToIdentitiesV2), sharing links (link.type, link.scope, link.webUrl), inheritance and expiry.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/me/drive/items/{item_id}/permissions",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "onedrive_create_sharing_link",
+ "description": "Create a sharing link for a file or folder: type \"view\" or \"edit\", scope \"anonymous\" (anyone with the link, if the organisation allows it), \"organization\" (people in the organisation) or \"users\" (only people already given access). Returns the link with its webUrl. Installed switched off: a link gives other people access to the user's files.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "type": {
+ "type": "string",
+ "description": "Link type.",
+ "enum": [
+ "view",
+ "edit"
+ ]
+ },
+ "scope": {
+ "type": "string",
+ "description": "Who the link works for. Personal OneDrive supports only anonymous.",
+ "enum": [
+ "anonymous",
+ "organization",
+ "users"
+ ]
+ },
+ "expiration_date_time": {
+ "type": "string",
+ "description": "Optional ISO 8601 expiry, e.g. 2026-12-31T23:00:00Z (where the organisation allows expiring links)."
+ },
+ "password": {
+ "type": "string",
+ "description": "Optional password for an anonymous link (personal OneDrive and where the organisation allows it)."
+ }
+ },
+ "required": [
+ "item_id",
+ "type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/me/drive/items/{item_id}/createLink",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "type": "$type",
+ "scope": "$scope",
+ "expirationDateTime": "$expiration_date_time",
+ "password": "$password"
+ }
+ },
+ "enabled": false
+ },
+ {
+ "name": "onedrive_invite",
+ "description": "Give people access to a file or folder by email address, with role \"read\" or \"write\", and optionally send them an invitation email with a message. Returns the permissions created. Installed switched off: inviting gives other people access to the user's files.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result (onedrive_list_root, onedrive_list_folder, onedrive_search...). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "recipients": {
+ "type": "array",
+ "description": "People to invite, e.g. [{\"email\": \"ana@example.com\"}].",
+ "items": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string",
+ "description": "Email address."
+ }
+ },
+ "required": [
+ "email"
+ ]
+ }
+ },
+ "roles": {
+ "type": "array",
+ "description": "[\"read\"] or [\"write\"].",
+ "items": {
+ "type": "string",
+ "enum": [
+ "read",
+ "write"
+ ]
+ }
+ },
+ "send_invitation": {
+ "type": "boolean",
+ "description": "true to email the people (default true in Graph); false to grant access silently (work or school only)."
+ },
+ "require_sign_in": {
+ "type": "boolean",
+ "description": "true (recommended) so the recipients must sign in to open the item."
+ },
+ "message": {
+ "type": "string",
+ "description": "Optional message in the invitation email."
+ },
+ "expiration_date_time": {
+ "type": "string",
+ "description": "Optional ISO 8601 time when the access ends."
+ }
+ },
+ "required": [
+ "item_id",
+ "recipients",
+ "roles"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/me/drive/items/{item_id}/invite",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "recipients": "$recipients",
+ "roles": "$roles",
+ "sendInvitation": "$send_invitation",
+ "requireSignIn": "$require_sign_in",
+ "message": "$message",
+ "expirationDateTime": "$expiration_date_time"
+ }
+ },
+ "enabled": false
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/microsoft-onedrive.live.spec.ts b/packages/backend/src/adapters/intl/microsoft-onedrive.live.spec.ts
new file mode 100644
index 000000000..7f7ebd194
--- /dev/null
+++ b/packages/backend/src/adapters/intl/microsoft-onedrive.live.spec.ts
@@ -0,0 +1,390 @@
+import axios from 'axios';
+import * as adapter from './microsoft-onedrive.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { applyResponseTransform, validateTransform } from '../../connectors/response-transform.util';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the OneDrive (Microsoft Graph) adapter:
+ *
+ * 1. Static: always runs. Pins the Entra OAuth setup shared with Outlook
+ * (tenant in both endpoints, offline_access), the fixed `$select` that
+ * keeps item lists small and leaves the pre-authenticated download URL
+ * out, the text upload sent as a raw `text/plain` body that never
+ * overwrites by default, the copy/move bodies Graph documents, and which
+ * tools install switched off (delete, sharing link, invite).
+ *
+ * 2. Live: skipped unless MS_GRAPH_ACCESS_TOKEN is set (a delegated Graph
+ * token with Files.ReadWrite, e.g. from Graph Explorer):
+ *
+ * MS_GRAPH_ACCESS_TOKEN=eyJ... npx jest src/adapters/intl/microsoft-onedrive.live.spec.ts
+ *
+ * With ONEDRIVE_LIVE_WRITE=1 as well, an "AnythingMCP test" folder is
+ * created at the top of the OneDrive with a text file in it, the file is
+ * replaced, renamed, copied and moved; the folder is deleted (to the
+ * recycle bin) in `finally`. Nothing is shared.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ // The outbound helper also calls axios.getUri, getAdapter and friends:
+ // keep every real static, only the call itself is mocked.
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ encodePathParams?: boolean;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ responseMapping?: Record;
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ appRegistrationUrl: string;
+ requiredEnvVars: string[];
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const BASE = 'https://graph.microsoft.com/v1.0';
+const newEngine = () =>
+ new RestEngine(
+ { getAccessToken: jest.fn().mockResolvedValue('test-token') } as unknown as OAuth2TokenService,
+ {} as LoginTokenService,
+ );
+const connectorConfig = () => ({
+ baseUrl: a.connector.baseUrl,
+ authType: a.connector.authType,
+ authConfig: { ...a.connector.authConfig },
+ headers: { ...a.connector.headers },
+});
+// Arguments as DynamicMcpTools hands them to the engine: schema defaults filled in.
+const call = (name: string, params: Record) =>
+ newEngine().execute(connectorConfig(), tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params));
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('microsoft-onedrive adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 10 Oct 2026), and says up front that it needs an Entra app', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/Entra/);
+ expect(a.prerequisites).toMatch(/Outlook connector can be reused/);
+ expect(a.appRegistrationUrl).toMatch(/^https:\/\/entra\.microsoft\.com\//);
+ });
+
+ it('signs in against the configured tenant with the Outlook variables and asks for a refresh token', () => {
+ expect(a.connector.authType).toBe('OAUTH2');
+ expect(a.requiredEnvVars).toEqual(['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET', 'MICROSOFT_TENANT_ID']);
+ expect(a.connector.authConfig).toEqual({
+ clientId: '{{MICROSOFT_CLIENT_ID}}',
+ clientSecret: '{{MICROSOFT_CLIENT_SECRET}}',
+ authorizationUrl: 'https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/authorize',
+ tokenUrl: 'https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/token',
+ scopes: 'offline_access User.Read Files.ReadWrite',
+ });
+ expect(a.connector.headers['User-Agent']).toBe('AnythingMCP');
+ expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback');
+ expect(a.instructions).toMatch(/One Entra app for all Microsoft connectors/);
+ expect(a.instructions).toMatch(/read-only setup[^\n]*Files\.Read/);
+ });
+
+ it('probes with /me/drive, which needs no arguments', () => {
+ expect(a.probe.tool).toBe('onedrive_get_drive');
+ expect(tool('onedrive_get_drive').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/me/drive');
+ });
+
+ it('prefixes every tool with onedrive_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^onedrive_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('percent-encodes ids in the path, but not paths, whose slashes must stay', () => {
+ for (const t of a.tools) {
+ const p = t.endpointMapping.path;
+ if (/\{path\}/.test(p)) expect(`${t.name}:${t.endpointMapping.encodePathParams}`).toBe(`${t.name}:undefined`);
+ else if (/\{\w+\}/.test(p)) expect(`${t.name}:${t.endpointMapping.encodePathParams}`).toBe(`${t.name}:true`);
+ }
+ });
+
+ it('installs delete, sharing link and invite switched off', () => {
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual([
+ 'onedrive_create_sharing_link',
+ 'onedrive_delete_item',
+ 'onedrive_invite',
+ ]);
+ expect(a.instructions).toMatch(/Switched off at install\*\*: `onedrive_delete_item`, `onedrive_create_sharing_link` and `onedrive_invite`/);
+ expect(annotationsOf(tool('onedrive_delete_item')).destructiveHint).toBe(true);
+ });
+
+ it('never downloads content and keeps the pre-authenticated download URL out of answers', () => {
+ // GET .../content would follow a redirect to the whole file, unbounded.
+ for (const t of a.tools) {
+ if (/\/content$/.test(t.endpointMapping.path)) expect(t.endpointMapping.method).toBe('PUT');
+ }
+ for (const t of a.tools.filter((x) => x.endpointMapping.method === 'GET')) {
+ const select = String(t.endpointMapping.queryParams?.$select ?? '');
+ if (t.name !== 'onedrive_list_permissions') expect(`${t.name}:${select.length > 0}`).toBe(`${t.name}:true`);
+ expect(select).not.toMatch(/downloadUrl/);
+ }
+ for (const name of ['onedrive_upload_text_file', 'onedrive_replace_file_content', 'onedrive_rename_item', 'onedrive_move_item']) {
+ const mapping = tool(name).responseMapping!;
+ expect(validateTransform(mapping.transform)).toBeNull();
+ const out = applyResponseTransform(
+ { id: '01ABC', name: 'a.txt', '@microsoft.graph.downloadUrl': 'https://public.example/secret' },
+ mapping,
+ ).value as Record;
+ expect(out).toEqual({ id: '01ABC', name: 'a.txt' });
+ }
+ });
+
+ it('marks reads read-only and rename or move as non-destructive', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'onedrive_get_drive',
+ 'onedrive_get_item',
+ 'onedrive_get_item_by_path',
+ 'onedrive_get_shared_item',
+ 'onedrive_list_folder',
+ 'onedrive_list_folder_by_path',
+ 'onedrive_list_permissions',
+ 'onedrive_list_root',
+ 'onedrive_search',
+ 'onedrive_search_shared',
+ ]);
+ expect(annotationsOf(tool('onedrive_rename_item')).destructiveHint).toBe(false);
+ expect(annotationsOf(tool('onedrive_move_item')).destructiveHint).toBe(false);
+ expect(annotationsOf(tool('onedrive_replace_file_content')).destructiveHint).toBe(true);
+ expect(annotationsOf(tool('onedrive_upload_text_file')).destructiveHint).toBe(false);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bonedrive_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bonedrive_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(5);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ it.each([
+ ['onedrive_get_drive', {}, 'GET', '/me/drive'],
+ ['onedrive_list_root', {}, 'GET', '/me/drive/root/children'],
+ ['onedrive_list_folder', { item_id: 'D4648F06C91D9D3D!54927' }, 'GET', '/me/drive/items/D4648F06C91D9D3D!54927/children'],
+ ['onedrive_list_folder_by_path', { path: 'Documents/Reports' }, 'GET', '/me/drive/root:/Documents/Reports:/children'],
+ ['onedrive_get_item', { item_id: '01BYE5RZ' }, 'GET', '/me/drive/items/01BYE5RZ'],
+ ['onedrive_get_item_by_path', { path: 'Documents/notes.txt' }, 'GET', '/me/drive/root:/Documents/notes.txt'],
+ ['onedrive_get_shared_item', { drive_id: 'b!x-y_z', item_id: '01AB' }, 'GET', '/drives/b!x-y_z/items/01AB'],
+ ['onedrive_search', { query: 'budget 2026' }, 'GET', "/me/drive/root/search(q='budget%202026')"],
+ ['onedrive_search_shared', { query: 'plan' }, 'GET', "/me/drive/search(q='plan')"],
+ ['onedrive_create_folder', { name: 'F' }, 'POST', '/me/drive/items/root/children'],
+ ['onedrive_upload_text_file', { file_name: 'notes.md', content: 'x' }, 'PUT', '/me/drive/items/root:/notes.md:/content'],
+ ['onedrive_replace_file_content', { item_id: '01AB', content: 'x' }, 'PUT', '/me/drive/items/01AB/content'],
+ ['onedrive_copy_item', { item_id: '01AB', destination_drive_id: 'b!1', destination_folder_id: '01CD' }, 'POST', '/me/drive/items/01AB/copy'],
+ ['onedrive_rename_item', { item_id: '01AB', name: 'n.txt' }, 'PATCH', '/me/drive/items/01AB'],
+ ['onedrive_move_item', { item_id: '01AB', new_parent_id: '01CD' }, 'PATCH', '/me/drive/items/01AB'],
+ ['onedrive_delete_item', { item_id: '01AB' }, 'DELETE', '/me/drive/items/01AB'],
+ ['onedrive_list_permissions', { item_id: '01AB' }, 'GET', '/me/drive/items/01AB/permissions'],
+ ['onedrive_create_sharing_link', { item_id: '01AB', type: 'view' }, 'POST', '/me/drive/items/01AB/createLink'],
+ ['onedrive_invite', { item_id: '01AB', recipients: [{ email: 'a@b.c' }], roles: ['read'] }, 'POST', '/me/drive/items/01AB/invite'],
+ ])('%s sends %s to the right Graph URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method,
+ url: `${BASE}${path}`,
+ headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
+ }),
+ );
+ });
+
+ it('lists with a fixed $select and passes paging through', async () => {
+ mockedAxios.mockResolvedValue({ data: { value: [] } });
+ await call('onedrive_list_root', { top: 50, skip_token: 'abc', orderby: 'name' });
+ expect(sent().params).toMatchObject({ $top: 50, $skiptoken: 'abc', $orderby: 'name' });
+ expect(sent().params.$select).toMatch(/^id,name,size,webUrl,/);
+ });
+
+ it('uploads the text as a raw text/plain body and refuses to overwrite by default', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ const content = '{"a": 1}\n indented, Grüße 👋\n';
+ await call('onedrive_upload_text_file', { file_name: 'data.json', content, parent_id: '01CD' });
+ expect(sent().url).toBe(`${BASE}/me/drive/items/01CD:/data.json:/content`);
+ expect(sent().headers['Content-Type']).toBe('text/plain');
+ expect(sent().data).toBe(content);
+ expect(sent().params).toEqual({ '@microsoft.graph.conflictBehavior': 'fail' });
+ expect((tool('onedrive_upload_text_file').parameters.properties!.conflict_behavior.enum as string[]).sort()).toEqual(['fail', 'rename']);
+ });
+
+ it('percent-encodes a file name with spaces in the upload path', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('onedrive_upload_text_file', { file_name: 'Meeting notes #3.md', content: 'x' });
+ expect(sent().url).toBe(`${BASE}/me/drive/items/root:/Meeting%20notes%20%233.md:/content`);
+ });
+
+ it('creates folders with the folder facet and the conflict behavior', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('onedrive_create_folder', { name: 'Reports', parent_id: '01CD' });
+ expect(sent().data).toEqual({ name: 'Reports', folder: {}, '@microsoft.graph.conflictBehavior': 'fail' });
+ });
+
+ it('copies with a parentReference naming drive and folder, and moves with the new parent', async () => {
+ mockedAxios.mockResolvedValue({ data: '' });
+ await call('onedrive_copy_item', { item_id: '01AB', destination_drive_id: 'b!1', destination_folder_id: '01CD', name: 'copy.txt' });
+ expect(sent().data).toEqual({ parentReference: { driveId: 'b!1', id: '01CD' }, name: 'copy.txt' });
+ expect(sent().params).toEqual({});
+ mockedAxios.mockClear();
+ await call('onedrive_move_item', { item_id: '01AB', new_parent_id: '01CD' });
+ expect(sent().data).toEqual({ parentReference: { id: '01CD' } });
+ mockedAxios.mockClear();
+ await call('onedrive_rename_item', { item_id: '01AB', name: 'n.txt' });
+ expect(sent().data).toEqual({ name: 'n.txt' });
+ });
+
+ it('builds the sharing link and invite bodies Graph documents', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('onedrive_create_sharing_link', { item_id: '01AB', type: 'view', scope: 'organization' });
+ expect(sent().data).toEqual({ type: 'view', scope: 'organization' });
+ mockedAxios.mockClear();
+ await call('onedrive_invite', {
+ item_id: '01AB',
+ recipients: [{ email: 'ana@example.com' }],
+ roles: ['write'],
+ require_sign_in: true,
+ send_invitation: false,
+ });
+ expect(sent().data).toEqual({
+ recipients: [{ email: 'ana@example.com' }],
+ roles: ['write'],
+ requireSignIn: true,
+ sendInvitation: false,
+ });
+ });
+});
+
+const TOKEN = process.env.MS_GRAPH_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('microsoft-onedrive adapter: live Graph API', () => {
+ beforeAll(() => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ });
+ const run = (name: string, params: Record = {}): Promise =>
+ new RestEngine({} as OAuth2TokenService, {} as LoginTokenService)
+ .execute(
+ { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ )
+ .then((raw) => applyResponseTransform(raw, tool(name).responseMapping).value);
+
+ it('reads the drive, its top folder, one item and its permissions', async () => {
+ const drive = await run('onedrive_get_drive');
+ expect(typeof drive.id).toBe('string');
+ const root = await run('onedrive_list_root', { top: 5 });
+ expect(Array.isArray(root.value)).toBe(true);
+ const rootItem = await run('onedrive_get_item', { item_id: 'root' });
+ expect(rootItem.root).toBeDefined();
+ if (root.value.length) {
+ const item = await run('onedrive_get_item', { item_id: root.value[0].id });
+ expect(item.id).toBe(root.value[0].id);
+ expect(item['@microsoft.graph.downloadUrl']).toBeUndefined();
+ const perms = await run('onedrive_list_permissions', { item_id: item.id });
+ expect(Array.isArray(perms.value)).toBe(true);
+ }
+ }, 60_000);
+
+ it('searches the OneDrive and what is shared with the user', async () => {
+ const mine = await run('onedrive_search', { query: 'a', top: 3 });
+ expect(Array.isArray(mine.value)).toBe(true);
+ const shared = await run('onedrive_search_shared', { query: 'a', top: 3 });
+ expect(Array.isArray(shared.value)).toBe(true);
+ }, 60_000);
+
+ (process.env.ONEDRIVE_LIVE_WRITE === '1' ? it : it.skip)(
+ 'creates a test folder with a text file, replaces, renames, copies and moves it, then deletes the folder',
+ async () => {
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const drive = await run('onedrive_get_drive');
+ const folder = await run('onedrive_create_folder', { name: `AnythingMCP test ${stamp}` });
+ expect(folder.folder).toBeDefined();
+ try {
+ const sub = await run('onedrive_create_folder', { name: 'sub', parent_id: folder.id });
+ const body = 'AnythingMCP test: Grüße, perché 👋\nline 2\n';
+ const file = await run('onedrive_upload_text_file', { file_name: 'AnythingMCP test.txt', content: body, parent_id: folder.id });
+ expect(file.name).toBe('AnythingMCP test.txt');
+ expect(file['@microsoft.graph.downloadUrl']).toBeUndefined();
+ await expect(
+ run('onedrive_upload_text_file', { file_name: 'AnythingMCP test.txt', content: 'again', parent_id: folder.id }),
+ ).rejects.toMatchObject({ response: { status: 409 } });
+
+ const replaced = await run('onedrive_replace_file_content', { item_id: file.id, content: 'replaced\n' });
+ expect(replaced.size).toBe(9);
+ const byPath = await run('onedrive_get_item_by_path', { path: `AnythingMCP test ${stamp}/AnythingMCP test.txt` });
+ expect(byPath.id).toBe(file.id);
+
+ const renamed = await run('onedrive_rename_item', { item_id: file.id, name: 'AnythingMCP renamed.txt' });
+ expect(renamed.name).toBe('AnythingMCP renamed.txt');
+ await run('onedrive_copy_item', {
+ item_id: file.id,
+ destination_drive_id: drive.id,
+ destination_folder_id: folder.id,
+ name: 'AnythingMCP copy.txt',
+ });
+ const moved = await run('onedrive_move_item', { item_id: file.id, new_parent_id: sub.id });
+ expect(moved.parentReference.id).toBe(sub.id);
+ const listed = await run('onedrive_list_folder', { item_id: sub.id });
+ expect(listed.value.map((i: { name: string }) => i.name)).toContain('AnythingMCP renamed.txt');
+ } finally {
+ await run('onedrive_delete_item', { item_id: folder.id });
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/microsoft-sharepoint.json b/packages/backend/src/adapters/intl/microsoft-sharepoint.json
new file mode 100644
index 000000000..db46f18e0
--- /dev/null
+++ b/packages/backend/src/adapters/intl/microsoft-sharepoint.json
@@ -0,0 +1,1211 @@
+{
+ "slug": "microsoft-sharepoint",
+ "name": "SharePoint (Microsoft 365)",
+ "description": "Let your AI work in SharePoint: find sites, browse and search document libraries, see file details and who has access, read, create and update list items, create lists, folders and text files, read site pages, and search everything with Microsoft Search. Microsoft 365 through Microsoft Graph, signed in with your work account.",
+ "instructions": "## SharePoint\nThis connector works with SharePoint Online through **Microsoft Graph v1.0** (`https://graph.microsoft.com/v1.0`): find sites, browse and search document libraries, read file details and who has access, read and write list items, create lists, folders and small text files, and read modern pages. It works with Microsoft 365 work or school accounts (personal Microsoft accounts have no SharePoint). Everything runs as the user who authorized the connector: the AI sees and changes exactly what that user can in SharePoint.\n\n**Setup (one-time, about 10 minutes)**\n1. Open https://entra.microsoft.com (or https://portal.azure.com) → **Applications → App registrations → New registration**.\n2. Name it (e.g. \"AnythingMCP Microsoft 365\") and choose the **supported account types**:\n - *Accounts in this organizational directory only* (single tenant): only your organisation's users can sign in. Set `MICROSOFT_TENANT_ID` to the **Directory (tenant) ID** shown on the app's Overview page (a GUID), or to your domain such as `contoso.onmicrosoft.com`.\n - *Accounts in any organizational directory* (multi-tenant): set `MICROSOFT_TENANT_ID` to `organizations`.\n - *... and personal Microsoft accounts*: set `MICROSOFT_TENANT_ID` to `common` (work, school and personal accounts) or `consumers` (personal accounts only).\n The value must match the app's account type, otherwise Microsoft refuses the sign-in (AADSTS50194 or AADSTS50020).\n3. Under **Redirect URI** pick platform **Web** and enter `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. It must match character for character (AADSTS50011 otherwise).\n4. **API permissions → Add a permission → Microsoft Graph → Delegated permissions**: add `offline_access`, `User.Read`, `Sites.ReadWrite.All`, `Files.ReadWrite.All`.\n5. **Certificates & secrets → New client secret**. Copy the secret **Value** (not the Secret ID); it is shown once. Note its expiry date: when it expires the connector stops working until you paste a new one.\n6. Paste the **Application (client) ID** into `MICROSOFT_CLIENT_ID`, the secret value into `MICROSOFT_CLIENT_SECRET`, the tenant value from step 2 into `MICROSOFT_TENANT_ID`, and install.\n7. On the connector page click **Authorize with Provider** and sign in with the account whose SharePoint sites the AI should work with. The refresh token is stored encrypted and access tokens (about 1 hour) are renewed automatically.\n\n**One Entra app for all Microsoft connectors**: the app registration made for the Outlook connector works here as well. Open it, add the delegated permissions from step 4 under **API permissions**, keep the same redirect URI, and reuse the same `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` and `MICROSOFT_TENANT_ID`. Each connector asks only for its own scopes when you authorize it. The permissions per connector:\n- Outlook: `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`\n- OneDrive: `Files.ReadWrite`\n- Excel: `Files.ReadWrite` (plus `Files.ReadWrite.All` for workbooks in SharePoint or shared with the user)\n- SharePoint: `Sites.ReadWrite.All` and `Files.ReadWrite.All`\nEvery connector also needs `offline_access` and `User.Read`.\n\n**Admin consent**: these delegated permissions normally do not need an administrator, but many organisations turn off user consent. If the sign-in shows \"Need admin approval\" (AADSTS65001 or AADSTS90094), a tenant administrator opens the app registration → **API permissions → Grant admin consent for ** once; afterwards every user can authorize. Each connector still works only with what the account that signed in may see.\n\n**Scopes**: `offline_access User.Read Sites.ReadWrite.All Files.ReadWrite.All`. `offline_access` is what makes Microsoft issue a refresh token. The *.All* in these delegated permissions means \"every site the signed-in user can open\", never more than the user's own SharePoint access. For a read-only setup, use `offline_access User.Read Sites.Read.All Files.Read.All` in the connector's OAuth settings (and add those two permissions to the app registration), authorize again, and disable `sharepoint_create_list_item`, `sharepoint_update_list_item`, `sharepoint_create_list`, `sharepoint_create_folder`, `sharepoint_upload_text_file`, `sharepoint_rename_drive_item`, `sharepoint_move_drive_item`.\n\n**Switched off at install**: `sharepoint_delete_list_item`, `sharepoint_delete_drive_item`, `sharepoint_create_sharing_link` and `sharepoint_invite`. Sharing gives other people access to the organisation's files, and a document the AI reads could ask it to share something, so turn these on per tool only if the AI should share or delete. Deleted items go to the site's recycle bin.\n\n**Finding your way**\n1. Site: `sharepoint_search_sites` by keyword, `sharepoint_get_site_by_path` from an address (https://contoso.sharepoint.com/sites/Marketing → hostname `contoso.sharepoint.com`, site_path `sites/Marketing`), or `sharepoint_get_root_site`. A site id looks like `contoso.sharepoint.com,,`.\n2. Files: `sharepoint_list_drives` lists a site's document libraries (\"Documents\" is the default one); then `sharepoint_list_drive_root`, `sharepoint_list_folder`, `sharepoint_search_drive` and `sharepoint_get_drive_item` with that drive id.\n3. Lists: `sharepoint_list_lists`, then `sharepoint_list_columns` for the internal column names, then `sharepoint_list_items`.\n4. Across everything: `sharepoint_search` uses Microsoft Search (KQL) over all sites, files and list items the user can see.\n\n**List items**: values live in `fields`, keyed by the column's internal name, which can differ from the name shown (a column shown as \"Due date\" may be `DueDate` or `Due_x0020_date`). Filter with `fields/`, e.g. `fields/Status eq 'Open'`. On lists over 5,000 items, filter and sort only on indexed columns; otherwise SharePoint refuses the query. Choice columns take one of their choices; lookup and person columns are written as `LookupId` with the id; dates as ISO 8601 in UTC.\n\n**Files**: this connector does not download file content (Graph answers with a redirect to the whole file, whose size AnythingMCP cannot cap); give the user `webUrl`. `sharepoint_upload_text_file` creates text files up to about 4 MB, never overwriting with `conflict_behavior` `fail` (default). For cell values of Excel workbooks in a library, use the Microsoft Excel connector with the library's drive id.\n\n**Pages**: `sharepoint_list_pages` and `sharepoint_get_page` read modern site pages; text web parts carry HTML in `innerHtml`. Classic pages, news posting and page editing are not covered.\n\n**Pagination**: responses carry `@odata.nextLink` while more results exist. Call the same tool again with the same arguments and `skip_token` set to the `$skiptoken` value in that link. `sharepoint_search` pages with `from` and `size` instead and reports `moreResultsAvailable`.\n\n**Rate limits**: SharePoint throttles per user and per app and answers 429 or 503 with Retry-After; wait and retry, and prefer filtered list queries over reading whole lists.\n\n**Common errors**\n- 401 `InvalidAuthenticationToken`: the authorization was revoked or the client secret expired. Paste a new secret if needed and click **Authorize with Provider** again.\n- 403 `accessDenied`: a permission from step 4 is missing or was not consented, or the user has no access to that site, library or list.\n- 404 `itemNotFound`: wrong site, drive, list or item id, or a site path that does not exist. Personal Microsoft accounts get 404 or 400 for every SharePoint call.\n- 400 `invalidRequest` on list items: a column name in `fields` or `filter` is not an internal name, or a value does not fit the column type. 400 with \"cannot be referenced in filter or orderby as it is not indexed\": index the column in the list settings, or filter on another column.\n- 409 `nameAlreadyExists`: an item with that name exists in the folder.\n\n**Out of scope here**: downloading file content, binary uploads, site creation and site permissions, list and column deletion, page editing, term store and content types.",
+ "region": "intl",
+ "category": "storage",
+ "icon": "microsoft-sharepoint",
+ "docsUrl": "https://learn.microsoft.com/en-us/graph/api/resources/sharepoint",
+ "requiredEnvVars": [
+ "MICROSOFT_CLIENT_ID",
+ "MICROSOFT_CLIENT_SECRET",
+ "MICROSOFT_TENANT_ID"
+ ],
+ "appRegistrationUrl": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "envVarMeta": {
+ "MICROSOFT_CLIENT_ID": {
+ "label": "Application (client) ID",
+ "kind": "credential",
+ "help": "Microsoft Entra admin center → App registrations → your app → Overview: Application (client) ID. The app registration made for the Outlook connector works here too.",
+ "link": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "example": "00001111-aaaa-2222-bbbb-3333cccc4444",
+ "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": "The Application (client) ID is a GUID like 00001111-aaaa-2222-bbbb-3333cccc4444."
+ },
+ "MICROSOFT_CLIENT_SECRET": {
+ "label": "Client secret value",
+ "kind": "credential",
+ "help": "Same app → Certificates & secrets → New client secret: copy the Value column (shown once), not the Secret ID.",
+ "link": "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
+ },
+ "MICROSOFT_TENANT_ID": {
+ "label": "Tenant",
+ "kind": "setting",
+ "help": "Single-tenant app: the Directory (tenant) ID from the app's Overview page (or your domain, e.g. contoso.onmicrosoft.com). Multi-tenant app: organizations. App that also accepts personal accounts: common (or consumers for personal accounts only).",
+ "example": "organizations",
+ "pattern": "^(common|organizations|consumers|[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}|[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+)$",
+ "patternMessage": "Use a tenant GUID, a verified domain such as contoso.onmicrosoft.com, or one of common, organizations, consumers."
+ }
+ },
+ "verifyHints": {
+ "401": "Microsoft refused the access token. Click Authorize with Provider again; if the client secret has expired, create a new one in the app registration first.",
+ "403": "The app lacks a Graph permission or consent for it. Add Sites.ReadWrite.All, Files.ReadWrite.All and User.Read as delegated permissions, grant admin consent if your organisation requires it, and authorize again.",
+ "404": "Microsoft Graph found no SharePoint for this account. SharePoint needs a Microsoft 365 work or school account; personal Microsoft accounts have none.",
+ "rate_limited": "SharePoint is throttling this account. Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "sharepoint_get_root_site"
+ },
+ "prerequisites": "A Microsoft 365 work or school account and an app registration in Microsoft Entra (free, about 10 minutes) with the redirect URI below, the delegated Graph permissions `offline_access`, `User.Read`, `Sites.ReadWrite.All` and `Files.ReadWrite.All`, and a client secret. The app registration of the Outlook connector can be reused: add the permissions to it. Some organisations only let an admin grant consent.",
+ "connector": {
+ "name": "SharePoint (Microsoft Graph)",
+ "type": "REST",
+ "baseUrl": "https://graph.microsoft.com/v1.0",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "clientId": "{{MICROSOFT_CLIENT_ID}}",
+ "clientSecret": "{{MICROSOFT_CLIENT_SECRET}}",
+ "authorizationUrl": "https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/authorize",
+ "tokenUrl": "https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/token",
+ "scopes": "offline_access User.Read Sites.ReadWrite.All Files.ReadWrite.All"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/sites/root"
+ },
+ "tools": [
+ {
+ "name": "sharepoint_get_root_site",
+ "description": "Get the organisation's root SharePoint site (e.g. https://contoso.sharepoint.com): its id, name, displayName, webUrl and hostname (siteCollection.hostname). A cheap check that SharePoint is reachable; the hostname is what sharepoint_get_site_by_path needs.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/root",
+ "queryParams": {
+ "$select": "id,name,displayName,description,webUrl,createdDateTime,lastModifiedDateTime,siteCollection"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_search_sites",
+ "description": "Find SharePoint sites the user can open by keyword in their name or description, e.g. \"marketing\". \"*\" lists all sites the user can reach. Returns id, name, displayName, webUrl and description. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Keyword, e.g. \"finance\", or \"*\" for all sites."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites",
+ "queryParams": {
+ "search": "$query",
+ "$top": "$top",
+ "$skiptoken": "$skip_token",
+ "$select": "id,name,displayName,description,webUrl,createdDateTime,lastModifiedDateTime,siteCollection"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_get_site_by_path",
+ "description": "Get a site from its address: hostname plus server-relative path, as in https://contoso.sharepoint.com/sites/Marketing → hostname \"contoso.sharepoint.com\", site_path \"sites/Marketing\". Returns the site id the other tools need.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "hostname": {
+ "type": "string",
+ "description": "SharePoint hostname, e.g. contoso.sharepoint.com (sharepoint_get_root_site → siteCollection.hostname)."
+ },
+ "site_path": {
+ "type": "string",
+ "description": "Server-relative path of the site without a leading slash, e.g. \"sites/Marketing\" or \"teams/Project-X\"."
+ }
+ },
+ "required": [
+ "hostname",
+ "site_path"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{hostname}:/{site_path}",
+ "queryParams": {
+ "$select": "id,name,displayName,description,webUrl,createdDateTime,lastModifiedDateTime,siteCollection"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_get_site",
+ "description": "Get one site by id: name, displayName, description, webUrl, dates and hostname.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ }
+ },
+ "required": [
+ "site_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,displayName,description,webUrl,createdDateTime,lastModifiedDateTime,siteCollection"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_list_subsites",
+ "description": "List the subsites of a site: id, name, displayName, webUrl. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "site_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/sites",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,displayName,description,webUrl,createdDateTime,lastModifiedDateTime,siteCollection",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_list_drives",
+ "description": "List the document libraries (drives) of a site, e.g. \"Documents\" (Shared Documents) and other libraries: id, name, description, driveType, webUrl and quota. The drive id is what the file tools need.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ }
+ },
+ "required": [
+ "site_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/drives",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,description,driveType,webUrl,createdDateTime,lastModifiedDateTime,quota"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_list_drive_root",
+ "description": "List the files and folders at the top level of a document library: id, name, size, dates, last modifier, file mimeType or folder child count, webUrl. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "orderby": {
+ "type": "string",
+ "description": "Sort order, e.g. \"name\", \"lastModifiedDateTime desc\" or \"size desc\". OneDrive for work or school sorts only by some properties and answers 400 for others."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "drive_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/root/children",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared",
+ "$top": "$top",
+ "$orderby": "$orderby",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_list_folder",
+ "description": "List the files and folders inside one folder of a document library. Returns the same fields as sharepoint_list_drive_root. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Id of the folder in that library."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "orderby": {
+ "type": "string",
+ "description": "Sort order, e.g. \"name\", \"lastModifiedDateTime desc\" or \"size desc\". OneDrive for work or school sorts only by some properties and answers 400 for others."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/children",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared",
+ "$top": "$top",
+ "$orderby": "$orderby",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_search_drive",
+ "description": "Search one document library for files and folders whose name, metadata or content matches the text. Returns id, name, size, dates, parentReference and webUrl per hit. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "query": {
+ "type": "string",
+ "description": "Search text, matched against file names, metadata and content, e.g. \"budget 2026\". Write a single quote as two (''), e.g. \"O''Brien\"."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "drive_id",
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/root/search(q='{query}')",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,searchResult",
+ "$top": "$top",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_get_drive_item",
+ "description": "Get one file's or folder's metadata in a document library: name, size, dates, creator and last modifier, parentReference, file mimeType or folder child count, webUrl. Does not return the content.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,size,webUrl,createdDateTime,lastModifiedDateTime,lastModifiedBy,parentReference,file,folder,package,remoteItem,shared,description,createdBy,fileSystemInfo,eTag,cTag,specialFolder,root"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_search",
+ "description": "Search everything the user can see across SharePoint and OneDrive with Microsoft Search: files (driveItem), list items (listItem), lists (list), sites (site) or libraries (drive). Supports KQL, e.g. \"budget filetype:xlsx\", \"author:\\\"Ana Diaz\\\"\", \"path:\\\"https://contoso.sharepoint.com/sites/Finance\\\"\". Returns hitsContainers with hits (summary, resource with id, name, webUrl, parentReference). Page with from and size.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Search text or KQL, e.g. \"quarterly report filetype:docx\"."
+ },
+ "entity_type": {
+ "type": "string",
+ "description": "What to search for (default driveItem: files and folders).",
+ "enum": [
+ "driveItem",
+ "listItem",
+ "list",
+ "site",
+ "drive"
+ ],
+ "default": "driveItem"
+ },
+ "from": {
+ "type": "integer",
+ "description": "0-based offset of the first hit, for the next page (from + size of the previous call).",
+ "minimum": 0
+ },
+ "size": {
+ "type": "integer",
+ "description": "Hits per page, 1 to 500 (default 25).",
+ "minimum": 1,
+ "maximum": 500
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/search/query",
+ "bodyMapping": {
+ "requests": [
+ {
+ "entityTypes": [
+ "$entity_type"
+ ],
+ "query": {
+ "queryString": "$query"
+ },
+ "from": "$from",
+ "size": "$size"
+ }
+ ]
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "sharepoint_list_lists",
+ "description": "List the lists and libraries of a site: id, name, displayName, description, list.template (genericList, documentLibrary, events...), list.hidden, webUrl and dates. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "site_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/lists",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,displayName,description,list,webUrl,createdDateTime,lastModifiedDateTime",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_list_columns",
+ "description": "List the columns of a list with their internal name (the key used in fields), displayName, type (text, number, dateTime, choice with its choices, lookup, personOrGroup...), required and readOnly flags. Call it before reading, filtering or writing list items.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "list_id": {
+ "type": "string",
+ "description": "List id (a GUID) or the list's display name, from sharepoint_list_lists."
+ }
+ },
+ "required": [
+ "site_id",
+ "list_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/lists/{list_id}/columns",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "sharepoint_list_items",
+ "description": "List the items of a list with their column values (fields), id, dates, creator and last modifier, webUrl. Filter on columns with OData, e.g. \"fields/Status eq 'Open'\"; limit the columns with expand, e.g. \"fields(select=Title,Status,DueDate)\". Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "list_id": {
+ "type": "string",
+ "description": "List id (a GUID) or the list's display name, from sharepoint_list_lists."
+ },
+ "filter": {
+ "type": "string",
+ "description": "OData filter on columns, prefixed with fields/, e.g. \"fields/Status eq 'Open'\" or \"fields/Modified ge '2026-10-01'\". Filtering on a column that is not indexed works on lists up to 5,000 items."
+ },
+ "expand": {
+ "type": "string",
+ "description": "\"fields\" (default) returns every column; \"fields(select=Title,Status,DueDate)\" only the named columns (internal names).",
+ "default": "fields"
+ },
+ "orderby": {
+ "type": "string",
+ "description": "Sort, e.g. \"fields/Modified desc\" (indexed columns only on large lists)."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "site_id",
+ "list_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/lists/{list_id}/items",
+ "encodePathParams": true,
+ "headers": {
+ "Prefer": "HonorNonIndexedQueriesWarningMayFailRandomly"
+ },
+ "queryParams": {
+ "expand": "$expand",
+ "$filter": "$filter",
+ "$orderby": "$orderby",
+ "$top": "$top",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_get_list_item",
+ "description": "Get one list item with all its column values (fields), id, dates, creator, last modifier and webUrl.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "list_id": {
+ "type": "string",
+ "description": "List id (a GUID) or the list's display name, from sharepoint_list_lists."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "List item id, a number as text, e.g. \"12\" (the id field of sharepoint_list_items)."
+ }
+ },
+ "required": [
+ "site_id",
+ "list_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/lists/{list_id}/items/{item_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "expand": "fields"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_create_list_item",
+ "description": "Create an item in a list. fields maps internal column names (sharepoint_list_columns) to values, e.g. {\"Title\": \"Order 4711\", \"Status\": \"Open\", \"DueDate\": \"2026-11-01T00:00:00Z\", \"Amount\": 120.5}. Returns the new item with its id and fields.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "list_id": {
+ "type": "string",
+ "description": "List id (a GUID) or the list's display name, from sharepoint_list_lists."
+ },
+ "fields": {
+ "type": "object",
+ "description": "Column values by internal name. Lookup and person columns take LookupId with the id, e.g. {\"AssignedToLookupId\": \"11\"}."
+ }
+ },
+ "required": [
+ "site_id",
+ "list_id",
+ "fields"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/sites/{site_id}/lists/{list_id}/items",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "fields": "$fields"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_update_list_item",
+ "description": "Change column values of a list item; columns not named keep their values. fields maps internal column names to new values, e.g. {\"Status\": \"Done\"}. Returns the item's updated fields.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "list_id": {
+ "type": "string",
+ "description": "List id (a GUID) or the list's display name, from sharepoint_list_lists."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "List item id, a number as text, e.g. \"12\" (the id field of sharepoint_list_items)."
+ },
+ "fields": {
+ "type": "object",
+ "description": "Column values to set, by internal name, e.g. {\"Status\": \"Done\", \"Amount\": 99}."
+ }
+ },
+ "required": [
+ "site_id",
+ "list_id",
+ "item_id",
+ "fields"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/sites/{site_id}/lists/{list_id}/items/{item_id}/fields",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "__merge": "$fields"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_delete_list_item",
+ "description": "Delete a list item. It goes to the site's recycle bin. Installed switched off.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "list_id": {
+ "type": "string",
+ "description": "List id (a GUID) or the list's display name, from sharepoint_list_lists."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "List item id, a number as text, e.g. \"12\" (the id field of sharepoint_list_items)."
+ }
+ },
+ "required": [
+ "site_id",
+ "list_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/sites/{site_id}/lists/{list_id}/items/{item_id}",
+ "encodePathParams": true
+ },
+ "enabled": false
+ },
+ {
+ "name": "sharepoint_create_list",
+ "description": "Create a new list in a site, optionally with columns, e.g. columns [{\"name\": \"Status\", \"text\": {}}, {\"name\": \"Amount\", \"number\": {}}, {\"name\": \"DueDate\", \"dateTime\": {}}]. Every list has a Title column. Returns the list with its id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "display_name": {
+ "type": "string",
+ "description": "Name of the list."
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description."
+ },
+ "columns": {
+ "type": "array",
+ "description": "Optional column definitions in Graph's columnDefinition format: {\"name\": \"Status\", \"choice\": {\"choices\": [\"Open\", \"Done\"]}}, {\"name\": \"Amount\", \"number\": {}}, {\"name\": \"Notes\", \"text\": {\"allowMultipleLines\": true}}.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "template": {
+ "type": "string",
+ "description": "List template (default genericList).",
+ "enum": [
+ "genericList",
+ "documentLibrary",
+ "events",
+ "tasks",
+ "contacts",
+ "links",
+ "announcements",
+ "issueTracking"
+ ],
+ "default": "genericList"
+ }
+ },
+ "required": [
+ "site_id",
+ "display_name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/sites/{site_id}/lists",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "displayName": "$display_name",
+ "description": "$description",
+ "columns": "$columns",
+ "list": {
+ "template": "$template"
+ }
+ }
+ }
+ },
+ {
+ "name": "sharepoint_create_folder",
+ "description": "Create a folder in a document library, inside a parent folder (default: the library's top level). Returns the new folder's id, name, parentReference and webUrl.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "name": {
+ "type": "string",
+ "description": "Folder name. Not allowed: \" * : < > ? / \\ |."
+ },
+ "parent_id": {
+ "type": "string",
+ "description": "Id of the folder in that library, or \"root\" for its top level.",
+ "default": "root"
+ },
+ "conflict_behavior": {
+ "type": "string",
+ "description": "What to do when an item with that name already exists: \"fail\" (default) or \"rename\" (Graph appends a number).",
+ "enum": [
+ "fail",
+ "rename"
+ ],
+ "default": "fail"
+ }
+ },
+ "required": [
+ "drive_id",
+ "name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{parent_id}/children",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name",
+ "folder": {},
+ "@microsoft.graph.conflictBehavior": "$conflict_behavior"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_upload_text_file",
+ "description": "Create a new text file (.txt, .md, .csv, .json, .html, .xml...) with the given content in a folder of a document library, up to about 4 MB. Returns the new file's id, name, size and webUrl. With conflict_behavior \"fail\" (default) an existing file of that name is left alone and Graph answers 409.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "file_name": {
+ "type": "string",
+ "description": "File name with extension, e.g. \"Meeting notes.md\", \"export.csv\". The extension decides the file type. Not allowed: \" * : < > ? / \\ |."
+ },
+ "content": {
+ "type": "string",
+ "description": "The full text content (UTF-8), not empty, at most about 4 MB.",
+ "minLength": 1
+ },
+ "parent_id": {
+ "type": "string",
+ "description": "Id of the folder in that library, or \"root\" for its top level.",
+ "default": "root"
+ },
+ "conflict_behavior": {
+ "type": "string",
+ "description": "What to do when an item with that name already exists: \"fail\" (default) or \"rename\" (Graph appends a number).",
+ "enum": [
+ "fail",
+ "rename"
+ ],
+ "default": "fail"
+ }
+ },
+ "required": [
+ "drive_id",
+ "file_name",
+ "content"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/drives/{drive_id}/items/{parent_id}:/{file_name}:/content",
+ "encodePathParams": true,
+ "headers": {
+ "Content-Type": "text/plain"
+ },
+ "queryParams": {
+ "@microsoft.graph.conflictBehavior": "$conflict_behavior"
+ },
+ "bodyMapping": {
+ "__raw": "$content"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ }
+ },
+ {
+ "name": "sharepoint_rename_drive_item",
+ "description": "Rename a file or folder in a document library in place. Keep the extension for files. Returns the item's id, name, parentReference and webUrl.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "name": {
+ "type": "string",
+ "description": "New name. Not allowed: \" * : < > ? / \\ |."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/drives/{drive_id}/items/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ },
+ "annotations": {
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "sharepoint_move_drive_item",
+ "description": "Move a file or folder to another folder of the same document library, optionally renaming it on the way. Returns the item's id, name, new parentReference and webUrl.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "new_parent_id": {
+ "type": "string",
+ "description": "Id of the target folder in the same library."
+ },
+ "name": {
+ "type": "string",
+ "description": "Optional new name."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "new_parent_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/drives/{drive_id}/items/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "parentReference": {
+ "id": "$new_parent_id"
+ },
+ "name": "$name"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "exclude": [
+ "['@microsoft.graph.downloadUrl']"
+ ]
+ }
+ },
+ "annotations": {
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "sharepoint_delete_drive_item",
+ "description": "Delete a file or folder (with its contents) from a document library. It goes to the site's recycle bin. Installed switched off.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/drives/{drive_id}/items/{item_id}",
+ "encodePathParams": true
+ },
+ "enabled": false
+ },
+ {
+ "name": "sharepoint_list_permissions",
+ "description": "List who has access to a file or folder in a document library: roles, people and groups (grantedToV2), sharing links (link.type, link.scope, link.webUrl), inheritance and expiry.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/drives/{drive_id}/items/{item_id}/permissions",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "sharepoint_create_sharing_link",
+ "description": "Create a sharing link for a file or folder in a document library: type \"view\" or \"edit\", scope \"anonymous\", \"organization\" or \"users\". Returns the link with its webUrl. Installed switched off: a link gives other people access.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "type": {
+ "type": "string",
+ "description": "Link type.",
+ "enum": [
+ "view",
+ "edit"
+ ]
+ },
+ "scope": {
+ "type": "string",
+ "description": "Who the link works for (the organisation's sharing settings decide what is allowed).",
+ "enum": [
+ "anonymous",
+ "organization",
+ "users"
+ ]
+ },
+ "expiration_date_time": {
+ "type": "string",
+ "description": "Optional ISO 8601 expiry, e.g. 2026-12-31T23:00:00Z."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{item_id}/createLink",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "type": "$type",
+ "scope": "$scope",
+ "expirationDateTime": "$expiration_date_time"
+ }
+ },
+ "enabled": false
+ },
+ {
+ "name": "sharepoint_invite",
+ "description": "Give people access to a file or folder in a document library by email address, with role \"read\" or \"write\", optionally emailing them. Returns the permissions created. Installed switched off: inviting gives other people access.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "drive_id": {
+ "type": "string",
+ "description": "Document library (drive) id from sharepoint_list_drives, e.g. b!s8RqPCGh0ESQS2EYnKM0IKS3lM7GxjdAviiob7oc5pXv_0LiL-62Qq3IXyrXnEop."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id from a list or search result in that library (sharepoint_list_drive_root, sharepoint_list_folder, sharepoint_search_drive). Work or school ids look like 01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K, personal ones like D4648F06C91D9D3D!54927."
+ },
+ "recipients": {
+ "type": "array",
+ "description": "People to invite, e.g. [{\"email\": \"ana@example.com\"}].",
+ "items": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string",
+ "description": "Email address."
+ }
+ },
+ "required": [
+ "email"
+ ]
+ }
+ },
+ "roles": {
+ "type": "array",
+ "description": "[\"read\"] or [\"write\"].",
+ "items": {
+ "type": "string",
+ "enum": [
+ "read",
+ "write"
+ ]
+ }
+ },
+ "send_invitation": {
+ "type": "boolean",
+ "description": "true to email the people; false to grant access silently."
+ },
+ "require_sign_in": {
+ "type": "boolean",
+ "description": "true (recommended) so the recipients must sign in."
+ },
+ "message": {
+ "type": "string",
+ "description": "Optional message in the invitation email."
+ }
+ },
+ "required": [
+ "drive_id",
+ "item_id",
+ "recipients",
+ "roles"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/drives/{drive_id}/items/{item_id}/invite",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "recipients": "$recipients",
+ "roles": "$roles",
+ "sendInvitation": "$send_invitation",
+ "requireSignIn": "$require_sign_in",
+ "message": "$message"
+ }
+ },
+ "enabled": false
+ },
+ {
+ "name": "sharepoint_list_pages",
+ "description": "List the modern pages of a site: id, name, title, webUrl, pageLayout, publishing state, dates and author. Paginate with skip_token.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "top": {
+ "type": "integer",
+ "description": "Items per page (Graph's default is 200 for folders and lists).",
+ "minimum": 1,
+ "maximum": 999
+ },
+ "skip_token": {
+ "type": "string",
+ "description": "To fetch the next page, pass the $skiptoken (or skipToken) value from the @odata.nextLink of the previous response, with the same other arguments."
+ }
+ },
+ "required": [
+ "site_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/pages/microsoft.graph.sitePage",
+ "encodePathParams": true,
+ "queryParams": {
+ "$select": "id,name,title,webUrl,pageLayout,publishingState,createdDateTime,lastModifiedDateTime,createdBy,description",
+ "$top": "$top",
+ "$skiptoken": "$skip_token"
+ }
+ }
+ },
+ {
+ "name": "sharepoint_get_page",
+ "description": "Read one modern page with its content: title, description and canvasLayout (sections, columns and web parts; text web parts carry innerHtml). Output over 200 KB is cut and marked _truncated.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "description": "Site id from sharepoint_search_sites, sharepoint_get_site_by_path or sharepoint_get_root_site, e.g. contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE."
+ },
+ "page_id": {
+ "type": "string",
+ "description": "Page id (a GUID) from sharepoint_list_pages."
+ }
+ },
+ "required": [
+ "site_id",
+ "page_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/sites/{site_id}/pages/{page_id}/microsoft.graph.sitePage",
+ "encodePathParams": true,
+ "queryParams": {
+ "$expand": "canvasLayout"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/microsoft-sharepoint.live.spec.ts b/packages/backend/src/adapters/intl/microsoft-sharepoint.live.spec.ts
new file mode 100644
index 000000000..747fb8a83
--- /dev/null
+++ b/packages/backend/src/adapters/intl/microsoft-sharepoint.live.spec.ts
@@ -0,0 +1,421 @@
+import axios from 'axios';
+import * as adapter from './microsoft-sharepoint.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { applyResponseTransform, validateTransform } from '../../connectors/response-transform.util';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the SharePoint (Microsoft Graph) adapter:
+ *
+ * 1. Static: always runs. Pins the Entra OAuth setup shared with Outlook,
+ * the Sites/Files scopes, composite site ids percent-encoded into the
+ * path while server-relative site paths keep their slashes, list items
+ * read with `expand=fields` and written as a `fields` object (create) or
+ * the bare field map (update), the Microsoft Search request body, the cap
+ * on page content, and which tools install switched off (deletes,
+ * sharing link, invite).
+ *
+ * 2. Live: skipped unless MS_GRAPH_ACCESS_TOKEN is set (a delegated Graph
+ * token of a Microsoft 365 work account with Sites.ReadWrite.All and
+ * Files.ReadWrite.All, e.g. from Graph Explorer). The site is discovered,
+ * not configured: the root site, or the first search hit when the user
+ * cannot read the root site.
+ *
+ * MS_GRAPH_ACCESS_TOKEN=eyJ... npx jest src/adapters/intl/microsoft-sharepoint.live.spec.ts
+ *
+ * With SHAREPOINT_LIVE_WRITE=1 as well, a list "AnythingMCP test "
+ * is created in that site with one item, which is read and updated, and a
+ * folder with a text file is created in the site's default library; the
+ * list and the folder are deleted (to the recycle bin) in `finally`.
+ * Nothing is shared.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ // The outbound helper also calls axios.getUri, getAdapter and friends:
+ // keep every real static, only the call itself is mocked.
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ encodePathParams?: boolean;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ responseMapping?: Record;
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const BASE = 'https://graph.microsoft.com/v1.0';
+const SITE = 'contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE';
+const SITE_ENC = 'contoso.sharepoint.com%2C2C712604-1370-44E7-A1F5-426573FDA80A%2C2D2244C3-251A-49EA-93A8-39E1C3A060FE';
+const newEngine = () =>
+ new RestEngine(
+ { getAccessToken: jest.fn().mockResolvedValue('test-token') } as unknown as OAuth2TokenService,
+ {} as LoginTokenService,
+ );
+const connectorConfig = () => ({
+ baseUrl: a.connector.baseUrl,
+ authType: a.connector.authType,
+ authConfig: { ...a.connector.authConfig },
+ headers: { ...a.connector.headers },
+});
+// Arguments as DynamicMcpTools hands them to the engine: schema defaults filled in.
+const call = (name: string, params: Record) =>
+ newEngine().execute(connectorConfig(), tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params));
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('microsoft-sharepoint adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 10 Oct 2026), and needs a work account and an Entra app', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/work or school account/);
+ expect(a.prerequisites).toMatch(/Outlook connector can be reused/);
+ });
+
+ it('signs in against the configured tenant with the Outlook variables and the Sites and Files scopes', () => {
+ expect(a.connector.authType).toBe('OAUTH2');
+ expect(a.requiredEnvVars).toEqual(['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET', 'MICROSOFT_TENANT_ID']);
+ expect(a.connector.authConfig).toEqual({
+ clientId: '{{MICROSOFT_CLIENT_ID}}',
+ clientSecret: '{{MICROSOFT_CLIENT_SECRET}}',
+ authorizationUrl: 'https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/authorize',
+ tokenUrl: 'https://login.microsoftonline.com/{{MICROSOFT_TENANT_ID}}/oauth2/v2.0/token',
+ scopes: 'offline_access User.Read Sites.ReadWrite.All Files.ReadWrite.All',
+ });
+ expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback');
+ expect(a.instructions).toMatch(/read-only setup[^\n]*Sites\.Read\.All Files\.Read\.All/);
+ });
+
+ it('probes with the root site, which needs no arguments', () => {
+ expect(a.probe.tool).toBe('sharepoint_get_root_site');
+ expect(tool('sharepoint_get_root_site').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/sites/root');
+ });
+
+ it('prefixes every tool with sharepoint_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^sharepoint_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('percent-encodes ids in the path, except the site address whose slashes must stay', () => {
+ for (const t of a.tools) {
+ const p = t.endpointMapping.path;
+ if (t.name === 'sharepoint_get_site_by_path') expect(t.endpointMapping.encodePathParams).toBeUndefined();
+ else if (/\{\w+\}/.test(p)) expect(`${t.name}:${t.endpointMapping.encodePathParams}`).toBe(`${t.name}:true`);
+ }
+ });
+
+ it('installs deletes, sharing link and invite switched off', () => {
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual([
+ 'sharepoint_create_sharing_link',
+ 'sharepoint_delete_drive_item',
+ 'sharepoint_delete_list_item',
+ 'sharepoint_invite',
+ ]);
+ expect(a.instructions).toMatch(
+ /Switched off at install\*\*: `sharepoint_delete_list_item`, `sharepoint_delete_drive_item`, `sharepoint_create_sharing_link` and `sharepoint_invite`/,
+ );
+ for (const t of a.tools.filter((x) => x.endpointMapping.method === 'DELETE')) expect(t.enabled).toBe(false);
+ });
+
+ it('marks reads, Microsoft Search included, read-only', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'sharepoint_get_drive_item',
+ 'sharepoint_get_list_item',
+ 'sharepoint_get_page',
+ 'sharepoint_get_root_site',
+ 'sharepoint_get_site',
+ 'sharepoint_get_site_by_path',
+ 'sharepoint_list_columns',
+ 'sharepoint_list_drive_root',
+ 'sharepoint_list_drives',
+ 'sharepoint_list_folder',
+ 'sharepoint_list_items',
+ 'sharepoint_list_lists',
+ 'sharepoint_list_pages',
+ 'sharepoint_list_permissions',
+ 'sharepoint_list_subsites',
+ 'sharepoint_search',
+ 'sharepoint_search_drive',
+ 'sharepoint_search_sites',
+ ]);
+ expect(annotationsOf(tool('sharepoint_rename_drive_item')).destructiveHint).toBe(false);
+ expect(annotationsOf(tool('sharepoint_move_drive_item')).destructiveHint).toBe(false);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bsharepoint_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bsharepoint_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(5);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ it.each([
+ ['sharepoint_get_root_site', {}, 'GET', '/sites/root'],
+ ['sharepoint_search_sites', { query: 'marketing' }, 'GET', '/sites'],
+ ['sharepoint_get_site_by_path', { hostname: 'contoso.sharepoint.com', site_path: 'sites/Marketing' }, 'GET', '/sites/contoso.sharepoint.com:/sites/Marketing'],
+ ['sharepoint_get_site', { site_id: SITE }, 'GET', `/sites/${SITE_ENC}`],
+ ['sharepoint_list_subsites', { site_id: SITE }, 'GET', `/sites/${SITE_ENC}/sites`],
+ ['sharepoint_list_drives', { site_id: SITE }, 'GET', `/sites/${SITE_ENC}/drives`],
+ ['sharepoint_list_drive_root', { drive_id: 'b!abc' }, 'GET', '/drives/b!abc/root/children'],
+ ['sharepoint_list_folder', { drive_id: 'b!abc', item_id: '01AB' }, 'GET', '/drives/b!abc/items/01AB/children'],
+ ['sharepoint_search_drive', { drive_id: 'b!abc', query: 'q 1' }, 'GET', "/drives/b!abc/root/search(q='q%201')"],
+ ['sharepoint_get_drive_item', { drive_id: 'b!abc', item_id: '01AB' }, 'GET', '/drives/b!abc/items/01AB'],
+ ['sharepoint_search', { query: 'budget' }, 'POST', '/search/query'],
+ ['sharepoint_list_lists', { site_id: SITE }, 'GET', `/sites/${SITE_ENC}/lists`],
+ ['sharepoint_list_columns', { site_id: SITE, list_id: 'L1' }, 'GET', `/sites/${SITE_ENC}/lists/L1/columns`],
+ ['sharepoint_list_items', { site_id: SITE, list_id: 'L1' }, 'GET', `/sites/${SITE_ENC}/lists/L1/items`],
+ ['sharepoint_get_list_item', { site_id: SITE, list_id: 'L1', item_id: '3' }, 'GET', `/sites/${SITE_ENC}/lists/L1/items/3`],
+ ['sharepoint_create_list_item', { site_id: SITE, list_id: 'L1', fields: { Title: 'x' } }, 'POST', `/sites/${SITE_ENC}/lists/L1/items`],
+ ['sharepoint_update_list_item', { site_id: SITE, list_id: 'L1', item_id: '3', fields: { Title: 'y' } }, 'PATCH', `/sites/${SITE_ENC}/lists/L1/items/3/fields`],
+ ['sharepoint_delete_list_item', { site_id: SITE, list_id: 'L1', item_id: '3' }, 'DELETE', `/sites/${SITE_ENC}/lists/L1/items/3`],
+ ['sharepoint_create_list', { site_id: SITE, display_name: 'T' }, 'POST', `/sites/${SITE_ENC}/lists`],
+ ['sharepoint_create_folder', { drive_id: 'b!abc', name: 'F' }, 'POST', '/drives/b!abc/items/root/children'],
+ ['sharepoint_upload_text_file', { drive_id: 'b!abc', file_name: 'a.csv', content: 'a,b' }, 'PUT', '/drives/b!abc/items/root:/a.csv:/content'],
+ ['sharepoint_rename_drive_item', { drive_id: 'b!abc', item_id: '01AB', name: 'n' }, 'PATCH', '/drives/b!abc/items/01AB'],
+ ['sharepoint_move_drive_item', { drive_id: 'b!abc', item_id: '01AB', new_parent_id: '01CD' }, 'PATCH', '/drives/b!abc/items/01AB'],
+ ['sharepoint_delete_drive_item', { drive_id: 'b!abc', item_id: '01AB' }, 'DELETE', '/drives/b!abc/items/01AB'],
+ ['sharepoint_list_permissions', { drive_id: 'b!abc', item_id: '01AB' }, 'GET', '/drives/b!abc/items/01AB/permissions'],
+ ['sharepoint_create_sharing_link', { drive_id: 'b!abc', item_id: '01AB', type: 'view' }, 'POST', '/drives/b!abc/items/01AB/createLink'],
+ ['sharepoint_invite', { drive_id: 'b!abc', item_id: '01AB', recipients: [{ email: 'a@b.c' }], roles: ['read'] }, 'POST', '/drives/b!abc/items/01AB/invite'],
+ ['sharepoint_list_pages', { site_id: SITE }, 'GET', `/sites/${SITE_ENC}/pages/microsoft.graph.sitePage`],
+ ['sharepoint_get_page', { site_id: SITE, page_id: 'p1' }, 'GET', `/sites/${SITE_ENC}/pages/p1/microsoft.graph.sitePage`],
+ ])('%s sends %s to the right Graph URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method,
+ url: `${BASE}${path}`,
+ headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
+ }),
+ );
+ });
+
+ it('reads list items with their fields, an OData filter and the non-indexed query header', async () => {
+ mockedAxios.mockResolvedValue({ data: { value: [] } });
+ await call('sharepoint_list_items', { site_id: SITE, list_id: 'L1', filter: "fields/Status eq 'Open'", top: 20 });
+ expect(sent().params).toEqual({ expand: 'fields', $filter: "fields/Status eq 'Open'", $top: 20 });
+ expect(sent().headers.Prefer).toBe('HonorNonIndexedQueriesWarningMayFailRandomly');
+ mockedAxios.mockClear();
+ await call('sharepoint_list_items', { site_id: SITE, list_id: 'L1', expand: 'fields(select=Title,Status)' });
+ expect(sent().params.expand).toBe('fields(select=Title,Status)');
+ });
+
+ it('creates an item with a fields object and updates with the bare field map', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('sharepoint_create_list_item', { site_id: SITE, list_id: 'L1', fields: { Title: 'Order 4711', Amount: 12.5 } });
+ expect(sent().data).toEqual({ fields: { Title: 'Order 4711', Amount: 12.5 } });
+ mockedAxios.mockClear();
+ await call('sharepoint_update_list_item', { site_id: SITE, list_id: 'L1', item_id: '3', fields: { Status: 'Done' } });
+ expect(sent().data).toEqual({ Status: 'Done' });
+ });
+
+ it('creates a generic list with columns by default', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('sharepoint_create_list', { site_id: SITE, display_name: 'Orders', columns: [{ name: 'Status', text: {} }] });
+ expect(sent().data).toEqual({ displayName: 'Orders', columns: [{ name: 'Status', text: {} }], list: { template: 'genericList' } });
+ });
+
+ it('builds the Microsoft Search request body', async () => {
+ mockedAxios.mockResolvedValue({ data: { value: [] } });
+ await call('sharepoint_search', { query: 'budget filetype:xlsx', size: 10 });
+ expect(sent().data).toEqual({ requests: [{ entityTypes: ['driveItem'], query: { queryString: 'budget filetype:xlsx' }, size: 10 }] });
+ mockedAxios.mockClear();
+ await call('sharepoint_search', { query: 'x', entity_type: 'listItem', from: 25, size: 25 });
+ expect(sent().data.requests[0]).toMatchObject({ entityTypes: ['listItem'], from: 25, size: 25 });
+ });
+
+ it('searches sites by keyword and pages with skip_token', async () => {
+ mockedAxios.mockResolvedValue({ data: { value: [] } });
+ await call('sharepoint_search_sites', { query: 'finance', skip_token: 't' });
+ expect(sent().params).toMatchObject({ search: 'finance', $skiptoken: 't' });
+ });
+
+ it('uploads text raw, never overwriting by default', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('sharepoint_upload_text_file', { drive_id: 'b!abc', file_name: 'Notes 1.md', content: '# Hi\n', parent_id: '01CD' });
+ expect(sent().url).toBe(`${BASE}/drives/b!abc/items/01CD:/Notes%201.md:/content`);
+ expect(sent().headers['Content-Type']).toBe('text/plain');
+ expect(sent().data).toBe('# Hi\n');
+ expect(sent().params).toEqual({ '@microsoft.graph.conflictBehavior': 'fail' });
+ });
+
+ it('caps page content at 200 KB and keeps download URLs out of write answers', () => {
+ const page = tool('sharepoint_get_page').responseMapping!;
+ expect(validateTransform(page.transform)).toBeNull();
+ expect(applyResponseTransform({ title: 'x'.repeat(300_000) }, page).truncated).toBe(true);
+ for (const name of ['sharepoint_upload_text_file', 'sharepoint_rename_drive_item', 'sharepoint_move_drive_item']) {
+ const out = applyResponseTransform(
+ { id: '1', '@microsoft.graph.downloadUrl': 'https://public.example/secret' },
+ tool(name).responseMapping,
+ ).value;
+ expect(out).toEqual({ id: '1' });
+ }
+ for (const t of a.tools) {
+ if (/\/content$/.test(t.endpointMapping.path)) expect(t.endpointMapping.method).toBe('PUT');
+ }
+ });
+});
+
+const TOKEN = process.env.MS_GRAPH_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('microsoft-sharepoint adapter: live Graph API', () => {
+ const auth = { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers };
+ const run = (name: string, params: Record = {}): Promise =>
+ new RestEngine({} as OAuth2TokenService, {} as LoginTokenService)
+ .execute(auth, tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params))
+ .then((raw) => applyResponseTransform(raw, tool(name).responseMapping).value);
+ let site: { id: string; webUrl: string; siteCollection?: { hostname: string } };
+
+ beforeAll(async () => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ // A site the user can reach: the root site, else the first search hit.
+ try {
+ site = await run('sharepoint_get_root_site');
+ } catch {
+ const found = await run('sharepoint_search_sites', { query: '*', top: 5 });
+ if (!found.value?.length) throw new Error('The token reaches no SharePoint site');
+ site = found.value[0];
+ }
+ }, 60_000);
+
+ it('reads the site by id and by path, its drives, lists and pages', async () => {
+ const byId = await run('sharepoint_get_site', { site_id: site.id });
+ expect(byId.id).toBe(site.id);
+ const url = new URL(site.webUrl);
+ if (url.pathname.length > 1) {
+ const byPath = await run('sharepoint_get_site_by_path', { hostname: url.hostname, site_path: url.pathname.replace(/^\//, '') });
+ expect(byPath.id).toBe(site.id);
+ }
+ const drives = await run('sharepoint_list_drives', { site_id: site.id });
+ expect(Array.isArray(drives.value)).toBe(true);
+ if (drives.value.length) {
+ const root = await run('sharepoint_list_drive_root', { drive_id: drives.value[0].id, top: 5 });
+ expect(Array.isArray(root.value)).toBe(true);
+ const hits = await run('sharepoint_search_drive', { drive_id: drives.value[0].id, query: 'a', top: 3 });
+ expect(Array.isArray(hits.value)).toBe(true);
+ }
+ const lists = await run('sharepoint_list_lists', { site_id: site.id });
+ expect(Array.isArray(lists.value)).toBe(true);
+ const visible = lists.value.find((l: { list?: { hidden?: boolean; template?: string } }) => !l.list?.hidden && l.list?.template === 'genericList');
+ if (visible) {
+ const cols = await run('sharepoint_list_columns', { site_id: site.id, list_id: visible.id });
+ expect(Array.isArray(cols.value)).toBe(true);
+ const items = await run('sharepoint_list_items', { site_id: site.id, list_id: visible.id, top: 3 });
+ expect(Array.isArray(items.value)).toBe(true);
+ }
+ const pages = await run('sharepoint_list_pages', { site_id: site.id, top: 3 });
+ expect(Array.isArray(pages.value)).toBe(true);
+ if (pages.value.length) {
+ const page = await run('sharepoint_get_page', { site_id: site.id, page_id: pages.value[0].id });
+ expect(page.id ?? page._truncated).toBeTruthy();
+ }
+ }, 120_000);
+
+ it('searches across SharePoint with Microsoft Search', async () => {
+ const res = await run('sharepoint_search', { query: 'a', size: 5 });
+ expect(Array.isArray(res.value)).toBe(true);
+ }, 60_000);
+
+ (process.env.SHAREPOINT_LIVE_WRITE === '1' ? it : it.skip)(
+ 'creates a test list with an item and a test folder with a text file, then deletes both',
+ async () => {
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const list = await run('sharepoint_create_list', {
+ site_id: site.id,
+ display_name: `AnythingMCP test ${stamp}`,
+ columns: [{ name: 'Status', text: {} }, { name: 'Amount', number: {} }],
+ });
+ const drives = await run('sharepoint_list_drives', { site_id: site.id });
+ const driveId: string = drives.value[0].id;
+ let folderId: string | undefined;
+ try {
+ const item = await run('sharepoint_create_list_item', {
+ site_id: site.id,
+ list_id: list.id,
+ fields: { Title: 'AnythingMCP test item', Status: 'Open', Amount: 12.5 },
+ });
+ expect(item.fields.Title).toBe('AnythingMCP test item');
+ const updated = await run('sharepoint_update_list_item', { site_id: site.id, list_id: list.id, item_id: item.id, fields: { Status: 'Done' } });
+ expect(updated.Status).toBe('Done');
+ const read = await run('sharepoint_get_list_item', { site_id: site.id, list_id: list.id, item_id: item.id });
+ expect(read.fields.Amount).toBe(12.5);
+ const open = await run('sharepoint_list_items', { site_id: site.id, list_id: list.id, filter: "fields/Status eq 'Done'" });
+ expect(open.value.length).toBe(1);
+
+ const folder = await run('sharepoint_create_folder', { drive_id: driveId, name: `AnythingMCP test ${stamp}` });
+ folderId = folder.id;
+ const file = await run('sharepoint_upload_text_file', { drive_id: driveId, file_name: 'AnythingMCP test.txt', content: 'AnythingMCP test\n', parent_id: folder.id });
+ expect(file['@microsoft.graph.downloadUrl']).toBeUndefined();
+ const renamed = await run('sharepoint_rename_drive_item', { drive_id: driveId, item_id: file.id, name: 'AnythingMCP renamed.txt' });
+ expect(renamed.name).toBe('AnythingMCP renamed.txt');
+ const perms = await run('sharepoint_list_permissions', { drive_id: driveId, item_id: file.id });
+ expect(Array.isArray(perms.value)).toBe(true);
+ } finally {
+ if (folderId) await run('sharepoint_delete_drive_item', { drive_id: driveId, item_id: folderId });
+ // No tool deletes a list, on purpose: clean up with a direct call.
+ await mockedAxios.__actual({
+ method: 'DELETE',
+ url: `${BASE}/sites/${encodeURIComponent(site.id)}/lists/${list.id}`,
+ headers: { Authorization: `Bearer ${TOKEN}` },
+ });
+ }
+ },
+ 180_000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/monday.json b/packages/backend/src/adapters/intl/monday.json
new file mode 100644
index 000000000..3870dd9f0
--- /dev/null
+++ b/packages/backend/src/adapters/intl/monday.json
@@ -0,0 +1,608 @@
+{
+ "slug": "monday",
+ "name": "monday.com",
+ "description": "Let your AI work in monday.com: find boards and items, read column values and comments, create items, update statuses, dates and owners, and post updates. One account, with your personal API token.",
+ "instructions": "## monday.com\nThis connector works with one monday.com account through the **GraphQL API** (`https://api.monday.com/v2`, API version 2026-07 sent in the `API-Version` header): workspaces, boards with their groups and columns, items with column values, updates (comments) and users. It can create items and groups, change column values, move items and post updates. It authenticates with your **personal API token**, so it sees and does exactly what you can in monday.com.\n\n**Setup (about 2 minutes)**\n1. In monday.com click your profile picture (top right) and choose **Developers**; the Developer Center opens in a new tab.\n2. Open **API token** (called **My access tokens** in older menus), click **Show** and copy the token. Account admins also find it under **Administration → Connections → Personal API token**.\n3. Paste it into `MONDAY_API_TOKEN`. It is sent as is in the `Authorization` header, without the word Bearer.\n4. Regenerating the token in the same place invalidates the old one immediately.\n\n**Read-only setup**: personal tokens carry all of the user's permissions and cannot be limited. Give the token's user only the board access it needs, and switch off `monday_create_item`, `monday_change_column_values`, `monday_move_item_to_group`, `monday_create_group`, `monday_create_update` and the generic `monday_graphql_mutation`. `monday_archive_item` is installed switched off.\n\n**Workflow**: `monday_list_boards` (or `monday_list_workspaces` first), then `monday_get_board` for the board's groups and columns. Column ids (`status`, `date4`, `person`) and the status labels in each column's `settings` are what filters and writes need. `monday_get_items_page` reads items; `monday_get_items` reads specific items from any board.\n\n**Ids and values**: board, item, user and update ids are numbers sent as strings; group ids are strings such as `topics`. `column_values` returns `text` (what the user sees) and `value` (raw JSON). Writes take a JSON **string** keyed by column id: status `{\"label\": \"Done\"}`, date `{\"date\": \"2026-10-15\"}`, people `{\"personsAndTeams\": [{\"id\": 123, \"kind\": \"person\"}]}`, text and numbers a plain string, item name via the `name` column.\n\n**Pagination**: `monday_get_items_page` returns up to 500 items and a `cursor`; pass it to `monday_next_items_page` until the cursor is null. A cursor expires 60 minutes after the first request. Boards, users and workspaces page with `limit` and `page`; an empty page means the end. Updates page with their own `cursor`.\n\n**Rate limits**: personal tokens share a budget of 10,000,000 complexity points per minute (1,000,000 on free and trial accounts), and accounts have a daily call limit (1,000 on Free, Basic and Standard, 10,000 on Pro, 25,000 on Enterprise) plus 1,000 to 5,000 requests per minute by plan. Over a limit the error carries `retry_in_seconds`; wait that long.\n\n**Errors**: monday answers HTTP 200 with an `errors` array for most failures; the connector reports them as errors. `Not Authenticated` or 401: the token is wrong or was regenerated. `UserUnauthorizedException`: no access to that board or item. `InvalidColumnIdException`, `ColumnValueException`: wrong column id or value format; check `monday_get_board`. `ComplexityException`, `DAILY_LIMIT_EXCEEDED`, `Minute limit rate exceeded`: wait and use smaller pages.\n\n**Other operations**: `monday_graphql_schema` and `monday_graphql_query` reach anything else in the API, such as docs, dashboards or activity logs.",
+ "region": "intl",
+ "category": "project-management",
+ "icon": "monday",
+ "docsUrl": "https://developer.monday.com/api-reference/docs/basics",
+ "requiredEnvVars": [
+ "MONDAY_API_TOKEN"
+ ],
+ "envVarMeta": {
+ "MONDAY_API_TOKEN": {
+ "label": "Personal API token",
+ "kind": "credential",
+ "secret": true,
+ "help": "monday.com → your profile picture → Developers → API token → Show → copy. Admins also find it under Administration → Connections → Personal API token.",
+ "link": "https://developer.monday.com/api-reference/docs/authentication",
+ "pattern": "^(?!Bearer\\s)\\S+$",
+ "patternMessage": "Paste only the token, without spaces or the word Bearer."
+ }
+ },
+ "verifyHints": {
+ "401": "monday.com refused the token. Copy it again from Developers → API token; regenerating it invalidates the old one.",
+ "403": "The token's user has no access to this board or item. Ask a board owner to add the user, or use a token of a user who has access.",
+ "rate_limited": "monday.com is rate limiting this token (complexity, minute or daily limit). Wait for the retry time in the error and try again."
+ },
+ "probe": {
+ "tool": "monday_get_me"
+ },
+ "prerequisites": "A monday.com account and the personal API token of an admin or member user the AI should act as. Guests and viewers have no personal token.",
+ "connector": {
+ "name": "monday.com GraphQL API",
+ "type": "GRAPHQL",
+ "baseUrl": "https://api.monday.com/v2",
+ "authType": "API_KEY",
+ "authConfig": {
+ "headerName": "Authorization",
+ "apiKey": "{{MONDAY_API_TOKEN}}"
+ },
+ "headers": {
+ "API-Version": "2026-07",
+ "User-Agent": "AnythingMCP"
+ },
+ "schemaUrl": "https://api.monday.com/v2/get_schema?format=sdl&version=2026-07"
+ },
+ "tools": [
+ {
+ "name": "monday_get_me",
+ "description": "Check the token and return who it acts as: user id, name, email, title, time zone, and the account's id, name, slug and plan tier.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Me { me { id name email title kind time_zone_identifier account { id name slug tier plan { tier max_users } } } }"
+ }
+ },
+ {
+ "name": "monday_list_workspaces",
+ "description": "List the workspaces the user can see: id, name, kind (open, closed, template) and description, a page at a time. Workspace ids narrow monday_list_boards.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Workspaces per page (default 25)."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Workspaces($limit: Int = 25, $page: Int = 1) { workspaces(limit: $limit, page: $page) { id name kind description } }",
+ "queryParams": {
+ "limit": "$limit",
+ "page": "$page"
+ }
+ }
+ },
+ {
+ "name": "monday_list_boards",
+ "description": "List boards the user can access: id, name, description, kind (public, private, share), state, workspace id, item count, last update and URL. Pages with limit and page; an empty list means no more pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "workspace_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only boards in these workspaces (ids from monday_list_workspaces)."
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "active",
+ "archived",
+ "deleted",
+ "all"
+ ],
+ "description": "Board state (default active)."
+ },
+ "order_by": {
+ "type": "string",
+ "enum": [
+ "created_at",
+ "used_at"
+ ],
+ "description": "Sort by creation date or last use, newest first."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Boards per page (default 25)."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Boards($workspaceIds: [ID], $state: State = active, $orderBy: BoardsOrderBy, $limit: Int = 25, $page: Int = 1) { boards(workspace_ids: $workspaceIds, state: $state, order_by: $orderBy, limit: $limit, page: $page) { id name description board_kind state workspace_id items_count updated_at url } }",
+ "queryParams": {
+ "workspaceIds": "$workspace_ids",
+ "state": "$state",
+ "orderBy": "$order_by",
+ "limit": "$limit",
+ "page": "$page"
+ }
+ }
+ },
+ {
+ "name": "monday_get_board",
+ "description": "Get one board's structure: name, workspace, owners, item count, its groups (id, title, color, position) and its columns (id, title, type and settings, which hold the allowed status and dropdown labels). Read this before filtering items or writing column values.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. \"1234567890\" (the number in the board URL)."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Board($boardId: ID!) { boards(ids: [$boardId]) { id name description board_kind state items_count url workspace { id name } owners { id name } groups { id title color position archived } columns { id title type description settings } } }",
+ "queryParams": {
+ "boardId": "$board_id"
+ }
+ }
+ },
+ {
+ "name": "monday_get_items_page",
+ "description": "Read a board's items with their column values (id, type, text and raw JSON value), optionally filtered and sorted with query_params, up to 500 per page. Returns items and a cursor: while it is not null, call monday_next_items_page with it. Cursors expire 60 minutes after the first request.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 500,
+ "description": "Items per page, 1 to 500 (default 25)."
+ },
+ "column_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only these column ids (from monday_get_board), e.g. [\"status\", \"person\", \"date4\"]. Leave out for all columns."
+ },
+ "query_params": {
+ "type": "object",
+ "description": "Optional filter and sort. Example: {\"rules\": [{\"column_id\": \"status\", \"compare_value\": [1], \"operator\": \"any_of\"}], \"operator\": \"and\", \"order_by\": [{\"column_id\": \"date4\", \"direction\": \"desc\"}]}. Rule operators: any_of, not_any_of, is_empty, is_not_empty, greater_than, greater_than_or_equals, lower_than, lower_than_or_equal, between, contains_text, not_contains_text, contains_terms, starts_with, ends_with, within_the_last, within_the_next. Status columns compare label indexes from the column settings; people columns compare \"person-\"; \"group\" as column_id filters by group id; ids: [item ids] limits to given items.",
+ "properties": {
+ "rules": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Filter rules: {column_id, compare_value, operator, compare_attribute?}."
+ },
+ "operator": {
+ "type": "string",
+ "enum": [
+ "and",
+ "or"
+ ],
+ "description": "How rules combine (default and)."
+ },
+ "order_by": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Sorting: [{column_id, direction: asc|desc}]."
+ },
+ "ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only these item ids."
+ }
+ }
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query ItemsPage($boardId: ID!, $limit: Int! = 25, $queryParams: ItemsQuery, $columnIds: [String!]) { boards(ids: [$boardId]) { id name items_page(limit: $limit, query_params: $queryParams) { cursor items { id name state url created_at updated_at group { id title } column_values(ids: $columnIds) { id type text value } } } } }",
+ "queryParams": {
+ "boardId": "$board_id",
+ "limit": "$limit",
+ "queryParams": "$query_params",
+ "columnIds": "$column_ids"
+ }
+ }
+ },
+ {
+ "name": "monday_next_items_page",
+ "description": "Fetch the next page of items for a cursor returned by monday_get_items_page (or by a previous call of this tool), with the same item fields. Returns a new cursor, null on the last page.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous page."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 500,
+ "description": "Items per page, 1 to 500 (default 25)."
+ },
+ "column_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only these column ids (from monday_get_board), e.g. [\"status\", \"person\", \"date4\"]. Leave out for all columns."
+ }
+ },
+ "required": [
+ "cursor"
+ ]
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query NextItemsPage($cursor: String!, $limit: Int! = 25, $columnIds: [String!]) { next_items_page(cursor: $cursor, limit: $limit) { cursor items { id name state url created_at updated_at group { id title } column_values(ids: $columnIds) { id type text value } } } }",
+ "queryParams": {
+ "cursor": "$cursor",
+ "limit": "$limit",
+ "columnIds": "$column_ids"
+ }
+ }
+ },
+ {
+ "name": "monday_get_items",
+ "description": "Get items by id, from any board: name, board, group, state, creator, timestamps, URL, all column values and their subitems (id and name).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Item ids, up to 100."
+ },
+ "column_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only these column ids (from monday_get_board), e.g. [\"status\", \"person\", \"date4\"]. Leave out for all columns."
+ }
+ },
+ "required": [
+ "item_ids"
+ ]
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Items($ids: [ID!], $columnIds: [String!]) { items(ids: $ids, limit: 100) { id name state url created_at updated_at creator { id name } board { id name } group { id title } column_values(ids: $columnIds) { id type text value } subitems { id name } } }",
+ "queryParams": {
+ "ids": "$item_ids",
+ "columnIds": "$column_ids"
+ }
+ }
+ },
+ {
+ "name": "monday_create_item",
+ "description": "Create an item on a board, optionally in a group and with column values. Returns the new item's id, name and URL. Show the user what will be created and wait for confirmation; never retry a successful create.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id."
+ },
+ "item_name": {
+ "type": "string",
+ "description": "Item name (the first column)."
+ },
+ "group_id": {
+ "type": "string",
+ "description": "Group id from monday_get_board, e.g. \"topics\". Default: the board's top group."
+ },
+ "column_values": {
+ "type": "string",
+ "description": "Column values as a JSON string keyed by column id, e.g. \"{\\\"status\\\": {\\\"label\\\": \\\"Working on it\\\"}, \\\"date4\\\": {\\\"date\\\": \\\"2026-10-15\\\"}, \\\"person\\\": {\\\"personsAndTeams\\\": [{\\\"id\\\": 123, \\\"kind\\\": \\\"person\\\"}]}, \\\"text\\\": \\\"Hello\\\"}\". Simple columns (text, numbers) take a plain string."
+ },
+ "create_labels_if_missing": {
+ "type": "boolean",
+ "description": "true to add status or dropdown labels that do not exist yet (needs board owner rights)."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "mutation",
+ "path": "mutation CreateItem($boardId: ID!, $itemName: String!, $groupId: String, $columnValues: JSON, $createLabels: Boolean) { create_item(board_id: $boardId, item_name: $itemName, group_id: $groupId, column_values: $columnValues, create_labels_if_missing: $createLabels) { id name url group { id title } } }",
+ "queryParams": {
+ "boardId": "$board_id",
+ "itemName": "$item_name",
+ "groupId": "$group_id",
+ "columnValues": "$column_values",
+ "createLabels": "$create_labels_if_missing"
+ }
+ }
+ },
+ {
+ "name": "monday_change_column_values",
+ "description": "Change one or more column values of an item, including its name (column id \"name\"). Only the given columns change. Returns the item with the changed columns' text. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id of the item."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id."
+ },
+ "column_values": {
+ "type": "string",
+ "description": "JSON string keyed by column id, e.g. \"{\\\"status\\\": {\\\"label\\\": \\\"Done\\\"}, \\\"numbers\\\": \\\"42\\\", \\\"name\\\": \\\"New name\\\"}\". To clear a column send an empty value, e.g. {\\\"status\\\": null} or {\\\"text\\\": \\\"\\\"}."
+ },
+ "create_labels_if_missing": {
+ "type": "boolean",
+ "description": "true to add status or dropdown labels that do not exist yet."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id",
+ "column_values"
+ ]
+ },
+ "endpointMapping": {
+ "method": "mutation",
+ "path": "mutation ChangeColumnValues($boardId: ID!, $itemId: ID, $columnValues: JSON!, $createLabels: Boolean) { change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues, create_labels_if_missing: $createLabels) { id name column_values { id text } } }",
+ "queryParams": {
+ "boardId": "$board_id",
+ "itemId": "$item_id",
+ "columnValues": "$column_values",
+ "createLabels": "$create_labels_if_missing"
+ }
+ }
+ },
+ {
+ "name": "monday_move_item_to_group",
+ "description": "Move an item to another group of the same board. Returns the item with its new group. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id."
+ },
+ "group_id": {
+ "type": "string",
+ "description": "Target group id from monday_get_board."
+ }
+ },
+ "required": [
+ "item_id",
+ "group_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "mutation",
+ "path": "mutation MoveItem($itemId: ID, $groupId: String!) { move_item_to_group(item_id: $itemId, group_id: $groupId) { id name group { id title } } }",
+ "queryParams": {
+ "itemId": "$item_id",
+ "groupId": "$group_id"
+ }
+ },
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true
+ }
+ },
+ {
+ "name": "monday_create_group",
+ "description": "Add a group (section) to a board, at the top by default or next to another group. Returns the group id and title. Confirm with the user before writing.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id."
+ },
+ "group_name": {
+ "type": "string",
+ "description": "Group title."
+ },
+ "relative_to": {
+ "type": "string",
+ "description": "Id of an existing group to place the new one next to."
+ },
+ "position_relative_method": {
+ "type": "string",
+ "enum": [
+ "before_at",
+ "after_at"
+ ],
+ "description": "Place before or after relative_to."
+ }
+ },
+ "required": [
+ "board_id",
+ "group_name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "mutation",
+ "path": "mutation CreateGroup($boardId: ID!, $groupName: String!, $relativeTo: String, $method: PositionRelative) { create_group(board_id: $boardId, group_name: $groupName, relative_to: $relativeTo, position_relative_method: $method) { id title } }",
+ "queryParams": {
+ "boardId": "$board_id",
+ "groupName": "$group_name",
+ "relativeTo": "$relative_to",
+ "method": "$position_relative_method"
+ }
+ }
+ },
+ {
+ "name": "monday_list_updates",
+ "description": "Read the updates (comments) on an item, newest first, with their replies: id, plain text, HTML body, creator and time. Returns a cursor for older updates while it is not null.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "description": "Updates per page (default 25)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous page."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Updates($itemId: ID!, $limit: Int = 25, $cursor: String) { items(ids: [$itemId]) { id name updates_page(limit: $limit, cursor: $cursor) { cursor updates { id text_body body created_at creator { id name } replies { id text_body created_at creator { id name } } } } } }",
+ "queryParams": {
+ "itemId": "$item_id",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "monday_create_update",
+ "description": "Post an update (comment) on an item, or a reply to an update with parent_id. The body accepts simple HTML. People who follow the item are notified. Confirm the text with the user before posting.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id to comment on."
+ },
+ "body": {
+ "type": "string",
+ "description": "Update text; HTML such as , , is allowed."
+ },
+ "parent_id": {
+ "type": "string",
+ "description": "Update id to reply to, instead of starting a new update."
+ }
+ },
+ "required": [
+ "body"
+ ]
+ },
+ "endpointMapping": {
+ "method": "mutation",
+ "path": "mutation CreateUpdate($itemId: ID, $body: String!, $parentId: ID) { create_update(item_id: $itemId, body: $body, parent_id: $parentId) { id text_body created_at item_id } }",
+ "queryParams": {
+ "itemId": "$item_id",
+ "body": "$body",
+ "parentId": "$parent_id"
+ }
+ }
+ },
+ {
+ "name": "monday_list_users",
+ "description": "List the account's users: id, name, email, title, kind (admin, member, guest, view_only), status and time zone. Filter by name or email. User ids are what people columns and mentions use.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Only users whose name contains this text."
+ },
+ "emails": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Only users with these email addresses."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 200,
+ "description": "Users per page (default 50)."
+ },
+ "page": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Page number, starting at 1."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "query",
+ "path": "query Users($name: String, $emails: [String!], $limit: Int = 50, $page: Int = 1) { users(name: $name, emails: $emails, limit: $limit, page: $page) { id name email title kind status is_deleted time_zone_identifier } }",
+ "queryParams": {
+ "name": "$name",
+ "emails": "$emails",
+ "limit": "$limit",
+ "page": "$page"
+ }
+ }
+ },
+ {
+ "name": "monday_archive_item",
+ "description": "Archive an item and its subitems: it leaves the board and can be restored from the board's archive. Installed switched off; turn it on only if the AI should remove items, and confirm every archive with the user.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "Item id to archive."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "mutation",
+ "path": "mutation ArchiveItem($itemId: ID) { archive_item(item_id: $itemId) { id name state } }",
+ "queryParams": {
+ "itemId": "$item_id"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/monday.live.spec.ts b/packages/backend/src/adapters/intl/monday.live.spec.ts
new file mode 100644
index 000000000..266efe460
--- /dev/null
+++ b/packages/backend/src/adapters/intl/monday.live.spec.ts
@@ -0,0 +1,233 @@
+import * as http from 'node:http';
+import { AddressInfo } from 'node:net';
+import { Kind, OperationDefinitionNode, parse } from 'graphql';
+import * as adapterJson from './monday.json';
+import { GraphqlEngine } from '../../connectors/engines/graphql.engine';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { GraphqlSchemaService } from '../../connectors/engines/graphql-schema.service';
+
+/**
+ * Static checks and one request against a local server always run. Every
+ * operation was also validated against the published 2026-07 schema
+ * (https://api.monday.com/v2/get_schema?format=sdl&version=2026-07) when the
+ * adapter was written. The live block runs read-only queries with a token:
+ * MONDAY_API_TOKEN=xxx npx jest src/adapters/intl/monday.live.spec.ts
+ * MONDAY_LIVE_WRITE=1 with MONDAY_TEST_BOARD_ID= also creates an
+ * item named "AnythingMCP test", comments on it, renames it and archives it.
+ */
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: { method: string; path: string; queryParams?: Record };
+};
+const a = adapterJson as unknown as {
+ unlisted?: boolean;
+ instructions: string;
+ requiredEnvVars: string[];
+ probe: { tool: string };
+ connector: {
+ type: string;
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ schemaUrl: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const operation = (t: Tool) =>
+ parse(t.endpointMapping.path).definitions.find(
+ (d): d is OperationDefinitionNode => d.kind === Kind.OPERATION_DEFINITION,
+ )!;
+const engine = () => new GraphqlEngine({} as OAuth2TokenService, {} as LoginTokenService, {} as GraphqlSchemaService);
+
+describe('monday adapter: static spec conformance', () => {
+ it('is listed (verified live on 10 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ });
+
+ it('calls the v2 GraphQL endpoint with the raw token in Authorization and a pinned API version', () => {
+ expect(a.connector.type).toBe('GRAPHQL');
+ expect(a.connector.baseUrl).toBe('https://api.monday.com/v2');
+ expect(a.connector.authType).toBe('API_KEY');
+ expect(a.connector.authConfig).toEqual({ headerName: 'Authorization', apiKey: '{{MONDAY_API_TOKEN}}' });
+ expect(a.connector.headers['API-Version']).toBe('2026-07');
+ expect(a.connector.headers['User-Agent']).toBe('AnythingMCP');
+ expect(a.connector.schemaUrl).toBe('https://api.monday.com/v2/get_schema?format=sdl&version=2026-07');
+ expect(a.requiredEnvVars).toEqual(['MONDAY_API_TOKEN']);
+ });
+
+ it('probes with me, which takes no argument', () => {
+ expect(a.probe.tool).toBe('monday_get_me');
+ expect(operation(tool('monday_get_me')).variableDefinitions ?? []).toHaveLength(0);
+ });
+
+ it('every operation parses, and parameters and variables match one to one', () => {
+ for (const t of a.tools) {
+ expect(t.name.startsWith('monday_')).toBe(true);
+ const op = operation(t);
+ expect(`${t.name}:${op.operation}`).toBe(`${t.name}:${t.endpointMapping.method}`);
+ const declared = (op.variableDefinitions ?? []).map((d) => d.variable.name.value).sort();
+ const mapped = Object.keys(t.endpointMapping.queryParams ?? {}).sort();
+ expect(`${t.name}:${mapped.join(',')}`).toBe(`${t.name}:${declared.join(',')}`);
+ const used = Object.values(t.endpointMapping.queryParams ?? {}).map((v) => v.slice(1)).sort();
+ const params = Object.keys(t.parameters.properties ?? {}).sort();
+ expect(`${t.name}:${used.join(',')}`).toBe(`${t.name}:${params.join(',')}`);
+ for (const r of t.parameters.required ?? []) expect(params).toContain(r);
+ }
+ });
+
+ it('has the expected writes, none of them deleting, and installs archive switched off', () => {
+ const mutations = a.tools.filter((t) => t.endpointMapping.method === 'mutation').map((t) => t.name).sort();
+ expect(mutations).toEqual([
+ 'monday_archive_item',
+ 'monday_change_column_values',
+ 'monday_create_group',
+ 'monday_create_item',
+ 'monday_create_update',
+ 'monday_move_item_to_group',
+ ]);
+ for (const t of a.tools) expect(t.endpointMapping.path).not.toMatch(/delete_/);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['monday_archive_item']);
+ for (const name of mutations.filter((n) => n !== 'monday_archive_item')) expect(tool(name).description).toMatch(/confirm/i);
+ });
+
+ it('pages items with items_page and next_items_page cursors', () => {
+ expect(tool('monday_get_items_page').endpointMapping.path).toContain('items_page(limit: $limit, query_params: $queryParams) { cursor items');
+ expect(tool('monday_next_items_page').endpointMapping.path).toContain('next_items_page(cursor: $cursor, limit: $limit) { cursor items');
+ expect(a.instructions).toMatch(/60 minutes/);
+ });
+
+ it('takes column values as a JSON string, as the JSON scalar expects', () => {
+ for (const name of ['monday_create_item', 'monday_change_column_values']) {
+ const props = tool(name).parameters.properties as Record;
+ expect(props.column_values.type).toBe('string');
+ }
+ });
+
+ it('documents setup, the read-only setup and the generic mutation tool', () => {
+ expect(a.instructions).toMatch(/Developers/);
+ expect(a.instructions).toContain('Read-only setup');
+ expect(a.instructions).toContain('monday_graphql_mutation');
+ expect(JSON.stringify(adapterJson)).not.toContain('—');
+ });
+});
+
+describe('monday adapter: request through the real engine', () => {
+ let server: http.Server;
+ let origin: string;
+ const seen: Array<{ headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ seen.push({ headers: req.headers, body });
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ data: { create_item: { id: '1' } } }));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+ afterAll(() => new Promise((r) => server.close(() => r())));
+
+ it('sends the token without Bearer, the API version and the mapped variables', async () => {
+ const result = await engine().execute(
+ { baseUrl: origin, authType: 'API_KEY', authConfig: { headerName: 'Authorization', apiKey: 'tok123' }, headers: a.connector.headers },
+ tool('monday_create_item').endpointMapping,
+ { board_id: '42', item_name: 'AnythingMCP test', column_values: '{"status":{"label":"Done"}}' },
+ );
+ expect(result).toEqual({ create_item: { id: '1' } });
+ const [req] = seen;
+ expect(req.headers.authorization).toBe('tok123');
+ expect(req.headers['api-version']).toBe('2026-07');
+ const body = JSON.parse(req.body);
+ expect(body.query).toMatch(/^mutation CreateItem/);
+ expect(body.variables).toEqual({ boardId: '42', itemName: 'AnythingMCP test', columnValues: '{"status":{"label":"Done"}}' });
+ });
+});
+
+const TOKEN = process.env.MONDAY_API_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('monday adapter: live calls', () => {
+ const run = (name: string, params: Record = {}): Promise =>
+ engine().execute(
+ {
+ baseUrl: a.connector.baseUrl,
+ authType: 'API_KEY',
+ authConfig: { headerName: 'Authorization', apiKey: TOKEN as string },
+ headers: a.connector.headers,
+ },
+ tool(name).endpointMapping,
+ params,
+ );
+
+ let boardId: string | undefined;
+
+ it('reads me (the probe), workspaces and users', async () => {
+ const me = await run('monday_get_me');
+ expect(me.me.id).toBeTruthy();
+ const ws = await run('monday_list_workspaces', { limit: 5 });
+ expect(Array.isArray(ws.workspaces)).toBe(true);
+ const users = await run('monday_list_users', { limit: 5 });
+ expect(Array.isArray(users.users)).toBe(true);
+ }, 30000);
+
+ it('lists boards, reads one and pages its items', async () => {
+ const boards = await run('monday_list_boards', { limit: 5, order_by: 'used_at' });
+ expect(Array.isArray(boards.boards)).toBe(true);
+ boardId = process.env.MONDAY_TEST_BOARD_ID || boards.boards.find((b: { items_count: number }) => b.items_count > 0)?.id;
+ if (!boardId) return;
+ const board = await run('monday_get_board', { board_id: boardId });
+ expect(board.boards[0].id).toBe(boardId);
+ const page = await run('monday_get_items_page', { board_id: boardId, limit: 2 });
+ const items = page.boards[0].items_page.items;
+ expect(Array.isArray(items)).toBe(true);
+ if (items[0]) {
+ const got = await run('monday_get_items', { item_ids: [items[0].id] });
+ expect(got.items[0].id).toBe(items[0].id);
+ const updates = await run('monday_list_updates', { item_id: items[0].id, limit: 2 });
+ expect(Array.isArray(updates.items[0].updates_page.updates)).toBe(true);
+ }
+ const cursor = page.boards[0].items_page.cursor;
+ if (cursor) {
+ const next = await run('monday_next_items_page', { cursor, limit: 2 });
+ expect(Array.isArray(next.next_items_page.items)).toBe(true);
+ }
+ }, 60000);
+
+ const BOARD = process.env.MONDAY_TEST_BOARD_ID;
+ (process.env.MONDAY_LIVE_WRITE === '1' && BOARD ? it : it.skip)(
+ 'creates, comments, renames and archives an "AnythingMCP test" item',
+ async () => {
+ let itemId: string | undefined;
+ try {
+ const created = await run('monday_create_item', { board_id: BOARD, item_name: 'AnythingMCP test' });
+ itemId = created.create_item.id;
+ const update = await run('monday_create_update', { item_id: itemId, body: 'AnythingMCP test update' });
+ expect(update.create_update.id).toBeTruthy();
+ const renamed = await run('monday_change_column_values', {
+ board_id: BOARD,
+ item_id: itemId,
+ column_values: JSON.stringify({ name: 'AnythingMCP test (renamed)' }),
+ });
+ expect(renamed.change_multiple_column_values.name).toBe('AnythingMCP test (renamed)');
+ } finally {
+ if (itemId) await run('monday_archive_item', { item_id: itemId });
+ }
+ },
+ 60000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/quickbooks-online.json b/packages/backend/src/adapters/intl/quickbooks-online.json
new file mode 100644
index 000000000..d2f762e9e
--- /dev/null
+++ b/packages/backend/src/adapters/intl/quickbooks-online.json
@@ -0,0 +1,777 @@
+{
+ "slug": "quickbooks-online",
+ "name": "QuickBooks Online",
+ "description": "Let your AI work with QuickBooks Online: query customers, vendors, items, invoices, bills, payments and accounts, read single records, run Profit and Loss, Balance Sheet and aged receivables and payables reports, and create customers and invoices. Intuit OAuth sign-in, sandbox or production company.",
+ "instructions": "## QuickBooks Online\nThis connector reads and writes one QuickBooks Online company through the **QuickBooks Online Accounting API v3** (`https://quickbooks.api.intuit.com/v3/company/`): company info, a read-only query language over every accounting entity (customers, vendors, items, accounts, invoices, bills, payments...), single records by Id, Profit and Loss, Balance Sheet and aged receivables and payables, and creating customers and invoices. It runs as the QuickBooks user who authorized the connector, on the company chosen at sign-in.\n\n**Setup (one-time, about 15 minutes)**\n1. Sign in at https://developer.intuit.com → **My Hub → App dashboard → Create an app** → *QuickBooks Online and Payments*, and select the scope **com.intuit.quickbooks.accounting**.\n2. Under **Settings → Redirect URIs** add `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself, for **Development** and, later, **Production**. It must match exactly.\n3. Copy the **Client ID** and **Client Secret** from **Keys & credentials**. Development keys work only with sandbox companies; production keys need Intuit's app assessment (questionnaire on the app dashboard) and work with real companies.\n4. Find the company's **realm id** (Company ID): in QuickBooks, gear icon → **Account and settings → Billing & subscription** (Company ID, without spaces); for a sandbox company, on the developer site under **Sandboxes**. Paste it into `QBO_REALM_ID`.\n5. Set `QBO_API_URL` to `https://quickbooks.api.intuit.com` for real companies or `https://sandbox-quickbooks.api.intuit.com` for a sandbox company. It must match the keys: development keys with the sandbox URL, production keys with the production URL.\n6. Install, then on the connector page click **Authorize with Provider**, sign in to Intuit and connect the same company. AnythingMCP exchanges the code with HTTP Basic client authentication and stores the tokens encrypted.\n7. Run `qbo_get_company_info` and check the company name before reading or writing anything. One connector reads one company; install another for a second company.\n\n**Tokens**: access tokens last one hour and are renewed automatically. Intuit replaces the refresh token about every 24 hours and AnythingMCP stores each new one; a refresh token not used for 100 days expires, and every refresh token expires 5 years after the first authorization at the latest. After that, or after the app is disconnected in QuickBooks, click **Authorize with Provider** again. Do not use the same Intuit app's refresh token in another tool at the same time: one of the two will hold a replaced token.\n\n**Scope**: `com.intuit.quickbooks.accounting` gives read and write access to the accounting data, as far as the signing-in user's QuickBooks role allows. Intuit has no read-only accounting scope: for a read-only setup, sign in with a QuickBooks user whose role cannot create transactions, and disable `qbo_create_customer`, `qbo_update_customer`, `qbo_create_invoice`.\n\n**Switched off at install**: `qbo_send_invoice` (it emails the customer in the company's name) and `qbo_delete_invoice`. Turn them on per tool if the AI should send or delete invoices.\n\n**Creating records**: `qbo_create_invoice` books a real invoice at once: QuickBooks has no drafts, it raises the customer's balance and appears in reports, but nothing is emailed. Look up the customer and the items with `qbo_query` first; each sales line needs `Amount` and `SalesItemLineDetail.ItemRef.value` (an Item Id). Companies with sales tax may also need `TxnTaxDetail` or a TaxCodeRef per line (non-US companies especially). Customers cannot be deleted, only made inactive with `qbo_update_customer` (`active: false`). Updates need the record's current `SyncToken`; read the record again if QuickBooks answers error 5010 (stale object).\n\n**Query language** (`qbo_query`): `SELECT * | fields | COUNT(*) FROM [WHERE ...] [ORDERBY [ASC|DESC]] [STARTPOSITION n] [MAXRESULTS n]`.\n- One entity per query, no joins, no `OR`, no `GROUP BY`; combine conditions with `AND`. Operators `=`, `<`, `>`, `<=`, `>=`, `IN ('1','2')`, `LIKE '%Acme%'` (only `%`, only on name fields).\n- Strings and dates in single quotes: `TxnDate >= '2026-01-01'`, `DisplayName = 'Acme GmbH'`; a quote inside a string as `\\'`. Booleans unquoted: `Active = true`.\n- Only filterable fields can be used in `WHERE`, e.g. Id, DisplayName, Active, Balance, TxnDate, DueDate, DocNumber, CustomerRef, VendorRef, `MetaData.LastUpdatedTime`. Inactive customers, vendors and items are left out unless the query says `Active IN (true, false)`.\n- Results come in `QueryResponse.`; `STARTPOSITION` is 1-based and `MAXRESULTS` goes up to 1000 (default 100). Page by raising STARTPOSITION by MAXRESULTS until fewer rows come back. `COUNT(*)` returns `totalCount`.\n- Examples: open invoices `select * from Invoice where Balance > '0' orderby DueDate`; a customer's invoices `select * from Invoice where CustomerRef = '58'`; changed since `select * from Customer where MetaData.LastUpdatedTime > '2026-10-01T00:00:00Z'`.\n\n**Reports**: `qbo_report_profit_and_loss` and `qbo_report_balance_sheet` take `start_date`/`end_date` (YYYY-MM-DD) or a `date_macro` such as \"This Fiscal Year\", optionally `accounting_method` (Accrual or Cash) and `summarize_column_by` (Month, Quarter...). The aged receivables and payables reports take a `report_date`. Report JSON has `Header`, `Columns` and nested `Rows` whose `ColData` values are strings.\n\n**Minor version**: every call sends `minorversion=75`, the lowest version Intuit still serves since August 2025.\n\n**Rate limits**: 500 requests per minute and 10 concurrent requests per company; over the limit Intuit answers 429. Prefer one query with MAXRESULTS 1000 over many small ones.\n\n**Common errors**\n- 401 `AuthenticationFailed` / 3200: the authorization expired or was revoked, or `QBO_API_URL` does not match the keys (sandbox keys against production or the other way round). Check the URL, then click **Authorize with Provider** again.\n- 403 / 3100 `ApplicationAuthorizationFailed`: the realm id belongs to a company this authorization was not granted for, or the user's role lacks access. Check `QBO_REALM_ID` against the company connected at sign-in.\n- 400 `QueryParserError` / `QueryValidationError`: query syntax (quotes, an unsupported operator such as OR, a field that cannot be filtered).\n- 400 `ValidationFault` 6240 `Duplicate Name Exists Error`: the DisplayName is taken; 2040 `Invalid String`: a name contains a colon (it separates sub-customers), tab or line break; 610 `Object Not Found`: an Id that does not exist; 5010 `Stale Object Error`: read the record again for its SyncToken; 6000 business validation (for example a missing tax code).\n- 429 `ThrottleExceeded`: wait a minute and retry.\n\n**Out of scope here**: invoice PDFs and attachments, payments and bank feeds, payroll, QuickBooks Desktop, batch and change-data-capture calls, deleting anything except invoices.",
+ "region": "intl",
+ "category": "accounting",
+ "icon": "quickbooks-online",
+ "docsUrl": "https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account",
+ "requiredEnvVars": [
+ "QBO_CLIENT_ID",
+ "QBO_CLIENT_SECRET",
+ "QBO_REALM_ID",
+ "QBO_API_URL"
+ ],
+ "appRegistrationUrl": "https://developer.intuit.com/app/developer/dashboard",
+ "envVarMeta": {
+ "QBO_CLIENT_ID": {
+ "label": "Client ID",
+ "kind": "credential",
+ "help": "Intuit Developer → App dashboard → your app → Keys & credentials (Development for sandbox companies, Production for real ones): Client ID.",
+ "link": "https://developer.intuit.com/app/developer/dashboard"
+ },
+ "QBO_CLIENT_SECRET": {
+ "label": "Client secret",
+ "kind": "credential",
+ "help": "Same page: Client Secret, from the same Development or Production section as the Client ID.",
+ "link": "https://developer.intuit.com/app/developer/dashboard"
+ },
+ "QBO_REALM_ID": {
+ "label": "Company ID (realm id)",
+ "kind": "setting",
+ "help": "QuickBooks → gear icon → Account and settings → Billing & subscription → Company ID (digits only, no spaces). Sandbox companies: developer.intuit.com → Sandboxes.",
+ "example": "9341453050071234",
+ "pattern": "^\\d{6,20}$",
+ "patternMessage": "The realm id is the company's numeric Company ID, digits only, without spaces."
+ },
+ "QBO_API_URL": {
+ "label": "Environment",
+ "kind": "address",
+ "help": "https://quickbooks.api.intuit.com for real companies (production keys), https://sandbox-quickbooks.api.intuit.com for sandbox companies (development keys).",
+ "example": "https://sandbox-quickbooks.api.intuit.com",
+ "link": "https://developer.intuit.com/app/developer/qbo/docs/develop/sandboxes",
+ "pattern": "^https://(sandbox-)?quickbooks\\.api\\.intuit\\.com$",
+ "patternMessage": "Use https://quickbooks.api.intuit.com or https://sandbox-quickbooks.api.intuit.com, without a trailing slash."
+ }
+ },
+ "verifyHints": {
+ "401": "Intuit refused the access token. Check that QBO_API_URL matches the keys (sandbox URL with development keys, production URL with production keys), then click Authorize with Provider again.",
+ "403": "This authorization has no access to the company in QBO_REALM_ID. Check the Company ID and connect that same company when you authorize.",
+ "rate_limited": "QuickBooks is throttling this company (500 requests per minute). Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "qbo_get_company_info"
+ },
+ "prerequisites": "An Intuit Developer account (free) with an app for QuickBooks Online using the accounting scope, the redirect URI below, and the company's realm id. Development keys work with sandbox companies only; real companies need production keys, which Intuit grants after a short app assessment. About 15 minutes.",
+ "connector": {
+ "name": "QuickBooks Online Accounting API",
+ "type": "REST",
+ "baseUrl": "{{QBO_API_URL}}/v3/company/{{QBO_REALM_ID}}",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "clientId": "{{QBO_CLIENT_ID}}",
+ "clientSecret": "{{QBO_CLIENT_SECRET}}",
+ "authorizationUrl": "https://appcenter.intuit.com/connect/oauth2",
+ "tokenUrl": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
+ "tokenAuthMethod": "client_secret_basic",
+ "scopes": "com.intuit.quickbooks.accounting"
+ },
+ "headers": {
+ "Accept": "application/json",
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/preferences?minorversion=75",
+ "config": {
+ "errorWhen": [
+ {
+ "path": "Fault.Error[0].Message",
+ "messagePath": [
+ "Fault.Error[0].Message",
+ "Fault.Error[0].Detail"
+ ],
+ "status": 400
+ }
+ ]
+ }
+ },
+ "tools": [
+ {
+ "name": "qbo_get_company_info",
+ "description": "Read the connected company: CompanyName, LegalName, addresses, email, phone, Country, FiscalYearStartMonth, the company's start date and its settings. Call it first to confirm which company (realm) the connector reads.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/companyinfo/{QBO_REALM_ID}",
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_query",
+ "description": "Run a read-only QuickBooks query (SQL-like, one entity, no joins): e.g. \"select * from Invoice where Balance > '0' orderby DueDate startposition 1 maxresults 100\", \"select Id, DisplayName, Balance from Customer where Active = true\", \"select count(*) from Bill where TxnDate >= '2026-01-01'\". Returns QueryResponse with the entity list, startPosition and maxResults. Page with STARTPOSITION (1-based) and MAXRESULTS (up to 1000).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "The query. Grammar: SELECT * | Field1, Field2 | COUNT(*) FROM Entity [WHERE Field op value [AND Field op value ...]] [ORDERBY Field [ASC|DESC]] [STARTPOSITION n] [MAXRESULTS n]. Operators: =, <, >, <=, >=, IN ('a','b'), LIKE '%text%' (on names). Strings and dates in single quotes, a quote inside as \\'. No OR, no JOIN, no GROUP BY. Entities: Customer, Vendor, Employee, Item, Account, Invoice, Estimate, SalesReceipt, Payment, CreditMemo, RefundReceipt, Bill, BillPayment, Purchase, PurchaseOrder, VendorCredit, Deposit, Transfer, JournalEntry, TimeActivity, Class, Department, Term, TaxCode, TaxRate, PaymentMethod, CompanyCurrency."
+ }
+ },
+ "required": [
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/query",
+ "queryParams": {
+ "query": "$query",
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_get_customer",
+ "description": "Read one customer by its Id (the numeric Id from qbo_query, not a document number or name), with contact details, addresses, Balance and terms. Returns {\"Customer\": {...}} with every field, including SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The customer's Id, e.g. \"42\"."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/customer/{id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_get_invoice",
+ "description": "Read one invoice by its Id (the numeric Id from qbo_query, not a document number or name), with its lines, totals, Balance, due date, EmailStatus and linked payments. Returns {\"Invoice\": {...}} with every field, including SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The invoice's Id, e.g. \"42\"."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/invoice/{id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_get_item",
+ "description": "Read one item (product or service) by its Id (the numeric Id from qbo_query, not a document number or name), with prices, income and expense accounts and, for inventory items, QtyOnHand. Returns {\"Item\": {...}} with every field, including SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The item (product or service)'s Id, e.g. \"42\"."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/item/{id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_get_vendor",
+ "description": "Read one vendor by its Id (the numeric Id from qbo_query, not a document number or name), with contact details, addresses and Balance. Returns {\"Vendor\": {...}} with every field, including SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The vendor's Id, e.g. \"42\"."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/vendor/{id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_get_bill",
+ "description": "Read one bill (a vendor's invoice to the company) by its Id (the numeric Id from qbo_query, not a document number or name), with its lines, due date and Balance. Returns {\"Bill\": {...}} with every field, including SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The bill (a vendor's invoice to the company)'s Id, e.g. \"42\"."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/bill/{id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_get_payment",
+ "description": "Read one customer payment by its Id (the numeric Id from qbo_query, not a document number or name), with the invoices it was applied to (Line.LinkedTxn). Returns {\"Payment\": {...}} with every field, including SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The customer payment's Id, e.g. \"42\"."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/payment/{id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_create_customer",
+ "description": "Create a customer. DisplayName must be unique across customers, vendors and employees (error 6240 otherwise; search with qbo_query first). Returns {\"Customer\": {...}} with the new Id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "display_name": {
+ "type": "string",
+ "description": "Unique display name, e.g. \"Acme GmbH\" or \"Ana Diaz\". No colon (:), tab or line break: QuickBooks rejects them with error 2040 (a colon separates sub-customers)."
+ },
+ "company_name": {
+ "type": "string",
+ "description": "Company name."
+ },
+ "given_name": {
+ "type": "string",
+ "description": "First name of the contact person."
+ },
+ "family_name": {
+ "type": "string",
+ "description": "Last name of the contact person."
+ },
+ "primary_email_addr": {
+ "type": "object",
+ "description": "Email, e.g. {\"Address\": \"billing@acme.example\"}."
+ },
+ "primary_phone": {
+ "type": "object",
+ "description": "Phone, e.g. {\"FreeFormNumber\": \"+49 761 1234567\"}."
+ },
+ "bill_addr": {
+ "type": "object",
+ "description": "Billing address, e.g. {\"Line1\": \"Hauptstr. 1\", \"City\": \"Freiburg\", \"PostalCode\": \"79098\", \"Country\": \"DE\"}."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Free-form notes on the customer."
+ },
+ "additional_fields": {
+ "type": "object",
+ "description": "Any other Customer fields as QuickBooks names them, e.g. {\"CurrencyRef\": {\"value\": \"EUR\"}, \"SalesTermRef\": {\"value\": \"3\"}, \"Taxable\": true}."
+ }
+ },
+ "required": [
+ "display_name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/customer",
+ "queryParams": {
+ "minorversion": "75"
+ },
+ "bodyMapping": {
+ "DisplayName": "$display_name",
+ "CompanyName": "$company_name",
+ "GivenName": "$given_name",
+ "FamilyName": "$family_name",
+ "PrimaryEmailAddr": "$primary_email_addr",
+ "PrimaryPhone": "$primary_phone",
+ "BillAddr": "$bill_addr",
+ "Notes": "$notes",
+ "__merge": "$additional_fields"
+ }
+ }
+ },
+ {
+ "name": "qbo_update_customer",
+ "description": "Change some fields of a customer (sparse update); fields not named keep their values. Needs the customer's Id and current SyncToken from qbo_get_customer. Set active false to make a customer inactive (QuickBooks does not delete customers). Returns the updated customer with its new SyncToken.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The customer's Id."
+ },
+ "sync_token": {
+ "type": "string",
+ "description": "The SyncToken from the latest read of the customer; a stale one fails with error 5010."
+ },
+ "display_name": {
+ "type": "string",
+ "description": "New display name, unique, without colon (:), tab or line break."
+ },
+ "active": {
+ "type": "boolean",
+ "description": "false makes the customer inactive (hidden from lists), true reactivates it."
+ },
+ "additional_fields": {
+ "type": "object",
+ "description": "Other fields to set, e.g. {\"PrimaryEmailAddr\": {\"Address\": \"new@acme.example\"}, \"Notes\": \"...\"}."
+ }
+ },
+ "required": [
+ "id",
+ "sync_token"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/customer",
+ "queryParams": {
+ "minorversion": "75"
+ },
+ "bodyMapping": {
+ "Id": "$id",
+ "SyncToken": "$sync_token",
+ "sparse": true,
+ "DisplayName": "$display_name",
+ "Active": "$active",
+ "__merge": "$additional_fields"
+ }
+ },
+ "annotations": {
+ "destructiveHint": true
+ }
+ },
+ {
+ "name": "qbo_create_invoice",
+ "description": "Create an invoice for a customer. It is booked right away (QuickBooks has no draft invoices) and is not emailed. Lines reference items by Id, e.g. [{\"DetailType\": \"SalesItemLineDetail\", \"Amount\": 200, \"Description\": \"Consulting\", \"SalesItemLineDetail\": {\"ItemRef\": {\"value\": \"1\"}, \"Qty\": 2, \"UnitPrice\": 100}}]. Returns {\"Invoice\": {...}} with Id, DocNumber and TotalAmt.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer's Id (qbo_query on Customer)."
+ },
+ "lines": {
+ "type": "array",
+ "description": "Invoice lines in QuickBooks format. Each SalesItemLineDetail line needs Amount (= Qty x UnitPrice) and ItemRef.value (an Item Id from qbo_query on Item); a DescriptionOnly line holds text.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "txn_date": {
+ "type": "string",
+ "description": "Invoice date, YYYY-MM-DD (default today).",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "due_date": {
+ "type": "string",
+ "description": "Due date, YYYY-MM-DD (default from the customer's terms).",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "doc_number": {
+ "type": "string",
+ "description": "Invoice number; leave empty to let QuickBooks number it when custom transaction numbers are off."
+ },
+ "bill_email": {
+ "type": "object",
+ "description": "Recipient for a later qbo_send_invoice, e.g. {\"Address\": \"billing@acme.example\"}."
+ },
+ "private_note": {
+ "type": "string",
+ "description": "Internal memo, not shown to the customer."
+ },
+ "additional_fields": {
+ "type": "object",
+ "description": "Other Invoice fields, e.g. {\"CustomerMemo\": {\"value\": \"Thank you!\"}, \"SalesTermRef\": {\"value\": \"3\"}, \"CurrencyRef\": {\"value\": \"EUR\"}, \"TxnTaxDetail\": {...}}."
+ }
+ },
+ "required": [
+ "customer_id",
+ "lines"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoice",
+ "queryParams": {
+ "minorversion": "75"
+ },
+ "bodyMapping": {
+ "CustomerRef": {
+ "value": "$customer_id"
+ },
+ "Line": "$lines",
+ "TxnDate": "$txn_date",
+ "DueDate": "$due_date",
+ "DocNumber": "$doc_number",
+ "BillEmail": "$bill_email",
+ "PrivateNote": "$private_note",
+ "__merge": "$additional_fields"
+ }
+ }
+ },
+ {
+ "name": "qbo_send_invoice",
+ "description": "Email an invoice to the customer as a PDF from QuickBooks, to the invoice's BillEmail or to send_to (which also becomes the invoice's BillEmail). Sets EmailStatus to EmailSent. Installed switched off: it sends email to a customer in the company's name.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The invoice's Id."
+ },
+ "send_to": {
+ "type": "string",
+ "description": "Optional recipient address; default: the invoice's BillEmail."
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoice/{id}/send",
+ "encodePathParams": true,
+ "headers": {
+ "Content-Type": "application/octet-stream"
+ },
+ "queryParams": {
+ "sendTo": "$send_to",
+ "minorversion": "75"
+ },
+ "bodyMapping": {
+ "__raw": ""
+ }
+ },
+ "enabled": false
+ },
+ {
+ "name": "qbo_delete_invoice",
+ "description": "Delete an invoice permanently (it disappears from reports; QuickBooks keeps it in the audit log). Needs Id and the current SyncToken from qbo_get_invoice. Installed switched off.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The invoice's Id."
+ },
+ "sync_token": {
+ "type": "string",
+ "description": "The invoice's current SyncToken."
+ }
+ },
+ "required": [
+ "id",
+ "sync_token"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoice",
+ "queryParams": {
+ "operation": "delete",
+ "minorversion": "75"
+ },
+ "bodyMapping": {
+ "Id": "$id",
+ "SyncToken": "$sync_token"
+ }
+ },
+ "enabled": false
+ },
+ {
+ "name": "qbo_report_profit_and_loss",
+ "description": "Profit and Loss report (income, cost of goods sold, expenses, net income) for a period, as QuickBooks' report JSON: Header, Columns and nested Rows with ColData values. Use start_date and end_date or date_macro; summarize_column_by \"Month\" gives one column per month.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "start_date": {
+ "type": "string",
+ "description": "First day, YYYY-MM-DD. With end_date; or use date_macro instead.",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "end_date": {
+ "type": "string",
+ "description": "Last day, YYYY-MM-DD.",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "date_macro": {
+ "type": "string",
+ "description": "A named period instead of start_date and end_date, e.g. \"This Fiscal Year\", \"Last Month\", \"This Quarter-to-date\", \"Last Year\".",
+ "enum": [
+ "Today",
+ "Yesterday",
+ "This Week",
+ "Last Week",
+ "This Week-to-date",
+ "This Month",
+ "Last Month",
+ "This Month-to-date",
+ "This Fiscal Quarter",
+ "Last Fiscal Quarter",
+ "This Fiscal Quarter-to-date",
+ "This Fiscal Year",
+ "Last Fiscal Year",
+ "This Fiscal Year-to-date",
+ "This Calendar Year",
+ "Last Calendar Year",
+ "This Calendar Year-to-date"
+ ]
+ },
+ "accounting_method": {
+ "type": "string",
+ "description": "\"Accrual\" or \"Cash\" (default: the company's preference).",
+ "enum": [
+ "Accrual",
+ "Cash"
+ ]
+ },
+ "summarize_column_by": {
+ "type": "string",
+ "description": "Columns of the report: \"Total\" (default), or one per Month, Quarter, Year, Week, Days, or per Customers, Vendors, Classes, Departments, ProductsAndServices.",
+ "enum": [
+ "Total",
+ "Month",
+ "Week",
+ "Days",
+ "Quarter",
+ "Year",
+ "Customers",
+ "Vendors",
+ "Classes",
+ "Departments",
+ "Employees",
+ "ProductsAndServices"
+ ]
+ },
+ "customer": {
+ "type": "string",
+ "description": "Optional customer Id(s), comma-separated, to filter."
+ },
+ "department": {
+ "type": "string",
+ "description": "Optional department (location) Id(s), comma-separated."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/reports/ProfitAndLoss",
+ "queryParams": {
+ "start_date": "$start_date",
+ "end_date": "$end_date",
+ "date_macro": "$date_macro",
+ "accounting_method": "$accounting_method",
+ "summarize_column_by": "$summarize_column_by",
+ "customer": "$customer",
+ "department": "$department",
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_report_balance_sheet",
+ "description": "Balance Sheet report (assets, liabilities, equity) as of the end of a period, as QuickBooks' report JSON with nested Rows. Use start_date and end_date or date_macro; summarize_column_by \"Month\" or \"Quarter\" compares periods.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "start_date": {
+ "type": "string",
+ "description": "First day, YYYY-MM-DD. With end_date; or use date_macro instead.",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "end_date": {
+ "type": "string",
+ "description": "Last day, YYYY-MM-DD.",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "date_macro": {
+ "type": "string",
+ "description": "A named period instead of start_date and end_date, e.g. \"This Fiscal Year\", \"Last Month\", \"This Quarter-to-date\", \"Last Year\".",
+ "enum": [
+ "Today",
+ "Yesterday",
+ "This Week",
+ "Last Week",
+ "This Week-to-date",
+ "This Month",
+ "Last Month",
+ "This Month-to-date",
+ "This Fiscal Quarter",
+ "Last Fiscal Quarter",
+ "This Fiscal Quarter-to-date",
+ "This Fiscal Year",
+ "Last Fiscal Year",
+ "This Fiscal Year-to-date",
+ "This Calendar Year",
+ "Last Calendar Year",
+ "This Calendar Year-to-date"
+ ]
+ },
+ "accounting_method": {
+ "type": "string",
+ "description": "\"Accrual\" or \"Cash\" (default: the company's preference).",
+ "enum": [
+ "Accrual",
+ "Cash"
+ ]
+ },
+ "summarize_column_by": {
+ "type": "string",
+ "description": "Columns of the report: \"Total\" (default), or one per Month, Quarter, Year, Week, Days, or per Customers, Vendors, Classes, Departments, ProductsAndServices.",
+ "enum": [
+ "Total",
+ "Month",
+ "Week",
+ "Days",
+ "Quarter",
+ "Year",
+ "Customers",
+ "Vendors",
+ "Classes",
+ "Departments",
+ "Employees",
+ "ProductsAndServices"
+ ]
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/reports/BalanceSheet",
+ "queryParams": {
+ "start_date": "$start_date",
+ "end_date": "$end_date",
+ "date_macro": "$date_macro",
+ "accounting_method": "$accounting_method",
+ "summarize_column_by": "$summarize_column_by",
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_report_aged_receivables",
+ "description": "Aged Receivables summary: what each customer owes, split into Current, 1-30, 31-60, 61-90 and 91+ days past due, as of report_date (default today).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "report_date": {
+ "type": "string",
+ "description": "As-of date, YYYY-MM-DD (default today).",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "aging_period": {
+ "type": "integer",
+ "description": "Days per aging column (default 30).",
+ "minimum": 1
+ },
+ "num_periods": {
+ "type": "integer",
+ "description": "Number of aging columns (default 4).",
+ "minimum": 1
+ },
+ "customer": {
+ "type": "string",
+ "description": "Optional customer Id(s), comma-separated."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/reports/AgedReceivables",
+ "queryParams": {
+ "report_date": "$report_date",
+ "aging_period": "$aging_period",
+ "num_periods": "$num_periods",
+ "customer": "$customer",
+ "minorversion": "75"
+ }
+ }
+ },
+ {
+ "name": "qbo_report_aged_payables",
+ "description": "Aged Payables summary: what the company owes each vendor, split into Current, 1-30, 31-60, 61-90 and 91+ days past due, as of report_date (default today).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "report_date": {
+ "type": "string",
+ "description": "As-of date, YYYY-MM-DD (default today).",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
+ },
+ "aging_period": {
+ "type": "integer",
+ "description": "Days per aging column (default 30).",
+ "minimum": 1
+ },
+ "num_periods": {
+ "type": "integer",
+ "description": "Number of aging columns (default 4).",
+ "minimum": 1
+ },
+ "vendor": {
+ "type": "string",
+ "description": "Optional vendor Id(s), comma-separated."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/reports/AgedPayables",
+ "queryParams": {
+ "report_date": "$report_date",
+ "aging_period": "$aging_period",
+ "num_periods": "$num_periods",
+ "vendor": "$vendor",
+ "minorversion": "75"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/quickbooks-online.live.spec.ts b/packages/backend/src/adapters/intl/quickbooks-online.live.spec.ts
new file mode 100644
index 000000000..147f10994
--- /dev/null
+++ b/packages/backend/src/adapters/intl/quickbooks-online.live.spec.ts
@@ -0,0 +1,415 @@
+import axios from 'axios';
+import * as adapter from './quickbooks-online.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { ResponseBodyError } from '../../connectors/engines/response-error.util';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { describeAdapterEnvVars } from '../env-var-meta';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the QuickBooks Online adapter:
+ *
+ * 1. Static: always runs. Pins Intuit's OAuth endpoints with HTTP Basic
+ * client authentication at the token endpoint, the base URL built from
+ * the environment (production or sandbox) and the realm id, minorversion
+ * 75 on every call, the query endpoint, the create and sparse-update
+ * bodies, the errorWhen rule for a Fault in a 200 answer, and which tools
+ * install switched off (send invoice, delete invoice).
+ *
+ * 2. Live: skipped unless QBO_ACCESS_TOKEN and QBO_REALM_ID are set. An
+ * access token of a sandbox company (Intuit's OAuth 2.0 Playground gives
+ * one, valid for an hour); QBO_SANDBOX=1 points at the sandbox host:
+ *
+ * QBO_ACCESS_TOKEN=eyJ... QBO_REALM_ID=9341... QBO_SANDBOX=1 \
+ * npx jest src/adapters/intl/quickbooks-online.live.spec.ts
+ *
+ * With QBO_LIVE_WRITE=1 as well (sandbox only), a customer
+ * "AnythingMCP test " and an invoice for it are created and read
+ * back; in `finally` the invoice is deleted and the customer made
+ * inactive (QuickBooks cannot delete customers). Nothing is emailed.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ // The outbound helper also calls axios.getUri, getAdapter and friends:
+ // keep every real static, only the call itself is mocked.
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ encodePathParams?: boolean;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ config: { errorWhen: unknown };
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const REALM = '9341453050071234';
+const SANDBOX = 'https://sandbox-quickbooks.api.intuit.com';
+const BASE = `${SANDBOX}/v3/company/${REALM}`;
+const newEngine = () =>
+ new RestEngine(
+ { getAccessToken: jest.fn().mockResolvedValue('test-token') } as unknown as OAuth2TokenService,
+ {} as LoginTokenService,
+ );
+// The base URL as ConnectorsService resolves it; env vars also reach the
+// tool's params, which is how `{QBO_REALM_ID}` in a path is filled.
+const connectorConfig = () => ({
+ baseUrl: a.connector.baseUrl.replace('{{QBO_API_URL}}', SANDBOX).replace('{{QBO_REALM_ID}}', REALM),
+ authType: a.connector.authType,
+ authConfig: { ...a.connector.authConfig },
+ headers: { ...a.connector.headers },
+ errorWhen: a.connector.config.errorWhen,
+});
+const call = (name: string, params: Record) =>
+ newEngine().execute(connectorConfig(), tool(name).endpointMapping, {
+ ...applySchemaDefaults(tool(name).parameters, params),
+ QBO_REALM_ID: REALM,
+ });
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('quickbooks-online adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 10 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/sandbox/);
+ });
+
+ it("signs in at Intuit with HTTP Basic client authentication and the accounting scope", () => {
+ expect(a.connector.authType).toBe('OAUTH2');
+ expect(a.connector.authConfig).toEqual({
+ clientId: '{{QBO_CLIENT_ID}}',
+ clientSecret: '{{QBO_CLIENT_SECRET}}',
+ authorizationUrl: 'https://appcenter.intuit.com/connect/oauth2',
+ tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
+ tokenAuthMethod: 'client_secret_basic',
+ scopes: 'com.intuit.quickbooks.accounting',
+ });
+ // The callback stores the (rotating) refresh token; there is no variable for it.
+ expect(a.connector.authConfig.refreshToken).toBeUndefined();
+ expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback');
+ expect(a.instructions).toMatch(/about every 24 hours/);
+ });
+
+ it('builds the base URL from the environment and the realm id, both validated', () => {
+ expect(a.requiredEnvVars).toEqual(['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET', 'QBO_REALM_ID', 'QBO_API_URL']);
+ expect(a.connector.baseUrl).toBe('{{QBO_API_URL}}/v3/company/{{QBO_REALM_ID}}');
+ const env = new RegExp(a.envVarMeta.QBO_API_URL.pattern!);
+ expect(env.test('https://quickbooks.api.intuit.com')).toBe(true);
+ expect(env.test(SANDBOX)).toBe(true);
+ expect(env.test(`${SANDBOX}/`)).toBe(false);
+ expect(env.test('https://evil.example')).toBe(false);
+ const realm = new RegExp(a.envVarMeta.QBO_REALM_ID.pattern!);
+ expect(realm.test(REALM)).toBe(true);
+ expect(realm.test('9341 4530 5007 1234')).toBe(false);
+ const described = describeAdapterEnvVars(a as never);
+ expect(described.find((d) => d.name === 'QBO_API_URL')!.kind).toBe('address');
+ expect(described.find((d) => d.name === 'QBO_CLIENT_SECRET')!.secret).toBe(true);
+ });
+
+ it('probes with company info and health-checks without a templated path', () => {
+ expect(a.probe.tool).toBe('qbo_get_company_info');
+ expect(tool('qbo_get_company_info').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/preferences?minorversion=75');
+ expect(a.connector.headers).toEqual({ Accept: 'application/json', 'User-Agent': 'AnythingMCP' });
+ });
+
+ it('prefixes every tool with qbo_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^qbo_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('sends minorversion 75 on every call', () => {
+ for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.queryParams?.minorversion}`).toBe(`${t.name}:75`);
+ });
+
+ it('installs send and delete switched off, and keeps invoice and customer creation on', () => {
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual(['qbo_delete_invoice', 'qbo_send_invoice']);
+ expect(a.instructions).toMatch(/Switched off at install\*\*: `qbo_send_invoice`/);
+ expect(a.instructions).toMatch(/no drafts/);
+ expect(annotationsOf(tool('qbo_delete_invoice')).destructiveHint).toBe(true);
+ expect(annotationsOf(tool('qbo_update_customer')).destructiveHint).toBe(true);
+ expect(annotationsOf(tool('qbo_create_invoice')).destructiveHint).toBe(false);
+ });
+
+ it('marks queries, records and reports read-only', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'qbo_get_bill',
+ 'qbo_get_company_info',
+ 'qbo_get_customer',
+ 'qbo_get_invoice',
+ 'qbo_get_item',
+ 'qbo_get_payment',
+ 'qbo_get_vendor',
+ 'qbo_query',
+ 'qbo_report_aged_payables',
+ 'qbo_report_aged_receivables',
+ 'qbo_report_balance_sheet',
+ 'qbo_report_profit_and_loss',
+ ]);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bqbo_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bqbo_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(5);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ it.each([
+ ['qbo_get_company_info', {}, 'GET', `/companyinfo/${REALM}`],
+ ['qbo_query', { query: 'select * from Customer' }, 'GET', '/query'],
+ ['qbo_get_customer', { id: '58' }, 'GET', '/customer/58'],
+ ['qbo_get_invoice', { id: '130' }, 'GET', '/invoice/130'],
+ ['qbo_get_item', { id: '1' }, 'GET', '/item/1'],
+ ['qbo_get_vendor', { id: '41' }, 'GET', '/vendor/41'],
+ ['qbo_get_bill', { id: '25' }, 'GET', '/bill/25'],
+ ['qbo_get_payment', { id: '7' }, 'GET', '/payment/7'],
+ ['qbo_create_customer', { display_name: 'Acme' }, 'POST', '/customer'],
+ ['qbo_update_customer', { id: '58', sync_token: '0' }, 'POST', '/customer'],
+ ['qbo_create_invoice', { customer_id: '58', lines: [] }, 'POST', '/invoice'],
+ ['qbo_send_invoice', { id: '130' }, 'POST', '/invoice/130/send'],
+ ['qbo_delete_invoice', { id: '130', sync_token: '2' }, 'POST', '/invoice'],
+ ['qbo_report_profit_and_loss', {}, 'GET', '/reports/ProfitAndLoss'],
+ ['qbo_report_balance_sheet', {}, 'GET', '/reports/BalanceSheet'],
+ ['qbo_report_aged_receivables', {}, 'GET', '/reports/AgedReceivables'],
+ ['qbo_report_aged_payables', {}, 'GET', '/reports/AgedPayables'],
+ ])('%s sends %s to the right URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method,
+ url: `${BASE}${path}`,
+ headers: expect.objectContaining({ Authorization: 'Bearer test-token', Accept: 'application/json' }),
+ }),
+ );
+ });
+
+ it('passes the query text through as one parameter', async () => {
+ mockedAxios.mockResolvedValue({ data: { QueryResponse: {} } });
+ const query = "select * from Invoice where Balance > '0' and CustomerRef = '58' orderby DueDate startposition 1 maxresults 100";
+ await call('qbo_query', { query });
+ expect(sent().params).toEqual({ query, minorversion: '75' });
+ expect(sent().paramsSerializer({ query: "DisplayName LIKE '%Acme%'" })).toBe("query=DisplayName+LIKE+'%25Acme%25'");
+ });
+
+ it('creates a customer with the given fields and merges additional ones', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('qbo_create_customer', {
+ display_name: 'Acme GmbH',
+ primary_email_addr: { Address: 'billing@acme.example' },
+ additional_fields: { CurrencyRef: { value: 'EUR' }, DisplayName: 'ignored' },
+ });
+ expect(sent().data).toEqual({
+ DisplayName: 'Acme GmbH',
+ PrimaryEmailAddr: { Address: 'billing@acme.example' },
+ CurrencyRef: { value: 'EUR' },
+ });
+ });
+
+ it('sends a sparse customer update and can make a customer inactive', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('qbo_update_customer', { id: '58', sync_token: '3', active: false });
+ expect(sent().data).toEqual({ Id: '58', SyncToken: '3', sparse: true, Active: false });
+ });
+
+ it('creates an invoice with the customer reference and the lines as given', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ const lines = [{ DetailType: 'SalesItemLineDetail', Amount: 200, SalesItemLineDetail: { ItemRef: { value: '1' }, Qty: 2, UnitPrice: 100 } }];
+ await call('qbo_create_invoice', { customer_id: '58', lines, due_date: '2026-11-01', additional_fields: { CustomerMemo: { value: 'Thanks' } } });
+ expect(sent().data).toEqual({ CustomerRef: { value: '58' }, Line: lines, DueDate: '2026-11-01', CustomerMemo: { value: 'Thanks' } });
+ });
+
+ it('sends an invoice with an empty octet-stream body, and deletes with operation=delete', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('qbo_send_invoice', { id: '130', send_to: 'ana@example.com' });
+ expect(sent().headers['Content-Type']).toBe('application/octet-stream');
+ expect(sent().data).toBe('');
+ expect(sent().params).toEqual({ sendTo: 'ana@example.com', minorversion: '75' });
+ mockedAxios.mockClear();
+ await call('qbo_delete_invoice', { id: '130', sync_token: '2' });
+ expect(sent().params).toEqual({ operation: 'delete', minorversion: '75' });
+ expect(sent().data).toEqual({ Id: '130', SyncToken: '2' });
+ });
+
+ it('passes report dates and options as Intuit names them', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('qbo_report_profit_and_loss', { start_date: '2026-01-01', end_date: '2026-09-30', accounting_method: 'Cash', summarize_column_by: 'Month' });
+ expect(sent().params).toEqual({
+ start_date: '2026-01-01',
+ end_date: '2026-09-30',
+ accounting_method: 'Cash',
+ summarize_column_by: 'Month',
+ minorversion: '75',
+ });
+ mockedAxios.mockClear();
+ await call('qbo_report_aged_receivables', { report_date: '2026-10-10', aging_period: 15, num_periods: 6 });
+ expect(sent().params).toEqual({ report_date: '2026-10-10', aging_period: 15, num_periods: 6, minorversion: '75' });
+ });
+
+ it('turns a Fault inside a 200 answer into an error with its message', async () => {
+ mockedAxios.mockResolvedValue({
+ data: { Fault: { Error: [{ Message: 'Duplicate Name Exists Error', Detail: 'The name supplied already exists.', code: '6240' }], type: 'ValidationFault' } },
+ });
+ await expect(call('qbo_create_customer', { display_name: 'Acme' })).rejects.toThrow(
+ new ResponseBodyError(
+ 'The API answered with an error in a successful response: Duplicate Name Exists Error The name supplied already exists.',
+ 400,
+ {},
+ ).message,
+ );
+ mockedAxios.mockResolvedValue({ data: { QueryResponse: { Customer: [] } } });
+ await expect(call('qbo_query', { query: 'select * from Customer' })).resolves.toEqual({ QueryResponse: { Customer: [] } });
+ });
+});
+
+const TOKEN = process.env.QBO_ACCESS_TOKEN;
+const LIVE_REALM = process.env.QBO_REALM_ID;
+const live = TOKEN && LIVE_REALM ? describe : describe.skip;
+
+live('quickbooks-online adapter: live Accounting API', () => {
+ const host = process.env.QBO_SANDBOX === '1' ? SANDBOX : 'https://quickbooks.api.intuit.com';
+ beforeAll(() => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ });
+ const run = (name: string, params: Record = {}): Promise =>
+ new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute(
+ {
+ baseUrl: `${host}/v3/company/${LIVE_REALM}`,
+ authType: 'BEARER_TOKEN',
+ authConfig: { token: TOKEN as string },
+ headers: a.connector.headers,
+ errorWhen: a.connector.config.errorWhen,
+ } as never,
+ tool(name).endpointMapping,
+ { ...applySchemaDefaults(tool(name).parameters, params), QBO_REALM_ID: LIVE_REALM },
+ );
+
+ it('reads the company, queries customers and items, and reads one record', async () => {
+ const info = await run('qbo_get_company_info');
+ expect(typeof info.CompanyInfo.CompanyName).toBe('string');
+ const customers = await run('qbo_query', { query: 'select Id, DisplayName, Balance from Customer maxresults 5' });
+ expect(customers.QueryResponse).toBeDefined();
+ if (customers.QueryResponse.Customer?.length) {
+ const one = await run('qbo_get_customer', { id: customers.QueryResponse.Customer[0].Id });
+ expect(one.Customer.Id).toBe(customers.QueryResponse.Customer[0].Id);
+ }
+ const count = await run('qbo_query', { query: 'select count(*) from Invoice' });
+ expect(typeof count.QueryResponse.totalCount).toBe('number');
+ const items = await run('qbo_query', { query: "select * from Item where Type = 'Service' maxresults 3" });
+ expect(items.QueryResponse).toBeDefined();
+ }, 60_000);
+
+ it('runs the four reports', async () => {
+ const pl = await run('qbo_report_profit_and_loss', { date_macro: 'This Fiscal Year', summarize_column_by: 'Quarter' });
+ expect(pl.Header.ReportName).toBe('ProfitAndLoss');
+ const bs = await run('qbo_report_balance_sheet', { date_macro: 'This Fiscal Year' });
+ expect(bs.Header.ReportName).toBe('BalanceSheet');
+ const ar = await run('qbo_report_aged_receivables', {});
+ expect(ar.Header).toBeDefined();
+ const ap = await run('qbo_report_aged_payables', {});
+ expect(ap.Header).toBeDefined();
+ }, 60_000);
+
+ it('rejects a malformed query with the Intuit error', async () => {
+ await expect(run('qbo_query', { query: 'select * from Customer where OR' })).rejects.toBeDefined();
+ }, 30_000);
+
+ (process.env.QBO_LIVE_WRITE === '1' && process.env.QBO_SANDBOX === '1' ? it : it.skip)(
+ 'creates a test customer and invoice, reads them back, deletes the invoice and deactivates the customer',
+ async () => {
+ // QuickBooks names may not contain a colon (it separates sub-customers),
+ // so the timestamp goes in without one: 2026-10-10T11-22-26.
+ const stamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const created = await run('qbo_create_customer', {
+ display_name: `AnythingMCP test ${stamp}`,
+ primary_email_addr: { Address: 'anythingmcp-test@example.com' },
+ notes: 'Created by the AnythingMCP live spec; safe to ignore.',
+ });
+ const customer = created.Customer;
+ let invoice: { Id: string; SyncToken: string } | undefined;
+ try {
+ const items = await run('qbo_query', { query: "select * from Item where Type = 'Service' maxresults 1" });
+ const item = items.QueryResponse.Item?.[0];
+ expect(item).toBeDefined();
+ const res = await run('qbo_create_invoice', {
+ customer_id: customer.Id,
+ lines: [{ DetailType: 'SalesItemLineDetail', Amount: 20, Description: 'AnythingMCP test line', SalesItemLineDetail: { ItemRef: { value: item.Id }, Qty: 2, UnitPrice: 10 } }],
+ private_note: 'AnythingMCP live spec',
+ });
+ invoice = res.Invoice;
+ const read = await run('qbo_get_invoice', { id: invoice!.Id });
+ expect(read.Invoice.CustomerRef.value).toBe(customer.Id);
+ expect(read.Invoice.TotalAmt).toBeGreaterThanOrEqual(20);
+ invoice = read.Invoice;
+ } finally {
+ try {
+ if (invoice) {
+ const deleted = await run('qbo_delete_invoice', { id: invoice.Id, sync_token: invoice.SyncToken });
+ expect(deleted.Invoice.status).toBe('Deleted');
+ }
+ } finally {
+ // Customers cannot be deleted: make the test customer inactive.
+ const fresh = await run('qbo_get_customer', { id: customer.Id });
+ const done = await run('qbo_update_customer', { id: customer.Id, sync_token: fresh.Customer.SyncToken, active: false });
+ expect(done.Customer.Active).toBe(false);
+ }
+ }
+ },
+ 120_000,
+ );
+});
diff --git a/packages/backend/src/connectors/engines/engine-types.ts b/packages/backend/src/connectors/engines/engine-types.ts
index e421d9c61..9e7159d68 100644
--- a/packages/backend/src/connectors/engines/engine-types.ts
+++ b/packages/backend/src/connectors/engines/engine-types.ts
@@ -17,7 +17,8 @@ export interface RestEndpointMapping {
/**
* Reserved keys, each opt-in: `__raw` (non-JSON body), `__merge` (spread an
* object argument), `__spread` (form bodies), `{ "__file": "$p" }` (multipart
- * upload) and `{ "__mime": { to, cc, bcc, from, replyTo, subject,
+ * upload), `{ "__json": }` (the field is sent as the JSON text
+ * of its resolved value) and `{ "__mime": { to, cc, bcc, from, replyTo, subject,
* subjectPrefix, text, html, inReplyTo, references }, "__encoding"?:
* "base64url" | "base64" | "none" }`, which builds an RFC 5322 message where
* the marker stands (see mime-message.util.ts).
diff --git a/packages/backend/src/connectors/engines/rest.engine.json-field.spec.ts b/packages/backend/src/connectors/engines/rest.engine.json-field.spec.ts
new file mode 100644
index 000000000..a69f9952b
--- /dev/null
+++ b/packages/backend/src/connectors/engines/rest.engine.json-field.spec.ts
@@ -0,0 +1,87 @@
+import axios from 'axios';
+import { RestEngine } from './rest.engine';
+import { OAuth2TokenService } from './oauth2-token.service';
+import { LoginTokenService } from './login-token.service';
+
+// The callable default export is mocked; every real static (AxiosError,
+// isAxiosError, create, …) is kept so the engine's helpers behave as in prod.
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ ...actual,
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ };
+});
+jest.mock('../../common/ssrf.util', () => ({
+ ...jest.requireActual('../../common/ssrf.util'),
+ assertSafeOutboundUrl: jest.fn().mockResolvedValue(undefined),
+}));
+
+const mockedAxios = axios as unknown as jest.Mock;
+const config = { baseUrl: 'https://api.example.com', authType: 'NONE' };
+
+async function sent(mapping: Record, params: Record) {
+ mockedAxios.mockResolvedValue({ data: { status: 'SUCCESS' }, headers: {} });
+ await new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute(
+ config,
+ { method: 'POST', path: '/connector.php', ...mapping } as any,
+ params,
+ );
+ return mockedAxios.mock.calls[0][0];
+}
+
+beforeEach(() => mockedAxios.mockReset());
+
+describe('RestEngine __json marker', () => {
+ it('sends a form field as the JSON text of its resolved object, dropping omitted arguments', async () => {
+ const req = await sent(
+ {
+ bodyEncoding: 'form-urlencoded',
+ bodyMapping: {
+ method: 'getOrders',
+ parameters: { __json: { order_id: '$order_id', status_id: '$status_id', get_unconfirmed_orders: '$unconfirmed' } },
+ },
+ },
+ { order_id: 42, unconfirmed: false },
+ );
+ const form = new URLSearchParams(req.data);
+ expect(form.get('method')).toBe('getOrders');
+ expect(JSON.parse(form.get('parameters')!)).toEqual({ order_id: 42, get_unconfirmed_orders: false });
+ expect([...form.keys()]).toEqual(['method', 'parameters']);
+ expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
+ });
+
+ it('percent-encodes the JSON text, so & + = and non-ASCII survive', async () => {
+ const req = await sent(
+ { bodyEncoding: 'form-urlencoded', bodyMapping: { parameters: { __json: { filter_name: '$name' } } } },
+ { name: 'A&B + C=D Łódź' },
+ );
+ expect(JSON.parse(new URLSearchParams(req.data).get('parameters')!)).toEqual({ filter_name: 'A&B + C=D Łódź' });
+ });
+
+ it('passes a whole object argument through, nested arrays and keys included', async () => {
+ const order = { order_status_id: 1, products: [{ name: 'Mug', quantity: 2, price_brutto: 9.5 }] };
+ const req = await sent({ bodyEncoding: 'form-urlencoded', bodyMapping: { parameters: { __json: '$order' } } }, { order });
+ expect(JSON.parse(new URLSearchParams(req.data).get('parameters')!)).toEqual(order);
+ });
+
+ it('sends {} when nothing resolves', async () => {
+ const a = await sent({ bodyEncoding: 'form-urlencoded', bodyMapping: { parameters: { __json: {} } } }, {});
+ expect(new URLSearchParams(a.data).get('parameters')).toBe('{}');
+ mockedAxios.mockReset();
+ const b = await sent({ bodyEncoding: 'form-urlencoded', bodyMapping: { parameters: { __json: '$missing' } } }, {});
+ expect(new URLSearchParams(b.data).get('parameters')).toBe('{}');
+ });
+
+ it('works in a JSON body too, as a string field', async () => {
+ const req = await sent({ bodyMapping: { payload: { __json: { a: '$a' } }, b: '$b' } }, { a: 1, b: 'x' });
+ expect(req.data).toEqual({ payload: '{"a":1}', b: 'x' });
+ });
+
+ it('ignores a marker the caller smuggles in through an argument', async () => {
+ const req = await sent({ bodyEncoding: 'form-urlencoded', bodyMapping: { data: '$data' } }, { data: { __json: { x: 1 } } });
+ expect(new URLSearchParams(req.data).get('data[__json][x]')).toBe('1');
+ });
+});
diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts
index 6b8c9a7d8..b1f0bfe7d 100644
--- a/packages/backend/src/connectors/engines/rest.engine.ts
+++ b/packages/backend/src/connectors/engines/rest.engine.ts
@@ -345,6 +345,9 @@ export class RestEngine {
);
} else {
const mapped = this.mapParams(bodyMapping, bodyParams);
+ // `{ "__json": … }`: the field goes out as the JSON text of its
+ // resolved value (Baselinker's `parameters` form field).
+ stringifyJsonMarkers(mapped, bodyMapping);
const encoding = endpointMapping.bodyEncoding || 'json';
// Only fields whose marker is written in the tool's own bodyMapping
// may fetch a file. A `$param` resolves to whatever the caller sent,
@@ -1186,6 +1189,39 @@ function configuredFileKeys(bodyMapping: Record): Set {
);
}
+/** A `{ __json: }` wrapper in a bodyMapping entry. */
+function isJsonMarker(value: unknown): value is { __json?: unknown } {
+ return (
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ '__json' in (value as Record)
+ );
+}
+
+/**
+ * Replace every top-level field the tool's own bodyMapping wraps in
+ * `{ "__json": … }` with the JSON text of its resolved value. Some APIs take
+ * a method's arguments as one JSON-encoded form field (Baselinker's
+ * `method=getOrders¶meters={"order_id":1}`), which the form encoders
+ * would otherwise spell as `parameters[order_id]=1`. Optional arguments the
+ * caller left out are dropped before encoding, and a marker whose value
+ * resolved to nothing sends `{}`. Only markers written in the mapping count:
+ * a caller-supplied `{ "__json": … }` argument stays an ordinary object.
+ */
+function stringifyJsonMarkers(
+ mapped: Record,
+ bodyMapping: Record,
+): void {
+ if (Array.isArray(bodyMapping) || Array.isArray(mapped)) return;
+ for (const [key, template] of Object.entries(bodyMapping)) {
+ if (!isJsonMarker(template)) continue;
+ const resolved = mapped[key];
+ const inner = isJsonMarker(resolved) ? resolved.__json : undefined;
+ mapped[key] = JSON.stringify(inner ?? {});
+ }
+}
+
interface FetchedFile {
buffer: Buffer;
filename: string;
diff --git a/server.json b/server.json
index 726ce45b1..be660b088 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. 331 connectors: SAP, ERP.",
+ "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 338 connectors: SAP, ERP.",
"repository": {
"url": "https://github.com/HelpCode-ai/anythingmcp",
"source": "github"