diff --git a/CITATION.cff b/CITATION.cff index 2c716131f..595b5d349 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 484 pre-built adapters, with a focus on ERP and e-commerce + It ships 486 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 7ad28f15e..d4d4392f2 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: 484 Connectors, 21 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 486 Connectors, 21 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 484 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 21 davon kommen ohne API-Schlüssel aus. + Es bringt 486 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 21 davon kommen ohne API-Schlüssel aus.

@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec ## Connector-Katalog -484 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. +486 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 12b24fad8..222ecbbfc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。484 のコネクター、うち 21 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。486 のコネクター、うち 21 は 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 などを含む 484 種類の既製アダプターを同梱しており、うち 21 は API キー不要です。 + SAP、Etsy、weclapp、Amazon Seller などを含む 486 種類の既製アダプターを同梱しており、うち 21 は API キー不要です。

@@ -111,7 +111,7 @@ amd64 ではイメージの取得に約 30 秒、その 24 秒後に API が利 ## コネクターカタログ -484 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 +486 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 | カテゴリー | 例 | |---|---| diff --git a/README.md b/README.md index 6598eaf13..7d9f4251c 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: 484 connectors, 21 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 486 connectors, 21 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 484 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 21 of them need no API key. + It ships 486 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 21 of them need no API key.

@@ -103,7 +103,7 @@ Tools register at runtime, without a restart. Per-connector `{{VAR}}` values are ## Connector catalog -484 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. +486 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 21093048f..b633e4cf6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,5 @@

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

AnythingMCP:自行托管的 MCP 网关

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

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

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 484 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 486 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/glama.json b/glama.json index 1b14c5d25..d0206bdf1 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. 484 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. 486 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 531fac811..0abf4c63a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.37.0", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 484 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. 486 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 4c3297544..a9f856509 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -269,6 +269,7 @@ import * as mailerlite from './intl/mailerlite.json'; import * as mailshake from './intl/mailshake.json'; import * as make from './intl/make.json'; import * as mangools from './intl/mangools.json'; +import * as manychat from './intl/manychat.json'; import * as mapbox from './intl/mapbox.json'; import * as mastodon from './intl/mastodon.json'; import * as matomo from './intl/matomo.json'; @@ -462,6 +463,7 @@ import * as youtubeData from './intl/youtube-data.json'; import * as zabbix from './intl/zabbix.json'; import * as zendesk from './intl/zendesk.json'; import * as zenhub from './intl/zenhub.json'; +import * as zenkit from './intl/zenkit.json'; import * as zohoBooks from './intl/zoho-books.json'; import * as zohoCrm from './intl/zoho-crm.json'; import * as zohoMail from './intl/zoho-mail.json'; @@ -987,6 +989,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mailshake as unknown as AdapterDefinition, make as unknown as AdapterDefinition, mangools as unknown as AdapterDefinition, + manychat as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, mastodon as unknown as AdapterDefinition, matomo as unknown as AdapterDefinition, @@ -1180,6 +1183,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ zabbix as unknown as AdapterDefinition, zendesk as unknown as AdapterDefinition, zenhub as unknown as AdapterDefinition, + zenkit as unknown as AdapterDefinition, zohoBooks as unknown as AdapterDefinition, zohoCrm as unknown as AdapterDefinition, zohoMail as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/manychat.json b/packages/backend/src/adapters/intl/manychat.json new file mode 100644 index 000000000..abe07b250 --- /dev/null +++ b/packages/backend/src/adapters/intl/manychat.json @@ -0,0 +1,794 @@ +{ + "slug": "manychat", + "name": "Manychat", + "aliases": [ + "Many Chat" + ], + "description": "Let your AI work with a Manychat account on Instagram, WhatsApp, Messenger and Telegram: find contacts and read their tags and fields, tag contacts, update fields and contact details, list flows and growth tools, and send messages or start flows with your confirmation.", + "instructions": "## Manychat\nThis connector calls the **Manychat Public API** (`https://api.manychat.com`) with the API key of one Manychat account (one Facebook page, Instagram, WhatsApp or Telegram bot). It reads contacts, tags, fields, flows and growth tools, updates contacts, and sends messages and flows.\n\n**Plan**: API access needs the **Manychat Pro** plan (or higher). The free plan and the Essential plan have no API key. Creating contacts through the API (`manychat_create_contact`) is an import feature of paid plans as well.\n\n**Setup (about 2 minutes)**\n1. In Manychat open the account, then **Settings > API** and click **Generate your API Key**.\n2. Copy the key into `MANYCHAT_API_KEY`. It looks like `1234567:abcdef0123456789...` (the page id, a colon and a hash) and is sent as `Authorization: Bearer `.\n3. Regenerating or deleting the key in Manychat stops every tool that uses the old one. One key covers one account; install the connector once per account.\n\n**Where to start**: `manychat_get_account` checks the key. `manychat_list_tags`, `manychat_list_custom_fields`, `manychat_list_bot_fields` and `manychat_list_flows` give the names and ids the other tools need. There is no endpoint that lists every contact: find contacts with `manychat_find_contacts_by_name`, `manychat_find_contact_by_email_or_phone` or `manychat_find_contacts_by_custom_field` (text and number fields only, at most 100 results, no paging), then read one with `manychat_get_contact`.\n\n**Ids**: contact ids are long numbers; pass them exactly as Manychat returned them. Tags are addressed by name, custom and bot fields by `field_id` or `field_name`.\n\n**Field values** must match the field type: text as a string, number as a number, boolean as true or false, date as `YYYY-MM-DD`, datetime as `YYYY-MM-DDTHH:MM:SS+00:00`. A wrong type answers 400.\n\n**Sending**: `manychat_send_text_message`, `manychat_send_content` and `manychat_send_flow` send real messages. Messenger, Instagram and WhatsApp only allow automated messages within 24 hours of the contact's last message (`last_interaction` on the contact). Meta retired Messenger message tags in February 2026, so `message_tag` no longer opens the window. Outside the window WhatsApp needs an approved template: start a flow that begins with a template message using `manychat_send_flow`. WhatsApp content sent directly accepts only text with url or flow buttons. Show the user the recipient and the exact content and wait for a yes before every send.\n\n**Other writes**: tagging, field updates, contact updates and new tags or fields change the account and can start automations that listen for them; confirm each with the user. `manychat_delete_tag` removes a tag from the account and every contact, cannot be undone, and is installed switched off.\n\n**Rate limits** are per account: 100 requests a second for most reads, 10 a second for `manychat_list_flows`, contact lookups and all writes, 25 a second for sends and 20 a second plus 100 an hour per contact for `manychat_send_flow`. Manychat may stop serving an account that keeps exceeding them, so avoid loops over many contacts.\n\n**Errors**: 401 `Wrong token` or `Wrong format token` means the key is wrong, incomplete or regenerated. 400 carries `message` and `details`; send errors carry a `code`, e.g. 3011 (outside the 24-hour window) or 3040 to 3049 (content not supported on the contact's channel).\n\n**Out of scope**: listing all contacts, deleting contacts or custom fields, live chat conversations, comments, payments and the Messenger checkbox (user_ref) tools.", + "region": "intl", + "category": "marketing-automation", + "icon": "manychat", + "docsUrl": "https://api.manychat.com/swagger", + "prerequisites": "A Manychat account on the Pro plan or higher, and its API key from Settings > API. About 2 minutes.", + "requiredEnvVars": [ + "MANYCHAT_API_KEY" + ], + "envVarMeta": { + "MANYCHAT_API_KEY": { + "label": "Manychat API key", + "kind": "credential", + "secret": true, + "help": "Manychat, Settings, API, Generate your API Key. Needs the Pro plan. It looks like the page id, a colon and a hash.", + "link": "https://help.manychat.com/hc/en-us/articles/14959510331420", + "example": "1234567:0123456789abcdef0123456789abcdef", + "pattern": "^[0-9]+:[A-Za-z0-9]+$", + "patternMessage": "Paste the whole key as Manychat shows it (digits, a colon, then letters and digits), without the word Bearer." + } + }, + "verifyHints": { + "401": "Manychat refused the API key: it is wrong, incomplete or was regenerated. Copy it again from Settings > API in Manychat (Pro plan needed).", + "429": "Manychat is rate limiting this account. Wait a minute and verify again." + }, + "probe": { + "tool": "manychat_get_account" + }, + "connector": { + "name": "Manychat API", + "type": "REST", + "baseUrl": "https://api.manychat.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MANYCHAT_API_KEY}}" + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/fb/page/getInfo", + "config": { + "errorWhen": [ + { + "path": "status", + "equals": "error", + "messagePath": "message", + "messageMatches": "token", + "status": 401 + }, + { + "path": "status", + "equals": "error", + "messagePath": "message", + "status": 400 + }, + { + "path": "status", + "equals": "exception", + "messagePath": "message", + "status": 502 + } + ] + } + }, + "tools": [ + { + "name": "manychat_get_account", + "description": "Read the connected Manychat account (page): id, name, category, username, timezone and is_pro. Checks the API key.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/page/getInfo" + } + }, + { + "name": "manychat_list_tags", + "description": "List the account's tags with id and name. Tag names are what the tagging tools take.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/page/getTags" + } + }, + { + "name": "manychat_list_custom_fields", + "description": "List the account's contact (custom) fields with id, name, type (text, number, date, datetime, boolean) and description. Use the id or name with manychat_set_contact_fields and manychat_find_contacts_by_custom_field.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/page/getCustomFields" + } + }, + { + "name": "manychat_list_bot_fields", + "description": "List the account's bot fields (account-wide values shared by every contact) with id, name, type, description and current value.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/page/getBotFields" + } + }, + { + "name": "manychat_list_flows", + "description": "List the automations (flows) of the account with their ns id, name and folder, plus the folders. Use ns as flow_ns in manychat_send_flow.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/page/getFlows" + } + }, + { + "name": "manychat_list_growth_tools", + "description": "List the growth tools (entry points such as ref links, QR codes, comment triggers and widgets) with id, name and type.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/page/getGrowthTools" + } + }, + { + "name": "manychat_get_contact", + "description": "Get one contact by id: name, channels (Messenger, Instagram username, WhatsApp phone, email, SMS phone) and their opt-ins, subscription state, last interaction and last seen, live chat URL, tags and custom field values.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + } + }, + "required": [ + "subscriber_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/subscriber/getInfo", + "queryParams": { + "subscriber_id": "$subscriber_id" + } + } + }, + { + "name": "manychat_find_contacts_by_name", + "description": "Find contacts by full name (up to 100, no paging). Returns the same details as manychat_get_contact.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full name of the contact, e.g. Jane Doe." + } + }, + "required": [ + "name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/subscriber/findByName", + "queryParams": { + "name": "$name" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "manychat_find_contacts_by_custom_field", + "description": "Find contacts whose text or number custom field has a given value (up to 100, most recently updated first). Date and boolean fields cannot be searched.", + "parameters": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "description": "Custom field id from manychat_list_custom_fields (a text or number field)." + }, + "field_value": { + "type": "string", + "description": "Exact value to look for." + } + }, + "required": [ + "field_id", + "field_value" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/subscriber/findByCustomField", + "queryParams": { + "field_id": "$field_id", + "field_value": "$field_value" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "manychat_find_contact_by_email_or_phone", + "description": "Find one contact by email address or by phone number. Give exactly one of email or phone.", + "parameters": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Email address of the contact." + }, + "phone": { + "type": "string", + "description": "Phone number with country code, e.g. +15405550100." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/fb/subscriber/findBySystemField", + "queryParams": { + "email": "$email", + "phone": "$phone" + } + } + }, + { + "name": "manychat_tag_contact", + "description": "Add an existing tag to a contact, by tag name. Adding a tag can start automations that listen for it. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "tag_name": { + "type": "string", + "description": "Tag name as listed by manychat_list_tags." + } + }, + "required": [ + "subscriber_id", + "tag_name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/subscriber/addTagByName", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "tag_name": "$tag_name" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "manychat_untag_contact", + "description": "Remove a tag from one contact, by tag name. The tag itself stays in the account. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "tag_name": { + "type": "string", + "description": "Tag name as listed by manychat_list_tags." + } + }, + "required": [ + "subscriber_id", + "tag_name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/subscriber/removeTagByName", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "tag_name": "$tag_name" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "manychat_set_contact_fields", + "description": "Set one or more custom field values of a contact (up to 20 per call), each by field_id or field_name. Overwrites the current values and can start automations. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "fields": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "description": "Field id. Give field_id or field_name, not both." + }, + "field_name": { + "type": "string", + "description": "Field name, as listed." + }, + "field_value": { + "description": "New value: text as a string, number as a number, boolean as true or false, date as YYYY-MM-DD, datetime as YYYY-MM-DDTHH:MM:SS+00:00." + } + }, + "required": [ + "field_value" + ] + }, + "description": "Fields to set, e.g. [{\"field_name\": \"City\", \"field_value\": \"Lyon\"}]." + } + }, + "required": [ + "subscriber_id", + "fields" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/subscriber/setCustomFields", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "fields": "$fields" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "manychat_create_contact", + "description": "Create a contact from a phone number, WhatsApp number or email (at least one). An SMS phone needs has_opt_in_sms and, when true, consent_phrase; an email needs has_opt_in_email. A whatsapp_phone alone creates a WhatsApp contact. Fails if the contact exists. Importing contacts needs a paid plan. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "description": "First name." + }, + "last_name": { + "type": "string", + "description": "Last name." + }, + "phone": { + "type": "string", + "description": "SMS phone number with country code, e.g. +15405550100." + }, + "whatsapp_phone": { + "type": "string", + "description": "WhatsApp number with country code, e.g. +15405550100." + }, + "email": { + "type": "string", + "description": "Email address." + }, + "gender": { + "type": "string", + "description": "Gender, e.g. male or female." + }, + "has_opt_in_sms": { + "type": "boolean", + "description": "Whether the contact agreed to receive SMS. Required with phone." + }, + "has_opt_in_email": { + "type": "boolean", + "description": "Whether the contact agreed to receive email. Required with email." + }, + "consent_phrase": { + "type": "string", + "description": "The consent text the contact agreed to. Required when has_opt_in_sms is true." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/subscriber/createSubscriber", + "bodyMapping": { + "first_name": "$first_name", + "last_name": "$last_name", + "phone": "$phone", + "whatsapp_phone": "$whatsapp_phone", + "email": "$email", + "gender": "$gender", + "has_opt_in_sms": "$has_opt_in_sms", + "has_opt_in_email": "$has_opt_in_email", + "consent_phrase": "$consent_phrase" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "manychat_update_contact", + "description": "Update a contact's name, gender, SMS phone, email and their opt-ins. The WhatsApp number cannot be changed here. Same opt-in rules as manychat_create_contact. Overwrites the values sent. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "first_name": { + "type": "string", + "description": "First name." + }, + "last_name": { + "type": "string", + "description": "Last name." + }, + "phone": { + "type": "string", + "description": "SMS phone number with country code." + }, + "email": { + "type": "string", + "description": "Email address." + }, + "gender": { + "type": "string", + "description": "Gender." + }, + "has_opt_in_sms": { + "type": "boolean", + "description": "SMS opt-in. Required with phone." + }, + "has_opt_in_email": { + "type": "boolean", + "description": "Email opt-in. Required with email." + }, + "consent_phrase": { + "type": "string", + "description": "Consent text. Required when has_opt_in_sms is true." + } + }, + "required": [ + "subscriber_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/subscriber/updateSubscriber", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "first_name": "$first_name", + "last_name": "$last_name", + "phone": "$phone", + "email": "$email", + "gender": "$gender", + "has_opt_in_sms": "$has_opt_in_sms", + "has_opt_in_email": "$has_opt_in_email", + "consent_phrase": "$consent_phrase" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "manychat_create_tag", + "description": "Create a new tag in the account, to use with manychat_tag_contact. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the new tag." + } + }, + "required": [ + "name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/page/createTag", + "bodyMapping": { + "name": "$name" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "manychat_delete_tag", + "description": "Delete a tag from the account and from every contact that has it. Automations using it lose it, and it cannot be undone. Installed switched off. Name the tag and the effect and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "tag_name": { + "type": "string", + "description": "Name of the tag to delete, as listed by manychat_list_tags." + } + }, + "required": [ + "tag_name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/page/removeTagByName", + "bodyMapping": { + "tag_name": "$tag_name" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + }, + "enabled": false + }, + { + "name": "manychat_create_custom_field", + "description": "Create a contact (custom) field. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "caption": { + "type": "string", + "description": "Name of the new field." + }, + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date", + "datetime", + "boolean" + ], + "description": "Field type." + }, + "description": { + "type": "string", + "description": "Optional description." + } + }, + "required": [ + "caption", + "type" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/page/createCustomField", + "bodyMapping": { + "caption": "$caption", + "type": "$type", + "description": "$description" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "manychat_set_bot_fields", + "description": "Set one or more bot field values (account-wide, up to 20 per call), each by field_id or field_name. Every automation that reads the field sees the new value. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "description": "Field id. Give field_id or field_name, not both." + }, + "field_name": { + "type": "string", + "description": "Field name, as listed." + }, + "field_value": { + "description": "New value: text as a string, number as a number, boolean as true or false, date as YYYY-MM-DD, datetime as YYYY-MM-DDTHH:MM:SS+00:00." + } + }, + "required": [ + "field_value" + ] + }, + "description": "Bot fields to set, e.g. [{\"field_name\": \"promo_code\", \"field_value\": \"FALL10\"}]." + } + }, + "required": [ + "fields" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/page/setBotFields", + "bodyMapping": { + "fields": "$fields" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "manychat_send_flow", + "description": "Start an automation (flow) for one contact, which sends its messages on the contact's channel. Messaging rules apply: outside the 24-hour window only content the channel allows goes out (on WhatsApp, a flow that starts with an approved template). This sends real messages: name the contact and the flow and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "flow_ns": { + "type": "string", + "description": "Flow ns id from manychat_list_flows, e.g. content20240101000000_123456." + } + }, + "required": [ + "subscriber_id", + "flow_ns" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/sending/sendFlow", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "flow_ns": "$flow_ns" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + } + }, + { + "name": "manychat_send_text_message", + "description": "Send a plain text message to one contact on Messenger, Instagram, WhatsApp or Telegram. Only inside the 24-hour window after the contact's last message (check last_interaction). This sends a real message: show the user the recipient and the exact text and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "channel": { + "type": "string", + "enum": [ + "instagram", + "whatsapp", + "telegram" + ], + "description": "Channel of the contact. Leave empty for Facebook Messenger." + }, + "text": { + "type": "string", + "description": "Message text." + } + }, + "required": [ + "subscriber_id", + "text" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/sending/sendContent", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "data": { + "version": "v2", + "content": { + "type": "$channel", + "messages": [ + { + "type": "text", + "text": "$text" + } + ] + } + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + } + }, + { + "name": "manychat_send_content", + "description": "Send rich content to one contact in Manychat's v2 content format: up to 10 messages (text, image, video, audio, file, cards) with buttons, actions and quick replies. What a channel accepts differs (WhatsApp: text with url or flow buttons only). Only inside the 24-hour window. This sends a real message: show the user the recipient and the content and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { + "type": "integer", + "description": "Contact (subscriber) id: from manychat_find_contacts_by_name, manychat_find_contact_by_email_or_phone or the contact card in Manychat. Pass the digits exactly as returned." + }, + "data": { + "type": "object", + "description": "Content object, e.g. {\"version\": \"v2\", \"content\": {\"type\": \"instagram\", \"messages\": [{\"type\": \"text\", \"text\": \"Hi\", \"buttons\": [{\"type\": \"url\", \"caption\": \"Open\", \"url\": \"https://example.com\"}]}]}}. Omit content.type for Messenger." + }, + "message_tag": { + "type": "string", + "description": "Legacy Messenger message tag (e.g. ACCOUNT_UPDATE). Meta retired message tags on Messenger in February 2026; leave empty." + } + }, + "required": [ + "subscriber_id", + "data" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fb/sending/sendContent", + "bodyMapping": { + "subscriber_id": "$subscriber_id", + "data": "$data", + "message_tag": "$message_tag" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/manychat.live.spec.ts b/packages/backend/src/adapters/intl/manychat.live.spec.ts new file mode 100644 index 000000000..e1f16d06c --- /dev/null +++ b/packages/backend/src/adapters/intl/manychat.live.spec.ts @@ -0,0 +1,321 @@ +import axios from 'axios'; +import * as adapter from './manychat.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'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; +import { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { interpolateConnectorConfig } from '../../common/env-interpolation.util'; +import { describeAdapterEnvVars } from '../env-var-meta'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Two layers of verification for the Manychat adapter (Public API): + * + * 1. Static: always runs. Pins Bearer auth on api.manychat.com, every + * tool's method, path, query and JSON body through the real RestEngine + * against Manychat's published OpenAPI document (api.manychat.com/swagger, + * Page API), the v2 content body of the text send, the errorWhen rules + * for {"status": "error"} answers, and which tools write or install off. + * + * 2. Live: skipped unless RUN_MANYCHAT_LIVE=1 and MANYCHAT_API_KEY are set. + * Reads only (page info, tags, fields, flows, growth tools); it never + * tags, updates or messages a contact. + * + * RUN_MANYCHAT_LIVE=1 MANYCHAT_API_KEY=... npx jest src/adapters/intl/manychat.live.spec.ts + */ + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + const mocked = jest.fn(); + return { + __esModule: true, + default: Object.assign(mocked, actual.default, { __actual: actual.default }), + AxiosError: actual.AxiosError, + }; +}); +const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios }; + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: Record }; + annotations?: Record; +}; +const a = adapter as unknown as { + slug: string; + unlisted?: boolean; + aliases: string[]; + category: string; + icon: string; + instructions: string; + prerequisites: string; + requiredEnvVars: string[]; + envVarMeta: Record; + verifyHints: Record; + probe: { tool: string }; + connector: { + baseUrl: string; + authType: string; + authConfig: Record; + headers: Record; + healthcheckPath: string; + config: { errorWhen: unknown }; + }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +const annotationsOf = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + +// Built at run time so no literal here looks like a real key. +const KEY = ['1234567', ['0a1b2c3d', '4e5f6a7b', '8c9d0e1f', '2a3b4c5d'].join('')].join(':'); +const BASE = 'https://api.manychat.com'; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Mirrors DynamicMcpTools: {{VAR}} interpolated, env vars also passed as params. +const execute = (name: string, key: string, params: Record) => { + const env = { MANYCHAT_API_KEY: key }; + const { config, endpointMapping } = interpolateConnectorConfig( + { baseUrl: a.connector.baseUrl, headers: a.connector.headers }, + tool(name).endpointMapping, + env, + ); + return engine().execute( + { baseUrl: config.baseUrl, authType: a.connector.authType, authConfig: { token: key }, headers: config.headers, errorWhen: a.connector.config.errorWhen as never }, + endpointMapping, + { ...applySchemaDefaults(tool(name).parameters, params), ...env }, + ) as Promise; +}; +const call = (name: string, params: Record) => execute(name, KEY, params); +const sent = () => mockedAxios.mock.calls[0][0]; +const statusOf = (body: unknown): number | null => { + try { + assertNoResponseBodyError(body, a.connector.config.errorWhen as never); + return null; + } catch (e) { + expect(e).toBeInstanceOf(ResponseBodyError); + return (e as ResponseBodyError).status; + } +}; + +const WRITES = [ + 'manychat_tag_contact', + 'manychat_create_custom_field', + 'manychat_create_contact', + 'manychat_create_tag', + 'manychat_delete_tag', + 'manychat_untag_contact', + 'manychat_send_content', + 'manychat_send_flow', + 'manychat_send_text_message', + 'manychat_set_bot_fields', + 'manychat_set_contact_fields', + 'manychat_update_contact', +]; +const SENDS = ['manychat_send_content', 'manychat_send_flow', 'manychat_send_text_message']; + +describe('manychat adapter: static spec conformance', () => { + beforeEach(() => mockedAxios.mockReset()); + + it('is listed: checked against the official Page API OpenAPI document on 19 Oct 2026', () => { + expect(a.unlisted).toBeFalsy(); + expect(a.slug).toBe('manychat'); + expect(a.icon).toBe('manychat'); + expect(a.category).toBe('marketing-automation'); + }); + + it('sends the key as Bearer to api.manychat.com and probes with the page info', async () => { + expect(a.connector.baseUrl).toBe(BASE); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{MANYCHAT_API_KEY}}' }); + expect(a.connector.healthcheckPath).toBe('/fb/page/getInfo'); + expect(a.probe.tool).toBe('manychat_get_account'); + mockedAxios.mockResolvedValue({ data: { status: 'success', data: {} } }); + await call('manychat_get_account', {}); + expect(sent().headers.Authorization).toBe(`Bearer ${KEY}`); + expect(sent().url).toBe(`${BASE}/fb/page/getInfo`); + }); + + it('asks for the key as a secret in the page-id:hash form', () => { + expect(a.requiredEnvVars).toEqual(['MANYCHAT_API_KEY']); + const meta = a.envVarMeta.MANYCHAT_API_KEY; + expect(meta.secret).toBe(true); + expect(meta.kind).toBe('credential'); + const re = new RegExp(meta.pattern!); + expect(re.test(KEY)).toBe(true); + expect(re.test(meta.example!)).toBe(true); + for (const bad of ['', '0a1b2c3d4e5f', `Bearer ${KEY}`, `${KEY} `, 'abc:def']) expect(re.test(bad)).toBe(false); + expect(describeAdapterEnvVars(a as never).find((d) => d.name === 'MANYCHAT_API_KEY')!.secret).toBe(true); + }); + + it('says that the API needs the Pro plan', () => { + expect(a.instructions).toMatch(/Manychat Pro\*\* plan/); + expect(a.prerequisites).toMatch(/Pro plan/); + expect(a.envVarMeta.MANYCHAT_API_KEY).toMatchObject({ help: expect.stringContaining('Pro plan') }); + }); + + it('turns {"status": "error"} answers into failed calls', () => { + expect(describeErrorWhenProblems(a.connector.config.errorWhen as never)).toEqual([]); + expect(statusOf({ status: 'success', data: [] })).toBeNull(); + expect(statusOf({ status: 'error', message: 'Wrong token' })).toBe(401); + expect(statusOf({ status: 'error', message: 'Wrong format token' })).toBe(401); + expect(statusOf({ status: 'error', message: 'Validation error', details: { messages: [{ message: 'tag_name is required' }] } })).toBe(400); + expect(statusOf({ status: 'error', message: 'Content can not be sent', code: 3011 })).toBe(400); + expect(statusOf({ status: 'exception', message: 'Something went wrong' })).toBe(502); + }); + + it('prefixes every tool with manychat_ and shares no tool name with another adapter', () => { + const mine = new Set(a.tools.map((t) => t.name)); + expect(mine.size).toBe(a.tools.length); + for (const name of mine) expect(name).toMatch(/^manychat_[a-z_]+$/); + for (const meta of listAdapters()) { + if (meta.slug === a.slug) continue; + for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false); + } + }); + + it('marks reads read-only, asks for confirmation on every write and installs only the tag deletion switched off', () => { + for (const t of a.tools) expect(`${t.name}:${annotationsOf(t).readOnlyHint === true}`).toBe(`${t.name}:${t.endpointMapping.method === 'GET'}`); + expect(a.tools.filter((t) => t.endpointMapping.method !== 'GET').map((t) => t.name).sort()).toEqual([...WRITES].sort()); + for (const name of WRITES) expect(tool(name).description).toMatch(/confirm with the user first/i); + for (const name of SENDS) expect(tool(name).description).toMatch(/real message/); + expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['manychat_delete_tag']); + expect(annotationsOf(tool('manychat_delete_tag')).destructiveHint).toBe(true); + }); + + it('only points the model at tools that exist, and writes no em dashes', () => { + const names = new Set(a.tools.map((t) => t.name)); + const mentioned = [ + ...a.instructions.matchAll(/\bmanychat_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bmanychat_[a-z_]+/g)]), + ].map((m) => m[0]); + expect(mentioned.length).toBeGreaterThan(10); + for (const name of mentioned) expect(names).toContain(name); + expect(a.instructions.startsWith('## Manychat\n')).toBe(true); + for (const text of ['Settings > API', '24 hours', 'template', 'Rate limits', 'switched off']) expect(a.instructions).toContain(text); + expect(JSON.stringify(adapter)).not.toMatch(/[–—]/); + }); + + // [tool, args, method, path] from the Page API OpenAPI document. + const table: Array<[string, Record, string, string]> = [ + ['manychat_get_account', {}, 'GET', '/fb/page/getInfo'], + ['manychat_list_tags', {}, 'GET', '/fb/page/getTags'], + ['manychat_list_custom_fields', {}, 'GET', '/fb/page/getCustomFields'], + ['manychat_list_bot_fields', {}, 'GET', '/fb/page/getBotFields'], + ['manychat_list_flows', {}, 'GET', '/fb/page/getFlows'], + ['manychat_list_growth_tools', {}, 'GET', '/fb/page/getGrowthTools'], + ['manychat_get_contact', { subscriber_id: 1234567890 }, 'GET', '/fb/subscriber/getInfo'], + ['manychat_find_contacts_by_name', { name: 'Jane Doe' }, 'GET', '/fb/subscriber/findByName'], + ['manychat_find_contacts_by_custom_field', { field_id: 42, field_value: 'Lyon' }, 'GET', '/fb/subscriber/findByCustomField'], + ['manychat_find_contact_by_email_or_phone', { email: 'jane@example.com' }, 'GET', '/fb/subscriber/findBySystemField'], + ['manychat_tag_contact', { subscriber_id: 1234567890, tag_name: 'vip' }, 'POST', '/fb/subscriber/addTagByName'], + ['manychat_untag_contact', { subscriber_id: 1234567890, tag_name: 'vip' }, 'POST', '/fb/subscriber/removeTagByName'], + ['manychat_set_contact_fields', { subscriber_id: 1234567890, fields: [{ field_name: 'City', field_value: 'Lyon' }] }, 'POST', '/fb/subscriber/setCustomFields'], + ['manychat_create_contact', { whatsapp_phone: '+15405550100' }, 'POST', '/fb/subscriber/createSubscriber'], + ['manychat_update_contact', { subscriber_id: 1234567890, first_name: 'Jane' }, 'POST', '/fb/subscriber/updateSubscriber'], + ['manychat_create_tag', { name: 'vip' }, 'POST', '/fb/page/createTag'], + ['manychat_delete_tag', { tag_name: 'vip' }, 'POST', '/fb/page/removeTagByName'], + ['manychat_create_custom_field', { caption: 'City', type: 'text' }, 'POST', '/fb/page/createCustomField'], + ['manychat_set_bot_fields', { fields: [{ field_name: 'promo_code', field_value: 'FALL10' }] }, 'POST', '/fb/page/setBotFields'], + ['manychat_send_flow', { subscriber_id: 1234567890, flow_ns: 'content20240101000000_123456' }, 'POST', '/fb/sending/sendFlow'], + ['manychat_send_text_message', { subscriber_id: 1234567890, text: 'Hi' }, 'POST', '/fb/sending/sendContent'], + ['manychat_send_content', { subscriber_id: 1234567890, data: { version: 'v2', content: { messages: [] } } }, 'POST', '/fb/sending/sendContent'], + ]; + + it('lists every tool in the URL table', () => { + expect(table.map(([name]) => name).sort()).toEqual(a.tools.map((t) => t.name).sort()); + }); + + it.each(table)('%s sends %s to %s', async (name, params, method, path) => { + mockedAxios.mockResolvedValue({ data: { status: 'success' } }); + await call(name, params); + expect(sent().method).toBe(method); + expect(sent().url).toBe(`${BASE}${path}`); + if (method === 'GET') expect(sent().data).toBeUndefined(); + }); + + it('sends lookups as query parameters', async () => { + mockedAxios.mockResolvedValue({ data: { status: 'success', data: {} } }); + await call('manychat_find_contact_by_email_or_phone', { phone: '+15405550100' }); + expect(sent().params).toEqual({ phone: '+15405550100' }); + mockedAxios.mockClear(); + await call('manychat_find_contacts_by_custom_field', { field_id: 42, field_value: 'Lyon' }); + expect(sent().params).toEqual({ field_id: 42, field_value: 'Lyon' }); + }); + + it('sends writes as the JSON bodies the OpenAPI document defines', async () => { + mockedAxios.mockResolvedValue({ data: { status: 'success' } }); + await call('manychat_tag_contact', { subscriber_id: 1234567890, tag_name: 'vip' }); + expect(sent().data).toEqual({ subscriber_id: 1234567890, tag_name: 'vip' }); + mockedAxios.mockClear(); + const fields = [{ field_id: 7, field_value: 3 }, { field_name: 'City', field_value: 'Lyon' }]; + await call('manychat_set_contact_fields', { subscriber_id: 1234567890, fields }); + expect(sent().data).toEqual({ subscriber_id: 1234567890, fields }); + mockedAxios.mockClear(); + await call('manychat_create_custom_field', { caption: 'City', type: 'text' }); + expect(sent().data).toEqual({ caption: 'City', type: 'text' }); + mockedAxios.mockClear(); + await call('manychat_create_contact', { email: 'jane@example.com', has_opt_in_email: true, first_name: 'Jane' }); + expect(sent().data).toEqual({ email: 'jane@example.com', has_opt_in_email: true, first_name: 'Jane' }); + mockedAxios.mockClear(); + await call('manychat_send_flow', { subscriber_id: 1234567890, flow_ns: 'content20240101000000_123456' }); + expect(sent().data).toEqual({ subscriber_id: 1234567890, flow_ns: 'content20240101000000_123456' }); + expect(tool('manychat_update_contact').parameters.properties).not.toHaveProperty('whatsapp_phone'); + }); + + it('builds a v2 text message, with the channel type only when it is not Messenger', async () => { + mockedAxios.mockResolvedValue({ data: { status: 'success' } }); + await call('manychat_send_text_message', { subscriber_id: 1234567890, text: 'Your order shipped.' }); + expect(sent().data).toEqual({ subscriber_id: 1234567890, data: { version: 'v2', content: { messages: [{ type: 'text', text: 'Your order shipped.' }] } } }); + mockedAxios.mockClear(); + await call('manychat_send_text_message', { subscriber_id: 1234567890, channel: 'whatsapp', text: 'Hi' }); + expect(sent().data).toEqual({ subscriber_id: 1234567890, data: { version: 'v2', content: { type: 'whatsapp', messages: [{ type: 'text', text: 'Hi' }] } } }); + }); + + it('passes rich content through unchanged', async () => { + mockedAxios.mockResolvedValue({ data: { status: 'success' } }); + const data = { version: 'v2', content: { type: 'instagram', messages: [{ type: 'text', text: 'Hi', buttons: [{ type: 'url', caption: 'Open', url: 'https://example.com' }] }] } }; + await call('manychat_send_content', { subscriber_id: 1234567890, data }); + expect(sent().data).toEqual({ subscriber_id: 1234567890, data }); + }); + + it('caps batch field updates at 20, as Manychat does', () => { + expect(tool('manychat_set_contact_fields').parameters.properties!.fields.maxItems).toBe(20); + expect(tool('manychat_set_bot_fields').parameters.properties!.fields.maxItems).toBe(20); + }); +}); + +const LIVE_KEY = process.env.MANYCHAT_API_KEY; +const live = process.env.RUN_MANYCHAT_LIVE === '1' && LIVE_KEY ? describe : describe.skip; + +live('manychat adapter: live Page API (reads only)', () => { + beforeAll(() => { + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + }); + const run = (name: string, params: Record = {}): Promise => execute(name, LIVE_KEY as string, params); + + it('reads the page, tags, fields, flows and growth tools', async () => { + const page = await run('manychat_get_account'); + expect(page.status).toBe('success'); + expect(page.data.id).toBeDefined(); + for (const name of ['manychat_list_tags', 'manychat_list_custom_fields', 'manychat_list_bot_fields', 'manychat_list_growth_tools']) { + expect(Array.isArray((await run(name)).data)).toBe(true); + } + expect(Array.isArray((await run('manychat_list_flows')).data.flows)).toBe(true); + }, 60_000); + + it('a wrong key is refused', async () => { + await expect(execute('manychat_get_account', '1:0000000000', {})).rejects.toBeDefined(); + }, 30_000); +}); diff --git a/packages/backend/src/adapters/intl/mirakl.json b/packages/backend/src/adapters/intl/mirakl.json index 283f7d528..0151f6242 100644 --- a/packages/backend/src/adapters/intl/mirakl.json +++ b/packages/backend/src/adapters/intl/mirakl.json @@ -1,8 +1,9 @@ { "slug": "mirakl", "name": "Mirakl Seller API", - "description": "Run your shop on any Mirakl-powered marketplace (Leroy Merlin, Conforama, BUT, home24, Maisons du Monde, Vente-unique, ePrice, Decathlon, Carrefour and many more) from an AI agent: orders, acceptance, tracking and shipping, offers with prices and stock, offer and product imports, customer and operator messages, returns, invoices and transactions. One install per marketplace.", - "instructions": "## Mirakl Seller API\nThis connector uses the **Mirakl Marketplace Platform (MMP) shop API**, the same REST API on every marketplace that runs on Mirakl. Each marketplace operator has its own Mirakl instance, so **one install covers one marketplace**: install the connector once per marketplace you sell on (for example once for Leroy Merlin and once for Conforama) and give each install a name that says which one it is.\n\n**Setup (about 5 minutes per marketplace)**\n1. Sign in to the marketplace's seller back office (the Mirakl portal the operator gave you).\n2. Copy the address of the portal without any path into `MIRAKL_URL`, e.g. `https://adeo-marketplace.mirakl.net` or `https://conforama-prod.mirakl.net`. Some operators use their own domain (e.g. `https://marketplace.eprice.it`); that works the same way. The connector adds `/api` itself, so do not paste it.\n3. Click your user name in the top right corner, open **My user settings** and the **API Key** tab, and generate a key (or copy the existing one). Paste it into `MIRAKL_API_KEY`. The key is sent as is in the `Authorization` header, without `Bearer`. Generating a new key invalidates the old one, so any other tool using it stops working.\n4. Only if your user manages several shops on this marketplace: set `MIRAKL_SHOP_ID` to the shop to work on (shown in the back office and in `mirakl_get_account`). Without it Mirakl uses your default shop.\n5. Install and run `mirakl_get_account`.\n\n**Order workflow**: a new order waits in `WAITING_ACCEPTANCE`. `mirakl_accept_order` accepts (or refuses) every line; after the customer is debited the order moves to `SHIPPING`. Then `mirakl_update_tracking` stores the carrier and tracking number (carrier codes come from `mirakl_list_carriers`) and `mirakl_ship_order` confirms the shipment (`SHIPPED`). Later states are `TO_COLLECT`, `RECEIVED`, `CLOSED`, `REFUSED` and `CANCELED`. Use `mirakl_list_orders` with `order_state_codes=WAITING_ACCEPTANCE` for the daily to-do list and `has_incident=true` for open incidents.\n\n**Writes: confirm first.** `mirakl_accept_order`, `mirakl_update_tracking`, `mirakl_ship_order`, `mirakl_update_offers`, `mirakl_reply_thread`, `mirakl_create_order_thread` and `mirakl_import_products` act on the live marketplace. Before each call, show the user what will change (order id and lines, tracking number, offer SKU with the old and new price or stock, the message text and recipients) and wait for a yes. Refusing an order line cancels it for good. `mirakl_import_offers` can delete offers (REPLACE mode deletes every offer missing from the file), so it is installed switched off.\n\n**Updating offers (prices and stock)**: `mirakl_update_offers` (OF24) replaces the whole offer. Every field you leave out is reset: a missing `quantity` becomes 0, a missing `discount` or `description` is deleted. Always read the offer first (`mirakl_get_offer` or `mirakl_list_offers` with `sku`) and send it back complete with only the intended change: `shop_sku`, `product_id` and `product_id_type` (from `product_references`), `state_code`, `price`, `quantity`, `leadtime_to_ship`, `logistic_class` (its code), `min_quantity_alert`, `description`, `discount`, `all_prices`, `available_started` / `available_ended` (read as `available_start_date` / `available_end_date`) and any `offer_additional_fields`. Leave `update_delete` empty or `update`. The call returns an `import_id`; the change is applied asynchronously, so check it with `mirakl_get_offer_import` before reading the offer again. Condition codes for `state_code` come from `mirakl_list_offer_conditions`.\n\n**File imports**: `mirakl_import_products` (P41) and `mirakl_import_offers` (OF01) take the URL of a CSV, XLSX or XML file in the operator's format; the connector downloads it and uploads it to Mirakl. Track them with `mirakl_list_product_imports` / `mirakl_get_product_import` and `mirakl_list_offer_imports` / `mirakl_get_offer_import`.\n\n**Messages**: `mirakl_list_threads` lists conversations (filter with `entity_type=MMP_ORDER` and `entity_id=` for one order), `mirakl_get_thread` reads one with its messages, `mirakl_reply_thread` answers (recipient `CUSTOMER` or `OPERATOR`) and `mirakl_create_order_thread` opens a new conversation on an order. Reason codes for topics come from `mirakl_list_reasons`.\n\n**Money**: `mirakl_list_invoices` (IV01) lists the operator's accounting documents and `mirakl_list_transactions` (TL02) the transaction lines (sales, commissions, refunds, payouts). On marketplaces with Enhanced Seller Invoicing IV01 returns no new documents; use the transactions then.\n\n**Pagination**: orders, offers, invoices and product imports page with `max` (at most 100) and `offset`. Threads, returns, transactions and offer imports return a `next_page_token`; pass it as `page_token` to get the next page (all other filters are then ignored) until it is missing.\n\n**Dates** are ISO 8601, e.g. `2026-10-01T00:00:00Z`; Mirakl answers in UTC.\n\n**Rate limits**: Mirakl limits each API separately and the operator can tighten them. Listing offers and orders is meant for interactive use, product and offer imports at most every 15 minutes, `mirakl_get_account` and `mirakl_list_carriers` about once a day. A 429 carries a `Retry-After` header with the seconds to wait; do not retry in a loop.\n\n**Errors**: 401 means the key is wrong or belongs to another marketplace, 403 that your user lacks the right for this action, 404 that the order, offer or thread does not exist for this shop (or that `MIRAKL_URL` is not a Mirakl instance), 400 a wrong parameter or an order in the wrong state (the message names it, e.g. `ORDER_INVALID_STATE`).", + "aliases": ["Leroy Merlin", "ADEO", "Conforama", "BUT", "home24", "Maisons du Monde", "Vente-unique", "ePrice", "Mirakl Marketplace Platform"], + "description": "Run your shop on any Mirakl-powered marketplace (Leroy Merlin, Conforama, BUT, home24, Maisons du Monde, Vente-unique, ePrice, Decathlon, Carrefour and many more) from an AI agent: orders, acceptance, tracking and shipping, incidents, offers with prices and stock, offer and product imports, customer and operator messages, returns, order documents, invoices and transactions. One install per marketplace.", + "instructions": "## Mirakl Seller API\nThis connector uses the **Mirakl Marketplace Platform (MMP) shop API**, the same REST API on every marketplace that runs on Mirakl. Each marketplace operator has its own Mirakl instance, so **one install covers one marketplace**: install the connector once per marketplace you sell on (for example once for Leroy Merlin and once for Conforama) and give each install a name that says which one it is.\n\n**Setup (about 5 minutes per marketplace)**\n1. Sign in to the marketplace's seller back office (the Mirakl portal the operator gave you).\n2. Copy the address of the portal without any path into `MIRAKL_URL`. The connector adds `/api` itself, so do not paste it. Addresses of common marketplaces:\n - Leroy Merlin (ADEO): `https://adeo-marketplace.mirakl.net`\n - BUT: `https://but-prod.mirakl.net`\n - Conforama: `https://conforama-prod.mirakl.net`\n - home24: `https://home24.mirakl.net`\n - Maisons du Monde: `https://maisonsdumonde-prod.mirakl.net`\n - Vente-unique: `https://venteunique-prod.mirakl.net`\n - ePrice: `https://marketplace.eprice.it`\n Other operators work the same way, also those on their own domain: use the address of the seller back office you sign in to.\n3. Click your user name in the top right corner, open **My user settings** and the **API Key** tab, and generate a key (or copy the existing one). Paste it into `MIRAKL_API_KEY`. The key is sent as is in the `Authorization` header, without `Bearer`. Generating a new key invalidates the old one, so any other tool using it stops working.\n4. Only if your user manages several shops on this marketplace (common on home24 and Vente-unique): set `MIRAKL_SHOP_ID` to the shop to work on (shown in the back office and in `mirakl_get_account`). Without it Mirakl uses your default shop.\n5. Install and run `mirakl_get_account`.\n\n**Order workflow**: a new order waits in `WAITING_ACCEPTANCE`. `mirakl_accept_order` accepts (or refuses) every line; after the customer is debited the order moves to `SHIPPING`. Then `mirakl_update_tracking` stores the carrier and tracking number (carrier codes come from `mirakl_list_carriers`) and `mirakl_ship_order` confirms the shipment (`SHIPPED`). Later states are `TO_COLLECT`, `RECEIVED`, `CLOSED`, `REFUSED` and `CANCELED`. Use `mirakl_list_orders` with `order_state_codes=WAITING_ACCEPTANCE` for the daily to-do list and `has_incident=true` for open incidents; `mirakl_resolve_incident` closes an incident on one order line once it is solved. Documents attached to orders (delivery notes, labels, customer invoices) are listed by `mirakl_list_order_documents`.\n\n**Writes: confirm first.** `mirakl_accept_order`, `mirakl_update_tracking`, `mirakl_ship_order`, `mirakl_resolve_incident`, `mirakl_accept_return`, `mirakl_receive_return`, `mirakl_update_offers`, `mirakl_reply_thread`, `mirakl_create_order_thread` and `mirakl_import_products` act on the live marketplace. Before each call, show the user what will change (order id and lines, tracking number, offer SKU with the old and new price or stock, the message text and recipients) and wait for a yes. Refusing an order line cancels it for good. No tool here refunds, cancels orders or moves money; do that in the back office. `mirakl_import_offers` can delete offers (REPLACE mode deletes every offer missing from the file), so it is installed switched off.\n\n**Updating offers (prices and stock)**: `mirakl_update_offers` (OF24) replaces the whole offer. Every field you leave out is reset: a missing `quantity` becomes 0, a missing `discount` or `description` is deleted. Always read the offer first (`mirakl_get_offer` or `mirakl_list_offers` with `sku`) and send it back complete with only the intended change: `shop_sku`, `product_id` and `product_id_type` (from `product_references`), `state_code`, `price`, `quantity`, `leadtime_to_ship`, `logistic_class` (its code), `min_quantity_alert`, `description`, `discount`, `all_prices`, `available_started` / `available_ended` (read as `available_start_date` / `available_end_date`) and any `offer_additional_fields`. Leave `update_delete` empty or `update`. The call returns an `import_id`; the change is applied asynchronously, so check it with `mirakl_get_offer_import` before reading the offer again. Condition codes for `state_code` come from `mirakl_list_offer_conditions`, logistic class codes from `mirakl_list_logistic_classes`.\n\n**File imports**: `mirakl_import_products` (P41) and `mirakl_import_offers` (OF01) take the URL of a CSV, XLSX or XML file in the operator's format; the connector downloads it and uploads it to Mirakl. Track them with `mirakl_list_product_imports` / `mirakl_get_product_import` and `mirakl_list_offer_imports` / `mirakl_get_offer_import`.\n\n**Returns**: `mirakl_list_returns` (RT11) lists return requests. Where the operator lets the shop decide, `mirakl_accept_return` accepts or refuses one in `WAITING_ACCEPTANCE` and `mirakl_receive_return` confirms the goods arrived. Refunds stay in the back office.\n\n**Messages**: `mirakl_list_threads` lists conversations (filter with `entity_type=MMP_ORDER` and `entity_id=` for one order; Mirakl refuses `entity_id` without `entity_type`), `mirakl_get_thread` reads one with its messages, `mirakl_reply_thread` answers (recipient `CUSTOMER` or `OPERATOR`) and `mirakl_create_order_thread` opens a new conversation on an order. Reason codes for topics come from `mirakl_list_reasons`; leave its `locale` empty unless you know the marketplace has that language (an inactive locale answers 400).\n\n**Money**: `mirakl_list_invoices` (IV01) lists the operator's accounting documents and `mirakl_list_transactions` (TL02) the transaction lines (sales, commissions, refunds, payouts). On marketplaces with Enhanced Seller Invoicing IV01 returns no new documents; use `mirakl_list_document_requests` (DR11) and the transactions then.\n\n**Reference lists**: `mirakl_list_channels` (country sites), `mirakl_list_carriers`, `mirakl_list_shipping_zones`, `mirakl_list_shipping_methods` and `mirakl_list_logistic_classes` change rarely; read them once per conversation.\n\n**Pagination**: orders, offers, invoices and product imports page with `max` (at most 100) and `offset`. Threads, returns, transactions, document requests and offer imports return a `next_page_token`; pass it as `page_token` to get the next page (all other filters are then ignored) until it is missing.\n\n**Dates** are ISO 8601, e.g. `2026-10-01T00:00:00Z`; Mirakl answers in UTC.\n\n**Rate limits**: Mirakl limits each API separately and the operator can tighten them. Listing offers and orders is meant for interactive use, product and offer imports at most every 15 minutes, `mirakl_get_account` and the reference lists about once a day. A 429 carries a `Retry-After` header with the seconds to wait; do not retry in a loop.\n\n**Errors**: 401 means the key is wrong or belongs to another marketplace, 403 that your user lacks the right for this action, 404 that the order, offer or thread does not exist for this shop (or that `MIRAKL_URL` is not a Mirakl instance), 400 a wrong parameter or an order in the wrong state (the message names it, e.g. `ORDER_INVALID_STATE`).", "region": "intl", "category": "e-commerce", "icon": "mirakl", @@ -590,7 +591,7 @@ "properties": { "locale": { "type": "string", - "description": "Language of the labels, e.g. fr_FR or en_US." + "description": "Language of the labels, e.g. fr_FR. Only a locale active on this marketplace works (others answer 400); leave empty for the default." } } }, @@ -739,6 +740,164 @@ } } }, + { + "name": "mirakl_list_document_requests", + "description": "List accounting document requests (DR11), used on marketplaces with Enhanced Seller Invoicing: invoices and credit notes the operator or the shop issues, with state, type, issuer, document number, amounts and payment state. Filter by date, state, type or document number. Pass next_page_token back as page_token for more.", + "parameters": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["STAGING", "TO_PROCESS", "ISSUED", "CANCELED"], + "description": "Only requests in this state. TO_PROCESS are documents the shop still has to issue." + }, + "type": { + "type": "string", + "enum": ["INVOICE", "CREDIT_NOTE"], + "description": "Only invoices or only credit notes." + }, + "issuer_type": { + "type": "string", + "enum": ["OPERATOR", "SHOP"], + "description": "Only documents issued by the operator, or by the shop." + }, + "document_number": { + "type": "string", + "description": "Only the request with this accounting document number." + }, + "date_created_from": { + "type": "string", + "description": "Requests created after this time, ISO 8601." + }, + "date_created_to": { + "type": "string", + "description": "Requests created before this time, ISO 8601." + }, + "issue_date_from": { + "type": "string", + "description": "Requests issued after this time, ISO 8601." + }, + "issue_date_to": { + "type": "string", + "description": "Requests issued before this time, ISO 8601." + }, + "payment_state": { + "type": "string", + "enum": ["PAID", "PENDING"], + "description": "Only paid or pending documents (purchase order and fee requests only)." + }, + "limit": { + "type": "integer", + "description": "Requests per page.", + "default": 20 + }, + "page_token": { + "type": "string", + "description": "next_page_token from the previous page. All other filters are then ignored." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/document-request/requests", + "queryParams": { + "state": "$state", + "type": "$type", + "issuer_type": "$issuer_type", + "document_number": "$document_number", + "date_created_from": "$date_created_from", + "date_created_to": "$date_created_to", + "issue_date_from": "$issue_date_from", + "issue_date_to": "$issue_date_to", + "payment_state": "$payment_state", + "limit": "$limit", + "page_token": "$page_token", + "shop_id": "${MIRAKL_SHOP_ID}" + } + } + }, + { + "name": "mirakl_list_order_documents", + "description": "List the documents attached to one or more orders (OR72): delivery notes, shipping labels, customer invoices and other files, with id, file name, type code and upload date. Download links are not part of this tool; open the file in the back office.", + "parameters": { + "type": "object", + "properties": { + "order_ids": { + "type": "string", + "description": "Comma-separated order ids, e.g. 1234567-A,1234568-A." + } + }, + "required": ["order_ids"] + }, + "endpointMapping": { + "method": "GET", + "path": "/orders/documents", + "queryParams": { + "order_ids": "$order_ids", + "shop_id": "${MIRAKL_SHOP_ID}" + } + } + }, + { + "name": "mirakl_list_channels", + "description": "List the marketplace's sales channels (CH11), usually one per country site, with code and label. Use the codes as channel_codes in mirakl_list_orders and mirakl_list_product_offers. Changes rarely: call it once, not on every question.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/channels", + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + } + } + }, + { + "name": "mirakl_list_shipping_zones", + "description": "List the active shipping zones of the marketplace (SH11) with code and label, e.g. mainland France or Corsica. Changes rarely: call it once, not on every question.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/shipping/zones", + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + } + } + }, + { + "name": "mirakl_list_shipping_methods", + "description": "List the active shipping methods of the marketplace (SH12), e.g. standard, express or pick-up, with code, label, whether a tracking number is mandatory and whether it is click and collect. The code appears as shipping_type_code on orders. Changes rarely: call it once, not on every question.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/shipping/types", + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + } + } + }, + { + "name": "mirakl_list_logistic_classes", + "description": "List the marketplace's logistic classes (SH31), the size or weight bands that decide shipping prices, with code and label. Use the code as logistic_class in mirakl_update_offers. Changes rarely: call it once, not on every question.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/shipping/logistic_classes", + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + } + } + }, { "name": "mirakl_accept_order", "description": "Accept or refuse the lines of an order waiting for acceptance (OR21). Every line of the order must be listed. Refusing a line cancels it for good. Acts on the live marketplace: name the order and the decision per line and get a yes from the user first.", @@ -1079,6 +1238,121 @@ "idempotentHint": false } }, + { + "name": "mirakl_resolve_incident", + "description": "Mark the open incident on one order line as resolved (OR64), once the customer's problem is solved. reason_code must be an INCIDENT_CLOSE reason from mirakl_list_reasons. Closes the incident on the live marketplace: name the order, the line and the reason and get a yes from the user first.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Order id, e.g. 1234567-A." + }, + "order_line_id": { + "type": "string", + "description": "Order line id with the open incident, e.g. 1234567-A-1 (order_line_id in mirakl_list_orders)." + }, + "reason_code": { + "type": "string", + "description": "Code of an INCIDENT_CLOSE reason from mirakl_list_reasons." + } + }, + "required": ["order_id", "order_line_id", "reason_code"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/orders/{order_id}/lines/{order_line_id}/resolve_incident", + "encodePathParams": true, + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + }, + "bodyMapping": { + "reason_code": "$reason_code" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "mirakl_accept_return", + "description": "Accept or refuse a return request in WAITING_ACCEPTANCE (RT21). Only on marketplaces that use Mirakl returns and leave the decision to the shop. Refusing needs a rejection_reason_code. This only decides the request; refunds are not made by this tool. Acts on the live marketplace: name the return, the decision and the reason and get a yes from the user first.", + "parameters": { + "type": "object", + "properties": { + "return_id": { + "type": "string", + "description": "Return id from mirakl_list_returns." + }, + "accepted": { + "type": "boolean", + "description": "true accepts the return, false refuses it." + }, + "rejection_reason_code": { + "type": "string", + "enum": ["RETURN_REJECTION_NOT_ELIGIBLE", "RETURN_REJECTION_NOT_NEEDED", "RETURN_REJECTION_WINDOW_CLOSED"], + "description": "Required when accepted is false: not eligible, no return needed, or return window closed." + } + }, + "required": ["return_id", "accepted"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/returns/accept", + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + }, + "bodyMapping": { + "returns": [ + { + "id": "$return_id", + "accepted": "$accepted", + "rejection_reason_code": "$rejection_reason_code" + } + ] + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "mirakl_receive_return", + "description": "Confirm that the goods of an accepted return arrived at your warehouse (RT25). The return moves to RECEIVED. Acts on the live marketplace: name the return and get a yes from the user first.", + "parameters": { + "type": "object", + "properties": { + "return_id": { + "type": "string", + "description": "Return id from mirakl_list_returns." + } + }, + "required": ["return_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/returns/receive", + "queryParams": { + "shop_id": "${MIRAKL_SHOP_ID}" + }, + "bodyMapping": { + "returns": [ + { + "id": "$return_id" + } + ] + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, { "name": "mirakl_import_products", "description": "Send a product file to the marketplace (P41) to create or enrich products in its catalog. file_url is the address of a CSV, XLSX or XML file in the operator's product format; the connector downloads it (up to 10 MB) and uploads it. Returns an import_id to follow with mirakl_get_product_import. Mirakl allows one import every 15 minutes. Changes the marketplace catalog: describe the file and get a yes from the user first.", diff --git a/packages/backend/src/adapters/intl/mirakl.live.spec.ts b/packages/backend/src/adapters/intl/mirakl.live.spec.ts index c1ad44050..355799a90 100644 --- a/packages/backend/src/adapters/intl/mirakl.live.spec.ts +++ b/packages/backend/src/adapters/intl/mirakl.live.spec.ts @@ -22,7 +22,7 @@ import { getAdapter, listAdapters } from '../catalog'; * * 2. Live: skipped unless MIRAKL_URL and MIRAKL_API_KEY are set. Reads * only: the shop, orders, offers, imports, threads, returns, reasons, - * carriers, invoices and transactions. + * carriers and the other reference lists, invoices and transactions. * * MIRAKL_URL=https://.mirakl.net MIRAKL_API_KEY=... \ * npx jest src/adapters/intl/mirakl.live.spec.ts @@ -124,10 +124,13 @@ const formPart = (form: FormData, name: string) => { const WRITES = [ 'mirakl_accept_order', + 'mirakl_accept_return', 'mirakl_create_order_thread', 'mirakl_import_offers', 'mirakl_import_products', + 'mirakl_receive_return', 'mirakl_reply_thread', + 'mirakl_resolve_incident', 'mirakl_ship_order', 'mirakl_update_offers', 'mirakl_update_tracking', @@ -263,12 +266,21 @@ describe('mirakl adapter: static spec conformance', () => { ['mirakl_list_carriers', {}, 'GET', '/shipping/carriers', 'SH21'], ['mirakl_list_invoices', {}, 'GET', '/invoices', 'IV01'], ['mirakl_list_transactions', {}, 'GET', '/sellerpayment/transactions_logs', 'TL02'], + ['mirakl_list_document_requests', {}, 'GET', '/document-request/requests', 'DR11'], + ['mirakl_list_order_documents', { order_ids: '1234567-A' }, 'GET', '/orders/documents', 'OR72'], + ['mirakl_list_channels', {}, 'GET', '/channels', 'CH11'], + ['mirakl_list_shipping_zones', {}, 'GET', '/shipping/zones', 'SH11'], + ['mirakl_list_shipping_methods', {}, 'GET', '/shipping/types', 'SH12'], + ['mirakl_list_logistic_classes', {}, 'GET', '/shipping/logistic_classes', 'SH31'], ['mirakl_accept_order', { order_id: '1234567-A', order_lines: [{ id: '1234567-A-1', accepted: true }] }, 'PUT', '/orders/1234567-A/accept', 'OR21'], ['mirakl_update_tracking', { order_id: '1234567-A', carrier_code: 'UPS', tracking_number: '1Z999' }, 'PUT', '/orders/1234567-A/tracking', 'OR23'], ['mirakl_ship_order', { order_id: '1234567-A' }, 'PUT', '/orders/1234567-A/ship', 'OR24'], ['mirakl_update_offers', { offers: [{ shop_sku: 'S1', state_code: '11', price: 10 }] }, 'POST', '/offers', 'OF24'], ['mirakl_reply_thread', { thread_id: 't-1', body: 'Hello', to: [{ type: 'CUSTOMER' }] }, 'POST', '/inbox/threads/t-1/message', 'M12'], ['mirakl_create_order_thread', { order_id: '1234567-A', body: 'Hello', to: ['CUSTOMER'], topic_value: 'Delivery' }, 'POST', '/orders/1234567-A/threads', 'OR43'], + ['mirakl_resolve_incident', { order_id: '1234567-A', order_line_id: '1234567-A-1', reason_code: '13' }, 'PUT', '/orders/1234567-A/lines/1234567-A-1/resolve_incident', 'OR64'], + ['mirakl_accept_return', { return_id: 'r-1', accepted: true }, 'PUT', '/returns/accept', 'RT21'], + ['mirakl_receive_return', { return_id: 'r-1' }, 'PUT', '/returns/receive', 'RT25'], ['mirakl_import_products', { file_url: 'https://files.example.com/products.csv' }, 'POST', '/products/imports', 'P41'], ['mirakl_import_offers', { file_url: 'https://files.example.com/offers.csv' }, 'POST', '/offers/imports', 'OF01'], ]; @@ -349,6 +361,36 @@ describe('mirakl adapter: static spec conformance', () => { }); }); + it('closes an incident on one order line with the reason code in the body', async () => { + mockedAxios.mockResolvedValue({ data: '' }); + await call('mirakl_resolve_incident', { order_id: '1234567-A', order_line_id: '1234567-A-1', reason_code: '13' }); + expect(sent().data).toEqual({ reason_code: '13' }); + }); + + it('decides and receives one return at a time with the body RT21 and RT25 expect', async () => { + mockedAxios.mockResolvedValue({ data: '' }); + await call('mirakl_accept_return', { return_id: 'r-1', accepted: true }); + expect(sent().data).toEqual({ returns: [{ id: 'r-1', accepted: true }] }); + mockedAxios.mockClear(); + await call('mirakl_accept_return', { return_id: 'r-2', accepted: false, rejection_reason_code: 'RETURN_REJECTION_WINDOW_CLOSED' }); + expect(sent().data).toEqual({ returns: [{ id: 'r-2', accepted: false, rejection_reason_code: 'RETURN_REJECTION_WINDOW_CLOSED' }] }); + mockedAxios.mockClear(); + await call('mirakl_receive_return', { return_id: 'r-1' }); + expect(sent().data).toEqual({ returns: [{ id: 'r-1' }] }); + }); + + it('offers no tool that refunds, cancels an order or moves money', () => { + for (const t of a.tools) expect(t.endpointMapping.path).not.toMatch(/refund|cancel|payout|promotions/); + }); + + it('is found by the names of the marketplaces that run on Mirakl', () => { + const aliases = (adapter as unknown as { aliases: string[] }).aliases; + for (const name of ['Leroy Merlin', 'ADEO', 'Conforama', 'BUT', 'home24', 'Maisons du Monde', 'Vente-unique', 'ePrice']) expect(aliases).toContain(name); + for (const url of ['adeo-marketplace.mirakl.net', 'but-prod.mirakl.net', 'conforama-prod.mirakl.net', 'home24.mirakl.net', 'maisonsdumonde-prod.mirakl.net', 'venteunique-prod.mirakl.net']) { + expect(a.instructions).toContain(`https://${url}`); + } + }); + it('uploads import files fetched from the given URL as the file part', async () => { mockedAxios.mockResolvedValue({ data: { import_id: 5 } }); mockedFetchOutbound.mockResolvedValue({ @@ -409,6 +451,8 @@ live('mirakl adapter: live shop API (reads only)', () => { expect(Array.isArray((await run('mirakl_list_returns', { limit: 2 })).data)).toBe(true); expect(Array.isArray((await run('mirakl_list_reasons')).reasons)).toBe(true); expect(Array.isArray((await run('mirakl_list_carriers')).carriers)).toBe(true); + expect(Array.isArray((await run('mirakl_list_channels')).channels)).toBe(true); + expect(Array.isArray((await run('mirakl_list_logistic_classes')).logistic_classes)).toBe(true); }, 120_000); it('reads invoices and transactions', async () => { diff --git a/packages/backend/src/adapters/intl/zenkit.json b/packages/backend/src/adapters/intl/zenkit.json new file mode 100644 index 000000000..e24505f77 --- /dev/null +++ b/packages/backend/src/adapters/intl/zenkit.json @@ -0,0 +1,585 @@ +{ + "slug": "zenkit", + "name": "Zenkit", + "aliases": [ + "Zenkit Base", + "Zenkit To Do", + "Zenkit Projects", + "Hypernotes" + ], + "description": "Let your AI work in Zenkit: browse workspaces and collections, read fields, entries, members and activity, search everywhere, and create or update entries and post comments with your confirmation.", + "instructions": "## Zenkit\nThis connector calls the **Zenkit REST API** (`https://zenkit.com/api/v1`) with a personal API key. The same API and the same data serve Zenkit Base, Zenkit To Do, Zenkit Projects and Hypernotes. It reads workspaces, collections (lists), fields, entries, members and activity, creates and updates entries and posts comments.\n\n**Setup (about 2 minutes)**\n1. In Zenkit open your profile (click your avatar), go to **Integrations** and, under **API Key**, generate a new key.\n2. Copy it into `ZENKIT_API_KEY`. It is sent in the `Zenkit-API-Key` header and acts as you, with access to everything you can open. Generating a new key invalidates the old one.\n\n**Words**: a **workspace** holds **collections** (the API calls them lists); a collection has **fields** (elements) and **entries** (items, rows, tasks or cards depending on the view). Every object has a numeric `id`, a `shortId` and a `uuid`; list and entry ids accept any of the three. `zenkit_list_workspace_users` needs the numeric workspace id and `zenkit_archive_entries` numeric entry ids.\n\n**Where to start**: `zenkit_list_workspaces_with_lists` lists every workspace with its collections. `zenkit_list_fields` gives each field's `id`, `uuid`, name and type, and for label fields the category ids. Then `zenkit_filter_entries` pages through entries (50 at a time, with `countData.filteredTotal`) and `zenkit_search_entries` searches by text everywhere. `displayString` is an entry's title.\n\n**Field values** are keyed by the field's uuid and a value key: `_text`, `_number`, `_link`, `_date` (ISO 8601, with `_endDate` and `_hasTime`), `_checked` (checkbox), `_categories` (label ids), `_persons` (user ids from `zenkit_list_workspace_users`) and `_references` (entry uuids). Reads also return helper keys such as `_categories_sort` with the label names; do not send those back. Formula and created or updated fields are read-only. Zenkit refuses unknown keys with error C21.\n\n**Filters**: `{\"AND\": {\"TERMS\": [{\"elementId\": , \"modus\": \"contains\", \"text\": \"...\"}]}}`; `OR` works the same way and both can be nested. Numbers use `numberFrom` / `numberTo` with `inRange`, dates `dateType` 10 with `dateFrom` / `dateTo`, labels `filterCategories`. Zenkit documents filters only briefly; when a filter is refused, read without it and filter the page yourself.\n\n**Writes: confirm first.** `zenkit_create_entry`, `zenkit_update_entry` and `zenkit_comment_on_entry` change shared data and notify followers. Show the user what will change and wait for a yes. `zenkit_archive_entries` moves entries to the archive and is installed switched off. Permanent deletion is not offered.\n\n**Rate limits**: 200 requests a minute per user and 500 a minute per IP address. Error D2 (Limit reached) means wait before trying again; prefer one filtered page over many single reads.\n\n**Errors** come as `{\"error\": {\"code\", \"message\", \"description\"}}`: 401 the key is wrong or was replaced, 403 no access to that workspace or collection, 404 unknown id, 400 C21 a parameter or value key Zenkit does not accept.\n\n**Out of scope**: creating or changing workspaces, collections and fields, file uploads, webhooks, and permanent deletion.", + "region": "intl", + "category": "project-management", + "icon": "zenkit", + "docsUrl": "https://base.zenkit.com/docs/api/overview/introduction", + "prerequisites": "A Zenkit account and a personal API key from your profile (Integrations, API Key). About 2 minutes.", + "requiredEnvVars": [ + "ZENKIT_API_KEY" + ], + "envVarMeta": { + "ZENKIT_API_KEY": { + "label": "Zenkit API key", + "kind": "credential", + "secret": true, + "help": "Zenkit, your profile, Integrations, API Key: generate a key. It acts as you; a new key invalidates the old one.", + "link": "https://base.zenkit.com/docs/api/overview/authentication", + "pattern": "^[A-Za-z0-9-]{20,80}$", + "patternMessage": "Paste only the key, without spaces or a header name." + } + }, + "verifyHints": { + "401": "Zenkit refused the API key: it is wrong or a newer key replaced it. Generate a key again in your Zenkit profile under Integrations.", + "429": "Zenkit is rate limiting this key (200 requests a minute). Wait a minute and verify again." + }, + "probe": { + "tool": "zenkit_get_current_user" + }, + "connector": { + "name": "Zenkit API", + "type": "REST", + "baseUrl": "https://zenkit.com/api/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "Zenkit-API-Key", + "apiKey": "{{ZENKIT_API_KEY}}" + }, + "headers": { + "Accept": "application/json", + "Accept-Language": "en", + "Content-Type": "application/json", + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/users/me" + }, + "tools": [ + { + "name": "zenkit_get_current_user", + "description": "Read the Zenkit user the API key belongs to: id, name, username, emails, locale and timezone. Checks the key.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/users/me" + } + }, + { + "name": "zenkit_list_workspaces_with_lists", + "description": "List every workspace the user can open, each with its collections (lists): ids, shortIds, uuids and names. Start here to find a list.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/users/me/workspacesWithLists" + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "zenkit_get_workspace", + "description": "Read one workspace with its name, description, settings and creation date.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { + "type": "string", + "description": "Workspace id, shortId or uuid." + } + }, + "required": [ + "workspace_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}", + "encodePathParams": true + } + }, + { + "name": "zenkit_list_workspace_users", + "description": "List the members of a workspace with user id, name and username. Use the user ids for member fields (_persons).", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { + "type": "integer", + "description": "Numeric workspace id (not the shortId), from zenkit_list_workspaces_with_lists." + } + }, + "required": [ + "workspace_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/users", + "encodePathParams": true + } + }, + { + "name": "zenkit_get_list", + "description": "Read one collection (list) with its name, workspace, description and settings.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + } + }, + "required": [ + "list_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{list_id}", + "encodePathParams": true + } + }, + { + "name": "zenkit_list_list_users", + "description": "List the users who have access to a collection (list), with user id, name and username.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + } + }, + "required": [ + "list_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{list_id}/users", + "encodePathParams": true + } + }, + { + "name": "zenkit_list_fields", + "description": "List the fields (elements) of a collection: id, uuid, name, type (elementcategory), whether it is the primary title field, and for label fields the predefined categories with their ids. Needed before reading or writing entry values.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + } + }, + "required": [ + "list_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{list_id}/elements", + "encodePathParams": true + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "zenkit_filter_entries", + "description": "List the entries (items) of a collection with all field values, page by page, optionally filtered. Returns countData (total and filteredTotal) and listEntries; displayString is each entry's title. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "filter": { + "type": "object", + "description": "Optional filter, e.g. {\"AND\": {\"TERMS\": [{\"elementId\": 230, \"modus\": \"contains\", \"text\": \"invoice\"}]}}. elementId is the numeric field id; modus is isEmpty, contains, equals, startsWith, endsWith, inRange, greaterOrEqual, lessOrEqual or containsCurrentUser; negated: true inverts a term." + }, + "limit": { + "type": "integer", + "minimum": 1, + "default": 50, + "description": "Entries per page. Default 50." + }, + "skip": { + "type": "integer", + "minimum": 0, + "description": "Entries to skip, for paging." + }, + "allowDeprecated": { + "type": "boolean", + "description": "true to include archived (deleted) entries." + } + }, + "required": [ + "list_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{list_id}/entries/filter/list", + "encodePathParams": true, + "bodyMapping": { + "filter": "$filter", + "limit": "$limit", + "skip": "$skip", + "allowDeprecated": "$allowDeprecated" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "zenkit_search_entries", + "description": "Search entries by text across every workspace and collection the user can open. Returns matching entries, lists and workspaces, and thereAreMoreResults.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Text to search for." + }, + "limit": { + "type": "integer", + "minimum": 1, + "default": 20, + "description": "Maximum results. Default 20." + }, + "searchInArchive": { + "type": "boolean", + "description": "true to include archived entries." + } + }, + "required": [ + "query" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/entries/search", + "queryParams": { + "query": "$query", + "limit": "$limit", + "searchInArchive": "$searchInArchive" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "zenkit_get_entry", + "description": "Read one entry with all its field values, keyed by field uuid (for example _text), plus displayString, comment count and dates.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "entry_id": { + "type": "string", + "description": "Entry id: the numeric id, shortId or uuid of the entry." + } + }, + "required": [ + "list_id", + "entry_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{list_id}/entries/{entry_id}", + "encodePathParams": true + } + }, + { + "name": "zenkit_list_entry_activities", + "description": "List the comments and change history of one entry, newest first, with total for paging. A comment has type 0 and its text in message.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "entry_id": { + "type": "string", + "description": "Entry id: the numeric id, shortId or uuid of the entry." + }, + "filter": { + "type": "integer", + "enum": [ + 0, + 2, + 3 + ], + "description": "0 all activities (default), 2 comments only, 3 deleted items." + }, + "limit": { + "type": "integer", + "minimum": 1, + "default": 20, + "description": "Activities per page. Default 20." + }, + "skip": { + "type": "integer", + "minimum": 0, + "description": "Activities to skip, for paging." + } + }, + "required": [ + "list_id", + "entry_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{list_id}/entries/{entry_id}/activities", + "encodePathParams": true, + "queryParams": { + "filter": "$filter", + "limit": "$limit", + "skip": "$skip" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "zenkit_list_list_activities", + "description": "List the comments and changes across a whole collection, newest first, with total for paging.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "filter": { + "type": "integer", + "enum": [ + 0, + 2, + 3 + ], + "description": "0 all activities (default), 2 comments only, 3 deleted items." + }, + "limit": { + "type": "integer", + "minimum": 1, + "default": 20, + "description": "Activities per page. Default 20." + }, + "skip": { + "type": "integer", + "minimum": 0, + "description": "Activities to skip, for paging." + } + }, + "required": [ + "list_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{list_id}/activities", + "encodePathParams": true, + "queryParams": { + "filter": "$filter", + "limit": "$limit", + "skip": "$skip" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 150000 + } + } + }, + { + "name": "zenkit_create_entry", + "description": "Create an entry (item) in a collection with the given field values. Read the field uuids with zenkit_list_fields first; the primary text field is the title. Show the values and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "values": { + "type": "object", + "description": "Field values keyed by _, using the uuids from zenkit_list_fields. Text: {\"_text\": \"Call supplier\"}; number: _number; link: _link; date: _date (ISO 8601, optional _endDate and _hasTime); checkbox: _checked (true or false); labels: _categories (array of category ids from the field's elementData.predefinedCategories); members: _persons (array of user ids); references: _references (array of entry uuids). Unknown keys are refused." + } + }, + "required": [ + "list_id", + "values" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{list_id}/entries", + "encodePathParams": true, + "bodyMapping": { + "__merge": "$values" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "zenkit_update_entry", + "description": "Change field values of an entry. Only the keys sent change. For array fields (labels, members, references) update_action decides whether the values replace, are added to or are removed from the current ones. Show the change and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "entry_id": { + "type": "string", + "description": "Entry id: the numeric id, shortId or uuid of the entry." + }, + "values": { + "type": "object", + "description": "Field values keyed by _, using the uuids from zenkit_list_fields. Text: {\"_text\": \"Call supplier\"}; number: _number; link: _link; date: _date (ISO 8601, optional _endDate and _hasTime); checkbox: _checked (true or false); labels: _categories (array of category ids from the field's elementData.predefinedCategories); members: _persons (array of user ids); references: _references (array of entry uuids). Unknown keys are refused." + }, + "update_action": { + "type": "string", + "enum": [ + "replace", + "append", + "remove" + ], + "description": "For array fields: replace (default), append or remove." + } + }, + "required": [ + "list_id", + "entry_id", + "values" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/lists/{list_id}/entries/{entry_id}", + "encodePathParams": true, + "bodyMapping": { + "__merge": "$values", + "updateAction": "$update_action" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "zenkit_comment_on_entry", + "description": "Post a comment on an entry as the API key's user. Members following the entry may be notified. Show the user the text and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "entry_id": { + "type": "string", + "description": "Entry id: the numeric id, shortId or uuid of the entry." + }, + "message": { + "type": "string", + "description": "Comment text." + } + }, + "required": [ + "list_id", + "entry_id", + "message" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/users/me/lists/{list_id}/entries/{entry_id}/activities", + "encodePathParams": true, + "bodyMapping": { + "message": "$message" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "zenkit_archive_entries", + "description": "Delete entries of a collection by moving them to the archive (Zenkit's soft delete; they can be restored in the Zenkit app). Takes numeric entry ids. Installed switched off. Name the entries and confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "List id: the numeric id, shortId or uuid, e.g. from zenkit_list_workspaces_with_lists." + }, + "entry_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "description": "Numeric ids (the id field, not shortId or uuid) of the entries to archive." + } + }, + "required": [ + "list_id", + "entry_ids" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{list_id}/entries/delete/filter", + "encodePathParams": true, + "bodyMapping": { + "shouldDeleteAll": false, + "listEntryIds": "$entry_ids" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + }, + "enabled": false + } + ] +} diff --git a/packages/backend/src/adapters/intl/zenkit.live.spec.ts b/packages/backend/src/adapters/intl/zenkit.live.spec.ts new file mode 100644 index 000000000..29714ab18 --- /dev/null +++ b/packages/backend/src/adapters/intl/zenkit.live.spec.ts @@ -0,0 +1,262 @@ +import axios from 'axios'; +import * as adapter from './zenkit.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 { interpolateConnectorConfig } from '../../common/env-interpolation.util'; +import { describeAdapterEnvVars } from '../env-var-meta'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Two layers of verification for the Zenkit adapter (REST API v1): + * + * 1. Static: always runs. Pins the Zenkit-API-Key header on zenkit.com/api/v1, + * every tool's method, path, query and body through the real RestEngine + * against Zenkit's API reference (base.zenkit.com/docs/api), the entry + * bodies keyed by _, and which tools write or + * install switched off. The JSON bodies of filter, create, update, + * comment and archive were also checked against the live server's + * request validation, which runs before authentication. + * + * 2. Live: skipped unless RUN_ZENKIT_LIVE=1 and ZENKIT_API_KEY are set. + * Reads only: the user, workspaces, and fields and entries of the + * first collection. + * + * RUN_ZENKIT_LIVE=1 ZENKIT_API_KEY=... npx jest src/adapters/intl/zenkit.live.spec.ts + */ + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + const mocked = jest.fn(); + return { + __esModule: true, + default: Object.assign(mocked, actual.default, { __actual: actual.default }), + AxiosError: actual.AxiosError, + }; +}); +const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios }; + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; encodePathParams?: boolean; queryParams?: Record; bodyMapping?: Record }; + annotations?: Record; +}; +const a = adapter as unknown as { + slug: string; + unlisted?: boolean; + category: string; + icon: string; + instructions: string; + requiredEnvVars: string[]; + envVarMeta: Record; + probe: { tool: string }; + connector: { baseUrl: string; authType: string; authConfig: Record; headers: Record; healthcheckPath: string }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +const annotationsOf = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + +// Built at run time so no literal here looks like a real key. +const KEY = ['Ab12Cd34', 'Ef56Gh78Ij90Kl12Mn34Op56Qr78St90'].join('-'); +const BASE = 'https://zenkit.com/api/v1'; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Mirrors DynamicMcpTools: {{VAR}} interpolated, env vars also passed as params. +const execute = (name: string, key: string, params: Record) => { + const env = { ZENKIT_API_KEY: key }; + const { config, endpointMapping } = interpolateConnectorConfig( + { baseUrl: a.connector.baseUrl, headers: a.connector.headers }, + tool(name).endpointMapping, + env, + ); + return engine().execute( + { baseUrl: config.baseUrl, authType: a.connector.authType, authConfig: { headerName: 'Zenkit-API-Key', apiKey: key }, headers: config.headers }, + endpointMapping, + { ...applySchemaDefaults(tool(name).parameters, params), ...env }, + ) as Promise; +}; +const call = (name: string, params: Record) => execute(name, KEY, params); +const sent = () => mockedAxios.mock.calls[0][0]; + +const UUID = '5a2e6a4c-1b2c-4d3e-8f90-123456789abc'; +const WRITES = ['zenkit_comment_on_entry', 'zenkit_archive_entries', 'zenkit_create_entry', 'zenkit_update_entry']; + +describe('zenkit adapter: static spec conformance', () => { + beforeEach(() => mockedAxios.mockReset()); + + it('is listed: checked against the official API reference on 19 Oct 2026', () => { + expect(a.unlisted).toBeFalsy(); + expect(a.slug).toBe('zenkit'); + expect(a.icon).toBe('zenkit'); + expect(a.category).toBe('project-management'); + }); + + it('sends the key in the Zenkit-API-Key header and asks for English errors', async () => { + expect(a.connector.baseUrl).toBe(BASE); + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ headerName: 'Zenkit-API-Key', apiKey: '{{ZENKIT_API_KEY}}' }); + expect(a.connector.headers['Accept-Language']).toBe('en'); + expect(a.connector.healthcheckPath).toBe('/users/me'); + expect(a.probe.tool).toBe('zenkit_get_current_user'); + mockedAxios.mockResolvedValue({ data: {} }); + await call('zenkit_get_current_user', {}); + expect(sent().headers['Zenkit-API-Key']).toBe(KEY); + expect(sent().url).toBe(`${BASE}/users/me`); + expect(JSON.stringify(sent().params ?? {})).not.toContain(KEY); + }); + + it('asks for the key as a secret', () => { + expect(a.requiredEnvVars).toEqual(['ZENKIT_API_KEY']); + const meta = a.envVarMeta.ZENKIT_API_KEY; + expect(meta.secret).toBe(true); + expect(meta.kind).toBe('credential'); + const re = new RegExp(meta.pattern!); + expect(re.test(KEY)).toBe(true); + for (const bad of ['', 'short', `Zenkit-API-Key: ${KEY}`, `${KEY} `]) expect(re.test(bad)).toBe(false); + expect(describeAdapterEnvVars(a as never).find((d) => d.name === 'ZENKIT_API_KEY')!.secret).toBe(true); + }); + + it('prefixes every tool with zenkit_ and shares no tool name with another adapter', () => { + const mine = new Set(a.tools.map((t) => t.name)); + expect(mine.size).toBe(a.tools.length); + for (const name of mine) expect(name).toMatch(/^zenkit_[a-z_]+$/); + for (const meta of listAdapters()) { + if (meta.slug === a.slug) continue; + for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false); + } + }); + + it('marks reads read-only (the POST filter too), asks for confirmation on every write and installs archiving switched off', () => { + for (const t of a.tools) { + const read = t.endpointMapping.method === 'GET' || t.name === 'zenkit_filter_entries'; + expect(`${t.name}:${annotationsOf(t).readOnlyHint === true}`).toBe(`${t.name}:${read}`); + } + expect(a.tools.filter((t) => annotationsOf(t).readOnlyHint !== true).map((t) => t.name).sort()).toEqual([...WRITES].sort()); + for (const name of WRITES) expect(tool(name).description).toMatch(/confirm with the user first/i); + expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['zenkit_archive_entries']); + expect(annotationsOf(tool('zenkit_archive_entries')).destructiveHint).toBe(true); + // Permanent deletion is not offered. + for (const t of a.tools) expect(t.endpointMapping.path).not.toContain('deprecated-entries'); + expect(a.tools.some((t) => t.endpointMapping.method === 'DELETE')).toBe(false); + }); + + it('URL-encodes path ids, so an id cannot reach another route', async () => { + for (const t of a.tools.filter((x) => x.endpointMapping.path.includes('{'))) expect(`${t.name}:${t.endpointMapping.encodePathParams}`).toBe(`${t.name}:true`); + mockedAxios.mockResolvedValue({ data: {} }); + await call('zenkit_get_entry', { list_id: 'abc1234', entry_id: '../../users/me' }); + expect(sent().url).toBe(`${BASE}/lists/abc1234/entries/..%2F..%2Fusers%2Fme`); + }); + + it('only points the model at tools that exist, and writes no em dashes', () => { + const names = new Set(a.tools.map((t) => t.name)); + const mentioned = [ + ...a.instructions.matchAll(/\bzenkit_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bzenkit_[a-z_]+/g)]), + ].map((m) => m[0]); + expect(mentioned.length).toBeGreaterThan(8); + for (const name of mentioned) expect(names).toContain(name); + expect(a.instructions.startsWith('## Zenkit\n')).toBe(true); + for (const text of ['Integrations', 'API Key', '200 requests a minute', '_categories', 'C21', 'Out of scope']) expect(a.instructions).toContain(text); + expect(JSON.stringify(adapter)).not.toMatch(/[–—]/); + }); + + // [tool, args, method, path] from the Zenkit API reference. + const table: Array<[string, Record, string, string]> = [ + ['zenkit_get_current_user', {}, 'GET', '/users/me'], + ['zenkit_list_workspaces_with_lists', {}, 'GET', '/users/me/workspacesWithLists'], + ['zenkit_get_workspace', { workspace_id: 'Wk12345' }, 'GET', '/workspaces/Wk12345'], + ['zenkit_list_workspace_users', { workspace_id: 42 }, 'GET', '/workspaces/42/users'], + ['zenkit_get_list', { list_id: 'Ls12345' }, 'GET', '/lists/Ls12345'], + ['zenkit_list_list_users', { list_id: 'Ls12345' }, 'GET', '/lists/Ls12345/users'], + ['zenkit_list_fields', { list_id: 'Ls12345' }, 'GET', '/lists/Ls12345/elements'], + ['zenkit_filter_entries', { list_id: 'Ls12345' }, 'POST', '/lists/Ls12345/entries/filter/list'], + ['zenkit_search_entries', { query: 'invoice' }, 'GET', '/entries/search'], + ['zenkit_get_entry', { list_id: 'Ls12345', entry_id: 'En12345' }, 'GET', '/lists/Ls12345/entries/En12345'], + ['zenkit_list_entry_activities', { list_id: 'Ls12345', entry_id: 'En12345' }, 'GET', '/lists/Ls12345/entries/En12345/activities'], + ['zenkit_list_list_activities', { list_id: 'Ls12345' }, 'GET', '/lists/Ls12345/activities'], + ['zenkit_create_entry', { list_id: 'Ls12345', values: { [`${UUID}_text`]: 'A' } }, 'POST', '/lists/Ls12345/entries'], + ['zenkit_update_entry', { list_id: 'Ls12345', entry_id: 'En12345', values: { [`${UUID}_text`]: 'B' } }, 'PUT', '/lists/Ls12345/entries/En12345'], + ['zenkit_comment_on_entry', { list_id: 'Ls12345', entry_id: 'En12345', message: 'Done' }, 'POST', '/users/me/lists/Ls12345/entries/En12345/activities'], + ['zenkit_archive_entries', { list_id: 'Ls12345', entry_ids: [101] }, 'POST', '/lists/Ls12345/entries/delete/filter'], + ]; + + it('lists every tool in the URL table', () => { + expect(table.map(([name]) => name).sort()).toEqual(a.tools.map((t) => t.name).sort()); + }); + + it.each(table)('%s sends %s to %s', async (name, params, method, path) => { + mockedAxios.mockResolvedValue({ data: {} }); + await call(name, params); + expect(sent().method).toBe(method); + expect(sent().url).toBe(`${BASE}${path}`); + }); + + it('filters entries with only the body keys Zenkit allows, 50 per page by default', async () => { + mockedAxios.mockResolvedValue({ data: { countData: { total: 0, filteredTotal: 0 }, listEntries: [] } }); + await call('zenkit_filter_entries', { list_id: 'Ls12345' }); + expect(sent().data).toEqual({ limit: 50 }); + mockedAxios.mockClear(); + const filter = { AND: { TERMS: [{ elementId: 230, modus: 'contains', text: 'invoice' }] } }; + await call('zenkit_filter_entries', { list_id: 'Ls12345', filter, limit: 10, skip: 20, allowDeprecated: false }); + expect(sent().data).toEqual({ filter, limit: 10, skip: 20, allowDeprecated: false }); + }); + + it('sends entry values as top-level _ fields, with updateAction on updates', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + const values = { [`${UUID}_text`]: 'Call supplier', [`${UUID}_checked`]: true, [`${UUID}_categories`]: [2] }; + await call('zenkit_create_entry', { list_id: 'Ls12345', values }); + expect(sent().data).toEqual(values); + mockedAxios.mockClear(); + await call('zenkit_update_entry', { list_id: 'Ls12345', entry_id: 'En12345', values: { [`${UUID}_persons`]: [7] }, update_action: 'append' }); + expect(sent().data).toEqual({ [`${UUID}_persons`]: [7], updateAction: 'append' }); + }); + + it('comments with a message body and archives by numeric ids without deleting everything', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('zenkit_comment_on_entry', { list_id: 'Ls12345', entry_id: 'En12345', message: 'Done' }); + expect(sent().data).toEqual({ message: 'Done' }); + mockedAxios.mockClear(); + await call('zenkit_archive_entries', { list_id: 'Ls12345', entry_ids: [101, 102] }); + expect(sent().data).toEqual({ shouldDeleteAll: false, listEntryIds: [101, 102] }); + }); + + it('pages activities in the query string', async () => { + mockedAxios.mockResolvedValue({ data: { total: 0, activities: [] } }); + await call('zenkit_list_entry_activities', { list_id: 'Ls12345', entry_id: 'En12345', filter: 2 }); + expect(sent().params).toEqual({ filter: 2, limit: 20 }); + }); +}); + +const LIVE_KEY = process.env.ZENKIT_API_KEY; +const live = process.env.RUN_ZENKIT_LIVE === '1' && LIVE_KEY ? describe : describe.skip; + +live('zenkit adapter: live API (reads only)', () => { + beforeAll(() => { + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + }); + const run = (name: string, params: Record = {}): Promise => execute(name, LIVE_KEY as string, params); + + it('reads the user, workspaces, and the fields and entries of the first collection', async () => { + expect((await run('zenkit_get_current_user')).id).toBeDefined(); + const workspaces = await run('zenkit_list_workspaces_with_lists'); + expect(Array.isArray(workspaces)).toBe(true); + const list = workspaces.flatMap((w: { lists?: unknown[] }) => w.lists ?? [])[0] as { id: number } | undefined; + if (!list) return; + expect(Array.isArray(await run('zenkit_list_fields', { list_id: String(list.id) }))).toBe(true); + const entries = await run('zenkit_filter_entries', { list_id: String(list.id), limit: 3 }); + expect(Array.isArray(entries.listEntries)).toBe(true); + }, 60_000); + + it('a wrong key is a 401', async () => { + await expect(execute('zenkit_get_current_user', 'AAAAAAAA-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', {})).rejects.toMatchObject({ response: { status: 401 } }); + }, 30_000); +}); diff --git a/packages/frontend/public/logos/connectors/manychat.svg b/packages/frontend/public/logos/connectors/manychat.svg new file mode 100644 index 000000000..e2aefdf8e --- /dev/null +++ b/packages/frontend/public/logos/connectors/manychat.svg @@ -0,0 +1 @@ +Manychat diff --git a/packages/frontend/public/logos/connectors/zenkit.svg b/packages/frontend/public/logos/connectors/zenkit.svg new file mode 100644 index 000000000..f712a351f --- /dev/null +++ b/packages/frontend/public/logos/connectors/zenkit.svg @@ -0,0 +1 @@ +Zenkit diff --git a/server.json b/server.json index 997c44e5f..4c50c2ef0 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.HelpCode-ai/anythingmcp", "title": "AnythingMCP", - "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 484 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 486 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github"