diff --git a/CITATION.cff b/CITATION.cff index c132ff7f2..2cd2398b4 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 338 pre-built adapters, with a focus on ERP and e-commerce + It ships 347 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 998b5afa8..123fd5dc4 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: 338 Connectors, 17 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 347 Connectors, 17 davon ohne API-Schlüssel.

AnythingMCP: selbst gehostetes MCP-Gateway

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

AnythingMCP ist ein quelloffenes, selbst gehostetes MCP-Gateway, das jedes REST-/OpenAPI-, SOAP-, GraphQL-, OData- oder SQL-System in MCP-Tools für Claude, ChatGPT und Copilot verwandelt, ohne dass du einen MCP-Server programmierst.
- Es bringt 338 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 17 davon kommen ohne API-Schlüssel aus. + Es bringt 347 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 17 davon kommen ohne API-Schlüssel aus.

@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec ## Connector-Katalog -338 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. +347 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 876e04fb5..b4afbc910 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。338 のコネクター、うち 17 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。347 のコネクター、うち 17 は API キー不要。

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

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

AnythingMCP は、オープンソースのセルフホスト型 MCP ゲートウェイです。MCP サーバーを書かずに、REST/OpenAPI、SOAP、GraphQL、OData、SQL のあらゆるシステムを Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- SAP、Etsy、weclapp、Amazon Seller などを含む 338 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。 + SAP、Etsy、weclapp、Amazon Seller などを含む 347 種類の既製アダプターを同梱しており、うち 17 は API キー不要です。

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

AnythingMCP: self-hosted MCP gateway

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

AnythingMCP is an open-source, self-hosted MCP gateway that turns any REST/OpenAPI, SOAP, GraphQL, OData or SQL system into MCP tools for Claude, ChatGPT and Copilot, without writing an MCP server.
- It ships 338 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 17 of them need no API key. + It ships 347 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 17 of them need no API key.

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

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

AnythingMCP:自行托管的 MCP 网关

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

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

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 338 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 347 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/glama.json b/glama.json index c9569136c..781a8fc7a 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. 338 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 347 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 b762346bf..95a10a580 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.21.0", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 338 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 347 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 cc95c1f0a..387d9167d 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -109,6 +109,7 @@ import * as clearbit from './intl/clearbit.json'; import * as clickup from './intl/clickup.json'; import * as clockify from './intl/clockify.json'; import * as close from './intl/close.json'; +import * as cloudflare from './intl/cloudflare.json'; import * as coda from './intl/coda.json'; import * as coingecko from './intl/coingecko.json'; import * as convertkit from './intl/convertkit.json'; @@ -120,6 +121,7 @@ import * as dchub from './intl/dchub.json'; import * as deel from './intl/deel.json'; import * as directus from './intl/directus.json'; import * as discordBot from './intl/discord-bot.json'; +import * as docusign from './intl/docusign.json'; import * as drip from './intl/drip.json'; import * as dropbox from './intl/dropbox.json'; import * as dropboxSign from './intl/dropbox-sign.json'; @@ -131,6 +133,7 @@ import * as etsy from './intl/etsy.json'; import * as exerciseCom from './intl/exercise-com.json'; import * as fathom from './intl/fathom.json'; import * as fhir from './intl/fhir.json'; +import * as figma from './intl/figma.json'; import * as fillout from './intl/fillout.json'; import * as firecrawl from './intl/firecrawl.json'; import * as firma from './intl/firma.json'; @@ -150,6 +153,7 @@ import * as georgianRailway from './intl/georgian-railway.json'; import * as ghost from './intl/ghost.json'; import * as gitbook from './intl/gitbook.json'; import * as github from './intl/github.json'; +import * as gitlab from './intl/gitlab.json'; import * as glpi from './intl/glpi.json'; import * as gmail from './intl/gmail.json'; import * as gocardless from './intl/gocardless.json'; @@ -213,6 +217,7 @@ import * as microsoftOutlook from './intl/microsoft-outlook.json'; import * as microsoftSharepoint from './intl/microsoft-sharepoint.json'; import * as microsoftTeams from './intl/microsoft-teams.json'; import * as mintlify from './intl/mintlify.json'; +import * as miro from './intl/miro.json'; import * as mollie from './intl/mollie.json'; import * as monday from './intl/monday.json'; import * as mongodb from './intl/mongodb.json'; @@ -237,6 +242,7 @@ import * as oracle from './intl/oracle.json'; import * as orderful from './intl/orderful.json'; import * as outreach from './intl/outreach.json'; import * as pandadoc from './intl/pandadoc.json'; +import * as paypal from './intl/paypal.json'; import * as pexels from './intl/pexels.json'; import * as phorest from './intl/phorest.json'; import * as pinterest from './intl/pinterest.json'; @@ -275,6 +281,7 @@ import * as seamlessAi from './intl/seamless-ai.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sentry from './intl/sentry.json'; import * as serply from './intl/serply.json'; +import * as shippo from './intl/shippo.json'; import * as shipstation from './intl/shipstation.json'; import * as shopify from './intl/shopify.json'; import * as signwell from './intl/signwell.json'; @@ -284,10 +291,12 @@ import * as snov from './intl/snov.json'; import * as snowflake from './intl/snowflake.json'; import * as sorare from './intl/sorare.json'; import * as splunk from './intl/splunk.json'; +import * as square from './intl/square.json'; import * as statsig from './intl/statsig.json'; import * as streak from './intl/streak.json'; import * as stripe from './intl/stripe.json'; import * as substack from './intl/substack.json'; +import * as supabase from './intl/supabase.json'; import * as surveymonkey from './intl/surveymonkey.json'; import * as synology from './intl/synology.json'; import * as tally from './intl/tally.json'; @@ -326,6 +335,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 zohoBooks from './intl/zoho-books.json'; import * as zohoCrm from './intl/zoho-crm.json'; import * as mercadoLibre from './br/mercado-libre.json'; import * as tiendanube from './br/tiendanube.json'; @@ -666,6 +676,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ clickup as unknown as AdapterDefinition, clockify as unknown as AdapterDefinition, close as unknown as AdapterDefinition, + cloudflare as unknown as AdapterDefinition, coda as unknown as AdapterDefinition, coingecko as unknown as AdapterDefinition, convertkit as unknown as AdapterDefinition, @@ -677,6 +688,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ deel as unknown as AdapterDefinition, directus as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, + docusign as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, dropbox as unknown as AdapterDefinition, dropboxSign as unknown as AdapterDefinition, @@ -688,6 +700,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ exerciseCom as unknown as AdapterDefinition, fathom as unknown as AdapterDefinition, fhir as unknown as AdapterDefinition, + figma as unknown as AdapterDefinition, fillout as unknown as AdapterDefinition, firecrawl as unknown as AdapterDefinition, firma as unknown as AdapterDefinition, @@ -707,6 +720,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ ghost as unknown as AdapterDefinition, gitbook as unknown as AdapterDefinition, github as unknown as AdapterDefinition, + gitlab as unknown as AdapterDefinition, glpi as unknown as AdapterDefinition, gmail as unknown as AdapterDefinition, gocardless as unknown as AdapterDefinition, @@ -770,6 +784,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ microsoftSharepoint as unknown as AdapterDefinition, microsoftTeams as unknown as AdapterDefinition, mintlify as unknown as AdapterDefinition, + miro as unknown as AdapterDefinition, mollie as unknown as AdapterDefinition, monday as unknown as AdapterDefinition, mongodb as unknown as AdapterDefinition, @@ -794,6 +809,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ orderful as unknown as AdapterDefinition, outreach as unknown as AdapterDefinition, pandadoc as unknown as AdapterDefinition, + paypal as unknown as AdapterDefinition, pexels as unknown as AdapterDefinition, phorest as unknown as AdapterDefinition, pinterest as unknown as AdapterDefinition, @@ -832,6 +848,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ sendgrid as unknown as AdapterDefinition, sentry as unknown as AdapterDefinition, serply as unknown as AdapterDefinition, + shippo as unknown as AdapterDefinition, shipstation as unknown as AdapterDefinition, shopify as unknown as AdapterDefinition, signwell as unknown as AdapterDefinition, @@ -841,10 +858,12 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ snowflake as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, splunk as unknown as AdapterDefinition, + square as unknown as AdapterDefinition, statsig as unknown as AdapterDefinition, streak as unknown as AdapterDefinition, stripe as unknown as AdapterDefinition, substack as unknown as AdapterDefinition, + supabase as unknown as AdapterDefinition, surveymonkey as unknown as AdapterDefinition, synology as unknown as AdapterDefinition, tally as unknown as AdapterDefinition, @@ -883,6 +902,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ zabbix as unknown as AdapterDefinition, zendesk as unknown as AdapterDefinition, zenhub as unknown as AdapterDefinition, + zohoBooks as unknown as AdapterDefinition, zohoCrm as unknown as AdapterDefinition, mercadoLibre as unknown as AdapterDefinition, tiendanube as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/cloudflare.json b/packages/backend/src/adapters/intl/cloudflare.json new file mode 100644 index 000000000..8764db664 --- /dev/null +++ b/packages/backend/src/adapters/intl/cloudflare.json @@ -0,0 +1,1162 @@ +{ + "slug": "cloudflare", + "name": "Cloudflare", + "description": "Let your AI manage your Cloudflare zones: review and change DNS records, purge the cache, read zone settings, redirect, transform and cache rules, check traffic analytics, and list Workers and R2 buckets. With an API token limited to what it should touch.", + "instructions": "## Cloudflare\nThis connector works with Cloudflare through the **API v4** (`https://api.cloudflare.com/client/v4`) and the **GraphQL Analytics API**: zones and their settings, DNS records (list, export, create, change, delete), cache purges, rulesets (redirects, transform, cache, origin and WAF custom rules), legacy Page Rules, traffic analytics, Workers (scripts, settings, code, routes, custom domains) and R2 buckets. It authenticates with an **API token**, which you limit to the permissions and zones the AI should reach.\n\n**Setup (about 3 minutes)**\n1. Open https://dash.cloudflare.com/profile/api-tokens (**My Profile → API Tokens**) and click **Create Token**, then **Create Custom Token** at the bottom (or start from the **Read all resources** template and add the two write permissions below).\n2. Name it (for example \"AnythingMCP\") and add these permissions:\n - Zone → **Zone** → Read; Zone → **DNS** → Edit (Read for a read-only setup); Zone → **Cache Purge** → Purge\n - Zone → **Zone Settings** → Read; Zone → **Page Rules** → Read; Zone → **Analytics** → Read; Zone → **Workers Routes** → Read\n - For rules: Zone → **Single Redirect**, **Transform Rules**, **Cache Rules**, **Origin Rules**, **Config Rules** and **Zone WAF** → Read (each phase needs its own permission)\n - Account → **Account Settings** → Read; Account → **Workers Scripts** → Read; Account → **Workers R2 Storage** → Read; Account → **Account Analytics** → Read\n3. Under **Zone Resources** pick **Include → All zones from an account** (or **Specific zone** to limit the AI to some domains); under **Account Resources** include your account. Optionally restrict **Client IP Address Filtering** and set a **TTL**.\n4. **Continue to summary → Create Token**, copy the token (Cloudflare shows it once) and paste it into `CLOUDFLARE_API_TOKEN`. Do not use the Global API Key.\n5. An **account-owned token** (Manage Account → Account API Tokens) works too, except for Page Rules, which it cannot read.\n\n**Read-only setup**: give DNS only Read, leave out Cache Purge, and switch off `cloudflare_create_dns_record`, `cloudflare_update_dns_record` and `cloudflare_purge_cache`. `cloudflare_delete_dns_record` and `cloudflare_purge_everything` are installed switched off.\n\n**Ids**: zone ids and account ids are 32 hex characters; `cloudflare_list_zones` (filter by `name`) and `cloudflare_list_accounts` give them, and every zone carries its `account.id`. DNS record ids come from `cloudflare_list_dns_records`. Answers are wrapped as `{success, errors, messages, result, result_info}`.\n\n**DNS changes**\n- Look before you change: `cloudflare_list_dns_records` with `name` or `type`, and `cloudflare_export_dns_records` for a full BIND copy to keep before larger edits.\n- Names are full names (`www.example.com`); `ttl` 1 means automatic; `proxied` (orange cloud) only applies to A, AAAA and CNAME records; MX and URI records need `priority`; SRV and CAA take `data` instead of `content`.\n- A CNAME cannot share its name with any other record, and an identical record is refused. Changes go live within seconds, so a wrong record can take a website or its email offline at once: confirm each change with the user.\n\n**Cache**: `cloudflare_purge_cache` takes exactly one of `files` (full URLs), `tags`, `hosts` or `prefixes`, up to 100 per call; every plan can use all four. Free plans allow 5 purge requests per minute by tag, host or prefix. `cloudflare_purge_everything` (switched off) empties the zone's whole cache and sends every next request to the origin.\n\n**Rules**: modern rules live in rulesets, one entry point per phase. `cloudflare_get_phase_rules` reads the zone's rules for a phase: `http_request_dynamic_redirect` (single redirects), `http_request_transform` (URL rewrites), `http_request_late_transform` and `http_response_headers_transform` (header changes), `http_request_cache_settings` (cache rules), `http_request_origin` (origin rules), `http_config_settings` (configuration rules), `http_request_firewall_custom` (WAF custom rules), `http_ratelimit`. A 404 means the zone has no rules in that phase. Legacy Page Rules come from `cloudflare_list_page_rules`. Rules are read-only here.\n\n**Analytics**: `cloudflare_get_zone_traffic` gives daily totals for a date range. For anything else, `cloudflare_graphql_analytics` runs a GraphQL query: for example `httpRequestsAdaptiveGroups(limit: 20, filter: {datetime_geq: \"...\", datetime_leq: \"...\"}, orderBy: [count_DESC]) { count dimensions { clientRequestPath } }` under `viewer { zones(filter: {zoneTag: \"\"}) }`. How far back a dataset reaches depends on the plan; adaptive datasets keep at least 31 days. GraphQL errors (for example a range the plan does not allow) are reported as failed calls.\n\n**Workers and R2**: `cloudflare_list_workers`, `cloudflare_get_worker_settings` (bindings; secret values are never shown) and `cloudflare_get_worker_content` (the deployed code) read Workers; `cloudflare_list_worker_routes` and `cloudflare_list_worker_domains` show where they run. R2 must be enabled (subscribed, free tier included) under **Storage & databases → R2** before `cloudflare_list_r2_buckets` answers; otherwise it fails with 403 \"NotEntitled\". Nothing here deploys Workers or touches R2 objects.\n\n**Pagination**: page-based lists take `page` and `per_page` and report `result_info.total_count` and `total_pages`; rulesets and R2 buckets page with a `cursor` from `result_info`.\n\n**Rate limits**: 1,200 requests per 5 minutes per token (dashboard use counts too). Over it Cloudflare answers 429 and blocks every call for 5 minutes, so avoid tight loops over many zones. GraphQL has its own limit of about 300 queries per 5 minutes.\n\n**Errors**: 400 with code 6003 usually means the Global API Key was pasted instead of a token. 401 or 403 with code 10000 or 9109 means the token is invalid or lacks the permission for that zone or account: edit the token and add it. 404 is an unknown zone, record or ruleset (or a phase without rules). A create answering 400 \"An identical record already exists\" or \"A CNAME record with that host already exists\" names the conflicting record.\n\n**Out of scope here**: editing rulesets, Page Rules and zone settings, SSL/TLS certificates, deploying or deleting Workers, KV, D1 and R2 objects, Zero Trust, registrar and billing.", + "region": "intl", + "category": "infrastructure", + "icon": "cloudflare", + "docsUrl": "https://developers.cloudflare.com/api/", + "requiredEnvVars": [ + "CLOUDFLARE_API_TOKEN" + ], + "envVarMeta": { + "CLOUDFLARE_API_TOKEN": { + "label": "API token", + "kind": "credential", + "secret": true, + "help": "Cloudflare dashboard → My Profile → API Tokens → Create Token, with the permissions listed in the setup steps. Not the Global API Key.", + "link": "https://dash.cloudflare.com/profile/api-tokens", + "pattern": "^(?!Bearer\\s)[A-Za-z0-9_-]{40,}$", + "patternMessage": "Paste only the API token (40 or more letters, digits, - and _), not the Global API Key and without the word Bearer." + } + }, + "verifyHints": { + "400": "Cloudflare rejected the request headers. Make sure you pasted an API token, not the Global API Key.", + "401": "Cloudflare refused the token: it is wrong, expired, disabled or limited to other client IP addresses. Check it under My Profile → API Tokens.", + "403": "The token lacks a permission for this call or this zone. Edit the token and add the permission (for example Zone Read or DNS Read) for the zone.", + "rate_limited": "Cloudflare allows 1,200 API requests per 5 minutes per token and then blocks for 5 minutes. Wait and try again." + }, + "probe": { + "tool": "cloudflare_list_zones", + "params": { + "per_page": 5 + } + }, + "prerequisites": "A Cloudflare account (the Free plan works) with at least one zone, and an API token with the permissions listed below (about 3 minutes). R2 must be enabled on the account before its buckets can be listed.", + "connector": { + "name": "Cloudflare API v4", + "type": "REST", + "baseUrl": "https://api.cloudflare.com/client/v4", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{CLOUDFLARE_API_TOKEN}}" + }, + "headers": { + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/zones?per_page=5", + "config": { + "errorWhen": [ + { + "path": "errors[0].message", + "messagePath": "errors[0].message", + "messageMatches": "not authori[sz]ed|authentication|permission", + "status": 403 + }, + { + "path": "errors[0].message", + "messagePath": "errors[0].message", + "status": 400 + } + ] + } + }, + "tools": [ + { + "name": "cloudflare_list_zones", + "description": "List the zones (domains) the token can see: id, name, status (active, pending, initializing, moved), plan, name_servers, paused and account (id, name). The zone id is what the zone tools take.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Only the zone with this domain name, e.g. example.com. Prefix with contains: or starts_with: to match part of it." + }, + "status": { + "type": "string", + "enum": [ + "active", + "pending", + "initializing", + "moved" + ], + "description": "Only zones with this status." + }, + "account_id": { + "type": "string", + "description": "Only zones of this account." + }, + "page": { + "type": "integer", + "minimum": 1, + "description": "Page number, from 1. result_info.total_pages says how many there are." + }, + "per_page": { + "type": "integer", + "minimum": 5, + "maximum": 50, + "default": 20, + "description": "Zones per page, 5 to 50 (default 20)." + }, + "order": { + "type": "string", + "enum": [ + "name", + "status", + "account.id", + "account.name", + "plan.id" + ], + "description": "Sort by name, status, account.id, account.name or plan.id." + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "asc or desc." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/zones", + "queryParams": { + "name": "$name", + "status": "$status", + "account.id": "$account_id", + "page": "$page", + "per_page": "$per_page", + "order": "$order", + "direction": "$direction" + } + } + }, + { + "name": "cloudflare_get_zone", + "description": "Get one zone: name, status, plan, name servers (the ones Cloudflare assigned and the original ones), account, created and activated dates, paused and development_mode.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}" + } + }, + { + "name": "cloudflare_list_accounts", + "description": "List the Cloudflare accounts the token has access to: id, name, type and settings. Workers, R2 and other account tools take the id.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Only accounts whose name matches." + }, + "page": { + "type": "integer", + "minimum": 1, + "description": "Page number, from 1. result_info.total_pages says how many there are." + }, + "per_page": { + "type": "integer", + "minimum": 5, + "maximum": 50, + "description": "Accounts per page, 5 to 50 (default 20)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "queryParams": { + "name": "$name", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "cloudflare_list_zone_settings", + "description": "Read all settings of a zone, each as id, value and editable: SSL mode (ssl), always_use_https, min_tls_version, cache_level, browser_cache_ttl, development_mode, security_level, http3, brotli, rocket_loader and more.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/settings" + } + }, + { + "name": "cloudflare_get_zone_setting", + "description": "Read one zone setting by its id, e.g. ssl, always_use_https, min_tls_version, security_level, cache_level, development_mode: value, editable and modified_on.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "setting_id": { + "type": "string", + "description": "Setting id, e.g. ssl or always_use_https." + } + }, + "required": [ + "zone_id", + "setting_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/settings/{setting_id}", + "encodePathParams": true + } + }, + { + "name": "cloudflare_list_dns_records", + "description": "List a zone's DNS records with filters: id, type, name, content, proxied, ttl (1 = automatic), priority, comment, tags and modified_on. Pages with page and per_page; result_info has the total.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "type": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CAA", + "CERT", + "CNAME", + "DNSKEY", + "DS", + "HTTPS", + "LOC", + "MX", + "NAPTR", + "NS", + "OPENPGPKEY", + "PTR", + "SMIMEA", + "SRV", + "SSHFP", + "SVCB", + "TLSA", + "TXT", + "URI" + ], + "description": "Only records of this type." + }, + "name": { + "type": "string", + "description": "Only records with exactly this full name, e.g. www.example.com." + }, + "name_contains": { + "type": "string", + "description": "Only records whose name contains this text." + }, + "content": { + "type": "string", + "description": "Only records with exactly this content, e.g. an IP address." + }, + "content_contains": { + "type": "string", + "description": "Only records whose content contains this text." + }, + "proxied": { + "type": "boolean", + "description": "true for proxied (orange cloud) records only, false for DNS-only ones." + }, + "comment_contains": { + "type": "string", + "description": "Only records whose comment contains this text." + }, + "tag": { + "type": "string", + "description": "Only records with this tag, as name:value or just name." + }, + "order": { + "type": "string", + "enum": [ + "type", + "name", + "content", + "ttl", + "proxied" + ], + "description": "Sort by type (default), name, content, ttl or proxied." + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "asc or desc." + }, + "page": { + "type": "integer", + "minimum": 1, + "description": "Page number, from 1. result_info.total_pages says how many there are." + }, + "per_page": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100, + "description": "Records per page, 1 to 1000 (default 100)." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/dns_records", + "queryParams": { + "type": "$type", + "name.exact": "$name", + "name.contains": "$name_contains", + "content.exact": "$content", + "content.contains": "$content_contains", + "proxied": "$proxied", + "comment.contains": "$comment_contains", + "tag": "$tag", + "order": "$order", + "direction": "$direction", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "cloudflare_get_dns_record", + "description": "Get one DNS record by id: type, name, content, proxied, ttl, priority, data (SRV, CAA ...), comment, tags, created_on and modified_on.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "dns_record_id": { + "type": "string", + "description": "DNS record id, from cloudflare_list_dns_records." + } + }, + "required": [ + "zone_id", + "dns_record_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/dns_records/{dns_record_id}" + } + }, + { + "name": "cloudflare_export_dns_records", + "description": "Export all DNS records of a zone as a BIND zone file (plain text), handy for a backup before changes or to review everything at once. Cut at 200 KB.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/dns_records/export" + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + } + }, + { + "name": "cloudflare_create_dns_record", + "description": "Create a DNS record in a zone. Returns the new record with its id. Check with cloudflare_list_dns_records first: an identical record, or a CNAME next to an A record of the same name, is refused. Show the user the record and wait for confirmation; DNS changes go live within seconds.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "type": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CAA", + "CERT", + "CNAME", + "DNSKEY", + "DS", + "HTTPS", + "LOC", + "MX", + "NAPTR", + "NS", + "OPENPGPKEY", + "PTR", + "SMIMEA", + "SRV", + "SSHFP", + "SVCB", + "TLSA", + "TXT", + "URI" + ], + "description": "Record type." + }, + "name": { + "type": "string", + "description": "Full record name, e.g. www.example.com, or @ for the zone apex." + }, + "content": { + "type": "string", + "description": "Record value: an IPv4 address for A, IPv6 for AAAA, a host name for CNAME, MX and NS, the text for TXT. SRV and CAA use data instead." + }, + "ttl": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "description": "Time to live in seconds, 60 to 86400, or 1 for automatic (default 1)." + }, + "proxied": { + "type": "boolean", + "description": "Route traffic through Cloudflare (orange cloud). Only for A, AAAA and CNAME; default false." + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Priority, required for MX and URI records (lower wins)." + }, + "data": { + "type": "object", + "description": "Structured value for SRV ({\"port\": 5060, \"priority\": 10, \"weight\": 5, \"target\": \"sip.example.com\"}), CAA ({\"flags\": 0, \"tag\": \"issue\", \"value\": \"letsencrypt.org\"}) and similar types." + }, + "comment": { + "type": "string", + "description": "A note on the record, e.g. who added it and why." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags as name:value, e.g. [\"owner:web-team\"]. Paid plans only." + } + }, + "required": [ + "zone_id", + "type", + "name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/zones/{zone_id}/dns_records", + "bodyMapping": { + "type": "$type", + "name": "$name", + "content": "$content", + "ttl": "$ttl", + "proxied": "$proxied", + "priority": "$priority", + "data": "$data", + "comment": "$comment", + "tags": "$tags" + } + } + }, + { + "name": "cloudflare_update_dns_record", + "description": "Change fields of an existing DNS record (only the fields given), e.g. point it at a new IP, switch proxying on or off, change the TTL or comment. Read the record first, show the user old and new values and wait for confirmation: a wrong value takes a site or its email offline.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "dns_record_id": { + "type": "string", + "description": "DNS record id." + }, + "type": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CAA", + "CERT", + "CNAME", + "DNSKEY", + "DS", + "HTTPS", + "LOC", + "MX", + "NAPTR", + "NS", + "OPENPGPKEY", + "PTR", + "SMIMEA", + "SRV", + "SSHFP", + "SVCB", + "TLSA", + "TXT", + "URI" + ], + "description": "New record type, rarely needed." + }, + "name": { + "type": "string", + "description": "New full record name." + }, + "content": { + "type": "string", + "description": "Record value: an IPv4 address for A, IPv6 for AAAA, a host name for CNAME, MX and NS, the text for TXT. SRV and CAA use data instead." + }, + "ttl": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "description": "Time to live in seconds, 60 to 86400, or 1 for automatic (default 1)." + }, + "proxied": { + "type": "boolean", + "description": "Route traffic through Cloudflare (orange cloud). Only for A, AAAA and CNAME; default false." + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Priority, required for MX and URI records (lower wins)." + }, + "data": { + "type": "object", + "description": "Structured value for SRV ({\"port\": 5060, \"priority\": 10, \"weight\": 5, \"target\": \"sip.example.com\"}), CAA ({\"flags\": 0, \"tag\": \"issue\", \"value\": \"letsencrypt.org\"}) and similar types." + }, + "comment": { + "type": "string", + "description": "A note on the record, e.g. who added it and why." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags as name:value, e.g. [\"owner:web-team\"]. Paid plans only." + } + }, + "required": [ + "zone_id", + "dns_record_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/zones/{zone_id}/dns_records/{dns_record_id}", + "bodyMapping": { + "type": "$type", + "name": "$name", + "content": "$content", + "ttl": "$ttl", + "proxied": "$proxied", + "priority": "$priority", + "data": "$data", + "comment": "$comment", + "tags": "$tags" + } + } + }, + { + "name": "cloudflare_delete_dns_record", + "enabled": false, + "description": "Delete a DNS record. Whatever relied on it (a website, mail delivery, a domain verification) stops working within seconds. Switched off at install; export the zone first with cloudflare_export_dns_records.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "dns_record_id": { + "type": "string", + "description": "DNS record id." + } + }, + "required": [ + "zone_id", + "dns_record_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/zones/{zone_id}/dns_records/{dns_record_id}" + } + }, + { + "name": "cloudflare_purge_cache", + "description": "Remove cached content from Cloudflare's edge so the next request fetches it from the origin. Pass exactly one of files (full URLs), tags, hosts or prefixes, at most 100 entries. A success means the purge was accepted; it completes within seconds. Free plans allow 5 purge requests per minute by tag, host or prefix.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Full URLs to purge, e.g. [\"https://www.example.com/css/site.css\"]." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cache-Tag header values to purge." + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Host names whose whole cache to purge, e.g. [\"www.example.com\"]." + }, + "prefixes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "URL prefixes without scheme, e.g. [\"www.example.com/blog/\"]." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/zones/{zone_id}/purge_cache", + "bodyMapping": { + "files": "$files", + "tags": "$tags", + "hosts": "$hosts", + "prefixes": "$prefixes" + } + } + }, + { + "name": "cloudflare_purge_everything", + "enabled": false, + "description": "Purge the zone's entire cache. Every visitor's next request goes to the origin, which can overload it on a busy site. Switched off at install; prefer cloudflare_purge_cache with specific URLs.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/zones/{zone_id}/purge_cache", + "bodyMapping": { + "purge_everything": true + } + } + }, + { + "name": "cloudflare_list_rulesets", + "description": "List a zone's rulesets: id, name, kind (zone, managed, custom), phase and last_updated. Each phase (redirects, transform rules, cache rules, WAF custom rules ...) has one zone entry point ruleset holding the zone's own rules.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "cursor": { + "type": "string", + "description": "result_info.cursors.after from the previous page." + }, + "per_page": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Rulesets per page, 1 to 50." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/rulesets", + "queryParams": { + "cursor": "$cursor", + "per_page": "$per_page" + } + } + }, + { + "name": "cloudflare_get_ruleset", + "description": "Get one ruleset with all its rules: for each rule id, description, expression (the filter, e.g. http.host eq \"example.com\"), action, action_parameters, enabled and position.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "ruleset_id": { + "type": "string", + "description": "Ruleset id, from cloudflare_list_rulesets." + } + }, + "required": [ + "zone_id", + "ruleset_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/rulesets/{ruleset_id}" + } + }, + { + "name": "cloudflare_get_phase_rules", + "description": "Get the zone's own rules for one phase (its entry point ruleset): e.g. http_request_dynamic_redirect for single redirects, http_request_transform for URL rewrites, http_response_headers_transform for response headers, http_request_cache_settings for cache rules, http_request_firewall_custom for WAF custom rules. A 404 means the zone has no rules in that phase.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "phase": { + "type": "string", + "enum": [ + "http_request_dynamic_redirect", + "http_request_redirect", + "http_request_transform", + "http_request_late_transform", + "http_response_headers_transform", + "http_request_origin", + "http_request_cache_settings", + "http_response_cache_settings", + "http_config_settings", + "http_request_firewall_custom", + "http_request_firewall_managed", + "http_response_firewall_managed", + "http_ratelimit", + "http_response_compression", + "http_custom_errors", + "http_log_custom_fields", + "http_request_sanitize", + "http_request_sbfm", + "ddos_l7" + ], + "description": "Ruleset phase." + } + }, + "required": [ + "zone_id", + "phase" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/rulesets/phases/{phase}/entrypoint" + } + }, + { + "name": "cloudflare_list_page_rules", + "description": "List a zone's legacy Page Rules: id, targets (URL patterns), actions (forwarding_url, cache_level, always_use_https ...), priority and status. Cloudflare is replacing Page Rules with rulesets; newer zones may have none.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "status": { + "type": "string", + "enum": [ + "active", + "disabled" + ], + "description": "active or disabled." + }, + "order": { + "type": "string", + "enum": [ + "status", + "priority" + ], + "description": "Sort by status or priority (default priority)." + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "asc or desc." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/pagerules", + "queryParams": { + "status": "$status", + "order": "$order", + "direction": "$direction" + } + } + }, + { + "name": "cloudflare_get_zone_traffic", + "description": "Daily traffic of a zone between two dates from the GraphQL Analytics API: per day requests, bytes, cached requests and bytes, threats, page views, unique visitors, encrypted requests, and breakdowns by response status and by country. The range a plan may query is limited (Free: recent weeks).", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + }, + "since": { + "type": "string", + "description": "First day, YYYY-MM-DD." + }, + "until": { + "type": "string", + "description": "Last day, YYYY-MM-DD." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 366, + "default": 31, + "description": "Days to return at most (default 31)." + } + }, + "required": [ + "zone_id", + "since", + "until" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query ZoneTraffic($zoneTag: string, $start: Date, $end: Date, $limit: uint64!) { viewer { zones(filter: {zoneTag: $zoneTag}) { httpRequests1dGroups(limit: $limit, filter: {date_geq: $start, date_leq: $end}, orderBy: [date_ASC]) { dimensions { date } sum { requests bytes cachedRequests cachedBytes threats pageViews encryptedRequests responseStatusMap { edgeResponseStatus requests } countryMap { clientCountryName requests threats } } uniq { uniques } } } } }", + "variables": { + "zoneTag": "$zone_id", + "start": "$since", + "end": "$until", + "limit": "$limit" + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "cloudflare_graphql_analytics", + "description": "Run a query against Cloudflare's GraphQL Analytics API (read-only) for anything cloudflare_get_zone_traffic does not cover: e.g. httpRequestsAdaptiveGroups by path, host, status or country, firewallEventsAdaptive, workersInvocationsAdaptive. Start from viewer { zones(filter: {zoneTag: $zoneTag}) { ... } } or viewer { accounts(filter: {accountTag: $accountTag}) { ... } }; adaptive datasets need a datetime filter and a limit. Errors come back in errors with data null.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "GraphQL query text." + }, + "variables": { + "type": "object", + "description": "Variables for the query, e.g. {\"zoneTag\": \"\", \"since\": \"2026-10-09T00:00:00Z\"}." + } + }, + "required": [ + "query" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "$query", + "variables": "$variables" + } + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "cloudflare_list_workers", + "description": "List the Workers scripts of an account: id (the script name), created_on, modified_on, usage_model, handlers (fetch, scheduled ...), compatibility_date and tags.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "Account id (32 hex characters), from cloudflare_list_accounts or a zone's account.id." + } + }, + "required": [ + "account_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{account_id}/workers/scripts" + } + }, + { + "name": "cloudflare_get_worker_settings", + "description": "Get a Worker's settings: bindings (KV, R2, D1, Durable Objects, environment variables; secret values are never returned), compatibility date and flags, usage model, logpush and observability.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "Account id (32 hex characters), from cloudflare_list_accounts or a zone's account.id." + }, + "script_name": { + "type": "string", + "description": "Worker script name, from cloudflare_list_workers." + } + }, + "required": [ + "account_id", + "script_name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{account_id}/workers/scripts/{script_name}/settings", + "encodePathParams": true + } + }, + { + "name": "cloudflare_get_worker_content", + "description": "Download a Worker's deployed code as text, cut at 200 KB. Workers made of several modules come back as one multipart text with each file in its own part.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "Account id (32 hex characters), from cloudflare_list_accounts or a zone's account.id." + }, + "script_name": { + "type": "string", + "description": "Worker script name." + } + }, + "required": [ + "account_id", + "script_name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{account_id}/workers/scripts/{script_name}/content/v2", + "encodePathParams": true + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + } + }, + { + "name": "cloudflare_list_worker_routes", + "description": "List a zone's Worker routes: id, pattern (e.g. example.com/api/*) and the script that handles it.", + "parameters": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone id (32 hex characters), from cloudflare_list_zones." + } + }, + "required": [ + "zone_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/zones/{zone_id}/workers/routes" + } + }, + { + "name": "cloudflare_list_worker_domains", + "description": "List the custom domains attached to Workers in an account: hostname, zone, service (the Worker) and environment.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "Account id (32 hex characters), from cloudflare_list_accounts or a zone's account.id." + }, + "hostname": { + "type": "string", + "description": "Only this hostname." + }, + "service": { + "type": "string", + "description": "Only domains of this Worker." + }, + "zone_id": { + "type": "string", + "description": "Only domains in this zone." + } + }, + "required": [ + "account_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{account_id}/workers/domains", + "queryParams": { + "hostname": "$hostname", + "service": "$service", + "zone_id": "$zone_id" + } + } + }, + { + "name": "cloudflare_list_r2_buckets", + "description": "List an account's R2 buckets: name, creation_date, location and jurisdiction. Pages with cursor (result_info.cursor). Needs R2 to be enabled on the account.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "Account id (32 hex characters), from cloudflare_list_accounts or a zone's account.id." + }, + "name_contains": { + "type": "string", + "description": "Only buckets whose name contains this text." + }, + "per_page": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Buckets per page, 1 to 1000 (default 20)." + }, + "cursor": { + "type": "string", + "description": "result_info.cursor from the previous page." + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "asc or desc." + } + }, + "required": [ + "account_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{account_id}/r2/buckets", + "queryParams": { + "name_contains": "$name_contains", + "per_page": "$per_page", + "cursor": "$cursor", + "direction": "$direction" + } + } + }, + { + "name": "cloudflare_get_r2_bucket", + "description": "Get one R2 bucket: name, creation_date, location, jurisdiction and storage_class.", + "parameters": { + "type": "object", + "properties": { + "account_id": { + "type": "string", + "description": "Account id (32 hex characters), from cloudflare_list_accounts or a zone's account.id." + }, + "bucket_name": { + "type": "string", + "description": "Bucket name." + } + }, + "required": [ + "account_id", + "bucket_name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{account_id}/r2/buckets/{bucket_name}", + "encodePathParams": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/cloudflare.live.spec.ts b/packages/backend/src/adapters/intl/cloudflare.live.spec.ts new file mode 100644 index 000000000..1d7209d9a --- /dev/null +++ b/packages/backend/src/adapters/intl/cloudflare.live.spec.ts @@ -0,0 +1,338 @@ +import * as http from 'node:http'; +import { AddressInfo } from 'node:net'; +import * as adapterJson from './cloudflare.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 { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; +import { assertNoResponseBodyError, describeErrorWhenProblems, ResponseBodyError } from '../../connectors/engines/response-error.util'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Static checks and request-shape checks against a local server always run. + * The live block runs every read tool through the real RestEngine: + * CLOUDFLARE_API_TOKEN=... [CLOUDFLARE_ZONE_ID=...] [CLOUDFLARE_ACCOUNT_ID=...] \ + * npx jest src/adapters/intl/cloudflare.live.spec.ts + * Without CLOUDFLARE_ZONE_ID the first zone the token sees is read, and its + * account unless CLOUDFLARE_ACCOUNT_ID is set. CLOUDFLARE_LIVE_WRITE=1 (needs + * CLOUDFLARE_ZONE_ID and DNS Edit + Cache Purge) also creates a TXT record + * anythingmcp-test-

`, `` and ``.\n\n**Reading a board**: `miro_list_items` returns every item (filter by `type` or by frame with `parent_item_id`), `miro_list_connectors` the lines between them. Both page with a `cursor` and take 10 to 50 per page. Boards, members and tags page with `offset` and `limit`.\n\n**Tags**: only cards and sticky notes can carry tags, at most 8 each. Create the tag (`miro_create_tag`), then `miro_attach_tag`; `miro_list_items_by_tag` finds the tagged items. Tag changes made through the API appear on an open board after a refresh.\n\n**Deleting**: `miro_delete_item` removes any item for good (there is no undo through the API); deleting a frame leaves its contents on the board. `miro_delete_tag` removes the tag from every item. Confirm deletions with the user unless the AI just created the item in the same task.\n\n**Plans**: the API is free to use. Free teams may hold only 3 editable boards, so `miro_create_board` and `miro_copy_board` can fail there; boards of a developer team carry a watermark.\n\n**Rate limits**: Miro counts credits per user and app, 100,000 a minute: a read costs 50, most writes 100, creating or deleting boards and deleting items 500, copying a board 2,000. Over the limit Miro answers 429; wait a minute.\n\n**Errors**: 401 (`tokenNotProvided`) means the authorization expired or was revoked: click **Authorize with Provider** again. 403 means a missing permission (`boards:write` for changes) or no access to that board. 404 is a wrong board or item id (or one passed as a number). 400 (`invalidParameters`) names the field, e.g. a sticky note with both width and height, a hex color on a sticky note, or a frame used as a connector end. 409 means the item changed at the same time; read it again and retry.\n\n**Out of scope here**: images, documents and embeds (read only through `miro_list_items`), app cards, comments, mind maps, exports, and team or organization administration.", + "region": "intl", + "category": "productivity", + "icon": "miro", + "docsUrl": "https://developers.miro.com/reference/api-reference", + "requiredEnvVars": [ + "MIRO_CLIENT_ID", + "MIRO_CLIENT_SECRET" + ], + "appRegistrationUrl": "https://miro.com/app/settings/user-profile/apps", + "envVarMeta": { + "MIRO_CLIENT_ID": { + "label": "Client ID", + "kind": "credential", + "help": "Miro → avatar → Settings → Your apps → your app → App Credentials: Client ID.", + "link": "https://miro.com/app/settings/user-profile/apps", + "pattern": "^[0-9]{6,}$", + "patternMessage": "The Client ID is a long number, e.g. 3458764512345678901." + }, + "MIRO_CLIENT_SECRET": { + "label": "Client secret", + "kind": "credential", + "secret": true, + "help": "Same page → App Credentials: Client secret (click to reveal and copy).", + "link": "https://miro.com/app/settings/user-profile/apps" + } + }, + "verifyHints": { + "401": "Miro refused the access token. Click Authorize with Provider again; if the app was deleted or its secret reset, update MIRO_CLIENT_SECRET first.", + "403": "The app lacks the boards:read or boards:write permission, or the user has no access to this board. Tick the permissions in the app settings and authorize again.", + "rate_limited": "Miro is rate limiting this user. Wait a minute and try again." + }, + "probe": { + "tool": "miro_get_token_info" + }, + "prerequisites": "A Miro account (the free plan works) and a Miro app in a developer team (free, about 5 minutes) with the redirect URI below, the permissions boards:read and boards:write, and expiring tokens turned on when the app is created.", + "connector": { + "name": "Miro REST API v2", + "type": "REST", + "baseUrl": "https://api.miro.com/v2", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{MIRO_CLIENT_ID}}", + "clientSecret": "{{MIRO_CLIENT_SECRET}}", + "authorizationUrl": "https://miro.com/oauth/authorize", + "tokenUrl": "https://api.miro.com/v1/oauth/token" + }, + "headers": { + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/boards?limit=1" + }, + "tools": [ + { + "name": "miro_get_token_info", + "description": "Show who the connection belongs to: the Miro user, the team the app was installed in, the organization and the granted scopes. A cheap check that the authorization works.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.miro.com/v1/oauth-token" + } + }, + { + "name": "miro_list_boards", + "description": "Find boards the user can open: by text, owner, team or project. Returns id, name, description, owner, team, viewLink, createdAt and modifiedAt, with total for paging by offset. Recently created boards can take a few seconds to show up in a search.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Text to search in board names and descriptions." + }, + "team_id": { + "type": "string", + "description": "Only boards of this team (then query and owner are ignored)." + }, + "project_id": { + "type": "string", + "description": "Only boards in this project (space)." + }, + "owner": { + "type": "string", + "description": "Only boards owned by this user id." + }, + "sort": { + "type": "string", + "enum": [ + "default", + "last_modified", + "last_opened", + "last_created", + "alphabetically" + ], + "description": "default, last_modified, last_opened, last_created or alphabetically (applies when filtering by team or project)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Boards per page, 1 to 50 (default 20)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "How many boards to skip, for paging (default 0)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/boards", + "queryParams": { + "query": "$query", + "team_id": "$team_id", + "project_id": "$project_id", + "owner": "$owner", + "sort": "$sort", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "miro_get_board", + "description": "Get one board: name, description, owner, team, project, sharing and permission policies, viewLink, createdAt and modifiedAt.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}", + "encodePathParams": true + } + }, + { + "name": "miro_create_board", + "description": "Create a board. Returns the new board with its id and viewLink. Free plans allow only 3 editable boards per team. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Board name, up to 60 characters (default Untitled)." + }, + "description": { + "type": "string", + "description": "Description, up to 300 characters." + }, + "team_id": { + "type": "string", + "description": "Team to create it in; the user's default team otherwise." + }, + "project_id": { + "type": "string", + "description": "Project (space) to put it in." + }, + "policy": { + "type": "object", + "description": "Sharing and permissions, e.g. {\"sharingPolicy\": {\"access\": \"private\", \"teamAccess\": \"edit\"}, \"permissionsPolicy\": {\"copyAccess\": \"team_members\"}}. access and teamAccess: private, view, comment or edit." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/boards", + "bodyMapping": { + "name": "$name", + "description": "$description", + "teamId": "$team_id", + "projectId": "$project_id", + "policy": "$policy" + } + } + }, + { + "name": "miro_update_board", + "description": "Rename a board, change its description or its sharing policy. Only the fields given change.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "name": { + "type": "string", + "description": "New name, up to 60 characters." + }, + "description": { + "type": "string", + "description": "New description, up to 300 characters." + }, + "policy": { + "type": "object", + "description": "Sharing and permissions, e.g. {\"sharingPolicy\": {\"access\": \"private\", \"teamAccess\": \"edit\"}, \"permissionsPolicy\": {\"copyAccess\": \"team_members\"}}. access and teamAccess: private, view, comment or edit." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}", + "encodePathParams": true, + "bodyMapping": { + "name": "$name", + "description": "$description", + "policy": "$policy" + } + } + }, + { + "name": "miro_copy_board", + "description": "Copy a board with all its content into a new board, optionally with a new name and in another team. Returns the new board. Confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "name": { + "type": "string", + "description": "Name of the copy." + }, + "description": { + "type": "string", + "description": "Description of the copy." + }, + "team_id": { + "type": "string", + "description": "Team to put the copy in." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/boards?copy_from={board_id}", + "encodePathParams": true, + "bodyMapping": { + "name": "$name", + "description": "$description", + "teamId": "$team_id" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "miro_delete_board", + "enabled": false, + "description": "Delete a whole board with everything on it. On paid plans it stays in the trash for 90 days. Switched off at install.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/boards/{board_id}", + "encodePathParams": true + } + }, + { + "name": "miro_list_items", + "description": "List the items on a board: id, type, data (content, title ...), style, position, geometry, parent (frame) and who changed them when. Filter by type or by frame (parent_item_id). Pages with cursor: pass the cursor of the answer until none comes back.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "type": { + "type": "string", + "enum": [ + "sticky_note", + "shape", + "text", + "card", + "app_card", + "frame", + "image", + "document", + "embed", + "preview" + ], + "description": "Only items of this type." + }, + "parent_item_id": { + "type": "string", + "description": "Only items inside this frame." + }, + "limit": { + "type": "integer", + "minimum": 10, + "maximum": 50, + "description": "Items per page, 10 to 50 (default 10)." + }, + "cursor": { + "type": "string", + "description": "cursor from the previous page." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/items", + "encodePathParams": true, + "queryParams": { + "type": "$type", + "parent_item_id": "$parent_item_id", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "miro_get_item", + "description": "Get one item of any type (sticky note, shape, text, card, frame, image ...): type, data, style, position, geometry and parent.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/items/{item_id}", + "encodePathParams": true + } + }, + { + "name": "miro_move_item", + "description": "Move any item: to a new position, into a frame (parent {\"id\": \"\"}) or out of it back onto the board (parent {\"id\": null}).", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "parent": { + "type": "object", + "description": "New frame {\"id\": \"\"}, or {\"id\": null} to take it out of its frame." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/items/{item_id}", + "encodePathParams": true, + "bodyMapping": { + "position": "$position", + "parent": "$parent" + } + } + }, + { + "name": "miro_delete_item", + "description": "Delete an item of any type (sticky note, shape, text, card, frame, image ...). Deleting a frame does not delete the items inside it. Confirm with the user before deleting anything they did not just create.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/boards/{board_id}/items/{item_id}", + "encodePathParams": true + } + }, + { + "name": "miro_create_sticky_note", + "description": "Add a sticky note to a board. Returns the note with its id. Content may use simple HTML (

, , ).", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "content": { + "type": "string", + "description": "Text of the note." + }, + "shape": { + "type": "string", + "enum": [ + "square", + "rectangle" + ], + "description": "square (default) or rectangle." + }, + "style": { + "type": "object", + "description": "Look: {\"fillColor\": \"light_yellow\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. fillColor is a name: gray, light_yellow (default), yellow, orange, light_green, green, dark_green, cyan, light_pink, pink, violet, red, light_blue, blue, dark_blue or black. textAlign left, center or right; textAlignVertical top, middle or bottom." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 200} or {\"height\": 200}, never both (sticky notes keep their aspect ratio)." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id", + "content" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/sticky_notes", + "encodePathParams": true, + "bodyMapping": { + "data": { + "content": "$content", + "shape": "$shape" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_update_sticky_note", + "description": "Change a sticky note's text, shape, color, position, size or frame. Only the fields given change.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + }, + "content": { + "type": "string", + "description": "New text." + }, + "shape": { + "type": "string", + "enum": [ + "square", + "rectangle" + ], + "description": "square or rectangle." + }, + "style": { + "type": "object", + "description": "Look: {\"fillColor\": \"light_yellow\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. fillColor is a name: gray, light_yellow (default), yellow, orange, light_green, green, dark_green, cyan, light_pink, pink, violet, red, light_blue, blue, dark_blue or black. textAlign left, center or right; textAlignVertical top, middle or bottom." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 200} or {\"height\": 200}, never both (sticky notes keep their aspect ratio)." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/sticky_notes/{item_id}", + "encodePathParams": true, + "bodyMapping": { + "data": { + "content": "$content", + "shape": "$shape" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_create_shape", + "description": "Add a shape (rectangle, circle, flowchart symbol ...) with optional text to a board. Returns the shape with its id.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "shape": { + "type": "string", + "default": "rectangle", + "description": "Shape: rectangle (default), round_rectangle, circle, triangle, rhombus, parallelogram, trapezoid, pentagon, hexagon, octagon, star, cloud, cross, can, right_arrow, left_arrow, left_right_arrow, left_brace, right_brace, wedge_round_rectangle_callout, or a flowchart shape such as flow_chart_process, flow_chart_decision, flow_chart_terminator." + }, + "content": { + "type": "string", + "description": "Text inside the shape; simple HTML allowed." + }, + "style": { + "type": "object", + "description": "Look, all values as strings: {\"fillColor\": \"#fff9b1\", \"fillOpacity\": \"1\", \"borderColor\": \"#1a1a1a\", \"borderWidth\": \"2\", \"borderStyle\": \"normal\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\", \"fontFamily\": \"arial\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. Colors are hex; borderStyle normal, dotted or dashed; fontSize 10 to 288." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/shapes", + "encodePathParams": true, + "bodyMapping": { + "data": { + "shape": "$shape", + "content": "$content" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_update_shape", + "description": "Change a shape's type, text, style, position, size or frame. Only the fields given change.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + }, + "shape": { + "type": "string", + "description": "New shape: rectangle (default), round_rectangle, circle, triangle, rhombus, parallelogram, trapezoid, pentagon, hexagon, octagon, star, cloud, cross, can, right_arrow, left_arrow, left_right_arrow, left_brace, right_brace, wedge_round_rectangle_callout, or a flowchart shape such as flow_chart_process, flow_chart_decision, flow_chart_terminator." + }, + "content": { + "type": "string", + "description": "New text." + }, + "style": { + "type": "object", + "description": "Look, all values as strings: {\"fillColor\": \"#fff9b1\", \"fillOpacity\": \"1\", \"borderColor\": \"#1a1a1a\", \"borderWidth\": \"2\", \"borderStyle\": \"normal\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\", \"fontFamily\": \"arial\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. Colors are hex; borderStyle normal, dotted or dashed; fontSize 10 to 288." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/shapes/{item_id}", + "encodePathParams": true, + "bodyMapping": { + "data": { + "shape": "$shape", + "content": "$content" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_create_text", + "description": "Add a free text item to a board (a heading, a label, a paragraph). Returns it with its id.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "content": { + "type": "string", + "description": "The text; simple HTML allowed." + }, + "style": { + "type": "object", + "description": "Look, values as strings: {\"color\": \"#1a1a1a\", \"fontSize\": \"24\", \"fontFamily\": \"arial\", \"textAlign\": \"left\", \"fillColor\": \"#ffffff\", \"fillOpacity\": \"1\"}." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "{\"width\": 400} and optionally \"rotation\"; the height follows the text. The width must be at least 1.7 times the font size." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id", + "content" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/texts", + "encodePathParams": true, + "bodyMapping": { + "data": { + "content": "$content" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_update_text", + "description": "Change a text item. Miro wants the text on every update, so pass the current content when only changing style, position or size.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + }, + "content": { + "type": "string", + "description": "The text (new or current)." + }, + "style": { + "type": "object", + "description": "Look, values as strings: {\"color\": \"#1a1a1a\", \"fontSize\": \"24\", \"fontFamily\": \"arial\", \"textAlign\": \"left\", \"fillColor\": \"#ffffff\", \"fillOpacity\": \"1\"}." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "{\"width\": 400} and optionally \"rotation\"; the height follows the text. The width must be at least 1.7 times the font size." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id", + "item_id", + "content" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/texts/{item_id}", + "encodePathParams": true, + "bodyMapping": { + "data": { + "content": "$content" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_create_card", + "description": "Add a card (a task-like item with title, description, assignee and due date) to a board. Returns it with its id. Cards and sticky notes are the items that can carry tags.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "title": { + "type": "string", + "description": "Card title; simple HTML allowed." + }, + "description": { + "type": "string", + "description": "Card description." + }, + "assignee_id": { + "type": "string", + "description": "User id of the assignee (from miro_list_board_members)." + }, + "due_date": { + "type": "string", + "description": "Due date and time, ISO 8601 in UTC, e.g. 2026-10-31T17:00:00Z." + }, + "style": { + "type": "object", + "description": "{\"cardTheme\": \"#2d9bf0\"}: the card's accent color in hex." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/cards", + "encodePathParams": true, + "bodyMapping": { + "data": { + "title": "$title", + "description": "$description", + "assigneeId": "$assignee_id", + "dueDate": "$due_date" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_update_card", + "description": "Change a card's title, description, assignee, due date, color, position, size or frame. Only the fields given change.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + }, + "title": { + "type": "string", + "description": "Card title; simple HTML allowed." + }, + "description": { + "type": "string", + "description": "Card description." + }, + "assignee_id": { + "type": "string", + "description": "User id of the assignee (from miro_list_board_members)." + }, + "due_date": { + "type": "string", + "description": "Due date and time, ISO 8601 in UTC, e.g. 2026-10-31T17:00:00Z." + }, + "style": { + "type": "object", + "description": "{\"cardTheme\": \"#2d9bf0\"}: the card's accent color in hex." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees." + }, + "parent": { + "type": "object", + "description": "Frame to put the item in: {\"id\": \"\"}." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/cards/{item_id}", + "encodePathParams": true, + "bodyMapping": { + "data": { + "title": "$title", + "description": "$description", + "assigneeId": "$assignee_id", + "dueDate": "$due_date" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry", + "parent": "$parent" + } + } + }, + { + "name": "miro_create_frame", + "description": "Add a frame (a titled area that groups items) to a board. Returns it with its id; put items into it with their parent field or miro_move_item. Frames cannot sit inside other frames here. Miro sometimes answers a valid request with 500 \"Internal error occurred\" for several minutes; wait a minute and try the same request again instead of changing it.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "title": { + "type": "string", + "description": "Frame title." + }, + "style": { + "type": "object", + "description": "{\"fillColor\": \"#ffffff\"}: background color in hex; transparent when left out." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 1200, \"height\": 800}." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/frames", + "encodePathParams": true, + "bodyMapping": { + "data": { + "title": "$title", + "format": "custom", + "type": "freeform" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry" + } + } + }, + { + "name": "miro_update_frame", + "description": "Change a frame's title, background, position or size. Only the fields given change; the items inside move with the frame.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string." + }, + "title": { + "type": "string", + "description": "New title." + }, + "style": { + "type": "object", + "description": "{\"fillColor\": \"#ffffff\"}: background color in hex; transparent when left out." + }, + "position": { + "type": "object", + "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner." + }, + "geometry": { + "type": "object", + "description": "Size: {\"width\": 1200, \"height\": 800}." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/frames/{item_id}", + "encodePathParams": true, + "bodyMapping": { + "data": { + "title": "$title" + }, + "style": "$style", + "position": "$position", + "geometry": "$geometry" + } + } + }, + { + "name": "miro_list_connectors", + "description": "List the connector lines on a board: id, startItem and endItem (item ids), shape, captions and style. Pages with cursor.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "limit": { + "type": "integer", + "minimum": 10, + "maximum": 50, + "description": "Connectors per page, 10 to 50 (default 10)." + }, + "cursor": { + "type": "string", + "description": "cursor from the previous page." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/connectors", + "encodePathParams": true, + "queryParams": { + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "miro_get_connector", + "description": "Get one connector line: the items it joins, its shape, captions and style.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "connector_id": { + "type": "string", + "description": "Connector id." + } + }, + "required": [ + "board_id", + "connector_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/connectors/{connector_id}", + "encodePathParams": true + } + }, + { + "name": "miro_create_connector", + "description": "Draw a connector line between two items (sticky notes, shapes, cards, texts; not frames), e.g. to link steps of a flowchart. Returns it with its id.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "start_item": { + "type": "object", + "description": "Where the line starts: {\"id\": \"\", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"\", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected." + }, + "end_item": { + "type": "object", + "description": "Where the line ends, another item: {\"id\": \"\", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"\", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected." + }, + "shape": { + "type": "string", + "enum": [ + "straight", + "elbowed", + "curved" + ], + "description": "straight, elbowed or curved (default curved)." + }, + "captions": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Labels on the line, up to 20: [{\"content\": \"yes\", \"position\": \"50%\"}]." + }, + "style": { + "type": "object", + "description": "Line look, values as strings: {\"strokeColor\": \"#1a1a1a\", \"strokeWidth\": \"2\", \"strokeStyle\": \"normal\", \"startStrokeCap\": \"none\", \"endStrokeCap\": \"arrow\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\"}. strokeStyle normal, dotted or dashed; caps none, arrow, stealth, triangle, filled_triangle, diamond, oval and more." + } + }, + "required": [ + "board_id", + "start_item", + "end_item" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/connectors", + "encodePathParams": true, + "bodyMapping": { + "startItem": "$start_item", + "endItem": "$end_item", + "shape": "$shape", + "captions": "$captions", + "style": "$style" + } + } + }, + { + "name": "miro_update_connector", + "description": "Change a connector line: its ends, shape, captions or style. Only the fields given change.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "connector_id": { + "type": "string", + "description": "Connector id." + }, + "start_item": { + "type": "object", + "description": "New start: {\"id\": \"\", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"\", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected." + }, + "end_item": { + "type": "object", + "description": "New end: {\"id\": \"\", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"\", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected." + }, + "shape": { + "type": "string", + "enum": [ + "straight", + "elbowed", + "curved" + ], + "description": "straight, elbowed or curved." + }, + "captions": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Labels on the line, up to 20: [{\"content\": \"yes\", \"position\": \"50%\"}]." + }, + "style": { + "type": "object", + "description": "Line look, values as strings: {\"strokeColor\": \"#1a1a1a\", \"strokeWidth\": \"2\", \"strokeStyle\": \"normal\", \"startStrokeCap\": \"none\", \"endStrokeCap\": \"arrow\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\"}. strokeStyle normal, dotted or dashed; caps none, arrow, stealth, triangle, filled_triangle, diamond, oval and more." + } + }, + "required": [ + "board_id", + "connector_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/connectors/{connector_id}", + "encodePathParams": true, + "bodyMapping": { + "startItem": "$start_item", + "endItem": "$end_item", + "shape": "$shape", + "captions": "$captions", + "style": "$style" + } + } + }, + { + "name": "miro_delete_connector", + "description": "Delete a connector line between two items; the items it joined stay where they are.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "connector_id": { + "type": "string", + "description": "Connector id." + } + }, + "required": [ + "board_id", + "connector_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/boards/{board_id}/connectors/{connector_id}", + "encodePathParams": true + } + }, + { + "name": "miro_list_board_members", + "description": "List who has access to a board: member id (the user id), name, role (viewer, commenter, editor, coowner, owner). Pages by offset.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Members per page, 1 to 50 (default 20)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "How many to skip." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/members", + "encodePathParams": true, + "queryParams": { + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "miro_get_board_member", + "description": "Get one member of a board: user id, name and role (viewer, commenter, editor, coowner, owner).", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "board_member_id": { + "type": "string", + "description": "Member id, from miro_list_board_members." + } + }, + "required": [ + "board_id", + "board_member_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/members/{board_member_id}", + "encodePathParams": true + } + }, + { + "name": "miro_share_board", + "enabled": false, + "description": "Invite people to a board by email with a role; Miro emails them an invitation. Returns successful and failed invitations. Switched off at install.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "emails": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Email addresses, up to 20." + }, + "role": { + "type": "string", + "enum": [ + "viewer", + "commenter", + "editor", + "coowner" + ], + "description": "viewer, commenter (default), editor or coowner." + }, + "message": { + "type": "string", + "description": "Personal message for the invitation email." + } + }, + "required": [ + "board_id", + "emails" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/members", + "encodePathParams": true, + "bodyMapping": { + "emails": "$emails", + "role": "$role", + "message": "$message" + } + } + }, + { + "name": "miro_update_board_member", + "enabled": false, + "description": "Change a board member's role, e.g. from viewer to editor. Switched off at install.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "board_member_id": { + "type": "string", + "description": "Member id." + }, + "role": { + "type": "string", + "enum": [ + "viewer", + "commenter", + "editor", + "coowner" + ], + "description": "New role." + } + }, + "required": [ + "board_id", + "board_member_id", + "role" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/members/{board_member_id}", + "encodePathParams": true, + "bodyMapping": { + "role": "$role" + } + } + }, + { + "name": "miro_remove_board_member", + "enabled": false, + "description": "Remove someone's access to a board. Switched off at install.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "board_member_id": { + "type": "string", + "description": "Member id." + } + }, + "required": [ + "board_id", + "board_member_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/boards/{board_id}/members/{board_member_id}", + "encodePathParams": true + } + }, + { + "name": "miro_list_tags", + "description": "List the tags defined on a board: id, title and fillColor. Pages by offset.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Tags per page, 1 to 50 (default 20)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "How many to skip." + } + }, + "required": [ + "board_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/tags", + "encodePathParams": true, + "queryParams": { + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "miro_get_tag", + "description": "Get one tag of a board by its id: title and fillColor, as shown on cards and sticky notes.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "tag_id": { + "type": "string", + "description": "Tag id, a long number as a string, from miro_list_tags." + } + }, + "required": [ + "board_id", + "tag_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/tags/{tag_id}", + "encodePathParams": true + } + }, + { + "name": "miro_create_tag", + "description": "Create a tag on a board, to attach to cards and sticky notes. Titles are unique per board and case-sensitive. Returns the tag with its id.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "title": { + "type": "string", + "description": "Tag text, up to 120 characters." + }, + "fill_color": { + "type": "string", + "enum": [ + "red", + "light_green", + "cyan", + "yellow", + "magenta", + "green", + "blue", + "gray", + "violet", + "dark_green", + "dark_blue", + "black" + ], + "description": "Color (default red)." + } + }, + "required": [ + "board_id", + "title" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/tags", + "encodePathParams": true, + "bodyMapping": { + "title": "$title", + "fillColor": "$fill_color" + } + } + }, + { + "name": "miro_update_tag", + "description": "Rename a tag or change its color; every item carrying it changes too. The board shows API tag changes after a refresh.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "tag_id": { + "type": "string", + "description": "Tag id, a long number as a string, from miro_list_tags." + }, + "title": { + "type": "string", + "description": "New text." + }, + "fill_color": { + "type": "string", + "enum": [ + "red", + "light_green", + "cyan", + "yellow", + "magenta", + "green", + "blue", + "gray", + "violet", + "dark_green", + "dark_blue", + "black" + ], + "description": "New color." + } + }, + "required": [ + "board_id", + "tag_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/boards/{board_id}/tags/{tag_id}", + "encodePathParams": true, + "bodyMapping": { + "title": "$title", + "fillColor": "$fill_color" + } + } + }, + { + "name": "miro_delete_tag", + "description": "Delete a tag from a board; it is removed from every item that carries it.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "tag_id": { + "type": "string", + "description": "Tag id, a long number as a string, from miro_list_tags." + } + }, + "required": [ + "board_id", + "tag_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/boards/{board_id}/tags/{tag_id}", + "encodePathParams": true + } + }, + { + "name": "miro_attach_tag", + "description": "Attach an existing tag to a card or sticky note (other item types cannot carry tags; at most 8 per item). The board shows it after a refresh.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Id of a card or sticky note." + }, + "tag_id": { + "type": "string", + "description": "Tag id, a long number as a string, from miro_list_tags." + } + }, + "required": [ + "board_id", + "item_id", + "tag_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards/{board_id}/items/{item_id}?tag_id={tag_id}", + "encodePathParams": true + } + }, + { + "name": "miro_remove_tag_from_item", + "description": "Take a tag off a card or sticky note; the tag itself stays on the board. Always pass tag_id: this is the same address as deleting the item.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Id of the card or sticky note." + }, + "tag_id": { + "type": "string", + "description": "Tag id, a long number as a string, from miro_list_tags." + } + }, + "required": [ + "board_id", + "item_id", + "tag_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/boards/{board_id}/items/{item_id}?tag_id={tag_id}", + "encodePathParams": true + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "miro_get_item_tags", + "description": "List the tags attached to one card or sticky note, each with id, title and fillColor.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "item_id": { + "type": "string", + "description": "Id of the card or sticky note." + } + }, + "required": [ + "board_id", + "item_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/items/{item_id}/tags", + "encodePathParams": true + } + }, + { + "name": "miro_list_items_by_tag", + "description": "List the cards and sticky notes that carry a tag. Pages by offset.", + "parameters": { + "type": "object", + "properties": { + "board_id": { + "type": "string", + "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)." + }, + "tag_id": { + "type": "string", + "description": "Tag id, a long number as a string, from miro_list_tags." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Items per page, 1 to 50 (default 20)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "How many to skip." + } + }, + "required": [ + "board_id", + "tag_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/items", + "encodePathParams": true, + "queryParams": { + "tag_id": "$tag_id", + "limit": "$limit", + "offset": "$offset" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/miro.live.spec.ts b/packages/backend/src/adapters/intl/miro.live.spec.ts new file mode 100644 index 000000000..b7201653c --- /dev/null +++ b/packages/backend/src/adapters/intl/miro.live.spec.ts @@ -0,0 +1,307 @@ +import * as http from 'node:http'; +import { AddressInfo } from 'node:net'; +import * as adapterJson from './miro.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 { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Static checks and request-shape checks against a local server always run. + * The live block calls Miro with an OAuth access token of an app that has + * boards:read and boards:write (the app page's "Install app and get OAuth + * token" button gives one): + * MIRO_ACCESS_TOKEN=eyJ... [MIRO_BOARD_ID=uXjV...=] npx jest src/adapters/intl/miro.live.spec.ts + * Reads use MIRO_BOARD_ID, or the first board the user can open. + * MIRO_LIVE_WRITE=1 creates a board "AnythingMCP test" and on it a frame, a + * sticky note, a shape, a text, a card, a connector and a tag; reads, changes, + * tags, untags and deletes them, and deletes the board in `finally`. + */ + +type Mapping = { method: string; path: string; encodePathParams?: boolean; queryParams?: Record; bodyMapping?: Record }; +type Tool = { + name: string; + description: string; + enabled?: boolean; + annotations?: Record; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: Mapping; +}; +const a = adapterJson as unknown as { + slug: string; + unlisted?: boolean; + instructions: string; + prerequisites: string; + appRegistrationUrl: 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 }); +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +const OFF = ['miro_delete_board', 'miro_remove_board_member', 'miro_share_board', 'miro_update_board_member']; + +describe('miro adapter: static spec conformance', () => { + it('is listed (verified live on 11 Oct 2026)', () => { + expect(a.unlisted).toBeUndefined(); + }); + + it('signs in through Miro OAuth with the secret in the token request body', () => { + expect(a.connector.baseUrl).toBe('https://api.miro.com/v2'); + expect(a.connector.authType).toBe('OAUTH2'); + // Scopes are ticked on the app, not requested; Basic client auth makes Miro answer 500. + expect(a.connector.authConfig).toEqual({ + clientId: '{{MIRO_CLIENT_ID}}', + clientSecret: '{{MIRO_CLIENT_SECRET}}', + authorizationUrl: 'https://miro.com/oauth/authorize', + tokenUrl: 'https://api.miro.com/v1/oauth/token', + }); + expect(a.requiredEnvVars).toEqual(['MIRO_CLIENT_ID', 'MIRO_CLIENT_SECRET']); + expect(a.envVarMeta.MIRO_CLIENT_SECRET.secret).toBe(true); + expect(new RegExp(a.envVarMeta.MIRO_CLIENT_ID.pattern!).test('3458764512345678901')).toBe(true); + expect(a.appRegistrationUrl).toBe('https://miro.com/app/settings/user-profile/apps'); + expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback'); + expect(a.instructions).toContain('Expire user authorization token'); + expect(a.instructions).toMatch(/boards:read.*boards:write/); + }); + + it('probes with the token info, which lives under v1', () => { + expect(a.probe.tool).toBe('miro_get_token_info'); + expect(tool('miro_get_token_info').endpointMapping).toEqual({ method: 'GET', path: 'https://api.miro.com/v1/oauth-token' }); + expect(a.connector.healthcheckPath).toBe('/boards?limit=1'); + }); + + it('prefixes every tool with miro_ 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(/^miro_[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 every GET read-only and installs board deletion and member management 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.enabled === false).map((t) => t.name).sort()).toEqual(OFF); + for (const name of ['miro_delete_board', 'miro_delete_item', 'miro_delete_tag', 'miro_delete_connector']) { + expect(annotationsOf(tool(name)).destructiveHint).toBe(true); + } + expect(annotationsOf(tool('miro_copy_board')).destructiveHint).toBe(false); + expect(annotationsOf(tool('miro_remove_tag_from_item')).destructiveHint).toBe(false); + }); + + it('keeps every id a string, since Miro ids exceed the safe integer range', () => { + for (const t of a.tools) { + for (const [name, p] of Object.entries(t.parameters.properties ?? {})) { + if (/_id$/.test(name)) expect(`${t.name}.${name}:${p.type}`).toBe(`${t.name}.${name}:string`); + } + } + }); + + it('never lets a tag removal turn into an item deletion', () => { + // Removing a tag is DELETE /items/{id}?tag_id=…, the item delete with a + // query. The tag id sits in the path template, so a missing one leaves a + // placeholder Miro rejects instead of a bare item URL. + expect(tool('miro_remove_tag_from_item').endpointMapping.path).toBe('/boards/{board_id}/items/{item_id}?tag_id={tag_id}'); + expect(tool('miro_remove_tag_from_item').parameters.required).toEqual(['board_id', 'item_id', 'tag_id']); + expect(tool('miro_attach_tag').endpointMapping.path).toBe('/boards/{board_id}/items/{item_id}?tag_id={tag_id}'); + }); + + it('only points the model at tools that exist, documents setup and writes no em dashes', () => { + const names = new Set(a.tools.map((t) => t.name)); + const mentioned = [ + ...a.instructions.matchAll(/\bmiro_[a-z_]+\*?/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bmiro_[a-z_]+/g)]), + ] + .map((m) => m[0]) + // `miro_create_*` stands for a group of tools. + .filter((n) => !n.endsWith('*')); + expect(mentioned.length).toBeGreaterThan(10); + for (const name of mentioned) expect(names).toContain(name); + expect(a.instructions).toContain('Read-only setup'); + expect(JSON.stringify(adapterJson)).not.toMatch(/[–—]/); + }); +}); + +describe('miro adapter: requests through the real engine', () => { + let server: http.Server; + let origin: string; + const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = []; + + beforeAll(async () => { + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + seen.push({ method: req.method, url: req.url, headers: req.headers, body }); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ id: '3458764517517819000', type: 'sticky_note' })); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll(() => new Promise((r) => server.close(() => r()))); + beforeEach(() => (seen.length = 0)); + + const run = (name: string, params: Record) => + engine().execute( + { baseUrl: `${origin}/v2`, authType: 'BEARER_TOKEN', authConfig: { token: 'miro-test' }, headers: a.connector.headers }, + tool(name).endpointMapping, + applySchemaDefaults(tool(name).parameters, params), + ); + + it('encodes the board id and builds a sticky note body from the given parts only', async () => { + await run('miro_create_sticky_note', { board_id: 'uXjVOD6LSME=', content: 'Idea', style: { fillColor: 'yellow' }, position: { x: 100, y: -50 } }); + expect(seen[0].method).toBe('POST'); + expect(seen[0].url).toBe('/v2/boards/uXjVOD6LSME%3D/sticky_notes'); + expect(seen[0].headers.authorization).toBe('Bearer miro-test'); + expect(JSON.parse(seen[0].body)).toEqual({ data: { content: 'Idea' }, style: { fillColor: 'yellow' }, position: { x: 100, y: -50 } }); + }); + + it('creates a rectangle shape by default and a frame as custom freeform', async () => { + await run('miro_create_shape', { board_id: 'b', content: 'Step 1' }); + expect(JSON.parse(seen[0].body)).toEqual({ data: { shape: 'rectangle', content: 'Step 1' } }); + await run('miro_create_frame', { board_id: 'b', title: 'Sprint', geometry: { width: 1200, height: 800 } }); + expect(JSON.parse(seen[1].body)).toEqual({ data: { title: 'Sprint', format: 'custom', type: 'freeform' }, geometry: { width: 1200, height: 800 } }); + }); + + it('maps card fields to Miro names', async () => { + await run('miro_create_card', { board_id: 'b', title: 'Fix login', assignee_id: '3074457350000000000', due_date: '2026-10-31T17:00:00Z', parent: { id: '345' } }); + expect(JSON.parse(seen[0].body)).toEqual({ data: { title: 'Fix login', assigneeId: '3074457350000000000', dueDate: '2026-10-31T17:00:00Z' }, parent: { id: '345' } }); + }); + + it('connects two items with snapping ends', async () => { + await run('miro_create_connector', { board_id: 'b', start_item: { id: '1', snapTo: 'right' }, end_item: { id: '2', snapTo: 'left' }, shape: 'elbowed' }); + expect(JSON.parse(seen[0].body)).toEqual({ startItem: { id: '1', snapTo: 'right' }, endItem: { id: '2', snapTo: 'left' }, shape: 'elbowed' }); + }); + + it('attaches and removes a tag with tag_id in the query, and copies a board', async () => { + await run('miro_attach_tag', { board_id: 'b', item_id: '11', tag_id: '22' }); + expect(`${seen[0].method} ${seen[0].url}`).toBe('POST /v2/boards/b/items/11?tag_id=22'); + await run('miro_remove_tag_from_item', { board_id: 'b', item_id: '11', tag_id: '22' }); + expect(`${seen[1].method} ${seen[1].url}`).toBe('DELETE /v2/boards/b/items/11?tag_id=22'); + await run('miro_copy_board', { board_id: 'uXjVOD6LSME=', name: 'Copy' }); + expect(`${seen[2].method} ${seen[2].url}`).toBe('PUT /v2/boards?copy_from=uXjVOD6LSME%3D'); + expect(JSON.parse(seen[2].body)).toEqual({ name: 'Copy' }); + }); +}); + +const TOKEN = process.env.MIRO_ACCESS_TOKEN; +const live = TOKEN ? describe : describe.skip; + +live('miro adapter: live calls', () => { + const config = () => ({ baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers }); + const run = (name: string, params: Record = {}): Promise => + engine().execute(config(), tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params)); + + let boardId = process.env.MIRO_BOARD_ID; + + it('reads the token info (the probe) and finds boards', async () => { + const info = await run('miro_get_token_info'); + expect(info.scopes).toEqual(expect.arrayContaining(['boards:read'])); + const boards = await run('miro_list_boards', { limit: 5 }); + expect(Array.isArray(boards.data)).toBe(true); + boardId = boardId ?? boards.data[0]?.id; + }, 30000); + + it('reads a board, its items, connectors, members and tags', async () => { + if (!boardId) return; + expect((await run('miro_get_board', { board_id: boardId })).id).toBe(boardId); + const items = await run('miro_list_items', { board_id: boardId, limit: 10 }); + expect(Array.isArray(items.data)).toBe(true); + if (items.data[0]) expect((await run('miro_get_item', { board_id: boardId, item_id: items.data[0].id })).id).toBe(items.data[0].id); + const connectors = await run('miro_list_connectors', { board_id: boardId }); + if (connectors.data[0]) expect((await run('miro_get_connector', { board_id: boardId, connector_id: connectors.data[0].id })).id).toBe(connectors.data[0].id); + const members = await run('miro_list_board_members', { board_id: boardId }); + expect(members.data.length).toBeGreaterThan(0); + expect((await run('miro_get_board_member', { board_id: boardId, board_member_id: members.data[0].id })).id).toBe(members.data[0].id); + const tags = await run('miro_list_tags', { board_id: boardId }); + if (tags.data[0]) { + expect((await run('miro_get_tag', { board_id: boardId, tag_id: tags.data[0].id })).id).toBe(tags.data[0].id); + expect(Array.isArray((await run('miro_list_items_by_tag', { board_id: boardId, tag_id: tags.data[0].id })).data)).toBe(true); + } + }, 60000); + + (process.env.MIRO_LIVE_WRITE === '1' ? it : it.skip)( + 'builds an "AnythingMCP test" board with every item type, changes, tags and deletes them, and deletes the board', + async () => { + let testBoard: string | undefined; + try { + const board = await run('miro_create_board', { name: 'AnythingMCP test', description: 'Created by the AnythingMCP live spec; safe to delete.' }); + testBoard = board.id; + const renamed = await run('miro_update_board', { board_id: testBoard, name: 'AnythingMCP test (updated)' }); + expect(renamed.name).toBe('AnythingMCP test (updated)'); + const b = testBoard!; + + // Miro answered valid frame requests with 500 "Internal error occurred" + // for minutes at a time on 11 Oct 2026 (other item types kept working): + // retry a 500 for up to a minute before calling it a failure. + const createFrame = async (): Promise => { + for (let attempt = 1; ; attempt++) { + try { + return await run('miro_create_frame', { board_id: b, title: 'AnythingMCP frame', position: { x: 0, y: 0 }, geometry: { width: 1600, height: 900 } }); + } catch (e) { + if ((e as { response?: { status?: number } }).response?.status !== 500 || attempt >= 12) throw e; + await new Promise((r) => setTimeout(r, 5000)); + } + } + }; + const frame = await createFrame(); + const sticky = await run('miro_create_sticky_note', { board_id: b, content: 'AnythingMCP sticky', style: { fillColor: 'light_yellow' }, position: { x: -400, y: 0 } }); + const shape = await run('miro_create_shape', { board_id: b, content: 'AnythingMCP shape', shape: 'round_rectangle', position: { x: 0, y: 0 }, geometry: { width: 200, height: 100 } }); + const text = await run('miro_create_text', { board_id: b, content: 'AnythingMCP text', position: { x: 0, y: -300 } }); + const card = await run('miro_create_card', { board_id: b, title: 'AnythingMCP card', description: 'test', position: { x: 400, y: 0 } }); + const connector = await run('miro_create_connector', { board_id: b, start_item: { id: sticky.id, snapTo: 'right' }, end_item: { id: shape.id, snapTo: 'left' }, shape: 'straight' }); + + expect((await run('miro_update_sticky_note', { board_id: b, item_id: sticky.id, content: 'AnythingMCP sticky (updated)', style: { fillColor: 'light_green' } })).data.content).toContain('updated'); + expect((await run('miro_update_shape', { board_id: b, item_id: shape.id, style: { fillColor: '#d5f692' } })).style.fillColor).toBe('#d5f692'); + expect((await run('miro_update_text', { board_id: b, item_id: text.id, content: 'AnythingMCP text (updated)' })).data.content).toContain('updated'); + expect((await run('miro_update_card', { board_id: b, item_id: card.id, title: 'AnythingMCP card (updated)' })).data.title).toContain('updated'); + expect((await run('miro_update_frame', { board_id: b, item_id: frame.id, title: 'AnythingMCP frame (updated)' })).data.title).toContain('updated'); + expect((await run('miro_update_connector', { board_id: b, connector_id: connector.id, shape: 'elbowed' })).shape).toBe('elbowed'); + const moved = await run('miro_move_item', { board_id: b, item_id: card.id, position: { x: 100, y: 100 }, parent: { id: frame.id } }); + expect(moved.parent.id).toBe(frame.id); + + const items = await run('miro_list_items', { board_id: b, limit: 50 }); + expect(items.data.length).toBeGreaterThanOrEqual(5); + expect((await run('miro_list_items', { board_id: b, parent_item_id: frame.id })).data.map((i: { id: string }) => i.id)).toContain(card.id); + expect((await run('miro_get_item', { board_id: b, item_id: sticky.id })).type).toBe('sticky_note'); + expect((await run('miro_list_connectors', { board_id: b })).data[0].id).toBe(connector.id); + expect((await run('miro_get_connector', { board_id: b, connector_id: connector.id })).startItem.id).toBe(sticky.id); + + const tag = await run('miro_create_tag', { board_id: b, title: 'anythingmcp-test', fill_color: 'cyan' }); + expect((await run('miro_get_tag', { board_id: b, tag_id: tag.id })).title).toBe('anythingmcp-test'); + expect((await run('miro_update_tag', { board_id: b, tag_id: tag.id, title: 'anythingmcp-test-2' })).title).toBe('anythingmcp-test-2'); + await run('miro_attach_tag', { board_id: b, item_id: sticky.id, tag_id: tag.id }); + expect((await run('miro_get_item_tags', { board_id: b, item_id: sticky.id })).tags.map((t: { id: string }) => t.id)).toContain(tag.id); + expect((await run('miro_list_items_by_tag', { board_id: b, tag_id: tag.id })).data.map((i: { id: string }) => i.id)).toContain(sticky.id); + await run('miro_remove_tag_from_item', { board_id: b, item_id: sticky.id, tag_id: tag.id }); + // The note itself must survive the tag removal. + expect((await run('miro_get_item', { board_id: b, item_id: sticky.id })).id).toBe(sticky.id); + await run('miro_delete_tag', { board_id: b, tag_id: tag.id }); + + const members = await run('miro_list_board_members', { board_id: b }); + expect(members.data.some((m: { role: string }) => m.role === 'owner')).toBe(true); + + await run('miro_delete_connector', { board_id: b, connector_id: connector.id }); + for (const item of [sticky, shape, text, card, frame]) await run('miro_delete_item', { board_id: b, item_id: item.id }); + expect((await run('miro_list_items', { board_id: b })).data).toEqual([]); + } finally { + if (testBoard) await run('miro_delete_board', { board_id: testBoard }); + } + }, + 180000, + ); +}); diff --git a/packages/backend/src/adapters/intl/paypal.json b/packages/backend/src/adapters/intl/paypal.json new file mode 100644 index 000000000..dbdfceed8 --- /dev/null +++ b/packages/backend/src/adapters/intl/paypal.json @@ -0,0 +1,1149 @@ +{ + "slug": "paypal", + "name": "PayPal", + "description": "Let your AI work with your PayPal business account: create and capture orders, refund payments, create, send and chase invoices, search the transaction history and balances, review disputes, and look up subscription plans, subscriptions and payout batches. Sandbox or live, with your own REST app keys.", + "instructions": "## PayPal\nThis connector works with one PayPal business account through the **PayPal REST APIs** (`https://api-m.paypal.com`, or `https://api-m.sandbox.paypal.com` for the sandbox): orders and captures, refunds, invoices (Invoicing v2), the transaction history (Transaction Search), balances, disputes, catalog products, subscription plans and subscriptions, and payout batches. It signs in as your own REST app with its client ID and secret (OAuth2 client credentials); no PayPal user has to click through a consent screen.\n\n**Setup (about 10 minutes)**\n1. Sign in at https://developer.paypal.com with the PayPal business account and open **Apps & Credentials**. The toggle at the top switches between **Sandbox** and **Live**; each has its own apps and keys.\n2. Use the **Default Application** or click **Create App**, type **Merchant**, and (in the sandbox) pick the sandbox business account it should act for.\n3. Open the app and, under its features, tick what the AI should reach: **Invoicing**, **Transaction Search**, **Subscriptions** (also covers catalog products), **Customer Disputes**, **Payouts**. PayPal can take up to 9 hours to grant a newly ticked feature to an app that already issued tokens; until then those calls answer 403 `NOT_AUTHORIZED`.\n4. Copy the **Client ID** and **Secret** into `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET`.\n5. Set `PAYPAL_API_URL` to `https://api-m.sandbox.paypal.com` for sandbox keys or `https://api-m.paypal.com` for live keys. Sandbox keys do not work against the live URL and the other way round (the token request fails with `invalid_client`).\n6. Install. AnythingMCP fetches an access token from `PAYPAL_API_URL/v1/oauth2/token` with the client ID and secret and renews it before it expires.\n\n**Money moves: confirm first.** `paypal_capture_order`, `paypal_refund_capture` and `paypal_cancel_subscription` move or stop real money on a live account, and `paypal_send_invoice`, `paypal_remind_invoice` and `paypal_cancel_invoice` email the customer. Before calling any of them, state exactly what will happen (amount, currency, customer, ids) and wait for the user's explicit yes. Pass `request_id` (any UUID) on captures and refunds so a retry cannot act twice. `paypal_delete_draft_invoice` is installed switched off.\n\n**Orders**: `paypal_create_order` creates an order and returns an `approve` (or `payer-action`) link. The buyer must open it and approve before `paypal_capture_order` can collect the money; capturing an unapproved order fails with 422 `ORDER_NOT_APPROVED`. Amounts are strings with the currency's decimals: `{\"currency_code\": \"EUR\", \"value\": \"19.99\"}`. When an order lists `items`, `amount.breakdown.item_total` must equal their sum. The capture id needed for refunds is in `purchase_units[].payments.captures[].id`.\n\n**Refunds**: `paypal_refund_capture` with no amount refunds the whole capture; pass `amount` for a partial refund. Refunds can only be made on captures, up to the captured amount minus earlier refunds.\n\n**Invoices**: `paypal_create_draft_invoice` creates a draft (nothing is emailed); `paypal_send_invoice` sends it (or, with `send_to_recipient: false`, just makes it payable through its link). `paypal_remind_invoice` works on sent, unpaid invoices (at most 2 reminders a day). `paypal_cancel_invoice` works only on sent invoices; drafts are deleted instead. Invoice numbers are generated if you leave `detail.invoice_number` out.\n\n**Transaction history** (`paypal_search_transactions`): `start_date` and `end_date` in RFC 3339 with seconds (`2026-10-01T00:00:00Z` or `2026-10-01T00:00:00+02:00`), at most 31 days apart; for longer periods call once per month. New transactions show up after up to 3 hours, history goes back 3 years. Amount filters use minor units: `[500 TO 1005]` means 5.00 to 10.05.\n\n**Disputes**: `dispute_state` filters by REQUIRED_ACTION (waiting for you), UNDER_PAYPAL_REVIEW, RESOLVED and so on; date filters reach back 180 days.\n\n**Subscriptions**: plans belong to catalog products (`paypal_list_products`, `paypal_list_plans`). `paypal_list_subscriptions` filters by plan and status. `paypal_list_subscription_transactions` needs `start_time` and `end_time`. `paypal_cancel_subscription` ends a subscription for good (it cannot be reactivated) and needs a `reason`.\n\n**Paging**: invoices and disputes take `page` and `page_size`; products, plans and subscriptions allow at most 20 per page; the transaction history up to 500.\n\n**Common errors**\n- 401 `invalid_client` or `invalid_token`: wrong client ID or secret, or keys from the other environment than `PAYPAL_API_URL`.\n- 403 `NOT_AUTHORIZED` / `PERMISSION_DENIED`: the feature is not ticked on the app (Transaction Search, Invoicing, Disputes, Subscriptions, Payouts), or was ticked less than 9 hours ago.\n- 422 `UNPROCESSABLE_ENTITY`: a business rule, named in `details[].issue` (ORDER_NOT_APPROVED, AMOUNT_MISMATCH, CAPTURE_FULLY_REFUNDED, ...).\n- 429 `RATE_LIMIT_REACHED`: wait a little and retry.\n\n**Out of scope here**: card payments and vaulting, webhooks, sending payouts, accepting or answering disputes, editing plans.", + "region": "intl", + "category": "payments", + "icon": "paypal", + "docsUrl": "https://developer.paypal.com/api/rest/", + "requiredEnvVars": [ + "PAYPAL_CLIENT_ID", + "PAYPAL_CLIENT_SECRET", + "PAYPAL_API_URL" + ], + "appRegistrationUrl": "https://developer.paypal.com/dashboard/applications", + "envVarMeta": { + "PAYPAL_CLIENT_ID": { + "label": "Client ID", + "kind": "credential", + "help": "developer.paypal.com → Apps & Credentials (Sandbox or Live toggle) → your app → Client ID.", + "link": "https://developer.paypal.com/dashboard/applications", + "pattern": "^\\S+$", + "patternMessage": "Paste the client ID without spaces." + }, + "PAYPAL_CLIENT_SECRET": { + "label": "Secret", + "kind": "credential", + "secret": true, + "help": "Same app page: Secret key 1 (click to show), from the same Sandbox or Live section as the client ID.", + "link": "https://developer.paypal.com/dashboard/applications", + "pattern": "^\\S+$", + "patternMessage": "Paste the secret without spaces." + }, + "PAYPAL_API_URL": { + "label": "Environment", + "kind": "address", + "help": "https://api-m.sandbox.paypal.com for sandbox keys, https://api-m.paypal.com for live keys.", + "example": "https://api-m.sandbox.paypal.com", + "link": "https://developer.paypal.com/api/rest/", + "pattern": "^https://api-m\\.(sandbox\\.)?paypal\\.com$", + "patternMessage": "Use https://api-m.paypal.com or https://api-m.sandbox.paypal.com, without a trailing slash." + } + }, + "verifyHints": { + "401": "PayPal refused the client ID or secret. Check that both come from the same app and that PAYPAL_API_URL matches it (sandbox keys with the sandbox URL, live keys with the live URL).", + "403": "The app lacks the feature this call needs. On developer.paypal.com open the app and tick Invoicing, Transaction Search, Subscriptions, Customer Disputes or Payouts; a new feature can take up to 9 hours to reach the app.", + "rate_limited": "PayPal is rate limiting this app. Wait a minute and try again." + }, + "probe": { + "tool": "paypal_list_products", + "params": { + "page_size": 1 + } + }, + "prerequisites": "A PayPal business account (or a free developer account with sandbox accounts) and a REST app on developer.paypal.com with the features you need ticked. About 10 minutes.", + "connector": { + "name": "PayPal REST API", + "type": "REST", + "baseUrl": "{{PAYPAL_API_URL}}", + "authType": "OAUTH2", + "authConfig": { + "grant": "client_credentials", + "clientId": "{{PAYPAL_CLIENT_ID}}", + "clientSecret": "{{PAYPAL_CLIENT_SECRET}}", + "tokenUrl": "{{PAYPAL_API_URL}}/v1/oauth2/token", + "tokenAuthMethod": "client_secret_basic" + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/v1/catalogs/products?page_size=1" + }, + "tools": [ + { + "name": "paypal_create_order", + "description": "Create a checkout order (nothing is charged yet). Returns the order id, status CREATED or PAYER_ACTION_REQUIRED, and links: the buyer must open the \"approve\" or \"payer-action\" link and approve before paypal_capture_order can collect the money.", + "parameters": { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "CAPTURE to collect the money right after approval, AUTHORIZE to only hold it.", + "enum": [ + "CAPTURE", + "AUTHORIZE" + ], + "default": "CAPTURE" + }, + "purchase_units": { + "type": "array", + "description": "1 to 10 purchase units. Minimal: [{\"amount\": {\"currency_code\": \"EUR\", \"value\": \"19.99\"}, \"description\": \"Order 1042\"}]. Optional per unit: reference_id, invoice_id, custom_id, items (each name, quantity as string, unit_amount; then amount.breakdown.item_total is required), shipping.", + "items": { + "type": "object" + } + }, + "payment_source": { + "type": "object", + "description": "Optional payer experience, e.g. {\"paypal\": {\"experience_context\": {\"return_url\": \"https://shop.example/ok\", \"cancel_url\": \"https://shop.example/cancel\", \"user_action\": \"PAY_NOW\", \"shipping_preference\": \"NO_SHIPPING\"}}}." + }, + "request_id": { + "type": "string", + "description": "Optional idempotency key (PayPal-Request-Id), for example a UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "purchase_units" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/checkout/orders", + "headers": { + "Prefer": "return=representation", + "PayPal-Request-Id": "$request_id" + }, + "bodyMapping": { + "intent": "$intent", + "purchase_units": "$purchase_units", + "payment_source": "$payment_source" + } + } + }, + { + "name": "paypal_get_order", + "description": "Read one checkout order: status (CREATED, APPROVED, COMPLETED, VOIDED, ...), amounts, payer, and for completed orders the captures under purchase_units[].payments.captures[].", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "The order id, e.g. \"5O190127TN364715T\"." + } + }, + "required": [ + "order_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/checkout/orders/{order_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_capture_order", + "description": "Collect the money of an order the buyer has approved (intent CAPTURE). This charges the buyer: confirm the order id, amount and currency with the user first. Fails with 422 ORDER_NOT_APPROVED if the buyer has not approved yet. Returns the completed order with the capture id under purchase_units[].payments.captures[].id.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "The approved order id." + }, + "request_id": { + "type": "string", + "description": "Optional idempotency key (PayPal-Request-Id), for example a UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "order_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/checkout/orders/{order_id}/capture", + "encodePathParams": true, + "headers": { + "Prefer": "return=representation", + "PayPal-Request-Id": "$request_id" + }, + "bodyMapping": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "paypal_get_capture", + "description": "Read one captured payment: amount, status (COMPLETED, PENDING, PARTIALLY_REFUNDED, REFUNDED, DECLINED), seller fees and net amount, and links to its order and refunds.", + "parameters": { + "type": "object", + "properties": { + "capture_id": { + "type": "string", + "description": "The capture id (a transaction id), e.g. \"2GG279541U471931P\"." + } + }, + "required": [ + "capture_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/payments/captures/{capture_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_refund_capture", + "description": "Refund a captured payment, in full (no amount) or in part. This sends money back to the buyer and cannot be undone: confirm the capture id, amount and currency with the user first. Returns the refund id and status (COMPLETED or PENDING).", + "parameters": { + "type": "object", + "properties": { + "capture_id": { + "type": "string", + "description": "The capture id to refund." + }, + "amount": { + "type": "object", + "description": "For a partial refund, e.g. {\"currency_code\": \"EUR\", \"value\": \"5.00\"}. Leave out to refund what remains of the capture." + }, + "note_to_payer": { + "type": "string", + "description": "Reason shown to the buyer, up to 255 characters." + }, + "invoice_id": { + "type": "string", + "description": "Your own invoice or reference number for this refund, up to 127 characters." + }, + "request_id": { + "type": "string", + "description": "Optional idempotency key (PayPal-Request-Id), for example a UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "capture_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/payments/captures/{capture_id}/refund", + "encodePathParams": true, + "headers": { + "Prefer": "return=representation", + "PayPal-Request-Id": "$request_id" + }, + "bodyMapping": { + "amount": "$amount", + "note_to_payer": "$note_to_payer", + "invoice_id": "$invoice_id" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "paypal_get_refund", + "description": "Read one refund: amount, status (COMPLETED, PENDING, FAILED, CANCELLED), note to the payer and the capture it belongs to.", + "parameters": { + "type": "object", + "properties": { + "refund_id": { + "type": "string", + "description": "The refund id." + } + }, + "required": [ + "refund_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/payments/refunds/{refund_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_get_authorization", + "description": "Read one authorized payment (an order created with intent AUTHORIZE): amount, status (CREATED, CAPTURED, PARTIALLY_CAPTURED, VOIDED, DENIED, PENDING) and expiry.", + "parameters": { + "type": "object", + "properties": { + "authorization_id": { + "type": "string", + "description": "The authorization id." + } + }, + "required": [ + "authorization_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/payments/authorizations/{authorization_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_list_invoices", + "description": "List invoices, newest first, with id, number, status, recipient, amount and due date. Returns items, plus total_items and total_pages when total_required is true. For filters (status, recipient, dates, amount) use paypal_search_invoices.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "description": "Invoices per page, 1 to 100 (default 20).", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "total_required": { + "type": "boolean", + "description": "true to also return total_items and total_pages." + }, + "fields": { + "type": "string", + "description": "Comma-separated extra fields to include, e.g. \"items,payments\"." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/invoicing/invoices", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "total_required": "$total_required", + "fields": "$fields" + } + } + }, + { + "name": "paypal_search_invoices", + "description": "Search invoices by recipient, number, status, currency, dates or amount (all filters combined with AND). Read-only, although it is a POST. Returns items with total_items and total_pages.", + "parameters": { + "type": "object", + "properties": { + "recipient_email": { + "type": "string", + "description": "Recipient email address." + }, + "recipient_business_name": { + "type": "string", + "description": "Recipient business name." + }, + "invoice_number": { + "type": "string", + "description": "Exact invoice number." + }, + "status": { + "type": "array", + "description": "Up to 5 statuses, e.g. [\"SENT\", \"UNPAID\", \"PARTIALLY_PAID\"]. Others: DRAFT, SCHEDULED, PAID, MARKED_AS_PAID, CANCELLED, REFUNDED, PARTIALLY_REFUNDED, PAYMENT_PENDING.", + "items": { + "type": "string" + } + }, + "reference": { + "type": "string", + "description": "The invoice reference field." + }, + "currency_code": { + "type": "string", + "description": "Three-letter currency code, e.g. \"EUR\"." + }, + "invoice_date_range": { + "type": "object", + "description": "Invoice date range, e.g. {\"start\": \"2026-09-01\", \"end\": \"2026-09-30\"}." + }, + "due_date_range": { + "type": "object", + "description": "Due date range, e.g. {\"start\": \"2026-10-01\", \"end\": \"2026-10-31\"}." + }, + "creation_date_range": { + "type": "object", + "description": "Creation time range in RFC 3339, both bounds required, e.g. {\"start\": \"2026-09-01T00:00:00Z\", \"end\": \"2026-09-30T23:59:59Z\"}." + }, + "total_amount_range": { + "type": "object", + "description": "Amount range, both bounds required, e.g. {\"lower_amount\": {\"currency_code\": \"EUR\", \"value\": \"100.00\"}, \"upper_amount\": {\"currency_code\": \"EUR\", \"value\": \"500.00\"}}." + }, + "archived": { + "type": "boolean", + "description": "true for archived invoices only, false for not archived." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "description": "Invoices per page, 1 to 100 (default 20).", + "minimum": 1, + "maximum": 100, + "default": 20 + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoicing/search-invoices", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "total_required": "true" + }, + "bodyMapping": { + "recipient_email": "$recipient_email", + "recipient_business_name": "$recipient_business_name", + "invoice_number": "$invoice_number", + "status": "$status", + "reference": "$reference", + "currency_code": "$currency_code", + "invoice_date_range": "$invoice_date_range", + "due_date_range": "$due_date_range", + "creation_date_range": "$creation_date_range", + "total_amount_range": "$total_amount_range", + "archived": "$archived" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "paypal_create_draft_invoice", + "description": "Create a draft invoice. Nothing is emailed: send it with paypal_send_invoice. Returns the full invoice with its id (INV2-...), number and status DRAFT.", + "parameters": { + "type": "object", + "properties": { + "detail": { + "type": "object", + "description": "Invoice header. currency_code is required, e.g. {\"currency_code\": \"EUR\", \"invoice_date\": \"2026-10-10\", \"payment_term\": {\"term_type\": \"NET_30\"}, \"note\": \"Thank you\", \"reference\": \"PO-77\"}. Add \"invoice_number\" to set one yourself (unique, up to 25 characters); otherwise PayPal numbers it." + }, + "primary_recipients": { + "type": "array", + "description": "The customer, e.g. [{\"billing_info\": {\"name\": {\"given_name\": \"Ana\", \"surname\": \"Diaz\"}, \"email_address\": \"ana@example.com\"}}]. Without an email address PayPal cannot email the invoice.", + "items": { + "type": "object" + } + }, + "items": { + "type": "array", + "description": "Line items, each with name, quantity (string) and unit_amount, e.g. [{\"name\": \"Consulting\", \"quantity\": \"2\", \"unit_amount\": {\"currency_code\": \"EUR\", \"value\": \"150.00\"}, \"tax\": {\"name\": \"VAT\", \"percent\": \"19\"}}].", + "items": { + "type": "object" + } + }, + "invoicer": { + "type": "object", + "description": "Optional seller details, e.g. {\"email_address\": \"billing@yourshop.example\", \"business_name\": \"Your Shop\"}. The email must belong to the PayPal account; the primary one is used if omitted." + }, + "configuration": { + "type": "object", + "description": "Optional settings, e.g. {\"allow_tip\": false, \"partial_payment\": {\"allow_partial_payment\": true}}." + } + }, + "required": [ + "detail", + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoicing/invoices", + "headers": { + "Prefer": "return=representation" + }, + "bodyMapping": { + "detail": "$detail", + "invoicer": "$invoicer", + "primary_recipients": "$primary_recipients", + "items": "$items", + "configuration": "$configuration" + } + } + }, + { + "name": "paypal_get_invoice", + "description": "Read one invoice: status, recipient, items, amounts, due amount, payments and refunds recorded on it, and the payer link (links[] rel \"payer-view\").", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The invoice id, e.g. \"INV2-Z56S-5LLA-Q52L-CPZ5\"." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/invoicing/invoices/{invoice_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_send_invoice", + "description": "Send a draft invoice. By default PayPal emails it to the recipient: confirm with the user first. With send_to_recipient false the invoice becomes payable through its link without an email. Returns a link to the payer view, or 202 when the send is scheduled for a future invoice date.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The draft invoice id." + }, + "send_to_recipient": { + "type": "boolean", + "description": "Email the invoice to the recipient (default true)." + }, + "send_to_invoicer": { + "type": "boolean", + "description": "Also email a copy to the seller (default false)." + }, + "additional_recipients": { + "type": "array", + "description": "Extra email addresses to copy, e.g. [\"accounts@example.com\"].", + "items": { + "type": "string" + } + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoicing/invoices/{invoice_id}/send", + "encodePathParams": true, + "bodyMapping": { + "send_to_recipient": "$send_to_recipient", + "send_to_invoicer": "$send_to_invoicer", + "additional_recipients": "$additional_recipients" + } + } + }, + { + "name": "paypal_remind_invoice", + "description": "Email a payment reminder for a sent, unpaid invoice (PayPal allows 2 reminders a day). Confirm with the user first. Returns no content on success.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The sent invoice id." + }, + "send_to_recipient": { + "type": "boolean", + "description": "Email the reminder to the recipient (default true)." + }, + "send_to_invoicer": { + "type": "boolean", + "description": "Also email a copy to the seller (default false)." + }, + "additional_recipients": { + "type": "array", + "description": "Extra email addresses to copy.", + "items": { + "type": "string" + } + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoicing/invoices/{invoice_id}/remind", + "encodePathParams": true, + "bodyMapping": { + "send_to_recipient": "$send_to_recipient", + "send_to_invoicer": "$send_to_invoicer", + "additional_recipients": "$additional_recipients" + } + } + }, + { + "name": "paypal_cancel_invoice", + "description": "Cancel a sent invoice so it can no longer be paid; by default the recipient is told by email. Drafts cannot be cancelled (they are deleted instead). This cannot be undone: confirm with the user first. Returns no content on success.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The sent invoice id." + }, + "send_to_recipient": { + "type": "boolean", + "description": "Email the cancellation to the recipient (default true)." + }, + "send_to_invoicer": { + "type": "boolean", + "description": "Also email a copy to the seller (default false)." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoicing/invoices/{invoice_id}/cancel", + "encodePathParams": true, + "bodyMapping": { + "send_to_recipient": "$send_to_recipient", + "send_to_invoicer": "$send_to_invoicer" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "paypal_delete_draft_invoice", + "description": "Delete a draft or scheduled invoice for good (sent invoices are cancelled with paypal_cancel_invoice instead). Confirm with the user first. Returns no content on success.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The draft invoice id." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/v2/invoicing/invoices/{invoice_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_search_transactions", + "description": "Search the account history (Transaction Search): payments received and sent, refunds, fees, transfers and their payer and cart details. At most 31 days per call; data can lag up to 3 hours. Returns transaction_details[] with total_items and total_pages; when nothing matches, PayPal leaves transaction_details out and total_items is 0. Needs Transaction Search enabled on the app.", + "parameters": { + "type": "object", + "properties": { + "start_date": { + "type": "string", + "description": "Start, RFC 3339 with seconds, e.g. \"2026-09-01T00:00:00Z\" or \"2026-09-01T00:00:00+02:00\"." + }, + "end_date": { + "type": "string", + "description": "End, same format, at most 31 days after start_date." + }, + "transaction_id": { + "type": "string", + "description": "Only this transaction id (17 to 19 characters)." + }, + "transaction_type": { + "type": "string", + "description": "PayPal event code, e.g. \"T0006\" (checkout payment), \"T1107\" (refund), \"T0400\" (withdrawal)." + }, + "transaction_status": { + "type": "string", + "description": "D denied, P pending, S success, V reversed.", + "enum": [ + "D", + "P", + "S", + "V" + ] + }, + "transaction_amount": { + "type": "string", + "description": "Amount range in minor units, e.g. \"[500 TO 1005]\" for 5.00 to 10.05." + }, + "transaction_currency": { + "type": "string", + "description": "Three-letter currency code." + }, + "balance_affecting_records_only": { + "type": "string", + "description": "Y (default) for rows that change the balance only, N for all rows.", + "enum": [ + "Y", + "N" + ] + }, + "fields": { + "type": "string", + "description": "Which details to include: \"all\" (default here) or a comma list of transaction_info, payer_info, shipping_info, cart_info, incentive_info, store_info, auction_info.", + "default": "all" + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 1 to 500 (default 100).", + "minimum": 1, + "maximum": 500, + "default": 100 + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + } + }, + "required": [ + "start_date", + "end_date" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/reporting/transactions", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date", + "transaction_id": "$transaction_id", + "transaction_type": "$transaction_type", + "transaction_status": "$transaction_status", + "transaction_amount": "$transaction_amount", + "transaction_currency": "$transaction_currency", + "balance_affecting_records_only": "$balance_affecting_records_only", + "fields": "$fields", + "page_size": "$page_size", + "page": "$page" + } + } + }, + { + "name": "paypal_get_balances", + "description": "Read the account balances per currency (available, withheld and total), now or as of a past time: balances[] with as_of_time. An account that has never held money gets no balances key at all. Refreshed with up to 3 hours delay. Needs Transaction Search enabled on the app.", + "parameters": { + "type": "object", + "properties": { + "as_of_time": { + "type": "string", + "description": "Optional point in time, RFC 3339, e.g. \"2026-09-30T23:59:59Z\"." + }, + "currency_code": { + "type": "string", + "description": "Only this currency, e.g. \"EUR\"." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/reporting/balances", + "queryParams": { + "as_of_time": "$as_of_time", + "currency_code": "$currency_code" + } + } + }, + { + "name": "paypal_list_disputes", + "description": "List disputes, claims and chargebacks raised against the account (last 180 days), with dispute_id, reason, status, dispute_state, amount and dates.", + "parameters": { + "type": "object", + "properties": { + "dispute_state": { + "type": "string", + "description": "Comma-separated: OPEN_INQUIRIES, REQUIRED_ACTION, REQUIRED_OTHER_PARTY_ACTION, UNDER_PAYPAL_REVIEW, APPEALABLE, RESOLVED." + }, + "disputed_transaction_id": { + "type": "string", + "description": "Only disputes about this transaction id." + }, + "create_time_after": { + "type": "string", + "description": "Created after, RFC 3339." + }, + "create_time_before": { + "type": "string", + "description": "Created before, RFC 3339." + }, + "update_time_after": { + "type": "string", + "description": "Updated after, RFC 3339." + }, + "update_time_before": { + "type": "string", + "description": "Updated before, RFC 3339." + }, + "page_size": { + "type": "integer", + "description": "Disputes per page, 1 to 50 (default 10).", + "minimum": 1, + "maximum": 50, + "default": 10 + }, + "page": { + "type": "integer", + "description": "Page number, 1 to 50.", + "minimum": 1, + "maximum": 50, + "default": 1 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/customer/disputes", + "queryParams": { + "dispute_state": "$dispute_state", + "disputed_transaction_id": "$disputed_transaction_id", + "create_time_after": "$create_time_after", + "create_time_before": "$create_time_before", + "update_time_after": "$update_time_after", + "update_time_before": "$update_time_before", + "page_size": "$page_size", + "page": "$page" + } + } + }, + { + "name": "paypal_get_dispute", + "description": "Read one dispute in full: reason, status, amounts, disputed transactions, messages, offers, evidence and the deadline to respond (seller_response_due_date).", + "parameters": { + "type": "object", + "properties": { + "dispute_id": { + "type": "string", + "description": "The dispute id, e.g. \"PP-D-27803\"." + } + }, + "required": [ + "dispute_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/customer/disputes/{dispute_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_list_products", + "description": "List catalog products (the products subscription plans belong to): id, name, description, type and dates.", + "parameters": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "description": "Products per page, 1 to 20 (default 10).", + "minimum": 1, + "maximum": 20, + "default": 10 + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + }, + "total_required": { + "type": "boolean", + "description": "true to also return total_items and total_pages." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/catalogs/products", + "queryParams": { + "page_size": "$page_size", + "page": "$page", + "total_required": "$total_required" + } + } + }, + { + "name": "paypal_get_product", + "description": "Read one catalog product: name, description, type (PHYSICAL, DIGITAL, SERVICE), category, image and home URLs.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "string", + "description": "The product id, e.g. \"PROD-XYAB12ABSB7868434\"." + } + }, + "required": [ + "product_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/catalogs/products/{product_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_list_plans", + "description": "List subscription plans, optionally for one product: id, name, status (CREATED, ACTIVE, INACTIVE), product_id and dates. Prices are in paypal_get_plan.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "string", + "description": "Only plans of this product id." + }, + "page_size": { + "type": "integer", + "description": "Plans per page, 1 to 20 (default 10).", + "minimum": 1, + "maximum": 20, + "default": 10 + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + }, + "total_required": { + "type": "boolean", + "description": "true to also return total_items and total_pages." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/billing/plans", + "queryParams": { + "product_id": "$product_id", + "page_size": "$page_size", + "page": "$page", + "total_required": "$total_required" + } + } + }, + { + "name": "paypal_get_plan", + "description": "Read one subscription plan: billing cycles with prices and frequency, trial periods, setup fee, taxes and payment failure settings.", + "parameters": { + "type": "object", + "properties": { + "plan_id": { + "type": "string", + "description": "The plan id, e.g. \"P-5ML4271244454362WXNWU5NQ\"." + } + }, + "required": [ + "plan_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/billing/plans/{plan_id}", + "encodePathParams": true + } + }, + { + "name": "paypal_list_subscriptions", + "description": "List subscriptions, filtered by plan and status: id, status, plan_id, subscriber, start time and next billing time.", + "parameters": { + "type": "object", + "properties": { + "plan_ids": { + "type": "string", + "description": "Comma-separated plan ids." + }, + "statuses": { + "type": "string", + "description": "Comma-separated: APPROVAL_PENDING, APPROVED, ACTIVE, SUSPENDED, CANCELLED, EXPIRED." + }, + "created_after": { + "type": "string", + "description": "Created after, RFC 3339." + }, + "created_before": { + "type": "string", + "description": "Created before, RFC 3339." + }, + "page_size": { + "type": "integer", + "description": "Subscriptions per page, 1 to 20 (default 10).", + "minimum": 1, + "maximum": 20, + "default": 10 + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/billing/subscriptions", + "queryParams": { + "plan_ids": "$plan_ids", + "statuses": "$statuses", + "created_after": "$created_after", + "created_before": "$created_before", + "page_size": "$page_size", + "page": "$page" + } + } + }, + { + "name": "paypal_get_subscription", + "description": "Read one subscription: status, plan, subscriber (name, email, shipping address), billing info (outstanding balance, last payment, next billing time, failed payments count).", + "parameters": { + "type": "object", + "properties": { + "subscription_id": { + "type": "string", + "description": "The subscription id, e.g. \"I-BW452GLLEP1G\"." + }, + "fields": { + "type": "string", + "description": "Extra details: \"last_failed_payment\", \"plan\" or both comma-separated." + } + }, + "required": [ + "subscription_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/billing/subscriptions/{subscription_id}", + "encodePathParams": true, + "queryParams": { + "fields": "$fields" + } + } + }, + { + "name": "paypal_list_subscription_transactions", + "description": "List the payments of one subscription between two times: id, status, amounts with fees and net, payer and time.", + "parameters": { + "type": "object", + "properties": { + "subscription_id": { + "type": "string", + "description": "The subscription id." + }, + "start_time": { + "type": "string", + "description": "Start, RFC 3339, e.g. \"2026-01-01T00:00:00Z\"." + }, + "end_time": { + "type": "string", + "description": "End, RFC 3339." + } + }, + "required": [ + "subscription_id", + "start_time", + "end_time" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/billing/subscriptions/{subscription_id}/transactions", + "encodePathParams": true, + "queryParams": { + "start_time": "$start_time", + "end_time": "$end_time" + } + } + }, + { + "name": "paypal_cancel_subscription", + "description": "Cancel a subscription for good: no further payments are collected and it cannot be reactivated. Confirm the subscription and the reason with the user first. Returns no content on success.", + "parameters": { + "type": "object", + "properties": { + "subscription_id": { + "type": "string", + "description": "The subscription id." + }, + "reason": { + "type": "string", + "description": "Why it is cancelled, 1 to 128 characters (shown in the subscription history)." + } + }, + "required": [ + "subscription_id", + "reason" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/billing/subscriptions/{subscription_id}/cancel", + "encodePathParams": true, + "bodyMapping": { + "reason": "$reason" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "paypal_get_payout_batch", + "description": "Read one payout batch: batch status and totals, and its items (receiver, amount, status, fees, errors). Sending payouts is not part of this connector.", + "parameters": { + "type": "object", + "properties": { + "payout_batch_id": { + "type": "string", + "description": "The payout batch id." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "description": "Items per page, 1 to 1000.", + "minimum": 1, + "maximum": 1000 + }, + "total_required": { + "type": "boolean", + "description": "true to also return total_items and total_pages." + } + }, + "required": [ + "payout_batch_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/payments/payouts/{payout_batch_id}", + "encodePathParams": true, + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "total_required": "$total_required" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/paypal.live.spec.ts b/packages/backend/src/adapters/intl/paypal.live.spec.ts new file mode 100644 index 000000000..811807dab --- /dev/null +++ b/packages/backend/src/adapters/intl/paypal.live.spec.ts @@ -0,0 +1,490 @@ +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import * as adapter from './paypal.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 { PrismaService } from '../../common/prisma.service'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; +import { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { interpolateConnectorConfig, interpolateDeep } from '../../common/env-interpolation.util'; +import { describeAdapterEnvVars } from '../env-var-meta'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Two layers of verification for the PayPal adapter: + * + * 1. Static: always runs. Pins the client-credentials token request (HTTP + * Basic at PAYPAL_API_URL/v1/oauth2/token), the environment variable + * patterns, every tool's method and URL, the bodies and headers of the + * money-moving calls, and which tools are read-only or destructive. + * + * 2. Live: skipped unless PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET are set. + * Uses the sandbox unless PAYPAL_API_URL says otherwise: + * + * PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... \ + * npx jest src/adapters/intl/paypal.live.spec.ts + * + * Reads products, plans, subscriptions, invoices, the transaction + * history, balances and disputes. Optional ids read what cannot be + * created without a buyer: PAYPAL_CAPTURE_ID, PAYPAL_AUTHORIZATION_ID, + * PAYPAL_PAYOUT_BATCH_ID. + * + * PAYPAL_LIVE_WRITE=1 (sandbox only) adds: an order that is created, + * read and refused at capture (no buyer approved it); a draft invoice + * that is read, found by search, sent, reminded and cancelled (deleted + * instead if sending failed). With PAYPAL_CAPTURE_ID it also refunds + * 0.01 of that capture; with PAYPAL_CANCEL_SUBSCRIPTION_ID it cancels + * that sandbox subscription. + */ + +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; post: jest.Mock }; + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { + method: string; + path: string; + headers?: Record; + queryParams?: Record; + bodyMapping?: Record; + }; + annotations?: Record; +}; +const a = adapter as unknown as { + slug: string; + unlisted?: boolean; + instructions: string; + prerequisites: string; + requiredEnvVars: string[]; + envVarMeta: Record; + probe: { tool: string; params?: Record }; + connector: { + baseUrl: string; + authType: string; + authConfig: Record; + headers: Record; + healthcheckPath: string; + }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +const annotationsOf = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + +const SANDBOX = 'https://api-m.sandbox.paypal.com'; +const ENV = { PAYPAL_API_URL: SANDBOX, PAYPAL_CLIENT_ID: 'client-id', PAYPAL_CLIENT_SECRET: 'client-secret' }; +const newEngine = () => + new RestEngine( + { getAccessToken: jest.fn().mockResolvedValue('test-token') } as unknown as OAuth2TokenService, + {} as LoginTokenService, + ); +// Resolves {{VAR}} the way DynamicMcpTools does before the engine runs. +const resolved = (name: string, env: Record = ENV) => + interpolateConnectorConfig({ baseUrl: a.connector.baseUrl, headers: a.connector.headers }, tool(name).endpointMapping, env); +const call = (name: string, params: Record) => { + const { config, endpointMapping } = resolved(name); + return newEngine().execute( + { baseUrl: config.baseUrl, authType: a.connector.authType, authConfig: { ...a.connector.authConfig }, headers: config.headers }, + endpointMapping, + applySchemaDefaults(tool(name).parameters, params), + ); +}; +const sent = () => mockedAxios.mock.calls[0][0]; + +describe('paypal adapter: static spec conformance', () => { + beforeEach(() => mockedAxios.mockReset()); + + it('is listed (verified live on 11 Oct 2026)', () => { + expect(a.unlisted).toBeUndefined(); + expect(a.prerequisites).toMatch(/sandbox/); + }); + + it('gets its token with the client-credentials grant at the chosen environment', () => { + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig).toEqual({ + grant: 'client_credentials', + clientId: '{{PAYPAL_CLIENT_ID}}', + clientSecret: '{{PAYPAL_CLIENT_SECRET}}', + tokenUrl: '{{PAYPAL_API_URL}}/v1/oauth2/token', + tokenAuthMethod: 'client_secret_basic', + }); + // No browser sign-in: nothing to authorize. + expect(a.connector.authConfig.authorizationUrl).toBeUndefined(); + expect(a.connector.baseUrl).toBe('{{PAYPAL_API_URL}}'); + }); + + it('sends client id and secret as HTTP Basic and grant_type=client_credentials in the body', async () => { + const service = new OAuth2TokenService( + {} as PrismaService, + { get: () => 'test-encryption-key-32-chars!!!!' } as unknown as ConfigService, + ); + const post = jest.spyOn(mockedAxios, 'post').mockResolvedValue({ data: { access_token: 'A21AA', expires_in: 32400 } }); + const authConfig = interpolateDeep({ ...a.connector.authConfig }, ENV); + await expect(service.getAccessToken(authConfig)).resolves.toBe('A21AA'); + const [url, body, options] = post.mock.calls[0] as [string, string, { headers: Record }]; + expect(url).toBe(`${SANDBOX}/v1/oauth2/token`); + expect(body).toBe('grant_type=client_credentials'); + expect(options.headers.Authorization).toBe(`Basic ${Buffer.from('client-id:client-secret').toString('base64')}`); + post.mockRestore(); + }); + + it('validates the environment URL and marks the secret as secret', () => { + expect(a.requiredEnvVars).toEqual(['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_API_URL']); + const env = new RegExp(a.envVarMeta.PAYPAL_API_URL.pattern!); + expect(env.test(SANDBOX)).toBe(true); + expect(env.test('https://api-m.paypal.com')).toBe(true); + expect(env.test('https://api.paypal.com')).toBe(false); + expect(env.test(`${SANDBOX}/`)).toBe(false); + expect(env.test('https://api-m.paypal.com.evil.example')).toBe(false); + const described = describeAdapterEnvVars(a as never); + expect(described.find((d) => d.name === 'PAYPAL_API_URL')!.kind).toBe('address'); + expect(described.find((d) => d.name === 'PAYPAL_CLIENT_SECRET')!.secret).toBe(true); + }); + + it('probes with a one-product page and health-checks the same endpoint', () => { + expect(a.probe).toEqual({ tool: 'paypal_list_products', params: { page_size: 1 } }); + expect(tool('paypal_list_products').parameters.required).toBeUndefined(); + expect(a.connector.healthcheckPath).toBe('/v1/catalogs/products?page_size=1'); + }); + + it('prefixes every tool with paypal_ 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(/^paypal_[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, including the POST invoice search', () => { + const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name); + expect(readOnly.sort()).toEqual([ + 'paypal_get_authorization', + 'paypal_get_balances', + 'paypal_get_capture', + 'paypal_get_dispute', + 'paypal_get_invoice', + 'paypal_get_order', + 'paypal_get_payout_batch', + 'paypal_get_plan', + 'paypal_get_product', + 'paypal_get_refund', + 'paypal_get_subscription', + 'paypal_list_disputes', + 'paypal_list_invoices', + 'paypal_list_plans', + 'paypal_list_products', + 'paypal_list_subscription_transactions', + 'paypal_list_subscriptions', + 'paypal_search_invoices', + 'paypal_search_transactions', + ]); + }); + + it('marks money-moving and cancelling tools destructive and non-idempotent, and asks for confirmation', () => { + for (const name of ['paypal_capture_order', 'paypal_refund_capture', 'paypal_cancel_invoice', 'paypal_cancel_subscription']) { + const ann = annotationsOf(tool(name)); + expect(`${name}:${ann.readOnlyHint}:${ann.destructiveHint}:${ann.idempotentHint}`).toBe(`${name}:false:true:false`); + expect(tool(name).description).toMatch(/confirm/i); + } + expect(annotationsOf(tool('paypal_delete_draft_invoice')).destructiveHint).toBe(true); + expect(annotationsOf(tool('paypal_create_order')).destructiveHint).toBe(false); + expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['paypal_delete_draft_invoice']); + expect(a.instructions).toMatch(/Money moves: confirm first/); + }); + + 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(/\bpaypal_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bpaypal_[a-z_]+/g)]), + ].map((m) => m[0]); + expect(mentioned.length).toBeGreaterThan(10); + for (const name of mentioned) expect(names).toContain(name); + expect(JSON.stringify(adapter)).not.toMatch(/[–—]/); + }); + + it.each([ + ['paypal_create_order', { purchase_units: [] }, 'POST', '/v2/checkout/orders'], + ['paypal_get_order', { order_id: '5O190127TN364715T' }, 'GET', '/v2/checkout/orders/5O190127TN364715T'], + ['paypal_capture_order', { order_id: '5O190127TN364715T' }, 'POST', '/v2/checkout/orders/5O190127TN364715T/capture'], + ['paypal_get_capture', { capture_id: '2GG279541U471931P' }, 'GET', '/v2/payments/captures/2GG279541U471931P'], + ['paypal_refund_capture', { capture_id: '2GG279541U471931P' }, 'POST', '/v2/payments/captures/2GG279541U471931P/refund'], + ['paypal_get_refund', { refund_id: '1JU08902781691411' }, 'GET', '/v2/payments/refunds/1JU08902781691411'], + ['paypal_get_authorization', { authorization_id: '0VF52814937998046' }, 'GET', '/v2/payments/authorizations/0VF52814937998046'], + ['paypal_list_invoices', {}, 'GET', '/v2/invoicing/invoices'], + ['paypal_search_invoices', {}, 'POST', '/v2/invoicing/search-invoices'], + ['paypal_create_draft_invoice', { detail: { currency_code: 'EUR' }, items: [] }, 'POST', '/v2/invoicing/invoices'], + ['paypal_get_invoice', { invoice_id: 'INV2-Z56S-5LLA-Q52L-CPZ5' }, 'GET', '/v2/invoicing/invoices/INV2-Z56S-5LLA-Q52L-CPZ5'], + ['paypal_send_invoice', { invoice_id: 'INV2-A' }, 'POST', '/v2/invoicing/invoices/INV2-A/send'], + ['paypal_remind_invoice', { invoice_id: 'INV2-A' }, 'POST', '/v2/invoicing/invoices/INV2-A/remind'], + ['paypal_cancel_invoice', { invoice_id: 'INV2-A' }, 'POST', '/v2/invoicing/invoices/INV2-A/cancel'], + ['paypal_delete_draft_invoice', { invoice_id: 'INV2-A' }, 'DELETE', '/v2/invoicing/invoices/INV2-A'], + ['paypal_search_transactions', { start_date: '2026-09-01T00:00:00Z', end_date: '2026-09-30T23:59:59Z' }, 'GET', '/v1/reporting/transactions'], + ['paypal_get_balances', {}, 'GET', '/v1/reporting/balances'], + ['paypal_list_disputes', {}, 'GET', '/v1/customer/disputes'], + ['paypal_get_dispute', { dispute_id: 'PP-D-27803' }, 'GET', '/v1/customer/disputes/PP-D-27803'], + ['paypal_list_products', {}, 'GET', '/v1/catalogs/products'], + ['paypal_get_product', { product_id: 'PROD-XYAB12ABSB7868434' }, 'GET', '/v1/catalogs/products/PROD-XYAB12ABSB7868434'], + ['paypal_list_plans', {}, 'GET', '/v1/billing/plans'], + ['paypal_get_plan', { plan_id: 'P-5ML4271244454362WXNWU5NQ' }, 'GET', '/v1/billing/plans/P-5ML4271244454362WXNWU5NQ'], + ['paypal_list_subscriptions', {}, 'GET', '/v1/billing/subscriptions'], + ['paypal_get_subscription', { subscription_id: 'I-BW452GLLEP1G' }, 'GET', '/v1/billing/subscriptions/I-BW452GLLEP1G'], + ['paypal_list_subscription_transactions', { subscription_id: 'I-BW452GLLEP1G', start_time: '2026-01-01T00:00:00Z', end_time: '2026-10-01T00:00:00Z' }, 'GET', '/v1/billing/subscriptions/I-BW452GLLEP1G/transactions'], + ['paypal_cancel_subscription', { subscription_id: 'I-BW452GLLEP1G', reason: 'Customer request' }, 'POST', '/v1/billing/subscriptions/I-BW452GLLEP1G/cancel'], + ['paypal_get_payout_batch', { payout_batch_id: 'FYXMPQTX4JC9N' }, 'GET', '/v1/payments/payouts/FYXMPQTX4JC9N'], + ])('%s sends %s to the right URL', async (name, params, method, path) => { + mockedAxios.mockResolvedValue({ data: {} }); + await call(name, params); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method, + url: `${SANDBOX}${path}`, + headers: expect.objectContaining({ Authorization: 'Bearer test-token', Accept: 'application/json' }), + }), + ); + }); + + it('covers every tool in the URL table', () => { + expect(a.tools.length).toBe(28); + }); + + it('creates an order with CAPTURE by default and asks for the full representation', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + const units = [{ amount: { currency_code: 'EUR', value: '19.99' } }]; + await call('paypal_create_order', { purchase_units: units, request_id: 'req-1' }); + expect(sent().data).toEqual({ intent: 'CAPTURE', purchase_units: units }); + expect(sent().headers).toEqual(expect.objectContaining({ Prefer: 'return=representation', 'PayPal-Request-Id': 'req-1' })); + }); + + it('captures with an empty JSON body and omits the idempotency header when none is given', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('paypal_capture_order', { order_id: 'ORDER1' }); + expect(sent().data).toEqual({}); + expect(sent().headers['Content-Type']).toBe('application/json'); + expect(sent().headers['PayPal-Request-Id']).toBeUndefined(); + }); + + it('refunds a capture in part with a note, or in full with an empty body', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('paypal_refund_capture', { capture_id: 'CAP1', amount: { currency_code: 'EUR', value: '5.00' }, note_to_payer: 'Sorry', request_id: 'r2' }); + expect(sent().data).toEqual({ amount: { currency_code: 'EUR', value: '5.00' }, note_to_payer: 'Sorry' }); + expect(sent().headers['PayPal-Request-Id']).toBe('r2'); + mockedAxios.mockClear(); + await call('paypal_refund_capture', { capture_id: 'CAP1' }); + expect(sent().data).toEqual({}); + }); + + it('searches invoices with filters in the body and paging in the query', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('paypal_search_invoices', { status: ['SENT', 'UNPAID'], recipient_email: 'ana@example.com', page: 2 }); + expect(sent().data).toEqual({ status: ['SENT', 'UNPAID'], recipient_email: 'ana@example.com' }); + expect(sent().params).toEqual({ page: 2, page_size: 20, total_required: 'true' }); + }); + + it('asks for all transaction details by default', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('paypal_search_transactions', { start_date: '2026-09-01T00:00:00Z', end_date: '2026-09-30T23:59:59Z', transaction_status: 'S' }); + expect(sent().params).toEqual({ + start_date: '2026-09-01T00:00:00Z', + end_date: '2026-09-30T23:59:59Z', + transaction_status: 'S', + fields: 'all', + page_size: 100, + page: 1, + }); + // The offset of a local time must survive as `+`, encoded. + expect(sent().paramsSerializer({ start_date: '2026-09-01T00:00:00+02:00' })).toBe('start_date=2026-09-01T00:00:00%2B02:00'); + }); + + it('sends, reminds and cancels invoices with notification flags only', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('paypal_send_invoice', { invoice_id: 'INV2-A', send_to_recipient: false }); + expect(sent().data).toEqual({ send_to_recipient: false }); + mockedAxios.mockClear(); + await call('paypal_cancel_invoice', { invoice_id: 'INV2-A' }); + expect(sent().data).toEqual({}); + mockedAxios.mockClear(); + await call('paypal_cancel_subscription', { subscription_id: 'I-1', reason: 'Asked by customer' }); + expect(sent().data).toEqual({ reason: 'Asked by customer' }); + }); +}); + +const CLIENT_ID = process.env.PAYPAL_CLIENT_ID; +const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET; +const live = CLIENT_ID && CLIENT_SECRET ? describe : describe.skip; + +live('paypal adapter: live REST API', () => { + const host = process.env.PAYPAL_API_URL || SANDBOX; + const isSandbox = host === SANDBOX; + const write = process.env.PAYPAL_LIVE_WRITE === '1' && isSandbox; + let token = ''; + beforeAll(async () => { + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + const res = await mockedAxios.__actual.post(`${host}/v1/oauth2/token`, 'grant_type=client_credentials', { + auth: { username: CLIENT_ID as string, password: CLIENT_SECRET as string }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + token = res.data.access_token; + expect(token).toBeTruthy(); + }); + const run = (name: string, params: Record = {}): Promise => { + const { config, endpointMapping } = resolved(name, { ...ENV, PAYPAL_API_URL: host }); + return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute( + { baseUrl: config.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token }, headers: config.headers } as never, + endpointMapping, + applySchemaDefaults(tool(name).parameters, params), + ); + }; + const iso = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); + + it('reads products, plans and subscriptions', async () => { + const products = await run('paypal_list_products', { page_size: 5, total_required: true }); + expect(Array.isArray(products.products ?? [])).toBe(true); + if (products.products?.length) { + const one = await run('paypal_get_product', { product_id: products.products[0].id }); + expect(one.id).toBe(products.products[0].id); + } + const plans = await run('paypal_list_plans', { page_size: 5 }); + expect(Array.isArray(plans.plans ?? [])).toBe(true); + if (plans.plans?.length) { + const plan = await run('paypal_get_plan', { plan_id: plans.plans[0].id }); + expect(plan.id).toBe(plans.plans[0].id); + const subs = await run('paypal_list_subscriptions', { plan_ids: plan.id, page_size: 5 }); + expect(Array.isArray(subs.subscriptions ?? [])).toBe(true); + if (subs.subscriptions?.length) { + const sub = await run('paypal_get_subscription', { subscription_id: subs.subscriptions[0].id, fields: 'plan' }); + expect(sub.id).toBe(subs.subscriptions[0].id); + const txs = await run('paypal_list_subscription_transactions', { + subscription_id: sub.id, + start_time: iso(new Date(Date.now() - 300 * 86400_000)), + end_time: iso(new Date()), + }); + expect(txs).toBeDefined(); + } + } + }, 60_000); + + it('reads invoices, the transaction history, balances and disputes', async () => { + const invoices = await run('paypal_list_invoices', { page_size: 5, total_required: true }); + expect(typeof invoices.total_items).toBe('number'); + if (invoices.items?.length) { + const one = await run('paypal_get_invoice', { invoice_id: invoices.items[0].id }); + expect(one.id).toBe(invoices.items[0].id); + } + const found = await run('paypal_search_invoices', { status: ['DRAFT', 'SENT', 'UNPAID', 'PAID'], page_size: 5 }); + expect(found).toBeDefined(); + const history = await run('paypal_search_transactions', { + start_date: iso(new Date(Date.now() - 30 * 86400_000)), + end_date: iso(new Date()), + page_size: 10, + }); + // With no matching transactions PayPal leaves transaction_details out entirely. + if (history.total_items === 0) expect(history.transaction_details ?? []).toEqual([]); + else expect(Array.isArray(history.transaction_details)).toBe(true); + const balances = await run('paypal_get_balances'); + // A fresh sandbox account that never held money answers without a balances key. + expect(balances.as_of_time).toBeDefined(); + expect(Array.isArray(balances.balances ?? [])).toBe(true); + const disputes = await run('paypal_list_disputes', { page_size: 5 }); + if (disputes.items?.length) { + const one = await run('paypal_get_dispute', { dispute_id: disputes.items[0].dispute_id }); + expect(one.dispute_id).toBe(disputes.items[0].dispute_id); + } + }, 60_000); + + it('reads a capture, an authorization and a payout batch when their ids are given', async () => { + if (process.env.PAYPAL_CAPTURE_ID) { + const capture = await run('paypal_get_capture', { capture_id: process.env.PAYPAL_CAPTURE_ID }); + expect(capture.id).toBe(process.env.PAYPAL_CAPTURE_ID); + } + if (process.env.PAYPAL_AUTHORIZATION_ID) { + const auth = await run('paypal_get_authorization', { authorization_id: process.env.PAYPAL_AUTHORIZATION_ID }); + expect(auth.id).toBe(process.env.PAYPAL_AUTHORIZATION_ID); + } + if (process.env.PAYPAL_PAYOUT_BATCH_ID) { + const batch = await run('paypal_get_payout_batch', { payout_batch_id: process.env.PAYPAL_PAYOUT_BATCH_ID }); + expect(batch.batch_header.payout_batch_id).toBe(process.env.PAYPAL_PAYOUT_BATCH_ID); + } + }, 60_000); + + (write ? it : it.skip)('creates an order, reads it, and is refused at capture without buyer approval', async () => { + const order = await run('paypal_create_order', { + purchase_units: [{ amount: { currency_code: 'EUR', value: '1.00' }, description: 'AnythingMCP live spec' }], + }); + expect(order.status).toMatch(/CREATED|PAYER_ACTION_REQUIRED/); + const read = await run('paypal_get_order', { order_id: order.id }); + expect(read.id).toBe(order.id); + await expect(run('paypal_capture_order', { order_id: order.id, request_id: `amcp-${Date.now()}` })).rejects.toMatchObject({ + response: { status: 422 }, + }); + }, 60_000); + + (write ? it : it.skip)('creates a draft invoice, finds it, sends, reminds and cancels it', async () => { + const recipient = process.env.PAYPAL_TEST_BUYER_EMAIL || 'anythingmcp-buyer@example.com'; + const created = await run('paypal_create_draft_invoice', { + detail: { currency_code: 'EUR', note: 'AnythingMCP live spec, safe to ignore', payment_term: { term_type: 'NET_10' } }, + primary_recipients: [{ billing_info: { email_address: recipient } }], + items: [{ name: 'AnythingMCP test line', quantity: '1', unit_amount: { currency_code: 'EUR', value: '1.00' } }], + }); + expect(created.status).toBe('DRAFT'); + let isSent = false; + try { + const read = await run('paypal_get_invoice', { invoice_id: created.id }); + expect(read.detail.invoice_number).toBe(created.detail.invoice_number); + const found = await run('paypal_search_invoices', { invoice_number: created.detail.invoice_number }); + expect(found.items.map((i: { id: string }) => i.id)).toContain(created.id); + await run('paypal_send_invoice', { invoice_id: created.id }); + isSent = true; + const afterSend = await run('paypal_get_invoice', { invoice_id: created.id }); + expect(afterSend.status).toMatch(/SENT|UNPAID/); + await run('paypal_remind_invoice', { invoice_id: created.id, send_to_invoicer: false }); + } finally { + if (isSent) { + await run('paypal_cancel_invoice', { invoice_id: created.id, send_to_recipient: false }); + const cancelled = await run('paypal_get_invoice', { invoice_id: created.id }); + expect(cancelled.status).toBe('CANCELLED'); + } else { + await run('paypal_delete_draft_invoice', { invoice_id: created.id }); + } + } + }, 120_000); + + (write && process.env.PAYPAL_CAPTURE_ID ? it : it.skip)('refunds 0.01 of the given sandbox capture and reads the refund', async () => { + const capture = await run('paypal_get_capture', { capture_id: process.env.PAYPAL_CAPTURE_ID }); + const refund = await run('paypal_refund_capture', { + capture_id: capture.id, + amount: { currency_code: capture.amount.currency_code, value: '0.01' }, + note_to_payer: 'AnythingMCP live spec', + request_id: `amcp-refund-${Date.now()}`, + }); + expect(refund.status).toMatch(/COMPLETED|PENDING/); + const read = await run('paypal_get_refund', { refund_id: refund.id }); + expect(read.id).toBe(refund.id); + }, 60_000); + + (write && process.env.PAYPAL_CANCEL_SUBSCRIPTION_ID ? it : it.skip)('cancels the given sandbox subscription', async () => { + const id = process.env.PAYPAL_CANCEL_SUBSCRIPTION_ID as string; + await run('paypal_cancel_subscription', { subscription_id: id, reason: 'AnythingMCP live spec' }); + const sub = await run('paypal_get_subscription', { subscription_id: id }); + expect(sub.status).toBe('CANCELLED'); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/shippo.json b/packages/backend/src/adapters/intl/shippo.json new file mode 100644 index 000000000..a5c5c58d0 --- /dev/null +++ b/packages/backend/src/adapters/intl/shippo.json @@ -0,0 +1,1233 @@ +{ + "slug": "shippo", + "name": "Shippo", + "description": "Let your AI handle shipping through Shippo: validate addresses, compare live rates from your carriers, buy and refund labels, track parcels, prepare customs paperwork and create scan forms. Test mode is free.", + "instructions": "## Shippo\nThis connector works with one Shippo account through the **Shippo API** (`https://api.goshippo.com`): addresses and validation, parcels, shipments with live rates from your carriers, buying labels, tracking, carrier accounts, customs declarations, label refunds and manifests (scan forms).\n\n**Setup (about 3 minutes)**\n1. Sign up at https://apps.goshippo.com/join (free) and open the API portal: https://portal.goshippo.com/api-config/api → **API Configuration → Developer keys**.\n2. Click **Create new test key** (or **Create new live key**) and copy it at once; it is shown only once. Test keys start with `shippo_test_`, live keys with `shippo_live_`.\n3. Paste it into `SHIPPO_API_TOKEN` and install. `shippo_list_carrier_accounts` should list Shippo's own carrier accounts (for example USPS), which are ready to use in the US.\n\n**Test or live**: the key decides. With a test key labels are free, watermarked \"SAMPLE - DO NOT MAIL\", tracking numbers never update, refunds always succeed, and manifests and batches are not available. A test key sees only test data and a live key only live data. Carrier accounts you connect with your own carrier credentials work only in the mode in which they were added.\n\n**Buying a label costs money: confirm first.** With a live key `shippo_buy_label` charges the label price (plus any insurance) to the Shippo account at once. Before calling it, state carrier, service, price and both addresses and wait for the user's yes. `shippo_request_refund` voids a label (it can no longer be used) and asks the carrier for the money back; only unused labels up to 90 days old qualify, and the refund itself takes days to weeks.\n\n**Typical flow**\n1. `shippo_create_shipment` with `address_from`, `address_to` and `parcels` (objects or saved object_ids) returns `rates[]`: each with `object_id`, `amount`, `currency`, `provider`, `servicelevel.name`, `estimated_days`.\n2. Pick a rate with the user, then `shippo_buy_label` with its `object_id`. The answer has `tracking_number`, `tracking_url_provider` and `label_url` (a signed link to the PDF that expires; read the label again with `shippo_get_label` for a fresh one).\n3. Follow the parcel with `shippo_get_tracking` (the carrier token as Shippo names it, such as `usps`, `ups`, `fedex` or `dhl_express`; `shippo_list_carrier_accounts` shows the tokens, plus the tracking number). In test mode use carrier `shippo` with tracking number `SHIPPO_TRANSIT`, `SHIPPO_DELIVERED`, `SHIPPO_RETURNED` or `SHIPPO_FAILURE`.\n\n**International shipments** need a customs declaration: create items with `shippo_create_customs_item` (description, quantity, net weight, value, origin country, tariff number), then `shippo_create_customs_declaration` with those items, `contents_type`, `non_delivery_option`, `certify: true` and `certify_signer`, and pass its object_id as `customs_declaration` to `shippo_create_shipment`.\n\n**Units and formats**: dimensions and weights are strings with their units (`\"length\": \"30\", \"distance_unit\": \"cm\"`, `\"weight\": \"1.2\", \"mass_unit\": \"kg\"`). Countries are ISO 2 codes (`DE`, `US`). Dates are ISO 8601 (`2026-10-12T09:00:00Z`).\n\n**Waiting for results**: the shipment, label, refund and manifest tools wait for the result by default (`async: false`). If Shippo answers with `status` QUEUED or WAITING, read the object again a few seconds later. A label with `status` ERROR explains why in `messages[]`.\n\n**Address validation**: `shippo_create_address` with `validate: true` (or `shippo_validate_address` on a saved address) returns `validation_results.is_valid` and messages. `shippo_validate_address` saves the checked, standardized address as a new object with its own `object_id` and leaves the original untouched, so ship to the new id. With a test key, addresses outside the US are echoed back without being checked; with a live key, validating them is charged.\n\n**API version**: no `SHIPPO-API-VERSION` header is sent, so the account's default version applies. Sending one would also change the version for later calls of the whole account.\n\n**Rate limits** per minute: 500 creates and 4,000 reads of single objects, but only 50 list calls (10 with a test key); prefer reading single objects by id over paging through lists.\n\n**Common errors**\n- 401 `Token does not exist`: wrong or deleted key; create a new one in the API portal.\n- 400: a field is missing or invalid; the body names it (for example an address that the carrier rejects).\n- 422 / `status: ERROR` with `messages[]`: the carrier refused, for example an unsupported service for that route or missing customs data.\n- 429: over the rate limit; wait a minute.\n\n**Out of scope here**: batches, orders, pickups, webhooks other than tracking registration, connecting carrier accounts and the v2 address API.", + "region": "intl", + "category": "logistics", + "icon": "shippo", + "docsUrl": "https://docs.goshippo.com/shippoapi/public-api/", + "requiredEnvVars": [ + "SHIPPO_API_TOKEN" + ], + "appRegistrationUrl": "https://portal.goshippo.com/api-config/api", + "envVarMeta": { + "SHIPPO_API_TOKEN": { + "label": "API token", + "kind": "credential", + "secret": true, + "help": "Shippo API portal → API Configuration → Developer keys → Create new test key (free) or live key. Shown only once.", + "link": "https://portal.goshippo.com/api-config/api", + "pattern": "^shippo_(test|live)_\\S+$", + "patternMessage": "Paste the key as shown, starting with shippo_test_ or shippo_live_, without the word ShippoToken." + } + }, + "verifyHints": { + "401": "Shippo refused the token. Create a new key in the API portal (API Configuration → Developer keys) and paste it without the word ShippoToken.", + "rate_limited": "Shippo is rate limiting this account (list calls are limited to 50 per minute, 10 with a test key). Wait a minute and try again." + }, + "probe": { + "tool": "shippo_list_carrier_accounts", + "params": { + "results": 1 + } + }, + "prerequisites": "A Shippo account (free) and an API key from the Shippo API portal. A test key costs nothing; a live key buys real labels. About 3 minutes.", + "connector": { + "name": "Shippo API", + "type": "REST", + "baseUrl": "https://api.goshippo.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "ShippoToken {{SHIPPO_API_TOKEN}}" + }, + "headers": { + "Accept": "application/json", + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/carrier_accounts?results=1" + }, + "tools": [ + { + "name": "shippo_create_address", + "description": "Save an address, optionally validated. Returns the address with object_id (usable in shipments instead of the full address), is_complete and, with validate true, validation_results.is_valid and messages.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full name of the person." + }, + "company": { + "type": "string", + "description": "Company name." + }, + "street1": { + "type": "string", + "description": "Street and house number." + }, + "street2": { + "type": "string", + "description": "Second address line." + }, + "street_no": { + "type": "string", + "description": "House number, if the carrier wants it separate (some EU carriers)." + }, + "city": { + "type": "string", + "description": "City." + }, + "state": { + "type": "string", + "description": "State or province code (required for US, CA and some other countries)." + }, + "zip": { + "type": "string", + "description": "Postal code." + }, + "country": { + "type": "string", + "description": "ISO 3166 two-letter country code, e.g. \"DE\" or \"US\"." + }, + "phone": { + "type": "string", + "description": "Phone number (many carriers require it for the recipient)." + }, + "email": { + "type": "string", + "description": "Email address." + }, + "is_residential": { + "type": "boolean", + "description": "true for a private home, false for a business." + }, + "validate": { + "type": "boolean", + "description": "true to validate the address while saving it; the answer then has validation_results." + }, + "metadata": { + "type": "string", + "description": "Your own note, up to 100 characters." + } + }, + "required": [ + "country" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/addresses", + "bodyMapping": { + "name": "$name", + "company": "$company", + "street1": "$street1", + "street2": "$street2", + "street_no": "$street_no", + "city": "$city", + "state": "$state", + "zip": "$zip", + "country": "$country", + "phone": "$phone", + "email": "$email", + "is_residential": "$is_residential", + "validate": "$validate", + "metadata": "$metadata" + } + } + }, + { + "name": "shippo_get_address", + "description": "Read a saved address by object_id, with its fields, completeness and any validation results.", + "parameters": { + "type": "object", + "properties": { + "address_id": { + "type": "string", + "description": "The address object_id." + } + }, + "required": [ + "address_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/addresses/{address_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_validate_address", + "description": "Validate a saved address. Shippo answers with a new saved address that has its own object_id, the standardized fields (for example the ZIP+4 code) and validation_results (is_valid, messages with suggested corrections); the original address stays as it was. Use the new object_id in shipments. With a test key, non-US addresses are not checked.", + "parameters": { + "type": "object", + "properties": { + "address_id": { + "type": "string", + "description": "The address object_id." + } + }, + "required": [ + "address_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/addresses/{address_id}/validate", + "encodePathParams": true + } + }, + { + "name": "shippo_list_addresses", + "description": "List saved addresses, newest first, with next and previous page links.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/addresses", + "queryParams": { + "page": "$page", + "results": "$results" + } + } + }, + { + "name": "shippo_create_parcel", + "description": "Save a parcel (dimensions and weight, or a carrier parcel template plus weight). Returns object_id, usable in shipments.", + "parameters": { + "type": "object", + "properties": { + "length": { + "type": "string", + "description": "Length, e.g. \"30\"." + }, + "width": { + "type": "string", + "description": "Width." + }, + "height": { + "type": "string", + "description": "Height." + }, + "distance_unit": { + "type": "string", + "description": "cm, in, ft, m, mm or yd.", + "enum": [ + "cm", + "in", + "ft", + "m", + "mm", + "yd" + ] + }, + "weight": { + "type": "string", + "description": "Weight, e.g. \"1.2\"." + }, + "mass_unit": { + "type": "string", + "description": "g, kg, lb or oz.", + "enum": [ + "g", + "kg", + "lb", + "oz" + ] + }, + "template": { + "type": "string", + "description": "A carrier parcel template token instead of dimensions, e.g. \"USPS_FlatRateEnvelope\" (see shippo_list_parcel_templates)." + }, + "metadata": { + "type": "string", + "description": "Your own note, up to 100 characters." + } + }, + "required": [ + "weight", + "mass_unit" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/parcels", + "bodyMapping": { + "length": "$length", + "width": "$width", + "height": "$height", + "distance_unit": "$distance_unit", + "weight": "$weight", + "mass_unit": "$mass_unit", + "template": "$template", + "metadata": "$metadata" + } + } + }, + { + "name": "shippo_get_parcel", + "description": "Read a saved parcel by object_id: dimensions, weight, units and template.", + "parameters": { + "type": "object", + "properties": { + "parcel_id": { + "type": "string", + "description": "The parcel object_id." + } + }, + "required": [ + "parcel_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/parcels/{parcel_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_list_parcel_templates", + "description": "List carrier parcel templates (flat rate boxes and envelopes): token, name, carrier and dimensions.", + "parameters": { + "type": "object", + "properties": { + "carrier": { + "type": "string", + "description": "Only templates of this carrier, e.g. \"usps\", \"fedex\", \"ups\"." + }, + "include": { + "type": "string", + "description": "all, user or enabled.", + "enum": [ + "all", + "user", + "enabled" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/parcel-templates", + "queryParams": { + "carrier": "$carrier", + "include": "$include" + } + } + }, + { + "name": "shippo_create_shipment", + "description": "Create a shipment and get live rates from the account's carriers. Nothing is bought. Returns object_id, status and rates[] (object_id, amount, currency, provider, servicelevel, estimated_days, attributes such as CHEAPEST and FASTEST).", + "parameters": { + "type": "object", + "properties": { + "address_from": { + "description": "An address object {\"name\", \"company\", \"street1\", \"street2\", \"city\", \"state\", \"zip\", \"country\" (ISO 2), \"phone\", \"email\"} or the object_id of a saved address.", + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + }, + "address_to": { + "description": "An address object {\"name\", \"company\", \"street1\", \"street2\", \"city\", \"state\", \"zip\", \"country\" (ISO 2), \"phone\", \"email\"} or the object_id of a saved address.", + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + }, + "parcels": { + "type": "array", + "description": "Parcels: objects {\"length\", \"width\", \"height\", \"distance_unit\", \"weight\", \"mass_unit\"} or parcel object_ids.", + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + } + }, + "address_return": { + "description": "Optional return address (object or object_id).", + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + }, + "customs_declaration": { + "type": "string", + "description": "object_id of a customs declaration, required for international shipments." + }, + "extra": { + "type": "object", + "description": "Optional services, e.g. {\"signature_confirmation\": \"STANDARD\", \"insurance\": {\"amount\": \"200\", \"currency\": \"EUR\", \"content\": \"Books\"}, \"reference_1\": \"Order 1042\", \"is_return\": false}." + }, + "shipment_date": { + "type": "string", + "description": "Planned ship date, ISO 8601 (default now)." + }, + "carrier_accounts": { + "type": "array", + "description": "Only rates from these carrier account object_ids.", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "string", + "description": "Your own note, up to 100 characters." + }, + "async": { + "type": "boolean", + "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.", + "default": false + } + }, + "required": [ + "address_from", + "address_to", + "parcels" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/shipments", + "bodyMapping": { + "address_from": "$address_from", + "address_to": "$address_to", + "parcels": "$parcels", + "address_return": "$address_return", + "customs_declaration": "$customs_declaration", + "extra": "$extra", + "shipment_date": "$shipment_date", + "carrier_accounts": "$carrier_accounts", + "metadata": "$metadata", + "async": "$async" + } + } + }, + { + "name": "shippo_get_shipment", + "description": "Read a shipment with its addresses, parcels, rates and messages.", + "parameters": { + "type": "object", + "properties": { + "shipment_id": { + "type": "string", + "description": "The shipment object_id." + } + }, + "required": [ + "shipment_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shipments/{shipment_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_list_shipments", + "description": "List shipments, newest first, with status and addresses (Shippo keeps the last 390 days).", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + }, + "object_created_gte": { + "type": "string", + "description": "Created at or after, ISO 8601 UTC, e.g. \"2026-10-01T00:00:00\"." + }, + "object_created_lte": { + "type": "string", + "description": "Created at or before, ISO 8601 UTC." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/shipments", + "queryParams": { + "page": "$page", + "results": "$results", + "object_created_gte": "$object_created_gte", + "object_created_lte": "$object_created_lte" + } + } + }, + { + "name": "shippo_list_shipment_rates", + "description": "List the rates of a shipment again (the same rates[] shippo_create_shipment returned), with paging.", + "parameters": { + "type": "object", + "properties": { + "shipment_id": { + "type": "string", + "description": "The shipment object_id." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "shipment_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shipments/{shipment_id}/rates", + "encodePathParams": true, + "queryParams": { + "page": "$page", + "results": "$results" + } + } + }, + { + "name": "shippo_get_rate", + "description": "Read one rate: amount, currency, provider, service level, estimated days, duration terms and zone.", + "parameters": { + "type": "object", + "properties": { + "rate_id": { + "type": "string", + "description": "The rate object_id." + } + }, + "required": [ + "rate_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/rates/{rate_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_buy_label", + "description": "Buy a shipping label for a rate. With a live key this charges the label price to the Shippo account immediately: confirm carrier, service, price and addresses with the user first. Returns the transaction with status, tracking_number, tracking_url_provider and label_url (expiring link to the label file).", + "parameters": { + "type": "object", + "properties": { + "rate": { + "type": "string", + "description": "The object_id of the chosen rate." + }, + "label_file_type": { + "type": "string", + "description": "PDF, PDF_4x6, PDF_4x8, PDF_A4, PDF_A5, PDF_A6, PDF_2.3x7.5, PNG, PNG_2.3x7.5 or ZPLII; the account default when omitted.", + "enum": [ + "PDF", + "PDF_4x6", + "PDF_4x8", + "PDF_A4", + "PDF_A5", + "PDF_A6", + "PDF_2.3x7.5", + "PNG", + "PNG_2.3x7.5", + "ZPLII" + ] + }, + "metadata": { + "type": "string", + "description": "Your own note, e.g. an order number, up to 100 characters." + }, + "async": { + "type": "boolean", + "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.", + "default": false + } + }, + "required": [ + "rate" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/transactions", + "bodyMapping": { + "rate": "$rate", + "label_file_type": "$label_file_type", + "metadata": "$metadata", + "async": "$async" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "shippo_list_labels", + "description": "List purchased labels (transactions), newest first, with status, tracking number and tracking status.", + "parameters": { + "type": "object", + "properties": { + "object_status": { + "type": "string", + "description": "SUCCESS, ERROR, QUEUED, WAITING, REFUNDED, REFUNDPENDING or REFUNDREJECTED." + }, + "tracking_status": { + "type": "string", + "description": "UNKNOWN, PRE_TRANSIT, TRANSIT, DELIVERED, RETURNED or FAILURE." + }, + "rate": { + "type": "string", + "description": "Only the label bought for this rate object_id." + }, + "object_created_gte": { + "type": "string", + "description": "Created at or after, ISO 8601 UTC." + }, + "object_created_lte": { + "type": "string", + "description": "Created at or before, ISO 8601 UTC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions", + "queryParams": { + "object_status": "$object_status", + "tracking_status": "$tracking_status", + "rate": "$rate", + "object_created_gte": "$object_created_gte", + "object_created_lte": "$object_created_lte", + "page": "$page", + "results": "$results" + } + } + }, + { + "name": "shippo_get_label", + "description": "Read one purchased label (transaction): status, tracking number and status, ETA, a fresh label_url and commercial invoice URL, and messages.", + "parameters": { + "type": "object", + "properties": { + "transaction_id": { + "type": "string", + "description": "The transaction object_id." + } + }, + "required": [ + "transaction_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions/{transaction_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_get_tracking", + "description": "Read the tracking status and history of a parcel by carrier and tracking number, for labels bought anywhere. Returns tracking_status (status, details, time, location), tracking_history[] and eta.", + "parameters": { + "type": "object", + "properties": { + "carrier": { + "type": "string", + "description": "Carrier token as Shippo names it, e.g. \"usps\", \"ups\", \"fedex\", \"dhl_express\" (see the carrier field of shippo_list_carrier_accounts); \"shippo\" for test tracking numbers." + }, + "tracking_number": { + "type": "string", + "description": "The tracking number; with carrier \"shippo\": SHIPPO_PRE_TRANSIT, SHIPPO_TRANSIT, SHIPPO_DELIVERED, SHIPPO_RETURNED, SHIPPO_FAILURE or SHIPPO_UNKNOWN." + } + }, + "required": [ + "carrier", + "tracking_number" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/tracks/{carrier}/{tracking_number}", + "encodePathParams": true + } + }, + { + "name": "shippo_register_tracking", + "description": "Register a tracking number for status updates, which Shippo then sends to the account's \"track updated\" webhook (set up in the Shippo dashboard). Returns the current tracking status.", + "parameters": { + "type": "object", + "properties": { + "carrier": { + "type": "string", + "description": "Carrier token, e.g. \"usps\", \"dhl_express\"." + }, + "tracking_number": { + "type": "string", + "description": "The tracking number." + }, + "metadata": { + "type": "string", + "description": "Your own note, up to 100 characters, sent back with each update." + } + }, + "required": [ + "carrier", + "tracking_number" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/tracks", + "bodyMapping": { + "carrier": "$carrier", + "tracking_number": "$tracking_number", + "metadata": "$metadata" + } + } + }, + { + "name": "shippo_list_carrier_accounts", + "description": "List the carrier accounts usable for rates and labels: object_id, carrier, account_id, active, whether it is a Shippo-provided account, and optionally their service levels.", + "parameters": { + "type": "object", + "properties": { + "carrier": { + "type": "string", + "description": "Only this carrier, e.g. \"usps\", \"ups\", \"dhl_express\"." + }, + "service_levels": { + "type": "boolean", + "description": "true to include each account's service levels." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/carrier_accounts", + "queryParams": { + "carrier": "$carrier", + "service_levels": "$service_levels", + "page": "$page", + "results": "$results" + } + } + }, + { + "name": "shippo_get_carrier_account", + "description": "Read one carrier account by object_id: carrier, account number, active flag and parameters.", + "parameters": { + "type": "object", + "properties": { + "carrier_account_id": { + "type": "string", + "description": "The carrier account object_id." + } + }, + "required": [ + "carrier_account_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/carrier_accounts/{carrier_account_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_create_customs_item", + "description": "Create a customs item (one line of a customs declaration). Returns object_id.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "What it is, e.g. \"Cotton T-shirt\"." + }, + "quantity": { + "type": "integer", + "description": "Number of pieces.", + "minimum": 1 + }, + "net_weight": { + "type": "string", + "description": "Total net weight of these pieces, e.g. \"0.4\"." + }, + "mass_unit": { + "type": "string", + "description": "g, kg, lb or oz.", + "enum": [ + "g", + "kg", + "lb", + "oz" + ] + }, + "value_amount": { + "type": "string", + "description": "Total value of these pieces, e.g. \"25.00\"." + }, + "value_currency": { + "type": "string", + "description": "Currency, e.g. \"EUR\"." + }, + "origin_country": { + "type": "string", + "description": "Country of manufacture, ISO 2." + }, + "tariff_number": { + "type": "string", + "description": "HS tariff number, e.g. \"610910\"." + }, + "sku_code": { + "type": "string", + "description": "Your SKU." + }, + "eccn_ear99": { + "type": "string", + "description": "Export control classification (US exports)." + }, + "metadata": { + "type": "string", + "description": "Your own note." + } + }, + "required": [ + "description", + "quantity", + "net_weight", + "mass_unit", + "value_amount", + "value_currency", + "origin_country" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/customs/items", + "bodyMapping": { + "description": "$description", + "quantity": "$quantity", + "net_weight": "$net_weight", + "mass_unit": "$mass_unit", + "value_amount": "$value_amount", + "value_currency": "$value_currency", + "origin_country": "$origin_country", + "tariff_number": "$tariff_number", + "sku_code": "$sku_code", + "eccn_ear99": "$eccn_ear99", + "metadata": "$metadata" + } + } + }, + { + "name": "shippo_get_customs_item", + "description": "Read a customs item by object_id: description, quantity, weight, value, origin and tariff number.", + "parameters": { + "type": "object", + "properties": { + "customs_item_id": { + "type": "string", + "description": "The customs item object_id." + } + }, + "required": [ + "customs_item_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/customs/items/{customs_item_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_create_customs_declaration", + "description": "Create a customs declaration for an international shipment from customs items. Pass its object_id as customs_declaration to shippo_create_shipment.", + "parameters": { + "type": "object", + "properties": { + "contents_type": { + "type": "string", + "description": "DOCUMENTS, GIFT, SAMPLE, MERCHANDISE, HUMANITARIAN_DONATION, RETURN_MERCHANDISE or OTHER.", + "enum": [ + "DOCUMENTS", + "GIFT", + "SAMPLE", + "MERCHANDISE", + "HUMANITARIAN_DONATION", + "RETURN_MERCHANDISE", + "OTHER" + ] + }, + "contents_explanation": { + "type": "string", + "description": "Required when contents_type is OTHER." + }, + "non_delivery_option": { + "type": "string", + "description": "ABANDON or RETURN.", + "enum": [ + "ABANDON", + "RETURN" + ] + }, + "certify": { + "type": "boolean", + "description": "true: the signer certifies the information is correct (required)." + }, + "certify_signer": { + "type": "string", + "description": "Name of the person certifying." + }, + "items": { + "type": "array", + "description": "Customs item object_ids (or item objects).", + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string" + } + ] + } + }, + "incoterm": { + "type": "string", + "description": "DDP, DDU, FCA, DAP or eDAP.", + "enum": [ + "DDP", + "DDU", + "FCA", + "DAP", + "eDAP" + ] + }, + "eel_pfc": { + "type": "string", + "description": "US export exemption, e.g. NOEEI_30_37_a." + }, + "invoice": { + "type": "string", + "description": "Commercial invoice number." + }, + "exporter_identification": { + "type": "object", + "description": "e.g. {\"eori_number\": \"DE123456789012345\"}." + }, + "metadata": { + "type": "string", + "description": "Your own note." + } + }, + "required": [ + "contents_type", + "non_delivery_option", + "certify", + "certify_signer", + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/customs/declarations", + "bodyMapping": { + "contents_type": "$contents_type", + "contents_explanation": "$contents_explanation", + "non_delivery_option": "$non_delivery_option", + "certify": "$certify", + "certify_signer": "$certify_signer", + "items": "$items", + "incoterm": "$incoterm", + "eel_pfc": "$eel_pfc", + "invoice": "$invoice", + "exporter_identification": "$exporter_identification", + "metadata": "$metadata" + } + } + }, + { + "name": "shippo_get_customs_declaration", + "description": "Read a customs declaration by object_id, with its items, contents type, incoterm and signer.", + "parameters": { + "type": "object", + "properties": { + "customs_declaration_id": { + "type": "string", + "description": "The customs declaration object_id." + } + }, + "required": [ + "customs_declaration_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/customs/declarations/{customs_declaration_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_list_customs_declarations", + "description": "List customs declarations, newest first, with contents type, items and certification details.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customs/declarations", + "queryParams": { + "page": "$page", + "results": "$results" + } + } + }, + { + "name": "shippo_request_refund", + "description": "Void a purchased label and ask for its price back. The label can no longer be used. Only unused labels up to 90 days old; the money comes back after the carrier confirms (days to weeks). Confirm with the user first. Returns the refund with status QUEUED, PENDING, SUCCESS or ERROR.", + "parameters": { + "type": "object", + "properties": { + "transaction": { + "type": "string", + "description": "The transaction (label) object_id." + }, + "async": { + "type": "boolean", + "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.", + "default": false + } + }, + "required": [ + "transaction" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/refunds", + "bodyMapping": { + "transaction": "$transaction", + "async": "$async" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "shippo_list_refunds", + "description": "List label refunds with their status (QUEUED, PENDING, SUCCESS, ERROR) and the label they belong to.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/refunds/" + } + }, + { + "name": "shippo_get_refund", + "description": "Read one label refund by object_id: status, label (transaction) and dates.", + "parameters": { + "type": "object", + "properties": { + "refund_id": { + "type": "string", + "description": "The refund object_id." + } + }, + "required": [ + "refund_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/refunds/{refund_id}", + "encodePathParams": true + } + }, + { + "name": "shippo_create_manifest", + "description": "Create a manifest (scan form) for a carrier pickup: one document listing the labels of one carrier account, ship date and origin address. Not available with a test key. Returns status and documents[] (URLs).", + "parameters": { + "type": "object", + "properties": { + "carrier_account": { + "type": "string", + "description": "Carrier account object_id." + }, + "shipment_date": { + "type": "string", + "description": "Ship date, ISO 8601, e.g. \"2026-10-12T09:00:00Z\"." + }, + "address_from": { + "type": "string", + "description": "Origin address object_id (the same as on the labels)." + }, + "transactions": { + "type": "array", + "description": "Label (transaction) object_ids; all eligible labels of that day when omitted.", + "items": { + "type": "string" + } + }, + "async": { + "type": "boolean", + "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.", + "default": false + } + }, + "required": [ + "carrier_account", + "shipment_date", + "address_from" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/manifests", + "bodyMapping": { + "carrier_account": "$carrier_account", + "shipment_date": "$shipment_date", + "address_from": "$address_from", + "transactions": "$transactions", + "async": "$async" + } + } + }, + { + "name": "shippo_list_manifests", + "description": "List manifests (scan forms), newest first, with carrier account, ship date, status and documents.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "results": { + "type": "integer", + "description": "Results per page, up to 100.", + "minimum": 1, + "maximum": 100 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/manifests", + "queryParams": { + "page": "$page", + "results": "$results" + } + } + }, + { + "name": "shippo_get_manifest", + "description": "Read one manifest (scan form) with its status, the labels on it, document URLs and errors.", + "parameters": { + "type": "object", + "properties": { + "manifest_id": { + "type": "string", + "description": "The manifest object_id." + } + }, + "required": [ + "manifest_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/manifests/{manifest_id}", + "encodePathParams": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/shippo.live.spec.ts b/packages/backend/src/adapters/intl/shippo.live.spec.ts new file mode 100644 index 000000000..d45e7f4d1 --- /dev/null +++ b/packages/backend/src/adapters/intl/shippo.live.spec.ts @@ -0,0 +1,351 @@ +import axios from 'axios'; +import * as adapter from './shippo.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, interpolateDeep } from '../../common/env-interpolation.util'; +import { describeAdapterEnvVars } from '../env-var-meta'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Two layers of verification for the Shippo adapter: + * + * 1. Static: always runs. Pins the `ShippoToken` Authorization header, the + * token pattern, every tool's method and URL, the shipment, label, + * customs and refund bodies (synchronous by default), and which tools + * are read-only or destructive. + * + * 2. Live: skipped unless SHIPPO_API_TOKEN is set. Writes need a test token + * (shippo_test_...), where labels are free: + * + * SHIPPO_API_TOKEN=shippo_test_... npx jest src/adapters/intl/shippo.live.spec.ts + * + * Reads carrier accounts, parcel templates, addresses, shipments, labels, + * customs declarations, refunds, manifests and test tracking numbers. + * + * SHIPPO_LIVE_WRITE=1 (test token only) runs the whole flow: two US + * addresses (one validated), a parcel, a shipment with rates, a USPS test + * label bought from the cheapest rate, read back and listed, tracking + * registered, a customs item and declaration, and in `finally` a refund + * of the label, read back. Test addresses and parcels cannot be deleted; + * they stay in the test data, labelled "AnythingMCP test". + */ + +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; + headers?: Record; + queryParams?: Record; + bodyMapping?: Record; + }; + annotations?: Record; +}; +const a = adapter as unknown as { + slug: string; + unlisted?: boolean; + instructions: string; + prerequisites: string; + requiredEnvVars: string[]; + envVarMeta: Record; + probe: { tool: string; params?: Record }; + connector: { + baseUrl: string; + authType: string; + authConfig: Record; + headers: Record; + healthcheckPath: string; + }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +const annotationsOf = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + +const BASE = 'https://api.goshippo.com'; +const execute = (name: string, token: string, params: Record) => { + const env = { SHIPPO_API_TOKEN: token }; + const { config, endpointMapping } = interpolateConnectorConfig( + { baseUrl: a.connector.baseUrl, headers: a.connector.headers }, + tool(name).endpointMapping, + env, + ); + return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute( + { baseUrl: config.baseUrl, authType: a.connector.authType, authConfig: interpolateDeep({ ...a.connector.authConfig }, env), headers: config.headers }, + endpointMapping, + applySchemaDefaults(tool(name).parameters, params), + ) as Promise; +}; +const call = (name: string, params: Record) => execute(name, 'shippo_test_abc123', params); +const sent = () => mockedAxios.mock.calls[0][0]; + +describe('shippo adapter: static spec conformance', () => { + beforeEach(() => mockedAxios.mockReset()); + + it('is listed (verified live on 11 Oct 2026)', () => { + expect(a.unlisted).toBeUndefined(); + expect(a.prerequisites).toMatch(/test key/); + }); + + it('sends the token as "Authorization: ShippoToken " and no API version header', async () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ headerName: 'Authorization', apiKey: 'ShippoToken {{SHIPPO_API_TOKEN}}' }); + expect(a.connector.baseUrl).toBe(BASE); + mockedAxios.mockResolvedValue({ data: {} }); + await call('shippo_list_carrier_accounts', {}); + expect(sent().headers.Authorization).toBe('ShippoToken shippo_test_abc123'); + // A version header would change the whole account's default version. + expect(Object.keys(sent().headers).map((h) => h.toLowerCase())).not.toContain('shippo-api-version'); + }); + + it('accepts test and live tokens only, and marks the token secret', () => { + expect(a.requiredEnvVars).toEqual(['SHIPPO_API_TOKEN']); + const token = new RegExp(a.envVarMeta.SHIPPO_API_TOKEN.pattern!); + expect(token.test('shippo_test_5f1b2c3d4e')).toBe(true); + expect(token.test('shippo_live_5f1b2c3d4e')).toBe(true); + expect(token.test('ShippoToken shippo_test_5f1b')).toBe(false); + expect(describeAdapterEnvVars(a as never).find((d) => d.name === 'SHIPPO_API_TOKEN')!.secret).toBe(true); + }); + + it('probes with one carrier account and health-checks the same list', () => { + expect(a.probe).toEqual({ tool: 'shippo_list_carrier_accounts', params: { results: 1 } }); + expect(a.connector.healthcheckPath).toBe('/carrier_accounts?results=1'); + }); + + it('prefixes every tool with shippo_ 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(/^shippo_[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 and buying or refunding a label destructive', () => { + const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name); + expect(readOnly.sort()).toEqual([ + 'shippo_get_address', + 'shippo_get_carrier_account', + 'shippo_get_customs_declaration', + 'shippo_get_customs_item', + 'shippo_get_label', + 'shippo_get_manifest', + 'shippo_get_parcel', + 'shippo_get_rate', + 'shippo_get_refund', + 'shippo_get_shipment', + 'shippo_get_tracking', + 'shippo_list_addresses', + 'shippo_list_carrier_accounts', + 'shippo_list_customs_declarations', + 'shippo_list_labels', + 'shippo_list_manifests', + 'shippo_list_parcel_templates', + 'shippo_list_refunds', + 'shippo_list_shipment_rates', + 'shippo_list_shipments', + 'shippo_validate_address', + ]); + for (const name of ['shippo_buy_label', 'shippo_request_refund']) { + const ann = annotationsOf(tool(name)); + expect(`${name}:${ann.readOnlyHint}:${ann.destructiveHint}:${ann.idempotentHint}`).toBe(`${name}:false:true:false`); + expect(tool(name).description).toMatch(/confirm/i); + } + expect(annotationsOf(tool('shippo_create_shipment')).destructiveHint).toBe(false); + }); + + it('only points the model at tools that exist, and writes no em dashes', () => { + const names = new Set(a.tools.map((t) => t.name)); + const mentioned = [ + ...a.instructions.matchAll(/\bshippo_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bshippo_[a-z_]+/g)]), + ] + .map((m) => m[0]) + // Key prefixes, not tool names. + .filter((m) => m !== 'shippo_test_' && m !== 'shippo_live_'); + expect(mentioned.length).toBeGreaterThan(8); + for (const name of mentioned) expect(names).toContain(name); + expect(JSON.stringify(adapter)).not.toMatch(/[–—]/); + }); + + const table: Array<[string, Record, string, string]> = [ + ['shippo_create_address', { country: 'US' }, 'POST', '/addresses'], + ['shippo_get_address', { address_id: 'a1' }, 'GET', '/addresses/a1'], + ['shippo_validate_address', { address_id: 'a1' }, 'GET', '/addresses/a1/validate'], + ['shippo_list_addresses', {}, 'GET', '/addresses'], + ['shippo_create_parcel', { weight: '1', mass_unit: 'kg' }, 'POST', '/parcels'], + ['shippo_get_parcel', { parcel_id: 'p1' }, 'GET', '/parcels/p1'], + ['shippo_list_parcel_templates', {}, 'GET', '/parcel-templates'], + ['shippo_create_shipment', { address_from: 'a1', address_to: 'a2', parcels: ['p1'] }, 'POST', '/shipments'], + ['shippo_get_shipment', { shipment_id: 's1' }, 'GET', '/shipments/s1'], + ['shippo_list_shipments', {}, 'GET', '/shipments'], + ['shippo_list_shipment_rates', { shipment_id: 's1' }, 'GET', '/shipments/s1/rates'], + ['shippo_get_rate', { rate_id: 'r1' }, 'GET', '/rates/r1'], + ['shippo_buy_label', { rate: 'r1' }, 'POST', '/transactions'], + ['shippo_list_labels', {}, 'GET', '/transactions'], + ['shippo_get_label', { transaction_id: 't1' }, 'GET', '/transactions/t1'], + ['shippo_get_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_TRANSIT' }, 'GET', '/tracks/shippo/SHIPPO_TRANSIT'], + ['shippo_register_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_TRANSIT' }, 'POST', '/tracks'], + ['shippo_list_carrier_accounts', {}, 'GET', '/carrier_accounts'], + ['shippo_get_carrier_account', { carrier_account_id: 'c1' }, 'GET', '/carrier_accounts/c1'], + ['shippo_create_customs_item', { description: 'T-shirt', quantity: 1, net_weight: '0.2', mass_unit: 'kg', value_amount: '10', value_currency: 'EUR', origin_country: 'DE' }, 'POST', '/customs/items'], + ['shippo_get_customs_item', { customs_item_id: 'ci1' }, 'GET', '/customs/items/ci1'], + ['shippo_create_customs_declaration', { contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'Ana', items: ['ci1'] }, 'POST', '/customs/declarations'], + ['shippo_get_customs_declaration', { customs_declaration_id: 'cd1' }, 'GET', '/customs/declarations/cd1'], + ['shippo_list_customs_declarations', {}, 'GET', '/customs/declarations'], + ['shippo_request_refund', { transaction: 't1' }, 'POST', '/refunds'], + ['shippo_list_refunds', {}, 'GET', '/refunds/'], + ['shippo_get_refund', { refund_id: 'rf1' }, 'GET', '/refunds/rf1'], + ['shippo_create_manifest', { carrier_account: 'c1', shipment_date: '2026-10-12T09:00:00Z', address_from: 'a1' }, 'POST', '/manifests'], + ['shippo_list_manifests', {}, 'GET', '/manifests'], + ['shippo_get_manifest', { manifest_id: 'm1' }, 'GET', '/manifests/m1'], + ]; + + 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 the right URL', async (name, params, method, path) => { + mockedAxios.mockResolvedValue({ data: {} }); + await call(name, params); + expect(mockedAxios).toHaveBeenCalledWith(expect.objectContaining({ method, url: `${BASE}${path}` })); + }); + + it('creates shipments, labels, refunds and manifests synchronously by default', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + const from = { name: 'A', street1: '215 Clayton St.', city: 'San Francisco', state: 'CA', zip: '94117', country: 'US' }; + await call('shippo_create_shipment', { address_from: from, address_to: 'addr-2', parcels: [{ length: '10', width: '10', height: '10', distance_unit: 'in', weight: '1', mass_unit: 'lb' }] }); + expect(sent().data).toEqual({ address_from: from, address_to: 'addr-2', parcels: [{ length: '10', width: '10', height: '10', distance_unit: 'in', weight: '1', mass_unit: 'lb' }], async: false }); + for (const [name, params, body] of [ + ['shippo_buy_label', { rate: 'r1', label_file_type: 'PDF_A6' }, { rate: 'r1', label_file_type: 'PDF_A6', async: false }], + ['shippo_request_refund', { transaction: 't1' }, { transaction: 't1', async: false }], + ['shippo_create_manifest', { carrier_account: 'c1', shipment_date: '2026-10-12T09:00:00Z', address_from: 'a1' }, { carrier_account: 'c1', shipment_date: '2026-10-12T09:00:00Z', address_from: 'a1', async: false }], + ] as Array<[string, Record, Record]>) { + mockedAxios.mockClear(); + await call(name, params); + expect(sent().data).toEqual(body); + } + }); + + it('passes customs items and the declaration as Shippo names them', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('shippo_create_customs_declaration', { contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'Ana Diaz', items: ['ci1'], incoterm: 'DDU' }); + expect(sent().data).toEqual({ contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'Ana Diaz', items: ['ci1'], incoterm: 'DDU' }); + }); +}); + +const TOKEN = process.env.SHIPPO_API_TOKEN; +const live = TOKEN ? describe : describe.skip; + +live('shippo adapter: live API', () => { + const write = process.env.SHIPPO_LIVE_WRITE === '1' && !!TOKEN?.startsWith('shippo_test_'); + beforeAll(() => { + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + }); + const run = (name: string, params: Record = {}): Promise => execute(name, TOKEN as string, params); + + it('reads carrier accounts, parcel templates and the lists', async () => { + const carriers = await run('shippo_list_carrier_accounts', { results: 5, service_levels: true }); + expect(Array.isArray(carriers.results)).toBe(true); + if (carriers.results.length) { + const one = await run('shippo_get_carrier_account', { carrier_account_id: carriers.results[0].object_id }); + expect(one.object_id).toBe(carriers.results[0].object_id); + } + const templates = await run('shippo_list_parcel_templates', { carrier: 'usps' }); + expect(templates).toBeDefined(); + for (const name of ['shippo_list_addresses', 'shippo_list_shipments', 'shippo_list_labels', 'shippo_list_customs_declarations', 'shippo_list_manifests']) { + const page = await run(name, { results: 2 }); + expect(Array.isArray(page.results)).toBe(true); + } + const refunds = await run('shippo_list_refunds'); + expect(refunds).toBeDefined(); + const labels = await run('shippo_list_labels', { results: 1 }); + if (labels.results.length) { + const one = await run('shippo_get_label', { transaction_id: labels.results[0].object_id }); + expect(one.object_id).toBe(labels.results[0].object_id); + } + }, 90_000); + + (TOKEN?.startsWith('shippo_test_') ? it : it.skip)('reads a test tracking number', async () => { + const track = await run('shippo_get_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_TRANSIT' }); + expect(track.tracking_status.status).toBe('TRANSIT'); + }, 30_000); + + (write ? it : it.skip)('validates addresses, rates a shipment, buys a test label, tracks it, prepares customs and refunds the label', async () => { + const from = await run('shippo_create_address', { + name: 'AnythingMCP test sender', company: 'AnythingMCP test', street1: '215 Clayton St.', city: 'San Francisco', state: 'CA', zip: '94117', country: 'US', + phone: '+1 555 341 9393', email: 'anythingmcp-test@example.com', validate: true, + }); + expect(from.validation_results).toBeDefined(); + const to = await run('shippo_create_address', { + name: 'AnythingMCP test recipient', street1: '965 Mission St', city: 'San Francisco', state: 'CA', zip: '94103', country: 'US', phone: '+1 555 341 9394', email: 'anythingmcp-test@example.com', + }); + // Validation saves the standardized address as a new object (own + // object_id, ZIP+4) and leaves the original untouched; ship to the new one. + const validated = await run('shippo_validate_address', { address_id: to.object_id }); + expect(validated.object_id).toMatch(/^[0-9a-f]{32}$/); + expect(validated.validation_results.is_valid).toBe(true); + expect(validated.zip.startsWith('94103')).toBe(true); + expect((await run('shippo_get_address', { address_id: from.object_id })).object_id).toBe(from.object_id); + + const parcel = await run('shippo_create_parcel', { length: '10', width: '8', height: '4', distance_unit: 'in', weight: '2', mass_unit: 'lb', metadata: 'AnythingMCP test' }); + expect((await run('shippo_get_parcel', { parcel_id: parcel.object_id })).object_id).toBe(parcel.object_id); + + const shipment = await run('shippo_create_shipment', { address_from: from.object_id, address_to: validated.object_id, parcels: [parcel.object_id], metadata: 'AnythingMCP test' }); + expect(shipment.status).toBe('SUCCESS'); + expect((await run('shippo_get_shipment', { shipment_id: shipment.object_id })).object_id).toBe(shipment.object_id); + const rates = await run('shippo_list_shipment_rates', { shipment_id: shipment.object_id }); + expect(rates.results.length).toBeGreaterThan(0); + const usps = rates.results.filter((r: { provider: string }) => r.provider === 'USPS'); + const cheapest = (usps.length ? usps : rates.results).sort((x: { amount: string }, y: { amount: string }) => Number(x.amount) - Number(y.amount))[0]; + expect((await run('shippo_get_rate', { rate_id: cheapest.object_id })).object_id).toBe(cheapest.object_id); + + const label = await run('shippo_buy_label', { rate: cheapest.object_id, label_file_type: 'PDF', metadata: 'AnythingMCP test' }); + try { + expect(label.status).toBe('SUCCESS'); + expect(label.test).toBe(true); + const read = await run('shippo_get_label', { transaction_id: label.object_id }); + expect(read.tracking_number).toBe(label.tracking_number); + const listed = await run('shippo_list_labels', { rate: cheapest.object_id }); + expect(listed.results.map((t: { object_id: string }) => t.object_id)).toContain(label.object_id); + const registered = await run('shippo_register_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_DELIVERED', metadata: 'AnythingMCP test' }); + expect(registered.tracking_number).toBe('SHIPPO_DELIVERED'); + + const item = await run('shippo_create_customs_item', { + description: 'AnythingMCP test T-shirt', quantity: 2, net_weight: '0.4', mass_unit: 'kg', value_amount: '20.00', value_currency: 'USD', origin_country: 'US', tariff_number: '610910', + }); + expect((await run('shippo_get_customs_item', { customs_item_id: item.object_id })).object_id).toBe(item.object_id); + const declaration = await run('shippo_create_customs_declaration', { + contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'AnythingMCP Test', items: [item.object_id], incoterm: 'DDU', eel_pfc: 'NOEEI_30_37_a', + }); + expect((await run('shippo_get_customs_declaration', { customs_declaration_id: declaration.object_id })).object_id).toBe(declaration.object_id); + } finally { + const refund = await run('shippo_request_refund', { transaction: label.object_id }); + expect(refund.status).toMatch(/QUEUED|PENDING|SUCCESS/); + const again = await run('shippo_get_refund', { refund_id: refund.object_id }); + expect(again.transaction).toBe(label.object_id); + } + }, 180_000); +}); diff --git a/packages/backend/src/adapters/intl/square.json b/packages/backend/src/adapters/intl/square.json new file mode 100644 index 000000000..1ddf556cc --- /dev/null +++ b/packages/backend/src/adapters/intl/square.json @@ -0,0 +1,1451 @@ +{ + "slug": "square", + "name": "Square", + "description": "Let your AI work with your Square business: look up payments, refunds and orders, manage customers, search and edit the item catalog, check inventory, create and send invoices, and see locations and team members. Sandbox or production, with your own access token.", + "instructions": "## Square\nThis connector works with one Square seller account through the **Square API** (`https://connect.squareup.com`, or `https://connect.squareupsandbox.com` for the sandbox), version `2026-09-16`: business and locations, payments and refunds, orders, customers, the item catalog, inventory counts, invoices and team members.\n\n**Setup (about 5 minutes)**\n1. Sign in at https://developer.squareup.com/apps with the Square account and click **Create an application** (any name).\n2. Open the application → **Credentials**. The toggle at the top switches between **Sandbox** and **Production**.\n3. Copy the **access token** of that environment into `SQUARE_ACCESS_TOKEN`. It is a personal access token with full access to your own account, which is what this connector needs; it does not expire, but anyone holding it can act for the business, so keep it secret and replace it on the same page if it leaks.\n4. Set `SQUARE_API_URL` to `https://connect.squareupsandbox.com` for the sandbox token or `https://connect.squareup.com` for the production token. A token only works against its own environment (401 otherwise).\n5. Install and run `square_get_merchant` to check the business name.\n\n**Sandbox**: the sandbox token belongs to a default test seller. To look at the results as a seller, open the developer console → **Sandbox test accounts** → the account → **Square Dashboard**. The sandbox never emails invoices; use the invoice's `public_url`.\n\n**Money and customer emails: confirm first.** `square_refund_payment` sends money back, `square_publish_invoice` sends the invoice to the customer (email) and makes it payable, and `square_cancel_invoice` and `square_upsert_catalog_object` change data customers see. Before calling them, state what will happen (amount, customer, ids) and wait for the user's yes. `square_delete_customer`, `square_delete_catalog_object` and `square_delete_invoice` are installed switched off.\n\n**Idempotency keys**: every create, refund, publish and upsert takes an `idempotency_key`. Use a fresh UUID per action, and reuse the same key only when retrying the same action after a timeout.\n\n**Money**: amounts are integers in the smallest unit of the currency (`{\"amount\": 1999, \"currency\": \"EUR\"}` is 19.99 EUR); the currency must be the seller's (see `square_get_merchant`). Times are RFC 3339 (`2026-10-01T00:00:00Z`).\n\n**Locations**: most data belongs to a location. `square_list_locations` gives their ids; `\"main\"` works as the id of the main location in `square_get_location`. `square_list_payments` without `location_id` covers only the main location; `square_search_orders` needs 1 to 10 `location_ids`; `square_list_invoices` needs one `location_id`.\n\n**Orders**: `square_search_orders` filters by state (OPEN, COMPLETED, CANCELED), dates and customers. When you filter by a date field, sort by the same field (`\"sort\": {\"sort_field\": \"CLOSED_AT\"}` with a `closed_at` filter). Orders taken offline can take up to 72 hours to appear.\n\n**Invoices**: an invoice is built on an OPEN order. Steps: `square_create_order` (location, line items, customer) → `square_create_invoice` with that `order_id`, the `customer_id`, one `payment_requests` entry (`{\"request_type\": \"BALANCE\", \"due_date\": \"2026-11-01\"}`) and `accepted_payment_methods` (`{\"card\": true}`) → it is a DRAFT until `square_publish_invoice`. Publishing and cancelling need the invoice's current `version` (from `square_get_invoice`). For EMAIL delivery the customer needs an email address. Deleting a draft also cancels its order.\n\n**Customers**: `square_search_customers` filters by email, phone (E.164, `+4976112345`), reference id or dates; a new or changed customer can take a few seconds to become searchable. `square_update_customer` changes only the fields you send; pass `version` to avoid overwriting a newer change.\n\n**Catalog**: `square_list_catalog` takes `types` (ITEM, ITEM_VARIATION, CATEGORY, TAX, DISCOUNT, MODIFIER_LIST, ...). `square_search_catalog_items` searches item names, SKUs and descriptions. `square_upsert_catalog_object` creates or REPLACES a whole object: to change an item, read it with `square_get_catalog_object`, change the copy and send it back with its `version`, or fields you leave out (variations included) are removed. New objects use temporary ids starting with `#`.\n\n**Inventory**: counts are per item variation (not per item) and location: `square_get_inventory_counts` with variation ids, or `square_get_inventory_count` for one.\n\n**Paging**: list and search answers carry `cursor` while more pages exist; pass it back to continue.\n\n**Common errors** (`errors[].code` and `detail`)\n- 401 `UNAUTHORIZED`: wrong token, or a sandbox token against the production URL (or the other way round).\n- 400 `INVALID_REQUEST_ERROR` / `VERSION_MISMATCH`: a field or an outdated `version`; read the object again.\n- 404 `NOT_FOUND`: wrong id, or an object from another location or environment.\n- 429 `RATE_LIMITED`: wait a moment and retry.\n\n**Out of scope here**: taking card payments, gift cards, loyalty, bookings, subscriptions, payouts and webhooks.", + "region": "intl", + "category": "payments", + "icon": "square", + "docsUrl": "https://developer.squareup.com/reference/square", + "requiredEnvVars": [ + "SQUARE_ACCESS_TOKEN", + "SQUARE_API_URL" + ], + "appRegistrationUrl": "https://developer.squareup.com/apps", + "envVarMeta": { + "SQUARE_ACCESS_TOKEN": { + "label": "Access token", + "kind": "credential", + "secret": true, + "help": "developer.squareup.com/apps → your application → Credentials → Sandbox or Production → Access token (Show, then copy).", + "link": "https://developer.squareup.com/apps", + "pattern": "^\\S+$", + "patternMessage": "Paste only the token, without the word Bearer and without spaces." + }, + "SQUARE_API_URL": { + "label": "Environment", + "kind": "address", + "help": "https://connect.squareupsandbox.com for a sandbox token, https://connect.squareup.com for a production token.", + "example": "https://connect.squareupsandbox.com", + "link": "https://developer.squareup.com/docs/devtools/sandbox/overview", + "pattern": "^https://connect\\.(squareup|squareupsandbox)\\.com$", + "patternMessage": "Use https://connect.squareup.com or https://connect.squareupsandbox.com, without a trailing slash." + } + }, + "verifyHints": { + "401": "Square refused the access token. Check that it is the token of the same environment as SQUARE_API_URL (sandbox token with the sandbox URL) and that it was not replaced in the developer console.", + "403": "The token lacks a permission for this call. A personal access token from the Credentials page has full access; an OAuth token needs the scope named in the error.", + "rate_limited": "Square is rate limiting this account. Wait a moment and try again." + }, + "probe": { + "tool": "square_get_merchant" + }, + "prerequisites": "A Square account and a free application on developer.squareup.com, whose Credentials page gives a sandbox and a production access token. About 5 minutes.", + "connector": { + "name": "Square API", + "type": "REST", + "baseUrl": "{{SQUARE_API_URL}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SQUARE_ACCESS_TOKEN}}" + }, + "headers": { + "Square-Version": "2026-09-16", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/v2/merchants/me" + }, + "tools": [ + { + "name": "square_get_merchant", + "description": "Read the seller account behind the token: business name, country, currency, language, status and main_location_id. Call it first to confirm which business the connector reaches.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/merchants/me" + } + }, + { + "name": "square_list_locations", + "description": "List the business locations: id, name, address, timezone, currency, status and capabilities. Most other tools need a location id from here.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/locations" + } + }, + { + "name": "square_get_location", + "description": "Read one location by id, or the main location with \"main\": address, hours, currency, capabilities.", + "parameters": { + "type": "object", + "properties": { + "location_id": { + "type": "string", + "description": "Location id, or \"main\"." + } + }, + "required": [ + "location_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/locations/{location_id}", + "encodePathParams": true + } + }, + { + "name": "square_list_payments", + "description": "List payments (newest first by default) with amount, status, card details, order and customer ids, refunds and receipt URL. Without location_id only the main location is listed; without dates the last year.", + "parameters": { + "type": "object", + "properties": { + "begin_time": { + "type": "string", + "description": "From, RFC 3339 (default one year ago)." + }, + "end_time": { + "type": "string", + "description": "Until, RFC 3339 (default now)." + }, + "sort_order": { + "type": "string", + "description": "DESC (default, newest first) or ASC.", + "enum": [ + "ASC", + "DESC" + ] + }, + "location_id": { + "type": "string", + "description": "Location id (default: the main location)." + }, + "total": { + "type": "integer", + "description": "Only payments with exactly this total, in the smallest currency unit." + }, + "last_4": { + "type": "string", + "description": "Last 4 digits of the card." + }, + "card_brand": { + "type": "string", + "description": "Card brand, e.g. VISA, MASTERCARD." + }, + "limit": { + "type": "integer", + "description": "Payments per page, 1 to 100 (default 100).", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/payments", + "queryParams": { + "begin_time": "$begin_time", + "end_time": "$end_time", + "sort_order": "$sort_order", + "location_id": "$location_id", + "total": "$total", + "last_4": "$last_4", + "card_brand": "$card_brand", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "square_get_payment", + "description": "Read one payment: amounts (total, tip, app fee, refunded), status (APPROVED, PENDING, COMPLETED, CANCELED, FAILED), source and card details, order, customer and receipt.", + "parameters": { + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "The payment id." + } + }, + "required": [ + "payment_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/payments/{payment_id}", + "encodePathParams": true + } + }, + { + "name": "square_list_refunds", + "description": "List refunds (newest first by default) across all locations unless one is given: amount, status, reason, payment id.", + "parameters": { + "type": "object", + "properties": { + "begin_time": { + "type": "string", + "description": "From, RFC 3339 (default one year ago)." + }, + "end_time": { + "type": "string", + "description": "Until, RFC 3339." + }, + "sort_order": { + "type": "string", + "description": "DESC (default) or ASC.", + "enum": [ + "ASC", + "DESC" + ] + }, + "location_id": { + "type": "string", + "description": "Only this location." + }, + "status": { + "type": "string", + "description": "PENDING, COMPLETED, REJECTED or FAILED.", + "enum": [ + "PENDING", + "COMPLETED", + "REJECTED", + "FAILED" + ] + }, + "source_type": { + "type": "string", + "description": "CARD, BANK_ACCOUNT, WALLET, CASH or EXTERNAL." + }, + "limit": { + "type": "integer", + "description": "Refunds per page, 1 to 100.", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/refunds", + "queryParams": { + "begin_time": "$begin_time", + "end_time": "$end_time", + "sort_order": "$sort_order", + "location_id": "$location_id", + "status": "$status", + "source_type": "$source_type", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "square_get_refund", + "description": "Read one refund: amount, status, reason, payment and order ids.", + "parameters": { + "type": "object", + "properties": { + "refund_id": { + "type": "string", + "description": "The refund id." + } + }, + "required": [ + "refund_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/refunds/{refund_id}", + "encodePathParams": true + } + }, + { + "name": "square_refund_payment", + "description": "Refund a payment in full or in part. This sends money back to the customer and cannot be undone: confirm the payment, amount and reason with the user first. Returns the refund with status PENDING or COMPLETED.", + "parameters": { + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "The payment to refund." + }, + "amount_money": { + "type": "object", + "description": "Money in the smallest currency unit, e.g. {\"amount\": 1999, \"currency\": \"EUR\"} for 19.99 EUR. At most what is left of the payment." + }, + "reason": { + "type": "string", + "description": "Reason, up to 192 characters." + }, + "idempotency_key": { + "type": "string", + "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "payment_id", + "amount_money", + "idempotency_key" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/refunds", + "bodyMapping": { + "idempotency_key": "$idempotency_key", + "payment_id": "$payment_id", + "amount_money": "$amount_money", + "reason": "$reason" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "square_search_orders", + "description": "Search orders of 1 to 10 locations by state, dates, customers or source, with line items, totals, tenders and fulfillments. Read-only, although it is a POST. Returns orders (or light order_entries) and a cursor.", + "parameters": { + "type": "object", + "properties": { + "location_ids": { + "type": "array", + "description": "1 to 10 location ids.", + "items": { + "type": "string" + } + }, + "query": { + "type": "object", + "description": "Optional filter and sort, e.g. {\"filter\": {\"state_filter\": {\"states\": [\"COMPLETED\"]}, \"date_time_filter\": {\"closed_at\": {\"start_at\": \"2026-10-01T00:00:00Z\", \"end_at\": \"2026-10-10T00:00:00Z\"}}, \"customer_filter\": {\"customer_ids\": [\"...\"]}}, \"sort\": {\"sort_field\": \"CLOSED_AT\", \"sort_order\": \"DESC\"}}. Sort by the same field you filter dates on." + }, + "limit": { + "type": "integer", + "description": "Orders per page, 1 to 1000 (default 500).", + "minimum": 1, + "maximum": 1000 + }, + "return_entries": { + "type": "boolean", + "description": "true to return only order ids, versions and locations (order_entries)." + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + }, + "required": [ + "location_ids" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/orders/search", + "bodyMapping": { + "location_ids": "$location_ids", + "query": "$query", + "limit": "$limit", + "return_entries": "$return_entries", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_get_order", + "description": "Read one order: state, line items with modifiers, taxes and discounts, totals, tenders (payments), refunds and fulfillments.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "The order id." + } + }, + "required": [ + "order_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/orders/{order_id}", + "encodePathParams": true + } + }, + { + "name": "square_create_order", + "description": "Create an OPEN order, for example as the basis of an invoice. Nothing is charged. Returns the order with its id, version and computed totals.", + "parameters": { + "type": "object", + "properties": { + "location_id": { + "type": "string", + "description": "The location id." + }, + "line_items": { + "type": "array", + "description": "Line items: ad hoc {\"name\": \"Repair\", \"quantity\": \"1\", \"base_price_money\": {\"amount\": 4500, \"currency\": \"EUR\"}} or from the catalog {\"catalog_object_id\": \"\", \"quantity\": \"2\"}. quantity is a string.", + "items": { + "type": "object" + } + }, + "customer_id": { + "type": "string", + "description": "Optional customer id." + }, + "reference_id": { + "type": "string", + "description": "Optional own reference, e.g. a job number." + }, + "idempotency_key": { + "type": "string", + "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "location_id", + "line_items", + "idempotency_key" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/orders", + "bodyMapping": { + "order": { + "location_id": "$location_id", + "line_items": "$line_items", + "customer_id": "$customer_id", + "reference_id": "$reference_id" + }, + "idempotency_key": "$idempotency_key" + } + } + }, + { + "name": "square_list_customers", + "description": "List customer profiles: id, names, company, email, phone, address, reference id, version.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Customers per page, 1 to 100.", + "minimum": 1, + "maximum": 100 + }, + "sort_field": { + "type": "string", + "description": "DEFAULT (by name) or CREATED_AT.", + "enum": [ + "DEFAULT", + "CREATED_AT" + ] + }, + "sort_order": { + "type": "string", + "description": "ASC (default) or DESC.", + "enum": [ + "ASC", + "DESC" + ] + }, + "count": { + "type": "boolean", + "description": "true to also return the total count." + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/customers", + "queryParams": { + "limit": "$limit", + "sort_field": "$sort_field", + "sort_order": "$sort_order", + "count": "$count", + "cursor": "$cursor" + } + } + }, + { + "name": "square_search_customers", + "description": "Search customer profiles by email, phone, reference id, creation or update time, or group. Read-only, although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Filter and sort, e.g. {\"filter\": {\"email_address\": {\"fuzzy\": \"acme\"}}} or {\"filter\": {\"phone_number\": {\"exact\": \"+4976112345\"}}} or {\"filter\": {\"created_at\": {\"start_at\": \"2026-09-01T00:00:00Z\"}}, \"sort\": {\"field\": \"CREATED_AT\", \"order\": \"DESC\"}}. email_address, phone_number and reference_id take exact or fuzzy, not both." + }, + "limit": { + "type": "integer", + "description": "Customers per page, 1 to 100.", + "minimum": 1, + "maximum": 100 + }, + "count": { + "type": "boolean", + "description": "true to also return the number of matches." + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/customers/search", + "bodyMapping": { + "query": "$query", + "limit": "$limit", + "count": "$count", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_get_customer", + "description": "Read one customer profile with its current version, groups and preferences.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + "description": "The customer id." + } + }, + "required": [ + "customer_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/customers/{customer_id}", + "encodePathParams": true + } + }, + { + "name": "square_create_customer", + "description": "Create a customer profile. At least one of given_name, family_name, company_name, email_address or phone_number is required. Returns the customer with its id.", + "parameters": { + "type": "object", + "properties": { + "given_name": { + "type": "string", + "description": "First name." + }, + "family_name": { + "type": "string", + "description": "Last name." + }, + "company_name": { + "type": "string", + "description": "Company name." + }, + "email_address": { + "type": "string", + "description": "Email address." + }, + "phone_number": { + "type": "string", + "description": "Phone number, e.g. \"+4976112345\"." + }, + "address": { + "type": "object", + "description": "Address, e.g. {\"address_line_1\": \"Hauptstr. 1\", \"locality\": \"Freiburg\", \"postal_code\": \"79098\", \"country\": \"DE\"}." + }, + "reference_id": { + "type": "string", + "description": "Your own id for this customer." + }, + "note": { + "type": "string", + "description": "A note about the customer." + }, + "idempotency_key": { + "type": "string", + "description": "Optional unique key, e.g. a UUID, so a retry does not create a duplicate." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/customers", + "bodyMapping": { + "given_name": "$given_name", + "family_name": "$family_name", + "company_name": "$company_name", + "email_address": "$email_address", + "phone_number": "$phone_number", + "address": "$address", + "reference_id": "$reference_id", + "note": "$note", + "idempotency_key": "$idempotency_key" + } + } + }, + { + "name": "square_update_customer", + "description": "Update a customer profile. Only the fields you send change; send null to clear one. Pass the version from square_get_customer to refuse overwriting a newer change.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + "description": "The customer id." + }, + "given_name": { + "type": "string", + "description": "First name." + }, + "family_name": { + "type": "string", + "description": "Last name." + }, + "company_name": { + "type": "string", + "description": "Company name." + }, + "email_address": { + "type": "string", + "description": "Email address." + }, + "phone_number": { + "type": "string", + "description": "Phone number." + }, + "address": { + "type": "object", + "description": "Address object." + }, + "reference_id": { + "type": "string", + "description": "Your own id." + }, + "note": { + "type": "string", + "description": "Note." + }, + "version": { + "type": "integer", + "description": "The current version of the profile." + } + }, + "required": [ + "customer_id" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v2/customers/{customer_id}", + "encodePathParams": true, + "bodyMapping": { + "given_name": "$given_name", + "family_name": "$family_name", + "company_name": "$company_name", + "email_address": "$email_address", + "phone_number": "$phone_number", + "address": "$address", + "reference_id": "$reference_id", + "note": "$note", + "version": "$version" + } + } + }, + { + "name": "square_delete_customer", + "description": "Delete a customer profile for good. Confirm with the user first.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + "description": "The customer id." + }, + "version": { + "type": "integer", + "description": "Optional current version; the delete fails if the profile changed since." + } + }, + "required": [ + "customer_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/v2/customers/{customer_id}", + "encodePathParams": true, + "queryParams": { + "version": "$version" + } + } + }, + { + "name": "square_list_catalog", + "description": "List catalog objects of the given types (100 per page): items with variations and prices, categories, taxes, discounts, modifier lists.", + "parameters": { + "type": "object", + "properties": { + "types": { + "type": "string", + "description": "Comma-separated types, e.g. \"ITEM\" or \"ITEM,CATEGORY,TAX\". Others: ITEM_VARIATION, DISCOUNT, MODIFIER_LIST, IMAGE.", + "default": "ITEM" + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/catalog/list", + "queryParams": { + "types": "$types", + "cursor": "$cursor" + } + } + }, + { + "name": "square_search_catalog_items", + "description": "Search items by text (name, description, SKU, UPC), category, location, stock level or product type. Read-only, although it is a POST. Returns items and matched_variation_ids.", + "parameters": { + "type": "object", + "properties": { + "text_filter": { + "type": "string", + "description": "Text to look for, e.g. \"espresso\"." + }, + "category_ids": { + "type": "array", + "description": "Only these category ids.", + "items": { + "type": "string" + } + }, + "enabled_location_ids": { + "type": "array", + "description": "Only items enabled at these locations.", + "items": { + "type": "string" + } + }, + "stock_levels": { + "type": "array", + "description": "OUT or LOW.", + "items": { + "type": "string" + } + }, + "product_types": { + "type": "array", + "description": "e.g. [\"REGULAR\", \"APPOINTMENTS_SERVICE\"].", + "items": { + "type": "string" + } + }, + "archived_state": { + "type": "string", + "description": "ARCHIVED_STATE_NOT_ARCHIVED (default), ARCHIVED_STATE_ARCHIVED or ARCHIVED_STATE_ALL." + }, + "sort_order": { + "type": "string", + "description": "ASC or DESC by name.", + "enum": [ + "ASC", + "DESC" + ] + }, + "limit": { + "type": "integer", + "description": "Items per page, 1 to 100.", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/catalog/search-catalog-items", + "bodyMapping": { + "text_filter": "$text_filter", + "category_ids": "$category_ids", + "enabled_location_ids": "$enabled_location_ids", + "stock_levels": "$stock_levels", + "product_types": "$product_types", + "archived_state": "$archived_state", + "sort_order": "$sort_order", + "limit": "$limit", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_search_catalog_objects", + "description": "Search any catalog objects (items, variations, categories, taxes, discounts...) by keywords, exact or prefix attribute values, or change time. Read-only, although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "object_types": { + "type": "array", + "description": "Types to search, e.g. [\"ITEM_VARIATION\"]. All top-level types when omitted.", + "items": { + "type": "string" + } + }, + "query": { + "type": "object", + "description": "e.g. {\"text_query\": {\"keywords\": [\"latte\"]}} (1 to 3 keywords of 3+ characters, all must match), {\"exact_query\": {\"attribute_name\": \"sku\", \"attribute_value\": \"LAT-12\"}} or {\"prefix_query\": {\"attribute_name\": \"name\", \"attribute_prefix\": \"Lat\"}}." + }, + "include_related_objects": { + "type": "boolean", + "description": "true to also return referenced objects (categories, taxes, images)." + }, + "include_deleted_objects": { + "type": "boolean", + "description": "true to include deleted objects." + }, + "begin_time": { + "type": "string", + "description": "Only objects changed after this RFC 3339 time." + }, + "limit": { + "type": "integer", + "description": "Objects per page, up to 1000.", + "minimum": 1, + "maximum": 1000 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/catalog/search", + "bodyMapping": { + "object_types": "$object_types", + "query": "$query", + "include_related_objects": "$include_related_objects", + "include_deleted_objects": "$include_deleted_objects", + "begin_time": "$begin_time", + "limit": "$limit", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_get_catalog_object", + "description": "Read one catalog object by id with its version (needed to update it), optionally with the objects it references.", + "parameters": { + "type": "object", + "properties": { + "object_id": { + "type": "string", + "description": "The catalog object id." + }, + "include_related_objects": { + "type": "boolean", + "description": "true to also return categories, taxes, modifiers and images it references." + } + }, + "required": [ + "object_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/catalog/object/{object_id}", + "encodePathParams": true, + "queryParams": { + "include_related_objects": "$include_related_objects" + } + } + }, + { + "name": "square_upsert_catalog_object", + "description": "Create a catalog object, or REPLACE an existing one as a whole: fields left out (variations included) are removed, so for an update send the full object from square_get_catalog_object with its version. Confirm changes to existing items with the user first. Returns catalog_object and id_mappings (temporary #ids to real ids).", + "parameters": { + "type": "object", + "properties": { + "object": { + "type": "object", + "description": "The object. New item: {\"type\": \"ITEM\", \"id\": \"#coffee\", \"item_data\": {\"name\": \"Coffee\", \"variations\": [{\"type\": \"ITEM_VARIATION\", \"id\": \"#coffee-regular\", \"item_variation_data\": {\"item_id\": \"#coffee\", \"name\": \"Regular\", \"pricing_type\": \"FIXED_PRICING\", \"price_money\": {\"amount\": 250, \"currency\": \"EUR\"}}}]}}. Existing objects keep their id and must carry their current version." + }, + "idempotency_key": { + "type": "string", + "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "object", + "idempotency_key" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/catalog/object", + "bodyMapping": { + "idempotency_key": "$idempotency_key", + "object": "$object" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "square_delete_catalog_object", + "description": "Delete a catalog object for good; deleting an item also deletes its variations. Confirm with the user first. Returns deleted_object_ids.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "object_id": { + "type": "string", + "description": "The catalog object id." + } + }, + "required": [ + "object_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/v2/catalog/object/{object_id}", + "encodePathParams": true + } + }, + { + "name": "square_get_inventory_counts", + "description": "Read inventory counts for many item variations and locations at once: quantity (a string) per state such as IN_STOCK, newest first. Read-only, although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "catalog_object_ids": { + "type": "array", + "description": "Item variation ids (not item ids).", + "items": { + "type": "string" + } + }, + "location_ids": { + "type": "array", + "description": "Location ids; all when omitted.", + "items": { + "type": "string" + } + }, + "updated_after": { + "type": "string", + "description": "Only counts changed after this RFC 3339 time." + }, + "states": { + "type": "array", + "description": "States, e.g. [\"IN_STOCK\"]; default IN_STOCK.", + "items": { + "type": "string" + } + }, + "limit": { + "type": "integer", + "description": "Counts per page, 1 to 1000.", + "minimum": 1, + "maximum": 1000 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/inventory/counts/batch-retrieve", + "bodyMapping": { + "catalog_object_ids": "$catalog_object_ids", + "location_ids": "$location_ids", + "updated_after": "$updated_after", + "states": "$states", + "limit": "$limit", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_get_inventory_count", + "description": "Read the inventory count of one item variation, per location and state.", + "parameters": { + "type": "object", + "properties": { + "catalog_object_id": { + "type": "string", + "description": "The item variation id." + }, + "location_ids": { + "type": "string", + "description": "Comma-separated location ids; all when omitted." + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + }, + "required": [ + "catalog_object_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/inventory/{catalog_object_id}", + "encodePathParams": true, + "queryParams": { + "location_ids": "$location_ids", + "cursor": "$cursor" + } + } + }, + { + "name": "square_list_invoices", + "description": "List the invoices of one location: id, number, status (DRAFT, UNPAID, SCHEDULED, PARTIALLY_PAID, PAID, CANCELED, ...), recipient, payment requests with due dates and amounts, public_url.", + "parameters": { + "type": "object", + "properties": { + "location_id": { + "type": "string", + "description": "The location id (required)." + }, + "limit": { + "type": "integer", + "description": "Invoices per page, 1 to 200.", + "minimum": 1, + "maximum": 200 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + }, + "required": [ + "location_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/invoices", + "queryParams": { + "location_id": "$location_id", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "square_search_invoices", + "description": "Search the invoices of one location, optionally of one customer, sorted by invoice date. Read-only, although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "location_id": { + "type": "string", + "description": "The location id." + }, + "customer_ids": { + "type": "array", + "description": "Only invoices of this customer: a list with at most one customer id.", + "items": { + "type": "string" + }, + "maxItems": 1 + }, + "sort_order": { + "type": "string", + "description": "DESC (newest first) or ASC.", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + }, + "limit": { + "type": "integer", + "description": "Invoices per page, 1 to 200.", + "minimum": 1, + "maximum": 200 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + }, + "required": [ + "location_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoices/search", + "bodyMapping": { + "query": { + "filter": { + "location_ids": [ + "$location_id" + ], + "customer_ids": "$customer_ids" + }, + "sort": { + "field": "INVOICE_SORT_DATE", + "order": "$sort_order" + } + }, + "limit": "$limit", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_get_invoice", + "description": "Read one invoice: status, version (needed to publish or cancel), order, recipient, payment requests with computed amounts, public_url and timestamps.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The invoice id." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/invoices/{invoice_id}", + "encodePathParams": true + } + }, + { + "name": "square_create_invoice", + "description": "Create a DRAFT invoice for an OPEN order (make one with square_create_order). Nothing is sent until square_publish_invoice. Returns the invoice with id and version.", + "parameters": { + "type": "object", + "properties": { + "location_id": { + "type": "string", + "description": "The order's location id." + }, + "order_id": { + "type": "string", + "description": "An OPEN order at that location." + }, + "customer_id": { + "type": "string", + "description": "The customer to bill (needs an email for EMAIL delivery)." + }, + "payment_requests": { + "type": "array", + "description": "1 to 13 payment requests, usually [{\"request_type\": \"BALANCE\", \"due_date\": \"2026-11-01\"}]; with reminders: \"reminders\": [{\"relative_scheduled_days\": -1, \"message\": \"Due tomorrow\"}].", + "items": { + "type": "object" + } + }, + "accepted_payment_methods": { + "type": "object", + "description": "At least one true, e.g. {\"card\": true, \"bank_account\": false, \"square_gift_card\": false}.", + "default": { + "card": true + } + }, + "delivery_method": { + "type": "string", + "description": "EMAIL (Square emails the invoice) or SHARE_MANUALLY (you share public_url).", + "enum": [ + "EMAIL", + "SHARE_MANUALLY" + ], + "default": "EMAIL" + }, + "title": { + "type": "string", + "description": "Title shown on the invoice." + }, + "description": { + "type": "string", + "description": "Description or terms." + }, + "invoice_number": { + "type": "string", + "description": "Own invoice number; generated when omitted." + }, + "sale_or_service_date": { + "type": "string", + "description": "Date of the sale or service, YYYY-MM-DD." + }, + "scheduled_at": { + "type": "string", + "description": "Optional RFC 3339 time to send it later." + }, + "idempotency_key": { + "type": "string", + "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "location_id", + "order_id", + "customer_id", + "payment_requests", + "idempotency_key" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoices", + "bodyMapping": { + "invoice": { + "location_id": "$location_id", + "order_id": "$order_id", + "primary_recipient": { + "customer_id": "$customer_id" + }, + "payment_requests": "$payment_requests", + "accepted_payment_methods": "$accepted_payment_methods", + "delivery_method": "$delivery_method", + "title": "$title", + "description": "$description", + "invoice_number": "$invoice_number", + "sale_or_service_date": "$sale_or_service_date", + "scheduled_at": "$scheduled_at" + }, + "idempotency_key": "$idempotency_key" + } + } + }, + { + "name": "square_publish_invoice", + "description": "Publish a draft invoice: Square sends it to the customer (EMAIL delivery) or makes it payable at public_url, and it can no longer be edited freely. Confirm with the user first. Needs the invoice's current version.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The draft invoice id." + }, + "version": { + "type": "integer", + "description": "The invoice's current version (from square_get_invoice)." + }, + "idempotency_key": { + "type": "string", + "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice." + } + }, + "required": [ + "invoice_id", + "version" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoices/{invoice_id}/publish", + "encodePathParams": true, + "bodyMapping": { + "version": "$version", + "idempotency_key": "$idempotency_key" + } + } + }, + { + "name": "square_cancel_invoice", + "description": "Cancel a published invoice so it can no longer be paid; the customer is notified. Not possible for drafts (delete those) or paid invoices. Cannot be undone: confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The invoice id." + }, + "version": { + "type": "integer", + "description": "The invoice's current version." + } + }, + "required": [ + "invoice_id", + "version" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/invoices/{invoice_id}/cancel", + "encodePathParams": true, + "bodyMapping": { + "version": "$version" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "square_delete_invoice", + "description": "Delete a DRAFT invoice; its order is canceled too. Confirm with the user first.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "The draft invoice id." + }, + "version": { + "type": "integer", + "description": "Optional current version." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/v2/invoices/{invoice_id}", + "encodePathParams": true, + "queryParams": { + "version": "$version" + } + } + }, + { + "name": "square_search_team_members", + "description": "Search team members by location, status or owner flag: id, names, email, phone, status, is_owner and assigned locations. Read-only, although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "location_ids": { + "type": "array", + "description": "Members assigned to any of these locations.", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "description": "ACTIVE or INACTIVE.", + "enum": [ + "ACTIVE", + "INACTIVE" + ] + }, + "is_owner": { + "type": "boolean", + "description": "true for the account owner only." + }, + "limit": { + "type": "integer", + "description": "Members per page, 1 to 200.", + "minimum": 1, + "maximum": 200 + }, + "cursor": { + "type": "string", + "description": "cursor from the previous response, to fetch the next page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/team-members/search", + "bodyMapping": { + "query": { + "filter": { + "location_ids": "$location_ids", + "status": "$status", + "is_owner": "$is_owner" + } + }, + "limit": "$limit", + "cursor": "$cursor" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "square_get_team_member", + "description": "Read one team member: names, email, phone, status, owner flag and location assignments.", + "parameters": { + "type": "object", + "properties": { + "team_member_id": { + "type": "string", + "description": "The team member id." + } + }, + "required": [ + "team_member_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/team-members/{team_member_id}", + "encodePathParams": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/square.live.spec.ts b/packages/backend/src/adapters/intl/square.live.spec.ts new file mode 100644 index 000000000..8a84494d5 --- /dev/null +++ b/packages/backend/src/adapters/intl/square.live.spec.ts @@ -0,0 +1,489 @@ +import axios from 'axios'; +import { randomUUID } from 'node:crypto'; +import * as adapter from './square.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 Square adapter: + * + * 1. Static: always runs. Pins the bearer token, the Square-Version header, + * the environment variable patterns, every tool's method and URL, the + * bodies of searches, refunds, orders and invoices, and which tools are + * read-only, destructive or switched off. + * + * 2. Live: skipped unless SQUARE_ACCESS_TOKEN is set. Uses the sandbox + * unless SQUARE_API_URL says otherwise (the token must match): + * + * SQUARE_ACCESS_TOKEN=EAAA... npx jest src/adapters/intl/square.live.spec.ts + * + * Reads the merchant, locations, payments, refunds, orders, customers, + * catalog, inventory, invoices and team members. + * + * SQUARE_LIVE_WRITE=1 (sandbox only) adds three round-trips, each + * cleaned up in `finally`: a customer created, read, updated, found and + * deleted; a catalog item created, read, searched, its inventory read + * and deleted; an order and a draft invoice for the test customer that + * is published and cancelled (deleted if publishing failed). A sandbox + * payment of 1.00 made with Square's test card nonce is read and + * refunded by 0.50. + */ + +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; + headers?: Record; + queryParams?: Record; + bodyMapping?: Record; + }; + annotations?: Record; +}; +const a = adapter as unknown as { + slug: string; + unlisted?: boolean; + instructions: string; + prerequisites: string; + requiredEnvVars: string[]; + envVarMeta: Record; + probe: { tool: string }; + connector: { + baseUrl: string; + authType: string; + authConfig: Record; + headers: Record; + healthcheckPath: string; + }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +const annotationsOf = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + +const SANDBOX = 'https://connect.squareupsandbox.com'; +const execute = (name: string, env: Record, params: Record) => { + const { config, endpointMapping } = interpolateConnectorConfig( + { baseUrl: a.connector.baseUrl, headers: a.connector.headers }, + tool(name).endpointMapping, + env, + ); + return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute( + { + baseUrl: config.baseUrl, + authType: a.connector.authType, + authConfig: { token: env.SQUARE_ACCESS_TOKEN }, + headers: config.headers, + }, + endpointMapping, + applySchemaDefaults(tool(name).parameters, params), + ) as Promise; +}; +const call = (name: string, params: Record) => + execute(name, { SQUARE_API_URL: SANDBOX, SQUARE_ACCESS_TOKEN: 'test-token' }, params); +const sent = () => mockedAxios.mock.calls[0][0]; + +describe('square adapter: static spec conformance', () => { + beforeEach(() => mockedAxios.mockReset()); + + it('is listed (verified live on 11 Oct 2026)', () => { + expect(a.unlisted).toBeUndefined(); + expect(a.prerequisites).toMatch(/sandbox/); + }); + + it('sends the access token as Bearer and pins a Square-Version', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{SQUARE_ACCESS_TOKEN}}' }); + expect(a.connector.baseUrl).toBe('{{SQUARE_API_URL}}'); + expect(a.connector.headers['Square-Version']).toMatch(/^20\d\d-\d\d-\d\d$/); + expect(a.connector.headers['Square-Version']).toBe('2026-09-16'); + expect(a.instructions).toContain(a.connector.headers['Square-Version']); + }); + + it('validates the environment URL and marks the token as secret', () => { + expect(a.requiredEnvVars).toEqual(['SQUARE_ACCESS_TOKEN', 'SQUARE_API_URL']); + const env = new RegExp(a.envVarMeta.SQUARE_API_URL.pattern!); + expect(env.test(SANDBOX)).toBe(true); + expect(env.test('https://connect.squareup.com')).toBe(true); + expect(env.test(`${SANDBOX}/`)).toBe(false); + expect(env.test('https://connect.squareup.com.evil.example')).toBe(false); + const described = describeAdapterEnvVars(a as never); + expect(described.find((d) => d.name === 'SQUARE_API_URL')!.kind).toBe('address'); + expect(described.find((d) => d.name === 'SQUARE_ACCESS_TOKEN')!.secret).toBe(true); + }); + + it('probes and health-checks with the merchant behind the token', () => { + expect(a.probe.tool).toBe('square_get_merchant'); + expect(tool('square_get_merchant').parameters.required).toBeUndefined(); + expect(a.connector.healthcheckPath).toBe('/v2/merchants/me'); + }); + + it('prefixes every tool with square_ 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(/^square_[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, including the POST searches and the inventory batch read', () => { + const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name); + expect(readOnly.sort()).toEqual([ + 'square_get_catalog_object', + 'square_get_customer', + 'square_get_inventory_count', + 'square_get_inventory_counts', + 'square_get_invoice', + 'square_get_location', + 'square_get_merchant', + 'square_get_order', + 'square_get_payment', + 'square_get_refund', + 'square_get_team_member', + 'square_list_catalog', + 'square_list_customers', + 'square_list_invoices', + 'square_list_locations', + 'square_list_payments', + 'square_list_refunds', + 'square_search_catalog_items', + 'square_search_catalog_objects', + 'square_search_customers', + 'square_search_invoices', + 'square_search_orders', + 'square_search_team_members', + ]); + }); + + it('marks refunds, cancellations and catalog replacement destructive, and installs deletes switched off', () => { + for (const name of ['square_refund_payment', 'square_cancel_invoice', 'square_upsert_catalog_object']) { + const ann = annotationsOf(tool(name)); + expect(`${name}:${ann.readOnlyHint}:${ann.destructiveHint}:${ann.idempotentHint}`).toBe(`${name}:false:true:false`); + expect(tool(name).description).toMatch(/confirm/i); + } + expect(tool('square_publish_invoice').description).toMatch(/confirm/i); + expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual([ + 'square_delete_catalog_object', + 'square_delete_customer', + 'square_delete_invoice', + ]); + }); + + 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(/\bsquare_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bsquare_[a-z_]+/g)]), + ].map((m) => m[0]); + expect(mentioned.length).toBeGreaterThan(10); + for (const name of mentioned) expect(names).toContain(name); + expect(JSON.stringify(adapter)).not.toMatch(/[–—]/); + }); + + const table: Array<[string, Record, string, string]> = [ + ['square_get_merchant', {}, 'GET', '/v2/merchants/me'], + ['square_list_locations', {}, 'GET', '/v2/locations'], + ['square_get_location', { location_id: 'main' }, 'GET', '/v2/locations/main'], + ['square_list_payments', {}, 'GET', '/v2/payments'], + ['square_get_payment', { payment_id: 'P1' }, 'GET', '/v2/payments/P1'], + ['square_list_refunds', {}, 'GET', '/v2/refunds'], + ['square_get_refund', { refund_id: 'R1' }, 'GET', '/v2/refunds/R1'], + ['square_refund_payment', { payment_id: 'P1', amount_money: { amount: 100, currency: 'EUR' }, idempotency_key: 'k' }, 'POST', '/v2/refunds'], + ['square_search_orders', { location_ids: ['L1'] }, 'POST', '/v2/orders/search'], + ['square_get_order', { order_id: 'O1' }, 'GET', '/v2/orders/O1'], + ['square_create_order', { location_id: 'L1', line_items: [], idempotency_key: 'k' }, 'POST', '/v2/orders'], + ['square_list_customers', {}, 'GET', '/v2/customers'], + ['square_search_customers', {}, 'POST', '/v2/customers/search'], + ['square_get_customer', { customer_id: 'C1' }, 'GET', '/v2/customers/C1'], + ['square_create_customer', { given_name: 'Ana' }, 'POST', '/v2/customers'], + ['square_update_customer', { customer_id: 'C1', note: 'x' }, 'PUT', '/v2/customers/C1'], + ['square_delete_customer', { customer_id: 'C1' }, 'DELETE', '/v2/customers/C1'], + ['square_list_catalog', {}, 'GET', '/v2/catalog/list'], + ['square_search_catalog_items', {}, 'POST', '/v2/catalog/search-catalog-items'], + ['square_search_catalog_objects', {}, 'POST', '/v2/catalog/search'], + ['square_get_catalog_object', { object_id: 'OBJ1' }, 'GET', '/v2/catalog/object/OBJ1'], + ['square_upsert_catalog_object', { object: {}, idempotency_key: 'k' }, 'POST', '/v2/catalog/object'], + ['square_delete_catalog_object', { object_id: 'OBJ1' }, 'DELETE', '/v2/catalog/object/OBJ1'], + ['square_get_inventory_counts', {}, 'POST', '/v2/inventory/counts/batch-retrieve'], + ['square_get_inventory_count', { catalog_object_id: 'V1' }, 'GET', '/v2/inventory/V1'], + ['square_list_invoices', { location_id: 'L1' }, 'GET', '/v2/invoices'], + ['square_search_invoices', { location_id: 'L1' }, 'POST', '/v2/invoices/search'], + ['square_get_invoice', { invoice_id: 'inv:1' }, 'GET', '/v2/invoices/inv%3A1'], + ['square_create_invoice', { location_id: 'L1', order_id: 'O1', customer_id: 'C1', payment_requests: [], idempotency_key: 'k' }, 'POST', '/v2/invoices'], + ['square_publish_invoice', { invoice_id: 'inv:1', version: 0 }, 'POST', '/v2/invoices/inv%3A1/publish'], + ['square_cancel_invoice', { invoice_id: 'inv:1', version: 1 }, 'POST', '/v2/invoices/inv%3A1/cancel'], + ['square_delete_invoice', { invoice_id: 'inv:1' }, 'DELETE', '/v2/invoices/inv%3A1'], + ['square_search_team_members', {}, 'POST', '/v2/team-members/search'], + ['square_get_team_member', { team_member_id: 'T1' }, 'GET', '/v2/team-members/T1'], + ]; + + 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 the right URL', async (name, params, method, path) => { + mockedAxios.mockResolvedValue({ data: {} }); + await call(name, params); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method, + url: `${SANDBOX}${path}`, + headers: expect.objectContaining({ Authorization: 'Bearer test-token', 'Square-Version': '2026-09-16' }), + }), + ); + }); + + it('refunds with the idempotency key, payment and amount', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('square_refund_payment', { payment_id: 'P1', amount_money: { amount: 50, currency: 'EUR' }, reason: 'Damaged', idempotency_key: 'k1' }); + expect(sent().data).toEqual({ idempotency_key: 'k1', payment_id: 'P1', amount_money: { amount: 50, currency: 'EUR' }, reason: 'Damaged' }); + }); + + it('wraps the order fields in an order object', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + const items = [{ name: 'Repair', quantity: '1', base_price_money: { amount: 4500, currency: 'EUR' } }]; + await call('square_create_order', { location_id: 'L1', line_items: items, customer_id: 'C1', idempotency_key: 'k2' }); + expect(sent().data).toEqual({ order: { location_id: 'L1', line_items: items, customer_id: 'C1' }, idempotency_key: 'k2' }); + }); + + it('builds the invoice with the recipient, card payments by default and EMAIL delivery', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + const requests = [{ request_type: 'BALANCE', due_date: '2026-11-01' }]; + await call('square_create_invoice', { location_id: 'L1', order_id: 'O1', customer_id: 'C1', payment_requests: requests, title: 'Repair', idempotency_key: 'k3' }); + expect(sent().data).toEqual({ + invoice: { + location_id: 'L1', + order_id: 'O1', + primary_recipient: { customer_id: 'C1' }, + payment_requests: requests, + accepted_payment_methods: { card: true }, + delivery_method: 'EMAIL', + title: 'Repair', + }, + idempotency_key: 'k3', + }); + }); + + it('searches invoices of one location and, when given, one customer', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('square_search_invoices', { location_id: 'L1' }); + expect(sent().data).toEqual({ query: { filter: { location_ids: ['L1'] }, sort: { field: 'INVOICE_SORT_DATE', order: 'DESC' } } }); + mockedAxios.mockClear(); + await call('square_search_invoices', { location_id: 'L1', customer_ids: ['C1'], limit: 10 }); + expect(sent().data).toEqual({ query: { filter: { location_ids: ['L1'], customer_ids: ['C1'] }, sort: { field: 'INVOICE_SORT_DATE', order: 'DESC' } }, limit: 10 }); + }); + + it('puts the team member filters in query.filter and lists ITEM by default', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('square_search_team_members', { status: 'ACTIVE', location_ids: ['L1'] }); + expect(sent().data).toEqual({ query: { filter: { location_ids: ['L1'], status: 'ACTIVE' } } }); + mockedAxios.mockClear(); + await call('square_list_catalog', {}); + expect(sent().params).toEqual({ types: 'ITEM' }); + }); + + it('publishes with the version and deletes a customer with an optional version in the query', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + await call('square_publish_invoice', { invoice_id: 'inv:1', version: 0, idempotency_key: 'k4' }); + expect(sent().data).toEqual({ version: 0, idempotency_key: 'k4' }); + mockedAxios.mockClear(); + await call('square_delete_customer', { customer_id: 'C1', version: 3 }); + expect(sent().params).toEqual({ version: 3 }); + expect(sent().data).toBeUndefined(); + }); +}); + +const TOKEN = process.env.SQUARE_ACCESS_TOKEN; +const live = TOKEN ? describe : describe.skip; + +live('square adapter: live API', () => { + const host = process.env.SQUARE_API_URL || SANDBOX; + const write = process.env.SQUARE_LIVE_WRITE === '1' && host === SANDBOX; + beforeAll(() => { + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + }); + const run = (name: string, params: Record = {}): Promise => + execute(name, { SQUARE_API_URL: host, SQUARE_ACCESS_TOKEN: TOKEN as string }, params); + let locationId = ''; + let currency = 'USD'; + + beforeAll(async () => { + const merchant = await run('square_get_merchant'); + locationId = merchant.merchant.main_location_id; + currency = merchant.merchant.currency; + }, 30_000); + + it('reads the merchant, locations, payments, refunds and orders', async () => { + const locations = await run('square_list_locations'); + expect(locations.locations.map((l: { id: string }) => l.id)).toContain(locationId); + const main = await run('square_get_location', { location_id: 'main' }); + expect(main.location.id).toBe(locationId); + const payments = await run('square_list_payments', { limit: 5, location_id: locationId }); + if (payments.payments?.length) { + const one = await run('square_get_payment', { payment_id: payments.payments[0].id }); + expect(one.payment.id).toBe(payments.payments[0].id); + } + const refunds = await run('square_list_refunds', { limit: 5 }); + if (refunds.refunds?.length) { + const one = await run('square_get_refund', { refund_id: refunds.refunds[0].id }); + expect(one.refund.id).toBe(refunds.refunds[0].id); + } + const orders = await run('square_search_orders', { location_ids: [locationId], limit: 5 }); + if (orders.orders?.length) { + const one = await run('square_get_order', { order_id: orders.orders[0].id }); + expect(one.order.id).toBe(orders.orders[0].id); + } + }, 60_000); + + it('reads customers, the catalog, inventory, invoices and team members', async () => { + const customers = await run('square_list_customers', { limit: 5, count: true }); + if (customers.customers?.length) { + const one = await run('square_get_customer', { customer_id: customers.customers[0].id }); + expect(one.customer.id).toBe(customers.customers[0].id); + } + await run('square_search_customers', { query: { filter: { created_at: { start_at: '2020-01-01T00:00:00Z' } } }, limit: 5 }); + const catalog = await run('square_list_catalog', { types: 'ITEM' }); + const items = await run('square_search_catalog_items', { limit: 5 }); + expect(items).toBeDefined(); + await run('square_search_catalog_objects', { object_types: ['ITEM'], limit: 5 }); + const firstItem = catalog.objects?.[0]; + if (firstItem) { + const one = await run('square_get_catalog_object', { object_id: firstItem.id, include_related_objects: true }); + expect(one.object.id).toBe(firstItem.id); + const variation = firstItem.item_data?.variations?.[0]; + if (variation) { + await run('square_get_inventory_counts', { catalog_object_ids: [variation.id], location_ids: [locationId] }); + await run('square_get_inventory_count', { catalog_object_id: variation.id, location_ids: locationId }); + } + } + const invoices = await run('square_list_invoices', { location_id: locationId, limit: 5 }); + if (invoices.invoices?.length) { + const one = await run('square_get_invoice', { invoice_id: invoices.invoices[0].id }); + expect(one.invoice.id).toBe(invoices.invoices[0].id); + } + await run('square_search_invoices', { location_id: locationId, limit: 5 }); + const team = await run('square_search_team_members', { status: 'ACTIVE', limit: 5 }); + if (team.team_members?.length) { + const one = await run('square_get_team_member', { team_member_id: team.team_members[0].id }); + expect(one.team_member.id).toBe(team.team_members[0].id); + } + }, 60_000); + + (write ? it : it.skip)('creates, reads, updates, finds and deletes a customer; invoices them and cancels it', async () => { + const created = await run('square_create_customer', { + given_name: 'AnythingMCP', + family_name: `Test ${Date.now()}`, + email_address: 'anythingmcp-test@example.com', + note: 'Created by the AnythingMCP live spec; safe to delete.', + idempotency_key: randomUUID(), + }); + const customer = created.customer; + try { + const read = await run('square_get_customer', { customer_id: customer.id }); + expect(read.customer.family_name).toBe(customer.family_name); + const updated = await run('square_update_customer', { customer_id: customer.id, note: 'Updated by the live spec', version: read.customer.version }); + expect(updated.customer.note).toBe('Updated by the live spec'); + await run('square_search_customers', { query: { filter: { email_address: { exact: 'anythingmcp-test@example.com' } } } }); + + const order = await run('square_create_order', { + location_id: locationId, + customer_id: customer.id, + line_items: [{ name: 'AnythingMCP test line', quantity: '1', base_price_money: { amount: 100, currency } }], + idempotency_key: randomUUID(), + }); + const due = new Date(Date.now() + 14 * 86400_000).toISOString().slice(0, 10); + const draft = await run('square_create_invoice', { + location_id: locationId, + order_id: order.order.id, + customer_id: customer.id, + payment_requests: [{ request_type: 'BALANCE', due_date: due }], + title: 'AnythingMCP live spec', + idempotency_key: randomUUID(), + }); + let published: { id: string; version: number } | undefined; + try { + expect(draft.invoice.status).toBe('DRAFT'); + const res = await run('square_publish_invoice', { invoice_id: draft.invoice.id, version: draft.invoice.version, idempotency_key: randomUUID() }); + published = res.invoice; + expect(res.invoice.status).toMatch(/UNPAID|SCHEDULED|PAYMENT_PENDING/); + } finally { + if (published) { + const cancelled = await run('square_cancel_invoice', { invoice_id: published.id, version: published.version }); + expect(cancelled.invoice.status).toBe('CANCELED'); + } else { + await run('square_delete_invoice', { invoice_id: draft.invoice.id, version: draft.invoice.version }); + } + } + } finally { + await run('square_delete_customer', { customer_id: customer.id }); + } + }, 120_000); + + (write ? it : it.skip)('creates a catalog item, reads and searches it, reads its inventory and deletes it', async () => { + const name = `AnythingMCP test ${Date.now()}`; + const created = await run('square_upsert_catalog_object', { + object: { + type: 'ITEM', + id: '#amcp-item', + item_data: { + name, + variations: [{ type: 'ITEM_VARIATION', id: '#amcp-var', item_variation_data: { item_id: '#amcp-item', name: 'Regular', pricing_type: 'FIXED_PRICING', price_money: { amount: 250, currency } } }], + }, + }, + idempotency_key: randomUUID(), + }); + const itemId = created.catalog_object.id; + try { + const read = await run('square_get_catalog_object', { object_id: itemId }); + expect(read.object.item_data.name).toBe(name); + const variationId = read.object.item_data.variations[0].id; + await run('square_get_inventory_count', { catalog_object_id: variationId }); + await run('square_search_catalog_objects', { object_types: ['ITEM'], query: { exact_query: { attribute_name: 'name', attribute_value: name } } }); + } finally { + const deleted = await run('square_delete_catalog_object', { object_id: itemId }); + expect(deleted.deleted_object_ids).toContain(itemId); + } + }, 120_000); + + (write ? it : it.skip)('refunds part of a sandbox test payment', async () => { + // Created directly: the connector has no tool that takes card payments. + const payment = await mockedAxios.__actual.post( + `${host}/v2/payments`, + { source_id: 'cnon:card-nonce-ok', idempotency_key: randomUUID(), amount_money: { amount: 100, currency }, location_id: locationId, note: 'AnythingMCP live spec' }, + { headers: { Authorization: `Bearer ${TOKEN}`, 'Square-Version': '2026-09-16', 'Content-Type': 'application/json' } }, + ); + const paymentId = payment.data.payment.id; + const read = await run('square_get_payment', { payment_id: paymentId }); + expect(read.payment.status).toBe('COMPLETED'); + const refund = await run('square_refund_payment', { payment_id: paymentId, amount_money: { amount: 50, currency }, reason: 'AnythingMCP live spec', idempotency_key: randomUUID() }); + expect(refund.refund.status).toMatch(/PENDING|COMPLETED/); + const again = await run('square_get_refund', { refund_id: refund.refund.id }); + expect(again.refund.payment_id).toBe(paymentId); + }, 120_000); +}); diff --git a/packages/backend/src/adapters/intl/supabase.json b/packages/backend/src/adapters/intl/supabase.json new file mode 100644 index 000000000..546b2b79a --- /dev/null +++ b/packages/backend/src/adapters/intl/supabase.json @@ -0,0 +1,746 @@ +{ + "slug": "supabase", + "name": "Supabase", + "description": "Let your AI look after your Supabase projects: list projects and check their health, explore schemas and tables, run read-only SQL, read logs, security and performance advisors, Edge Functions, migrations, branches and storage buckets. Through the Management API with a personal access token.", + "instructions": "## Supabase\nThis connector works with your Supabase projects through the **Management API** (`https://api.supabase.com`, v1): projects and their health, the database (read-only SQL, schemas, tables with columns, constraints, indexes and policies, extensions, migrations, backups, TypeScript types), logs, security and performance advisors, Edge Functions, API keys (without secrets), preview branches and Storage buckets. It is the view of a project owner, not of an app user: it does not go through your project's REST API or row level security.\n\n**Setup (about 2 minutes)**\n1. Open https://supabase.com/dashboard/account/tokens (account menu → **Account preferences** → **Access Tokens**) and click **Generate new token**.\n2. Prefer a **scoped token**: pick the organization and projects the AI may see and give each capability Read (or Read-write where it should change something). For the tools here: Projects, Project Settings, Database, Migrations, Backups, Edge Functions, API Keys (not API Key Secrets), Logs, Usage Analytics, Advisors, Storage, Development Branches and Organizations. A legacy token has your full access to every organization instead.\n3. Set an expiry (at most one year), copy the token (it starts with `sbp_`; Supabase shows it only once) and paste it into `SUPABASE_ACCESS_TOKEN`.\n4. A token's access cannot be changed later: to widen or narrow it, create a new one and delete the old.\n\n**Read-only setup**: give a scoped token Read on every capability. `supabase_execute_sql` is installed switched off; with Database Read only, even that tool runs its SQL read-only. Nothing else here changes data.\n\n**Projects**: every tool takes `project_ref`, the 20 lowercase letters in `https://.supabase.co` and in `supabase_list_projects`. Free projects pause after a week without activity and then show status `INACTIVE`: database tools fail until the project is restored in the dashboard. Check `supabase_get_project_health` when an app reports errors.\n\n**SQL**\n- `supabase_run_read_only_sql` runs one statement as a read-only database user. Qualify every table with its schema (`public.orders`, not `orders`), pass values as `$1`, `$2` with `parameters`, and add a `LIMIT`: results over 200 KB are cut and marked `_truncated`.\n- Start with `supabase_list_schemas`, `supabase_list_tables` (row level security on or off, estimated rows, size) and `supabase_describe_table` (columns, keys, indexes, policies).\n- `supabase_execute_sql` (switched off at install) runs as `postgres` with full rights: DDL, writes and grants apply immediately and cannot be undone. Turn it on only for a user who wants the AI to change the database, and have every statement confirmed. For schema changes that should live in version control, the user's own migration workflow is the better place.\n- The database query endpoints are marked beta by Supabase. A failing statement comes back with Postgres' message, e.g. \"Failed to run sql query: relation \\\"orders\\\" does not exist\".\n\n**Logs**: `supabase_query_logs` takes SQL in the ClickHouse dialect over one `logs` table. Filter on `source` (`edge_logs` for API requests, `postgres_logs`, `auth_logs`, `function_logs` and `function_edge_logs` for Edge Functions, `storage_logs`, `realtime_logs`) and read nested fields with `log_attributes['request.path']` or `log_attributes['response.status_code']`. Without `iso_timestamp_start` and `iso_timestamp_end` only the last minute is searched; a range may span at most 24 hours and must lie within the plan's retention (1 day on Free, 7 on Pro, 28 on Team). Example: `select timestamp, event_message from logs where source = 'postgres_logs' order by timestamp desc limit 50`.\n\n**Advisors**: `supabase_get_security_advisors` and `supabase_get_performance_advisors` run Supabase's database linter, the same checks as the dashboard's Advisors page. Each finding has a level (ERROR, WARN, INFO), the affected object and a remediation link; fix ERROR ones first, especially tables exposed without row level security.\n\n**API keys**: `supabase_list_api_keys` names every key but returns the value only of public ones (publishable keys and the legacy anon key). Secret keys and the legacy service_role key are listed without their value on purpose: they bypass row level security and must never travel through a chat. There is no tool to reveal them.\n\n**Edge Functions**: `supabase_list_edge_functions` and `supabase_get_edge_function` give status, version and settings. Function code is not downloaded; read it from the user's repository. For errors, query `function_logs` with `supabase_query_logs`.\n\n**Branches**: preview branches need a paid plan. `supabase_list_branches` lists them; each branch has its own `project_ref`, which the other tools accept.\n\n**Rate limits**: 120 requests per minute per user and endpoint (all of a user's tokens share it), 30 per minute for logs and usage. Over it Supabase answers 429 for the rest of the minute.\n\n**Errors**: 401 \"Invalid access token\" means the token is wrong, expired or deleted. 403 means the token may not do this: scoped tokens list what is missing in `missing_permissions`. 404 is an unknown project ref, or a project outside the token's organizations. 402 on logs means the organization's usage quota is exhausted. 410 Gone would mean an endpoint Supabase has retired.\n\n**Out of scope here**: creating, pausing, restoring or deleting projects, deploying Edge Functions, secrets and auth configuration, custom domains, network restrictions, billing, and data in your app's tables through PostgREST.", + "region": "intl", + "category": "database", + "icon": "supabase", + "docsUrl": "https://supabase.com/docs/reference/api/introduction", + "requiredEnvVars": [ + "SUPABASE_ACCESS_TOKEN" + ], + "envVarMeta": { + "SUPABASE_ACCESS_TOKEN": { + "label": "Personal access token", + "kind": "credential", + "secret": true, + "help": "Supabase dashboard → account menu → Account preferences → Access Tokens → Generate new token. A scoped token can be limited to some projects and to read access.", + "link": "https://supabase.com/dashboard/account/tokens", + "pattern": "^sbp_\\S+$", + "patternMessage": "Paste the token as Supabase shows it, starting with sbp_, without the word Bearer." + } + }, + "verifyHints": { + "401": "Supabase refused the token: it is wrong, expired or was deleted. Create a new one under Account preferences → Access Tokens.", + "403": "The token may not do this. A scoped token lists what it lacks in missing_permissions; create a new token that includes it (access cannot be changed afterwards).", + "rate_limited": "Supabase allows 120 Management API requests per minute per user (30 for logs). Wait a minute and try again." + }, + "probe": { + "tool": "supabase_list_projects" + }, + "prerequisites": "A Supabase account with at least one project (the Free plan works) and a personal access token created under Account preferences → Access Tokens. About 2 minutes.", + "connector": { + "name": "Supabase Management API", + "type": "REST", + "baseUrl": "https://api.supabase.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SUPABASE_ACCESS_TOKEN}}" + }, + "headers": { + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/v1/projects" + }, + "tools": [ + { + "name": "supabase_list_projects", + "description": "List every project the token can reach: ref, name, organization_slug, region, status (ACTIVE_HEALTHY, INACTIVE when paused, COMING_UP, RESTORING...), created_at and the Postgres host and version. The ref is what every other tool takes.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects" + } + }, + { + "name": "supabase_list_organizations", + "description": "List the organizations the token can reach, with their slug and name.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/organizations" + } + }, + { + "name": "supabase_get_organization", + "description": "Get one organization by slug, including its plan (free, pro, team, enterprise).", + "parameters": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "description": "Organization slug, from supabase_list_organizations or supabase_list_projects." + } + }, + "required": [ + "organization_slug" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/organizations/{organization_slug}", + "encodePathParams": true + } + }, + { + "name": "supabase_get_project", + "description": "Get one project: name, ref, organization, region, status, created_at and database host and Postgres version. A paused free project shows status INACTIVE; restore it in the dashboard before querying it.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}", + "encodePathParams": true + } + }, + { + "name": "supabase_get_project_health", + "description": "Check the health of a project's services: for each one name, status (ACTIVE_HEALTHY, COMING_UP, UNHEALTHY) and error or info. Use it when an app reports errors or right after a restore.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "services": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "auth", + "db", + "db_postgres_user", + "pooler", + "realtime", + "rest", + "storage", + "pg_bouncer" + ] + }, + "default": [ + "db", + "auth", + "rest", + "realtime", + "storage" + ], + "description": "Services to check." + }, + "timeout_ms": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "How long to wait for each check, 0 to 10000 milliseconds." + } + }, + "required": [ + "project_ref", + "services" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/health", + "encodePathParams": true, + "queryParams": { + "services": "$services", + "timeout_ms": "$timeout_ms" + } + } + }, + { + "name": "supabase_run_read_only_sql", + "description": "Run one SQL statement as the read-only database user and return the rows as JSON objects. Writes are refused. Qualify every table with its schema (public.orders, not orders). Use $1, $2 placeholders with parameters for values instead of pasting them into the text. Add a LIMIT: large results are cut at 200 KB.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "query": { + "type": "string", + "description": "One SQL statement, e.g. select id, status from public.orders where created_at > $1 order by created_at desc limit 50." + }, + "parameters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Values for $1, $2 ... in order." + } + }, + "required": [ + "project_ref", + "query" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/projects/{project_ref}/database/query/read-only", + "encodePathParams": true, + "bodyMapping": { + "query": "$query", + "parameters": "$parameters" + } + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "supabase_execute_sql", + "enabled": false, + "description": "Run SQL with full rights (as postgres): DDL, inserts, updates, deletes, grants. Changes take effect at once and cannot be undone. Show the user the exact statement and wait for explicit confirmation; for schema changes prefer a migration in the user's own workflow. Switched off at install.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "query": { + "type": "string", + "description": "SQL to run; several statements separated by semicolons are run together." + }, + "parameters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Values for $1, $2 ... in order." + }, + "read_only": { + "type": "boolean", + "description": "true to run it read-only after all." + } + }, + "required": [ + "project_ref", + "query" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/projects/{project_ref}/database/query", + "encodePathParams": true, + "bodyMapping": { + "query": "$query", + "parameters": "$parameters", + "read_only": "$read_only" + } + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "supabase_list_schemas", + "description": "List the database's schemas with their owner and number of tables, leaving out Postgres' own (pg_*, information_schema). Supabase keeps app data in public and its own services in auth, storage, realtime, graphql, extensions and vault.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/projects/{project_ref}/database/query/read-only", + "bodyMapping": { + "query": "select n.nspname as schema, pg_catalog.pg_get_userbyid(n.nspowner) as owner, (select count(*) from pg_catalog.pg_class c where c.relnamespace = n.oid and c.relkind in ('r', 'p')) as tables from pg_catalog.pg_namespace n where n.nspname not like 'pg\\_%' and n.nspname <> 'information_schema' order by n.nspname" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "supabase_list_tables", + "description": "List the tables, views and materialized views of one or more schemas: schema, name, kind, whether row level security is on, estimated row count, total size and comment. Tables in public without RLS are readable by anyone with the anon key.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "schemas": { + "type": "string", + "default": "public", + "description": "Comma-separated schemas, e.g. public or public,auth (default public)." + } + }, + "required": [ + "project_ref", + "schemas" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/projects/{project_ref}/database/query/read-only", + "bodyMapping": { + "query": "select n.nspname as schema, c.relname as name, case c.relkind when 'r' then 'table' when 'p' then 'partitioned table' when 'v' then 'view' when 'm' then 'materialized view' when 'f' then 'foreign table' end as kind, c.relrowsecurity as rls_enabled, greatest(c.reltuples, 0)::bigint as estimated_rows, pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(c.oid)) as total_size, pg_catalog.obj_description(c.oid, 'pg_class') as comment from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where c.relkind in ('r', 'p', 'v', 'm', 'f') and n.nspname = any(pg_catalog.string_to_array($1, ',')) order by n.nspname, c.relname", + "parameters": [ + "$schemas" + ] + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "supabase_describe_table", + "description": "Describe one table or view: columns (name, type, nullable, default, comment), constraints (primary key, foreign keys, unique, check, with their definition), indexes and row level security policies.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "schema": { + "type": "string", + "default": "public", + "description": "Schema of the table (default public)." + }, + "table": { + "type": "string", + "description": "Table or view name, exactly as stored (case-sensitive)." + } + }, + "required": [ + "project_ref", + "schema", + "table" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/projects/{project_ref}/database/query/read-only", + "bodyMapping": { + "query": "select (select coalesce(json_agg(json_build_object('name', a.attname, 'type', pg_catalog.format_type(a.atttypid, a.atttypmod), 'nullable', not a.attnotnull, 'default', pg_catalog.pg_get_expr(d.adbin, d.adrelid), 'comment', pg_catalog.col_description(a.attrelid, a.attnum)) order by a.attnum), '[]') from pg_catalog.pg_attribute a left join pg_catalog.pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum where a.attrelid = (select c.oid from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2) and a.attnum > 0 and not a.attisdropped) as columns, (select coalesce(json_agg(json_build_object('name', con.conname, 'type', case con.contype when 'p' then 'primary key' when 'f' then 'foreign key' when 'u' then 'unique' when 'c' then 'check' when 'x' then 'exclusion' else con.contype::text end, 'definition', pg_catalog.pg_get_constraintdef(con.oid))), '[]') from pg_catalog.pg_constraint con where con.conrelid = (select c.oid from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2)) as constraints, (select coalesce(json_agg(json_build_object('name', i.indexname, 'definition', i.indexdef)), '[]') from pg_catalog.pg_indexes i where i.schemaname = $1 and i.tablename = $2) as indexes, (select coalesce(json_agg(json_build_object('name', p.policyname, 'command', p.cmd, 'roles', p.roles, 'permissive', p.permissive, 'using', p.qual, 'with_check', p.with_check)), '[]') from pg_catalog.pg_policies p where p.schemaname = $1 and p.tablename = $2) as policies", + "parameters": [ + "$schema", + "$table" + ] + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "supabase_list_extensions", + "description": "List the Postgres extensions installed in the database: name, version and schema (on Supabase usually extensions).", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/projects/{project_ref}/database/query/read-only", + "bodyMapping": { + "query": "select e.extname as name, e.extversion as version, n.nspname as schema from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace order by e.extname" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "supabase_list_migrations", + "description": "List the migrations recorded for the database (by the Supabase CLI or the dashboard): version (timestamp) and name, oldest first.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/database/migrations", + "encodePathParams": true + } + }, + { + "name": "supabase_list_backups", + "description": "List the project's database backups: whether point-in-time recovery is on, and each backup's status and time. Free projects have no downloadable backups.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/database/backups", + "encodePathParams": true + } + }, + { + "name": "supabase_generate_typescript_types", + "description": "Generate TypeScript types for the database schema, as the Supabase CLI does: one string with a Database type covering tables, views, functions and enums. Large schemas are cut at 200 KB.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "included_schemas": { + "type": "string", + "default": "public", + "description": "Comma-separated schemas to include (default public)." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/types/typescript", + "encodePathParams": true, + "queryParams": { + "included_schemas": "$included_schemas" + } + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + } + }, + { + "name": "supabase_list_edge_functions", + "description": "List the project's Edge Functions: slug, name, status, version, verify_jwt, entrypoint path and when they were created and updated (epoch milliseconds).", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/functions", + "encodePathParams": true + } + }, + { + "name": "supabase_get_edge_function", + "description": "Get one Edge Function by slug: status, version, verify_jwt, entrypoint and import map paths, created and updated times. The function's code is not returned.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "function_slug": { + "type": "string", + "description": "Function slug, e.g. send-welcome-email." + } + }, + "required": [ + "project_ref", + "function_slug" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/functions/{function_slug}", + "encodePathParams": true + } + }, + { + "name": "supabase_list_api_keys", + "description": "List the project's API keys: id, name, type (publishable, secret or legacy), prefix and description. Only public keys (publishable and the legacy anon key) carry their value; secret and service_role keys are listed without it, on purpose.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/api-keys", + "encodePathParams": true, + "queryParams": { + "reveal": "false" + } + }, + "responseMapping": { + "transform": { + "mode": "jmespath", + "expression": "[].{id: id, name: name, type: type, prefix: prefix, description: description, inserted_at: inserted_at, updated_at: updated_at, api_key: (type == 'publishable' || name == 'anon') && api_key || `null`}", + "fallbackToRaw": false + } + } + }, + { + "name": "supabase_query_logs", + "description": "Query the project's logs with SQL (ClickHouse dialect) over one unified logs table: filter on source (edge_logs, postgres_logs, auth_logs, function_logs, function_edge_logs, storage_logs, realtime_logs), read nested fields with log_attributes['request.path']. Without a time range only the last minute is searched; the range may span at most 24 hours. Returns result rows and, on a bad query, error.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "sql": { + "type": "string", + "description": "SQL over the logs table, e.g. select timestamp, event_message, log_attributes['response.status_code'] as status from logs where source = 'edge_logs' order by timestamp desc limit 50." + }, + "iso_timestamp_start": { + "type": "string", + "description": "Start of the range, UTC ISO 8601 ending in Z, e.g. 2026-10-10T08:00:00Z." + }, + "iso_timestamp_end": { + "type": "string", + "description": "End of the range, UTC ISO 8601 ending in Z; at most 24 hours after the start." + } + }, + "required": [ + "project_ref", + "sql" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/analytics/endpoints/logs", + "encodePathParams": true, + "queryParams": { + "sql": "$sql", + "iso_timestamp_start": "$iso_timestamp_start", + "iso_timestamp_end": "$iso_timestamp_end" + } + }, + "responseMapping": { + "transform": { + "expression": "@", + "maxBytes": 200000 + } + } + }, + { + "name": "supabase_get_api_usage", + "description": "Get request counts per time bucket for the project's REST, Auth, Storage and Realtime APIs over a recent interval.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "interval": { + "type": "string", + "enum": [ + "15min", + "30min", + "1hr", + "3hr", + "1day", + "3day", + "7day" + ], + "default": "1day", + "description": "Time window, e.g. 1day (default) or 7day." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/analytics/endpoints/usage.api-counts", + "encodePathParams": true, + "queryParams": { + "interval": "$interval" + } + } + }, + { + "name": "supabase_get_security_advisors", + "description": "Run the security advisor (database linter) on the project: findings such as tables without row level security, exposed auth.users, mutable function search paths or insecure extensions, each with level (ERROR, WARN, INFO), detail and a remediation link.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/advisors/security", + "encodePathParams": true + } + }, + { + "name": "supabase_get_performance_advisors", + "description": "Run the performance advisor on the project: findings such as unindexed foreign keys, unused or duplicate indexes, RLS policies that re-evaluate auth functions per row, each with level, detail and a remediation link.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/advisors/performance", + "encodePathParams": true + } + }, + { + "name": "supabase_list_branches", + "description": "List the project's preview branches (branching, a paid feature): id, name, project_ref of the branch, git_branch, pr_number, is_default, persistent and status. A project without branching answers with an error or an empty list.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/branches", + "encodePathParams": true + } + }, + { + "name": "supabase_get_branch", + "description": "Get one preview branch of a project by its name: its own project ref, git branch, pull request number, status and whether it is persistent.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + }, + "branch_name": { + "type": "string", + "description": "Branch name, from supabase_list_branches." + } + }, + "required": [ + "project_ref", + "branch_name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/branches/{branch_name}", + "encodePathParams": true + } + }, + { + "name": "supabase_list_storage_buckets", + "description": "List the project's Storage buckets: id, name, owner, whether public, and created and updated times.", + "parameters": { + "type": "object", + "properties": { + "project_ref": { + "type": "string", + "description": "Project ref, the 20 lowercase letters in the project's URL (https://.supabase.co) and in supabase_list_projects." + } + }, + "required": [ + "project_ref" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/projects/{project_ref}/storage/buckets", + "encodePathParams": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/supabase.live.spec.ts b/packages/backend/src/adapters/intl/supabase.live.spec.ts new file mode 100644 index 000000000..3e5536684 --- /dev/null +++ b/packages/backend/src/adapters/intl/supabase.live.spec.ts @@ -0,0 +1,321 @@ +import * as http from 'node:http'; +import { AddressInfo } from 'node:net'; +import * as adapterJson from './supabase.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 { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Static checks and request-shape checks against a local server always run. + * The live block runs every read tool through the real RestEngine against the + * Supabase Management API: + * SUPABASE_ACCESS_TOKEN=sbp_... [SUPABASE_PROJECT_REF=abcdefghijklmnopqrst] \ + * npx jest src/adapters/intl/supabase.live.spec.ts + * Without SUPABASE_PROJECT_REF the first ACTIVE_HEALTHY project is used. + * SUPABASE_LIVE_WRITE=1 (needs SUPABASE_PROJECT_REF, a throwaway project) + * also creates a table public.anythingmcp_test_