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 macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 331 Connectors, 17 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 338 Connectors, 17 davon ohne API-Schlüssel.

AnythingMCP: selbst gehostetes MCP-Gateway

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

AnythingMCP ist ein quelloffenes, selbst gehostetes MCP-Gateway, das jedes REST-/OpenAPI-, SOAP-, GraphQL-, OData- oder SQL-System in MCP-Tools für Claude, ChatGPT und Copilot verwandelt, ohne dass du einen MCP-Server programmierst.
- Es bringt 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 は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。331 のコネクター、うち 17 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。338 のコネクター、うち 17 は API キー不要。

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

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

AnythingMCP は、オープンソースのセルフホスト型 MCP ゲートウェイです。MCP サーバーを書かずに、REST/OpenAPI、SOAP、GraphQL、OData、SQL のあらゆるシステムを Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- SAP、Etsy、weclapp、Amazon Seller などを含む 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 turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 331 connectors, 17 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 338 connectors, 17 of them with no API key.

AnythingMCP: self-hosted MCP gateway

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

AnythingMCP is an open-source, self-hosted MCP gateway that turns any REST/OpenAPI, SOAP, GraphQL, OData or SQL system into MCP tools for Claude, ChatGPT and Copilot, without writing an MCP server.
- It ships 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 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:331 个连接器,其中 17 个无需 API 密钥。 + AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:338 个连接器,其中 17 个无需 API 密钥。

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

, ,