From 971a6b5c453791689a8ad1986afef6baa905c222 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:56:03 +0200 Subject: [PATCH 1/6] Add 15 catalog adapters for payments, crypto, finance and B2B APIs New adapters, each built from the vendor's public API documentation, with instructions, envVarMeta, verify hints, a read-only probe where the API allows one, a logo and a live spec gated behind RUN__LIVE=1: - BlockCypher: Bitcoin, Litecoin, Dogecoin and Ethereum chain data (keyless, optional token) - Helius: Solana RPC, DAS assets, enhanced transactions and the Wallet API - Tabby, Tamara, noon payments: BNPL and card payment lookups; captures, refunds and closes install switched off - T-Bank Business: company, accounts, statements, documents and acquiring terminals, read-only; self-host only because the API presents a Russian Trusted Root CA certificate that Node does not trust by default - Checko: Russian company, entrepreneur and person registry data - Falco: Belgian purchase and sale documents, Peppol inbound and participant lookup; payment status and Peppol responses install switched off - Invoice123: invoices, purchases, PDFs and reference data, read-only - XOVI: SEO credits, rankings, OVI trend and keyword monitoring - Netskope: REST API v2 alerts, events, audit, URL lists, publishers, private apps and SCIM users/groups, read-only - SAP Sales Cloud and Service Cloud (C4C): OData v2 ready lists plus the generic OData tools - Trybe: creators, performance and submissions; review actions switched off - Medmis: clinic branches, staff, appointments, patients, invoices, stock and check-ups; personal identifiers are removed from responses, writes are off - Tezga eKasa: fiscal documents, tax labels and bank transactions; receipts, refunds and master-data upserts install switched off --- packages/backend/src/adapters/be/falco.json | 809 ++++++++ .../src/adapters/be/falco.live.spec.ts | 159 ++ packages/backend/src/adapters/de/xovi.json | 811 ++++++++ .../backend/src/adapters/de/xovi.live.spec.ts | 160 ++ .../src/adapters/intl/blockcypher.json | 437 ++++ .../adapters/intl/blockcypher.live.spec.ts | 181 ++ .../backend/src/adapters/intl/checko.json | 724 +++++++ .../src/adapters/intl/checko.live.spec.ts | 178 ++ .../backend/src/adapters/intl/helius.json | 559 +++++ .../src/adapters/intl/helius.live.spec.ts | 179 ++ .../backend/src/adapters/intl/invoice123.json | 612 ++++++ .../src/adapters/intl/invoice123.live.spec.ts | 149 ++ .../backend/src/adapters/intl/medmis.json | 1813 +++++++++++++++++ .../src/adapters/intl/medmis.live.spec.ts | 179 ++ .../backend/src/adapters/intl/netskope.json | 541 +++++ .../src/adapters/intl/netskope.live.spec.ts | 151 ++ .../src/adapters/intl/noon-payments.json | 335 +++ .../adapters/intl/noon-payments.live.spec.ts | 172 ++ .../backend/src/adapters/intl/sap-c4c.json | 544 +++++ .../src/adapters/intl/sap-c4c.live.spec.ts | 168 ++ packages/backend/src/adapters/intl/tabby.json | 458 +++++ .../src/adapters/intl/tabby.live.spec.ts | 182 ++ .../backend/src/adapters/intl/tamara.json | 457 +++++ .../src/adapters/intl/tamara.live.spec.ts | 186 ++ packages/backend/src/adapters/intl/tbank.json | 313 +++ .../src/adapters/intl/tbank.live.spec.ts | 179 ++ packages/backend/src/adapters/intl/tezga.json | 566 +++++ .../src/adapters/intl/tezga.live.spec.ts | 125 ++ packages/backend/src/adapters/intl/trybe.json | 409 ++++ .../src/adapters/intl/trybe.live.spec.ts | 129 ++ .../public/logos/connectors/blockcypher.svg | 1 + .../public/logos/connectors/checko.svg | 1 + .../public/logos/connectors/falco.svg | 1 + .../public/logos/connectors/helius.svg | 1 + .../public/logos/connectors/invoice123.svg | 1 + .../public/logos/connectors/medmis.svg | 1 + .../public/logos/connectors/netskope.svg | 1 + .../public/logos/connectors/noon-payments.svg | 1 + .../public/logos/connectors/sap-c4c.svg | 1 + .../public/logos/connectors/tabby.svg | 1 + .../public/logos/connectors/tamara.svg | 1 + .../public/logos/connectors/tbank.svg | 1 + .../public/logos/connectors/tezga.svg | 1 + .../public/logos/connectors/trybe.svg | 1 + .../frontend/public/logos/connectors/xovi.svg | 1 + 45 files changed, 11880 insertions(+) create mode 100644 packages/backend/src/adapters/be/falco.json create mode 100644 packages/backend/src/adapters/be/falco.live.spec.ts create mode 100644 packages/backend/src/adapters/de/xovi.json create mode 100644 packages/backend/src/adapters/de/xovi.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/blockcypher.json create mode 100644 packages/backend/src/adapters/intl/blockcypher.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/checko.json create mode 100644 packages/backend/src/adapters/intl/checko.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/helius.json create mode 100644 packages/backend/src/adapters/intl/helius.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/invoice123.json create mode 100644 packages/backend/src/adapters/intl/invoice123.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/medmis.json create mode 100644 packages/backend/src/adapters/intl/medmis.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/netskope.json create mode 100644 packages/backend/src/adapters/intl/netskope.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/noon-payments.json create mode 100644 packages/backend/src/adapters/intl/noon-payments.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/sap-c4c.json create mode 100644 packages/backend/src/adapters/intl/sap-c4c.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/tabby.json create mode 100644 packages/backend/src/adapters/intl/tabby.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/tamara.json create mode 100644 packages/backend/src/adapters/intl/tamara.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/tbank.json create mode 100644 packages/backend/src/adapters/intl/tbank.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/tezga.json create mode 100644 packages/backend/src/adapters/intl/tezga.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/trybe.json create mode 100644 packages/backend/src/adapters/intl/trybe.live.spec.ts create mode 100644 packages/frontend/public/logos/connectors/blockcypher.svg create mode 100644 packages/frontend/public/logos/connectors/checko.svg create mode 100644 packages/frontend/public/logos/connectors/falco.svg create mode 100644 packages/frontend/public/logos/connectors/helius.svg create mode 100644 packages/frontend/public/logos/connectors/invoice123.svg create mode 100644 packages/frontend/public/logos/connectors/medmis.svg create mode 100644 packages/frontend/public/logos/connectors/netskope.svg create mode 100644 packages/frontend/public/logos/connectors/noon-payments.svg create mode 100644 packages/frontend/public/logos/connectors/sap-c4c.svg create mode 100644 packages/frontend/public/logos/connectors/tabby.svg create mode 100644 packages/frontend/public/logos/connectors/tamara.svg create mode 100644 packages/frontend/public/logos/connectors/tbank.svg create mode 100644 packages/frontend/public/logos/connectors/tezga.svg create mode 100644 packages/frontend/public/logos/connectors/trybe.svg create mode 100644 packages/frontend/public/logos/connectors/xovi.svg diff --git a/packages/backend/src/adapters/be/falco.json b/packages/backend/src/adapters/be/falco.json new file mode 100644 index 000000000..596391238 --- /dev/null +++ b/packages/backend/src/adapters/be/falco.json @@ -0,0 +1,809 @@ +{ + "slug": "falco", + "name": "Falco", + "aliases": [ + "Falco app", + "Falco Peppol", + "Falco boekhouding", + "Falco comptabilite" + ], + "description": "Falco, Belgian accounting and Peppol e-invoicing: purchase and sale documents with their files, invoices received over Peppol, Peppol participant lookups, and the customers, articles and invoices of the Falco invoicing module. Mostly read-only; Peppol invoice responses are installed switched off.", + "region": "be", + "category": "accounting", + "icon": "falco", + "docsUrl": "https://docs.falco-app.be/docs/getting-started", + "instructions": "## Falco\nThis connector uses the **Falco API** (docs.falco-app.be) of Falco, the Belgian accounting and e-invoicing application: purchase and sale documents, documents received over **Peppol**, Peppol participant lookups, and the customers, articles and invoices of the Falco invoicing module. It reads; setting a purchase payment status is a write, and sending a Peppol invoice response is installed switched off.\n\n**Setup (two keys, both required)**\n1. The integrator creates an application on the Falco developer portal (dev.falco-app.be), requests the scopes it needs (e.g. `documents:purchases:read`, `documents:sales:read`, `peppol:inbound:read`, `peppol:participants:read`, `customers:read`, `articles:read`, `invoices:read`) and makes it **Public**. Its production **Application Secret** (`as_live_...`) goes into `FALCO_APP_SECRET`.\n2. The Falco customer (an administrator of the organisation, plan Expert Essential or Expert Premium) opens **API access** under their initials in the Falco desktop application, picks that application by its id (`app_...`) and generates an **API key** (`sk_live_...`) for their organisation. It goes into `FALCO_API_KEY`.\n3. Install and run `falco_get_organization` to check both keys. The connector calls the production API, `https://api.falco-app.be/v1`; sandbox keys (`as_test_`, `sk_test_`) only work on the sandbox host and are refused here.\n\n**Workflow**\n- Supplier invoices: `falco_list_peppol_inbound` for what arrived over Peppol (sender, amount, due date, payment reference, bank account), `falco_get_peppol_inbound_document`, `falco_download_peppol_inbound_document` for the UBL. `falco_list_purchase_documents` and `falco_get_purchase_document` cover all purchase documents with their files; `falco_download_purchase_document_file` with `main`, `ubl` or a file id fetches one.\n- Sales: `falco_list_sale_documents`, `falco_get_sale_document`; documents made in the invoicing module: `falco_list_invoices`, `falco_get_invoice`, `falco_download_invoice_pdf`; master data: `falco_list_customers`, `falco_get_customer`, `falco_list_articles`.\n- Peppol reachability of a company: `falco_find_peppol_participant` with its VAT number, or `falco_check_peppol_participant` with a participant id such as `0208:0123456789`.\n- After paying a supplier: `falco_set_purchase_payment_status` (switched off at install; replaces the status, so show the user the document and the new status and wait for a yes).\n- `falco_send_invoice_response` (switched off) sends an accept, reject, query or paid message to the supplier over Peppol at once; before calling it show the user the document and the response and wait for a yes.\n\n**Dates and amounts**: filters take `YYYY-MM-DD`. Amounts are strings in the document currency (usually EUR). Lists are paged with `page` (from 0) and `page_size` (20 to 200, invoices up to 100); ask for the next page while a page comes back full.\n\n**Limits and errors**: 60 requests per minute per application secret (429 above that; wait and retry). Errors follow RFC 9457 with `title`, `detail` and `code`. 401 means a wrong application secret or API key, or a sandbox key on production; 403 means the key lacks the scope for that area (the customer grants scopes when creating the key); 404 on a participant lookup means the company is not reachable on Peppol.", + "prerequisites": "A Falco developer application (dev.falco-app.be) made Public, its production application secret, and an API key the Falco customer generates for that application under API access (Expert Essential or Expert Premium plan).", + "requiredEnvVars": [ + "FALCO_APP_SECRET", + "FALCO_API_KEY" + ], + "envVarMeta": { + "FALCO_APP_SECRET": { + "label": "Application secret", + "kind": "credential", + "secret": true, + "help": "The production Application Secret of your application on the Falco developer portal (dev.falco-app.be), starting with as_live_.", + "link": "https://docs.falco-app.be/docs/authentication", + "pattern": "^as_live_\\S+$", + "patternMessage": "Paste the production application secret, starting with as_live_ (sandbox secrets as_test_ do not work on the production API)." + }, + "FALCO_API_KEY": { + "label": "API key", + "kind": "credential", + "secret": true, + "help": "The API key the Falco customer generates in the Falco desktop application under their initials, API access, for your application. It starts with sk_live_.", + "link": "https://docs.falco-app.be/docs/go-live-production-setup", + "pattern": "^sk_live_\\S+$", + "patternMessage": "Paste the production API key, starting with sk_live_." + } + }, + "verifyHints": { + "401": "Falco refused the keys. Check that FALCO_APP_SECRET is the production secret (as_live_) of the application the customer linked the API key (sk_live_) to.", + "403": "The keys work, but the API key lacks the scope for this area. The customer grants scopes when generating the key under API access.", + "rate_limited": "Falco allows 60 requests per minute per application secret. Wait a minute and try again." + }, + "probe": { + "tool": "falco_get_organization" + }, + "connector": { + "name": "Falco API", + "type": "REST", + "baseUrl": "https://api.falco-app.be/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Falco-Api-Key", + "apiKey": "{{FALCO_API_KEY}}", + "extraHeaders": { + "X-Falco-App-Secret": "{{FALCO_APP_SECRET}}" + } + } + }, + "tools": [ + { + "name": "falco_get_organization", + "description": "Get the Falco organization the API key belongs to: name, VAT number, Peppol identifier, address, IBANs, profiles, plan (expert_essential or expert_premium), invoices sent over Peppol this month and whether it is linked to accounting. Use it to check the connection.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/organization/whoami" + } + }, + { + "name": "falco_list_purchase_documents", + "description": "List purchase documents (supplier invoices and credit notes) in Falco, newest first: id, number, invoice and due date, currency, amount, supplier name, VAT and company number, payment status (not_paid, partially_paid, paid), Peppol status, events and attached files. Filter by creation date, invoice date or supplier name; page with page and page_size.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0.", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 20 to 200 (default 50).", + "minimum": 20, + "maximum": 200 + }, + "created_after": { + "type": "string", + "description": "Only documents created on or after this day, YYYY-MM-DD." + }, + "created_before": { + "type": "string", + "description": "Only documents created on or before this day, YYYY-MM-DD." + }, + "date_from": { + "type": "string", + "description": "Only documents with an invoice date on or after this day, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "Only documents with an invoice date on or before this day, YYYY-MM-DD." + }, + "supplier_name": { + "type": "string", + "description": "Supplier name to filter on." + }, + "sort_by": { + "type": "string", + "description": "Field to sort by (default created_at).", + "enum": [ + "created_at", + "invoice_date" + ] + }, + "sort_direction": { + "type": "string", + "description": "Sort direction. Default: newest first for dates.", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/purchases", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "created_after": "$created_after", + "created_before": "$created_before", + "date_from": "$date_from", + "date_to": "$date_to", + "supplier_name": "$supplier_name", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction" + } + } + }, + { + "name": "falco_get_purchase_document", + "description": "Get one purchase document by id: number, dates, amount, supplier, payment and Peppol status, its event history and the list of attached files (id, type pdf, image or ubl, file name, size).", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Purchase document id, from falco_list_purchase_documents." + } + }, + "required": [ + "document_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/purchases/{document_id}" + } + }, + { + "name": "falco_download_purchase_document_file", + "description": "Download a file of a purchase document: the main file (usually the PDF), the UBL XML, or one attached file. The file is handed over as an attachment.", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Purchase document id, from falco_list_purchase_documents." + }, + "file_identifier": { + "type": "string", + "description": "Which file: main, ubl, the position of the file in the files list, or the file id from falco_get_purchase_document." + } + }, + "required": [ + "document_id", + "file_identifier" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/purchases/{document_id}/download/{file_identifier}" + } + }, + { + "name": "falco_list_sale_documents", + "description": "List sale documents (sales invoices and credit notes, including those imported as PDF or UBL), newest first: id, number, dates, currency, amount, customer name, payment status and Peppol send status. Filter by creation date, invoice date or customer name.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0.", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 20 to 200 (default 50).", + "minimum": 20, + "maximum": 200 + }, + "created_after": { + "type": "string", + "description": "Only documents created on or after this day, YYYY-MM-DD." + }, + "created_before": { + "type": "string", + "description": "Only documents created on or before this day, YYYY-MM-DD." + }, + "date_from": { + "type": "string", + "description": "Only documents with an invoice date on or after this day, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "Only documents with an invoice date on or before this day, YYYY-MM-DD." + }, + "customer_name": { + "type": "string", + "description": "Customer name to filter on." + }, + "sort_by": { + "type": "string", + "description": "Field to sort by (default created_at).", + "enum": [ + "created_at", + "invoice_date" + ] + }, + "sort_direction": { + "type": "string", + "description": "Sort direction. Default: newest first for dates.", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/sales", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "created_after": "$created_after", + "created_before": "$created_before", + "date_from": "$date_from", + "date_to": "$date_to", + "customer_name": "$customer_name", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction" + } + } + }, + { + "name": "falco_get_sale_document", + "description": "Get one sale document by id: number, dates, amount, customer, payment status, Peppol send status, event history and attached files.", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Sale document id, from falco_list_sale_documents." + } + }, + "required": [ + "document_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/sales/{document_id}" + } + }, + { + "name": "falco_list_peppol_inbound", + "description": "List documents received over Peppol (incoming e-invoices and credit notes), newest first: id, received date, invoice and due date, amount, currency, sender name, VAT number and Peppol id, payment reference, bank account, invoice reference, payment status and whether it is a credit note. Filter by received date, invoice date, sender or number.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0.", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 20 to 200 (default 50).", + "minimum": 20, + "maximum": 200 + }, + "received_after": { + "type": "string", + "description": "Only documents received on or after this day, YYYY-MM-DD." + }, + "received_before": { + "type": "string", + "description": "Only documents received on or before this day, YYYY-MM-DD." + }, + "date_from": { + "type": "string", + "description": "Only documents with an invoice date on or after this day, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "Only documents with an invoice date on or before this day, YYYY-MM-DD." + }, + "sender_vat_number": { + "type": "string", + "description": "Sender VAT number, e.g. BE0123456789." + }, + "sender_name": { + "type": "string", + "description": "Sender name." + }, + "number": { + "type": "string", + "description": "Invoice number." + }, + "sort_by": { + "type": "string", + "description": "Field to sort by (default received_at).", + "enum": [ + "received_at", + "invoice_date" + ] + }, + "sort_direction": { + "type": "string", + "description": "Sort direction. Default: newest first for dates.", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/peppol/inbound", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "received_after": "$received_after", + "received_before": "$received_before", + "date_from": "$date_from", + "date_to": "$date_to", + "sender_vat_number": "$sender_vat_number", + "sender_name": "$sender_name", + "number": "$number", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction" + } + } + }, + { + "name": "falco_get_peppol_inbound_document", + "description": "Get one document received over Peppol by id: sender, amounts, dates, payment reference, bank account, invoice reference and payment status.", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Inbound document id, from falco_list_peppol_inbound." + } + }, + "required": [ + "document_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/peppol/inbound/{document_id}" + } + }, + { + "name": "falco_download_peppol_inbound_document", + "description": "Download the file of a document received over Peppol (the UBL XML as received). The file is handed over as an attachment.", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Inbound document id, from falco_list_peppol_inbound." + } + }, + "required": [ + "document_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/peppol/inbound/{document_id}/download" + } + }, + { + "name": "falco_find_peppol_participant", + "description": "Look up the Peppol participant identifier registered for a VAT number, to see whether a company can receive e-invoices over Peppol. A 404 means the company is not on Peppol, or the VAT number is not supported.", + "parameters": { + "type": "object", + "properties": { + "vat_number": { + "type": "string", + "description": "VAT number to look up, e.g. BE0123456789." + }, + "document_type": { + "type": "string", + "description": "Document type the receiver must accept.", + "enum": [ + "invoice", + "credit_note" + ] + }, + "test_environment": { + "type": "boolean", + "description": "true to look on the Peppol test network instead of production." + } + }, + "required": [ + "vat_number" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/peppol/participants/find", + "queryParams": { + "vat_number": "$vat_number", + "document_type": "$document_type", + "test_environment": "$test_environment" + } + } + }, + { + "name": "falco_check_peppol_participant", + "description": "Check whether a Peppol participant identifier (e.g. 0208:0123456789) is registered to receive invoices or credit notes. A 404 says why it is not reachable.", + "parameters": { + "type": "object", + "properties": { + "participant_identifier": { + "type": "string", + "description": "Peppol participant identifier, scheme and value, e.g. 0208:0123456789." + }, + "document_type": { + "type": "string", + "description": "Document type the receiver must accept.", + "enum": [ + "invoice", + "credit_note" + ] + }, + "test_environment": { + "type": "boolean", + "description": "true to check the Peppol test network instead of production." + } + }, + "required": [ + "participant_identifier" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/peppol/participants/check", + "queryParams": { + "participant_identifier": "$participant_identifier", + "document_type": "$document_type", + "test_environment": "$test_environment" + } + } + }, + { + "name": "falco_list_customers", + "description": "List customers of the Falco invoicing module: id, name, VAT and company number, language, e-mails, phone, contact, address and Peppol identifier. Search by name or VAT with q, or filter by exact VAT number or e-mail.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0.", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 20 to 200 (default 50).", + "minimum": 20, + "maximum": 200 + }, + "q": { + "type": "string", + "description": "Text search in name and VAT number." + }, + "vat": { + "type": "string", + "description": "Exact VAT number." + }, + "email": { + "type": "string", + "description": "E-mail address." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "q": "$q", + "vat": "$vat", + "email": "$email" + } + } + }, + { + "name": "falco_get_customer", + "description": "Get one customer of the invoicing module by id with full address, contacts and Peppol identifier.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { + "type": "string", + "description": "Customer id, from falco_list_customers." + } + }, + "required": [ + "customer_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{customer_id}" + } + }, + { + "name": "falco_list_articles", + "description": "List articles (products and services) of the invoicing module: id, descriptions, unit price excluding and including VAT, unit, category, type, cost code and account number. Search with q in reference and description.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0.", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 20 to 200 (default 50).", + "minimum": 20, + "maximum": 200 + }, + "q": { + "type": "string", + "description": "Text search in reference and description." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/articles", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "q": "$q" + } + } + }, + { + "name": "falco_list_invoices", + "description": "List sales invoices and credit notes created in the Falco invoicing module: id, number, type, status (draft, sent, locked), document and due date, customer, currency, amounts excluding and including VAT, payment, e-mail and Peppol status. Filter by type, status, customer, number or dates; at most 100 per page.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0.", + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Rows per page, 20 to 100 (default 50).", + "minimum": 20, + "maximum": 100 + }, + "document_type": { + "type": "string", + "description": "Invoice or credit note.", + "enum": [ + "sale_invoice", + "sale_credit_note" + ] + }, + "status": { + "type": "string", + "description": "Document status.", + "enum": [ + "draft", + "sent", + "locked" + ] + }, + "customer_id": { + "type": "string", + "description": "Customer id, from falco_list_customers." + }, + "number": { + "type": "string", + "description": "Invoice number." + }, + "created_after": { + "type": "string", + "description": "Created on or after this day, YYYY-MM-DD." + }, + "created_before": { + "type": "string", + "description": "Created on or before this day, YYYY-MM-DD." + }, + "date_from": { + "type": "string", + "description": "Document date on or after this day, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "Document date on or before this day, YYYY-MM-DD." + }, + "sort_by": { + "type": "string", + "description": "Field to sort by (default created_at).", + "enum": [ + "created_at", + "document_date", + "number", + "customer_name", + "total_amount" + ] + }, + "sort_direction": { + "type": "string", + "description": "Sort direction. Default: newest first for dates.", + "enum": [ + "asc", + "desc" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "document_type": "$document_type", + "status": "$status", + "customer_id": "$customer_id", + "number": "$number", + "created_after": "$created_after", + "created_before": "$created_before", + "date_from": "$date_from", + "date_to": "$date_to", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction" + } + } + }, + { + "name": "falco_get_invoice", + "description": "Get one invoice or credit note of the invoicing module with its lines (article, quantity, unit price, VAT rate, amounts), VAT subtotals, customer and supplier, payment details and Peppol status.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice id, from falco_list_invoices." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices/{invoice_id}" + } + }, + { + "name": "falco_download_invoice_pdf", + "description": "Download the PDF of an invoice or credit note of the invoicing module. The file is handed over as an attachment.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice id, from falco_list_invoices." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices/{invoice_id}/pdf" + } + }, + { + "name": "falco_set_purchase_payment_status", + "description": "Set the payment status of a purchase document to not_paid, partially_paid or paid, e.g. after paying a supplier. Replaces the current status; returns the updated document.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Purchase document id, from falco_list_purchase_documents." + }, + "payment_status": { + "type": "string", + "description": "New payment status.", + "enum": [ + "not_paid", + "partially_paid", + "paid" + ] + } + }, + "required": [ + "document_id", + "payment_status" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/documents/purchases/{document_id}/payment-status", + "bodyMapping": { + "payment_status": "$payment_status" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "falco_send_invoice_response", + "description": "Send a Peppol invoice response for a received document to its sender: acknowledged, in_process, under_query, conditionally_accepted, rejected, accepted or paid, with optional reasons. The supplier receives it at once and it cannot be withdrawn.", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Inbound document id, from falco_list_peppol_inbound." + }, + "response_code": { + "type": "string", + "description": "Response to send.", + "enum": [ + "acknowledged", + "in_process", + "under_query", + "conditionally_accepted", + "rejected", + "accepted", + "paid" + ] + }, + "status": { + "type": "array", + "description": "Optional reasons. status_reason_code is a Peppol OPStatusAction or OPStatusReason code (e.g. NOA, PIN, NIN for actions; REF, LEG, REC, QUA, DEL, PRI, QTY for reasons); status_reason is free text.", + "items": { + "type": "object", + "properties": { + "status_reason_code": { + "type": "string" + }, + "status_reason": { + "type": "string" + } + } + } + } + }, + "required": [ + "document_id", + "response_code" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/peppol/inbound/{document_id}/response", + "bodyMapping": { + "response_code": "$response_code", + "status": "$status" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false + } + } + ] +} diff --git a/packages/backend/src/adapters/be/falco.live.spec.ts b/packages/backend/src/adapters/be/falco.live.spec.ts new file mode 100644 index 000000000..bda405f7b --- /dev/null +++ b/packages/backend/src/adapters/be/falco.live.spec.ts @@ -0,0 +1,159 @@ +import * as adapter from './falco.json'; + +/** + * Falco API v1 (docs.falco-app.be, OpenAPI on each reference page): production host + * https://api.falco-app.be/v1, two headers on every call (X-Falco-App-Secret of the developer + * application, X-Falco-Api-Key generated by the Falco customer for that application). + * Live part: FALCO_APP_SECRET=... FALCO_API_KEY=... RUN_FALCO_LIVE=1 npx jest src/adapters/be/falco + * (reads only: organization, purchase documents, Peppol inbound documents). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { type: string; baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'falco_'; +const WRITES: string[] = ["falco_set_purchase_payment_status", "falco_send_invoice_response"]; +const SWITCHED_OFF: string[] = ["falco_send_invoice_response", "falco_set_purchase_payment_status"]; + +describe('falco adapter: static spec conformance', () => { + + it('sends the application secret and the API key as the two documented headers to the production v1 host', () => { + expect(a.requiredEnvVars).toEqual(['FALCO_APP_SECRET', 'FALCO_API_KEY']); + expect(a.connector.baseUrl).toBe('https://api.falco-app.be/v1'); + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ headerName: 'X-Falco-Api-Key', apiKey: '{{FALCO_API_KEY}}', extraHeaders: { 'X-Falco-App-Secret': '{{FALCO_APP_SECRET}}' } }); + expect(a.envVarMeta?.FALCO_APP_SECRET.secret).toBe(true); + expect(a.envVarMeta?.FALCO_API_KEY.secret).toBe(true); + expect(new RegExp(a.envVarMeta?.FALCO_API_KEY.pattern ?? '').test('sk_live_abc123')).toBe(true); + expect(new RegExp(a.envVarMeta?.FALCO_API_KEY.pattern ?? '').test('sk_test_abc123')).toBe(false); + expect(a.probe?.tool).toBe('falco_get_organization'); + expect(tool('falco_get_organization').endpointMapping).toEqual({ method: 'GET', path: '/organization/whoami' }); + }); + + it('maps the purchase, sale and Peppol inbound routes with their documented filters', () => { + expect(tool('falco_list_purchase_documents').endpointMapping).toEqual({ + method: 'GET', + path: '/documents/purchases', + queryParams: { page: '$page', page_size: '$page_size', created_after: '$created_after', created_before: '$created_before', date_from: '$date_from', date_to: '$date_to', supplier_name: '$supplier_name', sort_by: '$sort_by', sort_direction: '$sort_direction' }, + }); + expect(tool('falco_get_purchase_document').endpointMapping.path).toBe('/documents/purchases/{document_id}'); + expect(tool('falco_download_purchase_document_file').endpointMapping.path).toBe('/documents/purchases/{document_id}/download/{file_identifier}'); + expect(tool('falco_list_sale_documents').endpointMapping).toMatchObject({ path: '/documents/sales', queryParams: { customer_name: '$customer_name', date_from: '$date_from' } }); + expect(tool('falco_get_sale_document').endpointMapping.path).toBe('/documents/sales/{document_id}'); + expect(tool('falco_list_peppol_inbound').endpointMapping).toEqual({ + method: 'GET', + path: '/peppol/inbound', + queryParams: { page: '$page', page_size: '$page_size', received_after: '$received_after', received_before: '$received_before', date_from: '$date_from', date_to: '$date_to', sender_vat_number: '$sender_vat_number', sender_name: '$sender_name', number: '$number', sort_by: '$sort_by', sort_direction: '$sort_direction' }, + }); + expect(tool('falco_get_peppol_inbound_document').endpointMapping.path).toBe('/peppol/inbound/{document_id}'); + expect(tool('falco_download_peppol_inbound_document').endpointMapping.path).toBe('/peppol/inbound/{document_id}/download'); + expect(tool('falco_find_peppol_participant').endpointMapping).toMatchObject({ path: '/peppol/participants/find', queryParams: { vat_number: '$vat_number' } }); + expect(tool('falco_check_peppol_participant').endpointMapping).toMatchObject({ path: '/peppol/participants/check', queryParams: { participant_identifier: '$participant_identifier' } }); + const ps = tool('falco_list_peppol_inbound').parameters?.properties?.page_size as { minimum: number; maximum: number }; + expect([ps.minimum, ps.maximum]).toEqual([20, 200]); + }); + + it('maps the invoicing module routes', () => { + expect(tool('falco_list_customers').endpointMapping).toMatchObject({ path: '/customers', queryParams: { q: '$q', vat: '$vat', email: '$email' } }); + expect(tool('falco_get_customer').endpointMapping.path).toBe('/customers/{customer_id}'); + expect(tool('falco_list_articles').endpointMapping).toMatchObject({ path: '/articles', queryParams: { q: '$q' } }); + expect(tool('falco_list_invoices').endpointMapping).toMatchObject({ path: '/invoices', queryParams: { document_type: '$document_type', status: '$status', customer_id: '$customer_id' } }); + expect((tool('falco_list_invoices').parameters?.properties?.page_size as { maximum: number }).maximum).toBe(100); + expect(tool('falco_get_invoice').endpointMapping.path).toBe('/invoices/{invoice_id}'); + expect(tool('falco_download_invoice_pdf').endpointMapping.path).toBe('/invoices/{invoice_id}/pdf'); + }); + + it('sets a purchase payment status and sends Peppol responses only when switched on', () => { + expect(tool('falco_set_purchase_payment_status').endpointMapping).toEqual({ method: 'PUT', path: '/documents/purchases/{document_id}/payment-status', bodyMapping: { payment_status: '$payment_status' } }); + expect(tool('falco_set_purchase_payment_status').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(tool('falco_send_invoice_response').endpointMapping).toEqual({ method: 'POST', path: '/peppol/inbound/{document_id}/response', bodyMapping: { response_code: '$response_code', status: '$status' } }); + expect(tool('falco_send_invoice_response').enabled).toBe(false); + expect(tool('falco_set_purchase_payment_status').enabled).toBe(false); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + if (t.endpointMapping.method === 'static') continue; + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated, the risky ones installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...SWITCHED_OFF].sort()); + for (const n of WRITES) expect(`${n}:${tool(n).annotations?.readOnlyHint}`).toBe(`${n}:false`); + for (const t of a.tools) { + if (WRITES.includes(t.name)) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (!['GET', 'static'].includes(t.endpointMapping.method) && !t.endpointMapping.method.startsWith('odata_')) expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + }); + + it('documents every tool in the instructions, marks secrets and has no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + if (a.probe) { + const probe = tool(a.probe.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + expect(probe.parameters?.required ?? []).toEqual([]); + } + }); +}); + +const live = process.env.RUN_FALCO_LIVE === '1' && process.env.FALCO_APP_SECRET && process.env.FALCO_API_KEY ? describe : describe.skip; +live('falco adapter: live (read-only)', () => { + const headers = { 'X-Falco-App-Secret': process.env.FALCO_APP_SECRET as string, 'X-Falco-Api-Key': process.env.FALCO_API_KEY as string }; + it('reads the organization, purchase documents and Peppol inbound documents', async () => { + const org = (await (await fetch(`${a.connector.baseUrl}/organization/whoami`, { headers })).json()) as { id: string; name: string }; + expect(typeof org.name).toBe('string'); + const purchases = (await (await fetch(`${a.connector.baseUrl}/documents/purchases?page=0&page_size=20`, { headers })).json()) as { data: unknown[] }; + expect(Array.isArray(purchases.data)).toBe(true); + const inbound = (await (await fetch(`${a.connector.baseUrl}/peppol/inbound?page=0&page_size=20`, { headers })).json()) as { data: unknown[] }; + expect(Array.isArray(inbound.data)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/de/xovi.json b/packages/backend/src/adapters/de/xovi.json new file mode 100644 index 000000000..35e5f9bb6 --- /dev/null +++ b/packages/backend/src/adapters/de/xovi.json @@ -0,0 +1,811 @@ +{ + "slug": "xovi", + "name": "XOVI", + "aliases": [ + "XOVI Suite", + "XOVI SEO" + ], + "description": "XOVI Suite SEO data, read-only: API credit balance, visibility index (OVI) history, ranking keywords, ranking value and keyword history of any domain, and the daily keyword monitoring of your projects.", + "region": "de", + "category": "analytics", + "icon": "xovi", + "docsUrl": "https://developers.xovi.net/einstieg/", + "instructions": "## XOVI\nThis connector reads the **XOVI Suite API** (developers.xovi.net), the German SEO toolbox: API credit balance, the weekly domain analysis (visibility index OVI, ranking keywords, ranking value, keyword history and search results) and the daily keyword monitoring of your projects. It is read-only and calls `https://suite.xovi.net/api` directly.\n\n**Setup**\n1. The API needs a XOVI Pro, Business or Enterprise account.\n2. In the XOVI Suite open **Account > API Dashboard**, create your personal API key and paste it into `XOVI_API_KEY`. It is sent as the `key` parameter on every call.\n3. Install and run `xovi_get_credit_state`, which costs no credits, to check the key.\n\n**Credits**: most calls are paid from the account's API credit budget (per call or per block of rows, see each function in the XOVI reference); calls that only read your own account data are free. Check the balance with `xovi_get_credit_state` and `xovi_get_limits` before large exports, keep `limit` small, and do not loop over many domains without the user's go-ahead. Monitoring keyword slots are counted separately: `xovi_get_monitoring_keyword_limits`.\n\n**Workflow**\n- Domain analysis (weekly crawl): pick the search engine host with `xovi_list_search_engines` (default weekly, e.g. `google.de`), then `xovi_get_ovi_trend` for visibility over time, `xovi_list_ranking_keywords` for what a domain ranks for, `xovi_get_ranking_value`, `xovi_get_ranking_trend`, `xovi_get_keyword_trend` for one keyword, `xovi_get_keyword_serp` for the results page of a keyword. `urlpattern` takes a host (`www.example.com`), all subdomains (`example.com/*`) or a directory (`www.example.com/blog/*`). Data of a week is published on Sunday; `date` is that Sunday.\n- Monitoring (daily crawl): `xovi_list_projects` (project hash), `xovi_list_labels`, `xovi_list_search_engines` with interval `daily` (numeric `sengineid`), then `xovi_list_monitoring_domains`, `xovi_list_monitoring_keywords`, `xovi_get_monitoring_keyword_trend`, `xovi_get_monitoring_serp` and `xovi_get_monitoring_ovi_trend`.\n\n**Paging**: `limit` is at most 1000 rows; `skip` counts pages of `limit` rows (skip 1 with limit 100 returns rows 101 to 200). Results carry `resultCount` for the unfiltered total.\n\n**Errors**: XOVI answers HTTP 200 with `apiErrorCode` and `apiErrorMessage`; this connector turns them into errors. `param invalid` on `key` means a wrong or deleted API key; `no permission` means the key or plan may not use that function; `cost error` means the credit budget is used up until the next refill; `param missing` or `param invalid` on another field names the field to fix. `result empty` is a successful call without data (e.g. a domain without rankings).", + "prerequisites": "A XOVI Pro, Business or Enterprise account and a personal API key from Account > API Dashboard in the XOVI Suite.", + "requiredEnvVars": [ + "XOVI_API_KEY" + ], + "envVarMeta": { + "XOVI_API_KEY": { + "label": "API key", + "kind": "credential", + "secret": true, + "help": "XOVI Suite, Account, API Dashboard: your personal API key.", + "link": "https://suite.xovi.net", + "pattern": "^\\S{8,}$", + "patternMessage": "Paste only the API key, without spaces or quotes." + } + }, + "verifyHints": { + "401": "XOVI refused the API key (param invalid: key). Copy it again from Account > API Dashboard in the XOVI Suite.", + "403": "The key works, but it or the plan has no permission for this function.", + "rate_limited": "The XOVI API credits are used up until the next refill. Check xovi_get_credit_state or book more credits with XOVI." + }, + "probe": { + "tool": "xovi_get_credit_state" + }, + "connector": { + "name": "XOVI Suite API", + "type": "REST", + "baseUrl": "https://suite.xovi.net/api", + "authType": "QUERY_AUTH", + "authConfig": { + "key": "{{XOVI_API_KEY}}" + }, + "config": { + "errorWhen": [ + { + "path": "paramname", + "equals": "key", + "messagePath": "apiErrorMessage", + "status": 401 + }, + { + "path": "apiErrorMessage", + "matches": "^no permission", + "status": 403 + }, + { + "path": "apiErrorMessage", + "matches": "^cost error", + "status": 429 + }, + { + "path": "apiErrorMessage", + "matches": "^internal error", + "status": 502 + }, + { + "path": "apiErrorMessage", + "matches": "^(param missing|param invalid|maximum limit|urlpattern not supported)", + "messagePath": [ + "apiErrorMessage", + "paramname" + ], + "status": 400 + } + ] + } + }, + "tools": [ + { + "name": "xovi_get_credit_state", + "description": "Get the API credit balance: credits per interval, additionally booked credits, credits used and left, and the last refill date. Costs no credits; use it to check the key.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/user/getCreditstate", + "queryParams": { + "format": "json" + } + } + }, + { + "name": "xovi_get_limits", + "description": "Get the usage limits of the XOVI account per tool area (name and value), e.g. API credits and free export credits.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/user/getXoviLimits", + "queryParams": { + "format": "json" + } + } + }, + { + "name": "xovi_list_search_engines", + "description": "List the search engines XOVI crawls: id, host (e.g. google.de), country and city. Weekly engines are used by the domain analysis tools (sengine), daily engines by the keyword monitoring tools (sengineid).", + "parameters": { + "type": "object", + "properties": { + "interval": { + "type": "string", + "description": "weekly (default, for domain analysis) or daily (for keyword monitoring).", + "enum": [ + "weekly", + "daily" + ] + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getSearchEngines", + "queryParams": { + "format": "json", + "interval": "$interval", + "limit": "$limit", + "skip": "$skip" + } + } + }, + { + "name": "xovi_get_ovi_trend", + "description": "Get the weekly visibility index (OVI) history of a domain, subdomain, directory or URL in one search engine: date and staticOvi per week.", + "parameters": { + "type": "object", + "properties": { + "urlpattern": { + "type": "string", + "description": "Domain or URL pattern, e.g. www.example.com, example.com/* for all subdomains, or www.example.com/blog/* for a directory." + }, + "sengine": { + "type": "string", + "description": "Weekly crawled search engine host, e.g. google.de, google.at, google.ch; see xovi_list_search_engines." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Start from this crawl date; default the latest." + } + }, + "required": [ + "urlpattern", + "sengine" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getStaticOviTrend", + "queryParams": { + "format": "json", + "urlpattern": "$urlpattern", + "sengine": "$sengine", + "limit": "$limit", + "skip": "$skip", + "date": "$date" + } + } + }, + { + "name": "xovi_list_ranking_keywords", + "description": "List the keywords a domain or URL ranks for in one search engine in a weekly crawl: keyword, best ranking URL, position, change to the previous week (N for new), CPC, search volume (sevo) and competition.", + "parameters": { + "type": "object", + "properties": { + "urlpattern": { + "type": "string", + "description": "Domain or URL pattern, e.g. www.example.com, example.com/* for all subdomains, or www.example.com/blog/* for a directory." + }, + "sengine": { + "type": "string", + "description": "Weekly crawled search engine host, e.g. google.de, google.at, google.ch; see xovi_list_search_engines." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. The Sunday of the crawl week; default the latest week." + } + }, + "required": [ + "urlpattern", + "sengine" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getKeywords", + "queryParams": { + "format": "json", + "urlpattern": "$urlpattern", + "sengine": "$sengine", + "limit": "$limit", + "skip": "$skip", + "date": "$date" + } + } + }, + { + "name": "xovi_get_ranking_value", + "description": "Get the ranking value of a domain or directory: its traffic value computed from positions, search volume, CPC and click share, for one week.", + "parameters": { + "type": "object", + "properties": { + "urlpattern": { + "type": "string", + "description": "Domain or URL pattern, e.g. www.example.com, example.com/* for all subdomains, or www.example.com/blog/* for a directory." + }, + "sengine": { + "type": "string", + "description": "Weekly crawled search engine host, e.g. google.de, google.at, google.ch; see xovi_list_search_engines." + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Default the previous week." + } + }, + "required": [ + "urlpattern", + "sengine" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getRankingValue", + "queryParams": { + "format": "json", + "urlpattern": "$urlpattern", + "sengine": "$sengine", + "date": "$date" + } + } + }, + { + "name": "xovi_get_ranking_trend", + "description": "Get the weekly history of a domain: number of ranking keywords, rankings and keywords on page one per date.", + "parameters": { + "type": "object", + "properties": { + "urlpattern": { + "type": "string", + "description": "Domain or URL pattern, e.g. www.example.com, example.com/* for all subdomains, or www.example.com/blog/* for a directory." + }, + "sengine": { + "type": "string", + "description": "Weekly crawled search engine host, e.g. google.de, google.at, google.ch; see xovi_list_search_engines." + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Start date of the history." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + } + }, + "required": [ + "urlpattern", + "sengine" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getRankingTrend", + "queryParams": { + "format": "json", + "urlpattern": "$urlpattern", + "sengine": "$sengine", + "date": "$date", + "limit": "$limit", + "skip": "$skip" + } + } + }, + { + "name": "xovi_get_keyword_trend", + "description": "Get the weekly ranking history of one keyword for a domain: date, position and ranking URL.", + "parameters": { + "type": "object", + "properties": { + "urlpattern": { + "type": "string", + "description": "Domain or URL pattern, e.g. www.example.com, example.com/* for all subdomains, or www.example.com/blog/* for a directory." + }, + "keyword": { + "type": "string", + "description": "The keyword, e.g. seo tool." + }, + "sengine": { + "type": "string", + "description": "Weekly crawled search engine host, e.g. google.de, google.at, google.ch; see xovi_list_search_engines." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Default today." + } + }, + "required": [ + "urlpattern", + "keyword", + "sengine" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getKeywordTrend", + "queryParams": { + "format": "json", + "urlpattern": "$urlpattern", + "keyword": "$keyword", + "sengine": "$sengine", + "limit": "$limit", + "skip": "$skip", + "date": "$date" + } + } + }, + { + "name": "xovi_get_keyword_serp", + "description": "Get the ranking URLs and positions for one keyword in a search engine (the search result page of the weekly crawl).", + "parameters": { + "type": "object", + "properties": { + "keyword": { + "type": "string", + "description": "The keyword." + }, + "sengine": { + "type": "string", + "description": "Weekly crawled search engine host, e.g. google.de, google.at, google.ch; see xovi_list_search_engines." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 10).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Default the previous week." + } + }, + "required": [ + "keyword", + "sengine" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/keywords/getKeywordRankings", + "queryParams": { + "format": "json", + "keyword": "$keyword", + "sengine": "$sengine", + "limit": "$limit", + "skip": "$skip", + "date": "$date" + } + } + }, + { + "name": "xovi_list_projects", + "description": "List the XOVI projects: hash (projhash for the monitoring OVI), name, main domain and competitors. Filter by domain.", + "parameters": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Only projects of this domain." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 10).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/project/getProjects", + "queryParams": { + "format": "json", + "domain": "$domain", + "limit": "$limit", + "skip": "$skip" + } + } + }, + { + "name": "xovi_list_labels", + "description": "List the labels with hash, name and categories; a hash is the labelid of the monitoring tools.", + "parameters": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Only labels of this category, e.g. projects." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/project/getLabels", + "queryParams": { + "format": "json", + "category": "$category", + "limit": "$limit", + "skip": "$skip" + } + } + }, + { + "name": "xovi_list_monitoring_domains", + "description": "List the domains in the daily keyword monitoring, optionally for one search engine.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 50).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "sengineid": { + "type": "string", + "description": "Id of a daily crawled search engine, from xovi_list_search_engines with interval daily." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/monitor/getDomains", + "queryParams": { + "format": "json", + "limit": "$limit", + "skip": "$skip", + "sengineid": "$sengineid" + } + } + }, + { + "name": "xovi_list_monitoring_keywords", + "description": "List the keywords in the daily monitoring with their latest position, change to the previous day, ranking URL, universal search type, search volume, CPC, device and labels. Filter by keyword, domain, search engine, label, ranking state, position range, URL, date or device.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "keyword": { + "type": "string", + "description": "Only this keyword." + }, + "domain": { + "type": "string", + "description": "Only this monitored domain." + }, + "sengineid": { + "type": "string", + "description": "Id of a daily crawled search engine, from xovi_list_search_engines with interval daily." + }, + "labelid": { + "type": "string", + "description": "Label hash, from xovi_list_labels." + }, + "isRanking": { + "type": "integer", + "description": "-1 all (default), 1 only ranking, 2 only not ranking.", + "enum": [ + -1, + 1, + 2 + ] + }, + "posMin": { + "type": "integer", + "description": "Lowest position (default 0)." + }, + "posMax": { + "type": "integer", + "description": "Highest position (default 120)." + }, + "url": { + "type": "string", + "description": "Only keywords whose best ranking URL is this one." + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Crawl day; default each keyword's latest crawl." + }, + "device": { + "type": "integer", + "description": "1 desktop, 2 tablet, 3 smartphone.", + "enum": [ + 1, + 2, + 3 + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/monitor/getKeywords", + "queryParams": { + "format": "json", + "limit": "$limit", + "skip": "$skip", + "keyword": "$keyword", + "domain": "$domain", + "sengineid": "$sengineid", + "labelid": "$labelid", + "isRanking": "$isRanking", + "posMin": "$posMin", + "posMax": "$posMax", + "url": "$url", + "date": "$date", + "device": "$device" + } + } + }, + { + "name": "xovi_get_monitoring_keyword_trend", + "description": "Get the daily ranking history of a monitored keyword for a domain: crawl date and time, position, change, URL and universal search type.", + "parameters": { + "type": "object", + "properties": { + "sengineid": { + "type": "string", + "description": "Id of a daily crawled search engine, from xovi_list_search_engines with interval daily." + }, + "domain": { + "type": "string", + "description": "Monitored domain, e.g. www.example.com." + }, + "keyword": { + "type": "string", + "description": "Monitored keyword." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 1).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "ignoreUSearch": { + "type": "integer", + "description": "-1 all (default), 1 only where universal search is ignored, 2 only where it counts.", + "enum": [ + -1, + 1, + 2 + ] + } + }, + "required": [ + "sengineid", + "domain", + "keyword" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/monitor/getKeywordTrend", + "queryParams": { + "format": "json", + "sengineid": "$sengineid", + "domain": "$domain", + "keyword": "$keyword", + "limit": "$limit", + "skip": "$skip", + "ignoreUSearch": "$ignoreUSearch" + } + } + }, + { + "name": "xovi_get_monitoring_serp", + "description": "Get the top 100 search results of a monitored keyword on a day: domain, URL, page, position and universal search type.", + "parameters": { + "type": "object", + "properties": { + "keyword": { + "type": "string", + "description": "Monitored keyword." + }, + "sengineid": { + "type": "string", + "description": "Id of a daily crawled search engine, from xovi_list_search_engines with interval daily." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 120).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Default yesterday." + } + }, + "required": [ + "keyword", + "sengineid" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/monitor/getKeywordRankings", + "queryParams": { + "format": "json", + "keyword": "$keyword", + "sengineid": "$sengineid", + "limit": "$limit", + "skip": "$skip", + "date": "$date" + } + } + }, + { + "name": "xovi_get_monitoring_ovi_trend", + "description": "Get the daily monitoring OVI history of a project domain, optionally per device, search engine, label or competitor.", + "parameters": { + "type": "object", + "properties": { + "projhash": { + "type": "string", + "description": "Project hash, from xovi_list_projects." + }, + "limit": { + "type": "integer", + "description": "Rows to return, at most 1000 (default 100).", + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Pages to skip; one page is limit rows (default 0).", + "minimum": 0 + }, + "device": { + "type": "integer", + "description": "1 desktop (default), 2 tablet, 3 smartphone.", + "enum": [ + 1, + 2, + 3 + ] + }, + "sengineid": { + "type": "string", + "description": "Id of a daily crawled search engine, from xovi_list_search_engines with interval daily." + }, + "labelid": { + "type": "string", + "description": "Label hash, from xovi_list_labels." + }, + "date": { + "type": "string", + "description": "Date YYYY-MM-DD. Start date; the nearest earlier OVI is used when none exists that day." + }, + "competitor": { + "type": "string", + "description": "Competitor domain of the project." + } + }, + "required": [ + "projhash" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/monitor/getOviTrend", + "queryParams": { + "format": "json", + "projhash": "$projhash", + "limit": "$limit", + "skip": "$skip", + "device": "$device", + "sengineid": "$sengineid", + "labelid": "$labelid", + "date": "$date", + "competitor": "$competitor" + } + } + }, + { + "name": "xovi_get_monitoring_keyword_limits", + "description": "Get how many monitoring keyword credits the account has in total and how many are used.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/monitor/getKeywordLimits", + "queryParams": { + "format": "json" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/de/xovi.live.spec.ts b/packages/backend/src/adapters/de/xovi.live.spec.ts new file mode 100644 index 000000000..3dfd69a88 --- /dev/null +++ b/packages/backend/src/adapters/de/xovi.live.spec.ts @@ -0,0 +1,160 @@ +import * as adapter from './xovi.json'; +import { assertNoResponseBodyError } from '../../connectors/engines/response-error.util'; +/** + * XOVI Suite API (developers.xovi.net, API reference per service and method): root + * https://suite.xovi.net/api//, personal key in the key parameter, JSON envelope + * {apiErrorCode, apiErrorMessage, apiResult} with errors reported inside HTTP 200. + * Live part: XOVI_API_KEY=... RUN_XOVI_LIVE=1 npx jest src/adapters/de/xovi + * (reads only: the credit state, which costs no credits). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { type: string; baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'xovi_'; +const WRITES: string[] = []; +const SWITCHED_OFF: string[] = []; + +describe('xovi adapter: static spec conformance', () => { + + it('calls the XOVI Suite API directly with the key as query parameter and JSON output', () => { + expect(a.requiredEnvVars).toEqual(['XOVI_API_KEY']); + expect(a.connector.baseUrl).toBe('https://suite.xovi.net/api'); + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig).toEqual({ key: '{{XOVI_API_KEY}}' }); + expect(a.envVarMeta?.XOVI_API_KEY.secret).toBe(true); + expect(JSON.stringify(a)).not.toMatch(/workers\.dev|proxy/i); + for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.queryParams?.format}`).toBe(`${t.name}:json`); + expect(a.probe?.tool).toBe('xovi_get_credit_state'); + expect(tool('xovi_get_credit_state').endpointMapping.path).toBe('/user/getCreditstate'); + }); + + it('maps the documented service/method routes and their parameters', () => { + expect(tool('xovi_get_ovi_trend').endpointMapping).toEqual({ method: 'GET', path: '/keywords/getStaticOviTrend', queryParams: { format: 'json', urlpattern: '$urlpattern', sengine: '$sengine', limit: '$limit', skip: '$skip', date: '$date' } }); + expect(tool('xovi_list_ranking_keywords').endpointMapping).toMatchObject({ path: '/keywords/getKeywords', queryParams: { urlpattern: '$urlpattern', sengine: '$sengine' } }); + expect(tool('xovi_get_ranking_value').endpointMapping.path).toBe('/keywords/getRankingValue'); + expect(tool('xovi_get_ranking_trend').endpointMapping.path).toBe('/keywords/getRankingTrend'); + expect(tool('xovi_get_keyword_trend').endpointMapping).toMatchObject({ path: '/keywords/getKeywordTrend', queryParams: { keyword: '$keyword' } }); + expect(tool('xovi_get_keyword_serp').endpointMapping.path).toBe('/keywords/getKeywordRankings'); + expect(tool('xovi_list_search_engines').endpointMapping).toMatchObject({ path: '/keywords/getSearchEngines', queryParams: { interval: '$interval' } }); + expect(tool('xovi_list_monitoring_domains').endpointMapping).toMatchObject({ path: '/monitor/getDomains', queryParams: { sengineid: '$sengineid' } }); + expect(tool('xovi_list_monitoring_keywords').endpointMapping).toMatchObject({ path: '/monitor/getKeywords', queryParams: { isRanking: '$isRanking', posMin: '$posMin', posMax: '$posMax', labelid: '$labelid' } }); + expect(tool('xovi_get_monitoring_keyword_trend').parameters?.required).toEqual(['sengineid', 'domain', 'keyword']); + expect(tool('xovi_get_monitoring_serp').endpointMapping.path).toBe('/monitor/getKeywordRankings'); + expect(tool('xovi_get_monitoring_ovi_trend').endpointMapping).toMatchObject({ path: '/monitor/getOviTrend', queryParams: { projhash: '$projhash' } }); + expect(tool('xovi_get_monitoring_keyword_limits').endpointMapping.path).toBe('/monitor/getKeywordLimits'); + expect(tool('xovi_list_projects').endpointMapping.path).toBe('/project/getProjects'); + expect(tool('xovi_list_labels').endpointMapping.path).toBe('/project/getLabels'); + expect(tool('xovi_get_limits').endpointMapping.path).toBe('/user/getXoviLimits'); + for (const t of a.tools) { + const lim = t.parameters?.properties?.limit as { maximum?: number } | undefined; + if (lim) expect(`${t.name}:${lim.maximum}`).toBe(`${t.name}:1000`); + } + }); + + it('is read-only', () => { + for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.method}`).toBe(`${t.name}:GET`); + }); + + it('turns the errors XOVI reports inside HTTP 200 into failed calls', () => { + const rules = (a.connector.config as { errorWhen: unknown }).errorWhen; + const status = (body: unknown) => { + try { + assertNoResponseBodyError(body, rules); + return 200; + } catch (e) { + return (e as { status: number }).status; + } + }; + expect(status({ apiErrorCode: 2, apiErrorMessage: 'param invalid', paramname: 'key' })).toBe(401); + expect(status({ apiErrorCode: 1, apiErrorMessage: 'param missing', paramname: 'key' })).toBe(401); + expect(status({ apiErrorCode: 2, apiErrorMessage: 'param invalid', paramname: 'sengine' })).toBe(400); + expect(status({ apiErrorCode: 4, apiErrorMessage: 'no permission' })).toBe(403); + expect(status({ apiErrorCode: 7, apiErrorMessage: 'cost error' })).toBe(429); + expect(status({ apiErrorCode: 0, apiErrorMessage: '0k.', apiResult: { creditsleft: 5280 } })).toBe(200); + expect(status({ apiErrorCode: 6, apiErrorMessage: 'result empty', apiResult: [] })).toBe(200); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + if (t.endpointMapping.method === 'static') continue; + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated, the risky ones installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...SWITCHED_OFF].sort()); + for (const n of WRITES) expect(`${n}:${tool(n).annotations?.readOnlyHint}`).toBe(`${n}:false`); + for (const t of a.tools) { + if (WRITES.includes(t.name)) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (!['GET', 'static'].includes(t.endpointMapping.method) && !t.endpointMapping.method.startsWith('odata_')) expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + }); + + it('documents every tool in the instructions, marks secrets and has no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + if (a.probe) { + const probe = tool(a.probe.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + expect(probe.parameters?.required ?? []).toEqual([]); + } + }); +}); + +const live = process.env.RUN_XOVI_LIVE === '1' && process.env.XOVI_API_KEY ? describe : describe.skip; +live('xovi adapter: live (read-only)', () => { + it('reads the credit state (free of credits)', async () => { + const url = `${a.connector.baseUrl}/user/getCreditstate?format=json&key=${encodeURIComponent(process.env.XOVI_API_KEY ?? '')}`; + const body = (await (await fetch(url)).json()) as { apiErrorCode: number; apiResult: { creditsleft: number } }; + expect(body.apiErrorCode).toBe(0); + expect(typeof body.apiResult.creditsleft).toBe('number'); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/blockcypher.json b/packages/backend/src/adapters/intl/blockcypher.json new file mode 100644 index 000000000..285da55a8 --- /dev/null +++ b/packages/backend/src/adapters/intl/blockcypher.json @@ -0,0 +1,437 @@ +{ + "slug": "blockcypher", + "name": "BlockCypher", + "aliases": [ + "BlockCypher API", + "Bitcoin blockchain API" + ], + "description": "Public blockchain data from BlockCypher for Bitcoin, Litecoin, Dogecoin, Dash and Ethereum: chain state and fee estimates, blocks, address balances, address transaction histories, single transactions and the mempool. Read-only, works without a token on the free tier.", + "instructions": "## BlockCypher\nThis connector reads public blockchain data from the **BlockCypher API v1** (`https://api.blockcypher.com/v1`): chain state and fee estimates, blocks, address balances and histories, transactions and unconfirmed transactions on Bitcoin, Litecoin, Dogecoin, Dash and Ethereum. All tools are read-only; nothing is sent, signed or created.\n\n**Setup**\n- Nothing is required: BlockCypher answers read-only GET calls without a token.\n- Optional `BLOCKCYPHER_TOKEN`: sign up at https://accounts.blockcypher.com, copy the token from the dashboard. It is needed for `blockcypher_get_transaction_confidence`, for wallet names, and to raise limits on a paid plan. It is sent as the `token` query parameter.\n\n**Networks**: every tool takes `network` as `coin/chain`: `btc/main`, `ltc/main`, `doge/main`, `dash/main`, `eth/main`, or the test chains `btc/test3`, `bcy/test`, `beth/test`. Ethereum addresses are written without `0x`.\n\n**Amounts**: always in the smallest unit. Bitcoin, Litecoin, Dogecoin and Dash have 10^8 units per coin (100,000,000 satoshi = 1 BTC); Ethereum has 10^18 wei per ether. Convert before quoting an amount to the user. Times are ISO 8601 in UTC.\n\n**Workflow**\n- Where is the chain now: `blockcypher_get_chain` (height, latest block, fee estimates per kB).\n- How much is on an address: `blockcypher_get_address_balance` (`final_balance` includes unconfirmed transactions, `balance` only confirmed ones).\n- What happened on an address: `blockcypher_get_address` for light references or UTXOs (`unspent_only`), `blockcypher_get_address_full` for complete transactions. Both return newest first; while `hasMore` is true, call again with `before` = the lowest `block_height` you received. To fetch only what is new since a known height, use `after`.\n- One transaction: `blockcypher_get_transaction`; confirmations = chain height - block height + 1. For a still unconfirmed one, `blockcypher_get_transaction_confidence` (token required).\n- Blocks: `blockcypher_get_block` by hash or height. Mempool: `blockcypher_list_unconfirmed_transactions`.\n\n**Limits**: the free tier allows 3 requests per second and 100 requests per hour, with or without a token (confidence lookups 10 per hour); the hourly window resets at the top of the hour UTC. `blockcypher_get_chain` returns the remaining hourly calls in `_headers.x-ratelimit-remaining`. Prefer the balance call over the full address call, and keep `limit` small.\n\n**Errors**: 429 means the hourly or per-second limit is used up; wait until the next full hour or add a paid token. 404 means an unknown address, hash or block on that network (check that the address belongs to the chosen coin). 401 on the confidence call means the token is missing or wrong.\n\n**Limitations**: no sending, signing, wallet management, webhooks or asset (OAP) calls. Ethereum token (ERC-20) balances are not covered.", + "region": "intl", + "category": "data", + "icon": "blockcypher", + "docsUrl": "https://www.blockcypher.com/dev/bitcoin/", + "requiredEnvVars": [], + "optionalEnvVars": [ + "BLOCKCYPHER_TOKEN" + ], + "envVarMeta": { + "BLOCKCYPHER_TOKEN": { + "label": "API token (optional)", + "kind": "credential", + "secret": true, + "help": "Leave empty for the free keyless tier. With an account at accounts.blockcypher.com, copy the token from the dashboard; it is needed for transaction confidence and higher paid limits.", + "link": "https://accounts.blockcypher.com/", + "pattern": "^[A-Fa-f0-9]{32}$", + "patternMessage": "Paste only the 32-character token from the BlockCypher dashboard." + } + }, + "verifyHints": { + "401": "BlockCypher refused the token. Copy it again from accounts.blockcypher.com, or leave BLOCKCYPHER_TOKEN empty for the keyless free tier.", + "429": "The BlockCypher free tier allows 100 requests per hour and 3 per second. Wait until the next full hour (UTC) or use a paid token." + }, + "probe": { + "tool": "blockcypher_get_chain", + "params": { + "network": "btc/main" + } + }, + "connector": { + "name": "BlockCypher API v1", + "type": "REST", + "baseUrl": "https://api.blockcypher.com/v1", + "authType": "NONE", + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "blockcypher_get_chain", + "description": "Get the current state of a blockchain: height, hash and time of the latest block, previous block, unconfirmed transaction count, peer count and the fee estimates (high_fee_per_kb, medium_fee_per_kb, low_fee_per_kb in the smallest unit). Use it as the first call and to know the current block height for confirmations.", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + } + }, + "required": [ + "network" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}", + "queryParams": { + "token": "${BLOCKCYPHER_TOKEN}" + }, + "exposeHeaders": [ + "x-ratelimit-remaining" + ] + } + }, + { + "name": "blockcypher_get_block", + "description": "Get one block by its hash or its height: time, total amount transacted, fees, transaction count and the transaction hashes in block order (up to limit, default 20, max 500; page with txstart).", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "block": { + "type": "string", + "description": "Block hash (hex) or block height (integer as text), e.g. 294322." + }, + "txstart": { + "type": "integer", + "description": "Only list transaction hashes after this index in the block (for paging)." + }, + "limit": { + "type": "integer", + "description": "Maximum transaction hashes to list, up to 500." + } + }, + "required": [ + "network", + "block" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/blocks/{block}", + "queryParams": { + "txstart": "$txstart", + "limit": "$limit", + "token": "${BLOCKCYPHER_TOKEN}" + } + } + }, + { + "name": "blockcypher_get_address_balance", + "description": "Get the balance of an address, fastest call: balance, unconfirmed_balance and final_balance, total_received, total_sent and the number of confirmed and unconfirmed transactions. Amounts are in the smallest unit (satoshi, litoshi, koinu, duff, or wei on Ethereum).", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "address": { + "type": "string", + "description": "Public address on that chain, e.g. 1DEP8i3QJCsomS4BSMY2RpU1upv62aGvhD (Bitcoin) or 738d145faabb1e00cf5a017588a9c0f998318012 (Ethereum, without 0x). A wallet name created with a token also works." + } + }, + "required": [ + "network", + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/addrs/{address}/balance", + "queryParams": { + "token": "${BLOCKCYPHER_TOKEN}" + } + } + }, + { + "name": "blockcypher_get_address", + "description": "Get an address with its balance and transaction references (txrefs: tx_hash, block_height, value, spent, confirmations, tx_input_n / tx_output_n; unconfirmed ones in unconfirmed_txrefs), newest first. Lighter than blockcypher_get_address_full. Use unspent_only for UTXOs. Page with before = the lowest block_height of the previous page while hasMore is true.", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "address": { + "type": "string", + "description": "Public address on that chain, e.g. 1DEP8i3QJCsomS4BSMY2RpU1upv62aGvhD (Bitcoin) or 738d145faabb1e00cf5a017588a9c0f998318012 (Ethereum, without 0x). A wallet name created with a token also works." + }, + "unspent_only": { + "type": "boolean", + "description": "Only unspent outputs (UTXOs)." + }, + "before": { + "type": "integer", + "description": "Only transactions below this block height (paging)." + }, + "after": { + "type": "integer", + "description": "Only transactions above this block height." + }, + "limit": { + "type": "integer", + "description": "Minimum number of txrefs to return, default 200, max 2000." + }, + "confirmations": { + "type": "integer", + "description": "Only count transactions with at least this many confirmations." + }, + "include_confidence": { + "type": "boolean", + "description": "Add the confidence figure to unconfirmed txrefs." + } + }, + "required": [ + "network", + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/addrs/{address}", + "queryParams": { + "unspentOnly": "$unspent_only", + "before": "$before", + "after": "$after", + "limit": "$limit", + "confirmations": "$confirmations", + "includeConfidence": "$include_confidence", + "token": "${BLOCKCYPHER_TOKEN}" + } + } + }, + { + "name": "blockcypher_get_address_full", + "description": "Get an address with its complete transactions (inputs, outputs, fees, confirmations, block height and time), newest first. The slowest address call: default 10 transactions, max 50. Page with before = the lowest block_height of the previous page while hasMore is true; txlimit caps inputs and outputs per transaction.", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "address": { + "type": "string", + "description": "Public address on that chain, e.g. 1DEP8i3QJCsomS4BSMY2RpU1upv62aGvhD (Bitcoin) or 738d145faabb1e00cf5a017588a9c0f998318012 (Ethereum, without 0x). A wallet name created with a token also works." + }, + "before": { + "type": "integer", + "description": "Only transactions below this block height (paging)." + }, + "after": { + "type": "integer", + "description": "Only transactions above this block height, e.g. to fetch only what is new since the last check." + }, + "limit": { + "type": "integer", + "description": "Number of transactions, default 10, max 50." + }, + "txlimit": { + "type": "integer", + "description": "Maximum inputs and outputs listed per transaction." + }, + "confirmations": { + "type": "integer", + "description": "Only transactions with at least this many confirmations." + } + }, + "required": [ + "network", + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/addrs/{address}/full", + "queryParams": { + "before": "$before", + "after": "$after", + "limit": "$limit", + "txlimit": "$txlimit", + "confirmations": "$confirmations", + "token": "${BLOCKCYPHER_TOKEN}" + } + } + }, + { + "name": "blockcypher_get_transaction", + "description": "Get one transaction by its hash: block height and hash, confirmations, received and confirmed time, total, fees, size, double_spend flag and its inputs and outputs (addresses and values in the smallest unit). Inputs and outputs are capped by limit (default 20); page with instart / outstart.", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "tx_hash": { + "type": "string", + "description": "Transaction hash (hex), e.g. f854aebae95150b379cc1187d848d58225f3c4157fe992bcd166f58bd5063449." + }, + "limit": { + "type": "integer", + "description": "Maximum inputs and outputs to list, default 20." + }, + "instart": { + "type": "integer", + "description": "Only list inputs from this index." + }, + "outstart": { + "type": "integer", + "description": "Only list outputs from this index." + } + }, + "required": [ + "network", + "tx_hash" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/txs/{tx_hash}", + "queryParams": { + "limit": "$limit", + "instart": "$instart", + "outstart": "$outstart", + "token": "${BLOCKCYPHER_TOKEN}" + } + } + }, + { + "name": "blockcypher_get_transaction_confidence", + "description": "Get the confidence that an unconfirmed transaction will be confirmed (confidence 0 to 1, receive_count, age_millis). Needs a BLOCKCYPHER_TOKEN; the free tier allows 10 such lookups per hour.", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "tx_hash": { + "type": "string", + "description": "Hash of an unconfirmed transaction (hex)." + } + }, + "required": [ + "network", + "tx_hash" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/txs/{tx_hash}/confidence", + "queryParams": { + "token": "${BLOCKCYPHER_TOKEN}" + } + } + }, + { + "name": "blockcypher_list_unconfirmed_transactions", + "description": "List the latest transactions relayed to the network that are not yet in a block (mempool), newest first: hash, total, fees, inputs and outputs. Default 10, max 100.", + "parameters": { + "type": "object", + "properties": { + "network": { + "type": "string", + "description": "Blockchain as coin/chain: btc/main (Bitcoin), ltc/main (Litecoin), doge/main (Dogecoin), dash/main (Dash), eth/main (Ethereum), btc/test3 (Bitcoin testnet), bcy/test and beth/test (BlockCypher test chains).", + "enum": [ + "btc/main", + "btc/test3", + "ltc/main", + "doge/main", + "dash/main", + "eth/main", + "bcy/test", + "beth/test" + ] + }, + "limit": { + "type": "integer", + "description": "Number of transactions, default 10, max 100." + }, + "min_value": { + "type": "integer", + "description": "Only transactions moving more than this amount, in the smallest unit." + } + }, + "required": [ + "network" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/{network}/txs", + "queryParams": { + "limit": "$limit", + "minValue": "$min_value", + "token": "${BLOCKCYPHER_TOKEN}" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/blockcypher.live.spec.ts b/packages/backend/src/adapters/intl/blockcypher.live.spec.ts new file mode 100644 index 000000000..6f9b15d21 --- /dev/null +++ b/packages/backend/src/adapters/intl/blockcypher.live.spec.ts @@ -0,0 +1,181 @@ +import * as adapter from './blockcypher.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * BlockCypher API v1 (www.blockcypher.com/dev/bitcoin and /dev/ethereum): https://api.blockcypher.com/v1/{coin}/{chain}/..., + * keyless for read-only GET calls, optional token as the `token` query parameter. + * Live part: RUN_BLOCKCYPHER_LIVE=1 npx jest src/adapters/intl/blockcypher (BLOCKCYPHER_TOKEN=... optional) + * (reads only: chain state, one address balance, one block; mind the 100 requests per hour of the free tier). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'blockcypher_'; +const WRITES: string[] = []; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('blockcypher adapter: static spec conformance', () => { + it('needs no credentials and sends the optional token only when it is set', () => { + expect(a.requiredEnvVars).toEqual([]); + expect(a.optionalEnvVars).toEqual(['BLOCKCYPHER_TOKEN']); + expect(a.connector.authType).toBe('NONE'); + expect(a.connector.baseUrl).toBe('https://api.blockcypher.com/v1'); + expect(a.envVarMeta?.BLOCKCYPHER_TOKEN.secret).toBe(true); + for (const t of a.tools) { + expect(t.endpointMapping.method).toBe('GET'); + expect(t.endpointMapping.queryParams?.token).toBe('${BLOCKCYPHER_TOKEN}'); + expect(t.endpointMapping.path.startsWith('/{network}')).toBe(true); + expect(t.parameters?.required).toContain('network'); + } + }); + + it('maps the documented chain, block, address and transaction routes', () => { + expect(tool('blockcypher_get_chain').endpointMapping.path).toBe('/{network}'); + expect(tool('blockcypher_get_block').endpointMapping).toMatchObject({ path: '/{network}/blocks/{block}', queryParams: { txstart: '$txstart', limit: '$limit' } }); + expect(tool('blockcypher_get_address_balance').endpointMapping.path).toBe('/{network}/addrs/{address}/balance'); + expect(tool('blockcypher_get_address').endpointMapping).toMatchObject({ path: '/{network}/addrs/{address}', queryParams: { unspentOnly: '$unspent_only', before: '$before', after: '$after', limit: '$limit', includeConfidence: '$include_confidence' } }); + expect(tool('blockcypher_get_address_full').endpointMapping).toMatchObject({ path: '/{network}/addrs/{address}/full', queryParams: { before: '$before', after: '$after', limit: '$limit', txlimit: '$txlimit', confirmations: '$confirmations' } }); + expect(tool('blockcypher_get_transaction').endpointMapping).toMatchObject({ path: '/{network}/txs/{tx_hash}', queryParams: { limit: '$limit', instart: '$instart', outstart: '$outstart' } }); + expect(tool('blockcypher_get_transaction_confidence').endpointMapping.path).toBe('/{network}/txs/{tx_hash}/confidence'); + expect(tool('blockcypher_list_unconfirmed_transactions').endpointMapping).toMatchObject({ path: '/{network}/txs', queryParams: { limit: '$limit', minValue: '$min_value' } }); + }); + + it('offers exactly the documented coin/chain pairs', () => { + const net = (tool('blockcypher_get_chain').parameters?.properties as Record).network.enum; + expect(net).toEqual(['btc/main', 'btc/test3', 'ltc/main', 'doge/main', 'dash/main', 'eth/main', 'bcy/test', 'beth/test']); + expect(a.probe).toEqual({ tool: 'blockcypher_get_chain', params: { network: 'btc/main' } }); + }); + + it('builds the request URL with coin/chain and leaves the token out when it is not set', async () => { + const keyless = await request('blockcypher_get_address_full', { network: 'eth/main', address: '738d145faabb1e00cf5a017588a9c0f998318012', limit: 5 }); + expect(keyless.url).toBe('https://api.blockcypher.com/v1/eth/main/addrs/738d145faabb1e00cf5a017588a9c0f998318012/full'); + expect(keyless.params).toEqual({ limit: 5 }); + const withToken = await request('blockcypher_get_chain', { network: 'btc/main' }, { BLOCKCYPHER_TOKEN: 'abc' }); + expect(withToken.url).toBe('https://api.blockcypher.com/v1/btc/main'); + expect(withToken.params).toEqual({ token: 'abc' }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe!.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + for (const r of probe.parameters?.required ?? []) expect(a.probe!.params?.[r]).toBeDefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_BLOCKCYPHER_LIVE === '1' ? describe : describe.skip; +live('blockcypher adapter: live (read-only)', () => { + const token = process.env.BLOCKCYPHER_TOKEN ? `?token=${process.env.BLOCKCYPHER_TOKEN}` : ''; + const get = async (path: string) => { + const res = await fetch(`https://api.blockcypher.com/v1${path}${token}`, { headers: { Accept: 'application/json' } }); + expect(res.status).toBe(200); + return res.json() as Promise>; + }; + it('reads the chain, an address balance and a block', async () => { + const chain = await get('/btc/main'); + expect(chain.name).toBe('BTC.main'); + expect(typeof chain.height).toBe('number'); + const bal = await get('/btc/main/addrs/1DEP8i3QJCsomS4BSMY2RpU1upv62aGvhD/balance'); + expect(typeof bal.final_balance).toBe('number'); + const block = await get('/btc/main/blocks/294322'); + expect(block.height).toBe(294322); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/checko.json b/packages/backend/src/adapters/intl/checko.json new file mode 100644 index 000000000..4ed650d62 --- /dev/null +++ b/packages/backend/src/adapters/intl/checko.json @@ -0,0 +1,724 @@ +{ + "slug": "checko", + "name": "Checko", + "aliases": [ + "Чекко", + "Checko.ru", + "Checko API ЕГРЮЛ" + ], + "description": "Checko due diligence on Russian companies and entrepreneurs (EGRUL, EGRIP): company details, directors and founders, contacts, financial statements, reliability score, arbitration cases, enforcement proceedings, public procurement contracts, Fedresurs and bankruptcy messages, inspections, banks and search. Read-only.", + "instructions": "## Checko\nThis connector uses the **Checko API v2** (`https://api.checko.ru/v2`), a due-diligence service on Russian official registers: organisations (EGRUL) and entrepreneurs (EGRIP) with directors, founders, contacts, taxes and risk factors, financial statements, reliability scores, arbitration cases, FSSP enforcement proceedings, public procurement contracts, Fedresurs and EFRSB bankruptcy messages, inspections and banks. Everything is read-only.\n\n**Setup (2 minutes)**\n1. Register at https://checko.ru and open the personal account (**Личный кабинет**); the API key is shown there right after registration.\n2. Paste it into `CHECKO_API_KEY`. It is sent as the `key` query parameter.\n3. Run `checko_get_bank` (the install check reads Sberbank, BIC 044525225) to check the key.\n\n**Identifiers**: INN has 10 digits for an organisation and 12 for an entrepreneur or a person; OGRN has 13 digits (organisation), OGRNIP 15 (entrepreneur). Most tools take `inn` or `ogrn`; give one of them. A branch shares the INN of its head organisation, which is what is returned.\n\n**Workflow**\n- Find a company: `checko_search` (`by`=name, `obj`=org, a name of at least 4 characters; also by director, founder, phone, e-mail, OKVED code or registration date), then `checko_get_company` with the INN.\n- Due diligence on a counterparty: `checko_get_company` (status, directors, founders, tax debts, risk factors), `checko_get_reliability`, `checko_get_finances` (revenue line 2110, net profit 2400), `checko_list_legal_cases` (role defendant), `checko_list_enforcements`, `checko_list_bankruptcy_messages`, `checko_list_fedresurs_messages`, `checko_list_inspections`.\n- Entrepreneurs and people: `checko_get_entrepreneur`, `checko_get_person` (companies where a person is director or founder), `checko_get_timeline` for the change history.\n- Public procurement: `checko_list_contracts` with `law` 44, 94 or 223 and `role` customer or supplier.\n- Banks: `checko_get_bank` by BIC.\n\n**Paging**: list tools return up to 100 rows per page (`limit`, `page`); answers carry the total count. Every answer has `meta` with `status`, `today_request_count` and the account `balance` in roubles.\n\n**Costs and limits**: the first 100 requests per day are free on every plan; after that each successful request (HTTP 200) is billed at the plan price (from 0.10 to 0.15 roubles); errors are free. Technical limit 32 requests per second per IP address. Prefer one targeted call over many broad ones.\n\n**Errors**: 401 with `meta.message` means a missing or invalid key. 402 or a balance message means the free daily quota is used and the balance is empty. 404 means nothing found for that INN or OGRN. Error answers carry `meta.status` = error and the reason in `meta.message` (Russian).\n\n**Limitations**: Belarus companies, the raw EGRUL source data and the bulk datasets are not part of this connector.", + "region": "intl", + "category": "enrichment", + "icon": "checko", + "docsUrl": "https://checko.ru/integration/api", + "requiredEnvVars": [ + "CHECKO_API_KEY" + ], + "envVarMeta": { + "CHECKO_API_KEY": { + "label": "API key", + "kind": "credential", + "secret": true, + "help": "checko.ru, personal account (Личный кабинет): the API key shown after registration. 100 requests a day are free.", + "link": "https://checko.ru/integration/api", + "pattern": "^[A-Za-z0-9]{8,64}$", + "patternMessage": "Paste only the API key from the Checko personal account." + } + }, + "verifyHints": { + "401": "Checko refused the API key. Copy it again from the personal account at checko.ru.", + "402": "The 100 free requests of today are used and the Checko balance is empty. Top up the balance or wait until tomorrow.", + "429": "More than 32 requests per second from this address. Slow down." + }, + "probe": { + "tool": "checko_get_bank", + "params": { + "bic": "044525225" + } + }, + "connector": { + "name": "Checko API v2", + "type": "REST", + "baseUrl": "https://api.checko.ru/v2", + "authType": "QUERY_AUTH", + "authConfig": { + "key": "{{CHECKO_API_KEY}}" + }, + "headers": { + "Accept": "application/json" + }, + "config": { + "errorWhen": [ + { + "path": "meta.status", + "equals": "error", + "messagePath": "meta.message", + "messageMatches": "API-ключ|ключ", + "status": 401 + }, + { + "path": "meta.status", + "equals": "error", + "messagePath": "meta.message", + "status": 400 + } + ] + } + }, + "tools": [ + { + "name": "checko_search", + "description": "Search Russian organisations or entrepreneurs in the EGRUL / EGRIP registers by name, founder, director, OKVED activity code, registration or update date, phone or e-mail. Returns OGRN, INN, KPP, names, status, region, address and main OKVED per hit, 100 per page. Use it to find the INN, then call the detail tools.", + "parameters": { + "type": "object", + "properties": { + "by": { + "type": "string", + "description": "Search type: name (organisation name or entrepreneur full name), founder-name, leader-name, okved, reg-date, upd-date, phone, email. founder-name, leader-name, phone and email work for organisations only.", + "enum": [ + "name", + "founder-name", + "leader-name", + "okved", + "reg-date", + "upd-date", + "phone", + "email" + ] + }, + "obj": { + "type": "string", + "description": "org for organisations, ent for individual entrepreneurs.", + "enum": [ + "org", + "ent" + ] + }, + "query": { + "type": "string", + "description": "Search text: at least 4 characters for names; YYYY-MM-DD for dates; +7XXXXXXXXXX or 8XXXXXXXXXX for phones; an OKVED code for okved." + }, + "region": { + "type": "string", + "description": "Two-digit region code, e.g. 77 for Moscow." + }, + "okved": { + "type": "string", + "description": "Only this main OKVED-2 code (not with by=okved)." + }, + "opf": { + "type": "string", + "description": "Only this legal form code (OKOPF, 2 or 5 digits); organisations searched by okved, dates, phone or e-mail." + }, + "active": { + "type": "boolean", + "description": "Only active organisations or entrepreneurs." + }, + "all_okved_codes": { + "type": "string", + "description": "With by=okved, set to all to also match additional activity codes, not only the main one.", + "enum": [ + "all" + ] + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + } + }, + "required": [ + "by", + "obj", + "query" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "by": "$by", + "obj": "$obj", + "query": "$query", + "region": "$region", + "okved": "$okved", + "opf": "$opf", + "active": "$active", + "codes": "$all_okved_codes", + "limit": "$limit", + "page": "$page" + } + } + }, + { + "name": "checko_get_company", + "description": "Get a Russian organisation from EGRUL by INN, OGRN or OKPO: names, status and liquidation, registration, legal address, main and additional OKVED, directors, founders and shares, related companies, licences, branches, contacts (phones, e-mails, website), taxes and tax debts, SME register, risk factors and sanctions. Give one of inn, ogrn or okpo.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "Organisation INN, 10 digits, e.g. 7707083893." + }, + "ogrn": { + "type": "string", + "description": "Organisation OGRN, 13 digits." + }, + "okpo": { + "type": "string", + "description": "OKPO code, if neither INN nor OGRN is known." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/company", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "okpo": "$okpo" + } + } + }, + { + "name": "checko_get_entrepreneur", + "description": "Get an individual entrepreneur (IP) from EGRIP by INN, OGRNIP or OKPO: full name, status, registration and termination, OKVED codes, region, contacts, SME register, taxes and risk factors. Give one of inn, ogrn or okpo.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "Entrepreneur INN, 12 digits." + }, + "ogrn": { + "type": "string", + "description": "OGRNIP, 15 digits." + }, + "okpo": { + "type": "string", + "description": "OKPO code, if neither INN nor OGRNIP is known." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/entrepreneur", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "okpo": "$okpo" + } + } + }, + { + "name": "checko_get_person", + "description": "Get the business links of a private person by personal INN: organisations where the person is director or founder, and entrepreneur registrations, each with status, region, address and main OKVED.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "Personal INN, 12 digits.", + "pattern": "^[0-9]{12}$" + } + }, + "required": [ + "inn" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/person", + "queryParams": { + "inn": "$inn" + } + } + }, + { + "name": "checko_get_finances", + "description": "Get the financial statements (balance sheet and profit and loss lines by code, e.g. 2110 revenue, 2400 net profit, 1600 total assets) of an organisation for 2011 to 2025 from Rosstat and the FNS GIR BO register. extended=true returns the extended version.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "extended": { + "type": "boolean", + "description": "Return the extended statements." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/finances", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "extended": "$extended" + } + } + }, + { + "name": "checko_get_reliability", + "description": "Get a reliability assessment of an organisation or entrepreneur: business scale, financial risk, positive and negative factors with an overall verdict. A quick due-diligence answer before a contract.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/reliability", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn" + } + } + }, + { + "name": "checko_get_timeline", + "description": "Get the history of events and register changes of an organisation, entrepreneur or person: registration, changes of directors, founders, addresses, capital, statements filed, bankruptcy messages, newest first.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN of the organisation (10 digits), entrepreneur or person (12 digits)." + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/timeline", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn" + } + } + }, + { + "name": "checko_list_legal_cases", + "description": "List arbitration court cases (Kartoteka arbitrazhnykh del) of an organisation or entrepreneur: case number, court, date, claim amount, role, parties, status. Filter by role, period, claim amount, only actual or only active cases; 100 per page.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "role": { + "type": "string", + "description": "plaintiff or defendant.", + "enum": [ + "plaintiff", + "defendant" + ] + }, + "actual": { + "type": "boolean", + "description": "Only actual cases (without returned or rejected claims, waived or terminated cases)." + }, + "active": { + "type": "boolean", + "description": "Only cases not yet finished." + }, + "date_from": { + "type": "string", + "description": "Start of the period, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "End of the period, YYYY-MM-DD." + }, + "claim_amount_from": { + "type": "number", + "description": "Minimum claim amount, roubles." + }, + "claim_amount_to": { + "type": "number", + "description": "Maximum claim amount, roubles." + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "sort": { + "type": "string", + "description": "date (oldest first) or -date (newest first).", + "enum": [ + "date", + "-date" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/legal-cases", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "role": "$role", + "actual": "$actual", + "active": "$active", + "date_from": "$date_from", + "date_to": "$date_to", + "claim_amount_from": "$claim_amount_from", + "claim_amount_to": "$claim_amount_to", + "limit": "$limit", + "page": "$page", + "sort": "$sort" + } + } + }, + { + "name": "checko_list_enforcements", + "description": "List enforcement proceedings (FSSP bailiff service) opened against an organisation: number, date, subject, amount and remaining debt, bailiff department. 100 per page.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "sort": { + "type": "string", + "description": "date (oldest first) or -date (newest first).", + "enum": [ + "date", + "-date" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/enforcements", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "limit": "$limit", + "page": "$page", + "sort": "$sort" + } + } + }, + { + "name": "checko_list_contracts", + "description": "List public procurement contracts of an organisation or entrepreneur under 44-FZ, 94-FZ or 223-FZ, as customer or supplier: registry number, date, price, subject, counterparty, region. 100 per page.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "law": { + "type": "string", + "description": "Procurement law: 44, 94 or 223.", + "enum": [ + "44", + "94", + "223" + ] + }, + "role": { + "type": "string", + "description": "customer or supplier.", + "enum": [ + "customer", + "supplier" + ] + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "sort": { + "type": "string", + "description": "date, -date, price or -price.", + "enum": [ + "date", + "-date", + "price", + "-price" + ] + } + }, + "required": [ + "law" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/contracts", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "law": "$law", + "role": "$role", + "limit": "$limit", + "page": "$page", + "sort": "$sort" + } + } + }, + { + "name": "checko_list_fedresurs_messages", + "description": "List Fedresurs messages about an organisation or entrepreneur (pledges, leasing, intentions to liquidate or reduce capital, net assets and other notices), as publisher or participant. 100 per page.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "type": { + "type": "string", + "description": "Only this message type code (see the Checko Fedresurs type reference)." + }, + "role": { + "type": "string", + "description": "all (default) or publisher.", + "enum": [ + "all", + "publisher" + ] + }, + "date_from": { + "type": "string", + "description": "Start of the period, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "End of the period, YYYY-MM-DD." + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "sort": { + "type": "string", + "description": "date (oldest first) or -date (newest first).", + "enum": [ + "date", + "-date" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/fedresurs", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "type": "$type", + "role": "$role", + "date_from": "$date_from", + "date_to": "$date_to", + "limit": "$limit", + "page": "$page", + "sort": "$sort" + } + } + }, + { + "name": "checko_list_bankruptcy_messages", + "description": "List EFRSB bankruptcy register messages about an organisation, entrepreneur or person: message type, date, case number, arbitration manager. 100 per page.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "date_from": { + "type": "string", + "description": "Start of the period, YYYY-MM-DD." + }, + "date_to": { + "type": "string", + "description": "End of the period, YYYY-MM-DD." + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "sort": { + "type": "string", + "description": "date (oldest first) or -date (newest first).", + "enum": [ + "date", + "-date" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/bankruptcy-messages", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "date_from": "$date_from", + "date_to": "$date_to", + "limit": "$limit", + "page": "$page", + "sort": "$sort" + } + } + }, + { + "name": "checko_list_inspections", + "description": "List state inspections and control measures (FGIS ERP / ERKNM of the Prosecutor General) of an organisation or entrepreneur: authority, type, dates, result and violations. 100 per page.", + "parameters": { + "type": "object", + "properties": { + "inn": { + "type": "string", + "description": "INN: 10 digits for an organisation, 12 for an entrepreneur or a person, e.g. 7707083893.", + "pattern": "^[0-9]{10}([0-9]{2})?$" + }, + "ogrn": { + "type": "string", + "description": "OGRN (13 digits) of an organisation or OGRNIP (15 digits) of an entrepreneur; use instead of inn.", + "pattern": "^[0-9]{13}([0-9]{2})?$" + }, + "limit": { + "type": "integer", + "description": "Results per page, at most 100 (default 100)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "sort": { + "type": "string", + "description": "date (oldest first) or -date (newest first).", + "enum": [ + "date", + "-date" + ] + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/inspections", + "queryParams": { + "inn": "$inn", + "ogrn": "$ogrn", + "limit": "$limit", + "page": "$page", + "sort": "$sort" + } + } + }, + { + "name": "checko_get_bank", + "description": "Get a Russian bank or credit institution by BIC from the Bank of Russia directory: name, English name, address, type, correspondent account and its other accounts.", + "parameters": { + "type": "object", + "properties": { + "bic": { + "type": "string", + "description": "Bank BIC, 9 digits, e.g. 044525225.", + "pattern": "^[0-9]{9}$" + } + }, + "required": [ + "bic" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/bank", + "queryParams": { + "bic": "$bic" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/checko.live.spec.ts b/packages/backend/src/adapters/intl/checko.live.spec.ts new file mode 100644 index 000000000..8b9052901 --- /dev/null +++ b/packages/backend/src/adapters/intl/checko.live.spec.ts @@ -0,0 +1,178 @@ +import * as adapter from './checko.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Checko API v2 (checko.ru/integration/api and the method pages under it): GET https://api.checko.ru/v2/?key=..., + * answers {data, meta: {status, today_request_count, balance, message}}; errors carry meta.status = error. + * Live part: CHECKO_API_KEY=... RUN_CHECKO_LIVE=1 npx jest src/adapters/intl/checko + * (reads only: one bank and one company, two of the 100 free daily requests). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'checko_'; +const WRITES: string[] = []; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('checko adapter: static spec conformance', () => { + it('sends the API key as the key query parameter to api.checko.ru/v2', async () => { + expect(a.requiredEnvVars).toEqual(['CHECKO_API_KEY']); + expect(a.connector.baseUrl).toBe('https://api.checko.ru/v2'); + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig).toEqual({ key: '{{CHECKO_API_KEY}}' }); + expect(a.envVarMeta?.CHECKO_API_KEY.secret).toBe(true); + expect(a.probe).toEqual({ tool: 'checko_get_bank', params: { bic: '044525225' } }); + const req = await request('checko_get_company', { inn: '7707083893' }, { CHECKO_API_KEY: 'k1' }); + expect(req.url).toBe('https://api.checko.ru/v2/company'); + expect(req.params).toEqual({ key: 'k1', inn: '7707083893' }); + }); + + it('is read-only: every tool is a GET', () => { + for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.method}`).toBe(`${t.name}:GET`); + }); + + it('maps every documented method with its query parameter names', async () => { + const paths: Record = { + checko_search: '/search', checko_get_company: '/company', checko_get_entrepreneur: '/entrepreneur', checko_get_person: '/person', + checko_get_finances: '/finances', checko_get_reliability: '/reliability', checko_get_timeline: '/timeline', checko_list_legal_cases: '/legal-cases', + checko_list_enforcements: '/enforcements', checko_list_contracts: '/contracts', checko_list_fedresurs_messages: '/fedresurs', + checko_list_bankruptcy_messages: '/bankruptcy-messages', checko_list_inspections: '/inspections', checko_get_bank: '/bank', + }; + expect(a.tools.map((t) => t.name).sort()).toEqual(Object.keys(paths).sort()); + for (const [n, p] of Object.entries(paths)) expect(`${n}:${tool(n).endpointMapping.path}`).toBe(`${n}:${p}`); + const s = await request('checko_search', { by: 'okved', obj: 'org', query: '62.01', region: '77', all_okved_codes: 'all', active: true, limit: 10 }, { CHECKO_API_KEY: 'k' }); + expect(s.params).toEqual({ key: 'k', by: 'okved', obj: 'org', query: '62.01', region: '77', codes: 'all', active: true, limit: 10 }); + const lc = await request('checko_list_legal_cases', { inn: '7707083893', role: 'defendant', claim_amount_from: 100000, sort: '-date' }, { CHECKO_API_KEY: 'k' }); + expect(lc.params).toEqual({ key: 'k', inn: '7707083893', role: 'defendant', claim_amount_from: 100000, sort: '-date' }); + expect(tool('checko_list_contracts').parameters?.required).toEqual(['law']); + expect(tool('checko_list_contracts').endpointMapping.queryParams).toMatchObject({ law: '$law', role: '$role' }); + expect(tool('checko_get_finances').endpointMapping.queryParams).toMatchObject({ extended: '$extended' }); + }); + + it('turns an error envelope into a failed call', () => { + const rules = a.connector.config?.errorWhen as Array<{ path: string; equals: string; status: number; messageMatches?: string }>; + expect(rules[0]).toMatchObject({ path: 'meta.status', equals: 'error', status: 401 }); + expect(new RegExp(rules[0].messageMatches as string, 'i').test('API-ключ не действителен')).toBe(true); + expect(rules[1]).toMatchObject({ path: 'meta.status', equals: 'error', status: 400 }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe!.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + for (const r of probe.parameters?.required ?? []) expect(a.probe!.params?.[r]).toBeDefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_CHECKO_LIVE === '1' && process.env.CHECKO_API_KEY ? describe : describe.skip; +live('checko adapter: live (read-only)', () => { + const get = async (path: string) => { + const res = await fetch(`https://api.checko.ru/v2${path}&key=${process.env.CHECKO_API_KEY}`); + expect(res.status).toBe(200); + return res.json() as Promise<{ data: Record; meta: { status: string } }>; + }; + it('reads a bank and a company', async () => { + const bank = await get('/bank?bic=044525225'); + expect(bank.meta.status).toBe('ok'); + const company = await get('/company?inn=7707083893'); + expect(company.data['ИНН']).toBe('7707083893'); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/helius.json b/packages/backend/src/adapters/intl/helius.json new file mode 100644 index 000000000..5856bbc90 --- /dev/null +++ b/packages/backend/src/adapters/intl/helius.json @@ -0,0 +1,559 @@ +{ + "slug": "helius", + "name": "Helius", + "aliases": [ + "Helius RPC", + "Helius Solana API", + "Solana DAS API" + ], + "description": "Solana blockchain data through Helius: SOL and token balances, NFTs and tokens of a wallet (DAS API), readable transaction history, single transactions, wallet identity and funding source. Read-only.", + "instructions": "## Helius\nThis connector reads the Solana blockchain through **Helius**: standard Solana RPC (`https://mainnet.helius-rpc.com`), the DAS API for NFTs and tokens, Enhanced Transactions for human-readable history and the Wallet API (`https://api.helius.xyz/v1/wallet`, beta). Everything is read-only: no transaction is signed or sent.\n\n**Setup (2 minutes)**\n1. Sign up at https://dashboard.helius.dev (the Free plan works).\n2. Open **API Keys** and copy a key into `HELIUS_API_KEY`. It is sent as the `api-key` query parameter.\n3. Run `helius_get_epoch_info` to check the key.\n\n**Workflow**\n- Balances: `helius_get_balance` for SOL in lamports (divide by 1,000,000,000), `helius_get_assets_by_owner` with `show_fungible` and `show_native_balance` for everything a wallet holds, `helius_get_token_accounts` for raw token accounts by owner or mint, `helius_get_wallet_balances` for a USD-valued portfolio.\n- One asset or token: `helius_get_asset` with the mint address.\n- History: `helius_get_address_transactions` (readable types and transfers, 100 per call, page with `before_signature`), `helius_get_wallet_history` (balance changes per transaction), `helius_get_wallet_transfers` (transfers only), or `helius_get_signatures_for_address` (raw signatures, up to 1000) followed by `helius_get_transaction` or `helius_parse_transactions` for details.\n- Who is this wallet: `helius_get_wallet_identity` (exchanges, protocols, known entities) and `helius_get_wallet_funded_by` (first SOL funder).\n- Chain status: `helius_get_epoch_info`.\n\n**Units and times**: SOL amounts from RPC are lamports (10^9 per SOL). Token amounts from `helius_get_token_accounts` are raw integers; divide by 10^decimals of the mint. `blockTime` and `timestamp` are Unix seconds (UTC).\n\n**Credits and limits**: each plan has monthly credits (Free 1M). Standard RPC calls cost 1 credit, DAS and Enhanced calls more, every Wallet API call 100 credits. Rate limits on the Free plan: RPC 10 requests per second, DAS and Enhanced APIs 2 per second. Prefer the RPC and DAS tools for routine questions and the Wallet API for the questions only it answers.\n\n**Errors**: 401 means a missing or wrong API key. 429 means the plan rate limit or the monthly credits are used up. RPC errors come back as a JSON-RPC `error` object (for example an invalid address). The Wallet API answers 404 when it knows no identity or funder for the wallet.\n\n**Limitations**: mainnet only (devnet is not configured). Enhanced Transactions is in maintenance mode at Helius; the Wallet API is beta and its formats may change. Webhooks, streaming, priority fees and sending transactions are not part of this connector.", + "region": "intl", + "category": "data", + "icon": "helius", + "docsUrl": "https://www.helius.dev/docs", + "requiredEnvVars": [ + "HELIUS_API_KEY" + ], + "envVarMeta": { + "HELIUS_API_KEY": { + "label": "API key", + "kind": "credential", + "secret": true, + "help": "dashboard.helius.dev, API Keys: copy a key. The Free plan works.", + "link": "https://dashboard.helius.dev/", + "pattern": "^[A-Za-z0-9-]{20,64}$", + "patternMessage": "Paste only the API key (a UUID such as 1a2b3c4d-...), not the whole RPC URL." + } + }, + "verifyHints": { + "401": "Helius refused the API key. Copy it again from dashboard.helius.dev (API Keys), only the key, not the RPC URL.", + "429": "The Helius plan rate limit or the monthly credits are used up. Wait a moment or check the usage in the Helius dashboard." + }, + "probe": { + "tool": "helius_get_epoch_info" + }, + "connector": { + "name": "Helius Solana API", + "type": "REST", + "baseUrl": "https://mainnet.helius-rpc.com", + "authType": "QUERY_AUTH", + "authConfig": { + "api-key": "{{HELIUS_API_KEY}}" + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + } + }, + "tools": [ + { + "name": "helius_get_epoch_info", + "description": "Get the current Solana epoch: epoch number, slot index and slots in the epoch, absolute slot, block height and transaction count. Costs one RPC credit; use it to check the connection and to know the current slot.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getEpochInfo" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_balance", + "description": "Get the native SOL balance of an account in lamports (value; 1 SOL = 1,000,000,000 lamports) with the slot it was read at.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Solana address (base58), e.g. 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getBalance", + "params": [ + "$address" + ] + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_assets_by_owner", + "description": "List the digital assets a wallet owns through the DAS API: NFTs, compressed NFTs and, with show_fungible, SPL and Token-2022 tokens with balance, decimals and price where known; show_native_balance adds the SOL balance. Paged: page starts at 1, limit up to 1000.", + "parameters": { + "type": "object", + "properties": { + "owner_address": { + "type": "string", + "description": "Wallet address (base58)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "limit": { + "type": "integer", + "description": "Assets per page, up to 1000." + }, + "show_fungible": { + "type": "boolean", + "description": "Include fungible tokens (SPL, Token-2022) with balances." + }, + "show_native_balance": { + "type": "boolean", + "description": "Include the SOL balance of the wallet." + }, + "show_zero_balance": { + "type": "boolean", + "description": "Include token accounts with a zero balance." + } + }, + "required": [ + "owner_address" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getAssetsByOwner", + "params": { + "ownerAddress": "$owner_address", + "page": "$page", + "limit": "$limit", + "displayOptions": { + "showFungible": "$show_fungible", + "showNativeBalance": "$show_native_balance", + "showZeroBalance": "$show_zero_balance" + } + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_asset", + "description": "Get one digital asset (NFT, compressed NFT or token) by its id (mint address) through the DAS API: interface, name, symbol, metadata and image links, ownership, collection grouping, royalties, creators, supply and, for tokens, decimals and price info.", + "parameters": { + "type": "object", + "properties": { + "asset_id": { + "type": "string", + "description": "Asset id: the mint address (base58)." + } + }, + "required": [ + "asset_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getAsset", + "params": { + "id": "$asset_id" + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_token_accounts", + "description": "List SPL token accounts by owner or by mint through the DAS API: token account address, mint, owner, amount (raw, apply the mint decimals) and frozen state. Give owner, mint or both. Paged with page and limit.", + "parameters": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Owner wallet address (base58)." + }, + "mint": { + "type": "string", + "description": "Token mint address (base58), e.g. EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v for USDC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "limit": { + "type": "integer", + "description": "Accounts per page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getTokenAccounts", + "params": { + "owner": "$owner", + "mint": "$mint", + "page": "$page", + "limit": "$limit" + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_signatures_for_address", + "description": "List transaction signatures that touched an address, newest first: signature, slot, blockTime (Unix seconds), err (null on success) and memo. Default and maximum 1000; page with before = the last signature received. Fetch the details with helius_get_transaction.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Solana address (base58), e.g. 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY." + }, + "limit": { + "type": "integer", + "description": "Number of signatures, 1 to 1000." + }, + "before": { + "type": "string", + "description": "Start before this signature (paging backwards)." + }, + "until": { + "type": "string", + "description": "Stop at this signature (fetch only newer ones)." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getSignaturesForAddress", + "params": [ + "$address", + { + "limit": "$limit", + "before": "$before", + "until": "$until" + } + ] + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_transaction", + "description": "Get one confirmed transaction by signature with parsed instructions (jsonParsed): slot, blockTime, fee, status, account keys, pre and post SOL and token balances and log messages. Transaction versions legacy, 0 and 1 are accepted.", + "parameters": { + "type": "object", + "properties": { + "signature": { + "type": "string", + "description": "Transaction signature (base58)." + } + }, + "required": [ + "signature" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyMapping": { + "jsonrpc": "2.0", + "id": "anythingmcp", + "method": "getTransaction", + "params": [ + "$signature", + { + "encoding": "jsonParsed", + "maxSupportedTransactionVersion": 1 + } + ] + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_address_transactions", + "description": "Get the human-readable transaction history of an address (Enhanced Transactions): type (TRANSFER, SWAP, NFT_SALE...), source, description, fee, timestamp, nativeTransfers and tokenTransfers. Newest first, up to 100 per call; page with before_signature = the last signature received. token_accounts=balanceChanged also includes transfers that only touched the wallet token accounts.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Solana address (base58), e.g. 86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY." + }, + "limit": { + "type": "integer", + "description": "Number of transactions, 1 to 100." + }, + "before_signature": { + "type": "string", + "description": "Return transactions older than this signature (paging)." + }, + "after_signature": { + "type": "string", + "description": "Return transactions newer than this signature." + }, + "type": { + "type": "string", + "description": "Only this transaction type, e.g. TRANSFER, SWAP, NFT_SALE." + }, + "token_accounts": { + "type": "string", + "description": "none (default), balanceChanged or all: include activity of token accounts the address owns.", + "enum": [ + "none", + "balanceChanged", + "all" + ] + }, + "gte_time": { + "type": "integer", + "description": "Only transactions at or after this block time (Unix seconds)." + }, + "lte_time": { + "type": "integer", + "description": "Only transactions at or before this block time (Unix seconds)." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v0/addresses/{address}/transactions", + "queryParams": { + "limit": "$limit", + "before-signature": "$before_signature", + "after-signature": "$after_signature", + "type": "$type", + "token-accounts": "$token_accounts", + "gte-time": "$gte_time", + "lte-time": "$lte_time" + } + } + }, + { + "name": "helius_parse_transactions", + "description": "Turn up to 100 transaction signatures into human-readable transactions (Enhanced Transactions): type, source, description, fee, native and token transfers and events. Reads only; nothing is sent to the chain.", + "parameters": { + "type": "object", + "properties": { + "signatures": { + "type": "array", + "description": "Transaction signatures (base58), at most 100.", + "items": { + "type": "string" + } + } + }, + "required": [ + "signatures" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v0/transactions", + "bodyMapping": { + "transactions": "$signatures" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "helius_get_wallet_balances", + "description": "Get the token balances of a wallet with USD values (Wallet API, beta): mint, symbol, balance, pricePerToken, usdValue and totalUsdValue for the page, sorted by value. Prices are hourly estimates for about the top 10,000 tokens and may be null. 100 per page. Costs 100 credits.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Wallet address (base58)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "limit": { + "type": "integer", + "description": "Tokens per page, 1 to 100." + }, + "show_zero_balance": { + "type": "boolean", + "description": "Include tokens with a zero balance." + }, + "show_nfts": { + "type": "boolean", + "description": "Also list NFTs (first page only, at most 100)." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.helius.xyz/v1/wallet/{address}/balances", + "queryParams": { + "page": "$page", + "limit": "$limit", + "showZeroBalance": "$show_zero_balance", + "showNfts": "$show_nfts" + } + } + }, + { + "name": "helius_get_wallet_history", + "description": "Get the transaction history of a wallet with its balance changes per transaction (Wallet API, beta): signature, timestamp, fee and balanceChanges (mint, amount, decimals), newest first. Up to 100 per call; page with before = pagination cursor from the previous answer. Costs 100 credits.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Wallet address (base58)." + }, + "limit": { + "type": "integer", + "description": "Number of transactions, 1 to 100." + }, + "before": { + "type": "string", + "description": "Pagination cursor from the previous answer." + }, + "type": { + "type": "string", + "description": "Only this transaction type, e.g. SWAP, TRANSFER, NFT_SALE." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.helius.xyz/v1/wallet/{address}/history", + "queryParams": { + "limit": "$limit", + "before": "$before", + "type": "$type" + } + } + }, + { + "name": "helius_get_wallet_transfers", + "description": "List incoming and outgoing transfers of a wallet (Wallet API, beta): signature, timestamp, direction (in or out), counterparty, mint and amount. Default 50, up to 100 per call; page with cursor from the previous answer. Costs 100 credits.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Wallet address (base58)." + }, + "limit": { + "type": "integer", + "description": "Number of transfers, 1 to 100." + }, + "cursor": { + "type": "string", + "description": "Pagination cursor from the previous answer." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.helius.xyz/v1/wallet/{address}/transfers", + "queryParams": { + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "helius_get_wallet_identity", + "description": "Identify a known wallet (Wallet API, beta): name and category such as a centralized exchange, protocol or institution. Also accepts a .sol domain. Unknown wallets answer 404. Costs 100 credits.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Wallet address (base58) or .sol domain, e.g. toly.sol." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.helius.xyz/v1/wallet/{address}/identity" + } + }, + { + "name": "helius_get_wallet_funded_by", + "description": "Find who first funded a wallet with SOL (Wallet API, beta): funder address, funderName and funderType (exchange or null), amount, timestamp and signature. 404 means no funding transfer was found. Costs 100 credits.", + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Wallet address (base58)." + } + }, + "required": [ + "address" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.helius.xyz/v1/wallet/{address}/funded-by" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/helius.live.spec.ts b/packages/backend/src/adapters/intl/helius.live.spec.ts new file mode 100644 index 000000000..72c03b392 --- /dev/null +++ b/packages/backend/src/adapters/intl/helius.live.spec.ts @@ -0,0 +1,179 @@ +import * as adapter from './helius.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Helius (www.helius.dev/docs): Solana JSON-RPC and DAS on https://mainnet.helius-rpc.com, Enhanced Transactions on + * /v0/..., Wallet API (beta) on https://api.helius.xyz/v1/wallet/...; the API key travels as the api-key query parameter. + * Live part: HELIUS_API_KEY=... RUN_HELIUS_LIVE=1 npx jest src/adapters/intl/helius + * (reads only: epoch info, the SOL balance and the signatures of a public address). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'helius_'; +const WRITES: string[] = []; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('helius adapter: static spec conformance', () => { + it('sends the API key as the api-key query parameter to Helius mainnet', () => { + expect(a.requiredEnvVars).toEqual(['HELIUS_API_KEY']); + expect(a.connector.baseUrl).toBe('https://mainnet.helius-rpc.com'); + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig).toEqual({ 'api-key': '{{HELIUS_API_KEY}}' }); + expect(a.envVarMeta?.HELIUS_API_KEY.secret).toBe(true); + expect(a.probe).toEqual({ tool: 'helius_get_epoch_info' }); + }); + + it('builds JSON-RPC 2.0 bodies for the RPC and DAS tools', async () => { + const bal = await request('helius_get_balance', { address: 'Addr1' }, { HELIUS_API_KEY: 'k' }); + expect(bal.method.toUpperCase()).toBe('POST'); + expect(bal.url).toBe('https://mainnet.helius-rpc.com/'); + expect(bal.params).toEqual({ 'api-key': 'k' }); + expect(bal.data).toEqual({ jsonrpc: '2.0', id: 'anythingmcp', method: 'getBalance', params: ['Addr1'] }); + const sigs = await request('helius_get_signatures_for_address', { address: 'Addr1', limit: 5 }, { HELIUS_API_KEY: 'k' }); + expect(sigs.data).toEqual({ jsonrpc: '2.0', id: 'anythingmcp', method: 'getSignaturesForAddress', params: ['Addr1', { limit: 5 }] }); + const assets = await request('helius_get_assets_by_owner', { owner_address: 'Owner1', show_fungible: true, show_native_balance: false }, { HELIUS_API_KEY: 'k' }); + expect(assets.data).toEqual({ jsonrpc: '2.0', id: 'anythingmcp', method: 'getAssetsByOwner', params: { ownerAddress: 'Owner1', displayOptions: { showFungible: true, showNativeBalance: false } } }); + expect(tool('helius_get_transaction').endpointMapping.bodyMapping).toEqual({ jsonrpc: '2.0', id: 'anythingmcp', method: 'getTransaction', params: ['$signature', { encoding: 'jsonParsed', maxSupportedTransactionVersion: 1 }] }); + expect((tool('helius_get_asset').endpointMapping.bodyMapping as { params: unknown }).params).toEqual({ id: '$asset_id' }); + expect((tool('helius_get_token_accounts').endpointMapping.bodyMapping as { params: unknown }).params).toEqual({ owner: '$owner', mint: '$mint', page: '$page', limit: '$limit' }); + expect((tool('helius_get_epoch_info').endpointMapping.bodyMapping as { method: string }).method).toBe('getEpochInfo'); + }); + + it('maps Enhanced Transactions and the Wallet API with their documented parameter names', async () => { + const hist = await request('helius_get_address_transactions', { address: 'Addr1', before_signature: 'sig9', token_accounts: 'balanceChanged', limit: 10 }, { HELIUS_API_KEY: 'k' }); + expect(hist.url).toBe('https://mainnet.helius-rpc.com/v0/addresses/Addr1/transactions'); + expect(hist.params).toEqual({ 'api-key': 'k', limit: 10, 'before-signature': 'sig9', 'token-accounts': 'balanceChanged' }); + expect(tool('helius_parse_transactions').endpointMapping).toMatchObject({ method: 'POST', path: '/v0/transactions', bodyMapping: { transactions: '$signatures' } }); + const wb = await request('helius_get_wallet_balances', { address: 'W1', show_nfts: true }, { HELIUS_API_KEY: 'k' }); + expect(wb.url).toBe('https://api.helius.xyz/v1/wallet/W1/balances'); + expect(wb.params).toEqual({ 'api-key': 'k', showNfts: true }); + for (const n of ['history', 'transfers', 'identity', 'funded-by']) { + const t = a.tools.find((x) => x.endpointMapping.path === `https://api.helius.xyz/v1/wallet/{address}/${n}`); + expect(`${n}:${!!t}`).toBe(`${n}:true`); + } + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe!.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + for (const r of probe.parameters?.required ?? []) expect(a.probe!.params?.[r]).toBeDefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_HELIUS_LIVE === '1' && process.env.HELIUS_API_KEY ? describe : describe.skip; +live('helius adapter: live (read-only)', () => { + const url = `https://mainnet.helius-rpc.com/?api-key=${process.env.HELIUS_API_KEY}`; + const rpcCall = async (method: string, params?: unknown) => { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 'spec', method, params }) }); + expect(res.status).toBe(200); + return ((await res.json()) as { result: Record }).result; + }; + it('reads epoch info, a balance and signatures of a public address', async () => { + const epoch = await rpcCall('getEpochInfo'); + expect(typeof epoch.absoluteSlot).toBe('number'); + const bal = await rpcCall('getBalance', ['86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY']); + expect(typeof bal.value).toBe('number'); + const sigs = (await rpcCall('getSignaturesForAddress', ['86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY', { limit: 2 }])) as unknown as unknown[]; + expect(Array.isArray(sigs)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/invoice123.json b/packages/backend/src/adapters/intl/invoice123.json new file mode 100644 index 000000000..2d312ff23 --- /dev/null +++ b/packages/backend/src/adapters/intl/invoice123.json @@ -0,0 +1,612 @@ +{ + "slug": "invoice123", + "name": "Invoice123", + "aliases": [ + "Saskaita123", + "Sąskaita123", + "Rekins123" + ], + "description": "Invoice123 (Saskaita123), invoicing for Lithuania and Latvia, read-only: sales invoices with payments, payment links and PDFs, purchases, clients, products, projects, waybills, VAT rates, series and bank accounts.", + "region": "intl", + "category": "accounting", + "icon": "invoice123", + "docsUrl": "https://app.invoice123.com/docs/index.html", + "instructions": "## Invoice123\nThis connector reads **Invoice123** (app.invoice123.com, Saskaita123 in Lithuania), the invoicing and bookkeeping service for small businesses in Lithuania and Latvia, through its REST API v1.0: sales invoices and their payments and PDFs, purchases, clients, products, projects, waybills and the reference data (series, VAT rates, banks, units, activities). It is read-only.\n\n**Setup (about 2 minutes)**\n1. In Invoice123 open the account menu (top right), **Integrations** (puzzle icon) or **Account settings > Integrations**, and install the **API** integration.\n2. Click **Generate your API key**, name it and tick the permissions the AI may use, at least `invoice:index`, plus `expense:index`, `client:index`, `product:index`, `project:index` and `waybills:index` for those areas. The PDF route of invoices checks `invoice:store`, and the offer PDF `commercial:store`.\n3. Paste the key into `INVOICE123_API_TOKEN`. It is sent as `Authorization: Bearer`.\n4. Install and run `invoice123_get_company` to check the token.\n\n**Workflow**\n- Reference data first when you need ids: `invoice123_get_invoice_data` (activities, series, banks, units, VAT rates, warehouses in one call), or `invoice123_list_series`, `invoice123_list_bank_accounts`, `invoice123_list_units`, `invoice123_list_activities`, `invoice123_list_cash_registers`, `invoice123_list_email_templates`, `invoice123_list_invoice_vat_rates`.\n- Sales: `invoice123_list_invoices` (filter by `status` unpaid or late for open items, `range` for a period, `client_id` for one customer), `invoice123_list_invoice_payments` to see how and when an invoice was paid (transfer, cash, card, cash register receipt), `invoice123_get_invoice_payment_link`, `invoice123_download_invoice_pdf`.\n- Purchases: `invoice123_list_purchases`, `invoice123_list_purchase_types`, `invoice123_list_purchase_vat_rates`.\n- Master data: `invoice123_list_clients` (customers and suppliers), `invoice123_list_products`, `invoice123_list_projects`, `invoice123_list_waybills`; offers: `invoice123_download_commercial_offer_pdf`.\n\n**Paging**: lists return `data.result` and `data.pagination` (`current_page`, `last_page`, `total`); pass `page` (from 1) and `limit` (default 20) and stop at `last_page`. Date ranges are two `YYYY-MM-DD` dates joined by a comma. Amounts are strings in the invoice currency (usually EUR).\n\n**Limits and errors**: 60 requests per minute on the Standard plan, 1,000 per hour on Business (429 above that). 401 means a wrong or deleted token; 403 means the token lacks the permission named in the tool description; purchases, waybills and offers also need their module enabled in the account.", + "prerequisites": "An Invoice123 account with the API integration installed (Account settings > Integrations) and an API key with the read permissions the AI should use.", + "requiredEnvVars": [ + "INVOICE123_API_TOKEN" + ], + "envVarMeta": { + "INVOICE123_API_TOKEN": { + "label": "API key", + "kind": "credential", + "secret": true, + "help": "Invoice123, Account settings, Integrations: install the API integration, then Generate your API key with the read permissions the AI may use.", + "link": "https://help.invoice123.com/en/article/how-to-activate-invoice123s-api-edeyjl/", + "pattern": "^(?![Bb]earer )\\S{20,}$", + "patternMessage": "Paste only the API key, without Bearer, spaces or quotes." + } + }, + "verifyHints": { + "401": "Invoice123 refused the API key: it is wrong or was deleted. Generate a new one under Account settings > Integrations > API.", + "403": "The key works, but lacks the permission for this area (e.g. invoice:index or expense:index), or the module is not enabled. Edit the key permissions in Invoice123.", + "rate_limited": "Invoice123 allows 60 requests per minute on the Standard plan. Wait a minute and try again." + }, + "probe": { + "tool": "invoice123_get_company" + }, + "connector": { + "name": "Invoice123 API v1.0", + "type": "REST", + "baseUrl": "https://app.invoice123.com/api/v1.0", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{INVOICE123_API_TOKEN}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "invoice123_get_company", + "description": "Get the company profile behind the API token (currently whether the company is VAT registered). Costs one call and needs no permission, so it is the connection check.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/company" + } + }, + { + "name": "invoice123_list_invoices", + "description": "List sales invoices with client, line items, VAT, totals, currency, payments and paid flag, 20 per page by default. Filter by text (number, client, product), date range, payment date range, status (paid, unpaid, partially_paid, late, draft, credit, advance, nullified...) or by client, series, project, product, activity or warehouse id. Needs the invoice:index permission.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "limit": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + }, + "text": { + "type": "string", + "description": "Free text: invoice number, client name, address or code, product title or description." + }, + "range": { + "type": "string", + "description": "Invoice dates: Date range as two YYYY-MM-DD dates joined by a comma, e.g. 2026-01-01,2026-03-31." + }, + "payment_date": { + "type": "string", + "description": "Payment dates: Date range as two YYYY-MM-DD dates joined by a comma, e.g. 2026-01-01,2026-03-31." + }, + "status": { + "type": "string", + "description": "Invoice status or kind.", + "enum": [ + "draft", + "paid", + "unpaid", + "fully_unpaid", + "partially_paid", + "overpaid", + "cash", + "transfer", + "simple", + "advance", + "credit", + "late", + "nullified" + ] + }, + "invoice_id": { + "type": "string", + "description": "Only this invoice id." + }, + "series_id": { + "type": "string", + "description": "Series id, from invoice123_list_series." + }, + "activity_id": { + "type": "string", + "description": "Activity id, from invoice123_list_activities." + }, + "client_id": { + "type": "string", + "description": "Client id, from invoice123_list_clients." + }, + "project_id": { + "type": "string", + "description": "Project id, from invoice123_list_projects." + }, + "warehouse_id": { + "type": "string", + "description": "Warehouse id, from invoice123_get_invoice_data." + }, + "product_id": { + "type": "string", + "description": "Product id, from invoice123_list_products." + }, + "issued_by": { + "type": "string", + "description": "Name of the person who issued the invoice." + }, + "order_number": { + "type": "string", + "description": "Order number on the invoice." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "page": "$page", + "limit": "$limit", + "text": "$text", + "range": "$range", + "payment_date": "$payment_date", + "status": "$status", + "invoice": "$invoice_id", + "series": "$series_id", + "activity": "$activity_id", + "client": "$client_id", + "project": "$project_id", + "warehouse": "$warehouse_id", + "product": "$product_id", + "issued_by": "$issued_by", + "order_number": "$order_number" + } + } + }, + { + "name": "invoice123_list_invoice_payments", + "description": "List the payments registered for one invoice: type (transfer, cash, refund, other), cash document type and receipt number, amount, date and whether the invoice is fully paid. Needs invoice:index.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice id, from invoice123_list_invoices." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices/{invoice_id}/payments" + } + }, + { + "name": "invoice123_download_invoice_pdf", + "description": "Download the PDF of an invoice in Lithuanian, English or Latvian; the file is handed over as an attachment. The token needs the invoice:store permission for this route.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice id, from invoice123_list_invoices." + }, + "language": { + "type": "string", + "description": "Language of the PDF: lt (Lithuanian), en (English) or lv (Latvian).", + "enum": [ + "lt", + "en", + "lv" + ] + } + }, + "required": [ + "invoice_id", + "language" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/invoice/{invoice_id}/pdf/{language}" + } + }, + { + "name": "invoice123_get_invoice_payment_link", + "description": "Get the online payment link of an invoice, when the company has a payment provider (Montonio or Paysera) enabled. Needs invoice:index.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice id, from invoice123_list_invoices." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/invoice/{invoice_id}/payment-link" + } + }, + { + "name": "invoice123_list_invoice_vat_rates", + "description": "List the VAT rates available on sales invoices: id, VAT code (e.g. PVM1), tariff in percent and the reverse-charge article where it applies.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/invoice/vats" + } + }, + { + "name": "invoice123_get_invoice_data", + "description": "Get in one call the reference data used on invoices: activities, document series, bank accounts, units, VAT rates and warehouses, with their ids.", + "parameters": { + "type": "object", + "properties": { + "types": { + "type": "string", + "description": "Only series of these types, comma-separated: simple, advance, credit, kpo, ppk, kio." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoice-data", + "queryParams": { + "types": "$types" + } + } + }, + { + "name": "invoice123_list_purchases", + "description": "List purchase invoices (expenses) with supplier, line items with expense type, VAT, totals, payments and paid flag. Filter by text, date range, type (simple, vat-simple, credit, vat-credit, foreign, other), status (paid, unpaid, partially_paid, late, nullified), expense type, activity, client (supplier) or project. Needs expense:index and the purchases module.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "limit": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + }, + "text": { + "type": "string", + "description": "Free text search." + }, + "range": { + "type": "string", + "description": "Purchase dates: Date range as two YYYY-MM-DD dates joined by a comma, e.g. 2026-01-01,2026-03-31." + }, + "type": { + "type": "string", + "description": "Purchase document type.", + "enum": [ + "simple", + "vat-simple", + "credit", + "vat-credit", + "foreign", + "other" + ] + }, + "status": { + "type": "string", + "description": "Payment status.", + "enum": [ + "paid", + "unpaid", + "partially_paid", + "late", + "nullified" + ] + }, + "expense_type_id": { + "type": "string", + "description": "Expense type id, from invoice123_list_purchase_types." + }, + "activity_id": { + "type": "string", + "description": "Activity id, from invoice123_list_activities." + }, + "client_id": { + "type": "string", + "description": "Supplier id (suppliers are clients), from invoice123_list_clients." + }, + "project_id": { + "type": "string", + "description": "Project id, from invoice123_list_projects." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/expenses", + "queryParams": { + "page": "$page", + "limit": "$limit", + "text": "$text", + "range": "$range", + "type": "$type", + "status": "$status", + "expense_type": "$expense_type_id", + "activity": "$activity_id", + "client": "$client_id", + "project": "$project_id" + } + } + }, + { + "name": "invoice123_list_purchase_vat_rates", + "description": "List the VAT rates available on purchase invoices: id, VAT code, tariff in percent and reverse-charge article.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/expense/vats" + } + }, + { + "name": "invoice123_list_purchase_types", + "description": "List the expense types (purchase categories) that purchase line items can carry, with their ids.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/expense/types" + } + }, + { + "name": "invoice123_download_commercial_offer_pdf", + "description": "Download the PDF of a commercial offer (quote) in Lithuanian, English or Latvian; the file is handed over as an attachment. Needs the commercial:store permission and the commercial offers module.", + "parameters": { + "type": "object", + "properties": { + "offer_id": { + "type": "string", + "description": "Commercial offer id." + }, + "language": { + "type": "string", + "description": "Language of the PDF: lt (Lithuanian), en (English) or lv (Latvian).", + "enum": [ + "lt", + "en", + "lv" + ] + } + }, + "required": [ + "offer_id", + "language" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/commercial-suggestion/{offer_id}/pdf/{language}" + } + }, + { + "name": "invoice123_list_clients", + "description": "List clients (customers and suppliers): id, name, company or personal code, client code, VAT code, address and country. Filter by name or code. Needs client:index.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "limit": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + }, + "name": { + "type": "string", + "description": "Client name." + }, + "code": { + "type": "string", + "description": "Company code or personal identification number." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/clients", + "queryParams": { + "page": "$page", + "limit": "$limit", + "name": "$name", + "code": "$code" + } + } + }, + { + "name": "invoice123_list_products", + "description": "List the product catalog: id, name, code, price, description, prime cost, type, weight and stock per warehouse. Filter by exact code. Needs product:index.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "limit": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + }, + "code": { + "type": "string", + "description": "Product code." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "queryParams": { + "page": "$page", + "limit": "$limit", + "code": "$code" + } + } + }, + { + "name": "invoice123_list_projects", + "description": "List projects with number, description, start and end dates and budget. Projects can be linked to invoices and purchases. Needs project:index.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "limit": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/projects", + "queryParams": { + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "invoice123_list_waybills", + "description": "List waybills (delivery notes) with type, series, receiver, carrier, load and delivery dates and the linked invoice series, at most 50 per page. Needs waybills:index and the waybill module.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1.", + "minimum": 1 + }, + "limit": { + "type": "integer", + "description": "Results per page (at most 50).", + "minimum": 1 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/waybills", + "queryParams": { + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "invoice123_list_bank_accounts", + "description": "List the company bank accounts (IBAN, bank name, code, SWIFT, default flag) that invoices show for payment.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/banks" + } + }, + { + "name": "invoice123_list_series", + "description": "List document series (numbering ranges) with id, title and type: simple, advance, credit invoices, and the cash documents kpo, ppk, kio.", + "parameters": { + "type": "object", + "properties": { + "types": { + "type": "string", + "description": "Only these types, comma-separated: simple, advance, credit, kpo, ppk, kio." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/series", + "queryParams": { + "types": "$types" + } + } + }, + { + "name": "invoice123_list_cash_registers", + "description": "List the company cash registers with register number, last receipt number and receipt pattern.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/cash-registers" + } + }, + { + "name": "invoice123_list_units", + "description": "List the measurement units (pcs, hours, kg...) with their ids, as used on invoice lines.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/units" + } + }, + { + "name": "invoice123_list_activities", + "description": "List the business activities (for individual activity accounts) with their ids, used to filter invoices and purchases.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/activities" + } + }, + { + "name": "invoice123_list_email_templates", + "description": "List the e-mail templates the company uses to send invoices, with their ids and names.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/templates" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/invoice123.live.spec.ts b/packages/backend/src/adapters/intl/invoice123.live.spec.ts new file mode 100644 index 000000000..6211a13e5 --- /dev/null +++ b/packages/backend/src/adapters/intl/invoice123.live.spec.ts @@ -0,0 +1,149 @@ +import * as adapter from './invoice123.json'; + +/** + * Invoice123 API v1.0 (OpenAPI at app.invoice123.com/docs/definitions/openapi.1_0.json, rendered at + * app.invoice123.com/docs/index.html): base https://app.invoice123.com/api/v1.0, Authorization: Bearer + * with a key generated under Account settings > Integrations > API, per-permission scopes. + * Live part: INVOICE123_API_TOKEN=... RUN_INVOICE123_LIVE=1 npx jest src/adapters/intl/invoice123 + * (reads only: company, one page of invoices, invoice VAT rates). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { type: string; baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'invoice123_'; +const WRITES: string[] = []; +const SWITCHED_OFF: string[] = []; + +describe('invoice123 adapter: static spec conformance', () => { + + it('sends the key as Bearer to the documented v1.0 base URL and asks for JSON', () => { + expect(a.requiredEnvVars).toEqual(['INVOICE123_API_TOKEN']); + expect(a.connector.baseUrl).toBe('https://app.invoice123.com/api/v1.0'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{INVOICE123_API_TOKEN}}' }); + expect(a.connector.headers).toEqual({ Accept: 'application/json' }); + expect(a.envVarMeta?.INVOICE123_API_TOKEN.secret).toBe(true); + expect(a.probe?.tool).toBe('invoice123_get_company'); + }); + + it('maps the documented invoice routes and filters', () => { + expect(tool('invoice123_list_invoices').endpointMapping).toMatchObject({ + method: 'GET', + path: '/invoices', + queryParams: { page: '$page', limit: '$limit', text: '$text', range: '$range', status: '$status', client: '$client_id', series: '$series_id', invoice: '$invoice_id', payment_date: '$payment_date' }, + }); + expect(tool('invoice123_list_invoice_payments').endpointMapping.path).toBe('/invoices/{invoice_id}/payments'); + expect(tool('invoice123_download_invoice_pdf').endpointMapping.path).toBe('/invoice/{invoice_id}/pdf/{language}'); + expect((tool('invoice123_download_invoice_pdf').parameters?.properties?.language as { enum: string[] }).enum).toEqual(['lt', 'en', 'lv']); + expect(tool('invoice123_get_invoice_payment_link').endpointMapping.path).toBe('/invoice/{invoice_id}/payment-link'); + expect(tool('invoice123_list_invoice_vat_rates').endpointMapping.path).toBe('/invoice/vats'); + expect(tool('invoice123_get_invoice_data').endpointMapping).toEqual({ method: 'GET', path: '/invoice-data', queryParams: { types: '$types' } }); + }); + + it('maps purchases, master data and reference lists', () => { + expect(tool('invoice123_list_purchases').endpointMapping).toMatchObject({ path: '/expenses', queryParams: { type: '$type', status: '$status', expense_type: '$expense_type_id', client: '$client_id' } }); + expect(tool('invoice123_list_purchase_vat_rates').endpointMapping.path).toBe('/expense/vats'); + expect(tool('invoice123_list_purchase_types').endpointMapping.path).toBe('/expense/types'); + expect(tool('invoice123_download_commercial_offer_pdf').endpointMapping.path).toBe('/commercial-suggestion/{offer_id}/pdf/{language}'); + expect(tool('invoice123_list_clients').endpointMapping).toMatchObject({ path: '/clients', queryParams: { name: '$name', code: '$code' } }); + expect(tool('invoice123_list_products').endpointMapping).toMatchObject({ path: '/products', queryParams: { code: '$code' } }); + const simple: Record = { + invoice123_list_projects: '/projects', invoice123_list_waybills: '/waybills', invoice123_list_bank_accounts: '/banks', invoice123_list_series: '/series', + invoice123_list_cash_registers: '/cash-registers', invoice123_list_units: '/units', invoice123_list_activities: '/activities', invoice123_list_email_templates: '/templates', invoice123_get_company: '/company', + }; + for (const [n, p] of Object.entries(simple)) expect(`${n}:${tool(n).endpointMapping.path}`).toBe(`${n}:${p}`); + // /vats is deprecated by the vendor in favour of /invoice/vats and /expense/vats. + for (const t of a.tools) expect(t.endpointMapping.path).not.toBe('/vats'); + }); + + it('is read-only', () => { + for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.method}`).toBe(`${t.name}:GET`); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + if (t.endpointMapping.method === 'static') continue; + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated, the risky ones installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...SWITCHED_OFF].sort()); + for (const n of WRITES) expect(`${n}:${tool(n).annotations?.readOnlyHint}`).toBe(`${n}:false`); + for (const t of a.tools) { + if (WRITES.includes(t.name)) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (!['GET', 'static'].includes(t.endpointMapping.method) && !t.endpointMapping.method.startsWith('odata_')) expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + }); + + it('documents every tool in the instructions, marks secrets and has no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + if (a.probe) { + const probe = tool(a.probe.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + expect(probe.parameters?.required ?? []).toEqual([]); + } + }); +}); + +const live = process.env.RUN_INVOICE123_LIVE === '1' && process.env.INVOICE123_API_TOKEN ? describe : describe.skip; +live('invoice123 adapter: live (read-only)', () => { + const headers = { Authorization: `Bearer ${process.env.INVOICE123_API_TOKEN ?? ''}`, Accept: 'application/json' }; + it('reads the company, one page of invoices and the invoice VAT rates', async () => { + const company = await fetch(`${a.connector.baseUrl}/company`, { headers }); + expect(company.status).toBe(200); + const invoices = (await (await fetch(`${a.connector.baseUrl}/invoices?page=1&limit=1`, { headers })).json()) as { data: { result: unknown[]; pagination: { total: number } } }; + expect(Array.isArray(invoices.data.result)).toBe(true); + const vats = (await (await fetch(`${a.connector.baseUrl}/invoice/vats`, { headers })).json()) as { data: unknown[] }; + expect(Array.isArray(vats.data)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/medmis.json b/packages/backend/src/adapters/intl/medmis.json new file mode 100644 index 000000000..9411fd283 --- /dev/null +++ b/packages/backend/src/adapters/intl/medmis.json @@ -0,0 +1,1813 @@ +{ + "slug": "medmis", + "name": "Medmis", + "aliases": [ + "МЕДМИС", + "MEDMIS API" + ], + "description": "Medmis clinic management platform (Russia): branches, specialists and schedules, appointments, patients, invoices, payments, price list, stock, to-do tasks and occupational check-ups. Booking or changing an appointment and creating a task are included, installed switched off; nothing is deleted.", + "region": "intl", + "category": "healthcare", + "icon": "medmis", + "docsUrl": "https://node3.medmis.ru/apix/swagger/index.html", + "instructions": "## Medmis\nThis connector uses the **MEDMIS API v1** (the integration bus of the Medmis clinic management platform, `/apix/v1`): branches, specialists and their schedules, appointments, patients, invoices, payments, the price list, reserves, stock, to-do tasks and the occupational check-up module (Профосмотры). It reads; booking or changing an appointment and creating a task are included but installed switched off. Nothing is ever deleted through this connector.\n\n**Setup (about 5 minutes)**\n1. Ask Medmis support (support@medmis.ru, subject marked DEV / API_CORE) or your Medmis administrator for an **API user**: a login, its password and the **system name** used to sign in. The API user's permissions decide what the tools can read (`appointments.get`, `patients.get`, `patient_invoices.get`, `payments.get`, `schedules.get`, `stores.get`, `todo.tasks.get`, `profex.*`...).\n2. `MEDMIS_URL` is the address of your clinic's Medmis node, e.g. `https://node3.medmis.ru`, without `/apix`. Its API reference is at `/apix/swagger`.\n3. Fill in `MEDMIS_LOGIN`, `MEDMIS_PASSWORD` (at least 6 characters) and `MEDMIS_SYSTEM_NAME`, install, and run `medmis_list_branches` to check.\n\nThe connector signs in with `POST /apix/v1/Auth/login` and sends the access token as a Bearer header; tokens live about an hour and are renewed automatically.\n\n**Workflow**\n- Start with `medmis_list_branches` and `medmis_list_employees` (ids of branches, specialists and their specializations).\n- Free time: `medmis_get_schedule_grid` with branchIds, dateFrom, dateTo and `onlyFreeSlots=true`.\n- Appointments: `medmis_list_appointments` for a period (filter by specialist, status, patient), `medmis_get_appointment`, and `medmis_list_appointment_reference` (statuses, types, visitations, sources, tags) to read the ids.\n- Patients: `medmis_list_patients` (name, phone, e-mail, card number), `medmis_get_patient` for the full record, `medmis_list_patient_reference` (tags, attractions, identity_doc_types).\n- Money: `medmis_list_invoices`, `medmis_get_invoice`, `medmis_list_invoice_services`, `medmis_list_payments`, `medmis_get_payment`.\n- Price list: `medmis_list_service_categories`, `medmis_list_services`, and a specialist's offer with `medmis_list_employee_services`.\n- Other: `medmis_list_reserves`, `medmis_get_reserve`, `medmis_get_branch`; stock with `medmis_list_stores` (per branch), `medmis_list_store_materials`, `medmis_get_stock_levels`, `medmis_list_material_series`; tasks with `medmis_list_tasks`; check-ups with `medmis_list_checkup_companies`, `medmis_list_checkup_contracts`, `medmis_list_checkup_patients`.\n\n**Writes switched off**: `medmis_create_appointment` (book a slot), `medmis_update_appointment` (move, cancel via status, change comment or tags) and `medmis_create_task`. They change the clinic's live schedule, so an admin turns them on deliberately; before each call show the user the patient, specialist, day and time and wait for a yes.\n\n**Patient data**: patients, appointments, invoices and payments are personal health data. Read only what the question needs and do not repeat identity documents or contact data unless the user asks. The patient list leaves out identity document numbers, INN and SNILS.\n\n**Dates and ids**: day filters are `YYYY-MM-DD`; change-time filters are `yyyy-MM-ddTHH:mm:ss` in UTC. Appointments carry both UTC and local times (`datetimeFromLocal`) plus `utcOffsetMinutes`; quote local times. Branches, specialists, patients, appointments and payments use UUIDs; invoices, stores, materials, tasks and check-up records use numbers. List filters ending in `Ids` take several values.\n\n**Pagination and limits**: lists return `items`, `page`, `pageSize`, `totalCount` and `totalPages`; pageSize is at most 100, page starts at 1. Medmis limits each method: filtered lists 20 requests a minute (one per second), reference lists 40 a minute, single records 100 a minute, sign-in 40 a minute. Avoid tight loops; 429 means wait.\n\n**Errors**: 401 means a wrong login, password or system name, or an expired session (the connector signs in again once). 403 means the API user lacks the permission for that method. 400 `ValidationError` lists the bad fields (e.g. a password shorter than 6 characters, a malformed date). 404 means the id does not exist on your node.", + "prerequisites": "A Medmis clinic account and an API user (login, password and system name) from Medmis support or your administrator, with the permissions for the data you want to read.", + "requiredEnvVars": [ + "MEDMIS_URL", + "MEDMIS_LOGIN", + "MEDMIS_PASSWORD", + "MEDMIS_SYSTEM_NAME" + ], + "envVarMeta": { + "MEDMIS_URL": { + "label": "Medmis node address", + "kind": "address", + "help": "Address of your clinic's Medmis node, without /apix. Medmis support tells you which node your clinic runs on.", + "example": "https://node3.medmis.ru", + "pattern": "^https://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{2,5})?/?$", + "patternMessage": "Only the address, e.g. https://node3.medmis.ru, without /apix." + }, + "MEDMIS_LOGIN": { + "label": "API login", + "kind": "credential", + "help": "Login of the Medmis API user." + }, + "MEDMIS_PASSWORD": { + "label": "API password", + "kind": "credential", + "secret": true, + "help": "Password of the Medmis API user, at least 6 characters.", + "pattern": "^.{6,}$", + "patternMessage": "Medmis passwords have at least 6 characters." + }, + "MEDMIS_SYSTEM_NAME": { + "label": "System name", + "kind": "setting", + "help": "The system name the API user signs in with (systemName in the login request), given with the API user.", + "example": "medmis" + } + }, + "verifyHints": { + "401": "Medmis refused the sign-in. Check MEDMIS_LOGIN, MEDMIS_PASSWORD and MEDMIS_SYSTEM_NAME with Medmis support.", + "403": "Signed in, but the API user lacks the permission for this method (e.g. branches.get). Ask your Medmis administrator to grant it.", + "404": "Nothing found at this address. MEDMIS_URL must be your node's address without /apix, e.g. https://node3.medmis.ru.", + "429": "Medmis rate limit reached. Wait a minute and try again." + }, + "probe": { + "tool": "medmis_list_branches" + }, + "connector": { + "name": "MEDMIS API v1", + "type": "REST", + "baseUrl": "{{MEDMIS_URL}}/apix", + "authType": "LOGIN_TOKEN", + "authConfig": { + "loginUrl": "{{MEDMIS_URL}}/apix/v1/Auth/login", + "loginMethod": "POST", + "loginHeaders": { + "Content-Type": "application/json", + "Accept": "application/json" + }, + "loginBody": { + "login": "${username}", + "systemName": "${aud}", + "password": "${password}" + }, + "username": "{{MEDMIS_LOGIN}}", + "password": "{{MEDMIS_PASSWORD}}", + "aud": "{{MEDMIS_SYSTEM_NAME}}", + "tokenJsonPath": "accessToken", + "expiryJsonPath": "expiresIn", + "expiryFormat": "ttl_seconds", + "tokenTTLSeconds": 3600, + "proactiveRefreshSeconds": 300, + "refreshOn401": true, + "headerName": "Authorization", + "headerTemplate": "Bearer ${token}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "medmis_list_branches", + "description": "List the clinic's branches (филиалы): id, code, name, official name, address, phone, e-mail, working hours, licence and UTC offset. The id is the branchId other tools filter by.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Branches", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_branch", + "description": "Get one branch by id with its address, contacts, working hours, licence data and UTC offset.", + "parameters": { + "type": "object", + "properties": { + "branchId": { + "type": "string", + "description": "Branch UUID from medmis_list_branches." + } + }, + "required": [ + "branchId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Branches/{branchId}" + } + }, + { + "name": "medmis_list_employees", + "description": "List the specialists (doctors and other staff with schedules): id, name, mobile phone, specializations with their ids, branches and whether they are dismissed. Filter by branch or specialization. The employee id and the employeeSpecializationID are what the schedule and appointment tools take.", + "parameters": { + "type": "object", + "properties": { + "branchIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these branches (specialists with a schedule there) (UUIDs)." + }, + "specializationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these specializations (UUIDs)." + }, + "includeDismissed": { + "type": "boolean", + "description": "Include dismissed specialists (default true)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Employees", + "queryParams": { + "branchIds": "$branchIds", + "specializationIds": "$specializationIds", + "includeDismissed": "$includeDismissed", + "page": "$page", + "pageSize": "$pageSize" + } + }, + "responseMapping": { + "transform": { + "exclude": [ + "items[*].snils" + ] + } + } + }, + { + "name": "medmis_list_employee_services", + "description": "List the services one specialist can provide, after service categories, individual settings and exclusions are applied.", + "parameters": { + "type": "object", + "properties": { + "employeeId": { + "type": "string", + "description": "Specialist UUID from medmis_list_employees." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + }, + "required": [ + "employeeId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Employees/{employeeId}/services", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_schedule_grid", + "description": "Working schedule of specialists for one or more branches and a date range: per specialist and day the schedule id, working hours and slots, optionally only the free ones. Use it to find a free slot before booking. Needs at least one branch and both dates.", + "parameters": { + "type": "object", + "properties": { + "branchIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Branch UUIDs, at least one (from medmis_list_branches)." + }, + "dateFrom": { + "type": "string", + "description": "First day, YYYY-MM-DD." + }, + "dateTo": { + "type": "string", + "description": "Last day, YYYY-MM-DD." + }, + "onlyFreeSlots": { + "type": "boolean", + "description": "true returns only free slots (default false)." + }, + "employeeIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these specialists (UUIDs)." + }, + "specializationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these specializations (UUIDs)." + } + }, + "required": [ + "branchIds", + "dateFrom", + "dateTo" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Schedule/grid", + "queryParams": { + "branchIds": "$branchIds", + "dateFrom": "$dateFrom", + "dateTo": "$dateTo", + "onlyFreeSlots": "$onlyFreeSlots", + "employeeIds": "$employeeIds", + "specializationIds": "$specializationIds" + } + } + }, + { + "name": "medmis_list_appointments", + "description": "List appointments (записи на приём) with filters: period, branches, patients, specialists, specializations, statuses, types, tags, creation or change time. Returns id, schedule, times (UTC and local), status, patient, specialist, source, visit type, cabinet and tags.", + "parameters": { + "type": "object", + "properties": { + "dateFrom": { + "type": "string", + "description": "First appointment day, YYYY-MM-DD." + }, + "dateTo": { + "type": "string", + "description": "Last appointment day, YYYY-MM-DD." + }, + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these appointments (UUIDs)." + }, + "branchIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these branches (UUIDs)." + }, + "patientIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these patients (UUIDs)." + }, + "employeeIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these specialists (UUIDs)." + }, + "specializationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these specializations (UUIDs)." + }, + "statusIds": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only these status ids (from medmis_list_appointment_reference with dictionary=statuses)." + }, + "typeIds": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only these appointment type ids (dictionary=types)." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only appointments carrying all of these tags." + }, + "updatedAtAfter": { + "type": "string", + "description": "Changed after, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "updatedAtBefore": { + "type": "string", + "description": "Changed before, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "createdAtAfter": { + "type": "string", + "description": "Created after, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "createdAtBefore": { + "type": "string", + "description": "Created before, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Appointments", + "queryParams": { + "dateFrom": "$dateFrom", + "dateTo": "$dateTo", + "ids": "$ids", + "branchIds": "$branchIds", + "patientIds": "$patientIds", + "employeeIds": "$employeeIds", + "specializationIds": "$specializationIds", + "statusIds": "$statusIds", + "typeIds": "$typeIds", + "tags": "$tags", + "updatedAtAfter": "$updatedAtAfter", + "updatedAtBefore": "$updatedAtBefore", + "createdAtAfter": "$createdAtAfter", + "createdAtBefore": "$createdAtBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_appointment", + "description": "Get one appointment by id: schedule, start and end (UTC and local), status, patient, specialist and specialization, source, visit type, cabinet, tags, comment and whether a paid invoice exists.", + "parameters": { + "type": "object", + "properties": { + "appointmentId": { + "type": "string", + "description": "Appointment UUID from medmis_list_appointments." + } + }, + "required": [ + "appointmentId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Appointments/{appointmentId}" + } + }, + { + "name": "medmis_list_appointment_reference", + "description": "Read an appointment reference list: statuses, types (in person, telemedicine), visitations (first or repeat visit), sources (how the booking came in) or tags. Returns id/name pairs to read or filter appointments.", + "parameters": { + "type": "object", + "properties": { + "dictionary": { + "type": "string", + "enum": [ + "statuses", + "types", + "visitations", + "sources", + "tags" + ], + "description": "Which reference list to read." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + }, + "required": [ + "dictionary" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Appointments/{dictionary}", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_patients", + "description": "Search patients with filters: name parts (partial match), e-mail or mobile phone (exact), outpatient card number, birth date or age range, sex, tags, acquisition source, creation date and marketing consents. Returns contact data, card number, balances and flags; identity document numbers, INN and SNILS are left out of the list (use medmis_get_patient for one patient).", + "parameters": { + "type": "object", + "properties": { + "lastname": { + "type": "string", + "description": "Surname, partial match." + }, + "firstname": { + "type": "string", + "description": "First name, partial match." + }, + "middlename": { + "type": "string", + "description": "Patronymic, partial match." + }, + "email": { + "type": "string", + "description": "E-mail, exact match." + }, + "mobilephone": { + "type": "string", + "description": "Mobile phone, exact match as stored (e.g. 79161234567)." + }, + "outpatientCardNumber": { + "type": "string", + "description": "Outpatient card number, exact match." + }, + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these patients (UUIDs)." + }, + "birthdateAfter": { + "type": "string", + "description": "Born after, YYYY-MM-DD." + }, + "birthdateBefore": { + "type": "string", + "description": "Born before, YYYY-MM-DD." + }, + "ageAfter": { + "type": "integer", + "description": "Older than this many years." + }, + "ageBefore": { + "type": "integer", + "description": "Younger than this many years." + }, + "sex": { + "type": "string", + "enum": [ + "Male", + "Female" + ], + "description": "Sex." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only patients with these tags." + }, + "attractionIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these acquisition sources (dictionary=attractions) (UUIDs)." + }, + "createdAfter": { + "type": "string", + "description": "Created after, YYYY-MM-DD." + }, + "createdBefore": { + "type": "string", + "description": "Created before, YYYY-MM-DD." + }, + "agreeEmail": { + "type": "boolean", + "description": "Consent to e-mail." + }, + "agreeSms": { + "type": "boolean", + "description": "Consent to SMS." + }, + "agreeWhatsapp": { + "type": "boolean", + "description": "Consent to WhatsApp." + }, + "agreePush": { + "type": "boolean", + "description": "Consent to push notifications." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Patients", + "queryParams": { + "lastname": "$lastname", + "firstname": "$firstname", + "middlename": "$middlename", + "email": "$email", + "mobilephone": "$mobilephone", + "outpatientCardNumber": "$outpatientCardNumber", + "ids": "$ids", + "birthdateAfter": "$birthdateAfter", + "birthdateBefore": "$birthdateBefore", + "ageAfter": "$ageAfter", + "ageBefore": "$ageBefore", + "sex": "$sex", + "tags": "$tags", + "attractionIds": "$attractionIds", + "createdAfter": "$createdAfter", + "createdBefore": "$createdBefore", + "agreeEmail": "$agreeEmail", + "agreeSms": "$agreeSms", + "agreeWhatsapp": "$agreeWhatsapp", + "agreePush": "$agreePush", + "page": "$page", + "pageSize": "$pageSize" + } + }, + "responseMapping": { + "transform": { + "exclude": [ + "items[*].snils", + "items[*].inn", + "items[*].identityDocTypeId", + "items[*].identityDocNum", + "items[*].identityDocIssueDate", + "items[*].identityDocIssueAuthority", + "items[*].identityDocIssueCode" + ] + } + } + }, + { + "name": "medmis_get_patient", + "description": "Get one patient's full record by id: name, birth date and age, contacts, sex, tags, card and contract numbers, identity document, address, branch, consents, debtor and minor flags, and account balances. Contains personal and health-related data; show only what the user needs.", + "parameters": { + "type": "object", + "properties": { + "patientId": { + "type": "string", + "description": "Patient UUID from medmis_list_patients or an appointment." + } + }, + "required": [ + "patientId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Patients/{patientId}" + } + }, + { + "name": "medmis_list_patient_reference", + "description": "Read a patient reference list: tags, attractions (acquisition sources) or identity_doc_types (identity document types). Returns id/name pairs.", + "parameters": { + "type": "object", + "properties": { + "dictionary": { + "type": "string", + "enum": [ + "tags", + "attractions", + "identity_doc_types" + ], + "description": "Which reference list to read." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + }, + "required": [ + "dictionary" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Patients/{dictionary}", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_invoices", + "description": "List patient invoices (счета) with filters: period, branches, patients, appointments, paid or not, deleted, change time. Returns id, number, date, amount, patient, appointment, payment and notes.", + "parameters": { + "type": "object", + "properties": { + "dateFrom": { + "type": "string", + "description": "First invoice day, YYYY-MM-DD." + }, + "dateTo": { + "type": "string", + "description": "Last invoice day, YYYY-MM-DD." + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only these invoice ids (numbers)." + }, + "branchIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these branches (UUIDs)." + }, + "patientIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these patients (UUIDs)." + }, + "appointmentIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these appointments (UUIDs)." + }, + "isPaid": { + "type": "boolean", + "description": "true paid only, false unpaid only." + }, + "isDeleted": { + "type": "boolean", + "description": "true deleted only, false active only; omit for both." + }, + "updatedAtAfter": { + "type": "string", + "description": "Changed after, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "updatedAtBefore": { + "type": "string", + "description": "Changed before, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/PatientInvoices", + "queryParams": { + "dateFrom": "$dateFrom", + "dateTo": "$dateTo", + "ids": "$ids", + "branchIds": "$branchIds", + "patientIds": "$patientIds", + "appointmentIds": "$appointmentIds", + "isPaid": "$isPaid", + "isDeleted": "$isDeleted", + "updatedAtAfter": "$updatedAtAfter", + "updatedAtBefore": "$updatedAtBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_invoice", + "description": "Get one patient invoice by its numeric id: number, date, amount, duration, patient, appointment, payment and notes.", + "parameters": { + "type": "object", + "properties": { + "invoiceId": { + "type": "integer", + "description": "Numeric invoice id from medmis_list_invoices." + } + }, + "required": [ + "invoiceId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/PatientInvoices/{invoiceId}" + } + }, + { + "name": "medmis_list_invoice_services", + "description": "List the lines (services and goods) of one invoice with quantities, prices and discounts.", + "parameters": { + "type": "object", + "properties": { + "invoiceId": { + "type": "integer", + "description": "Numeric invoice id from medmis_list_invoices." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + }, + "required": [ + "invoiceId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/PatientInvoices/{invoiceId}/services", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_payments", + "description": "List payments with filters: period, branches, patients, payment type (1 standard payment, 2 prepared partial refund, 3 completed partial refund, 7 auto-deleted), fiscalised or not, deleted, change time. Returns amounts by method (cash, card, bonus points, insurance, company), cash desk, fiscal flag and patient.", + "parameters": { + "type": "object", + "properties": { + "dateFrom": { + "type": "string", + "description": "First payment day, YYYY-MM-DD." + }, + "dateTo": { + "type": "string", + "description": "Last payment day, YYYY-MM-DD." + }, + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these payments (UUIDs)." + }, + "branchIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these branches (UUIDs)." + }, + "patientIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these patients (UUIDs)." + }, + "typeIds": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Payment types: 1 standard, 2 partial refund prepared, 3 partial refund completed, 7 auto-deleted." + }, + "isFiscaled": { + "type": "boolean", + "description": "true fiscalised only, false not fiscalised." + }, + "isDeleted": { + "type": "boolean", + "description": "true deleted only, false active only; omit for both." + }, + "updatedAtAfter": { + "type": "string", + "description": "Changed after, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "updatedAtBefore": { + "type": "string", + "description": "Changed before, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Payments", + "queryParams": { + "dateFrom": "$dateFrom", + "dateTo": "$dateTo", + "ids": "$ids", + "branchIds": "$branchIds", + "patientIds": "$patientIds", + "typeIds": "$typeIds", + "isFiscaled": "$isFiscaled", + "isDeleted": "$isDeleted", + "updatedAtAfter": "$updatedAtAfter", + "updatedAtBefore": "$updatedAtBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_payment", + "description": "Get one payment by id with its amounts per payment method, cash desk, fiscal flag, notes and patient.", + "parameters": { + "type": "object", + "properties": { + "paymentId": { + "type": "string", + "description": "Payment UUID from medmis_list_payments." + } + }, + "required": [ + "paymentId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Payments/{paymentId}" + } + }, + { + "name": "medmis_list_service_categories", + "description": "List the price list's service categories, optionally below a parent category, by name or for one branch.", + "parameters": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these categories (UUIDs)." + }, + "parentId": { + "type": "string", + "description": "Parent category UUID; omit for all." + }, + "name": { + "type": "string", + "description": "Category name, partial match." + }, + "branchId": { + "type": "string", + "description": "Branch UUID." + }, + "includeDeleted": { + "type": "boolean", + "description": "Include deleted categories (default false)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Pricelist/service_categories", + "queryParams": { + "ids": "$ids", + "parentId": "$parentId", + "name": "$name", + "branchId": "$branchId", + "includeDeleted": "$includeDeleted", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_services", + "description": "Search the price list's services (услуги): code, PMU code, name, type (0 service, 1 goods, 2 lab test), category and price.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Service name, partial match." + }, + "code": { + "type": "string", + "description": "Service code, exact." + }, + "codePMU": { + "type": "string", + "description": "PMU code (Russian medical services nomenclature), exact." + }, + "serviceCategoryId": { + "type": "string", + "description": "Category UUID from medmis_list_service_categories." + }, + "typeId": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "0 service, 1 goods, 2 lab test." + }, + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these services (UUIDs)." + }, + "includeDeleted": { + "type": "boolean", + "description": "Include deleted services (default false)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Pricelist/services", + "queryParams": { + "name": "$name", + "code": "$code", + "codePMU": "$codePMU", + "serviceCategoryId": "$serviceCategoryId", + "typeId": "$typeId", + "ids": "$ids", + "includeDeleted": "$includeDeleted", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_reserves", + "description": "List reserves (time held in a specialist's schedule for a person, with name and phone) with filters: period, branches, specialists, patients, tags, creation time.", + "parameters": { + "type": "object", + "properties": { + "dateFrom": { + "type": "string", + "description": "First day, YYYY-MM-DD." + }, + "dateTo": { + "type": "string", + "description": "Last day, YYYY-MM-DD." + }, + "ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these reserves (UUIDs)." + }, + "branchIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these branches (UUIDs)." + }, + "employeeIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these specialists (UUIDs)." + }, + "patientIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only these patients (UUIDs)." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only reserves with these tags." + }, + "createdAtAfter": { + "type": "string", + "description": "Created after, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "createdAtBefore": { + "type": "string", + "description": "Created before, yyyy-MM-ddTHH:mm:ss in UTC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Reserves", + "queryParams": { + "dateFrom": "$dateFrom", + "dateTo": "$dateTo", + "ids": "$ids", + "branchIds": "$branchIds", + "employeeIds": "$employeeIds", + "patientIds": "$patientIds", + "tags": "$tags", + "createdAtAfter": "$createdAtAfter", + "createdAtBefore": "$createdAtBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_reserve", + "description": "Get one reserve by id: a time held in a specialist's schedule for a person, with branch, specialist, date and time, the person's name and phone, comment, tags and auto-extension settings.", + "parameters": { + "type": "object", + "properties": { + "reserveId": { + "type": "string", + "description": "Reserve UUID from medmis_list_reserves." + } + }, + "required": [ + "reserveId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Reserves/{reserveId}" + } + }, + { + "name": "medmis_list_stores", + "description": "List the stores (warehouses) of one branch. The id is the storeId for medmis_get_stock_levels.", + "parameters": { + "type": "object", + "properties": { + "branchId": { + "type": "string", + "description": "Branch UUID from medmis_list_branches." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + }, + "required": [ + "branchId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Store/{branchId}", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_store_materials", + "description": "Search the store nomenclature (materials, medicines, consumables) by name or code; isSection separates folders from items.", + "parameters": { + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "Text matched against name and code." + }, + "isSection": { + "type": "boolean", + "description": "true folders only, false materials only; omit for both." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Store/materials", + "queryParams": { + "search": "$search", + "isSection": "$isSection", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_get_stock_levels", + "description": "Stock on hand of materials per store, optionally for one store or one material.", + "parameters": { + "type": "object", + "properties": { + "storeId": { + "type": "integer", + "description": "Store id from medmis_list_stores." + }, + "storeMaterialId": { + "type": "integer", + "description": "Material id from medmis_list_store_materials." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Store/rest", + "queryParams": { + "storeId": "$storeId", + "storeMaterialId": "$storeMaterialId", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_material_series", + "description": "List batches (series) of store materials with expiry, optionally only expired ones or for one material.", + "parameters": { + "type": "object", + "properties": { + "storeMaterialId": { + "type": "integer", + "description": "Material id from medmis_list_store_materials." + }, + "search": { + "type": "string", + "description": "Series number, partial match." + }, + "expiredOnly": { + "type": "boolean", + "description": "Only expired series (default false)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Store/material-series", + "queryParams": { + "storeMaterialId": "$storeMaterialId", + "search": "$search", + "expiredOnly": "$expiredOnly", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_tasks", + "description": "List internal to-do tasks with filters: author, performer, responsible user, patient, status (0 to do, 2 in progress, 4 paused, 6 done), priority (0 high, 2 medium, 4 low), tags and creation, change, start or deadline time.", + "parameters": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only these task ids." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only tasks with these tags." + }, + "authorUserID": { + "type": "string", + "description": "Author user UUID." + }, + "performerUserID": { + "type": "string", + "description": "Performer user UUID." + }, + "responsibleUserID": { + "type": "string", + "description": "Responsible user UUID." + }, + "patientID": { + "type": "string", + "description": "Linked patient UUID." + }, + "todoTaskStatusIDs": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Statuses: 0 to do, 2 in progress, 4 paused, 6 done." + }, + "todoTaskPriorityIDs": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Priorities: 0 high, 2 medium, 4 low." + }, + "createdAfter": { + "type": "string", + "description": "Created after, ISO 8601 UTC." + }, + "createdBefore": { + "type": "string", + "description": "Created before, ISO 8601 UTC." + }, + "modifiedAfter": { + "type": "string", + "description": "Changed after, ISO 8601 UTC." + }, + "modifiedBefore": { + "type": "string", + "description": "Changed before, ISO 8601 UTC." + }, + "startAfter": { + "type": "string", + "description": "Start after, ISO 8601 UTC." + }, + "startBefore": { + "type": "string", + "description": "Start before, ISO 8601 UTC." + }, + "deadlineAfter": { + "type": "string", + "description": "Deadline after, ISO 8601 UTC." + }, + "deadlineBefore": { + "type": "string", + "description": "Deadline before, ISO 8601 UTC." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Todo/tasks", + "queryParams": { + "ids": "$ids", + "tags": "$tags", + "authorUserID": "$authorUserID", + "performerUserID": "$performerUserID", + "responsibleUserID": "$responsibleUserID", + "patientID": "$patientID", + "todoTaskStatusIDs": "$todoTaskStatusIDs", + "todoTaskPriorityIDs": "$todoTaskPriorityIDs", + "createdAfter": "$createdAfter", + "createdBefore": "$createdBefore", + "modifiedAfter": "$modifiedAfter", + "modifiedBefore": "$modifiedBefore", + "startAfter": "$startAfter", + "startBefore": "$startBefore", + "deadlineAfter": "$deadlineAfter", + "deadlineBefore": "$deadlineBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_checkup_companies", + "description": "List the companies of the occupational medical check-up module (Профосмотры): the employers the clinic examines staff for.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Profex/companies", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_checkup_contracts", + "description": "List check-up contracts with filters: company (B2B), patient (B2C), status, branch and creation date.", + "parameters": { + "type": "object", + "properties": { + "profexCompanyId": { + "type": "integer", + "description": "Company id from medmis_list_checkup_companies." + }, + "patientId": { + "type": "string", + "description": "Patient UUID (B2C contracts)." + }, + "stateId": { + "type": "integer", + "description": "Contract status id." + }, + "branchId": { + "type": "string", + "description": "Branch UUID." + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only these contract ids." + }, + "createdAtAfter": { + "type": "string", + "description": "Created from this day, yyyy-MM-ddTHH:mm:ss (time ignored)." + }, + "createdAtBefore": { + "type": "string", + "description": "Created up to this day, yyyy-MM-ddTHH:mm:ss (time ignored)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Profex/contracts", + "queryParams": { + "profexCompanyId": "$profexCompanyId", + "patientId": "$patientId", + "stateId": "$stateId", + "branchId": "$branchId", + "ids": "$ids", + "createdAtAfter": "$createdAtAfter", + "createdAtBefore": "$createdAtBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_list_checkup_patients", + "description": "List the people (check-ups) under check-up contracts with filters: contract, patient, status and check-up date.", + "parameters": { + "type": "object", + "properties": { + "profexContractId": { + "type": "integer", + "description": "Contract id from medmis_list_checkup_contracts." + }, + "patientId": { + "type": "string", + "description": "Patient UUID." + }, + "stateId": { + "type": "integer", + "description": "Status id." + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Only these check-up ids." + }, + "profexDateAfter": { + "type": "string", + "description": "Check-up from this day, yyyy-MM-ddTHH:mm:ss (time ignored)." + }, + "profexDateBefore": { + "type": "string", + "description": "Check-up up to this day, yyyy-MM-ddTHH:mm:ss (time ignored)." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "pageSize": { + "type": "integer", + "description": "Rows per page, 1 to 100 (default 100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/Profex/contract-patients", + "queryParams": { + "profexContractId": "$profexContractId", + "patientId": "$patientId", + "stateId": "$stateId", + "ids": "$ids", + "profexDateAfter": "$profexDateAfter", + "profexDateBefore": "$profexDateBefore", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "medmis_create_appointment", + "description": "Book an appointment in a free slot of a specialist's schedule. Take scheduleId and a free slot from medmis_get_schedule_grid and employeeSpecializationID from medmis_list_employees; times are local clinic time HH:mm. Installed switched off; confirm patient, specialist, day and time with the user first.", + "parameters": { + "type": "object", + "properties": { + "scheduleId": { + "type": "string", + "description": "Schedule UUID of the specialist's working day (from medmis_get_schedule_grid)." + }, + "employeeSpecializationID": { + "type": "string", + "description": "UUID of the specialist's specialization (from medmis_list_employees)." + }, + "timeFrom": { + "type": "string", + "description": "Start, local time HH:mm, e.g. 10:00." + }, + "timeTo": { + "type": "string", + "description": "End, local time HH:mm, e.g. 10:30." + }, + "patientId": { + "type": "string", + "description": "Patient UUID from medmis_list_patients." + }, + "statusId": { + "type": "integer", + "description": "Status id (dictionary=statuses); omit for the default." + }, + "sourceID": { + "type": "integer", + "description": "Booking source id (dictionary=sources)." + }, + "typeId": { + "type": "integer", + "description": "Appointment type id (dictionary=types), e.g. in person or telemedicine." + }, + "visitation": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "0 not set, 1 first visit, 2 repeat visit." + }, + "comment": { + "type": "string", + "description": "Comment shown on the appointment." + }, + "tags": { + "type": "string", + "description": "Existing tags in the form |tag1|tag2|." + }, + "cabinetID": { + "type": "string", + "description": "Cabinet (room) UUID." + } + }, + "required": [ + "scheduleId", + "employeeSpecializationID", + "timeFrom", + "timeTo", + "patientId" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/Appointments", + "bodyMapping": { + "scheduleId": "$scheduleId", + "employeeSpecializationID": "$employeeSpecializationID", + "timeFrom": "$timeFrom", + "timeTo": "$timeTo", + "patientId": "$patientId", + "statusId": "$statusId", + "sourceID": "$sourceID", + "typeId": "$typeId", + "visitation": "$visitation", + "comment": "$comment", + "tags": "$tags", + "cabinetID": "$cabinetID" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "medmis_update_appointment", + "description": "Change an existing appointment: status (e.g. cancel or confirm), start and end time in UTC, comment, tags, cabinet, source, type or visit type. Only the fields you pass change. Installed switched off; confirm the change with the user first.", + "parameters": { + "type": "object", + "properties": { + "appointmentId": { + "type": "string", + "description": "Appointment UUID from medmis_list_appointments." + }, + "statusId": { + "type": "integer", + "description": "New status id (dictionary=statuses)." + }, + "statusSubId": { + "type": "integer", + "description": "Status sub-id, if the status uses one." + }, + "datetimeFrom": { + "type": "string", + "description": "New start, ISO 8601 UTC." + }, + "datetimeTo": { + "type": "string", + "description": "New end, ISO 8601 UTC." + }, + "comment": { + "type": "string", + "description": "Comment (replaces the current one)." + }, + "tags": { + "type": "string", + "description": "Tags |tag1|tag2| (replaces the current ones)." + }, + "cabinetID": { + "type": "string", + "description": "Cabinet UUID." + }, + "sourceID": { + "type": "integer", + "description": "Booking source id." + }, + "typeId": { + "type": "integer", + "description": "Appointment type id." + }, + "visitation": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "0 not set, 1 first visit, 2 repeat visit." + } + }, + "required": [ + "appointmentId" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/v1/Appointments/{appointmentId}", + "bodyMapping": { + "statusId": "$statusId", + "statusSubId": "$statusSubId", + "datetimeFrom": "$datetimeFrom", + "datetimeTo": "$datetimeTo", + "comment": "$comment", + "tags": "$tags", + "cabinetID": "$cabinetID", + "sourceID": "$sourceID", + "typeId": "$typeId", + "visitation": "$visitation" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "medmis_create_task", + "description": "Create an internal to-do task, optionally linked to a patient, with performers, responsible user, start, deadline, status and priority. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Task title." + }, + "description": { + "type": "string", + "description": "Task description." + }, + "patientID": { + "type": "string", + "description": "Linked patient UUID." + }, + "performerUserIDsArray": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Performer user UUIDs." + }, + "responsibleUserID": { + "type": "string", + "description": "Responsible user UUID." + }, + "startDateUtc": { + "type": "string", + "description": "Start, ISO 8601 UTC." + }, + "deadlineDateUtc": { + "type": "string", + "description": "Deadline, ISO 8601 UTC." + }, + "todoTaskStatusID": { + "type": "integer", + "enum": [ + 0, + 2, + 4, + 6 + ], + "description": "0 to do (default), 2 in progress, 4 paused, 6 done." + }, + "todoTaskPriorityID": { + "type": "integer", + "enum": [ + 0, + 2, + 4 + ], + "description": "0 high, 2 medium, 4 low." + }, + "tagsArray": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags." + } + }, + "required": [ + "name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/Todo/tasks", + "bodyMapping": { + "name": "$name", + "description": "$description", + "patientID": "$patientID", + "performerUserIDsArray": "$performerUserIDsArray", + "responsibleUserID": "$responsibleUserID", + "startDateUtc": "$startDateUtc", + "deadlineDateUtc": "$deadlineDateUtc", + "todoTaskStatusID": "$todoTaskStatusID", + "todoTaskPriorityID": "$todoTaskPriorityID", + "tagsArray": "$tagsArray" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/medmis.live.spec.ts b/packages/backend/src/adapters/intl/medmis.live.spec.ts new file mode 100644 index 000000000..5376aa406 --- /dev/null +++ b/packages/backend/src/adapters/intl/medmis.live.spec.ts @@ -0,0 +1,179 @@ +import * as adapter from './medmis.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; + +/** + * MEDMIS API v1 (Swagger at /apix/swagger, spec at /apix/swagger/v1/swagger.json; each clinic + * runs on a node such as https://node3.medmis.ru). Sign-in: POST /apix/v1/Auth/login with login, + * systemName and password returns a JWT accessToken (expiresIn seconds) sent as Bearer. + * Reads only, plus three writes installed switched off; no DELETE route is mapped. + * Live part: MEDMIS_URL=https://nodeN.medmis.ru MEDMIS_LOGIN=... MEDMIS_PASSWORD=... MEDMIS_SYSTEM_NAME=... RUN_MEDMIS_LIVE=1 npx jest src/adapters/intl/medmis + * (reads only: branches, appointment statuses, today's appointments). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'medmis_'; +const WRITES: string[] = ['medmis_create_appointment', 'medmis_update_appointment', 'medmis_create_task']; + +describe('medmis adapter: static spec conformance', () => { + it('signs in with login, system name and password and sends the access token as Bearer', () => { + expect(a.requiredEnvVars).toEqual(['MEDMIS_URL', 'MEDMIS_LOGIN', 'MEDMIS_PASSWORD', 'MEDMIS_SYSTEM_NAME']); + expect(a.connector.baseUrl).toBe('{{MEDMIS_URL}}/apix'); + expect(a.connector.authType).toBe('LOGIN_TOKEN'); + expect(a.connector.authConfig).toMatchObject({ + loginUrl: '{{MEDMIS_URL}}/apix/v1/Auth/login', + loginMethod: 'POST', + loginBody: { login: '${username}', systemName: '${aud}', password: '${password}' }, + username: '{{MEDMIS_LOGIN}}', + password: '{{MEDMIS_PASSWORD}}', + aud: '{{MEDMIS_SYSTEM_NAME}}', + tokenJsonPath: 'accessToken', + expiryJsonPath: 'expiresIn', + expiryFormat: 'ttl_seconds', + refreshOn401: true, + headerTemplate: 'Bearer ${token}', + }); + expect(a.envVarMeta?.MEDMIS_PASSWORD.secret).toBe(true); + const url = new RegExp(a.envVarMeta?.MEDMIS_URL.pattern as string); + expect(url.test('https://node3.medmis.ru')).toBe(true); + expect(url.test('https://node3.medmis.ru/apix')).toBe(false); + for (const t of a.tools) expect(t.endpointMapping.path.startsWith('/v1/')).toBe(true); + for (const t of a.tools) expect(t.endpointMapping.path).not.toMatch(/Auth|Webhooks/); + }); + + it('maps the documented routes', () => { + expect(tool('medmis_list_appointments').endpointMapping).toMatchObject({ + method: 'GET', + path: '/v1/Appointments', + queryParams: { dateFrom: '$dateFrom', dateTo: '$dateTo', employeeIds: '$employeeIds', statusIds: '$statusIds', page: '$page', pageSize: '$pageSize' }, + }); + expect(tool('medmis_list_appointment_reference').endpointMapping.path).toBe('/v1/Appointments/{dictionary}'); + expect((tool('medmis_list_appointment_reference').parameters?.properties?.dictionary as { enum: string[] }).enum).toEqual(['statuses', 'types', 'visitations', 'sources', 'tags']); + expect((tool('medmis_list_patient_reference').parameters?.properties?.dictionary as { enum: string[] }).enum).toEqual(['tags', 'attractions', 'identity_doc_types']); + expect(tool('medmis_get_schedule_grid').endpointMapping).toMatchObject({ path: '/v1/Schedule/grid', queryParams: { branchIds: '$branchIds', dateFrom: '$dateFrom', dateTo: '$dateTo', onlyFreeSlots: '$onlyFreeSlots' } }); + expect(tool('medmis_get_schedule_grid').parameters?.required).toEqual(['branchIds', 'dateFrom', 'dateTo']); + expect(tool('medmis_list_invoices').endpointMapping).toMatchObject({ path: '/v1/PatientInvoices', queryParams: { isPaid: '$isPaid', patientIds: '$patientIds' } }); + expect(tool('medmis_list_invoice_services').endpointMapping.path).toBe('/v1/PatientInvoices/{invoiceId}/services'); + expect(tool('medmis_list_payments').endpointMapping).toMatchObject({ path: '/v1/Payments', queryParams: { typeIds: '$typeIds', isFiscaled: '$isFiscaled' } }); + expect(tool('medmis_list_stores').endpointMapping.path).toBe('/v1/Store/{branchId}'); + expect(tool('medmis_get_stock_levels').endpointMapping.path).toBe('/v1/Store/rest'); + expect(tool('medmis_list_checkup_patients').endpointMapping.path).toBe('/v1/Profex/contract-patients'); + expect(tool('medmis_create_appointment').endpointMapping).toMatchObject({ + method: 'POST', + path: '/v1/Appointments', + bodyMapping: { scheduleId: '$scheduleId', employeeSpecializationID: '$employeeSpecializationID', timeFrom: '$timeFrom', timeTo: '$timeTo', patientId: '$patientId' }, + }); + expect(tool('medmis_update_appointment').endpointMapping).toMatchObject({ method: 'PATCH', path: '/v1/Appointments/{appointmentId}', bodyMapping: { statusId: '$statusId' } }); + expect(tool('medmis_update_appointment').endpointMapping.bodyMapping).not.toHaveProperty('appointmentId'); + expect(tool('medmis_update_appointment').annotations?.destructiveHint).toBe(true); + expect(tool('medmis_create_task').endpointMapping).toMatchObject({ method: 'POST', path: '/v1/Todo/tasks', bodyMapping: { name: '$name' } }); + }); + + it('keeps identity numbers out of the patient and employee lists', () => { + const patients = applyResponseTransform( + { items: [{ id: 'p1', lastname: 'Ivanov', snils: '1', inn: '2', identityDocNum: '3', identityDocIssueAuthority: '4', mobilePhone: '7916' }], page: 1, totalCount: 1 }, + tool('medmis_list_patients').responseMapping as never, + ).value; + expect(patients).toEqual({ items: [{ id: 'p1', lastname: 'Ivanov', mobilePhone: '7916' }], page: 1, totalCount: 1 }); + const employees = applyResponseTransform({ items: [{ id: 'e1', name: 'Dr', snils: '1' }] }, tool('medmis_list_employees').responseMapping as never).value; + expect(employees).toEqual({ items: [{ id: 'e1', name: 'Dr' }] }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + for (const t of a.tools) expect(t.endpointMapping.method).not.toBe('DELETE'); + }); + + it('probes with a parameter-free read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe.tool); + expect(probe.endpointMapping.method).toBe('GET'); + expect(probe.parameters?.required ?? []).toEqual([]); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + + +const live = + process.env.RUN_MEDMIS_LIVE === '1' && process.env.MEDMIS_URL && process.env.MEDMIS_LOGIN && process.env.MEDMIS_PASSWORD && process.env.MEDMIS_SYSTEM_NAME ? describe : describe.skip; +live('medmis adapter: live (read-only)', () => { + const base = `${(process.env.MEDMIS_URL ?? '').replace(/\/$/, '')}/apix`; + const pause = () => new Promise((r) => setTimeout(r, 1100)); + it('signs in and reads branches, appointment statuses and today\'s appointments', async () => { + const login = await fetch(`${base}/v1/Auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ login: process.env.MEDMIS_LOGIN, systemName: process.env.MEDMIS_SYSTEM_NAME, password: process.env.MEDMIS_PASSWORD }), + }); + expect(login.status).toBe(200); + const { accessToken } = (await login.json()) as { accessToken: string }; + const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }; + const branches = (await (await fetch(`${base}/v1/Branches?pageSize=5`, { headers })).json()) as { items: unknown[]; totalCount: number }; + expect(Array.isArray(branches.items)).toBe(true); + await pause(); + const statuses = (await (await fetch(`${base}/v1/Appointments/statuses`, { headers })).json()) as { items: unknown[] }; + expect(Array.isArray(statuses.items)).toBe(true); + await pause(); + const day = new Date().toISOString().slice(0, 10); + const appts = (await (await fetch(`${base}/v1/Appointments?dateFrom=${day}&dateTo=${day}&pageSize=5`, { headers })).json()) as { items: unknown[] }; + expect(Array.isArray(appts.items)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/netskope.json b/packages/backend/src/adapters/intl/netskope.json new file mode 100644 index 000000000..2174803b1 --- /dev/null +++ b/packages/backend/src/adapters/intl/netskope.json @@ -0,0 +1,541 @@ +{ + "slug": "netskope", + "name": "Netskope", + "aliases": [ + "Netskope SSE", + "Netskope One", + "Netskope REST API" + ], + "description": "Netskope Security Service Edge, read-only: search alerts and events (application, web, network, incident, endpoint DLP), the admin audit log, user risk scores, Cloud Confidence Index lookups, URL lists, Private Access publishers and apps, and SCIM users and groups.", + "region": "intl", + "category": "monitoring", + "icon": "netskope", + "docsUrl": "https://docs.netskope.com/en/rest-api-v2-overview-312207/", + "instructions": "## Netskope\nThis connector reads a **Netskope** Security Service Edge tenant through **REST API v2**: alerts and events (application, web page, network, incident, client status, endpoint DLP), the administrator audit log, user risk scores (UCI), Cloud Confidence Index lookups, URL lists, Private Access publishers and apps, and SCIM users and groups. It is read-only.\n\n**Setup**\n1. In the Netskope tenant open **Settings > Tools > REST API v2** and create a new token (or use a service account under Settings > Administration > Administrators & Roles).\n2. Add every endpoint the AI should use with **Read** privilege: `/api/v2/events/datasearch/alert` and the other `/api/v2/events/datasearch/...` types, `/api/v2/events/data/audit`, `/api/v2/ubadatasvc/user/uci`, `/api/v2/services/cci/app`, `/api/v2/policy/urllist`, `/api/v2/infrastructure/publishers`, `/api/v2/steering/apps/private`, `/api/v2/scim/Users`, `/api/v2/scim/Groups`. Set an expiry and copy the token into `NETSKOPE_API_TOKEN`. It is sent in the `Netskope-Api-Token` header.\n3. `NETSKOPE_TENANT` is the host name of your tenant, e.g. `example.goskope.com` (EU tenants e.g. `example.eu.goskope.com`), without https:// or a path.\n4. If the tenant uses an IP allowlist (Settings > Administration > IP Allowlist), add the outbound address of this AnythingMCP server. Install and run `netskope_search_alerts` to check the token.\n\n**Workflow**\n- Alerts: `netskope_search_alerts` with a time range (`starttime`, `endtime` in Unix seconds) and a `query` such as `alert_type eq 'DLP'` or `severity eq 'high'`; ask only for the `fields` you need. For counts use `fields=count_total:count(_id)` with `groupbys` (e.g. `alert_name` or `user`) and `orderbys` `count_total desc`.\n- Events: `netskope_search_events` with `event_type` application, page, network, incident, clientstatus or epdlp, same parameters. Admin changes: `netskope_list_audit_events`.\n- A user: `netskope_get_user_risk_score` (UCI, from_time in milliseconds), `netskope_list_scim_users`, `netskope_list_scim_groups`.\n- An app: `netskope_lookup_cloud_app` needs the exact app name. Policies: `netskope_list_url_lists`, `netskope_get_url_list`. Private Access: `netskope_list_publishers`, `netskope_list_private_apps`.\n\n**Paging and limits**: datasearch lists page with `limit` and `offset`; SCIM with `startIndex` (from 1) and `count`. Always set a time range on event searches: large tenants hold millions of events. Netskope rate-limits per endpoint (a few calls per second; 429 with Retry-After above that).\n\n**Errors**: 401 or 403 means a wrong or expired token, or the endpoint was not added to the token with Read privilege. A datasearch call that fails reports `status.execution` FAILED with a message (e.g. an unknown field in `query`); this connector turns it into an error. A 404 on every call usually means a wrong `NETSKOPE_TENANT`.", + "prerequisites": "A Netskope tenant and a REST API v2 token with Read privilege on the endpoints the AI should use (Settings > Tools > REST API v2).", + "requiredEnvVars": [ + "NETSKOPE_TENANT", + "NETSKOPE_API_TOKEN" + ], + "envVarMeta": { + "NETSKOPE_TENANT": { + "label": "Tenant host", + "kind": "address", + "help": "The host name of your Netskope tenant, as in the browser address bar, without https:// or a path.", + "example": "example.goskope.com", + "pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*\\.goskope\\.com$", + "patternMessage": "Only the tenant host, e.g. example.goskope.com, without https:// or a path." + }, + "NETSKOPE_API_TOKEN": { + "label": "REST API v2 token", + "kind": "credential", + "secret": true, + "help": "Netskope, Settings > Tools > REST API v2: a token with Read privilege on the endpoints the AI should use.", + "link": "https://docs.netskope.com/en/rest-api-v2-overview-312207/", + "pattern": "^\\S{16,}$", + "patternMessage": "Paste only the token, without spaces or quotes." + } + }, + "verifyHints": { + "401": "Netskope refused the token: it is wrong or expired. Create a new one under Settings > Tools > REST API v2.", + "403": "The token works, but this endpoint was not added to it with Read privilege, or the tenant IP allowlist blocks this server.", + "not_found": "No Netskope REST API at this address. NETSKOPE_TENANT is only the tenant host, e.g. example.goskope.com.", + "rate_limited": "Netskope rate-limits each endpoint. Wait a few seconds and try again." + }, + "probe": { + "tool": "netskope_search_alerts", + "params": { + "limit": 1 + } + }, + "connector": { + "name": "Netskope REST API v2", + "type": "REST", + "baseUrl": "https://{{NETSKOPE_TENANT}}", + "authType": "API_KEY", + "authConfig": { + "headerName": "Netskope-Api-Token", + "apiKey": "{{NETSKOPE_API_TOKEN}}" + }, + "headers": { + "Accept": "application/json" + }, + "config": { + "errorWhen": [ + { + "path": "status.execution", + "equals": "FAILED", + "messagePath": "status.message", + "status": 400 + } + ] + } + }, + "tools": [ + { + "name": "netskope_search_alerts", + "description": "Search Netskope alerts (DLP, malware, policy, anomaly, compromised credential, UBA...) through the datasearch endpoint: filter with the Netskope query language and a time range, choose fields, aggregate with groupbys, sort and page. Returns result rows and status.count.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Filter in the Netskope (Skope IT) query language, e.g. severity eq 'high' and user eq 'alice@example.com', or alert_type in ('DLP','malware')." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to return, e.g. _id,timestamp,user,app,alert_name. Aggregations work too: count_total:count(_id)." + }, + "starttime": { + "type": "integer", + "description": "Start of the period, Unix time in seconds." + }, + "endtime": { + "type": "integer", + "description": "End of the period, Unix time in seconds." + }, + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + }, + "groupbys": { + "type": "string", + "description": "Comma-separated fields to aggregate on, e.g. user,app (use with an aggregation in fields)." + }, + "orderbys": { + "type": "string", + "description": "Sort order, e.g. timestamp desc or count_total desc." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/events/datasearch/alert", + "queryParams": { + "query": "$query", + "fields": "$fields", + "starttime": "$starttime", + "endtime": "$endtime", + "limit": "$limit", + "offset": "$offset", + "groupbys": "$groupbys", + "orderbys": "$orderbys" + } + } + }, + { + "name": "netskope_search_events", + "description": "Search Netskope events of one type through the datasearch endpoint: application (cloud app activity), page (web visits), network (firewall and private access traffic), incident, clientstatus (Netskope client status of devices) or epdlp (endpoint DLP). Same query language, fields, time range, aggregation and paging as alerts.", + "parameters": { + "type": "object", + "properties": { + "event_type": { + "type": "string", + "description": "Event type to search.", + "enum": [ + "application", + "page", + "network", + "incident", + "clientstatus", + "epdlp" + ] + }, + "query": { + "type": "string", + "description": "Filter in the Netskope (Skope IT) query language, e.g. severity eq 'high' and user eq 'alice@example.com', or alert_type in ('DLP','malware')." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to return, e.g. _id,timestamp,user,app,alert_name. Aggregations work too: count_total:count(_id)." + }, + "starttime": { + "type": "integer", + "description": "Start of the period, Unix time in seconds." + }, + "endtime": { + "type": "integer", + "description": "End of the period, Unix time in seconds." + }, + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + }, + "groupbys": { + "type": "string", + "description": "Comma-separated fields to aggregate on, e.g. user,app (use with an aggregation in fields)." + }, + "orderbys": { + "type": "string", + "description": "Sort order, e.g. timestamp desc or count_total desc." + } + }, + "required": [ + "event_type" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/events/datasearch/{event_type}", + "queryParams": { + "query": "$query", + "fields": "$fields", + "starttime": "$starttime", + "endtime": "$endtime", + "limit": "$limit", + "offset": "$offset", + "groupbys": "$groupbys", + "orderbys": "$orderbys" + } + } + }, + { + "name": "netskope_list_audit_events", + "description": "List the administrator audit log of the tenant (who changed which setting or policy, logins to the console) for a time range, optionally only one audit type.", + "parameters": { + "type": "object", + "properties": { + "starttime": { + "type": "integer", + "description": "Start of the period, Unix time in seconds." + }, + "endtime": { + "type": "integer", + "description": "End of the period, Unix time in seconds." + }, + "type": { + "type": "string", + "description": "Audit type filter, e.g. admin or user." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to return." + }, + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/events/data/audit", + "queryParams": { + "starttime": "$starttime", + "endtime": "$endtime", + "type": "$type", + "fields": "$fields", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "netskope_get_user_risk_score", + "description": "Get the User Confidence Index (UCI, behavior analytics risk score from 0 to 1000) of one user and its changes since a point in time. Read-only, sent as POST.", + "parameters": { + "type": "object", + "properties": { + "user": { + "type": "string", + "description": "User e-mail address as known to Netskope." + }, + "from_time": { + "type": "integer", + "description": "Start of the window, Unix time in milliseconds (e.g. 7 days ago)." + } + }, + "required": [ + "user", + "from_time" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/ubadatasvc/user/uci", + "bodyMapping": { + "user": "$user", + "fromTime": "$from_time" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "netskope_lookup_cloud_app", + "description": "Look up the Cloud Confidence Index (CCI) of a cloud application by its exact name (e.g. Dropbox, Slack): score, Cloud Confidence Level, category, tags and whether it is sanctioned or discovered.", + "parameters": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "Exact application name as Netskope lists it; several names separated by semicolons." + }, + "category": { + "type": "string", + "description": "Category filter, e.g. Cloud Storage." + }, + "ccl": { + "type": "string", + "description": "Cloud Confidence Level filter.", + "enum": [ + "excellent", + "high", + "medium", + "low", + "poor" + ] + }, + "tag": { + "type": "string", + "description": "Tag filter." + }, + "discovered": { + "type": "boolean", + "description": "true for discovered (shadow IT) apps only, false for sanctioned only." + }, + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + } + }, + "required": [ + "app_name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/services/cci/app", + "queryParams": { + "apps": "$app_name", + "category": "$category", + "ccl": "$ccl", + "tag": "$tag", + "discovered": "$discovered", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "netskope_list_url_lists", + "description": "List the URL lists used in policies (allow and block lists): id, name, type (exact or regex), URLs and pending changes.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/policy/urllist", + "queryParams": { + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "netskope_get_url_list", + "description": "Get one URL list by its numeric id with all its URLs and its type.", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "integer", + "description": "URL list id, from netskope_list_url_lists." + } + }, + "required": [ + "list_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/policy/urllist/{list_id}" + } + }, + { + "name": "netskope_list_publishers", + "description": "List the Netskope Private Access publishers: id, name, status (connected or not), version, assessment and the apps they serve.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "Filter expression, e.g. publisher_name eq 'pub-fra-1'." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to return." + }, + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/infrastructure/publishers", + "queryParams": { + "filter": "$filter", + "fields": "$fields", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "netskope_list_private_apps", + "description": "List the private applications of Netskope Private Access: name, hosts, protocols and ports, publishers, reachability, clientless access and tags.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search text." + }, + "app_name": { + "type": "string", + "description": "Application name." + }, + "publisher_name": { + "type": "string", + "description": "Publisher name." + }, + "host": { + "type": "string", + "description": "Host name or IP." + }, + "limit": { + "type": "integer", + "description": "Rows to return (default 100).", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "Rows to skip, for paging.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/steering/apps/private", + "queryParams": { + "query": "$query", + "app_name": "$app_name", + "publisher_name": "$publisher_name", + "host": "$host", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "netskope_list_scim_users", + "description": "List users provisioned to Netskope over SCIM: id, userName, name, e-mails, active flag and groups. Filter with a SCIM filter such as userName eq \"alice@example.com\".", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "SCIM filter, e.g. userName eq \"alice@example.com\"." + }, + "startIndex": { + "type": "integer", + "description": "1-based index of the first result.", + "minimum": 1 + }, + "count": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/scim/Users", + "queryParams": { + "filter": "$filter", + "startIndex": "$startIndex", + "count": "$count" + } + } + }, + { + "name": "netskope_list_scim_groups", + "description": "List groups provisioned to Netskope over SCIM with their members. Filter with a SCIM filter such as displayName eq \"Finance\".", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "SCIM filter, e.g. displayName eq \"Finance\"." + }, + "startIndex": { + "type": "integer", + "description": "1-based index of the first result.", + "minimum": 1 + }, + "count": { + "type": "integer", + "description": "Results per page.", + "minimum": 1 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/scim/Groups", + "queryParams": { + "filter": "$filter", + "startIndex": "$startIndex", + "count": "$count" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/netskope.live.spec.ts b/packages/backend/src/adapters/intl/netskope.live.spec.ts new file mode 100644 index 000000000..f7d1fc662 --- /dev/null +++ b/packages/backend/src/adapters/intl/netskope.live.spec.ts @@ -0,0 +1,151 @@ +import * as adapter from './netskope.json'; +import { assertNoResponseBodyError } from '../../connectors/engines/response-error.util'; +/** + * Netskope REST API v2 (docs.netskope.com: REST API v2 overview, datasearch and dataexport pages; + * endpoint paths as used by Netskope's own MIT-licensed Python SDK, github.com/netskopeoss/netskope-py-sdk): + * https://.goskope.com/api/v2/..., token in the Netskope-Api-Token header with per-endpoint Read privilege. + * Live part: NETSKOPE_TENANT=... NETSKOPE_API_TOKEN=... RUN_NETSKOPE_LIVE=1 npx jest src/adapters/intl/netskope + * (reads only: a count of the last day's alerts). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { type: string; baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'netskope_'; +const WRITES: string[] = []; +const SWITCHED_OFF: string[] = []; + +describe('netskope adapter: static spec conformance', () => { + + it('sends the token in Netskope-Api-Token to the tenant host', () => { + expect(a.requiredEnvVars).toEqual(['NETSKOPE_TENANT', 'NETSKOPE_API_TOKEN']); + expect(a.connector.baseUrl).toBe('https://{{NETSKOPE_TENANT}}'); + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ headerName: 'Netskope-Api-Token', apiKey: '{{NETSKOPE_API_TOKEN}}' }); + expect(a.envVarMeta?.NETSKOPE_API_TOKEN.secret).toBe(true); + const host = new RegExp(a.envVarMeta?.NETSKOPE_TENANT.pattern ?? ''); + expect(host.test('example.goskope.com')).toBe(true); + expect(host.test('example.eu.goskope.com')).toBe(true); + expect(host.test('https://example.goskope.com')).toBe(false); + for (const t of a.tools) expect(t.endpointMapping.path.startsWith('/api/v2/')).toBe(true); + expect(a.probe).toEqual({ tool: 'netskope_search_alerts', params: { limit: 1 } }); + }); + + it('maps the datasearch endpoints with the documented query parameters', () => { + const q = { query: '$query', fields: '$fields', starttime: '$starttime', endtime: '$endtime', limit: '$limit', offset: '$offset', groupbys: '$groupbys', orderbys: '$orderbys' }; + expect(tool('netskope_search_alerts').endpointMapping).toEqual({ method: 'GET', path: '/api/v2/events/datasearch/alert', queryParams: q }); + expect(tool('netskope_search_events').endpointMapping).toEqual({ method: 'GET', path: '/api/v2/events/datasearch/{event_type}', queryParams: q }); + expect((tool('netskope_search_events').parameters?.properties?.event_type as { enum: string[] }).enum).toEqual(['application', 'page', 'network', 'incident', 'clientstatus', 'epdlp']); + expect(tool('netskope_list_audit_events').endpointMapping).toMatchObject({ path: '/api/v2/events/data/audit', queryParams: { type: '$type', starttime: '$starttime' } }); + }); + + it('maps users, apps, URL lists and Private Access reads', () => { + expect(tool('netskope_get_user_risk_score').endpointMapping).toEqual({ method: 'POST', path: '/api/v2/ubadatasvc/user/uci', bodyMapping: { user: '$user', fromTime: '$from_time' } }); + expect(tool('netskope_get_user_risk_score').annotations?.readOnlyHint).toBe(true); + expect(tool('netskope_lookup_cloud_app').endpointMapping).toMatchObject({ path: '/api/v2/services/cci/app', queryParams: { apps: '$app_name', ccl: '$ccl' } }); + expect(tool('netskope_list_url_lists').endpointMapping.path).toBe('/api/v2/policy/urllist'); + expect(tool('netskope_get_url_list').endpointMapping.path).toBe('/api/v2/policy/urllist/{list_id}'); + expect(tool('netskope_list_publishers').endpointMapping.path).toBe('/api/v2/infrastructure/publishers'); + expect(tool('netskope_list_private_apps').endpointMapping.path).toBe('/api/v2/steering/apps/private'); + expect(tool('netskope_list_scim_users').endpointMapping).toEqual({ method: 'GET', path: '/api/v2/scim/Users', queryParams: { filter: '$filter', startIndex: '$startIndex', count: '$count' } }); + expect(tool('netskope_list_scim_groups').endpointMapping.path).toBe('/api/v2/scim/Groups'); + }); + + it('is read-only: no dataexport iterators (they move a shared cursor) and no writes', () => { + for (const t of a.tools) { + expect(t.endpointMapping.path).not.toMatch(/dataexport/); + expect(['GET', 'POST']).toContain(t.endpointMapping.method); + if (t.endpointMapping.method === 'POST') expect(t.annotations?.readOnlyHint).toBe(true); + } + }); + + it('turns a FAILED datasearch execution into a failed call', () => { + const rules = (a.connector.config as { errorWhen: unknown }).errorWhen; + expect(() => assertNoResponseBodyError({ result: [], status: { execution: 'FAILED', message: 'invalid field foo', status_code: 400 } }, rules)).toThrow(/invalid field foo/); + expect(() => assertNoResponseBodyError({ result: [{ count_total: 3 }], status: { execution: 'SUCCESS', count: 1 } }, rules)).not.toThrow(); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + if (t.endpointMapping.method === 'static') continue; + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated, the risky ones installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...SWITCHED_OFF].sort()); + for (const n of WRITES) expect(`${n}:${tool(n).annotations?.readOnlyHint}`).toBe(`${n}:false`); + for (const t of a.tools) { + if (WRITES.includes(t.name)) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (!['GET', 'static'].includes(t.endpointMapping.method) && !t.endpointMapping.method.startsWith('odata_')) expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + }); + + it('documents every tool in the instructions, marks secrets and has no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + if (a.probe) { + const probe = tool(a.probe.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + expect(probe.parameters?.required ?? []).toEqual([]); + } + }); +}); + +const live = process.env.RUN_NETSKOPE_LIVE === '1' && process.env.NETSKOPE_TENANT && process.env.NETSKOPE_API_TOKEN ? describe : describe.skip; +live('netskope adapter: live (read-only)', () => { + it('counts the alerts of the last day', async () => { + const end = Math.floor(Date.now() / 1000); + const url = `https://${process.env.NETSKOPE_TENANT}/api/v2/events/datasearch/alert?starttime=${end - 86_400}&endtime=${end}&fields=count_total:count(_id)`; + const body = (await (await fetch(url, { headers: { 'Netskope-Api-Token': process.env.NETSKOPE_API_TOKEN as string } })).json()) as { status: { execution: string }; result: unknown[] }; + expect(body.status.execution).toBe('SUCCESS'); + expect(Array.isArray(body.result)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/noon-payments.json b/packages/backend/src/adapters/intl/noon-payments.json new file mode 100644 index 000000000..b72b6738b --- /dev/null +++ b/packages/backend/src/adapters/intl/noon-payments.json @@ -0,0 +1,335 @@ +{ + "slug": "noon-payments", + "name": "noon payments", + "aliases": [ + "noon payment gateway", + "noonpayments", + "نون للمدفوعات" + ], + "description": "noon payments gateway (UAE, Saudi Arabia, Egypt): order status by order id or merchant reference with authorized, captured and refunded totals and transactions, scheduled settlement and commission reports, subscriptions and card tokens. Capture and refund are included, installed switched off.", + "instructions": "## noon payments\nThis connector uses the **noon payments API** (card and wallet payment gateway in the UAE, Saudi Arabia and Egypt): order status by order id or by your order reference, scheduled settlement and commission reports, subscriptions and saved card tokens. Capture and refund are included but installed switched off.\n\n**Setup (5 minutes)**\n1. In the noon payments Merchant Portal open **Account Settings > Applications**. Create an application (or use an existing one) with only the roles it needs: **Payment Integrator** or **Refund Manager** for order lookups, **Financial Auditor** for reports, **Token Manager** for card tokens.\n2. Build the key: `BusinessIdentifier.ApplicationIdentifier:ApplicationKey` (business identifier from the setup e-mail, a dot, the application identifier, a colon, the application key), then encode it in Base64, e.g. `printf '%s' 'mybusiness.myapp:KEY' | base64`. Paste the Base64 text into `NOON_PAYMENTS_AUTH_KEY`. The connector sends `Authorization: Key `.\n3. `NOON_PAYMENTS_API_URL`: `https://api.noonpayments.com` (global, live) or `https://api-test.noonpayments.com` (test); regional endpoints are `https://api.sa.noonpayments.com` and `https://api.eg.noonpayments.com` (test: `api-test.sa`, `api-test.eg`). Keys differ per environment.\n\n**Identifiers**: `order_id` is the numeric order id noon returned at INITIATE (stored with the shop order, often as the payment transaction id). `order_reference` is your own reference sent at INITIATE (the shop order number).\n\n**Workflow**\n- Order status: `noon_payments_get_order` or `noon_payments_get_order_by_reference`. Read `result.order.status` with the totals (`totalAuthorizedAmount`, `totalCapturedAmount`, `totalRefundedAmount`, `totalReversedAmount`, `totalRemainingAmount`) and `result.transactions` (each with type, status, amount and id). `resultCode` 0 only means the request worked, not that the payment succeeded; judge by the order status and `nextActions`.\n- Reconciliation: `noon_payments_list_scheduled_reports` with `report_type` ReconciliationSettlement and a day, then download the file from `ReportUrl` (the link expires). Do not loop over orders with get-order for reconciliation; noon throttles that.\n- Subscriptions and saved cards: `noon_payments_get_subscription`, `noon_payments_get_card_token`.\n\n**Writes switched off**: `noon_payments_capture_order` (captures an authorized order) and `noon_payments_refund_order` (refunds a capture; pass the CAPTURE transaction id from the order). Both move money; an admin turns them on deliberately. Before each call show the user the order, the amount and the currency and wait for a yes, and pass a `transaction_reference` so a retry is not executed twice.\n\n**Errors**: failures come back as HTTP 4xx or 5xx with `resultCode`, `message` and `classDescription` (Rejected_Unauthenticated: wrong key or environment; Rejected_AccessDenied: the application lacks the role; Invalid_BadRequest: a parameter). 429 means throttling; wait before retrying and do not repeat the same failing request, noon may lock the order or ban the address.\n\n**Limitations**: there is no endpoint to list or search orders; look them up by id or reference, or use the daily reports. Initiating payments, authorize, reverse, cancel and fraud review are not part of this connector.", + "region": "intl", + "category": "payments", + "icon": "noon-payments", + "docsUrl": "https://docs.noonpayments.com/payment-api/reference/get-order", + "requiredEnvVars": [ + "NOON_PAYMENTS_API_URL", + "NOON_PAYMENTS_AUTH_KEY" + ], + "envVarMeta": { + "NOON_PAYMENTS_API_URL": { + "label": "API endpoint", + "kind": "address", + "help": "https://api.noonpayments.com (live) or https://api-test.noonpayments.com (test); regional: https://api.sa.noonpayments.com, https://api.eg.noonpayments.com and their api-test variants. No path.", + "example": "https://api.noonpayments.com", + "pattern": "^https://api(-test)?(\\.(sa|eg))?\\.noonpayments\\.com/?$", + "patternMessage": "Only the endpoint, e.g. https://api.noonpayments.com or https://api-test.sa.noonpayments.com, without /payment/v1." + }, + "NOON_PAYMENTS_AUTH_KEY": { + "label": "Authorization key (Base64)", + "kind": "credential", + "secret": true, + "help": "Base64 of BusinessIdentifier.ApplicationIdentifier:ApplicationKey from Merchant Portal, Account Settings, Applications. Paste only the Base64 text, without the word Key.", + "link": "https://docs.noonpayments.com/", + "pattern": "^[A-Za-z0-9+/]{16,}={0,2}$", + "patternMessage": "Paste only the Base64 text (no \"Key \" in front, no spaces)." + } + }, + "verifyHints": { + "401": "noon payments refused the key. Rebuild it as Base64 of BusinessIdentifier.ApplicationIdentifier:ApplicationKey (dot, then colon) and check it belongs to the chosen environment (test or live).", + "403": "The key works but the application lacks the role for this call (Payment Integrator for orders, Financial Auditor for reports, Token Manager for card tokens).", + "404": "Not found. Check NOON_PAYMENTS_API_URL (no /payment/v1) and that the order exists in this environment.", + "429": "noon payments throttled the requests. Wait before retrying and avoid polling the same order." + }, + "connector": { + "name": "noon payments API", + "type": "REST", + "baseUrl": "{{NOON_PAYMENTS_API_URL}}", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "Key {{NOON_PAYMENTS_AUTH_KEY}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "noon_payments_get_order", + "description": "Get a noon payments order by its order id: order status (INITIATED, AUTHORIZED, CAPTURED, PARTIALLY_CAPTURED, REFUNDED, REVERSED, CANCELLED, FAILED, MARKED_FOR_REVIEW...), amount and currency, authorized, captured, refunded, reversed and remaining totals, merchant reference, the transactions (AUTHORIZATION, CAPTURE, REFUND... with status, amount, time and id), the masked payment details and nextActions. Not for reconciliation runs: noon throttles repeated polling.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "noon payments order id: the numeric order.id returned by INITIATE and stored with the shop order, passed as text so long ids keep every digit, e.g. 123456789012.", + "pattern": "^[0-9]{6,20}$" + }, + "verbose": { + "type": "boolean", + "description": "true adds the verbose details (heavier and slower); leave it out unless the extra fields are needed." + } + }, + "required": [ + "order_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/payment/v1/order/{order_id}", + "queryParams": { + "verbose": "$verbose" + } + } + }, + { + "name": "noon_payments_get_order_by_reference", + "description": "Get a noon payments order by your own order reference (order.reference sent at INITIATE, e.g. the shop order number) with the same details as noon_payments_get_order. If several orders share the reference (retries), the newest is returned; use noon_payments_get_order with the id for an exact match.", + "parameters": { + "type": "object", + "properties": { + "order_reference": { + "type": "string", + "description": "Your merchant order reference, e.g. NPORDTEST0001." + }, + "verbose": { + "type": "boolean", + "description": "true adds the verbose details (heavier and slower); leave it out unless the extra fields are needed." + } + }, + "required": [ + "order_reference" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/payment/v1/order/getbyreference/{order_reference}", + "encodePathParams": true, + "queryParams": { + "verbose": "$verbose" + } + } + }, + { + "name": "noon_payments_list_scheduled_reports", + "description": "List the scheduled reports noon generated for a day or month, each with ReportName, ReportType and a time-limited ReportUrl to download the ZIP (Excel or tab-separated). ReconciliationSettlement is daily (captures and refunds to reconcile with the bank settlement); ActiveSubscriptions, PaymentTokens, MerchantCommissions and MerchantCommissionDetails are monthly. Needs the Financial Auditor role on the application.", + "parameters": { + "type": "object", + "properties": { + "year": { + "type": "integer", + "description": "Four-digit year, e.g. 2026." + }, + "month": { + "type": "integer", + "description": "Month 1 to 12." + }, + "day": { + "type": "integer", + "description": "Day of the month, for daily reports." + }, + "report_type": { + "type": "string", + "description": "Report type.", + "enum": [ + "ReconciliationSettlement", + "ActiveSubscriptions", + "PaymentTokens", + "MerchantCommissions", + "MerchantCommissionDetails" + ] + } + }, + "required": [ + "year", + "month", + "report_type" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/report/v1/data/GetScheduleReports", + "queryParams": { + "year": "$year", + "month": "$month", + "day": "$day", + "reportType": "$report_type" + } + } + }, + { + "name": "noon_payments_get_subscription", + "description": "Get a recurring subscription by its identifier: creation date, status, validity, amount and schedule. Reads only (apiOperation RETRIEVE_SUBSCRIPTION).", + "parameters": { + "type": "object", + "properties": { + "subscription_id": { + "type": "string", + "description": "Subscription identifier (UUID) returned at INITIATE, e.g. 7852f7e2-cdae-424d-9d41-48179ff75444." + } + }, + "required": [ + "subscription_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/payment/v1/order", + "bodyMapping": { + "apiOperation": "RETRIEVE_SUBSCRIPTION", + "subscription": { + "identifier": "$subscription_id" + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_payments_get_card_token", + "description": "Get the masked card behind a saved card token (brand, masked number, expiry, holder name, status). Needs the Token Manager role. Not for bulk runs: noon throttles repeated calls.", + "parameters": { + "type": "object", + "properties": { + "token_identifier": { + "type": "string", + "description": "Card token identifier from a tokenized payment." + } + }, + "required": [ + "token_identifier" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/payment/v1/token/{token_identifier}" + } + }, + { + "name": "noon_payments_capture_order", + "description": "Capture an AUTHORIZED order in full or in part so the funds move to the merchant account. This moves money: show the order, amount and currency to the user and wait for a yes. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "noon payments order id: the numeric order.id returned by INITIATE and stored with the shop order, passed as text so long ids keep every digit, e.g. 123456789012.", + "pattern": "^[0-9]{6,20}$" + }, + "amount": { + "type": "number", + "description": "Amount to capture." + }, + "currency": { + "type": "string", + "description": "Currency of the order, e.g. AED, SAR, EGP." + }, + "final_capture": { + "type": "boolean", + "description": "true closes the order for further captures." + }, + "transaction_reference": { + "type": "string", + "description": "Your unique reference for this capture, e.g. the shipment number." + }, + "description": { + "type": "string", + "description": "Comment for the capture." + } + }, + "required": [ + "order_id", + "amount", + "currency" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/payment/v1/order", + "bodyMapping": { + "apiOperation": "CAPTURE", + "order": { + "id": "$order_id" + }, + "transaction": { + "amount": "$amount", + "currency": "$currency", + "finalCapture": "$final_capture", + "transactionReference": "$transaction_reference", + "description": "$description" + } + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "noon_payments_refund_order", + "description": "Refund a captured order in full or in part back to the customer card. It cannot be undone: show the order, amount and currency to the user and wait for a yes. Needs the Refund Manager role. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "noon payments order id: the numeric order.id returned by INITIATE and stored with the shop order, passed as text so long ids keep every digit, e.g. 123456789012.", + "pattern": "^[0-9]{6,20}$" + }, + "amount": { + "type": "number", + "description": "Amount to refund." + }, + "currency": { + "type": "string", + "description": "Currency of the order, e.g. AED, SAR, EGP." + }, + "capture_transaction_id": { + "type": "string", + "description": "Identifier of the CAPTURE transaction to refund (from noon_payments_get_order)." + }, + "transaction_reference": { + "type": "string", + "description": "Your unique reference for this refund, e.g. the credit note number." + }, + "description": { + "type": "string", + "description": "Comment for the refund." + } + }, + "required": [ + "order_id", + "amount", + "currency" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/payment/v1/order", + "bodyMapping": { + "apiOperation": "REFUND", + "order": { + "id": "$order_id" + }, + "transaction": { + "amount": "$amount", + "currency": "$currency", + "targetTransactionIdentifier": "$capture_transaction_id", + "transactionReference": "$transaction_reference", + "description": "$description" + } + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/noon-payments.live.spec.ts b/packages/backend/src/adapters/intl/noon-payments.live.spec.ts new file mode 100644 index 000000000..e01b6a66c --- /dev/null +++ b/packages/backend/src/adapters/intl/noon-payments.live.spec.ts @@ -0,0 +1,172 @@ +import * as adapter from './noon-payments.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * noon payments API (docs.noonpayments.com, API reference pages Payment / Report / Subscription / Token): + * {endpoint}/payment/v1/order and {endpoint}/report/v1, header Authorization: Key Base64(Business.Application:Key). + * There is no read without an order id or reference, so the adapter declares no install probe. + * Live part: NOON_PAYMENTS_API_URL=https://api-test.noonpayments.com NOON_PAYMENTS_AUTH_KEY=... NOON_PAYMENTS_ORDER_REFERENCE=... + * RUN_NOON_PAYMENTS_LIVE=1 npx jest src/adapters/intl/noon-payments (reads only: one order by reference). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'noon_payments_'; +const WRITES: string[] = ["noon_payments_capture_order", "noon_payments_refund_order"]; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('noon-payments adapter: static spec conformance', () => { + it('sends the Base64 key with the Key scheme to the configured endpoint', async () => { + expect(a.requiredEnvVars).toEqual(['NOON_PAYMENTS_API_URL', 'NOON_PAYMENTS_AUTH_KEY']); + expect(a.connector.baseUrl).toBe('{{NOON_PAYMENTS_API_URL}}'); + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ headerName: 'Authorization', apiKey: 'Key {{NOON_PAYMENTS_AUTH_KEY}}' }); + expect(a.envVarMeta?.NOON_PAYMENTS_AUTH_KEY.secret).toBe(true); + const re = new RegExp(a.envVarMeta?.NOON_PAYMENTS_API_URL.pattern as string); + for (const ok of ['https://api.noonpayments.com', 'https://api-test.noonpayments.com', 'https://api.sa.noonpayments.com', 'https://api-test.eg.noonpayments.com']) expect(`${ok}:${re.test(ok)}`).toBe(`${ok}:true`); + expect(re.test('https://api.noonpayments.com/payment/v1')).toBe(false); + const req = await request('noon_payments_get_order', { order_id: '123456789012' }, { NOON_PAYMENTS_API_URL: 'https://api-test.noonpayments.com', NOON_PAYMENTS_AUTH_KEY: 'YnVzLmFwcDprZXk=' }); + expect(req.url).toBe('https://api-test.noonpayments.com/payment/v1/order/123456789012'); + expect(req.headers.Authorization).toBe('Key YnVzLmFwcDprZXk='); + }); + + it('maps the documented order, report, subscription and token calls', async () => { + const env = { NOON_PAYMENTS_API_URL: 'https://api.noonpayments.com', NOON_PAYMENTS_AUTH_KEY: 'k' }; + const byRef = await request('noon_payments_get_order_by_reference', { order_reference: 'ORD#1', verbose: true }, env); + expect(byRef.url).toBe('https://api.noonpayments.com/payment/v1/order/getbyreference/ORD%231'); + expect(byRef.params).toEqual({ verbose: true }); + const rep = await request('noon_payments_list_scheduled_reports', { year: 2026, month: 10, day: 20, report_type: 'ReconciliationSettlement' }, env); + expect(rep.url).toBe('https://api.noonpayments.com/report/v1/data/GetScheduleReports'); + expect(rep.params).toEqual({ year: 2026, month: 10, day: 20, reportType: 'ReconciliationSettlement' }); + const sub = await request('noon_payments_get_subscription', { subscription_id: 's-1' }, env); + expect(sub.data).toEqual({ apiOperation: 'RETRIEVE_SUBSCRIPTION', subscription: { identifier: 's-1' } }); + expect(tool('noon_payments_get_card_token').endpointMapping.path).toBe('/payment/v1/token/{token_identifier}'); + expect(a.probe).toBeUndefined(); + }); + + it('keeps capture and refund switched off with the documented bodies', async () => { + const env = { NOON_PAYMENTS_API_URL: 'https://api.noonpayments.com', NOON_PAYMENTS_AUTH_KEY: 'k' }; + const cap = await request('noon_payments_capture_order', { order_id: '123456789012', amount: 10, currency: 'AED', final_capture: true }, env); + expect(cap.url).toBe('https://api.noonpayments.com/payment/v1/order'); + expect(cap.data).toEqual({ apiOperation: 'CAPTURE', order: { id: '123456789012' }, transaction: { amount: 10, currency: 'AED', finalCapture: true } }); + const ref = await request('noon_payments_refund_order', { order_id: '123456789012', amount: 5, currency: 'AED', capture_transaction_id: '959' }, env); + expect(ref.data).toEqual({ apiOperation: 'REFUND', order: { id: '123456789012' }, transaction: { amount: 5, currency: 'AED', targetTransactionIdentifier: '959' } }); + expect(tool('noon_payments_refund_order').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + expect(a.probe).toBeUndefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_NOON_PAYMENTS_LIVE === '1' && process.env.NOON_PAYMENTS_API_URL && process.env.NOON_PAYMENTS_AUTH_KEY && process.env.NOON_PAYMENTS_ORDER_REFERENCE ? describe : describe.skip; +live('noon-payments adapter: live (read-only)', () => { + const base = (process.env.NOON_PAYMENTS_API_URL ?? '').replace(/\/$/, ''); + const headers = { Authorization: `Key ${process.env.NOON_PAYMENTS_AUTH_KEY}`, Accept: 'application/json' }; + it('reads one order by its merchant reference', async () => { + const res = await fetch(`${base}/payment/v1/order/getbyreference/${encodeURIComponent(process.env.NOON_PAYMENTS_ORDER_REFERENCE as string)}`, { headers }); + expect(res.status).toBe(200); + const body = (await res.json()) as { resultCode: number; result: { order: { status: string } } }; + expect(body.resultCode).toBe(0); + expect(typeof body.result.order.status).toBe('string'); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/sap-c4c.json b/packages/backend/src/adapters/intl/sap-c4c.json new file mode 100644 index 000000000..a0432a147 --- /dev/null +++ b/packages/backend/src/adapters/intl/sap-c4c.json @@ -0,0 +1,544 @@ +{ + "slug": "sap-c4c", + "name": "SAP Sales Cloud and Service Cloud (C4C)", + "aliases": [ + "SAP C4C", + "SAP Cloud for Customer", + "SAP Sales Cloud", + "SAP Service Cloud", + "SAP C4C OData" + ], + "description": "Read SAP Sales Cloud and Service Cloud (Cloud for Customer, C4C) through its OData API: accounts, contacts, leads, opportunities, sales quotes, service requests, products and employees, plus generic OData tools for every other entity set. Basic authentication, read-only.", + "region": "intl", + "category": "crm", + "icon": "sap-c4c", + "docsUrl": "https://help.sap.com/doc/d0f9ba822c08405da7d88174b304df84/CLOUD/en-US/index.html", + "instructions": "## SAP Sales Cloud and Service Cloud (C4C)\nThis connector reads **SAP Sales Cloud and SAP Service Cloud** (SAP Cloud for Customer, C4C) through its standard OData V2 API, `https://.crm.ondemand.com/sap/c4c/odata/v1/c4codataapi`: accounts, contacts, leads, opportunities, sales quotes, service requests, products, employees and every other entity set of the service. It is read-only, and SAP checks the user's authorizations on every call.\n\n**Start with `sap_c4c_guide`** (free), then a ready tool: `sap_c4c_list_accounts`, `sap_c4c_list_contacts`, `sap_c4c_list_leads`, `sap_c4c_list_opportunities`, `sap_c4c_list_sales_quotes`, `sap_c4c_list_service_requests`, `sap_c4c_list_products`, `sap_c4c_list_employees`. For any other set use the built-in OData tools: `sap_c4c_list_services`, `sap_c4c_describe_service` (all entity sets; large, cached for a day), `sap_c4c_describe_entity` (fields with labels), `sap_c4c_query` and `sap_c4c_get_entity` (one record by ObjectID).\n\n**Setup**\n1. Recommended: a technical integration user. In C4C go to **Administrator > Integration > Communication Systems**, create a communication system, then under **Communication Arrangements** create one with the scenario **OData Services for Business Objects**, authentication **User ID and Password**, and select the OData services (at least c4codataapi). The arrangement shows the user id; set its password there.\n2. Alternatively a business user with Basic authentication whose business roles grant read access to the work centers the AI should see.\n3. Fill in `SAP_C4C_HOST`, the tenant host such as `my123456.crm.ondemand.com` (without https:// or a path), `SAP_C4C_USER` and `SAP_C4C_PASSWORD`.\n4. Install and run `sap_c4c_list_accounts` with top 1 to check the access.\n\n**Queries**: filters support eq, ne, ge, le, gt, lt, and, startswith and endswith, and or only on one field; dates are `datetimeoffset'2026-01-01T00:00:00Z'`; `EntityLastChangedOn` finds changes since a moment. `search` is SAP's free-text search. Records are keyed by `ObjectID`; business ids (AccountID, ID) are separate fields. Ask for the fields you need with `select`.\n\n**Paging and limits**: at most 1000 rows per page (`top`, then `skip`); the lists return the total count. `expand` is slow on large sets.\n\n**Errors**: 401 means a wrong user or password, or a locked user. 403 means the communication arrangement or the user's business roles do not cover this business object. A 404 on every call usually means a wrong `SAP_C4C_HOST`. A 400 names a field or filter the API does not accept; check it with `sap_c4c_describe_entity`.", + "prerequisites": "An SAP Sales Cloud or Service Cloud (C4C) tenant and a user with Basic authentication: ideally an integration user from a communication arrangement with the scenario OData Services for Business Objects.", + "requiredEnvVars": [ + "SAP_C4C_HOST", + "SAP_C4C_USER", + "SAP_C4C_PASSWORD" + ], + "envVarMeta": { + "SAP_C4C_HOST": { + "label": "Tenant host", + "kind": "address", + "help": "The host of your C4C tenant, as in the browser address bar, e.g. my123456.crm.ondemand.com, without https:// or a path.", + "example": "my123456.crm.ondemand.com", + "pattern": "^my[0-9]+(-[a-z0-9]+)?\\.crm\\.ondemand\\.com$", + "patternMessage": "Only the tenant host, e.g. my123456.crm.ondemand.com, without https:// or a path." + }, + "SAP_C4C_USER": { + "label": "User", + "kind": "credential", + "help": "The user id of the integration user (shown in the communication arrangement) or of a business user with Basic authentication.", + "example": "_ODATA_READ" + }, + "SAP_C4C_PASSWORD": { + "label": "Password", + "kind": "credential", + "secret": true, + "help": "The password of that user, set in the communication arrangement for an integration user." + } + }, + "verifyHints": { + "401": "C4C refused the user or password. Check both, and that the user is not locked and its password not expired (set it again in the communication arrangement).", + "403": "The user may not read this business object. Add the OData service to the communication arrangement, or a business role with access to it.", + "not_found": "No C4C OData API at this address. SAP_C4C_HOST is only the tenant host, e.g. my123456.crm.ondemand.com." + }, + "probe": { + "tool": "sap_c4c_list_accounts", + "params": { + "top": 1, + "select": "ObjectID" + } + }, + "connector": { + "name": "SAP Sales Cloud and Service Cloud (C4C)", + "type": "ODATA", + "baseUrl": "https://{{SAP_C4C_HOST}}/sap/c4c/odata/v1/c4codataapi", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{SAP_C4C_USER}}", + "password": "{{SAP_C4C_PASSWORD}}" + }, + "config": { + "odata": { + "version": "v2", + "toolPrefix": "sap_c4c", + "maxRows": 1000 + } + } + }, + "tools": [ + { + "name": "sap_c4c_guide", + "description": "How to read SAP Sales Cloud and Service Cloud (C4C) through its OData API: the workflow, filter syntax, keys and ids, paging and known limitations. Static text, costs no API call.", + "parameters": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "Chapter to read. Omit for the overview.", + "enum": [ + "filters", + "keys", + "pitfalls" + ] + } + } + }, + "endpointMapping": { + "method": "static", + "path": "", + "staticResponse": "SAP Sales Cloud and Service Cloud (Cloud for Customer, C4C) OData API: how to work.\n\n1. Start with a ready tool: sap_c4c_list_accounts (corporate accounts), sap_c4c_list_contacts, sap_c4c_list_leads, sap_c4c_list_opportunities, sap_c4c_list_sales_quotes, sap_c4c_list_service_requests (tickets), sap_c4c_list_products, sap_c4c_list_employees. Each takes filter, search, select, orderby, expand, top and skip and returns the total count.\n2. For anything else (appointments, tasks, visits, phone calls, sales orders, contracts, installed bases, custom fields), use the built-ins: sap_c4c_describe_service lists every entity set of c4codataapi (the model is large; the first call takes a while and is cached for a day), sap_c4c_describe_entity shows the fields of one set with their labels, sap_c4c_query reads rows, sap_c4c_get_entity reads one record by ObjectID.\n3. Always pass select with the fields you need: a C4C record carries hundreds of fields and code texts.\n\nTopics: filters, keys, pitfalls.", + "staticResponses": { + "filters": "Filtering in the C4C OData API (OData V2 with SAP restrictions)\n\n- Comparison: eq, ne, gt, ge, lt, le; combine with and. or works only between conditions on the same field (AccountID eq '1001' or AccountID eq '1002'); for other combinations run two queries.\n- Strings in single quotes: RoleCode eq 'CRM000'. A * inside an eq value is a wildcard: Name eq '*Porter*'. startswith(Name,'Porter') and endswith(Name,'LLC') (field first) are supported; substringof, tolower and other functions are not.\n- Dates and times: CreationDateTime ge datetimeoffset'2026-01-01T00:00:00Z'. EntityLastChangedOn ge datetimeoffset'...' finds records changed since a moment (delta reads).\n- Fields of an expanded navigation cannot be filtered on. Filter the child set and expand the parent instead, e.g. OpportunityItemCollection with filter ProductID eq 'P300104' and expand Opportunity.\n- search is SAP's free-text search on search-enabled fields: whole words, * as wildcard, several words ANDed; it can be combined with filter.\n- Code fields (StatusCode, RoleCode, PriorityCode...) come with a ...Text field holding the label.", + "keys": "Keys and ids\n\n- Every record has ObjectID, a 32-character hex key (e.g. 00163E03A0701ED28A8644CF8D83AE76). sap_c4c_get_entity and navigation use it.\n- Business ids are separate fields: AccountID, ContactID, ID (lead, opportunity, service request, quote, product), EmployeeID. Filter on them to find the ObjectID.\n- Child records point to their parent with ParentObjectID, e.g. OpportunityItemCollection rows carry the ObjectID of the opportunity.\n- Accounts: RoleCode CRM000 is a customer, BUP002 a prospect. Individual (B2C) customers are in IndividualCustomerCollection, not in CorporateAccountCollection.", + "pitfalls": "Pitfalls\n\n- The service returns at most 1000 rows per page; use skip for the next page and narrow with filter. Without top the server pages by 1000 by itself.\n- expand is slow on large sets; prefer a second query on the child set.\n- 401 means a wrong user or password, or a locked or expired user. 403 means the user's business role or the communication arrangement does not grant this business object.\n- Fields from custom extensions appear with their technical names (often ending in _KUT or _SDK); sap_c4c_describe_entity shows their labels.\n- Personal data (private addresses, birth dates) should only be read when the user asks for it." + } + } + }, + { + "name": "sap_c4c_list_accounts", + "description": "Corporate accounts (customers and prospects) from CorporateAccountCollection: ObjectID, AccountID, name, role (CRM000 customer, BUP002 prospect), status, ABC classification, industry, owner, main address, phone and e-mail. Filter or search to find an account and its ObjectID.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. RoleCode eq 'CRM000' and CountryCode eq 'DE'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,AccountID,Name,RoleCodeText,City,CountryCode,OwnerID. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/CorporateAccountCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_contacts", + "description": "Contact persons from ContactCollection: ObjectID, ContactID, first and last name, job title and function, the account they belong to, e-mail and phones, status.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. AccountID eq '1001234'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,ContactID,FirstName,LastName,AccountID,Email,Phone. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/ContactCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_leads", + "description": "Leads from LeadCollection: ObjectID, ID, name, life cycle status, qualification level, source, owner, account or company and contact, start and end date, creation and change times.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. LifeCycleStatusCode eq '2' and OwnerPartyID eq '8000000123'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,ID,Name,LifeCycleStatusCode,QualificationLevelCode,OwnerPartyID,AccountPartyID,Company. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/LeadCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_opportunities", + "description": "Opportunities from OpportunityCollection: ObjectID, ID, name, account, owner, sales phase, status, probability, expected value and currency, expected close date. Use it for the sales pipeline.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. LifeCycleStatusCode eq '2' and ExpectedRevenueEndDate le datetimeoffset'2026-12-31T00:00:00Z'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,ID,Name,ProspectPartyID,SalesCyclePhaseCode,ProbabilityPercent,ExpectedRevenueAmount,ExpectedRevenueAmountCurrencyCode,ExpectedRevenueEndDate. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/OpportunityCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_sales_quotes", + "description": "Sales quotes from SalesQuoteCollection: ObjectID, ID, name, account, status, net and gross amount with currency, valid-to date, owner and the opportunity it belongs to.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. BuyerPartyID eq '1001234'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,ID,Name,BuyerPartyID,LifeCycleStatusCodeText,NetAmount,NetAmountCurrencyCode. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/SalesQuoteCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_service_requests", + "description": "Service requests (tickets) from ServiceRequestCollection: ObjectID, ID, subject, customer (BuyerPartyID) and contact, status, priority, processor, service category, due dates and SLA times. Use it for the service backlog.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. ServiceRequestLifeCycleStatusCode eq '1' and ServicePriorityCode eq '1'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,ID,Name,BuyerPartyID,ServiceRequestUserLifeCycleStatusCode,ServicePriorityCode,ProcessorPartyID,ResolutionDueDateTime. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/ServiceRequestCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_products", + "description": "Products from ProductCollection: ObjectID, ProductID, description, status, product category, base unit of measure, usage and division.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. ProductCategoryID eq 'BIKES'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,ProductID,Description,Status,ProductCategoryID,BaseUOM. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/ProductCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + }, + { + "name": "sap_c4c_list_employees", + "description": "Employees from EmployeeCollection: ObjectID, EmployeeID, name, job, e-mail, phone, organizational unit and user. Use it to resolve owner and processor ids to names.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "OData $filter. Strings in single quotes, dates as datetimeoffset, e.g. LastName eq 'Smith'. Only eq, ne, ge, le, gt, lt, and, startswith and endswith; or only on the same field." + }, + "search": { + "type": "string", + "description": "Free-text search over the search-enabled fields, e.g. 'Porter' or Port* (exact words, * as wildcard, several words are ANDed)." + }, + "select": { + "type": "string", + "description": "Comma-separated fields to return, e.g. ObjectID,EmployeeID,FirstName,LastName,Email,UserID. Keeps answers small; names are case-sensitive." + }, + "orderby": { + "type": "string", + "description": "OData $orderby, e.g. EntityLastChangedOn desc." + }, + "expand": { + "type": "string", + "description": "Navigation properties to include, comma-separated (describe_entity lists them). Slows the call down." + }, + "top": { + "type": "integer", + "description": "Rows to return (default 100, at most 1000).", + "default": 100, + "minimum": 1, + "maximum": 1000 + }, + "skip": { + "type": "integer", + "description": "Rows to skip, for the next page.", + "minimum": 0 + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/EmployeeCollection", + "queryParams": { + "$filter": "$filter", + "$search": "$search", + "$select": "$select", + "$orderby": "$orderby", + "$expand": "$expand", + "$top": "$top", + "$skip": "$skip", + "$inlinecount": "allpages" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts b/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts new file mode 100644 index 000000000..b6aea4778 --- /dev/null +++ b/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts @@ -0,0 +1,168 @@ +import * as adapter from './sap-c4c.json'; +import { ODataEngine } from '../../connectors/engines/odata.engine'; +import { buildODataBuiltinTools, odataToolPrefix } from '../../connectors/odata/odata-builtins'; +import { renderStaticResponse } from '../../connectors/static-response.util'; +/** + * SAP Sales Cloud and Service Cloud (Cloud for Customer, C4C) OData API (help.sap.com OData API reference + * d0f9ba822c08405da7d88174b304df84 and SAP's C4C OData developer guide on GitHub): OData V2 service + * https://.crm.ondemand.com/sap/c4c/odata/v1/c4codataapi with Basic authentication. + * Live part: SAP_C4C_HOST=... SAP_C4C_USER=... SAP_C4C_PASSWORD=... RUN_SAP_C4C_LIVE=1 npx jest src/adapters/intl/sap-c4c + * (reads only: one account and the service document). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { type: string; baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'sap_c4c_'; +const WRITES: string[] = []; +const SWITCHED_OFF: string[] = []; + +describe('sap-c4c adapter: static spec conformance', () => { + + it('is an OData V2 connector on the c4codataapi service with Basic authentication', () => { + expect(a.requiredEnvVars).toEqual(['SAP_C4C_HOST', 'SAP_C4C_USER', 'SAP_C4C_PASSWORD']); + expect(a.connector.type).toBe('ODATA'); + expect(a.connector.baseUrl).toBe('https://{{SAP_C4C_HOST}}/sap/c4c/odata/v1/c4codataapi'); + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig).toEqual({ username: '{{SAP_C4C_USER}}', password: '{{SAP_C4C_PASSWORD}}' }); + expect(a.connector.config?.odata).toEqual({ version: 'v2', toolPrefix: 'sap_c4c', maxRows: 1000 }); + expect(a.envVarMeta?.SAP_C4C_PASSWORD.secret).toBe(true); + const host = new RegExp(a.envVarMeta?.SAP_C4C_HOST.pattern ?? ''); + expect(host.test('my123456.crm.ondemand.com')).toBe(true); + expect(host.test('https://my123456.crm.ondemand.com')).toBe(false); + expect(a.probe).toEqual({ tool: 'sap_c4c_list_accounts', params: { top: 1, select: 'ObjectID' } }); + }); + + it('gets the five OData built-ins under its own prefix, without clashing, and names them in the instructions', () => { + const prefix = odataToolPrefix({ toolPrefix: a.connector.config?.odata.toolPrefix as string, name: a.slug }); + expect(prefix).toBe('sap_c4c'); + const builtins = buildODataBuiltinTools({ prefix, displayName: 'SAP C4C', sap: false }).map((t) => t.name); + expect(builtins).toEqual(expect.arrayContaining(['sap_c4c_query', 'sap_c4c_describe_entity', 'sap_c4c_get_entity'])); + const own = a.tools.map((t) => t.name); + expect(builtins.filter((n) => own.includes(n))).toEqual([]); + for (const n of builtins) expect(`${n}:${a.instructions.includes(n)}`).toBe(`${n}:true`); + }); + + it('maps the ready tools to documented entity sets with the OData query options', () => { + const sets: Record = { + sap_c4c_list_accounts: '/CorporateAccountCollection', sap_c4c_list_contacts: '/ContactCollection', sap_c4c_list_leads: '/LeadCollection', + sap_c4c_list_opportunities: '/OpportunityCollection', sap_c4c_list_sales_quotes: '/SalesQuoteCollection', sap_c4c_list_service_requests: '/ServiceRequestCollection', + sap_c4c_list_products: '/ProductCollection', sap_c4c_list_employees: '/EmployeeCollection', + }; + for (const [n, path] of Object.entries(sets)) { + expect(tool(n).endpointMapping).toEqual({ + method: 'GET', + path, + queryParams: { $filter: '$filter', $search: '$search', $select: '$select', $orderby: '$orderby', $expand: '$expand', $top: '$top', $skip: '$skip', $inlinecount: 'allpages' }, + }); + } + }); + + it('answers every guide topic', () => { + const guide = tool('sap_c4c_guide'); + for (const topic of (guide.parameters?.properties?.topic as { enum: string[] }).enum) { + expect(renderStaticResponse(guide.endpointMapping as never, { topic })).not.toMatch(/^There is no topic/); + } + }); + + it('sends a list as OData V2 JSON and flattens the rows', async () => { + const executeWithMeta = jest.fn().mockResolvedValue({ body: { d: { results: [{ ObjectID: '00163E03A0701ED28A8644CF8D83AE76', AccountID: '1001' }], __count: '1' } }, headers: {} }); + const engine = new ODataEngine({ executeWithMeta } as never); + const out = (await engine.execute( + { baseUrl: 'https://my123456.crm.ondemand.com/sap/c4c/odata/v1/c4codataapi', authType: 'BASIC_AUTH' }, + tool('sap_c4c_list_accounts').endpointMapping as never, + { top: 5, filter: "RoleCode eq 'CRM000'", select: 'ObjectID,AccountID' }, + a.connector.config?.odata, + )) as { rows: unknown[] }; + expect(out.rows).toEqual([{ ObjectID: '00163E03A0701ED28A8644CF8D83AE76', AccountID: '1001' }]); + const [, mapping, params] = executeWithMeta.mock.calls[0]; + expect(mapping.path).toBe('/CorporateAccountCollection'); + expect(mapping.queryParams).toMatchObject({ $format: 'json', $filter: '$filter', $top: '$top' }); + expect(params).toMatchObject({ top: 5, filter: "RoleCode eq 'CRM000'" }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + if (t.endpointMapping.method === 'static') continue; + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated, the risky ones installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...SWITCHED_OFF].sort()); + for (const n of WRITES) expect(`${n}:${tool(n).annotations?.readOnlyHint}`).toBe(`${n}:false`); + for (const t of a.tools) { + if (WRITES.includes(t.name)) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (!['GET', 'static'].includes(t.endpointMapping.method) && !t.endpointMapping.method.startsWith('odata_')) expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + }); + + it('documents every tool in the instructions, marks secrets and has no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + if (a.probe) { + const probe = tool(a.probe.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + expect(probe.parameters?.required ?? []).toEqual([]); + } + }); +}); + +const live = process.env.RUN_SAP_C4C_LIVE === '1' && process.env.SAP_C4C_HOST && process.env.SAP_C4C_USER && process.env.SAP_C4C_PASSWORD ? describe : describe.skip; +live('sap-c4c adapter: live (read-only)', () => { + const base = `https://${process.env.SAP_C4C_HOST}/sap/c4c/odata/v1/c4codataapi`; + const headers = { Authorization: `Basic ${Buffer.from(`${process.env.SAP_C4C_USER}:${process.env.SAP_C4C_PASSWORD}`).toString('base64')}`, Accept: 'application/json' }; + it('reads the service document and one account', async () => { + const doc = (await (await fetch(`${base}/?$format=json`, { headers })).json()) as { d: { EntitySets: string[] } }; + expect(doc.d.EntitySets).toEqual(expect.arrayContaining(['CorporateAccountCollection', 'OpportunityCollection'])); + const acc = (await (await fetch(`${base}/CorporateAccountCollection?$top=1&$select=ObjectID&$format=json`, { headers })).json()) as { d: { results: unknown[] } }; + expect(Array.isArray(acc.d.results)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/tabby.json b/packages/backend/src/adapters/intl/tabby.json new file mode 100644 index 000000000..195eb7cdb --- /dev/null +++ b/packages/backend/src/adapters/intl/tabby.json @@ -0,0 +1,458 @@ +{ + "slug": "tabby", + "name": "Tabby", + "aliases": [ + "Tabby BNPL", + "Tabby Pay in 4", + "تابي" + ], + "description": "Tabby buy now, pay later (UAE, Saudi Arabia, Kuwait): look up payments with their captures and refunds, checkout sessions, customer disputes and webhooks. Capture, refund and close are included, installed switched off.", + "instructions": "## Tabby\nThis connector uses the **Tabby API** (buy now, pay later in the UAE, Saudi Arabia and Kuwait) of your merchant account: payments with their captures and refunds, checkout sessions, disputes and webhooks. Capture, refund and close are included but installed switched off.\n\n**Setup (2 minutes)**\n1. Sign in to the Tabby Merchant Dashboard (`merchant.tabby.ai` for the UAE, `merchant.tabby.sa` for Saudi Arabia) or ask your Tabby partner manager for the API keys.\n2. Copy the **secret key** into `TABBY_SECRET_KEY`: `sk_test_...` for test payments, `sk_...` for live ones. The public key (`pk_...`) does not work here.\n3. `TABBY_API_URL` is the API of your merchant region: `https://api.tabby.ai` for the UAE (and Kuwait), `https://api.tabby.sa` for Saudi Arabia. Tabby asks every call to go to the merchant region; a wrong region answers 403 or 404.\n4. Install and run `tabby_search_payments` to check the key.\n\n**Identifiers**: a payment id is a UUID assigned by Tabby. Your own order number is `order.reference_id` of the payment. To find a payment from an order number, list payments for the order date with `tabby_search_payments` (`created_from` / `created_to`) and match `order.reference_id`.\n\n**Workflow**\n- Recent payments: `tabby_search_payments` (20 per call, page with `offset`; without `status` only AUTHORIZED and CLOSED payments). Then `tabby_get_payment_details` for the full record.\n- Status meaning: AUTHORIZED = customer approved, not yet captured; CLOSED = captured (fully) or closed; REJECTED and EXPIRED = no money moved. Captures and refunds are listed inside the payment.\n- A checkout that did not finish: `tabby_get_checkout_session`.\n- Disputes: `tabby_list_disputes`, then `tabby_get_dispute`; `days_left` is the time before the dispute is approved automatically.\n- Webhooks: `tabby_list_webhooks` with your merchant code.\n\n**Writes switched off**: `tabby_create_capture` (captures money of an AUTHORIZED payment), `tabby_create_refund` (refunds a CLOSED payment, instantly) and `tabby_close_payment` (releases what is not captured). An admin turns them on deliberately. Before each call show the user the payment, the amount and the currency and wait for a yes. Pass a `reference_id` so a retry is not executed twice.\n\n**Amounts**: strings with up to 2 decimals (AED, SAR) or 3 (KWD). Dates and times are UTC, ISO 8601.\n\n**Errors**: 401 means a wrong secret key or a public key; 400 with an empty body can also be a missing key at the edge. 403 or 404 for a payment that exists usually means the wrong region in `TABBY_API_URL`. 409 on a refund means the payment is not refundable yet (not CLOSED) or the amount is too high.\n\n**Limitations**: creating checkout sessions, updating payments, registering webhooks and answering disputes are not part of this connector; do those in the Merchant Dashboard.", + "region": "intl", + "category": "payments", + "icon": "tabby", + "docsUrl": "https://docs.tabby.ai/api-reference/overview", + "requiredEnvVars": [ + "TABBY_API_URL", + "TABBY_SECRET_KEY" + ], + "envVarMeta": { + "TABBY_API_URL": { + "label": "API region", + "kind": "address", + "help": "https://api.tabby.ai for UAE (and Kuwait) merchants, https://api.tabby.sa for Saudi Arabia merchants. Test and live use the same address; the key decides.", + "example": "https://api.tabby.ai", + "pattern": "^https://api\\.tabby\\.(ai|sa)/?$", + "patternMessage": "Use https://api.tabby.ai (UAE) or https://api.tabby.sa (Saudi Arabia)." + }, + "TABBY_SECRET_KEY": { + "label": "Secret key", + "kind": "credential", + "secret": true, + "help": "The secret API key from Tabby: sk_test_... for test payments, sk_... for live payments. Not the public pk_ key.", + "link": "https://docs.tabby.ai/testing-guidelines/testing-credentials", + "pattern": "^sk_[A-Za-z0-9_-]{16,}$", + "patternMessage": "Paste the secret key, it starts with sk_ (or sk_test_)." + } + }, + "verifyHints": { + "400": "Tabby rejected the request at the edge, usually a missing or malformed key. Paste the secret key (sk_...) again.", + "401": "Tabby refused the secret key. Use the sk_ (or sk_test_) key, not the public pk_ key.", + "403": "The key works but not for this region. Check TABBY_API_URL: api.tabby.ai for the UAE, api.tabby.sa for Saudi Arabia.", + "404": "Nothing at this address. Check TABBY_API_URL: https://api.tabby.ai or https://api.tabby.sa." + }, + "probe": { + "tool": "tabby_search_payments", + "params": { + "limit": 1 + } + }, + "connector": { + "name": "Tabby API", + "type": "REST", + "baseUrl": "{{TABBY_API_URL}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TABBY_SECRET_KEY}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "tabby_search_payments", + "description": "List Tabby payments, newest first: id, created_at, status, amount and currency, buyer (name, email, phone), order reference_id and items, captures and refunds. Without status only AUTHORIZED and CLOSED payments are returned. At most 20 per call; page with offset and read pagination.total_count.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Payments per call, at most 20." + }, + "offset": { + "type": "integer", + "description": "Number of payments to skip (0-based), for paging." + }, + "status": { + "type": "string", + "description": "Filter: authorized, closed or rejected (API statuses), or new, captured, refunded, cancelled (Merchant Dashboard statuses).", + "enum": [ + "authorized", + "closed", + "rejected", + "new", + "captured", + "refunded", + "cancelled" + ] + }, + "created_from": { + "type": "string", + "description": "Only payments created on or after this date, YYYY-MM-DD (UTC)." + }, + "created_to": { + "type": "string", + "description": "Only payments created on or before this date, YYYY-MM-DD (UTC)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/payments", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "status": "$status", + "created_at__gte": "$created_from", + "created_at__lte": "$created_to" + } + }, + "responseMapping": { + "transform": { + "exclude": [ + "payments[*].order_history", + "payments[*].buyer_history", + "payments[*].attachment", + "payments[*].order.items[*].image_url", + "payments[*].order.items[*].product_url" + ] + } + } + }, + { + "name": "tabby_get_payment_details", + "description": "Get one Tabby payment by its id: status (CREATED, AUTHORIZED, CLOSED, REJECTED, EXPIRED), amount, currency, is_test, buyer, shipping address, order (reference_id = your order number, items, tax, shipping, discount), every capture and refund with amounts and dates, and meta.", + "parameters": { + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "Tabby payment id (UUID), from tabby_search_payments or your order record, e.g. 3c3bd0c4-2b6e-4d2b-8a4e-6c1d9f0e5a11." + } + }, + "required": [ + "payment_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/payments/{payment_id}" + }, + "responseMapping": { + "transform": { + "exclude": [ + "order_history", + "buyer_history", + "attachment" + ] + } + } + }, + { + "name": "tabby_get_checkout_session", + "description": "Get a checkout session by its id: session status (created, approved, rejected, expired), the payment it created (id, status, amount, order reference) and the merchant. Use it when a customer reports a checkout that did not finish.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Checkout session id returned when the session was created." + } + }, + "required": [ + "session_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/checkout/{session_id}" + }, + "responseMapping": { + "transform": { + "exclude": [ + "token", + "payment.order_history", + "payment.buyer_history", + "payment.attachment", + "merchant_urls" + ] + } + } + }, + { + "name": "tabby_list_disputes", + "description": "List the 100 most recent disputes customers opened on your payments: id, payment_id, order_number, amount, currency, status, reason (unreceived_refund, identity_theft, product_issue, not_delivered), days_left before automatic approval, comment and items. Page with page_token = next_page_token until it is empty.", + "parameters": { + "type": "object", + "properties": { + "statuses": { + "type": "array", + "description": "Only these statuses: new, declined, cancelled, refunded, in_progress, evidence_merchant, evidence_customer.", + "items": { + "type": "string", + "enum": [ + "new", + "declined", + "cancelled", + "refunded", + "in_progress", + "evidence_merchant", + "evidence_customer" + ] + } + }, + "created_from": { + "type": "string", + "description": "Only disputes created at or after this ISO 8601 date-time." + }, + "created_to": { + "type": "string", + "description": "Only disputes created at or before this ISO 8601 date-time." + }, + "page_token": { + "type": "string", + "description": "next_page_token from the previous answer." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/disputes", + "queryParams": { + "statuses": "$statuses", + "created_at_gte": "$created_from", + "created_at_lte": "$created_to", + "page_token": "$page_token" + } + } + }, + { + "name": "tabby_get_dispute", + "description": "Get one dispute by its id with the customer reason, comment, attachments, disputed items, amount, status and the days left before it is approved automatically.", + "parameters": { + "type": "object", + "properties": { + "dispute_id": { + "type": "string", + "description": "Dispute id (UUID) from tabby_list_disputes." + } + }, + "required": [ + "dispute_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/disputes/{dispute_id}" + } + }, + { + "name": "tabby_list_webhooks", + "description": "List the webhooks registered for one merchant code: id, url, is_test and the header Tabby sends with each notification (its value is not returned).", + "parameters": { + "type": "object", + "properties": { + "merchant_code": { + "type": "string", + "description": "Your Tabby merchant code (store code given by Tabby, e.g. one per country)." + } + }, + "required": [ + "merchant_code" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/webhooks", + "headers": { + "X-Merchant-Code": "$merchant_code" + } + }, + "responseMapping": { + "transform": { + "exclude": [ + "[*].header.value", + "header.value" + ] + } + } + }, + { + "name": "tabby_create_capture", + "description": "Capture an AUTHORIZED payment in full or in part when the order ships. This settles money: show the payment, amount and currency to the user and wait for a yes. A full capture closes the payment. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "Tabby payment id (UUID), from tabby_search_payments or your order record, e.g. 3c3bd0c4-2b6e-4d2b-8a4e-6c1d9f0e5a11." + }, + "amount": { + "type": "string", + "description": "Amount to capture including tax and shipping, e.g. \"250.00\" (up to 2 decimals for AED and SAR, 3 for KWD)." + }, + "reference_id": { + "type": "string", + "description": "Your idempotency key for this capture, e.g. the shipment number. A retry with the same key is not captured twice." + }, + "tax_amount": { + "type": "string", + "description": "Tax included in the amount." + }, + "shipping_amount": { + "type": "string", + "description": "Shipping included in the amount." + }, + "discount_amount": { + "type": "string", + "description": "Discount applied." + }, + "items": { + "type": "array", + "description": "Items being captured.", + "items": { + "type": "object", + "properties": { + "reference_id": { + "type": "string", + "description": "Your product id (SKU)." + }, + "title": { + "type": "string", + "description": "Product name." + }, + "quantity": { + "type": "integer", + "description": "Quantity." + }, + "unit_price": { + "type": "string", + "description": "Price per unit, e.g. \"100.00\"." + } + } + } + } + }, + "required": [ + "payment_id", + "amount" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/payments/{payment_id}/captures", + "bodyMapping": { + "amount": "$amount", + "reference_id": "$reference_id", + "tax_amount": "$tax_amount", + "shipping_amount": "$shipping_amount", + "discount_amount": "$discount_amount", + "items": "$items" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "tabby_create_refund", + "description": "Refund a CLOSED (captured) payment in full or in part. The money goes back to the customer at once and cannot be taken back: show the payment, amount and reason to the user and wait for a yes. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "Tabby payment id (UUID), from tabby_search_payments or your order record, e.g. 3c3bd0c4-2b6e-4d2b-8a4e-6c1d9f0e5a11." + }, + "amount": { + "type": "string", + "description": "Amount to refund, e.g. \"100.00\"." + }, + "reference_id": { + "type": "string", + "description": "Your idempotency key for this refund, e.g. the credit note number." + }, + "reason": { + "type": "string", + "description": "Reason for the refund." + }, + "items": { + "type": "array", + "description": "Items being refunded.", + "items": { + "type": "object", + "properties": { + "reference_id": { + "type": "string", + "description": "Your product id (SKU)." + }, + "title": { + "type": "string", + "description": "Product name." + }, + "quantity": { + "type": "integer", + "description": "Quantity." + }, + "unit_price": { + "type": "string", + "description": "Price per unit, e.g. \"100.00\"." + } + } + } + } + }, + "required": [ + "payment_id", + "amount" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/payments/{payment_id}/refunds", + "bodyMapping": { + "amount": "$amount", + "reference_id": "$reference_id", + "reason": "$reason", + "items": "$items" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "tabby_close_payment", + "description": "Close an AUTHORIZED payment. Any amount not captured yet is released to the customer and can no longer be captured; use it for a cancelled order or after a partial capture. Ask the user before calling. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "payment_id": { + "type": "string", + "description": "Tabby payment id (UUID), from tabby_search_payments or your order record, e.g. 3c3bd0c4-2b6e-4d2b-8a4e-6c1d9f0e5a11." + } + }, + "required": [ + "payment_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/payments/{payment_id}/close" + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/tabby.live.spec.ts b/packages/backend/src/adapters/intl/tabby.live.spec.ts new file mode 100644 index 000000000..d5d4f4e34 --- /dev/null +++ b/packages/backend/src/adapters/intl/tabby.live.spec.ts @@ -0,0 +1,182 @@ +import * as adapter from './tabby.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Tabby API (docs.tabby.ai, OpenAPI at docs.tabby.ai/openapi.yaml): region-specific base URL https://api.tabby.ai (UAE) + * or https://api.tabby.sa (KSA), Bearer secret key; payments /api/v2, disputes and webhooks /api/v1. + * Live part: TABBY_API_URL=https://api.tabby.ai TABBY_SECRET_KEY=sk_test_... RUN_TABBY_LIVE=1 npx jest src/adapters/intl/tabby + * (reads only: the latest payment, and that payment by id). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'tabby_'; +const WRITES: string[] = ["tabby_create_capture", "tabby_create_refund", "tabby_close_payment"]; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('tabby adapter: static spec conformance', () => { + it('sends the secret key as a Bearer token to the configured Tabby region', () => { + expect(a.requiredEnvVars).toEqual(['TABBY_API_URL', 'TABBY_SECRET_KEY']); + expect(a.connector.baseUrl).toBe('{{TABBY_API_URL}}'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{TABBY_SECRET_KEY}}' }); + expect(a.envVarMeta?.TABBY_SECRET_KEY.secret).toBe(true); + const re = new RegExp(a.envVarMeta?.TABBY_API_URL.pattern as string); + expect(re.test('https://api.tabby.ai')).toBe(true); + expect(re.test('https://api.tabby.sa')).toBe(true); + expect(re.test('https://checkout.tabby.ai')).toBe(false); + expect(a.probe).toEqual({ tool: 'tabby_search_payments', params: { limit: 1 } }); + }); + + it('maps the documented payment, checkout, dispute and webhook routes', async () => { + const list = await request('tabby_search_payments', { limit: 20, offset: 40, status: 'closed', created_from: '2026-10-01', created_to: '2026-10-20' }, { TABBY_API_URL: 'https://api.tabby.sa', TABBY_SECRET_KEY: 'sk_test_x' }); + expect(list.url).toBe('https://api.tabby.sa/api/v2/payments'); + expect(list.params).toEqual({ limit: 20, offset: 40, status: 'closed', created_at__gte: '2026-10-01', created_at__lte: '2026-10-20' }); + expect(list.headers.Authorization).toBe('Bearer sk_test_x'); + expect(tool('tabby_get_payment_details').endpointMapping.path).toBe('/api/v2/payments/{payment_id}'); + expect(tool('tabby_get_checkout_session').endpointMapping.path).toBe('/api/v2/checkout/{session_id}'); + expect(tool('tabby_list_disputes').endpointMapping.queryParams).toEqual({ statuses: '$statuses', created_at_gte: '$created_from', created_at_lte: '$created_to', page_token: '$page_token' }); + expect(tool('tabby_get_dispute').endpointMapping.path).toBe('/api/v1/disputes/{dispute_id}'); + const hooks = await request('tabby_list_webhooks', { merchant_code: 'ae_store' }, { TABBY_API_URL: 'https://api.tabby.ai', TABBY_SECRET_KEY: 'sk_x' }); + expect(hooks.url).toBe('https://api.tabby.ai/api/v1/webhooks'); + expect(hooks.headers['X-Merchant-Code']).toBe('ae_store'); + }); + + it('keeps money moves switched off with the documented bodies', () => { + expect(tool('tabby_create_capture').endpointMapping).toMatchObject({ method: 'POST', path: '/api/v2/payments/{payment_id}/captures', bodyMapping: { amount: '$amount', reference_id: '$reference_id' } }); + expect(tool('tabby_create_refund').endpointMapping).toMatchObject({ method: 'POST', path: '/api/v2/payments/{payment_id}/refunds', bodyMapping: { amount: '$amount', reference_id: '$reference_id', reason: '$reason', items: '$items' } }); + expect(tool('tabby_close_payment').endpointMapping).toMatchObject({ method: 'POST', path: '/api/v2/payments/{payment_id}/close' }); + expect(tool('tabby_create_refund').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(tool('tabby_close_payment').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + }); + + it('never hands the checkout token or the webhook signing value to the model', () => { + const session = transform('tabby_get_checkout_session', { id: 's1', status: 'approved', token: 'tok_secret', payment: { id: 'p1', order_history: [{ amount: '1' }] }, merchant_urls: { success: 'https://x' } }) as Record; + expect(session).toEqual({ id: 's1', status: 'approved', payment: { id: 'p1' } }); + expect(JSON.stringify(transform('tabby_list_webhooks', { id: 'w1', url: 'https://x', header: { title: 'X-Sig', value: 'secret' } }))).not.toMatch(/secret/); + expect(JSON.stringify(transform('tabby_list_webhooks', [{ id: 'w1', header: { title: 'X-Sig', value: 'secret' } }]))).not.toMatch(/secret/); + const list = transform('tabby_search_payments', { payments: [{ id: 'p1', order_history: [{}], buyer_history: {}, buyer: { name: 'A' } }], pagination: { total_count: 1 } }); + expect(list).toEqual({ payments: [{ id: 'p1', buyer: { name: 'A' } }], pagination: { total_count: 1 } }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe!.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + for (const r of probe.parameters?.required ?? []) expect(a.probe!.params?.[r]).toBeDefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_TABBY_LIVE === '1' && process.env.TABBY_API_URL && process.env.TABBY_SECRET_KEY ? describe : describe.skip; +live('tabby adapter: live (read-only)', () => { + const base = (process.env.TABBY_API_URL ?? '').replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${process.env.TABBY_SECRET_KEY}`, Accept: 'application/json' }; + it('lists payments and reads the first one by id', async () => { + const res = await fetch(`${base}/api/v2/payments?limit=1`, { headers }); + expect(res.status).toBe(200); + const body = (await res.json()) as { payments: Array<{ id: string }>; pagination: { total_count: number } }; + expect(Array.isArray(body.payments)).toBe(true); + if (body.payments[0]) { + const one = await fetch(`${base}/api/v2/payments/${body.payments[0].id}`, { headers }); + expect(one.status).toBe(200); + } + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/tamara.json b/packages/backend/src/adapters/intl/tamara.json new file mode 100644 index 000000000..373bd0254 --- /dev/null +++ b/packages/backend/src/adapters/intl/tamara.json @@ -0,0 +1,457 @@ +{ + "slug": "tamara", + "name": "Tamara", + "aliases": [ + "Tamara BNPL", + "Tamara Pay", + "تمارا" + ], + "description": "Tamara buy now, pay later (Saudi Arabia, UAE, Kuwait, Bahrain, Oman): look up orders by your order number or Tamara id with status, captured, refunded and settled amounts, check payment types and customer eligibility. Capture and refund are included, installed switched off.", + "instructions": "## Tamara\nThis connector uses the **Tamara merchant API** (buy now, pay later in Saudi Arabia, the UAE, Kuwait, Bahrain and Oman): order lookup by your order number or by Tamara order id, payment types and pre-checkout eligibility, and webhooks. Capture and refund are included but installed switched off.\n\n**Setup (2 minutes)**\n1. Sign in to the Tamara Partners Portal and open **API Token Management**, or use the API token Tamara sent with your merchant account. Sandbox and production have different tokens.\n2. Paste the token into `TAMARA_API_TOKEN`. It is sent as `Authorization: Bearer `. The notification token and the public key are not used here.\n3. `TAMARA_API_URL` is `https://api.tamara.co` for production or `https://api-sandbox.tamara.co` for the sandbox; it must match the token.\n4. Install and run `tamara_list_payment_types` to check the token.\n\n**Identifiers**: `order_reference_id` is your own order number sent at checkout (e.g. the shop order number); `order_id` is the UUID Tamara issued. Most questions start from your order number: `tamara_get_order_by_reference`.\n\n**Workflow**\n- Order status: `tamara_get_order_by_reference` (or `tamara_get_order` with the Tamara id). Status flow: new, approved, authorised, then partially_captured / fully_captured, and partially_refunded / fully_refunded; canceled, declined and expired are final. `paid_amount` is what the customer has paid so far; `settlement_status` and `settlement_date` say whether Tamara has paid you out.\n- Transactions: captures, refunds and cancels are inside `transactions` of the order.\n- Before checkout: `tamara_list_payment_types` (what the store offers), `tamara_check_payment_options` (what this customer can use for this amount), `tamara_check_customer_eligibility` (is the customer blocked).\n- Webhooks: `tamara_get_webhook` with the id from the registration.\n\n**Writes switched off**: `tamara_capture_order` (captures an authorised order when it ships) and `tamara_refund_order` (refunds a captured order). Both move money; an admin turns them on deliberately. Before each call show the user the order, the amount and the currency and wait for a yes.\n\n**Amounts**: objects `{amount, currency}`; SAR and AED with 2 decimals, BHD, KWD and OMR with 3. Times are UTC, ISO 8601.\n\n**Errors**: 401 means a wrong token or a token for the other environment (sandbox vs production). 404 on an order means no order with that reference or id in this environment. 400 or 422 on capture or refund name the field Tamara rejected (e.g. an amount above what can be captured).\n\n**Limitations**: Tamara has no endpoint to list or search orders; look orders up by reference or id. Creating checkout sessions, authorising and cancelling orders, the claims API (sandbox only at Tamara) and settlement reports are not part of this connector.", + "region": "intl", + "category": "payments", + "icon": "tamara", + "docsUrl": "https://docs.tamara.co/reference/tamara-api-reference-documentation", + "requiredEnvVars": [ + "TAMARA_API_URL", + "TAMARA_API_TOKEN" + ], + "envVarMeta": { + "TAMARA_API_URL": { + "label": "Environment", + "kind": "address", + "help": "https://api.tamara.co for production, https://api-sandbox.tamara.co for the sandbox. It must match the token.", + "example": "https://api.tamara.co", + "pattern": "^https://api(-sandbox)?\\.tamara\\.co/?$", + "patternMessage": "Use https://api.tamara.co (production) or https://api-sandbox.tamara.co (sandbox)." + }, + "TAMARA_API_TOKEN": { + "label": "API token", + "kind": "credential", + "secret": true, + "help": "Tamara Partners Portal, API Token Management, or the API token Tamara sent you. Not the notification token or the public key.", + "link": "https://docs.tamara.co/docs/api-token-management", + "pattern": "^[A-Za-z0-9._-]{20,}$", + "patternMessage": "Paste only the API token, without \"Bearer\" or spaces." + } + }, + "verifyHints": { + "401": "Tamara refused the API token. Check that it belongs to the chosen environment: a sandbox token only works with https://api-sandbox.tamara.co, a production token with https://api.tamara.co.", + "403": "The token works but may not use this API. Ask Tamara to enable it for your merchant account.", + "404": "Nothing at this address. Check TAMARA_API_URL: https://api.tamara.co or https://api-sandbox.tamara.co." + }, + "probe": { + "tool": "tamara_list_payment_types", + "params": { + "country": "SA" + } + }, + "connector": { + "name": "Tamara API", + "type": "REST", + "baseUrl": "{{TAMARA_API_URL}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TAMARA_API_TOKEN}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "tamara_list_payment_types", + "description": "List the Tamara payment types available to the merchant in a country (PAY_BY_INSTALMENTS, PAY_NOW...) with minimum and maximum order amounts and the supported numbers of instalments. With order_value only the types eligible for that amount are returned. A cheap call that also checks the connection.", + "parameters": { + "type": "object", + "properties": { + "country": { + "type": "string", + "description": "Merchant country, ISO code: SA, AE, KW, BH or OM.", + "enum": [ + "SA", + "AE", + "KW", + "BH", + "OM" + ] + }, + "currency": { + "type": "string", + "description": "Currency; the country default when omitted.", + "enum": [ + "SAR", + "AED", + "BHD", + "KWD", + "OMR" + ] + }, + "order_value": { + "type": "number", + "description": "Order total, to list only the types eligible for it." + }, + "phone": { + "type": "string", + "description": "Customer phone number with country code, e.g. 966501234567, for customer-specific limits." + } + }, + "required": [ + "country" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/checkout/payment-types", + "queryParams": { + "country": "$country", + "currency": "$currency", + "order_value": "$order_value", + "phone": "$phone" + } + } + }, + { + "name": "tamara_get_order_by_reference", + "description": "Get a Tamara order by your own order number (the order_reference_id sent at checkout, e.g. the shop order number): Tamara order_id, status, payment type and instalments, consumer, addresses, items, total, captured, refunded, canceled and paid amounts, settlement status and date, and the transactions (captures, refunds, cancels). If several orders share the reference, the newest is returned.", + "parameters": { + "type": "object", + "properties": { + "order_reference_id": { + "type": "string", + "description": "Your order number as sent to Tamara, e.g. A12345 or 000012345." + } + }, + "required": [ + "order_reference_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/merchants/orders/reference-id/{order_reference_id}", + "encodePathParams": true + }, + "responseMapping": { + "transform": { + "exclude": [ + "consumer.national_id" + ] + } + } + }, + { + "name": "tamara_get_order", + "description": "Get a Tamara order by its Tamara order_id with the same details as tamara_get_order_by_reference: status, amounts (total, captured, refunded, canceled, paid), settlement, items and transactions.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Tamara order_id (UUID) issued at checkout, e.g. 0ac038ef-de4a-491c-828e-b02ec0d4582d. tamara_get_order_by_reference returns it for your own order number." + } + }, + "required": [ + "order_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/orders/{order_id}" + }, + "responseMapping": { + "transform": { + "exclude": [ + "consumer.national_id" + ] + } + } + }, + { + "name": "tamara_check_payment_options", + "description": "Check which Tamara payment options a specific customer can use for an order amount before checkout (has_available_payment_options, available_payment_labels with payment type and instalments, single_checkout_enabled). Reads only; nothing is created.", + "parameters": { + "type": "object", + "properties": { + "country": { + "type": "string", + "description": "Merchant country: SA, AE, KW, BH or OM.", + "enum": [ + "SA", + "AE", + "KW", + "BH", + "OM" + ] + }, + "phone_number": { + "type": "string", + "description": "Customer phone number, e.g. 966501234567." + }, + "amount": { + "type": "number", + "description": "Order total." + }, + "currency": { + "type": "string", + "description": "Currency of the amount.", + "enum": [ + "SAR", + "AED", + "BHD", + "KWD", + "OMR" + ] + }, + "is_vip": { + "type": "boolean", + "description": "The customer is a VIP at your store." + } + }, + "required": [ + "country", + "phone_number", + "amount", + "currency" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/checkout/payment-options-pre-check", + "bodyMapping": { + "country": "$country", + "phone_number": "$phone_number", + "order_value": { + "amount": "$amount", + "currency": "$currency" + }, + "is_vip": "$is_vip" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "tamara_check_customer_eligibility", + "description": "Check whether Tamara currently treats a customer as eligible (is_eligible), based on active decline records, for an order amount. Give the phone number, the e-mail or both. Reads only.", + "parameters": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "description": "Order total." + }, + "currency": { + "type": "string", + "description": "Currency of the amount.", + "enum": [ + "SAR", + "AED", + "BHD", + "KWD", + "OMR" + ] + }, + "phone_number": { + "type": "string", + "description": "Customer phone number, e.g. 966501234567." + }, + "email": { + "type": "string", + "description": "Customer e-mail address." + } + }, + "required": [ + "amount", + "currency" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/pre-checkout/v1/eligibility", + "bodyMapping": { + "order": { + "amount": "$amount", + "currency": "$currency" + }, + "customer": { + "phone_number": "$phone_number", + "email": "$email" + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "tamara_get_webhook", + "description": "Get a registered webhook by its id: url, subscribed events (order_approved, order_authorised, order_captured, order_refunded...) and headers.", + "parameters": { + "type": "object", + "properties": { + "webhook_id": { + "type": "string", + "description": "Webhook id (UUID) returned when the webhook was registered." + } + }, + "required": [ + "webhook_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/webhooks/{webhook_id}" + }, + "responseMapping": { + "transform": { + "exclude": [ + "headers" + ] + } + } + }, + { + "name": "tamara_capture_order", + "description": "Capture an authorised Tamara order in full or in part when it ships, so Tamara settles it. This moves money: show the order, amount and currency to the user and wait for a yes. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Tamara order_id (UUID) issued at checkout, e.g. 0ac038ef-de4a-491c-828e-b02ec0d4582d. tamara_get_order_by_reference returns it for your own order number." + }, + "amount": { + "type": "number", + "description": "Amount to capture out of the order total." + }, + "currency": { + "type": "string", + "description": "Currency of the order.", + "enum": [ + "SAR", + "AED", + "BHD", + "KWD", + "OMR" + ] + }, + "shipped_at": { + "type": "string", + "description": "Shipping date-time, ISO 8601, e.g. 2026-10-20T10:00:00Z." + }, + "shipping_company": { + "type": "string", + "description": "Shipping company, e.g. Aramex, SMSA, DHL." + }, + "tracking_number": { + "type": "string", + "description": "Shipment tracking number." + }, + "tracking_url": { + "type": "string", + "description": "Tracking URL." + }, + "items": { + "type": "array", + "description": "Items being captured, in the Tamara item format (reference_id, type, name, sku, quantity, total_amount).", + "items": { + "type": "object" + } + } + }, + "required": [ + "order_id", + "amount", + "currency", + "shipped_at", + "shipping_company" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/capture", + "bodyMapping": { + "order_id": "$order_id", + "total_amount": { + "amount": "$amount", + "currency": "$currency" + }, + "shipping_info": { + "shipped_at": "$shipped_at", + "shipping_company": "$shipping_company", + "tracking_number": "$tracking_number", + "tracking_url": "$tracking_url" + }, + "items": "$items" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "tamara_refund_order", + "description": "Refund a captured Tamara order in full or in part (simplified refund). The money goes back to the customer and cannot be taken back: show the order, amount and comment to the user and wait for a yes. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Tamara order_id (UUID) issued at checkout, e.g. 0ac038ef-de4a-491c-828e-b02ec0d4582d. tamara_get_order_by_reference returns it for your own order number." + }, + "amount": { + "type": "number", + "description": "Amount to refund, excluding any discount." + }, + "currency": { + "type": "string", + "description": "Currency of the order.", + "enum": [ + "SAR", + "AED", + "BHD", + "KWD", + "OMR" + ] + }, + "comment": { + "type": "string", + "description": "Reason, stored in the order history, e.g. Refund for order A123." + }, + "merchant_refund_id": { + "type": "string", + "description": "Your own refund id, e.g. the credit note number." + } + }, + "required": [ + "order_id", + "amount", + "currency", + "comment" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/simplified-refund/{order_id}", + "bodyMapping": { + "total_amount": { + "amount": "$amount", + "currency": "$currency" + }, + "comment": "$comment", + "merchant_refund_id": "$merchant_refund_id" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/tamara.live.spec.ts b/packages/backend/src/adapters/intl/tamara.live.spec.ts new file mode 100644 index 000000000..6932ad869 --- /dev/null +++ b/packages/backend/src/adapters/intl/tamara.live.spec.ts @@ -0,0 +1,186 @@ +import * as adapter from './tamara.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Tamara merchant API (docs.tamara.co/reference): https://api.tamara.co or https://api-sandbox.tamara.co, Bearer API token; + * orders by Tamara order_id and by the merchant order_reference_id, payment types, pre-checkout checks, webhooks, + * capture and simplified refund. + * Live part: TAMARA_API_URL=https://api-sandbox.tamara.co TAMARA_API_TOKEN=... RUN_TAMARA_LIVE=1 npx jest src/adapters/intl/tamara + * (reads only: payment types for SA; with TAMARA_ORDER_REFERENCE=... also that order). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'tamara_'; +const WRITES: string[] = ["tamara_capture_order", "tamara_refund_order"]; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('tamara adapter: static spec conformance', () => { + it('sends the API token as a Bearer token to the chosen Tamara environment', () => { + expect(a.requiredEnvVars).toEqual(['TAMARA_API_URL', 'TAMARA_API_TOKEN']); + expect(a.connector.baseUrl).toBe('{{TAMARA_API_URL}}'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{TAMARA_API_TOKEN}}' }); + expect(a.envVarMeta?.TAMARA_API_TOKEN.secret).toBe(true); + const re = new RegExp(a.envVarMeta?.TAMARA_API_URL.pattern as string); + expect(re.test('https://api.tamara.co')).toBe(true); + expect(re.test('https://api-sandbox.tamara.co')).toBe(true); + expect(re.test('https://partners.tamara.co')).toBe(false); + expect(a.probe).toEqual({ tool: 'tamara_list_payment_types', params: { country: 'SA' } }); + }); + + it('maps the documented order lookups and pre-checkout calls', async () => { + const env = { TAMARA_API_URL: 'https://api-sandbox.tamara.co', TAMARA_API_TOKEN: 'tok' }; + const byRef = await request('tamara_get_order_by_reference', { order_reference_id: 'A#123' }, env); + expect(byRef.url).toBe('https://api-sandbox.tamara.co/merchants/orders/reference-id/A%23123'); + expect(byRef.headers.Authorization).toBe('Bearer tok'); + expect(tool('tamara_get_order').endpointMapping.path).toBe('/orders/{order_id}'); + const types = await request('tamara_list_payment_types', { country: 'AE', order_value: 300 }, env); + expect(types.url).toBe('https://api-sandbox.tamara.co/checkout/payment-types'); + expect(types.params).toEqual({ country: 'AE', order_value: 300 }); + const opts = await request('tamara_check_payment_options', { country: 'SA', phone_number: '966501234567', amount: 100, currency: 'SAR' }, env); + expect(opts.data).toEqual({ country: 'SA', phone_number: '966501234567', order_value: { amount: 100, currency: 'SAR' } }); + const elig = await request('tamara_check_customer_eligibility', { amount: 300, currency: 'SAR', email: 'a@b.co' }, env); + expect(elig.url).toBe('https://api-sandbox.tamara.co/pre-checkout/v1/eligibility'); + expect(elig.data).toEqual({ order: { amount: 300, currency: 'SAR' }, customer: { email: 'a@b.co' } }); + expect(tool('tamara_get_webhook').endpointMapping.path).toBe('/webhooks/{webhook_id}'); + }); + + it('keeps capture and refund switched off with the documented bodies', async () => { + const env = { TAMARA_API_URL: 'https://api.tamara.co', TAMARA_API_TOKEN: 'tok' }; + const cap = await request('tamara_capture_order', { order_id: 'o1', amount: 300, currency: 'SAR', shipped_at: '2026-10-20T10:00:00Z', shipping_company: 'Aramex' }, env); + expect(cap.url).toBe('https://api.tamara.co/payments/capture'); + expect(cap.data).toEqual({ order_id: 'o1', total_amount: { amount: 300, currency: 'SAR' }, shipping_info: { shipped_at: '2026-10-20T10:00:00Z', shipping_company: 'Aramex' } }); + const ref = await request('tamara_refund_order', { order_id: 'o1', amount: 50, currency: 'SAR', comment: 'Damaged' }, env); + expect(ref.url).toBe('https://api.tamara.co/payments/simplified-refund/o1'); + expect(ref.data).toEqual({ total_amount: { amount: 50, currency: 'SAR' }, comment: 'Damaged' }); + expect(tool('tamara_refund_order').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + }); + + it('drops the national id from order details', () => { + const out = transform('tamara_get_order_by_reference', { order_id: 'o1', status: 'fully_captured', consumer: { first_name: 'Mona', national_id: '1234567890' } }); + expect(out).toEqual({ order_id: 'o1', status: 'fully_captured', consumer: { first_name: 'Mona' } }); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe!.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + for (const r of probe.parameters?.required ?? []) expect(a.probe!.params?.[r]).toBeDefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_TAMARA_LIVE === '1' && process.env.TAMARA_API_URL && process.env.TAMARA_API_TOKEN ? describe : describe.skip; +live('tamara adapter: live (read-only)', () => { + const base = (process.env.TAMARA_API_URL ?? '').replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${process.env.TAMARA_API_TOKEN}`, Accept: 'application/json' }; + it('lists payment types and, when given, reads an order by reference', async () => { + const res = await fetch(`${base}/checkout/payment-types?country=SA`, { headers }); + expect(res.status).toBe(200); + expect(Array.isArray(await res.json())).toBe(true); + const ref = process.env.TAMARA_ORDER_REFERENCE; + if (ref) { + const order = await fetch(`${base}/merchants/orders/reference-id/${encodeURIComponent(ref)}`, { headers }); + expect(order.status).toBe(200); + expect(((await order.json()) as { order_reference_id: string }).order_reference_id).toBe(ref); + } + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/tbank.json b/packages/backend/src/adapters/intl/tbank.json new file mode 100644 index 000000000..34cddb903 --- /dev/null +++ b/packages/backend/src/adapters/intl/tbank.json @@ -0,0 +1,313 @@ +{ + "slug": "tbank", + "name": "T-Bank Business", + "aliases": [ + "T-API", + "Tinkoff Business", + "Т-Бизнес", + "Т-Банк", + "Тинькофф Бизнес" + ], + "description": "T-Bank Business (T-API, formerly Tinkoff Business): company details, accounts and balances, account statements with counterparties, payment confirmations, issued invoice status and card acquiring terminals and operations. Read-only.", + "instructions": "## T-Bank Business (T-API)\nThis connector reads the **T-Bank Business API (T-API)** of a Russian company account at T-Bank (formerly Tinkoff): company details, accounts with balances, account statements with counterparties, payment confirmations, issued invoice status, acquiring terminals and their card operations. It is read-only: no payments are created or signed.\n\n**Setup (10 minutes)**\n1. In T-Business (business.tbank.ru) open **Integrations > T-API** and click **Issue token**.\n2. Enter the IP addresses the calls come from, or for small and medium businesses choose **Disable address check** (the AnythingMCP server calls from its own address; on AnythingMCP Cloud the address check must be disabled or set to the Cloud egress address).\n3. Under **What the API may access**, tick at least: company information (T-Business ID), account information (`bank-accounts/get`), account operations (statements); add acquiring terminals, invoices and webhooks if you want those tools. You can limit the token to certain accounts.\n4. Copy the token into `TBANK_API_TOKEN`. It is valid for 90 days after its last use, so a token in regular use does not expire. To change addresses or access, issue a new token.\n5. `TBANK_API_URL` is `https://business.tbank.ru/openapi`. For the sandbox use `https://business.tbank.ru/openapi/sandbox` with the token `TBankSandboxToken` (answers do not depend on your data).\n6. Run `tbank_get_company` to check the token.\n\n**Certificates**: T-API presents a TLS certificate issued by the Russian Ministry of Digital Development (Russian Trusted Root CA); the old public certificates were revoked. A server that does not trust that root fails with a certificate error before any HTTP status. AnythingMCP Cloud does not trust that root, so this connector is offered for self-hosted AnythingMCP only. Self-hosted: add the root (from gosuslugi.ru/crt) to the Node trust store, e.g. `NODE_EXTRA_CA_CERTS=/path/russian_trusted_root_ca.pem`, and restart.\n\n**Workflow**\n- Accounts and balances: `tbank_list_accounts` (`balance.otb` is what can be spent now).\n- Statement: `tbank_get_statement` with `account_number` and `from` (and `to`); page with `cursor` = `nextCursor` until it is missing. `typeOfOperation` Credit = money in, Debit = money out. Filter by counterparty with `counterparty_inns`.\n- Proof of a payment: `tbank_get_payment_document` with the operationId and operationDate from the statement (PDF).\n- Invoices issued through T-API: `tbank_get_invoice_info`.\n- Card acquiring: `tbank_list_terminals`, then `tbank_list_terminal_operations` per terminal and day (amounts in kopecks).\n- Company details and webhooks: `tbank_get_company`, `tbank_list_webhooks`.\n\n**Dates and amounts**: date-time parameters and answers are ISO 8601 in UTC; operations on a weekend are booked on the next working day. Amounts are in the account currency (roubles with kopecks as decimals), except acquiring operations in kopecks.\n\n**Limits**: statement 20 calls per second, accounts and company 6, payment documents and acquiring 1 per second. Statements start in June 2023.\n\n**Errors**: 401 means a wrong or expired token, or a call from an address the token does not allow. 403 means the token lacks the access for that method (issue a new token with it ticked) or the account is not allowed for this token. 422 names an invalid parameter (e.g. an account number that is not 20 digits). Every error has an `errorId` worth quoting to T-Bank support.\n\n**Limitations**: payments, salary registers, self-employed payouts, cards with mTLS (secured-openapi) and the partner (OAuth) integration are not part of this connector.", + "region": "intl", + "category": "banking", + "icon": "tbank", + "docsUrl": "https://developer.tbank.ru/docs/api/scheta-i-vipiski", + "selfHostOnly": true, + "requiredEnvVars": [ + "TBANK_API_URL", + "TBANK_API_TOKEN" + ], + "envVarMeta": { + "TBANK_API_URL": { + "label": "API address", + "kind": "address", + "help": "https://business.tbank.ru/openapi for your data, https://business.tbank.ru/openapi/sandbox for the sandbox (token TBankSandboxToken).", + "example": "https://business.tbank.ru/openapi", + "pattern": "^https://business\\.tbank\\.ru/openapi(/sandbox)?/?$", + "patternMessage": "Use https://business.tbank.ru/openapi or https://business.tbank.ru/openapi/sandbox." + }, + "TBANK_API_TOKEN": { + "label": "T-API token", + "kind": "credential", + "secret": true, + "help": "T-Business, Integrations, T-API, Issue token: tick company information, accounts and account operations; disable the IP address check or allow the server address.", + "link": "https://developer.tbank.ru/docs/intro/manuals/self-service-auth", + "pattern": "^(t\\.[A-Za-z0-9_-]{20,}|TBankSandboxToken)$", + "patternMessage": "Paste the token as shown, it starts with t. (or TBankSandboxToken for the sandbox)." + } + }, + "verifyHints": { + "401": "T-Bank refused the token: it is wrong, expired after 90 days unused, or the call came from an IP address the token does not allow. Issue a new token with the address check disabled or the server address allowed.", + "403": "The token works but lacks this access. Issue a new token in T-Business, Integrations, T-API with company information, accounts and account operations ticked.", + "unreachable": "The connection to business.tbank.ru failed. T-API uses a certificate of the Russian Trusted Root CA: the server must trust it (self-hosted: NODE_EXTRA_CA_CERTS with the root from gosuslugi.ru/crt), and the network must reach T-Bank." + }, + "probe": { + "tool": "tbank_get_company" + }, + "connector": { + "name": "T-Bank Business API", + "type": "REST", + "baseUrl": "{{TBANK_API_URL}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TBANK_API_TOKEN}}" + }, + "headers": { + "Accept": "application/json" + } + }, + "tools": [ + { + "name": "tbank_get_company", + "description": "Get the company behind the token: full and short name, INN, KPP, OGRN, legal form, tax regime, legal address, registration date and the bank details (bank name, BIC, correspondent account). Use it to check the connection.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/company" + } + }, + { + "name": "tbank_list_accounts", + "description": "List the company accounts at T-Bank (settlement and, with with_invest, investment accounts; active, closed, blocked or arrested): account number, name, status, type, currency, opening date, tariff and balances (balance.otb available to spend, authorized holds, pending payments). The token may be limited to some accounts.", + "parameters": { + "type": "object", + "properties": { + "with_invest": { + "type": "boolean", + "description": "Also return investment (brokerage) accounts." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v4/bank-accounts", + "queryParams": { + "withInvest": "$with_invest" + } + } + }, + { + "name": "tbank_get_statement", + "description": "Get the operations of an account for a period, from June 2023: operationId, operationDate, operationStatus (Authorization or Transaction), typeOfOperation (Credit or Debit), category, operationAmount and currency, rubleAmount, description, payPurpose, counterParty (name, INN, KPP, account, bank BIC) and card details for card operations. Up to 5000 per call (default 1000); when nextCursor is returned, call again with cursor = nextCursor. with_balances adds opening and closing balances and turnover per day (first page only).", + "parameters": { + "type": "object", + "properties": { + "account_number": { + "type": "string", + "description": "Account number, 20 digits, from tbank_list_accounts, e.g. 40702810110011000000.", + "pattern": "^[0-9]{20}$" + }, + "from": { + "type": "string", + "description": "Start of the period, ISO 8601 date-time in UTC, inclusive, e.g. 2026-10-01T00:00:00Z." + }, + "to": { + "type": "string", + "description": "End of the period, ISO 8601 date-time in UTC, exclusive. Omit for up to now." + }, + "cursor": { + "type": "string", + "description": "nextCursor from the previous answer, to fetch the next portion." + }, + "limit": { + "type": "integer", + "description": "Operations per call, 1 to 5000, default 1000." + }, + "with_balances": { + "type": "boolean", + "description": "Return balances and daily turnover (only on the first call, without cursor)." + }, + "operation_status": { + "type": "string", + "description": "All (default), Authorization (holds not yet settled) or Transaction (settled).", + "enum": [ + "All", + "Authorization", + "Transaction" + ] + }, + "categories": { + "type": "array", + "description": "Only these operation categories (at most 50), see the T-Bank category list.", + "items": { + "type": "string" + } + }, + "counterparty_inns": { + "type": "array", + "description": "Only operations with these counterparty INNs (at most 50).", + "items": { + "type": "string" + } + }, + "card_ucid": { + "type": "integer", + "description": "Only operations of the company card with this UCID." + } + }, + "required": [ + "account_number", + "from" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/statement", + "queryParams": { + "accountNumber": "$account_number", + "from": "$from", + "to": "$to", + "cursor": "$cursor", + "limit": "$limit", + "withBalances": "$with_balances", + "operationStatus": "$operation_status", + "categories": "$categories", + "inns": "$counterparty_inns", + "ucid": "$card_ucid" + } + } + }, + { + "name": "tbank_get_payment_document", + "description": "Get the bank confirmation of an executed operation (payment order, bank order, memorial order and similar) as a Base64 string of a PDF (large; call it only when the user wants the document). Only for operations with operationStatus Transaction. Limit 1 call per second.", + "parameters": { + "type": "object", + "properties": { + "account_number": { + "type": "string", + "description": "Account number, 20 digits, from tbank_list_accounts, e.g. 40702810110011000000.", + "pattern": "^[0-9]{20}$" + }, + "operation_id": { + "type": "string", + "description": "operationId (UUID) from tbank_get_statement." + }, + "operation_date": { + "type": "string", + "description": "Date of the operation, YYYY-MM-DD, from tbank_get_statement." + } + }, + "required": [ + "account_number", + "operation_id", + "operation_date" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/payment-document", + "queryParams": { + "accountNumber": "$account_number", + "operationId": "$operation_id", + "operationDate": "$operation_date" + } + } + }, + { + "name": "tbank_get_invoice_info", + "description": "Get the status of an invoice the company issued through T-API (for example whether it has been paid).", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { + "type": "string", + "description": "Invoice id (UUID) returned when the invoice was issued." + } + }, + "required": [ + "invoice_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/openapi/invoice/{invoice_id}/info" + } + }, + { + "name": "tbank_list_terminals", + "description": "List the active card acquiring terminals of the company (terminal key, address, status). Paged: page starts at 0. Limit 1 call per second.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 0." + }, + "size": { + "type": "integer", + "description": "Terminals per page, e.g. 50." + } + }, + "required": [ + "page", + "size" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/tacq/terminals", + "queryParams": { + "page": "$page", + "size": "$size" + } + } + }, + { + "name": "tbank_list_terminal_operations", + "description": "List the card operations of one acquiring terminal for at most one day: amounts in kopecks, status, card mask, time. At most 1000 per call; data can lag up to two hours. Limit 1 call per second.", + "parameters": { + "type": "object", + "properties": { + "terminal_key": { + "type": "string", + "description": "Terminal serial number (terminal key) from tbank_list_terminals." + }, + "from": { + "type": "string", + "description": "Start, ISO 8601 date-time in UTC." + }, + "till": { + "type": "string", + "description": "End, ISO 8601 date-time in UTC, at most one day after from." + }, + "limit": { + "type": "integer", + "description": "Maximum operations, up to 1000." + } + }, + "required": [ + "terminal_key", + "from", + "till", + "limit" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/tacq/operations/terminal/{terminal_key}", + "queryParams": { + "from": "$from", + "till": "$till", + "limit": "$limit" + } + } + }, + { + "name": "tbank_list_webhooks", + "description": "List the webhooks configured for the company (event type and URL), for example the account operation notifications.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/webhooks/by-company" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/tbank.live.spec.ts b/packages/backend/src/adapters/intl/tbank.live.spec.ts new file mode 100644 index 000000000..a6b6df577 --- /dev/null +++ b/packages/backend/src/adapters/intl/tbank.live.spec.ts @@ -0,0 +1,179 @@ +import * as adapter from './tbank.json'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import axios from 'axios'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * T-Bank Business API, T-API (developer.tbank.ru/docs/api): https://business.tbank.ru/openapi (sandbox /openapi/sandbox with + * the token TBankSandboxToken), Bearer token issued in T-Business; company, accounts v4, statement v1, payment document, + * invoice info, acquiring terminals and operations, webhooks. The host presents a Russian Trusted Root CA certificate. + * Live part: NODE_EXTRA_CA_CERTS=russian_trusted_root_ca.pem TBANK_API_URL=https://business.tbank.ru/openapi/sandbox + * TBANK_API_TOKEN=TBankSandboxToken RUN_TBANK_LIVE=1 npx jest src/adapters/intl/tbank (reads only: company and accounts). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + optionalEnvVars?: string[]; + envVarMeta?: Record; + probe?: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record; config?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Connector variables (upper case) are filled in by the connector, not by the model. +const isEnv = (p: string) => /^[A-Z][A-Z0-9_]*$/.test(p); +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + for (const p of [...out]) if (isEnv(p)) out.delete(p); + return out; +}; + +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, + AxiosHeaders: actual.AxiosHeaders, + }; +}); +const mockedAxios = axios as unknown as jest.Mock; +const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Runs a tool through the real REST engine (axios mocked) and returns the request it built. +const request = async (name: string, args: Record, env: Record = {}) => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ status: 200, data: {}, headers: {} }); + const fill = (s: string) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => env[k] ?? ''); + const authConfig = JSON.parse(fill(JSON.stringify(a.connector.authConfig ?? {}))); + await engine().execute( + { baseUrl: fill(a.connector.baseUrl), authType: a.connector.authType, authConfig, headers: a.connector.headers } as never, + JSON.parse(fill(JSON.stringify(tool(name).endpointMapping))), + { ...env, ...args }, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0] as { method: string; url: string; params?: Record; data?: unknown; headers: Record }; +}; +const PREFIX = 'tbank_'; +const WRITES: string[] = []; +const transform = (name: string, body: unknown) => applyResponseTransform(body, tool(name).responseMapping as never).value; +void transform; + +describe('tbank adapter: static spec conformance', () => { + it('sends the T-API token as a Bearer token to business.tbank.ru/openapi', () => { + expect(a.requiredEnvVars).toEqual(['TBANK_API_URL', 'TBANK_API_TOKEN']); + expect(a.connector.baseUrl).toBe('{{TBANK_API_URL}}'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{TBANK_API_TOKEN}}' }); + expect(a.envVarMeta?.TBANK_API_TOKEN.secret).toBe(true); + const re = new RegExp(a.envVarMeta?.TBANK_API_URL.pattern as string); + expect(re.test('https://business.tbank.ru/openapi')).toBe(true); + expect(re.test('https://business.tbank.ru/openapi/sandbox')).toBe(true); + expect(re.test('https://secured-openapi.tbank.ru')).toBe(false); + expect(new RegExp(a.envVarMeta?.TBANK_API_TOKEN.pattern as string).test('TBankSandboxToken')).toBe(true); + expect(a.probe).toEqual({ tool: 'tbank_get_company' }); + }); + + it('is offered for self-hosted AnythingMCP only, because Cloud does not trust the Russian Trusted Root CA', () => { + const meta = adapter as unknown as { selfHostOnly?: boolean; instructions: string }; + expect(meta.selfHostOnly).toBe(true); + expect(meta.instructions).toMatch(/NODE_EXTRA_CA_CERTS/); + }); + + it('is read-only: every tool is a GET', () => { + for (const t of a.tools) expect(`${t.name}:${t.endpointMapping.method}`).toBe(`${t.name}:GET`); + }); + + it('maps the documented accounts, statement and document routes', async () => { + const env = { TBANK_API_URL: 'https://business.tbank.ru/openapi', TBANK_API_TOKEN: 't.abc' }; + const acc = await request('tbank_list_accounts', { with_invest: true }, env); + expect(acc.url).toBe('https://business.tbank.ru/openapi/api/v4/bank-accounts'); + expect(acc.params).toEqual({ withInvest: true }); + expect(acc.headers.Authorization).toBe('Bearer t.abc'); + const st = await request('tbank_get_statement', { account_number: '40702810110011000000', from: '2026-10-01T00:00:00Z', cursor: 'c1', limit: 500, operation_status: 'Transaction', counterparty_inns: ['7707083893'] }, env); + expect(st.url).toBe('https://business.tbank.ru/openapi/api/v1/statement'); + expect(st.params).toMatchObject({ accountNumber: '40702810110011000000', from: '2026-10-01T00:00:00Z', cursor: 'c1', limit: 500, operationStatus: 'Transaction' }); + expect(tool('tbank_get_statement').endpointMapping.queryParams).toMatchObject({ withBalances: '$with_balances', categories: '$categories', inns: '$counterparty_inns', ucid: '$card_ucid', to: '$to' }); + expect(tool('tbank_get_payment_document').endpointMapping.queryParams).toEqual({ accountNumber: '$account_number', operationId: '$operation_id', operationDate: '$operation_date' }); + expect(tool('tbank_get_company').endpointMapping.path).toBe('/api/v1/company'); + expect(tool('tbank_get_invoice_info').endpointMapping.path).toBe('/api/v1/openapi/invoice/{invoice_id}/info'); + expect(tool('tbank_list_terminals').endpointMapping).toMatchObject({ path: '/api/v1/tacq/terminals', queryParams: { page: '$page', size: '$size' } }); + expect(tool('tbank_list_terminal_operations').endpointMapping).toMatchObject({ path: '/api/v1/tacq/operations/terminal/{terminal_key}', queryParams: { from: '$from', till: '$till', limit: '$limit' } }); + expect(tool('tbank_list_webhooks').endpointMapping.path).toBe('/api/v1/webhooks/by-company'); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + }); + + it('probes with a read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe!.tool); + expect(probe.annotations?.readOnlyHint === false).toBe(false); + for (const r of probe.parameters?.required ?? []) expect(a.probe!.params?.[r]).toBeDefined(); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + +const live = process.env.RUN_TBANK_LIVE === '1' && process.env.TBANK_API_URL && process.env.TBANK_API_TOKEN ? describe : describe.skip; +live('tbank adapter: live (read-only)', () => { + const base = (process.env.TBANK_API_URL ?? '').replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${process.env.TBANK_API_TOKEN}`, Accept: 'application/json' }; + it('reads the company and its accounts', async () => { + const company = await fetch(`${base}/api/v1/company`, { headers }); + expect(company.status).toBe(200); + expect(typeof ((await company.json()) as { name: string }).name).toBe('string'); + const accounts = await fetch(`${base}/api/v4/bank-accounts`, { headers }); + expect(accounts.status).toBe(200); + expect(Array.isArray(await accounts.json())).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/tezga.json b/packages/backend/src/adapters/intl/tezga.json new file mode 100644 index 000000000..a54090d48 --- /dev/null +++ b/packages/backend/src/adapters/intl/tezga.json @@ -0,0 +1,566 @@ +{ + "slug": "tezga", + "name": "Tezga eKasa", + "aliases": [ + "Tezga fiskalna kasa", + "Narbiz POS" + ], + "description": "Tezga eKasa, the online fiscal cash register for Serbian companies: fiscal setup and tax labels, status and fiscal data of issued receipts, and bank transactions. Issuing, cancelling or refunding receipts and writing customers and items are included, installed switched off.", + "region": "intl", + "category": "accounting", + "icon": "tezga", + "docsUrl": "https://tezgafiskal.com/api-za-fiskalizaciju", + "instructions": "## Tezga\nThis connector uses the **Tezga eKasa public API** (`https://pos.narbiz.com/api/integrations`), the online fiscal cash register (ESIR with V-PFR) of GoSimple for Serbian companies. It reads the account's fiscal setup and tax labels, the status and fiscal data of issued documents and the company's bank transactions. Issuing a fiscal receipt, cancelling or refunding one, and writing customers and catalogue items are included but installed switched off.\n\n**Setup (about 3 minutes)**\n1. API access is part of the **Posao** and **Biznis** plans (or the Enterprise level). On other plans every call answers 402.\n2. In Tezga open **Podešavanja > Integracije > API ključevi**, name a key (e.g. AnythingMCP) and create it. It is shown once; copy it into `TEZGA_API_KEY`. Keys start with `tzg_` followed by 48 hex characters. Use a separate key for this connector so you can pause or delete it alone; you can also bind it to the server's IP addresses there.\n3. Install and run `tezga_get_account_setup` to check the key and see the environment.\n\nThe key is sent in the `X-Tezga-Api-Key` header. Keys rotate every 90 days: Tezga e-mails a reminder on day 76; paste the new key here before the old one is switched off. A read-only key is enough for the read tools; writes with it answer 403.\n\n**Environment first**: test and production keys look the same; the environment belongs to the account. `tezga_get_account_setup` returns `okruzenje`: `produkcija` (real receipts reported to the Tax Administration), `sandbox` (official test environment), `simulacija` (demo, PFR numbers start with MOCK) or `nepovezano` (no security element, fiscalisation refused). Tell the user which one before any write.\n\n**Workflow**\n- Status of an order: `tezga_find_document` with your order number (externalId), or `tezga_get_document` with the Tezga document id. `statusDokumenta` is nacrt (draft), izdat (issued) or storniran (cancelled); `fiskalni` holds the PFR number, counter and verification URL, null when not fiscalised; `javniPdfUrl` opens without login.\n- Payments: `tezga_list_bank_transactions` (`smer` uplata for incoming, isplata for outgoing, `since` for a start time).\n- Writes (switched off): `tezga_issue_receipt` (fiscal receipt, proforma or SEF e-invoice; idempotent per externalId), `tezga_refund_document` (storno or refund, a new document referring to the original), `tezga_upsert_customers` and `tezga_upsert_products` (match by PIB/e-mail/name or by item code; nothing is deleted). A fiscal receipt cannot be undone, only refunded, so an admin turns these on deliberately; before each call show the user the lines, amounts and environment and wait for a yes.\n\n**Amounts and tax**: prices are RSD including VAT (`cenaSaPdv`). Tax rates are 20, 10, 0 or oslobodjeno. A company outside the VAT system has every line recorded as exempt whatever rate is sent.\n\n**Limits**: 240 reads and 120 writes per minute per company by default; responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and 429 comes with `Retry-After`. At most 500 lines per receipt and 500 customers or items per call; body up to 512 KB.\n\n**Errors**: 401 missing or wrong key. 402 plan without API access or subscription not active (`error` says which). 403 read-only key used for a write, a till-terminal key, or a request from an IP not on the key's list. 404 the document does not exist in your company. 409 the action is not allowed (not issued, already partly refunded) or the same externalId is being processed. 422 field or rule errors listed in `fields`. 202 on a write means the document exists but the Tax Administration has not accepted it yet; Tezga retries by itself, do not send it again.", + "prerequisites": "A Tezga account on a plan with API access (Posao, Biznis or Enterprise level) and an API key from Podešavanja > Integracije.", + "requiredEnvVars": [ + "TEZGA_API_KEY" + ], + "envVarMeta": { + "TEZGA_API_KEY": { + "label": "API key", + "kind": "credential", + "secret": true, + "help": "Tezga, Podešavanja > Integracije > API ključevi: create a key and copy it (shown once). It starts with tzg_.", + "link": "https://tezgafiskal.com/api-za-fiskalizaciju", + "pattern": "^tzg_[0-9a-fA-F]{48}$", + "patternMessage": "Paste only the key: tzg_ followed by 48 hex characters." + } + }, + "verifyHints": { + "401": "Tezga refused the key. Create a new one under Podešavanja > Integracije and paste it (tzg_ plus 48 characters).", + "402": "The company's plan does not include API access (Posao, Biznis or Enterprise level) or the subscription is not active.", + "403": "The key is bound to IP addresses that do not include this server, or it is a read-only or till-terminal key. Check the shield icon next to the key in Podešavanja > Integracije." + }, + "probe": { + "tool": "tezga_get_account_setup" + }, + "connector": { + "name": "Tezga eKasa public API", + "type": "REST", + "baseUrl": "https://pos.narbiz.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Tezga-Api-Key", + "apiKey": "{{TEZGA_API_KEY}}" + }, + "headers": { + "Accept": "application/json" + }, + "healthcheckPath": "/api/integrations/tax-labels" + }, + "tools": [ + { + "name": "tezga_get_account_setup", + "description": "Read the account's fiscal setup: the environment (produkcija = real receipts at the Tax Administration, sandbox = official test environment, simulacija = demo, nothing reported, nepovezano = no security element yet), whether the company is in the VAT system and subject to fiscalisation, and the tax rates and labels it uses. Call it first; it also checks the key.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/integrations/tax-labels" + } + }, + { + "name": "tezga_get_document", + "description": "Get a document (receipt, proforma, refund) by its Tezga id: number, type, status (nacrt draft, izdat issued, storniran cancelled), total with VAT, payment method, the fiscal data (PFR number and time, receipt counter, verification URL, QR payload; null when not fiscalised) and public links to the receipt page and PDF.", + "parameters": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Tezga document UUID (documentId from an issued receipt or from tezga_find_document)." + } + }, + "required": [ + "document_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/integrations/documents/{document_id}" + } + }, + { + "name": "tezga_find_document", + "description": "Find the document issued for one of your order numbers (externalId) and return its status, fiscal data (PFR number, verification URL) and public PDF link. Use it to check whether an order already has a fiscal receipt.", + "parameters": { + "type": "object", + "properties": { + "externalId": { + "type": "string", + "description": "Your order number as it was sent to Tezga, e.g. NALOG-1042." + }, + "provider": { + "type": "string", + "enum": [ + "woocommerce", + "shopify", + "prestashop", + "opencart", + "magento", + "bigcommerce", + "custom" + ], + "description": "Where the order came from; default custom (orders sent through this API). Use the shop name for orders a connected web shop sent." + } + }, + "required": [ + "externalId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/integrations/documents", + "queryParams": { + "externalId": "$externalId", + "provider": "$provider" + } + } + }, + { + "name": "tezga_list_bank_transactions", + "description": "List the company's bank transactions imported from statements (incoming payments and outgoing payments), newest first, for matching payments to invoices.", + "parameters": { + "type": "object", + "properties": { + "smer": { + "type": "string", + "enum": [ + "uplata", + "isplata" + ], + "description": "uplata = incoming payments, isplata = outgoing payments; omit for both." + }, + "since": { + "type": "string", + "description": "Only transactions from this moment on, ISO 8601 (e.g. 2026-10-01T00:00:00Z)." + }, + "limit": { + "type": "integer", + "description": "How many, default 200, at most 500." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/integrations/transactions", + "queryParams": { + "smer": "$smer", + "since": "$since", + "limit": "$limit" + } + } + }, + { + "name": "tezga_issue_receipt", + "description": "Issue and fiscalise a receipt (or a proforma) for an order: Tezga signs it at the Tax Administration (V-PFR) and returns the PFR number, verification URL and public PDF; with tok sef it sends an e-invoice to SEF instead. Idempotent per externalId: the same order number never creates a second receipt. In produkcija this is a real fiscal receipt. Installed switched off; confirm the items, prices and payment method with the user first.", + "parameters": { + "type": "object", + "properties": { + "externalId": { + "type": "string", + "description": "Your unique order number, up to 200 characters. The same number never issues a second receipt." + }, + "stavke": { + "type": "array", + "description": "Receipt lines.", + "items": { + "type": "object", + "properties": { + "naziv": { + "type": "string", + "description": "Item name as printed." + }, + "sifra": { + "type": "string", + "description": "Item code; a code from the catalogue takes name, tax rate and price from it unless cenaIzZahteva is true." + }, + "kolicina": { + "type": "number", + "description": "Quantity." + }, + "cenaSaPdv": { + "type": "number", + "description": "Unit price including VAT, in RSD." + }, + "poreskaStopa": { + "type": "string", + "description": "20, 10, 0 or oslobodjeno (exempt)." + }, + "jedinicaMere": { + "type": "string", + "description": "Unit, e.g. kom, usluga, h, kg (default kom)." + }, + "popustProcenat": { + "type": "number", + "description": "Line discount in percent." + }, + "popustRsd": { + "type": "number", + "description": "Line discount in RSD for the whole line." + } + }, + "required": [ + "naziv", + "kolicina", + "cenaSaPdv" + ] + } + }, + "kupac": { + "type": "object", + "description": "Buyer: naziv, pib (tax id, for companies), email, adresa, mesto, postanskiBroj, maticniBroj. Without pib it is a sale to a private person.", + "properties": { + "naziv": { + "type": "string" + }, + "pib": { + "type": "string" + }, + "email": { + "type": "string" + }, + "adresa": { + "type": "string" + }, + "mesto": { + "type": "string" + }, + "postanskiBroj": { + "type": "string" + }, + "maticniBroj": { + "type": "string" + } + } + }, + "nacinPlacanja": { + "type": "string", + "description": "Payment method as text: gotovina (cash), kartica (card, default), instant/IPS, prenos (bank transfer), vaucer, cek; anything else counts as other non-cash." + }, + "tipDokumenta": { + "type": "string", + "enum": [ + "racun", + "predracun" + ], + "description": "racun (receipt, default) or predracun (proforma, needs pozivNaBroj)." + }, + "tok": { + "type": "string", + "enum": [ + "fiskalni", + "sef", + "fiskalni_i_sef", + "avans" + ], + "description": "fiskalni = fiscal receipt; sef = e-invoice on SEF (needs kupac.pib); fiskalni_i_sef = both; avans = order paid in advance, delivered later. Without a buyer pib always fiscal." + }, + "posaljiMejl": { + "type": "boolean", + "description": "E-mail the PDF to the buyer." + }, + "posaljiNaSef": { + "type": "boolean", + "description": "Send the e-invoice to SEF automatically (tok sef)." + }, + "fiskalizujPredracun": { + "type": "boolean", + "description": "Fiscalise the proforma itself (fiscal flow only)." + }, + "model": { + "type": "string", + "description": "Payment reference model, e.g. 97." + }, + "pozivNaBroj": { + "type": "string", + "description": "Payment reference number; required for a proforma." + }, + "popustProcenat": { + "type": "number", + "description": "Discount on the whole receipt in percent (not together with popustRsd)." + }, + "popustRsd": { + "type": "number", + "description": "Discount on the whole receipt in RSD (not together with popustProcenat)." + }, + "cenaIzZahteva": { + "type": "boolean", + "description": "true: prices and discounts from this request apply to catalogue items too." + }, + "datumUplate": { + "type": "string", + "description": "Only with tok avans: day the payment arrived, YYYY-MM-DD." + } + }, + "required": [ + "externalId", + "stavke" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/integrations/order", + "queryParams": { + "provider": "custom" + }, + "bodyMapping": { + "externalId": "$externalId", + "stavke": "$stavke", + "kupac": "$kupac", + "nacinPlacanja": "$nacinPlacanja", + "tipDokumenta": "$tipDokumenta", + "tok": "$tok", + "posaljiMejl": "$posaljiMejl", + "posaljiNaSef": "$posaljiNaSef", + "fiskalizujPredracun": "$fiskalizujPredracun", + "model": "$model", + "pozivNaBroj": "$pozivNaBroj", + "popustProcenat": "$popustProcenat", + "popustRsd": "$popustRsd", + "cenaIzZahteva": "$cenaIzZahteva", + "datumUplate": "$datumUplate" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "tezga_refund_document", + "description": "Cancel (storno) or refund an issued receipt. A fiscal receipt is never changed: Tezga issues a new refund document referring to the original and reports it to the Tax Administration. A refund can be partial by line. A refund of a cash payment needs the buyer's identity document number when the original has none. Installed switched off; confirm with the user first.", + "parameters": { + "type": "object", + "properties": { + "vrsta": { + "type": "string", + "enum": [ + "storno", + "refundacija" + ], + "description": "storno = receipt issued by mistake; refundacija = money returned to the buyer." + }, + "documentId": { + "type": "string", + "description": "Tezga UUID of the original receipt." + }, + "externalId": { + "type": "string", + "description": "Or your order number of the original instead of documentId." + }, + "provider": { + "type": "string", + "enum": [ + "woocommerce", + "shopify", + "prestashop", + "opencart", + "magento", + "bigcommerce", + "custom" + ], + "description": "Where the order came from; default custom (orders sent through this API). Use the shop name for orders a connected web shop sent." + }, + "razlog": { + "type": "string", + "description": "Reason, up to 500 characters." + }, + "stavke": { + "type": "array", + "description": "Only for a partial refund: lines to refund.", + "items": { + "type": "object", + "properties": { + "redosled": { + "type": "string", + "description": "Line position on the original, the first is 0." + }, + "kolicina": { + "type": "number", + "description": "Quantity to refund." + } + }, + "required": [ + "redosled", + "kolicina" + ] + } + }, + "kupacIdTip": { + "type": "string", + "description": "Buyer ID document type code of the Tax Administration: 20 ID card (default), 23 domestic passport, 30 foreign passport, 36 EU ID card, 10 PIB, 11 JMBG." + }, + "kupacIdBroj": { + "type": "string", + "description": "Buyer ID document number; needed only to refund a cash payment when the original has no buyer identification." + } + }, + "required": [ + "vrsta" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/integrations/refund", + "bodyMapping": { + "vrsta": "$vrsta", + "documentId": "$documentId", + "externalId": "$externalId", + "provider": "$provider", + "razlog": "$razlog", + "stavke": "$stavke", + "kupacIdTip": "$kupacIdTip", + "kupacIdBroj": "$kupacIdBroj" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "tezga_upsert_customers", + "description": "Create or update 1 to 500 customers in Tezga. A customer is matched by PIB, then e-mail, then name: a match is updated with the fields you send (empty fields do not overwrite), otherwise a new customer is created; nothing is deleted. Returns the outcome per customer (kreiran, izmenjen, bez_izmene, greska). Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "kupci": { + "type": "array", + "description": "Customers to write, 1 to 500.", + "items": { + "type": "object", + "properties": { + "naziv": { + "type": "string", + "description": "Company name or full name." + }, + "pib": { + "type": "string", + "description": "Tax id, 9 digits; first matching key." + }, + "maticniBroj": { + "type": "string", + "description": "Company registration number, 8 digits (needs pib)." + }, + "jbkjs": { + "type": "string", + "description": "Public sector budget user number, 5 digits." + }, + "email": { + "type": "string", + "description": "E-mail; second matching key." + }, + "telefon": { + "type": "string" + }, + "adresa": { + "type": "string" + }, + "mesto": { + "type": "string", + "description": "City." + }, + "postanskiBroj": { + "type": "string", + "description": "Postal code, 5 digits." + } + }, + "required": [ + "naziv" + ] + } + } + }, + "required": [ + "kupci" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/integrations/customers", + "bodyMapping": { + "kupci": "$kupci" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + }, + { + "name": "tezga_upsert_products", + "description": "Create or update 1 to 500 catalogue items. An item is matched by code (sifra): a match gets the name, price and fields you send (the price change goes into the price history), otherwise a new item is created; nothing is deleted and stock is not changed here. Returns the outcome per item. Installed switched off; prices change at the till at once.", + "parameters": { + "type": "object", + "properties": { + "stavke": { + "type": "array", + "description": "Items to write, 1 to 500.", + "items": { + "type": "object", + "properties": { + "sifra": { + "type": "string", + "description": "Item code from your ERP; the matching key." + }, + "naziv": { + "type": "string", + "description": "Name as printed on receipts." + }, + "cenaSaPdv": { + "type": "number", + "description": "Unit price including VAT, in RSD." + }, + "poreskaStopa": { + "type": "string", + "description": "20, 10, 0 or oslobodjeno; omitted keeps the current rate (new items 20)." + }, + "jedinicaMere": { + "type": "string", + "description": "Unit, e.g. kom, kg, l, usluga." + }, + "gtin": { + "type": "string", + "description": "Barcode, 8 to 14 digits." + }, + "aktivan": { + "type": "boolean", + "description": "false hides the item at the till." + } + }, + "required": [ + "sifra", + "naziv", + "cenaSaPdv" + ] + } + } + }, + "required": [ + "stavke" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/integrations/products", + "bodyMapping": { + "stavke": "$stavke" + } + }, + "enabled": false, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/tezga.live.spec.ts b/packages/backend/src/adapters/intl/tezga.live.spec.ts new file mode 100644 index 000000000..f0a2c3f1f --- /dev/null +++ b/packages/backend/src/adapters/intl/tezga.live.spec.ts @@ -0,0 +1,125 @@ +import * as adapter from './tezga.json'; + +/** + * Tezga eKasa public API (OpenAPI 3.1 at https://pos.narbiz.com/api/openapi.json, guide at + * https://tezgafiskal.com/api-za-fiskalizaciju; key tzg_... in the X-Tezga-Api-Key header). + * Reads tax labels and environment, documents and bank transactions; fiscal writes install switched off. + * Live part: TEZGA_API_KEY=tzg_... RUN_TEZGA_LIVE=1 npx jest src/adapters/intl/tezga + * (reads only: tax labels and environment, bank transactions). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'tezga_'; +const WRITES: string[] = ['tezga_issue_receipt', 'tezga_refund_document', 'tezga_upsert_customers', 'tezga_upsert_products']; + +describe('tezga adapter: static spec conformance', () => { + it('sends the key in X-Tezga-Api-Key to the documented host', () => { + expect(a.requiredEnvVars).toEqual(['TEZGA_API_KEY']); + expect(a.connector.baseUrl).toBe('https://pos.narbiz.com'); + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ headerName: 'X-Tezga-Api-Key', apiKey: '{{TEZGA_API_KEY}}' }); + expect(a.envVarMeta?.TEZGA_API_KEY.secret).toBe(true); + const key = new RegExp(a.envVarMeta?.TEZGA_API_KEY.pattern as string); + expect(key.test(`tzg_${'a1'.repeat(24)}`)).toBe(true); + expect(key.test('tzg_short')).toBe(false); + for (const t of a.tools) expect(t.endpointMapping.path.startsWith('/api/integrations/')).toBe(true); + }); + + it('maps the documented routes', () => { + expect(tool('tezga_get_account_setup').endpointMapping).toEqual({ method: 'GET', path: '/api/integrations/tax-labels' }); + expect(tool('tezga_get_document').endpointMapping).toEqual({ method: 'GET', path: '/api/integrations/documents/{document_id}' }); + expect(tool('tezga_find_document').endpointMapping).toEqual({ method: 'GET', path: '/api/integrations/documents', queryParams: { externalId: '$externalId', provider: '$provider' } }); + expect(tool('tezga_list_bank_transactions').endpointMapping).toEqual({ method: 'GET', path: '/api/integrations/transactions', queryParams: { smer: '$smer', since: '$since', limit: '$limit' } }); + expect(tool('tezga_issue_receipt').endpointMapping).toMatchObject({ method: 'POST', path: '/api/integrations/order', queryParams: { provider: 'custom' }, bodyMapping: { externalId: '$externalId', stavke: '$stavke', kupac: '$kupac', nacinPlacanja: '$nacinPlacanja', tok: '$tok' } }); + expect(tool('tezga_issue_receipt').parameters?.required).toEqual(['externalId', 'stavke']); + expect(tool('tezga_refund_document').endpointMapping).toMatchObject({ method: 'POST', path: '/api/integrations/refund', bodyMapping: { vrsta: '$vrsta', documentId: '$documentId', externalId: '$externalId', stavke: '$stavke' } }); + expect(tool('tezga_upsert_customers').endpointMapping).toEqual({ method: 'POST', path: '/api/integrations/customers', bodyMapping: { kupci: '$kupci' } }); + expect(tool('tezga_upsert_products').endpointMapping).toEqual({ method: 'POST', path: '/api/integrations/products', bodyMapping: { stavke: '$stavke' } }); + for (const n of ['tezga_refund_document', 'tezga_upsert_customers', 'tezga_upsert_products']) expect(tool(n).annotations?.destructiveHint).toBe(true); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + for (const t of a.tools) expect(t.endpointMapping.method).not.toBe('DELETE'); + }); + + it('probes with a parameter-free read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe.tool); + expect(probe.endpointMapping.method).toBe('GET'); + expect(probe.parameters?.required ?? []).toEqual([]); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + + +const live = process.env.RUN_TEZGA_LIVE === '1' && process.env.TEZGA_API_KEY ? describe : describe.skip; +live('tezga adapter: live (read-only)', () => { + const headers = { 'X-Tezga-Api-Key': process.env.TEZGA_API_KEY ?? '', Accept: 'application/json' }; + it('reads the environment and tax labels, then bank transactions', async () => { + const setup = (await (await fetch(`${a.connector.baseUrl}/api/integrations/tax-labels`, { headers })).json()) as { okruzenje: string; oznake: unknown[] }; + expect(['produkcija', 'sandbox', 'simulacija', 'nepovezano']).toContain(setup.okruzenje); + expect(Array.isArray(setup.oznake)).toBe(true); + const tx = await fetch(`${a.connector.baseUrl}/api/integrations/transactions?limit=1`, { headers }); + expect(tx.status).toBe(200); + }, 60_000); +}); diff --git a/packages/backend/src/adapters/intl/trybe.json b/packages/backend/src/adapters/intl/trybe.json new file mode 100644 index 000000000..7201c9b08 --- /dev/null +++ b/packages/backend/src/adapters/intl/trybe.json @@ -0,0 +1,409 @@ +{ + "slug": "trybe", + "name": "Trybe", + "aliases": [ + "Trybe UGC", + "jointrybe" + ], + "description": "Trybe performance-based UGC platform for brands: the creator roster and sample obligations, creator performance (earnings, GMV, ad spend, purchases and ROAS across Meta, TikTok and AppLovin) and the videos creators submit. Submission reviews are included, installed switched off.", + "region": "intl", + "category": "marketing-automation", + "icon": "trybe", + "docsUrl": "https://api.jointrybe.com/v1/openapi.json", + "instructions": "## Trybe\nThis connector uses the **Trybe Brand API** (`https://api.jointrybe.com/v1`), the HTTP API Trybe offers brands to pull data about their UGC creators: the creator roster with sample obligations, creator performance (earnings, Trybe conversions and GMV, ad spend, purchases and ROAS on Meta, TikTok and AppLovin) and the content creators submit. Reviewing a submission (approve, reject, request a revision) is included but installed switched off.\n\n**Setup (about 2 minutes)**\n1. API access is a feature of the brand account; if every call answers 403 `feature_not_enabled`, ask your Trybe contact to enable the Brand API for your brand.\n2. Create an API key for your brand in the Trybe brand portal (or ask your Trybe contact for one). It starts with `tk_live_`. Paste it into `TRYBE_API_KEY`.\n3. Install and run `trybe_list_creators` to check the key.\n\nThe key is sent as `Authorization: Bearer tk_live_...` and sees only your brand.\n\n**Workflow**\n- Roster: `trybe_list_creators` (filter `sample_obligation=overdue` for creators who owe a video), then `trybe_get_creator` for one creator's programs and commission rules.\n- Performance: `trybe_get_creator_performance` for a period (default the last 30 complete UTC days). Rank the roster with `sort_by` (earnings, spend, purchase_value...), restrict to one creator with `creator_id`, to one program with `program_id`, or to Meta/TikTok/AppLovin with `platforms`.\n- Content: `trybe_list_submissions` (e.g. `status=pending` for the review queue, `query` to find videos that say a word), then `trybe_get_submission` for one item.\n- Reviews (switched off): `trybe_approve_submission`, `trybe_reject_submission`, `trybe_request_submission_revision` (needs a `comment`). The creator is notified at once and approval affects what they earn, so an admin turns these on deliberately; before each call show the user the submission and the comment and wait for a yes.\n\n**Reading the numbers**: money fields end in `_cents` and are USD cents (`spend_cents` 250000 = 2,500.00 USD). Dates are UTC calendar days `YYYY-MM-DD`; `end_date` cannot be in the future. Sorting by a metric or `active_only=true` caps the window at 90 days. `roas` is purchase value divided by spend, null without spend. A creator's `samples` block of zeros means no samples; `null` means it could not be read on that request, so retry rather than reading it as none.\n\n**Ids**: creators `creator_`, programs `creator_program_`, submissions `submission_`, products `product_`, groups `subgroup_`. Use them exactly as returned. A submission's `trybe_id` (last 8 characters) must appear on the destination URL and ad name of every ad built from it; that is how Trybe attributes ads and pays creators.\n\n**Pagination and limits**: lists return `data`, `has_more` and `next_cursor`. Default 20 rows, at most 100 (`limit`); call again with `after` = `next_cursor` while `has_more` is true. The API reports its rate limit in `RateLimit-*` headers (300 requests per window); 429 `rate_limit_exceeded` means wait and retry. Download links in `asset.url` expire after about 20 minutes.\n\n**Errors**: 401 `invalid_api_key` means a missing or wrong key. 403 `feature_not_enabled` means the Brand API is not enabled for your brand. 400 `validation_error` names the bad parameter (e.g. start_date after end_date). 404 on one id means it does not exist or belongs to another brand.", + "prerequisites": "A Trybe brand account with Brand API access enabled, and a brand API key (tk_live_...).", + "requiredEnvVars": [ + "TRYBE_API_KEY" + ], + "envVarMeta": { + "TRYBE_API_KEY": { + "label": "Brand API key", + "kind": "credential", + "secret": true, + "help": "API key of your brand from the Trybe brand portal or your Trybe contact. It starts with tk_live_ and is sent as a Bearer token.", + "link": "https://api.jointrybe.com/v1/openapi.json", + "pattern": "^tk_[A-Za-z0-9_]+$", + "patternMessage": "Paste only the key, starting with tk_live_, without Bearer or spaces." + } + }, + "verifyHints": { + "401": "Trybe refused the key. Paste the brand API key again (it starts with tk_live_), without the word Bearer.", + "403": "The key works, but the Brand API is not enabled for this brand. Ask your Trybe contact to enable it.", + "429": "Trybe's rate limit was reached. Wait a minute and try again." + }, + "probe": { + "tool": "trybe_list_creators" + }, + "connector": { + "name": "Trybe Brand API", + "type": "REST", + "baseUrl": "https://api.jointrybe.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TRYBE_API_KEY}}" + }, + "headers": { + "Accept": "application/json" + }, + "healthcheckPath": "/v1/creators" + }, + "tools": [ + { + "name": "trybe_list_creators", + "description": "List the active creators of your brand, newest first: id, name, avatar, join date and their sample status at your brand (samples requested, approved, fulfilled, overdue, the sample to act on and the Sample Score). Filter by sample_obligation to find who owes a video. Paginated with cursors.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Rows per page, default 20, at most 100." + }, + "after": { + "type": "string", + "description": "Cursor from next_cursor of the previous page: returns the rows after it. Do not combine with before." + }, + "before": { + "type": "string", + "description": "Cursor from previous_cursor: returns the rows before it. Do not combine with after." + }, + "sample_obligation": { + "type": "string", + "enum": [ + "not_yet_due", + "submitted_awaiting_review", + "needs_resubmit", + "overdue", + "fulfilled", + "none" + ], + "description": "Keep only creators with a sample in this state: overdue (owes a video past the due date), needs_resubmit, not_yet_due, submitted_awaiting_review, fulfilled, or none (no sample ever approved)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/creators", + "queryParams": { + "limit": "$limit", + "after": "$after", + "before": "$before", + "sample_obligation": "$sample_obligation" + } + } + }, + { + "name": "trybe_get_creator", + "description": "Get one creator of your brand by Trybe id: name, avatar, join date, sample status, and the programs they are enrolled in with each program's commission rules (e.g. 10% of GMV plus a flat amount per order).", + "parameters": { + "type": "object", + "properties": { + "creator_id": { + "type": "string", + "description": "Trybe creator id as the API returns it, `creator_` plus a UUID, e.g. creator_8f3e21a0-7c6d-4b1e-9a3f-1b2c3d4e5f60. Take it from trybe_list_creators." + } + }, + "required": [ + "creator_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/creators/{creator_id}" + } + }, + { + "name": "trybe_get_creator_performance", + "description": "Creator performance for a date range: per creator their programs, last activity and metrics (earnings, Trybe conversions and GMV, new and active submissions, ads running their content, ad spend, purchases, purchase value and ROAS). Money is in USD cents. Without dates it covers the last 30 complete UTC days. Sort by a metric to rank the whole roster (window then capped at 90 days).", + "parameters": { + "type": "object", + "properties": { + "start_date": { + "type": "string", + "description": "First day, inclusive, YYYY-MM-DD in UTC. Default 30 complete days ago." + }, + "end_date": { + "type": "string", + "description": "Last day, inclusive, YYYY-MM-DD in UTC. Default yesterday; future dates are rejected." + }, + "creator_id": { + "type": "string", + "description": "Only this creator (`creator_` plus a UUID from trybe_list_creators)." + }, + "sort_by": { + "type": "string", + "enum": [ + "joined_at", + "earnings", + "trybe_gmv", + "trybe_conversions", + "new_submissions", + "active_submissions", + "ads", + "spend", + "purchase_value", + "purchases" + ], + "description": "Row order. joined_at (default) pages newest first; any metric ranks the whole roster highest first." + }, + "program_id": { + "type": "string", + "description": "Only creators enrolled in this program (`creator_program_` plus a UUID)." + }, + "managed_by": { + "type": "string", + "enum": [ + "brand", + "partner" + ], + "description": "partner = creators a partner agency manages; brand = creators the brand manages directly. Omit for both." + }, + "active_only": { + "type": "boolean", + "description": "true drops creators with every metric zero in the window (window then capped at 90 days)." + }, + "platforms": { + "type": "string", + "description": "Ad platforms whose ad data to include, comma-separated from meta, tiktok, applovin (e.g. meta,tiktok). Default: every platform." + }, + "limit": { + "type": "integer", + "description": "Rows per page, default 20, at most 100." + }, + "after": { + "type": "string", + "description": "Cursor from next_cursor of the previous page: returns the rows after it. Do not combine with before." + }, + "before": { + "type": "string", + "description": "Cursor from previous_cursor: returns the rows before it. Do not combine with after." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/creator-performance", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date", + "creator_id": "$creator_id", + "sort_by": "$sort_by", + "program_id": "$program_id", + "managed_by": "$managed_by", + "active_only": "$active_only", + "platforms": "$platforms", + "limit": "$limit", + "after": "$after", + "before": "$before" + } + } + }, + { + "name": "trybe_list_submissions", + "description": "List the content (video or image) creators submitted to your brand: status (pending, approved, rejected, revision_requested), creator, program, products, angles, transcript, all-time ad usage, thumbnail and a download link that expires after about 20 minutes. Filter by status, creator, program, product, angle, transcript language or words the video says or shows.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected", + "revision_requested" + ], + "description": "Review status to keep." + }, + "creator_id": { + "type": "string", + "description": "Only this creator (`creator_` plus a UUID)." + }, + "group_id": { + "type": "string", + "description": "Only the variants of this submission group (`subgroup_` plus a UUID)." + }, + "angle": { + "type": "string", + "description": "Only submissions tagged with this angle name, case-insensitive (e.g. Unboxing & First Impressions)." + }, + "program_id": { + "type": "string", + "description": "Program id `creator_program_` plus a UUID, as shown in programs[].id." + }, + "product_id": { + "type": "string", + "description": "Only submissions tagged with this product (`product_` plus a UUID, from products[].id)." + }, + "transcript_language": { + "type": "string", + "description": "ISO 639-1 language of the transcript, e.g. en or es." + }, + "query": { + "type": "string", + "description": "One phrase of 2 to 100 characters matched against the transcript, on-screen text and the creator's note." + }, + "platforms": { + "type": "string", + "description": "Ad platforms whose ad data to include, comma-separated from meta, tiktok, applovin (e.g. meta,tiktok). Default: every platform." + }, + "limit": { + "type": "integer", + "description": "Rows per page, default 20, at most 100." + }, + "after": { + "type": "string", + "description": "Cursor from next_cursor of the previous page: returns the rows after it. Do not combine with before." + }, + "before": { + "type": "string", + "description": "Cursor from previous_cursor: returns the rows before it. Do not combine with after." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/submissions", + "queryParams": { + "status": "$status", + "creator_id": "$creator_id", + "group_id": "$group_id", + "angle": "$angle", + "program_id": "$program_id", + "product_id": "$product_id", + "transcript_language": "$transcript_language", + "query": "$query", + "platforms": "$platforms", + "limit": "$limit", + "after": "$after", + "before": "$before" + } + } + }, + { + "name": "trybe_get_submission", + "description": "Get one submission by id with its status, creator, program, products, transcript, angles, review comment, ad usage, the trybe_id that ads must carry, and a fresh download link (expires after about 20 minutes, fetch again rather than storing it).", + "parameters": { + "type": "object", + "properties": { + "submission_id": { + "type": "string", + "description": "Submission id as the API returns it, `submission_` plus a UUID. Take it from trybe_list_submissions." + }, + "platforms": { + "type": "string", + "description": "Ad platforms whose ad data to include, comma-separated from meta, tiktok, applovin (e.g. meta,tiktok). Default: every platform." + } + }, + "required": [ + "submission_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/submissions/{submission_id}", + "queryParams": { + "platforms": "$platforms" + } + } + }, + { + "name": "trybe_approve_submission", + "description": "Approve a submission. The creator is notified and an approved submission can count toward their earnings and sample obligation. Installed switched off; confirm with the user first.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "submission_id": { + "type": "string", + "description": "Submission id as the API returns it, `submission_` plus a UUID. Take it from trybe_list_submissions." + }, + "comment": { + "type": "string", + "description": "Optional note to the creator, shown with the review." + } + }, + "required": [ + "submission_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/submissions/{submission_id}/approve", + "bodyMapping": { + "comment": "$comment" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "trybe_reject_submission", + "description": "Reject a submission. The creator is notified and the sample may turn into needs_resubmit. Installed switched off; confirm with the user first.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "submission_id": { + "type": "string", + "description": "Submission id as the API returns it, `submission_` plus a UUID. Take it from trybe_list_submissions." + }, + "comment": { + "type": "string", + "description": "Optional note to the creator, shown with the review." + } + }, + "required": [ + "submission_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/submissions/{submission_id}/reject", + "bodyMapping": { + "comment": "$comment" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "trybe_request_submission_revision", + "description": "Send a submission back to the creator with a revision request; the comment tells them what to change. Installed switched off; confirm with the user first.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "submission_id": { + "type": "string", + "description": "Submission id as the API returns it, `submission_` plus a UUID. Take it from trybe_list_submissions." + }, + "comment": { + "type": "string", + "description": "Message to the creator explaining what to change. Required." + } + }, + "required": [ + "submission_id", + "comment" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/submissions/{submission_id}/request-revision", + "bodyMapping": { + "comment": "$comment" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/trybe.live.spec.ts b/packages/backend/src/adapters/intl/trybe.live.spec.ts new file mode 100644 index 000000000..17e3ff01d --- /dev/null +++ b/packages/backend/src/adapters/intl/trybe.live.spec.ts @@ -0,0 +1,129 @@ +import * as adapter from './trybe.json'; + +/** + * Trybe Brand API v1 (OpenAPI 3.1 at https://api.jointrybe.com/v1/openapi.json; base URL + * https://api.jointrybe.com/v1; brand API key sent as Authorization: Bearer tk_live_...). + * Reads creators, creator performance and submissions; the three review actions install switched off. + * Live part: TRYBE_API_KEY=tk_live_... RUN_TRYBE_LIVE=1 npx jest src/adapters/intl/trybe + * (reads only: creators, creator performance, submissions). + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: unknown; headers?: Record }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + responseMapping?: unknown; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe: { tool: string; params?: Record }; + instructions: string; + connector: { baseUrl: string; authType: string; authConfig?: Record; headers?: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// Every argument a tool reads: {path} segments, "$arg" values and "${arg}" inside strings. +const used = (t: Tool) => { + const m = t.endpointMapping; + const out = new Set(); + for (const [, p] of m.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) out.add(p); + const walk = (v: unknown) => { + if (typeof v === 'string') { + if (v.startsWith('$') && !v.includes('${')) out.add(v.slice(1)); + for (const [, p] of v.matchAll(/\$\{(\w+)\}/g)) out.add(p); + } else if (v && typeof v === 'object') Object.values(v as Record).forEach(walk); + }; + walk(m.queryParams ?? {}); + walk(m.bodyMapping ?? {}); + walk(m.headers ?? {}); + return out; +}; +const PREFIX = 'trybe_'; +const WRITES: string[] = ['trybe_approve_submission', 'trybe_reject_submission', 'trybe_request_submission_revision']; + +describe('trybe adapter: static spec conformance', () => { + it('sends the brand key as Bearer to the documented base URL', () => { + expect(a.requiredEnvVars).toEqual(['TRYBE_API_KEY']); + expect(a.connector.baseUrl).toBe('https://api.jointrybe.com'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig).toEqual({ token: '{{TRYBE_API_KEY}}' }); + expect(a.envVarMeta?.TRYBE_API_KEY.secret).toBe(true); + expect(new RegExp(a.envVarMeta?.TRYBE_API_KEY.pattern as string).test('tk_live_abc123')).toBe(true); + for (const t of a.tools) expect(t.endpointMapping.path.startsWith('/v1/')).toBe(true); + }); + + it('maps the documented routes', () => { + expect(tool('trybe_list_creators').endpointMapping).toMatchObject({ method: 'GET', path: '/v1/creators', queryParams: { limit: '$limit', after: '$after', before: '$before', sample_obligation: '$sample_obligation' } }); + expect(tool('trybe_get_creator').endpointMapping).toMatchObject({ method: 'GET', path: '/v1/creators/{creator_id}' }); + expect(tool('trybe_get_creator_performance').endpointMapping).toMatchObject({ + method: 'GET', + path: '/v1/creator-performance', + queryParams: { start_date: '$start_date', end_date: '$end_date', creator_id: '$creator_id', sort_by: '$sort_by', program_id: '$program_id', managed_by: '$managed_by', active_only: '$active_only', platforms: '$platforms', limit: '$limit', after: '$after', before: '$before' }, + }); + expect(tool('trybe_list_submissions').endpointMapping).toMatchObject({ method: 'GET', path: '/v1/submissions', queryParams: { status: '$status', creator_id: '$creator_id', query: '$query', transcript_language: '$transcript_language' } }); + expect(tool('trybe_get_submission').endpointMapping).toMatchObject({ method: 'GET', path: '/v1/submissions/{submission_id}', queryParams: { platforms: '$platforms' } }); + expect(tool('trybe_approve_submission').endpointMapping).toMatchObject({ method: 'POST', path: '/v1/submissions/{submission_id}/approve', bodyMapping: { comment: '$comment' } }); + expect(tool('trybe_reject_submission').endpointMapping).toMatchObject({ method: 'POST', path: '/v1/submissions/{submission_id}/reject', bodyMapping: { comment: '$comment' } }); + expect(tool('trybe_request_submission_revision').endpointMapping).toMatchObject({ method: 'POST', path: '/v1/submissions/{submission_id}/request-revision', bodyMapping: { comment: '$comment' } }); + expect(tool('trybe_request_submission_revision').parameters?.required).toEqual(['submission_id', 'comment']); + }); + + it('every tool is prefixed, every declared parameter reaches the request and path parameters are required', () => { + for (const t of a.tools) { + expect(t.name.startsWith(PREFIX)).toBe(true); + const u = used(t); + const props = Object.keys(t.parameters?.properties ?? {}); + for (const p of props) expect(`${t.name}:${p}:${u.has(p)}`).toBe(`${t.name}:${p}:true`); + for (const p of u) expect(`${t.name}:${p}:${props.includes(p)}`).toBe(`${t.name}:${p}:true`); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) expect(t.parameters?.required).toContain(p); + } + }); + + it('writes are annotated and installed switched off; reads are GET or marked read-only', () => { + const off = a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort(); + expect(off).toEqual([...WRITES].sort()); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + if (t.endpointMapping.method !== 'GET') expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); + for (const t of a.tools) expect(t.endpointMapping.method).not.toBe('DELETE'); + }); + + it('probes with a parameter-free read tool and documents every tool in the instructions', () => { + const probe = tool(a.probe.tool); + expect(probe.endpointMapping.method).toBe('GET'); + expect(probe.parameters?.required ?? []).toEqual([]); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); +}); + + +const live = process.env.RUN_TRYBE_LIVE === '1' && process.env.TRYBE_API_KEY ? describe : describe.skip; +live('trybe adapter: live (read-only)', () => { + const headers = { Authorization: `Bearer ${process.env.TRYBE_API_KEY ?? ''}`, Accept: 'application/json' }; + const base = `${a.connector.baseUrl}/v1`; + it('reads creators, creator performance and submissions', async () => { + const creators = (await (await fetch(`${base}/creators?limit=1`, { headers })).json()) as { object: string; data: unknown[] }; + expect(creators.object).toBe('list'); + expect(Array.isArray(creators.data)).toBe(true); + const perf = (await (await fetch(`${base}/creator-performance?limit=1`, { headers })).json()) as { object: string; data: unknown[] }; + expect(perf.object).toBe('list'); + const subs = (await (await fetch(`${base}/submissions?limit=1`, { headers })).json()) as { object: string; data: unknown[] }; + expect(Array.isArray(subs.data)).toBe(true); + }, 60_000); +}); diff --git a/packages/frontend/public/logos/connectors/blockcypher.svg b/packages/frontend/public/logos/connectors/blockcypher.svg new file mode 100644 index 000000000..148f404da --- /dev/null +++ b/packages/frontend/public/logos/connectors/blockcypher.svg @@ -0,0 +1 @@ +BC diff --git a/packages/frontend/public/logos/connectors/checko.svg b/packages/frontend/public/logos/connectors/checko.svg new file mode 100644 index 000000000..294c23c36 --- /dev/null +++ b/packages/frontend/public/logos/connectors/checko.svg @@ -0,0 +1 @@ +Ch diff --git a/packages/frontend/public/logos/connectors/falco.svg b/packages/frontend/public/logos/connectors/falco.svg new file mode 100644 index 000000000..d882265ee --- /dev/null +++ b/packages/frontend/public/logos/connectors/falco.svg @@ -0,0 +1 @@ +Falco diff --git a/packages/frontend/public/logos/connectors/helius.svg b/packages/frontend/public/logos/connectors/helius.svg new file mode 100644 index 000000000..bbcb27ce8 --- /dev/null +++ b/packages/frontend/public/logos/connectors/helius.svg @@ -0,0 +1 @@ +HE diff --git a/packages/frontend/public/logos/connectors/invoice123.svg b/packages/frontend/public/logos/connectors/invoice123.svg new file mode 100644 index 000000000..e37376314 --- /dev/null +++ b/packages/frontend/public/logos/connectors/invoice123.svg @@ -0,0 +1 @@ +123 diff --git a/packages/frontend/public/logos/connectors/medmis.svg b/packages/frontend/public/logos/connectors/medmis.svg new file mode 100644 index 000000000..da245f034 --- /dev/null +++ b/packages/frontend/public/logos/connectors/medmis.svg @@ -0,0 +1 @@ +MEDMIS diff --git a/packages/frontend/public/logos/connectors/netskope.svg b/packages/frontend/public/logos/connectors/netskope.svg new file mode 100644 index 000000000..63c603cf6 --- /dev/null +++ b/packages/frontend/public/logos/connectors/netskope.svg @@ -0,0 +1 @@ +NS diff --git a/packages/frontend/public/logos/connectors/noon-payments.svg b/packages/frontend/public/logos/connectors/noon-payments.svg new file mode 100644 index 000000000..b9656c4ac --- /dev/null +++ b/packages/frontend/public/logos/connectors/noon-payments.svg @@ -0,0 +1 @@ +noon diff --git a/packages/frontend/public/logos/connectors/sap-c4c.svg b/packages/frontend/public/logos/connectors/sap-c4c.svg new file mode 100644 index 000000000..8449cbfeb --- /dev/null +++ b/packages/frontend/public/logos/connectors/sap-c4c.svg @@ -0,0 +1 @@ +SAP \ No newline at end of file diff --git a/packages/frontend/public/logos/connectors/tabby.svg b/packages/frontend/public/logos/connectors/tabby.svg new file mode 100644 index 000000000..39aaa0c7f --- /dev/null +++ b/packages/frontend/public/logos/connectors/tabby.svg @@ -0,0 +1 @@ +tabby diff --git a/packages/frontend/public/logos/connectors/tamara.svg b/packages/frontend/public/logos/connectors/tamara.svg new file mode 100644 index 000000000..136164a00 --- /dev/null +++ b/packages/frontend/public/logos/connectors/tamara.svg @@ -0,0 +1 @@ +tamara diff --git a/packages/frontend/public/logos/connectors/tbank.svg b/packages/frontend/public/logos/connectors/tbank.svg new file mode 100644 index 000000000..090eaef43 --- /dev/null +++ b/packages/frontend/public/logos/connectors/tbank.svg @@ -0,0 +1 @@ +T diff --git a/packages/frontend/public/logos/connectors/tezga.svg b/packages/frontend/public/logos/connectors/tezga.svg new file mode 100644 index 000000000..70cbb5db2 --- /dev/null +++ b/packages/frontend/public/logos/connectors/tezga.svg @@ -0,0 +1 @@ +Tezga diff --git a/packages/frontend/public/logos/connectors/trybe.svg b/packages/frontend/public/logos/connectors/trybe.svg new file mode 100644 index 000000000..1af6b4251 --- /dev/null +++ b/packages/frontend/public/logos/connectors/trybe.svg @@ -0,0 +1 @@ +Trybe diff --git a/packages/frontend/public/logos/connectors/xovi.svg b/packages/frontend/public/logos/connectors/xovi.svg new file mode 100644 index 000000000..f8ae2c7fd --- /dev/null +++ b/packages/frontend/public/logos/connectors/xovi.svg @@ -0,0 +1 @@ +XOVI From c7e424a314c5bfabec68e504492bc96fea53c31d Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:56:16 +0200 Subject: [PATCH 2/6] Add missing tools to 19 catalog adapters Endpoints that are documented by each vendor and needed in practice, but were not covered yet. Writes that move money, delete or reach a third party install switched off. - weclapp: party e-mail addresses, open items (list, payment state off), current user, property translations, stock movements, production orders; reference data also covers custom attribute definitions and serial numbers - Klardaten: VAT filings and data, extended clients, instance details, required services, metrics, logs, audit logs and webhooks (secrets removed) - JTL-FFN: announce an inbound delivery (off) - HR WORKS: working-time status, onboarding documents, delete working times (off) - Gmail: download an attachment - Etsy: shop transactions, listings by ids; every tool named in instructions - Telegram Bot: sticker sets; business_connection_id on every send tool - Zenhub: the generic GraphQL mutation and subscription tools install off, the query tool stays on - Weeek: remove a task from a project - Daftra: update and delete journals (off), journal categories, treasuries, invoice draft status (off) - Fatture in Cloud: F24 tax payments (asks for taxes:r) - Fortnox: create invoice (off), file attachments, inbox; the inbox scope is documented as opt-in so existing integrations can still authorize - Sportmonks: pre-match odds by market and by bookmaker - Tiendanube: product images - Magento: low-stock items and the guest cart flow (cart and order writes off) - Exercise.com: assessments, appointment packages, group calendars, user workout exercises, purchased items - Chatwoot: add, update and remove agents (off) - Printful: printfiles, webhooks - amoCRM: loss reasons Also: Trendyol instructions name all tools, and stray em dashes removed. --- .../backend/src/adapters/br/tiendanube.json | 192 ++++++++- .../src/adapters/br/tiendanube.live.spec.ts | 32 +- packages/backend/src/adapters/de/hrworks.json | 115 ++++- .../src/adapters/de/hrworks.live.spec.ts | 37 ++ packages/backend/src/adapters/de/jtl-ffn.json | 58 ++- .../src/adapters/de/jtl-ffn.live.spec.ts | 17 +- .../backend/src/adapters/de/klardaten.json | 278 ++++++++++++- .../src/adapters/de/klardaten.live.spec.ts | 48 +++ packages/backend/src/adapters/de/weclapp.json | 332 ++++++++++++++- .../adapters/de/weclapp.write-tools.spec.ts | 56 +++ .../backend/src/adapters/intl/amocrm.json | 14 +- .../src/adapters/intl/amocrm.live.spec.ts | 15 +- .../backend/src/adapters/intl/chatwoot.json | 141 ++++++- .../src/adapters/intl/chatwoot.live.spec.ts | 22 +- .../backend/src/adapters/intl/daftra.json | 204 ++++++++- .../src/adapters/intl/daftra.live.spec.ts | 38 +- packages/backend/src/adapters/intl/etsy.json | 82 +++- .../src/adapters/intl/etsy.live.spec.ts | 41 ++ .../src/adapters/intl/exercise-com.json | 160 ++++++- .../adapters/intl/exercise-com.live.spec.ts | 32 ++ packages/backend/src/adapters/intl/gmail.json | 28 +- .../src/adapters/intl/gmail.live.spec.ts | 25 ++ .../backend/src/adapters/intl/magento.json | 393 +++++++++++++++++- .../src/adapters/intl/magento.live.spec.ts | 64 +++ .../backend/src/adapters/intl/printful.json | 65 ++- .../src/adapters/intl/printful.live.spec.ts | 21 +- .../backend/src/adapters/intl/sportmonks.json | 126 +++++- .../src/adapters/intl/sportmonks.live.spec.ts | 11 + .../src/adapters/intl/telegram-bot.json | 54 ++- .../adapters/intl/telegram-bot.live.spec.ts | 33 ++ .../backend/src/adapters/intl/trendyol.json | 2 +- .../src/adapters/intl/trendyol.live.spec.ts | 3 +- packages/backend/src/adapters/intl/weeek.json | 31 +- .../src/adapters/intl/weeek.live.spec.ts | 10 + .../backend/src/adapters/intl/zenhub.json | 8 +- .../src/adapters/intl/zenhub.live.spec.ts | 28 +- .../src/adapters/it/fatture-in-cloud.json | 49 ++- .../adapters/it/fatture-in-cloud.live.spec.ts | 10 +- packages/backend/src/adapters/se/fortnox.json | 182 +++++++- .../src/adapters/se/fortnox.live.spec.ts | 33 ++ 40 files changed, 3003 insertions(+), 87 deletions(-) diff --git a/packages/backend/src/adapters/br/tiendanube.json b/packages/backend/src/adapters/br/tiendanube.json index fc4af92bc..ab4b7fa97 100644 --- a/packages/backend/src/adapters/br/tiendanube.json +++ b/packages/backend/src/adapters/br/tiendanube.json @@ -1,8 +1,8 @@ { "slug": "tiendanube", "name": "Tiendanube / Nuvemshop", - "description": "Tiendanube (Nuvemshop in Brazil) online stores: store info, products, variants and stock, categories, orders and fulfillment orders, customers, abandoned checkouts, coupons and webhooks. REST API 2025-03 with an app access token.", - "instructions": "## Tiendanube / Nuvemshop\nThis connector talks to the Tiendanube REST API (Nuvemshop in Brazil, same platform and same API), version `2025-03`, at `https://api.tiendanube.com/2025-03/`. Every request goes to one store.\n\n## Credentials\nTiendanube has no API keys in the store admin: access is granted to an app. To get a token for your own store:\n1. Register at the Tiendanube or Nuvemshop Partners portal and create an app under **Apps**. Choose the scopes you need (`read_products`, `write_products`, `read_orders`, `write_orders`, `read_customers`, `read_coupons`...); a write scope includes its read scope.\n2. Install the app on your store by opening `https://www.tiendanube.com/apps//authorize`. You are redirected to the app's redirect URL with a `code` (valid 5 minutes).\n3. Exchange it: `POST https://www.tiendanube.com/apps/authorize/token` with `client_id`, `client_secret`, `grant_type=authorization_code` and `code`. The answer has `access_token` (put it in `TIENDANUBE_ACCESS_TOKEN`) and `user_id`, which is the store id (put it in `TIENDANUBE_STORE_ID`).\nTokens do not expire; they stop working when a new one is issued for the same app or the app is uninstalled.\n\nThe token is sent as `Authorization: Bearer `. Older integrations use the legacy header `Authentication: bearer `, which the API still accepts; a token works with either. Tiendanube also requires a `User-Agent` with the app name and a contact; without it the API answers 400. The connector sends `AnythingMCP (support@anythingmcp.com)`.\n\n## Start here\n`tiendanube_get_store` returns the store name, languages, currency and plan. Then `tiendanube_list_orders` or `tiendanube_list_products`.\n\n## Pagination and limits\n- Lists return 30 items by default; `per_page` goes up to 200, `page` starts at 1. The total count is in the `x-total-count` header and the next page in the `Link` header (both returned in `_headers` / `_pagination`).\n- Order queries stop at 10,000 results: narrow them with `created_at_min` / `created_at_max` or a status filter.\n- Rate limit: a leaky bucket of 40 requests draining at 2 per second per store and app (10x on Next and Evolution plans). Over it the API returns 429.\n\n## Data conventions\n- Ids are integers. Product variants have their own ids; stock and price live on the variant, not on the product.\n- Product and category names, descriptions and handles are objects keyed by language (`{\"es\": \"...\", \"pt\": \"...\"}`). When creating, pass such an object or a plain string, which is applied to every language.\n- Money values are strings with two decimals (`\"5400.00\"`) in the store currency (`currency` on the order).\n- Dates are ISO 8601 with offset, e.g. `2026-09-01T00:00:00-03:00`.\n- Orders have three separate states: `status` (open, closed, cancelled), `payment_status` (pending, authorized, paid, abandoned, refunded, voided) and `shipping_status` (unpacked, unfulfilled, fulfilled).\n\n## Pitfalls\n- `published` and `visibility` on products cannot be sent together (422); this connector uses `visibility`.\n- A 402 means the store's API access is suspended for an unpaid subscription.\n- `tiendanube_cancel_order` restocks and e-mails the customer by default.", + "description": "Tiendanube (Nuvemshop in Brazil) online stores: store info, products, variants and stock, product images, categories, orders and fulfillment orders, customers, abandoned checkouts, coupons and webhooks. REST API 2025-03 with an app access token.", + "instructions": "## Tiendanube / Nuvemshop\nThis connector talks to the Tiendanube REST API (Nuvemshop in Brazil, same platform and same API), version `2025-03`, at `https://api.tiendanube.com/2025-03/`. Every request goes to one store.\n\n## Credentials\nTiendanube has no API keys in the store admin: access is granted to an app. To get a token for your own store:\n1. Register at the Tiendanube or Nuvemshop Partners portal and create an app under **Apps**. Choose the scopes you need (`read_products`, `write_products`, `read_orders`, `write_orders`, `read_customers`, `read_coupons`...); a write scope includes its read scope.\n2. Install the app on your store by opening `https://www.tiendanube.com/apps//authorize`. You are redirected to the app's redirect URL with a `code` (valid 5 minutes).\n3. Exchange it: `POST https://www.tiendanube.com/apps/authorize/token` with `client_id`, `client_secret`, `grant_type=authorization_code` and `code`. The answer has `access_token` (put it in `TIENDANUBE_ACCESS_TOKEN`) and `user_id`, which is the store id (put it in `TIENDANUBE_STORE_ID`).\nTokens do not expire; they stop working when a new one is issued for the same app or the app is uninstalled.\n\nThe token is sent as `Authorization: Bearer `. Older integrations use the legacy header `Authentication: bearer `, which the API still accepts; a token works with either. Tiendanube also requires a `User-Agent` with the app name and a contact; without it the API answers 400. The connector sends `AnythingMCP (support@anythingmcp.com)`.\n\n## Start here\n`tiendanube_get_store` returns the store name, languages, currency and plan. Then `tiendanube_list_orders` or `tiendanube_list_products`.\n\n## Pagination and limits\n- Lists return 30 items by default; `per_page` goes up to 200, `page` starts at 1. The total count is in the `x-total-count` header and the next page in the `Link` header (both returned in `_headers` / `_pagination`).\n- Order queries stop at 10,000 results: narrow them with `created_at_min` / `created_at_max` or a status filter.\n- Rate limit: a leaky bucket of 40 requests draining at 2 per second per store and app (10x on Next and Evolution plans). Over it the API returns 429.\n\n## Data conventions\n- Ids are integers. Product variants have their own ids; stock and price live on the variant, not on the product.\n- Product and category names, descriptions and handles are objects keyed by language (`{\"es\": \"...\", \"pt\": \"...\"}`). When creating, pass such an object or a plain string, which is applied to every language.\n- Money values are strings with two decimals (`\"5400.00\"`) in the store currency (`currency` on the order).\n- Dates are ISO 8601 with offset, e.g. `2026-09-01T00:00:00-03:00`.\n- Orders have three separate states: `status` (open, closed, cancelled), `payment_status` (pending, authorized, paid, abandoned, refunded, voided) and `shipping_status` (unpacked, unfulfilled, fulfilled).\n\n## Pitfalls\n- `published` and `visibility` on products cannot be sent together (422); this connector uses `visibility`.\n- A 402 means the store's API access is suspended for an unpaid subscription.\n- `tiendanube_cancel_order` restocks and e-mails the customer by default.\n\n## Tools\n- Store: `tiendanube_get_store`.\n- Products: `tiendanube_list_products`, `tiendanube_get_product`, `tiendanube_get_product_by_sku`, `tiendanube_create_product`, `tiendanube_update_product`, `tiendanube_delete_product`, `tiendanube_list_categories`.\n- Variants, prices and stock: `tiendanube_list_variants`, `tiendanube_update_variant`, `tiendanube_update_stock`.\n- Product images: `tiendanube_list_product_images` gives the image ids and positions; `tiendanube_add_product_image` adds one from a URL or a Base64 file, `tiendanube_update_product_image` reorders it (position 1 is the main image), changes its alt text or swaps the picture, and `tiendanube_delete_product_image` (installed switched off) removes it. Image writes change the live store: confirm with the user first.\n- Orders: `tiendanube_list_orders`, `tiendanube_get_order`, `tiendanube_list_fulfillment_orders`, `tiendanube_close_order`, `tiendanube_open_order`, `tiendanube_cancel_order`.\n- Customers and marketing: `tiendanube_list_customers`, `tiendanube_get_customer`, `tiendanube_list_abandoned_checkouts`, `tiendanube_list_coupons`, `tiendanube_list_webhooks`.", "prerequisites": "Needs an access token from a Tiendanube/Nuvemshop app installed on your store: create the app in the Partners portal, install it, and exchange the code for a token. The store id is the `user_id` returned with the token.", "region": "br", "category": "e-commerce", @@ -269,6 +269,194 @@ "bodyMapping": { "action": "$action", "value": "$value", "id": "$variant_id" } } }, + { + "name": "tiendanube_list_product_images", + "description": "List the images of one product with image id, URL (src), position (1 is the main image) and alt text. Use it to get an image id before changing or removing an image.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "Product id." + }, + "since_id": { + "type": "integer", + "description": "Only images with an id after this one." + }, + "src": { + "type": "string", + "description": "Only the image with this URL." + }, + "position": { + "type": "integer", + "description": "Only the image at this position." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to return, e.g. id,src,position." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "per_page": { + "type": "integer", + "description": "Results per page, up to 200." + } + }, + "required": [ + "product_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/products/{product_id}/images", + "queryParams": { + "since_id": "$since_id", + "src": "$src", + "position": "$position", + "fields": "$fields", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "tiendanube_add_product_image", + "description": "Add an image to a product, either from a public URL (src) or from a Base64-encoded file (attachment with filename); send one of the two. position 1 makes it the main image. A product holds at most 250 images. Changes the live store: show the product and image to the user and get a yes first.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "Product id." + }, + "src": { + "type": "string", + "description": "Public URL of the image, e.g. https://example.com/photo.jpg." + }, + "attachment": { + "type": "string", + "description": "The image encoded as a Base64 string, instead of src." + }, + "filename": { + "type": "string", + "description": "File name for attachment, e.g. photo.jpg." + }, + "position": { + "type": "integer", + "description": "Position in the product's image list; 1 is the main image. Omit to add it at the end." + }, + "alt": { + "type": [ + "object", + "string" + ], + "description": "Alt text for search engines and screen readers. Like other texts it can be an object keyed by language, e.g. {\"es\": \"Remera azul\", \"pt\": \"Camiseta azul\"}; send it in the shape tiendanube_list_product_images returns it." + } + }, + "required": [ + "product_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/products/{product_id}/images", + "bodyMapping": { + "src": "$src", + "attachment": "$attachment", + "filename": "$filename", + "position": "$position", + "alt": "$alt" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "tiendanube_update_product_image", + "description": "Change an existing product image: its position (1 makes it the main image), its alt text, or replace the picture with a new URL while keeping the image id. Only the fields passed change. Show the change to the user and get a yes first.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "Product id." + }, + "image_id": { + "type": "integer", + "description": "Image id from tiendanube_list_product_images." + }, + "position": { + "type": "integer", + "description": "New position; 1 is the main image." + }, + "alt": { + "type": [ + "object", + "string" + ], + "description": "Alt text for search engines and screen readers. Like other texts it can be an object keyed by language, e.g. {\"es\": \"Remera azul\", \"pt\": \"Camiseta azul\"}; send it in the shape tiendanube_list_product_images returns it." + }, + "src": { + "type": "string", + "description": "Public URL of a replacement picture." + } + }, + "required": [ + "product_id", + "image_id" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/products/{product_id}/images/{image_id}", + "bodyMapping": { + "position": "$position", + "alt": "$alt", + "src": "$src" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "tiendanube_delete_product_image", + "enabled": false, + "description": "Remove one image from a product. This cannot be undone; the picture has to be uploaded again to restore it. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "Product id." + }, + "image_id": { + "type": "integer", + "description": "Image id from tiendanube_list_product_images." + } + }, + "required": [ + "product_id", + "image_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/products/{product_id}/images/{image_id}" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, { "name": "tiendanube_list_categories", "description": "List the store's product categories with names per language, handle, parent and subcategory ids. Use the ids to filter products or assign categories.", diff --git a/packages/backend/src/adapters/br/tiendanube.live.spec.ts b/packages/backend/src/adapters/br/tiendanube.live.spec.ts index 5a0c4e0c5..a174692b7 100644 --- a/packages/backend/src/adapters/br/tiendanube.live.spec.ts +++ b/packages/backend/src/adapters/br/tiendanube.live.spec.ts @@ -1,11 +1,13 @@ import * as adapter from './tiendanube.json'; const a = adapter as unknown as { + instructions: string; requiredEnvVars: string[]; connector: { baseUrl: string; authType: string; authConfig: Record; headers: Record }; probe: { tool: string }; tools: Array<{ name: string; - parameters?: { properties?: Record }; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: Record }; annotations?: Record; }>; @@ -47,4 +49,32 @@ describe('tiendanube adapter: static spec conformance', () => { expect(body.includes('published') && body.includes('visibility')).toBe(false); } }); + + it('manages product images on the documented routes; removing one installs switched off', () => { + const t = (n: string) => a.tools.find((x) => x.name === n)!; + expect(t('tiendanube_list_product_images').endpointMapping).toMatchObject({ method: 'GET', path: '/products/{product_id}/images' }); + expect(t('tiendanube_add_product_image').endpointMapping).toEqual({ + method: 'POST', + path: '/products/{product_id}/images', + bodyMapping: { src: '$src', attachment: '$attachment', filename: '$filename', position: '$position', alt: '$alt' }, + }); + expect(t('tiendanube_update_product_image').endpointMapping).toEqual({ + method: 'PUT', + path: '/products/{product_id}/images/{image_id}', + bodyMapping: { position: '$position', alt: '$alt', src: '$src' }, + }); + expect(t('tiendanube_delete_product_image').endpointMapping).toEqual({ method: 'DELETE', path: '/products/{product_id}/images/{image_id}' }); + expect(t('tiendanube_delete_product_image').enabled).toBe(false); + expect(t('tiendanube_delete_product_image').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + for (const n of ['tiendanube_add_product_image', 'tiendanube_update_product_image']) expect(t(n).annotations?.readOnlyHint).toBe(false); + expect(t('tiendanube_update_product_image').parameters?.required).toEqual(['product_id', 'image_id']); + }); + + it('path parameters are required and every tool is named in the instructions', () => { + for (const t of a.tools) { + for (const [, p] of t.endpointMapping.path.matchAll(/\{(\w+)\}/g)) expect(`${t.name}:${(t.parameters?.required ?? []).includes(p)}`).toBe(`${t.name}:true`); + expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + } + expect(JSON.stringify(a)).not.toMatch(/\u2014/); + }); }); diff --git a/packages/backend/src/adapters/de/hrworks.json b/packages/backend/src/adapters/de/hrworks.json index da513057e..9674f7d52 100644 --- a/packages/backend/src/adapters/de/hrworks.json +++ b/packages/backend/src/adapters/de/hrworks.json @@ -2,7 +2,7 @@ "slug": "hrworks", "name": "HR WORKS", "description": "Access HR WORKS, the German HR/payroll platform: employees, absences, sick leaves, remote work (home office), working times, time accounts, wage payments, personnel files, projects, applicants, organization units, holidays and cost centers via the v2 REST API.", - "instructions": "This connector uses the HR WORKS v2 REST API (https://developers.hrworks.de).\n\n**Authentication — automatic token refresh**: HR WORKS issues short-lived tokens (~15 minutes). This connector uses the `LOGIN_TOKEN` flow: it POSTs `{accessKey, secretAccessKey}` to `/v2/authentication`, reads the returned `token`, sends it as `Authorization: Bearer `, and refreshes it automatically before expiry (and re-logs-in on a 401). You never handle tokens manually.\n\n**Setup**: create an API key pair in HR WORKS (Administration → API) and set:\n- `HRWORKS_ACCESS_KEY` = your access key\n- `HRWORKS_SECRET_ACCESS_KEY` = your secret access key\n\n**Sandbox**: HR WORKS provides a demo environment at `https://api.demo-hrworks.de`. To test against it, change the connector's Base URL and the `loginUrl` in the auth config to the demo host.\n\n**Identifiers**: most person-related endpoints accept either the HR WORKS personnel number or the HR WORKS username. Use the optional `personIdentifierType` / `usePersonnelNumbers` parameters when the identifier you have on hand is not the default.\n\n**Date intervals**: most listing endpoints (absences, sick leaves, working times, remote work) require ISO `YYYY-MM-DD` `beginDate`/`endDate` — maximum span is one year (or 31 days if `interval=days`). Working times are stricter: at most 31 days touching at most two calendar months per call.\n\n**Pagination**: page size is 50; the API returns a `Link` header with next/prev URLs. Pass `page` to step through results.\n\n**Rate limit**: HR WORKS imposes per-tenant rate limits — see their developer portal for details.", + "instructions": "This connector uses the HR WORKS v2 REST API (https://developers.hrworks.de).\n\n**Authentication: automatic token refresh**: HR WORKS issues short-lived tokens (~15 minutes). This connector uses the `LOGIN_TOKEN` flow: it POSTs `{accessKey, secretAccessKey}` to `/v2/authentication`, reads the returned `token`, sends it as `Authorization: Bearer `, and refreshes it automatically before expiry (and re-logs-in on a 401). You never handle tokens manually.\n\n**Setup**: create an API key pair in HR WORKS (Administration → API) and set:\n- `HRWORKS_ACCESS_KEY` = your access key\n- `HRWORKS_SECRET_ACCESS_KEY` = your secret access key\n\n**Sandbox**: HR WORKS provides a demo environment at `https://api.demo-hrworks.de`. To test against it, change the connector's Base URL and the `loginUrl` in the auth config to the demo host.\n\n**Identifiers**: most person-related endpoints accept either the HR WORKS personnel number or the HR WORKS username. Use the optional `personIdentifierType` / `usePersonnelNumbers` parameters when the identifier you have on hand is not the default.\n\n**Date intervals**: most listing endpoints (absences, sick leaves, working times, remote work) require ISO `YYYY-MM-DD` `beginDate`/`endDate`; the maximum span is one year (or 31 days if `interval=days`). Working times are stricter: at most 31 days touching at most two calendar months per call.\n\n**Pagination**: page size is 50; the API returns a `Link` header with next/prev URLs. Pass `page` to step through results.\n\n**Rate limit**: HR WORKS imposes per-tenant rate limits; see their developer portal for details.\n\n**Clock status, onboarding and deleting working times**: `hrworks_get_working_time_status` tells whether a person is clocked in right now. `hrworks_list_onboarding_documents` and `hrworks_get_onboarding_document` read the onboarding documents of new employees, which hold private data (bank account, social security number): share only what the user needs. `hrworks_delete_working_times` deletes working times by their number (`_`); it is installed switched off, and like the other working-time writes it answers with a job id for `hrworks_get_working_times_job`.", "region": "de", "category": "hr", "icon": "hrworks", @@ -605,7 +605,7 @@ }, { "name": "hrworks_list_projects", - "description": "List time-tracking projects of the company.", + "description": "List the time-tracking projects of the company, e.g. to find the project id behind a working time.", "parameters": { "type": "object", "properties": {} @@ -617,7 +617,7 @@ }, { "name": "hrworks_get_project", - "description": "Return the data of a specific time-tracking project by id.", + "description": "Return the data of one time-tracking project by its id from hrworks_list_projects.", "parameters": { "type": "object", "properties": { @@ -635,7 +635,7 @@ }, { "name": "hrworks_list_project_customers", - "description": "List the customers used for project time tracking.", + "description": "List the customers that time-tracking projects are booked for (project customers).", "parameters": { "type": "object", "properties": {} @@ -679,7 +679,7 @@ }, { "name": "hrworks_list_posts", - "description": "List all recruiting posts (job postings) of the company.", + "description": "List the recruiting posts (job postings) of the company; inactive ones on request.", "parameters": { "type": "object", "properties": { @@ -699,7 +699,7 @@ }, { "name": "hrworks_get_post", - "description": "Return the data of a single recruiting post by uuid.", + "description": "Return the data of one recruiting post (job posting) by its uuid from hrworks_list_posts.", "parameters": { "type": "object", "properties": { @@ -779,7 +779,7 @@ }, { "name": "hrworks_list_cost_objectives", - "description": "List all cost objectives (Kostenträger) of the company.", + "description": "List all cost objectives (Kostenträger) of the company with their number and name.", "parameters": { "type": "object", "properties": {} @@ -1409,13 +1409,13 @@ }, { "name": "hrworks_get_working_times_job", - "description": "Return the status and result of a create or update of working times, by the job id those tools returned. Read-only.", + "description": "Return the status and result of a create, update or delete of working times, by the job id those tools returned. Read-only.", "parameters": { "type": "object", "properties": { "jobId": { "type": "string", - "description": "Job id returned by hrworks_create_working_times or hrworks_update_working_times." + "description": "Job id returned by hrworks_create_working_times, hrworks_update_working_times or hrworks_delete_working_times." } }, "required": ["jobId"] @@ -1424,6 +1424,103 @@ "method": "GET", "path": "/v2/working-times/jobs/{jobId}" } + }, + { + "name": "hrworks_get_working_time_status", + "description": "Return whether a person is clocked in right now and, if so, the running working time: start, type (working time, business errand, doctor's appointment...), project, comment and kiosk. Read-only.", + "parameters": { + "type": "object", + "properties": { + "personIdentifier": { + "type": "string", + "description": "Personnel number of the person (default), or the identifier named in personIdentifierType." + }, + "personIdentifierType": { + "type": "string", + "enum": ["personId", "personnelNumber", "personLicenseNumber", "uuid", "personIdentifierForKiosk"], + "description": "Type of personIdentifier. Leave out for the company's default identifier for working times (usually the personnel number)." + } + }, + "required": ["personIdentifier"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/persons/{personIdentifier}/working-times/status", + "queryParams": { + "personIdentifierType": "$personIdentifierType" + } + } + }, + { + "name": "hrworks_list_onboarding_documents", + "description": "List the onboarding documents (Onboarding-Dokumente) of new employees: name, join date, organization unit, superior, status and the personal data the new employee entered. Contains private data such as address, bank account and social security number. Filter by status or organization unit. Read-only.", + "parameters": { + "type": "object", + "properties": { + "statusFilter": { + "type": "array", + "items": { "type": "string", "enum": ["created", "editingFinished", "activated"] }, + "description": "Only documents in these states: created (sent to the new employee), editingFinished (filled in, waiting for HR) or activated (the person was created from it)." + }, + "organizationUnits": { + "type": "array", + "items": { "type": "string" }, + "description": "Only documents of people assigned to these organization units (numbers from hrworks_list_organization_units)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/persons/onboarding", + "queryParams": { + "statusFilter": "$statusFilter", + "organizationUnits": "$organizationUnits" + } + } + }, + { + "name": "hrworks_get_onboarding_document", + "description": "Return one onboarding document by id, with all the data the new employee entered (address, bank account, tax and social security data, emergency contact) and its status. Read-only.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Onboarding document id from hrworks_list_onboarding_documents." + } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/persons/onboarding/{id}" + } + }, + { + "name": "hrworks_delete_working_times", + "description": "Delete recorded working times. Each one is named by its number: the personnel number, an underscore and the start as Unix time in seconds (e.g. 1_1683093600), the `id` that hrworks_list_working_times returns. Deleted times are gone from time tracking and the time accounts change. HR WORKS processes the request in the background and answers with a job id: check the result with hrworks_get_working_times_job. List the times to delete to the user and confirm before calling. Installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "numbers": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { "type": "string" }, + "description": "Working time numbers to delete, e.g. [\"1_1683093600\"]." + } + }, + "required": ["numbers"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/v2/working-times", + "queryParams": { + "numbers": "$numbers" + } + }, + "annotations": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": true } } ] } diff --git a/packages/backend/src/adapters/de/hrworks.live.spec.ts b/packages/backend/src/adapters/de/hrworks.live.spec.ts index 1dc8cfeb9..fa328a5ec 100644 --- a/packages/backend/src/adapters/de/hrworks.live.spec.ts +++ b/packages/backend/src/adapters/de/hrworks.live.spec.ts @@ -21,6 +21,7 @@ type Tool = { method: string; path: string; bodyMapping?: Record; + queryParams?: Record; }; }; const tools = (adapter as unknown as { tools: Tool[] }).tools; @@ -87,6 +88,42 @@ describe("hrworks adapter — static spec conformance", () => { } }); + // Paths and parameters from the HR WORKS API v2 reference + // (developers.hrworks.de: Working Time Status, Onboarding, DELETE Working Times). + it("reads the clock status and the onboarding documents", () => { + expect(tool("hrworks_get_working_time_status").endpointMapping).toMatchObject({ + method: "GET", + path: "/v2/persons/{personIdentifier}/working-times/status", + queryParams: { personIdentifierType: "$personIdentifierType" }, + }); + expect(tool("hrworks_get_working_time_status").parameters.required).toEqual(["personIdentifier"]); + expect(tool("hrworks_list_onboarding_documents").endpointMapping).toMatchObject({ + method: "GET", + path: "/v2/persons/onboarding", + queryParams: { statusFilter: "$statusFilter", organizationUnits: "$organizationUnits" }, + }); + expect(tool("hrworks_get_onboarding_document").endpointMapping).toMatchObject({ + method: "GET", + path: "/v2/persons/onboarding/{id}", + }); + expect(tool("hrworks_get_onboarding_document").parameters.required).toEqual(["id"]); + }); + + it("deletes working times by number, installed switched off", () => { + const t = tool("hrworks_delete_working_times") as Tool & { + enabled?: boolean; + annotations?: Record; + }; + expect(t.enabled).toBe(false); + expect(t.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(t.endpointMapping).toMatchObject({ + method: "DELETE", + path: "/v2/working-times", + queryParams: { numbers: "$numbers" }, + }); + expect(t.parameters.required).toEqual(["numbers"]); + }); + it("has no tool for /v3/working-times/{uuid}, which HR WORKS does not serve", () => { expect( tools.some((t) => t.endpointMapping.path.startsWith("/v3/working-times")), diff --git a/packages/backend/src/adapters/de/jtl-ffn.json b/packages/backend/src/adapters/de/jtl-ffn.json index 86aa2ccb6..7200c899a 100644 --- a/packages/backend/src/adapters/de/jtl-ffn.json +++ b/packages/backend/src/adapters/de/jtl-ffn.json @@ -2,7 +2,7 @@ "slug": "jtl-ffn", "name": "JTL Fulfillment Network", "description": "JTL Fulfillment Network (JTL-FFN) for merchants: products, stock per warehouse, outbound orders with shipping status and tracking, inbound deliveries, returns, warehouses and shipping methods of your fulfillers. Create or cancel outbounds on request. OAuth2.", - "instructions": "Reads the JTL-FFN Merchant API (`ffn2.api.jtl-software.com`) as the merchant who authorized it. The account needs the Merchant role in the FFN portal.\n\n**Getting credentials**\n1. Sign in once at https://fulfillment.jtl-software.com with your JTL account; until then the API answers 404 for your user.\n2. Open https://kundencenter.jtl-software.de/oauth (it is not in the Kundencenter menu), create an OAuth client with the scopes `ffn.merchant.read` and `ffn.merchant.write`, and set its redirect URI to `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when self-hosted.\n3. Paste the client ID and secret into `JTL_FFN_CLIENT_ID` and `JTL_FFN_CLIENT_SECRET`, install, then click **Authorize with Provider** on the connector page. Access tokens last 30 minutes; JTL issues a new refresh token on every renewal and AnythingMCP stores it.\n\n**Workflow**: `jtl_ffn_get_current_user` confirms the account. `jtl_ffn_list_warehouses` and `jtl_ffn_list_shipping_methods` give the ids an outbound needs. Find products with `jtl_ffn_list_products`, then read stock with `jtl_ffn_get_product_stock`. For an order, `jtl_ffn_get_outbound` shows its status and `jtl_ffn_list_outbound_shipping_notifications` the parcels with tracking.\n\n**Formats and gotchas**: a jfsku is JTL's 11-character product id (e.g. `MERC01PRDCT`), not your own SKU (`merchantSku`). Outbound and inbound tools accept either the FFN id or your own order number. `filter` takes an OData-style expression on the fields named in each tool, e.g. `status eq 'Shipped'`. Dates are ISO 8601. `jtl_ffn_create_outbound` and `jtl_ffn_cancel_outbound` install switched off: enable them deliberately, and always confirm the details with the user before a call.\n\n**Pagination and rate limits**: list tools take `limit` (default 50) and `offset`; the answer has `count`, `_page` and `nextPageLink` while more pages exist. Raise `offset` by `limit` until `nextPageLink` is missing. JTL publishes no rate limit; avoid tight polling loops and use the change feed for sync.\n\n**Cloud reachability**: `ffn2.api.jtl-software.com` and `oauth2.api.jtl-software.com` are public HTTPS endpoints, reachable from AnythingMCP Cloud and self-hosted installs.", + "instructions": "Reads the JTL-FFN Merchant API (`ffn2.api.jtl-software.com`) as the merchant who authorized it. The account needs the Merchant role in the FFN portal.\n\n**Getting credentials**\n1. Sign in once at https://fulfillment.jtl-software.com with your JTL account; until then the API answers 404 for your user.\n2. Open https://kundencenter.jtl-software.de/oauth (it is not in the Kundencenter menu), create an OAuth client with the scopes `ffn.merchant.read` and `ffn.merchant.write`, and set its redirect URI to `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when self-hosted.\n3. Paste the client ID and secret into `JTL_FFN_CLIENT_ID` and `JTL_FFN_CLIENT_SECRET`, install, then click **Authorize with Provider** on the connector page. Access tokens last 30 minutes; JTL issues a new refresh token on every renewal and AnythingMCP stores it.\n\n**Workflow**: `jtl_ffn_get_current_user` confirms the account. `jtl_ffn_list_warehouses` and `jtl_ffn_list_shipping_methods` give the ids an outbound needs. Find products with `jtl_ffn_list_products`, then read stock with `jtl_ffn_get_product_stock`. Deliveries to the warehouse are inbounds: `jtl_ffn_list_inbounds` and `jtl_ffn_get_inbound` show their status. For an order, `jtl_ffn_get_outbound` shows its status and `jtl_ffn_list_outbound_shipping_notifications` the parcels with tracking.\n\n**Formats and gotchas**: a jfsku is JTL's 11-character product id (e.g. `MERC01PRDCT`), not your own SKU (`merchantSku`). Outbound and inbound tools accept either the FFN id or your own order number. `filter` takes an OData-style expression on the fields named in each tool, e.g. `status eq 'Shipped'`. Dates are ISO 8601. `jtl_ffn_create_outbound`, `jtl_ffn_cancel_outbound` and `jtl_ffn_create_inbound` (announces a delivery to the fulfiller's warehouse) install switched off: enable them deliberately, and always confirm the details with the user before a call.\n\n**Pagination and rate limits**: list tools take `limit` (default 50) and `offset`; the answer has `count`, `_page` and `nextPageLink` while more pages exist. Raise `offset` by `limit` until `nextPageLink` is missing. JTL publishes no rate limit; avoid tight polling loops and use the change feed for sync.\n\n**Cloud reachability**: `ffn2.api.jtl-software.com` and `oauth2.api.jtl-software.com` are public HTTPS endpoints, reachable from AnythingMCP Cloud and self-hosted installs.", "region": "de", "category": "logistics", "icon": "jtl-ffn", @@ -424,6 +424,62 @@ "encodePathParams": true, "bodyMapping": { "status": "Canceled", "cancelReason": "$cancel_reason", "cancelReasonCode": "$cancel_reason_code" } } + }, + { + "name": "jtl_ffn_create_inbound", + "description": "Announce an inbound delivery (Anlieferung, e.g. new stock from a supplier or production) to the fulfiller's warehouse, so the fulfiller can receive and book it. The fulfiller sees it at once. Before calling, show the user the warehouse, the products and quantities and get an explicit yes. Answers 400 when a product is unknown in that warehouse or the merchant_inbound_number is already in use. Follow its status with jtl_ffn_get_inbound.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "merchant_inbound_number": { "type": "string", "description": "Your own unique number for this delivery, e.g. IN-2026-001." }, + "warehouse_id": { "type": "string", "description": "warehouseId from jtl_ffn_list_warehouses." }, + "items": { + "type": "array", + "description": "Products arriving. inboundItemId is your own line id (1, 2, 3...); give jfsku or merchantSku for each line.", + "items": { + "type": "object", + "properties": { + "inboundItemId": { "type": "string", "description": "Your line id, unique within the inbound." }, + "jfsku": { "type": "string", "description": "JTL product id, 11 characters, from jtl_ffn_list_products." }, + "merchantSku": { "type": "string", "description": "Your own SKU, instead of the jfsku." }, + "quantity": { "type": "number", "description": "Quantity expected." }, + "supplierSku": { "type": "string", "description": "The supplier's SKU for this product." }, + "supplierProductName": { "type": "string", "description": "The supplier's product name." }, + "note": { "type": "string", "description": "Note on this line for the fulfiller." } + }, + "required": ["inboundItemId", "quantity"] + } + }, + "purchase_order_number": { "type": "string", "description": "Your purchase order number for this delivery." }, + "external_inbound_number": { "type": "string", "description": "Another reference, e.g. the supplier's delivery note number." }, + "supplier": { + "type": "object", + "description": "The supplier sending the goods.", + "properties": { + "merchantSupplierNumber": { "type": "string", "description": "Your supplier number." }, + "merchantSupplierName": { "type": "string", "description": "Supplier name." } + }, + "required": ["merchantSupplierNumber"] + }, + "note": { "type": "string", "description": "Note on the delivery for the fulfiller, e.g. pallets or time slot." } + }, + "required": ["merchant_inbound_number", "warehouse_id", "items"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/merchant/inbounds", + "bodyMapping": { + "merchantInboundNumber": "$merchant_inbound_number", + "warehouseId": "$warehouse_id", + "items": "$items", + "purchaseOrderNumber": "$purchase_order_number", + "externalInboundNumber": "$external_inbound_number", + "supplier": "$supplier", + "note": "$note" + } + }, + "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false } } ] } diff --git a/packages/backend/src/adapters/de/jtl-ffn.live.spec.ts b/packages/backend/src/adapters/de/jtl-ffn.live.spec.ts index 870aa13f3..764900420 100644 --- a/packages/backend/src/adapters/de/jtl-ffn.live.spec.ts +++ b/packages/backend/src/adapters/de/jtl-ffn.live.spec.ts @@ -51,12 +51,13 @@ describe('JTL-FFN adapter — static contract', () => { expect(a.probe.tool).toBe('jtl_ffn_get_current_user'); }); - it('prefixes every tool and keeps writes to two switched-off tools', () => { + it('prefixes every tool and keeps writes to three switched-off tools', () => { expect(a.tools.every((t) => t.name.startsWith('jtl_ffn_'))).toBe(true); const writes = a.tools.filter((t) => t.endpointMapping.method !== 'GET'); expect(writes.map((t) => [t.name, t.endpointMapping.method, t.enabled])).toEqual([ ['jtl_ffn_create_outbound', 'POST', false], ['jtl_ffn_cancel_outbound', 'PUT', false], + ['jtl_ffn_create_inbound', 'POST', false], ]); expect(a.tools.filter((t) => t.endpointMapping.method === 'GET').every((t) => t.enabled === undefined)).toBe(true); }); @@ -99,6 +100,20 @@ describe('JTL-FFN adapter — static contract', () => { items: '$items', }), }); + expect(byName.jtl_ffn_create_inbound.parameters.required).toEqual(['merchant_inbound_number', 'warehouse_id', 'items']); + expect(byName.jtl_ffn_create_inbound.endpointMapping).toEqual({ + method: 'POST', + path: '/v1/merchant/inbounds', + bodyMapping: { + merchantInboundNumber: '$merchant_inbound_number', + warehouseId: '$warehouse_id', + items: '$items', + purchaseOrderNumber: '$purchase_order_number', + externalInboundNumber: '$external_inbound_number', + supplier: '$supplier', + note: '$note', + }, + }); expect(byName.jtl_ffn_cancel_outbound.endpointMapping.bodyMapping).toEqual({ status: 'Canceled', cancelReason: '$cancel_reason', diff --git a/packages/backend/src/adapters/de/klardaten.json b/packages/backend/src/adapters/de/klardaten.json index c5b7f167f..5226cf589 100644 --- a/packages/backend/src/adapters/de/klardaten.json +++ b/packages/backend/src/adapters/de/klardaten.json @@ -7,7 +7,7 @@ "icon": "klardaten", "docsUrl": "https://api.klardaten.com/docs", "prerequisites": "The Klardaten connector installed on the firm's DATEV server and a DATEV instance in the Klardaten dashboard. Tax and payroll data of the firm's clients: put this connector only on servers whose users may see all of it.", - "instructions": "**Sensitive data: read this first.** This connector reads a tax advisory firm's DATEV data through Klardaten: the master data, tax returns, bookkeeping and payroll of the firm's clients, including salaries, tax numbers, birth dates and bank transactions of private persons and companies. That data is covered by professional secrecy (§ 203 StGB, § 57 StBerG) and the GDPR. Add this connector only to servers whose users are allowed to see all of it, for example the partners or the staff who already have that access in DATEV. Do not add it to a shared or company-wide server. The API key acts with the DATEV profile behind it, so a model sees whatever that profile can read.\n\n**Setup**\n1. Sign in at https://dashboard.klardaten.com and create an API key for your user. It starts with `uk-` and goes into `KLARDATEN_API_KEY`; it is sent as `Authorization: Bearer`.\n2. Copy the id of the DATEV instance (a UUID, created with your consultant number when the Klardaten connector was installed on the DATEV server) into `KLARDATEN_INSTANCE_ID`. Every DATEV request carries it in the `x-client-instance-id` header. `klardaten_list_instances` lists the instances the key can reach.\n3. The Klardaten connector on the DATEV server must be running. When it is offline, DATEV calls fail while `klardaten_get_me` still works; `klardaten_get_datevconnect_health` shows the service status.\n\n**Which tool to start with**: `klardaten_get_me` to check access, then `klardaten_list_clients` (master data), `klardaten_list_accounting_clients` (bookkeeping), `klardaten_list_payroll_clients` (LODAS payroll) or `klardaten_list_firm_clients` (the firm's own practice management analytics).\n\n**Ids differ per area.** Master data uses UUIDs (`client_id`, `addressee_id`). Accounting uses a numeric `client_id` from `klardaten_list_accounting_clients`, and fiscal years are keyed by their start date as `YYYYMMDD` (e.g. `20250101`). Payroll and firm analytics use the `client_number`. Tax returns have their own `id`. Passing an id from another area is the usual cause of a 400 or 404.\n\n**Paging, fields and filters**: list tools take `page` (from 1) and `page_size` (default 100), `order_by` (`field:asc|desc`, comma-separated), `fields` (comma-separated, keeps answers small) and `filter`. A filter is `field operator value` with `eq`, `like`, `gt`, `gte`, `lt`, `lte`, `in`, `notin`, `is`, `isnot`, combined with `AND`/`OR`, e.g. `client_id eq 101 AND fiscal_year eq 20250101`. Only the fields of that endpoint are accepted; an unknown field or a wrongly formatted value returns 400. Always filter `klardaten_list_postings` by `client_id` and `fiscal_year`: across all clients of a firm the query takes about a minute and can time out.\n\n**DATEVconnect passthrough**: `klardaten_datevconnect_get` reads any DATEVconnect GET path (e.g. `master-data/v1/clients`) for data the other tools do not cover. It is read-only.\n\n**DATEV profile**: DATEVconnect calls run with the API key's default Klardaten profile. To act as another profile, set `KLARDATEN_PROFILE_ID`; it is sent as `x-profile-id` on every DATEVconnect call. Eigenorganisation writes need a profile that is an Eigenorganisation employee (`EODC10015` otherwise). `DCO10009` (\"License for DATEVconnect is not present\") comes from the DATEV server itself: check the DATEVconnect licence there.\n\n**DATEV Anwalt and user management**: `klardaten_list_law_files` and the other `klardaten_*_law_*` tools read the law firm module (files, parties, budget, expenses and its reference lists); they need DATEV Anwalt on the server. `klardaten_list_datev_users` and `klardaten_list_datev_groups` read DATEV's user management (read-only).\n\n**Writes (installed switched off)**: `klardaten_update_order` changes an Eigenorganisation order, `klardaten_create_expense_posting` books a time or cost entry on a sub-order, and `klardaten_set_client_responsibilities` replaces a client's responsibilities. They change the firm's DATEV data: turn them on only on a server whose users may do that in DATEV, and confirm every change with the user. `klardaten_update_order` and `klardaten_set_client_responsibilities` replace what they write, so read first and send the full object or list.\n\n**Licensing**: the Enterprise endpoints (accounting, payroll, tax, analytics) depend on the Klardaten plan; an endpoint outside the plan answers 403. `klardaten_search_documents` needs the document search add-on, which Klardaten enables per instance on request; it is installed switched off, turn it on once the add-on is active.", + "instructions": "**Sensitive data: read this first.** This connector reads a tax advisory firm's DATEV data through Klardaten: the master data, tax returns, bookkeeping and payroll of the firm's clients, including salaries, tax numbers, birth dates and bank transactions of private persons and companies. That data is covered by professional secrecy (§ 203 StGB, § 57 StBerG) and the GDPR. Add this connector only to servers whose users are allowed to see all of it, for example the partners or the staff who already have that access in DATEV. Do not add it to a shared or company-wide server. The API key acts with the DATEV profile behind it, so a model sees whatever that profile can read.\n\n**Setup**\n1. Sign in at https://dashboard.klardaten.com and create an API key for your user. It starts with `uk-` and goes into `KLARDATEN_API_KEY`; it is sent as `Authorization: Bearer`.\n2. Copy the id of the DATEV instance (a UUID, created with your consultant number when the Klardaten connector was installed on the DATEV server) into `KLARDATEN_INSTANCE_ID`. Every DATEV request carries it in the `x-client-instance-id` header. `klardaten_list_instances` lists the instances the key can reach.\n3. The Klardaten connector on the DATEV server must be running. When it is offline, DATEV calls fail while `klardaten_get_me` still works; `klardaten_get_datevconnect_health` shows the service status.\n\n**Which tool to start with**: `klardaten_get_me` to check access, then `klardaten_list_clients` (master data), `klardaten_list_accounting_clients` (bookkeeping), `klardaten_list_payroll_clients` (LODAS payroll) or `klardaten_list_firm_clients` (the firm's own practice management analytics).\n\n**Ids differ per area.** Master data uses UUIDs (`client_id`, `addressee_id`). Accounting uses a numeric `client_id` from `klardaten_list_accounting_clients`, and fiscal years are keyed by their start date as `YYYYMMDD` (e.g. `20250101`). Payroll and firm analytics use the `client_number`. Tax returns have their own `id`. Passing an id from another area is the usual cause of a 400 or 404.\n\n**Paging, fields and filters**: list tools take `page` (from 1) and `page_size` (default 100), `order_by` (`field:asc|desc`, comma-separated), `fields` (comma-separated, keeps answers small) and `filter`. A filter is `field operator value` with `eq`, `like`, `gt`, `gte`, `lt`, `lte`, `in`, `notin`, `is`, `isnot`, combined with `AND`/`OR`, e.g. `client_id eq 101 AND fiscal_year eq 20250101`. Only the fields of that endpoint are accepted; an unknown field or a wrongly formatted value returns 400. Always filter `klardaten_list_postings` by `client_id` and `fiscal_year`: across all clients of a firm the query takes about a minute and can time out.\n\n**Tax returns**: income tax, corporate tax, trade tax and VAT returns each have a list tool, a get tool and a data tool (`klardaten_list_vat_filings`, `klardaten_get_vat_filing`, `klardaten_get_vat_data` for Umsatzsteuer). `klardaten_list_clients_extended` returns clients with addresses, communications, tax office and bank accounts in one call; filter it, unfiltered it can time out.\n\n**Monitoring**: when DATEV calls fail, check `klardaten_get_instance` (status, last reply), `klardaten_get_instance_required_services`, `klardaten_get_datevconnect_health`, `klardaten_get_instance_metrics` and `klardaten_list_instance_logs` (keep the time window short). `klardaten_list_audit_logs` and `klardaten_get_audit_log` show which accesses took place; `klardaten_list_data_change_webhooks` and `klardaten_list_monitoring_webhooks` list the configured notifications without their secrets. The DATEVconnect diagnostics (`diagnostics/v1/echo`, `diagnostics/v1/domains`) are read through `klardaten_datevconnect_get`.\n\n**DATEVconnect passthrough**: `klardaten_datevconnect_get` reads any DATEVconnect GET path (e.g. `master-data/v1/clients`) for data the other tools do not cover. It is read-only.\n\n**DATEV profile**: DATEVconnect calls run with the API key's default Klardaten profile. To act as another profile, set `KLARDATEN_PROFILE_ID`; it is sent as `x-profile-id` on every DATEVconnect call. Eigenorganisation writes need a profile that is an Eigenorganisation employee (`EODC10015` otherwise). `DCO10009` (\"License for DATEVconnect is not present\") comes from the DATEV server itself: check the DATEVconnect licence there.\n\n**DATEV Anwalt and user management**: `klardaten_list_law_files` and the other `klardaten_*_law_*` tools read the law firm module (files, parties, budget, expenses and its reference lists); they need DATEV Anwalt on the server. `klardaten_list_datev_users` and `klardaten_list_datev_groups` read DATEV's user management (read-only).\n\n**Writes (installed switched off)**: `klardaten_update_order` changes an Eigenorganisation order, `klardaten_create_expense_posting` books a time or cost entry on a sub-order, and `klardaten_set_client_responsibilities` replaces a client's responsibilities. They change the firm's DATEV data: turn them on only on a server whose users may do that in DATEV, and confirm every change with the user. `klardaten_update_order` and `klardaten_set_client_responsibilities` replace what they write, so read first and send the full object or list.\n\n**Licensing**: the Enterprise endpoints (accounting, payroll, tax, analytics) depend on the Klardaten plan; an endpoint outside the plan answers 403. `klardaten_search_documents` needs the document search add-on, which Klardaten enables per instance on request; it is installed switched off, turn it on once the add-on is active.", "requiredEnvVars": [ "KLARDATEN_API_KEY", "KLARDATEN_INSTANCE_ID" @@ -372,6 +372,31 @@ "path": "/api/master-data/relationships/{relationship_id}" } }, + { + "name": "klardaten_list_clients_extended", + "description": "List DATEV clients (Mandanten) with their full master data in one call: person or company details, addresses, communications (phone, e-mail), tax office, bank accounts and relationships. On a large firm an unfiltered call can time out: always filter, e.g. by client_number, client_id or a phone number (communications.number like ...). For a plain list of clients use klardaten_list_clients.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "Filter expression 'field operator value' (eq, like, gt, gte, lt, lte, in, notin, is, isnot; combine with AND / OR), e.g. client_number eq 10023 or communications.number like %7612345%. Fields: client_id, client_number, client_name, short_name, alias_name, type, status, vat_id, updated_at, communications.number." + }, + "order_by": { + "type": "string", + "description": "Sort order as field:direction, e.g. client_number:asc. Fields: client_number, client_name, short_name, type, status, updated_at, client_id." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/master-data/clients/extended", + "queryParams": { + "filter": "$filter", + "order_by": "$order_by" + } + } + }, { "name": "klardaten_list_filing_standards", "description": "List the firm's filing standards (Ablage-Knigge): document classes with their standard folder and register, used to file documents in DATEV DMS.", @@ -692,6 +717,86 @@ "path": "/api/tax/trade-tax/{id}/data" } }, + { + "name": "klardaten_list_vat_filings", + "description": "List VAT returns (Umsatzsteuer: monthly or quarterly advance returns and annual returns) in DATEV: id, client_number, year, type, description, status, ELSTER telenumber, transmission status and dates, tax office arrival and notice status. Use the id with klardaten_get_vat_filing and klardaten_get_vat_data.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Page number, starting at 1." + }, + "page_size": { + "type": "integer", + "description": "Items per page. Defaults to 100." + }, + "order_by": { + "type": "string", + "description": "Sort order as field:direction pairs, comma-separated, e.g. transmission_date:desc,client_number:asc." + }, + "fields": { + "type": "string", + "description": "Comma-separated list of fields to return, e.g. id,client_number,year,status,transmission_status. Leave empty for all fields; narrowing keeps answers small." + }, + "filter": { + "type": "string", + "description": "Filter expression 'field operator value'. Operators: eq, like, gt, gte, lt, lte, in, notin, is, isnot. Combine with AND / OR and parentheses, e.g. client_number eq 10023 AND year eq 2026. Useful fields: client_number, year, type, status, transmission_status, transmission_date." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/tax/value-added-tax", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "order_by": "$order_by", + "fields": "$fields", + "filter": "$filter" + } + } + }, + { + "name": "klardaten_get_vat_filing", + "description": "Return one VAT return (Umsatzsteuer) by id: year, type, status, ELSTER transmission, tax office arrival and notice dates, objection status and client number.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Return id (field id) from klardaten_list_vat_filings." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/tax/value-added-tax/{id}" + } + }, + { + "name": "klardaten_get_vat_data", + "description": "Return the form data of one VAT return (Umsatzsteuer) by id, as name and content pairs. Use it to read the values entered in the return, such as turnover and input tax lines.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Return id (field id) from klardaten_list_vat_filings." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/tax/value-added-tax/{id}/data" + } + }, { "name": "klardaten_list_tax_calculations", "description": "List the tax calculations DATEV holds for one client and tax application, with tax years and calculation ids. Use it before klardaten_get_tax_calculation.", @@ -1591,6 +1696,177 @@ } } }, + { + "name": "klardaten_get_instance", + "description": "Return the configured DATEV instance: name, consultant number, owner, connection type, status, time of the last reply from the DATEV server, connector version and whether it is a test instance. A first check when DATEV calls fail.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/client-instances/{{KLARDATEN_INSTANCE_ID}}" + }, + "responseMapping": { + "transform": { + "mode": "select", + "exclude": [ + "installation_email", + "service_user" + ] + } + } + }, + { + "name": "klardaten_get_instance_required_services", + "description": "Return the state of the Windows services the configured DATEV instance needs (DATEVconnect, Klardaten connector and the DATEV services behind them). Use it when DATEV calls fail or time out.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/client-instances/{{KLARDATEN_INSTANCE_ID}}/required-services" + } + }, + { + "name": "klardaten_get_instance_metrics", + "description": "Return load metrics of the DATEV server behind the configured instance over time: CPU and memory of the machine and of the connector process, database CPU, memory and I/O, and the number of requests waiting and in progress. Use it to explain slow answers.", + "parameters": { + "type": "object", + "properties": { + "from_date": { + "type": "string", + "description": "Start of the period, ISO 8601, e.g. 2026-10-01T00:00:00Z. Keep the window short: answers grow quickly." + }, + "to_date": { + "type": "string", + "description": "End of the period, ISO 8601." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/monitoring/instance/{{KLARDATEN_INSTANCE_ID}}/metrics", + "queryParams": { + "from_date": "$from_date", + "to_date": "$to_date" + } + } + }, + { + "name": "klardaten_list_instance_logs", + "description": "Read the connector log of the configured instance, to find the cause of 5xx answers and time-outs. Returns log lines and a next_token; call again with next_token until it is missing. Narrow with filter_pattern and a short time window.", + "parameters": { + "type": "object", + "properties": { + "from_date": { + "type": "string", + "description": "Start of the period, ISO 8601, e.g. 2026-10-01T00:00:00Z. Keep the window short: answers grow quickly." + }, + "to_date": { + "type": "string", + "description": "End of the period, ISO 8601." + }, + "filter_pattern": { + "type": "string", + "description": "Text the log lines must contain, e.g. ERROR or a DATEV error code." + }, + "next_token": { + "type": "string", + "description": "next_token from the previous page." + }, + "oldest_first": { + "type": "boolean", + "description": "true returns the oldest lines first. Default: newest first." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/monitoring/instance/{{KLARDATEN_INSTANCE_ID}}/logs", + "queryParams": { + "from_date": "$from_date", + "to_date": "$to_date", + "filter_pattern": "$filter_pattern", + "next_token": "$next_token", + "oldest_first": "$oldest_first" + } + } + }, + { + "name": "klardaten_list_audit_logs", + "description": "List the audit log files of the configured instance (name, size, last modified). They record which accesses to DATEV data took place. Read one with klardaten_get_audit_log.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/monitoring/instance/{{KLARDATEN_INSTANCE_ID}}/audit-logs" + } + }, + { + "name": "klardaten_get_audit_log", + "description": "Return the content of one audit log file of the configured instance as text. Files can be large; list them first with klardaten_list_audit_logs and pick the one for the day you need.", + "parameters": { + "type": "object", + "properties": { + "log_name": { + "type": "string", + "description": "File name (field name) from klardaten_list_audit_logs." + } + }, + "required": [ + "log_name" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/monitoring/instance/{{KLARDATEN_INSTANCE_ID}}/audit-logs/{log_name}/download", + "encodePathParams": true + } + }, + { + "name": "klardaten_list_data_change_webhooks", + "description": "List the data-change webhooks of the account: which DATEV entity changes are sent to which endpoint, for which instance. The signing secret is removed from the answer.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/data-changes/webhooks" + }, + "responseMapping": { + "transform": { + "mode": "select", + "exclude": [ + "[*].secret" + ] + } + } + }, + { + "name": "klardaten_list_monitoring_webhooks", + "description": "List the monitoring webhooks of the account: the endpoints notified when an instance goes offline or comes back. The signing secret is removed from the answer.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/monitoring/webhooks" + }, + "responseMapping": { + "transform": { + "mode": "select", + "exclude": [ + "[*].secret" + ] + } + } + }, { "name": "klardaten_datevconnect_get", "description": "Read any DATEVconnect endpoint through the Klardaten gateway (GET only), for data the other tools do not cover, e.g. master-data/v1/clients or accounting/v1/clients. Returns DATEV's own JSON.", diff --git a/packages/backend/src/adapters/de/klardaten.live.spec.ts b/packages/backend/src/adapters/de/klardaten.live.spec.ts index a7c154ded..6b8545753 100644 --- a/packages/backend/src/adapters/de/klardaten.live.spec.ts +++ b/packages/backend/src/adapters/de/klardaten.live.spec.ts @@ -1,6 +1,7 @@ import * as adapter from './klardaten.json'; import axios from 'axios'; import { RestEngine } from '../../connectors/engines/rest.engine'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; jest.mock('axios', () => { const actual = jest.requireActual('axios'); @@ -13,6 +14,7 @@ type Tool = { enabled?: boolean; parameters: { properties: Record; required?: string[] }; endpointMapping: { method: string; path: string; queryParams?: Record }; + responseMapping?: Record; }; const a = adapter as unknown as { requiredEnvVars: string[]; @@ -107,6 +109,45 @@ describe('klardaten adapter: static spec conformance', () => { ); }); + it('reads VAT returns like the other tax returns', () => { + expect(tool('klardaten_list_vat_filings').endpointMapping).toMatchObject({ + method: 'GET', + path: '/api/tax/value-added-tax', + queryParams: { page: '$page', page_size: '$page_size', order_by: '$order_by', fields: '$fields', filter: '$filter' }, + }); + expect(tool('klardaten_get_vat_filing').endpointMapping.path).toBe('/api/tax/value-added-tax/{id}'); + expect(tool('klardaten_get_vat_data').endpointMapping.path).toBe('/api/tax/value-added-tax/{id}/data'); + expect(tool('klardaten_list_clients_extended').endpointMapping).toMatchObject({ + path: '/api/master-data/clients/extended', + queryParams: { filter: '$filter', order_by: '$order_by' }, + }); + }); + + it('instance details, logs, metrics and audit logs read the configured instance', () => { + const inst = '{{KLARDATEN_INSTANCE_ID}}'; + expect(tool('klardaten_get_instance').endpointMapping.path).toBe(`/api/client-instances/${inst}`); + expect(tool('klardaten_get_instance_required_services').endpointMapping.path).toBe( + `/api/client-instances/${inst}/required-services`, + ); + expect(tool('klardaten_get_instance_metrics').endpointMapping.path).toBe(`/api/monitoring/instance/${inst}/metrics`); + expect(tool('klardaten_list_instance_logs').endpointMapping).toMatchObject({ + path: `/api/monitoring/instance/${inst}/logs`, + queryParams: { from_date: '$from_date', to_date: '$to_date', filter_pattern: '$filter_pattern', next_token: '$next_token', oldest_first: '$oldest_first' }, + }); + expect(tool('klardaten_list_audit_logs').endpointMapping.path).toBe(`/api/monitoring/instance/${inst}/audit-logs`); + expect(tool('klardaten_get_audit_log').endpointMapping.path).toBe( + `/api/monitoring/instance/${inst}/audit-logs/{log_name}/download`, + ); + }); + + it('removes the signing secret from the webhook lists', () => { + const hooks = [{ id: 'w1', endpoint: 'https://example.com/hook', instance_id: 'i1', secret: 's3cr3t' }]; + for (const name of ['klardaten_list_data_change_webhooks', 'klardaten_list_monitoring_webhooks']) { + const out = applyResponseTransform(hooks, tool(name).responseMapping as any).value as any[]; + expect(out).toEqual([{ id: 'w1', endpoint: 'https://example.com/hook', instance_id: 'i1' }]); + } + }); + it('the DATEVconnect passthrough forwards its query string verbatim', () => { const t = tool('klardaten_datevconnect_get'); expect(t.endpointMapping.path).toBe('/datevconnect/{path}'); @@ -151,6 +192,13 @@ describe('klardaten adapter: requests', () => { expect(sent().headers['x-client-instance-id']).toBe('48fa2446-c731-4a74-9ea7-b4bd6d66fe5f'); }); + it('fetches one audit log file with its name encoded into the path', async () => { + const m = { ...(tool('klardaten_get_audit_log').endpointMapping as any) }; + m.path = m.path.replace('{{KLARDATEN_INSTANCE_ID}}', 'inst-1'); + await engine.execute(config, m, { log_name: 'audit 2026-10-20.log' }); + expect(sent().url).toBe('https://api.klardaten.com/api/monitoring/instance/inst-1/audit-logs/audit%202026-10-20.log/download'); + }); + it('sends the configured profile as x-profile-id', async () => { await engine.execute(config, tool('klardaten_list_datev_groups').endpointMapping as any, { KLARDATEN_PROFILE_ID: 'jdoe' }); expect(sent().headers['x-profile-id']).toBe('jdoe'); diff --git a/packages/backend/src/adapters/de/weclapp.json b/packages/backend/src/adapters/de/weclapp.json index 347fda05c..b8e9577de 100644 --- a/packages/backend/src/adapters/de/weclapp.json +++ b/packages/backend/src/adapters/de/weclapp.json @@ -6,7 +6,7 @@ "category": "erp", "icon": "weclapp", "docsUrl": "https://www.weclapp.com/api/", - "instructions": "weclapp is a cloud ERP for small and mid-sized companies. This connector reads and writes weclapp through the REST API v2: parties (customers, suppliers, contact persons, leads), quotations, sales orders, sales invoices, articles and stock, purchase orders, tasks, comments and time records. Recurring invoices and opportunities are read-only.\n\n**Getting credentials**\n1. In weclapp, open your user menu → **My settings** → **API** and copy the API token into `WECLAPP_API_TOKEN`. Each user has one token; generating a new one invalidates the old one.\n2. Set `WECLAPP_TENANT` to the first part of your weclapp address only: for `acme.weclapp.com`, enter `acme`. Requests go to `https://acme.weclapp.com/webapp/api/v2` with the token in the `AuthenticationToken` header.\n\n**Permissions follow the user**: the token acts as the weclapp user it belongs to, so records that user's role cannot see are missing from the results. Use a user with read access to sales, CRM and articles, and write access to whatever the AI should change. A write the user's role does not allow answers 403.\n\n**Finding records**: get tools take the internal id, a numeric string such as `\"3346\"`, not the document number. To find a document by its number, filter the list tool: `orderNumber-eq=SO-1042`, `invoiceNumber-eq=RE-1042`, `quotationNumber-eq=AN-1042`, `articleNumber-eq=A5101`. Customers and suppliers both live under `/party`: filter with `customer-eq=true` or `supplier-eq=true`.\n\n**Filter syntax**: `filter` is sent as query parameters written `property-operator=value` and joined with `&`, e.g. `paymentStatus-eq=OPEN&dueDate-lt=1767225600000`. Operators: `eq`, `ne`, `lt`, `gt`, `le`, `ge`, `like`, `ilike`, `in`, `notin`, `null`, `notnull`. `-null` and `-notnull` take an empty value (`customerNumber-notnull=`); `-in` takes a bracketed list. A property name that does not exist returns HTTP 400, in `filter` and in `properties` alike. Sales documents have no `customerName`: the name is in `recordAddress`, the link is `customerId`.\n\n**Data formats**: dates are Unix epoch milliseconds, in responses and in filters (2026-01-01 00:00 UTC is `1767225600000`). Prices and quantities come back as decimal strings such as `\"119.00\"`. Unpaid invoices are `paymentStatus-eq=OPEN`, cancelled ones `status-eq=CANCELLED`, credit notes `salesInvoiceType-eq=CREDIT_NOTE`: leave credit notes out when adding up revenue.\n\n**Pagination and limits**: `page` starts at 1; `pageSize` defaults to 100 and is capped at 1000. Fetch the next page until a page returns fewer rows than `pageSize`. Leave `properties` empty: every field comes back, and a guessed field name fails the whole call with 400 \"unknown property\". When a list must be trimmed, use only the field names listed in that tool's `properties` description. weclapp has no fixed rate limit, but it queues requests under heavy load and answers HTTP 429 after 30 seconds in the queue: wait, then retry.\n\n**Recurring invoices**: `/recurringInvoice` is not part of weclapp's published OpenAPI file. If `weclapp_list_recurring_invoices` returns 404 on a tenant, the resource is not available there; invoices generated from a recurring invoice still carry its `recurringInvoiceId`.\n\n**Writing to weclapp**: every write tool changes the live ERP. Read the record first, tell the user what will change, and wait for their confirmation. The delete tools are installed switched off. If a write tool named here is missing, the connector was set up before it existed: ask the user to apply the connector update in AnythingMCP.\n- Partial updates: the `weclapp_edit_*` tools send `PUT` with `ignoreMissingProperties=true`, weclapp's documented way to change single fields, so fields you leave out keep their values. Send only what changes: read-only fields such as `createdDate`, `statusHistory` or amounts are refused with HTTP 400 (\"property ... is read-only\"), so never copy a whole record back.\n- Lists are replaced, not merged: when you send `addresses`, `contacts`, `partyEmailAddresses`, `orderItems`, `quotationItems` or `articlePrices`, include every entry to keep by its `id` (the id alone keeps it unchanged) and new entries without an id. An entry you leave out is deleted.\n- Optimistic locking: pass the record's `version` from your last read and weclapp refuses the change with HTTP 409 \"optimistic lock error\" if someone changed it meanwhile; read again and retry. Without `version` the check is skipped.\n- `dryRun: true` on create, edit and delete tools makes weclapp validate the request and answer without saving.\n- References are ids (`customerId`, `responsibleUserId`, `unitId`, `warehouseId`...). `weclapp_list_reference_data` lists users, warehouses, storage places, units, taxes, currencies, payment terms and the other lists those ids point to.\n- Decimals are sent as strings (`\"quantity\": \"2\"`, `\"unitPrice\": \"119.00\"`) and dates as epoch milliseconds. A position with your own price needs `manualUnitPrice: true` next to `unitPrice`.\n- Status changes: `weclapp_set_sales_order_status` sends the status alone (weclapp refuses a reopen combined with other changes), `weclapp_cancel_sales_order` cancels or closes an order, `weclapp_set_quotation_status` uses weclapp's quotation status action. `weclapp_invoice_sales_order` creates a draft invoice from an order; every call creates another one.\n- Single-field shortcuts: `weclapp_set_sales_invoice_responsible`, `weclapp_set_sales_order_responsible` and `weclapp_set_party_responsible` change only the responsible user. They do the same as the `weclapp_edit_*` tools with `{\"responsibleUserId\": ...}`, with less room for a wrong body.\n- Purchasing master data: `weclapp_add_supply_source` (supplier + purchase price for an article), `weclapp_add_manufacturer` and `weclapp_add_article_item_group` create records that articles and documents then reference by id.\n- Stock is changed by bookings (`weclapp_book_stock_in`, `weclapp_book_stock_out`), not by editing the article; they take effect at once.\n\n**Cloud reachability**: `*.weclapp.com` is publicly reachable over HTTPS.", + "instructions": "weclapp is a cloud ERP for small and mid-sized companies. This connector reads and writes weclapp through the REST API v2: parties (customers, suppliers, contact persons, leads), quotations, sales orders, sales invoices, articles and stock, purchase orders, tasks, comments and time records. Recurring invoices and opportunities are read-only. Open items, stock movements, production orders and the translated texts of a record are read with `weclapp_list_sales_open_items`, `weclapp_list_warehouse_stock_movements`, `weclapp_list_production_orders` and `weclapp_get_property_translations`.\n\n**Getting credentials**\n1. In weclapp, open your user menu → **My settings** → **API** and copy the API token into `WECLAPP_API_TOKEN`. Each user has one token; generating a new one invalidates the old one.\n2. Set `WECLAPP_TENANT` to the first part of your weclapp address only: for `acme.weclapp.com`, enter `acme`. Requests go to `https://acme.weclapp.com/webapp/api/v2` with the token in the `AuthenticationToken` header.\n\n**Permissions follow the user**: the token acts as the weclapp user it belongs to, so records that user's role cannot see are missing from the results. Use a user with read access to sales, CRM and articles, and write access to whatever the AI should change. A write the user's role does not allow answers 403.\n\n**Finding records**: get tools take the internal id, a numeric string such as `\"3346\"`, not the document number. To find a document by its number, filter the list tool: `orderNumber-eq=SO-1042`, `invoiceNumber-eq=RE-1042`, `quotationNumber-eq=AN-1042`, `articleNumber-eq=A5101`. Customers and suppliers both live under `/party`: filter with `customer-eq=true` or `supplier-eq=true`.\n\n**Filter syntax**: `filter` is sent as query parameters written `property-operator=value` and joined with `&`, e.g. `paymentStatus-eq=OPEN&dueDate-lt=1767225600000`. Operators: `eq`, `ne`, `lt`, `gt`, `le`, `ge`, `like`, `ilike`, `in`, `notin`, `null`, `notnull`. `-null` and `-notnull` take an empty value (`customerNumber-notnull=`); `-in` takes a bracketed list. A property name that does not exist returns HTTP 400, in `filter` and in `properties` alike. Sales documents have no `customerName`: the name is in `recordAddress`, the link is `customerId`.\n\n**Data formats**: dates are Unix epoch milliseconds, in responses and in filters (2026-01-01 00:00 UTC is `1767225600000`). Prices and quantities come back as decimal strings such as `\"119.00\"`. Unpaid invoices are `paymentStatus-eq=OPEN`, cancelled ones `status-eq=CANCELLED`, credit notes `salesInvoiceType-eq=CREDIT_NOTE`: leave credit notes out when adding up revenue.\n\n**Pagination and limits**: `page` starts at 1; `pageSize` defaults to 100 and is capped at 1000. Fetch the next page until a page returns fewer rows than `pageSize`. Leave `properties` empty: every field comes back, and a guessed field name fails the whole call with 400 \"unknown property\". When a list must be trimmed, use only the field names listed in that tool's `properties` description. weclapp has no fixed rate limit, but it queues requests under heavy load and answers HTTP 429 after 30 seconds in the queue: wait, then retry.\n\n**Recurring invoices**: `/recurringInvoice` is not part of weclapp's published OpenAPI file. If `weclapp_list_recurring_invoices` returns 404 on a tenant, the resource is not available there; invoices generated from a recurring invoice still carry its `recurringInvoiceId`.\n\n**Writing to weclapp**: every write tool changes the live ERP. Read the record first, tell the user what will change, and wait for their confirmation. The delete tools are installed switched off. If a write tool named here is missing, the connector was set up before it existed: ask the user to apply the connector update in AnythingMCP.\n- Partial updates: the `weclapp_edit_*` tools send `PUT` with `ignoreMissingProperties=true`, weclapp's documented way to change single fields, so fields you leave out keep their values. Send only what changes: read-only fields such as `createdDate`, `statusHistory` or amounts are refused with HTTP 400 (\"property ... is read-only\"), so never copy a whole record back.\n- Lists are replaced, not merged: when you send `addresses`, `contacts`, `partyEmailAddresses`, `orderItems`, `quotationItems` or `articlePrices`, include every entry to keep by its `id` (the id alone keeps it unchanged) and new entries without an id. An entry you leave out is deleted.\n- Optimistic locking: pass the record's `version` from your last read and weclapp refuses the change with HTTP 409 \"optimistic lock error\" if someone changed it meanwhile; read again and retry. Without `version` the check is skipped.\n- `dryRun: true` on create, edit and delete tools makes weclapp validate the request and answer without saving.\n- References are ids (`customerId`, `responsibleUserId`, `unitId`, `warehouseId`...). `weclapp_get_current_user` returns the token's own user. `weclapp_list_reference_data` lists users, warehouses, storage places, units, taxes, currencies, payment terms and the other lists those ids point to.\n- Decimals are sent as strings (`\"quantity\": \"2\"`, `\"unitPrice\": \"119.00\"`) and dates as epoch milliseconds. A position with your own price needs `manualUnitPrice: true` next to `unitPrice`.\n- Status changes: `weclapp_set_sales_order_status` sends the status alone (weclapp refuses a reopen combined with other changes), `weclapp_cancel_sales_order` cancels or closes an order, `weclapp_set_quotation_status` uses weclapp's quotation status action. `weclapp_invoice_sales_order` creates a draft invoice from an order; every call creates another one.\n- Single-field shortcuts: `weclapp_set_sales_invoice_responsible`, `weclapp_set_sales_order_responsible` and `weclapp_set_party_responsible` change only the responsible user. They do the same as the `weclapp_edit_*` tools with `{\"responsibleUserId\": ...}`, with less room for a wrong body.\n- Purchasing master data: `weclapp_add_supply_source` (supplier + purchase price for an article), `weclapp_add_manufacturer` and `weclapp_add_article_item_group` create records that articles and documents then reference by id.\n- Receivables: `weclapp_list_sales_open_items` lists the open items of sales invoices (filter `salesInvoiceId-eq=...`). `weclapp_set_sales_open_item_payment_state` marks one as PAID or UNPAID; it is installed switched off because it changes what customers owe, so turn it on only for users who reconcile payments, and only after the payment was checked.\n- E-mail addresses for documents: `weclapp_set_party_emails` maintains a party's `partyEmailAddresses` and picks the entry used for orders, invoices, quotations, delivery notes, dunning and purchasing. A new address takes two calls: add the entry, then assign its new id.\n- Stock is changed by bookings (`weclapp_book_stock_in`, `weclapp_book_stock_out`), not by editing the article; they take effect at once.\n\n**Cloud reachability**: `*.weclapp.com` is publicly reachable over HTTPS.", "requiredEnvVars": [ "WECLAPP_TENANT", "WECLAPP_API_TOKEN" @@ -396,7 +396,7 @@ }, { "name": "weclapp_list_reference_data", - "description": "Look up the records other weclapp records point to by id, to turn a name into the id a write tool needs: users (responsibleUserId, task assignees, time records), warehouses and storage places, units, tax rates, currencies, terms of payment, payment and shipment methods, carriers, customer categories, sectors, lead sources and ratings, sales stages, titles, departments and roles of contact persons, cost centers, article categories and task lists. Read-only. Example: resource=user with filter='lastName-ilike=%meier%' to find a user's id.", + "description": "Look up the records other weclapp records point to by id, to turn a name into the id a write tool needs: users (responsibleUserId, task assignees, time records), warehouses and storage places, units, tax rates, currencies, terms of payment, payment and shipment methods, carriers, customer categories, sectors, lead sources and ratings, sales stages, titles, departments and roles of contact persons, cost centers, article categories and task lists, plus the custom attribute definitions (the meaning of the customAttributes ids on records) and serial numbers (serialNumber-eq=... gives the articleId). Read-only. Example: resource=user with filter='lastName-ilike=%meier%' to find a user's id.", "parameters": { "type": "object", "properties": { @@ -432,7 +432,9 @@ "taskList", "manufacturer", "articleSupplySource", - "articleItemGroup" + "articleItemGroup", + "customAttributeDefinition", + "serialNumber" ], "description": "Which list to read." }, @@ -518,6 +520,180 @@ } } }, + { + "name": "weclapp_list_sales_open_items", + "description": "List the open items (Offene Posten) of sales invoices and credit notes: amount, amountOpen, amountPaid, amountDiscount, cleared, clearanceDate, openItemNumber, openItemType, the payments applied to it and salesInvoiceId. Use it to see what a customer still owes, or to get the open item id that weclapp_set_sales_open_item_payment_state needs (that tool takes the open item id, not the invoice id). Read-only.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (1-based)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Leave empty (the default): every field comes back. Set it only to trim a large list, and then use only names from this list: any other name fails with 400 \"unknown property\". Valid fields (weclapp API v2): id, version, createdDate, lastModifiedDate, amount, amountDiscount, amountOpen, amountPaid, clearanceDate, cleared, openItemNumber, openItemType, paymentApplications, salesInvoiceId." + }, + "filter": { + "type": "string", + "description": "weclapp filter as query parameters: `property-operator=value`, joined with `&` (NOT a `filter=` wrapper). Operators: -eq -ne -gt -lt -ge -le -like -ilike -in -notin -null -notnull; -null/-notnull take an EMPTY value, -in/-notin a bracketed list (`id-in=[1,2]`). Examples: 'name-ilike=%netto%', 'email-eq=anna@example.com'. Examples: 'salesInvoiceId-eq=4711', 'salesInvoiceId-in=[4711,4712]', 'cleared-eq=false' for items not settled yet." + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/salesOpenItem", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize", + "properties": "$properties", + "__rawquery": "$filter", + "sort": "$sort" + } + } + }, + { + "name": "weclapp_get_current_user", + "description": "Return the weclapp user the API token belongs to: id, name, e-mail, username, status and roles. Use its id as responsibleUserId or task assignee when the user means themselves, and to check whose permissions the connector has. Read-only.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/user/currentUser" + } + }, + { + "name": "weclapp_get_property_translations", + "description": "Read the translated texts of one record, e.g. an article's name and descriptions in English or French as maintained in weclapp. Returns per property its texts per locale. weclapp marks this endpoint as deprecated but still serves it. Read-only.", + "parameters": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The weclapp entity, e.g. article, articleCategory, unit, termOfPayment." + }, + "entityId": { + "type": "string", + "description": "Id of the record (numeric string)." + }, + "locale": { + "type": "string", + "description": "Optional language code, e.g. en or fr. Leave out for every language." + }, + "propertyNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional property names to read, e.g. [\"name\", \"description\"]. Leave out for every translated property." + } + }, + "required": [ + "entityName", + "entityId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/propertyTranslation/readPropertyTranslations", + "queryParams": { + "entityName": "$entityName", + "entityId": "$entityId", + "locale": "$locale", + "propertyNames": "$propertyNames" + } + } + }, + { + "name": "weclapp_list_warehouse_stock_movements", + "description": "List warehouse stock movements (Lagerbewegungen): which article moved, how much, in or out, when, from or to which storage place, and the order, shipment, production order or user behind it. Use it to explain a stock level or trace a serial or batch number. Filter by article and date, the full history is long. Read-only; stock is booked with weclapp_book_stock_in and weclapp_book_stock_out.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (1-based)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Leave empty (the default): every field comes back. Set it only to trim a large list, and then use only names from this list: any other name fails with 400 \"unknown property\". Valid fields (weclapp API v2): id, version, createdDate, lastModifiedDate, customAttributes, articleId, batchNumberId, costCenterId, incomingGoodsItemId, internalTransportReferenceId, movementNote, movementNumber, postingDate, productionOrderId, quantity, salesOrderItemId, serialNumbers, shipmentItemId, stockMovementType, storagePlaceId, transportationOrderId, userId, valuationPrice." + }, + "filter": { + "type": "string", + "description": "weclapp filter as query parameters: `property-operator=value`, joined with `&` (NOT a `filter=` wrapper). Operators: -eq -ne -gt -lt -ge -le -like -ilike -in -notin -null -notnull; -null/-notnull take an EMPTY value, -in/-notin a bracketed list (`id-in=[1,2]`). Examples: 'name-ilike=%netto%', 'email-eq=anna@example.com'. Examples: 'articleId-eq=5101&postingDate-ge=1767225600000', 'stockMovementType-eq=OUT_SHIPMENT'. stockMovementType values start with IN or OUT, e.g. IN_PURCHASE_ORDER, IN_INVENTORY, OUT_SHIPMENT, OUT_PRODUCTION_ORDER, IN_TRANSFER." + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/warehouseStockMovement", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize", + "properties": "$properties", + "__rawquery": "$filter", + "sort": "$sort" + } + } + }, + { + "name": "weclapp_list_production_orders", + "description": "List production orders (Fertigungsaufträge): number, article, target and actual quantity, target and actual start and end dates, status, warehouse, the components to pick (productionOrderItems) and work items, and the sales or purchase order item it was created for. Read-only.", + "parameters": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (1-based)" + }, + "pageSize": { + "type": "number", + "description": "Results per page (default: 100, max: 1000)" + }, + "properties": { + "type": "string", + "description": "Leave empty (the default): every field comes back. Set it only to trim a large list, and then use only names from this list: any other name fails with 400 \"unknown property\". Valid fields (weclapp API v2): id, version, createdDate, lastModifiedDate, customAttributes, actualEndDate, actualQuantity, actualStartDate, articleId, assemblyStoragePlaceId, pickingInstructions, picksComplete, productionOrderItems, productionOrderNumber, purchaseOrderItemId, salesOrderItemId, status, statusHistory, targetEndDate, targetQuantity, targetStartDate, warehouseId, workItems." + }, + "filter": { + "type": "string", + "description": "weclapp filter as query parameters: `property-operator=value`, joined with `&` (NOT a `filter=` wrapper). Operators: -eq -ne -gt -lt -ge -le -like -ilike -in -notin -null -notnull; -null/-notnull take an EMPTY value, -in/-notin a bracketed list (`id-in=[1,2]`). Examples: 'name-ilike=%netto%', 'email-eq=anna@example.com'. Examples: 'productionOrderNumber-eq=FA-1001', 'status-in=[NEW,STARTED]', 'articleId-eq=5101'. Status values: NEW, ENTRY_IN_PROGRESS, DOCUMENTS_PRINTED, STARTED, INTERRUPTED, CLOSED, CANCELLED." + }, + "sort": { + "type": "string", + "description": "Sort field, prefix with '-' for descending" + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/productionOrder", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize", + "properties": "$properties", + "__rawquery": "$filter", + "sort": "$sort" + } + } + }, { "name": "weclapp_add_party", "description": "Create a customer or supplier (a party) in weclapp. Set customer: true for a customer, supplier: true for a supplier (both is allowed). weclapp assigns the customer or supplier number from its number range when you leave it out. Returns the new party with its id. Search with weclapp_list_customers first so you do not create a duplicate, and confirm the details with the user before creating.", @@ -2442,7 +2618,7 @@ }, { "name": "weclapp_set_party_responsible", - "description": "Set the responsible user (e.g. the field sales rep) of a customer, supplier or lead and nothing else; every other field keeps its value (partial PUT with ignoreMissingProperties=true). Get user ids from weclapp_list_reference_data with resource=user. Confirm with the user before changing it.", + "description": "Set the responsible user (e.g. the field sales rep) of a customer, supplier or lead and, optionally, its fixed-responsible flag (fixedResponsibleUser), nothing else; every other field keeps its value (partial PUT with ignoreMissingProperties=true). Get user ids from weclapp_list_reference_data with resource=user. Confirm with the user before changing it.", "parameters": { "type": "object", "properties": { @@ -2454,6 +2630,10 @@ "type": "string", "description": "ID of the user to make responsible." }, + "fixedResponsibleUser": { + "type": "boolean", + "description": "Optional. weclapp's \"fixed responsible user\" flag of the party (fixedResponsibleUser). Send true to fix this user as the party's responsible user, false to clear the flag; leave it out to keep the current setting." + }, "dryRun": { "type": "boolean", "description": "true makes weclapp check the request and answer as if it had saved it, without saving anything." @@ -2473,7 +2653,8 @@ "dryRun": "$dryRun" }, "bodyMapping": { - "responsibleUserId": "$responsibleUserId" + "responsibleUserId": "$responsibleUserId", + "fixedResponsibleUser": "$fixedResponsibleUser" } } }, @@ -2628,6 +2809,147 @@ } } }, + { + "name": "weclapp_set_party_emails", + "description": "Maintain a customer's or supplier's e-mail addresses for documents (master data, E-Mail-Adressen) and choose which entry weclapp uses for sales orders, invoices, quotations, delivery notes, dunning letters and purchase documents. Partial PUT with ignoreMissingProperties=true: fields you do not send keep their values. It is two steps when the address is new: (1) send `partyEmailAddresses` with every entry to keep by its `id` plus the new entry without an id, then read the id weclapp gave the new entry from the answer; (2) call again with that id in `salesOrderEmailAddressesId`, `salesInvoiceEmailAddressesId` or another `...EmailAddressesId` field. `partyEmailAddresses` replaces the whole list: an existing entry you leave out is deleted, so read the party first (weclapp_get_customer). The plain `email` field of the party is changed with weclapp_edit_party. Tell the user what will change and confirm before calling.", + "parameters": { + "type": "object", + "properties": { + "partyId": { + "type": "string", + "description": "The weclapp party ID (numeric string), not the customer number." + }, + "partyEmailAddresses": { + "type": "array", + "description": "Optional. The full list of e-mail address entries. Existing entries by `id` (with their addresses), new ones without an id. Each entry has `toAddresses` and optionally `ccAddresses` and `bccAddresses`; several addresses in one field are separated by a semicolon.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id of an existing entry; leave out for a new one." + }, + "toAddresses": { + "type": "string", + "description": "Recipient address(es), e.g. rechnung@example.com." + }, + "ccAddresses": { + "type": "string", + "description": "CC address(es)." + }, + "bccAddresses": { + "type": "string", + "description": "BCC address(es)." + } + } + } + }, + "salesOrderEmailAddressesId": { + "type": "string", + "description": "Optional. Id of the partyEmailAddresses entry used for sales orders (order confirmations)." + }, + "salesInvoiceEmailAddressesId": { + "type": "string", + "description": "Optional. Id of the entry used for sales invoices." + }, + "quotationEmailAddressesId": { + "type": "string", + "description": "Optional. Id of the entry used for quotations." + }, + "deliveryEmailAddressesId": { + "type": "string", + "description": "Optional. Id of the entry used for delivery notes and shipments." + }, + "dunningEmailAddressesId": { + "type": "string", + "description": "Optional. Id of the entry used for dunning letters." + }, + "purchaseEmailAddressesId": { + "type": "string", + "description": "Optional. Id of the entry used for purchase orders to a supplier." + }, + "version": { + "type": "string", + "description": "The record's `version` from your last read. weclapp then refuses the change (409 optimistic lock error) if someone else changed the record in the meantime. Leave it out to skip that check." + }, + "dryRun": { + "type": "boolean", + "description": "true makes weclapp check the request and answer as if it had saved it, without saving anything." + } + }, + "required": [ + "partyId" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/party/id/{partyId}", + "encodePathParams": true, + "queryParams": { + "ignoreMissingProperties": "true", + "dryRun": "$dryRun" + }, + "bodyMapping": { + "partyEmailAddresses": "$partyEmailAddresses", + "salesOrderEmailAddressesId": "$salesOrderEmailAddressesId", + "salesInvoiceEmailAddressesId": "$salesInvoiceEmailAddressesId", + "quotationEmailAddressesId": "$quotationEmailAddressesId", + "deliveryEmailAddressesId": "$deliveryEmailAddressesId", + "dunningEmailAddressesId": "$dunningEmailAddressesId", + "purchaseEmailAddressesId": "$purchaseEmailAddressesId", + "version": "$version" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "weclapp_set_sales_open_item_payment_state", + "description": "Mark one sales open item as paid (PAID, with the payment date) or back to unpaid (UNPAID), through weclapp's updatePaymentState action. It changes the receivables of the company: it records no amount and no cash discount, the open item simply counts as settled or open again, and it does not book a bank transaction. Take the id from weclapp_list_sales_open_items (filter by salesInvoiceId), not the invoice id. Only after the payment has been checked against the bank or remittance advice: show the user the open item and the date, and confirm before calling. Installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "salesOpenItemId": { + "type": "string", + "description": "Id of the open item (numeric string) from weclapp_list_sales_open_items, not the invoice id." + }, + "paymentState": { + "type": "string", + "enum": [ + "PAID", + "UNPAID" + ], + "description": "PAID to settle the open item, UNPAID to reopen it." + }, + "datePaid": { + "type": "integer", + "description": "Payment date as Unix epoch milliseconds, e.g. 1767225600000 for 2026-01-01. Send it with PAID." + } + }, + "required": [ + "salesOpenItemId", + "paymentState" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/salesOpenItem/id/{salesOpenItemId}/updatePaymentState", + "encodePathParams": true, + "bodyMapping": { + "paymentState": "$paymentState", + "datePaid": "$datePaid" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, { "name": "weclapp_delete_party", "description": "Delete a customer, supplier, contact person or lead. weclapp refuses when documents still refer to the party; block or deactivate it with weclapp_edit_party instead. Installed switched off: turn it on only if the AI should delete in weclapp, and confirm every deletion with the user.", diff --git a/packages/backend/src/adapters/de/weclapp.write-tools.spec.ts b/packages/backend/src/adapters/de/weclapp.write-tools.spec.ts index 9843c3262..ef387666c 100644 --- a/packages/backend/src/adapters/de/weclapp.write-tools.spec.ts +++ b/packages/backend/src/adapters/de/weclapp.write-tools.spec.ts @@ -244,6 +244,62 @@ describe('weclapp adapter: write tools', () => { expect(sent().url).toBe(`${base}/task/id/1%2F..%2F..%2FsalesOrder%2Fid%2F2`); }); + it('sets the fixed-responsible flag of a party only when it is given', async () => { + await run('weclapp_set_party_responsible', { partyId: '7992', responsibleUserId: '3555', fixedResponsibleUser: true }); + expect(sent().url).toBe(`${base}/party/id/7992`); + expect(sent().params).toEqual({ ignoreMissingProperties: 'true' }); + expect(sent().data).toEqual({ responsibleUserId: '3555', fixedResponsibleUser: true }); + jest.clearAllMocks(); + await run('weclapp_set_party_responsible', { partyId: '7992', responsibleUserId: '3555' }); + expect(sent().data).toEqual({ responsibleUserId: '3555' }); + }); + + it('adds a party e-mail entry, then assigns it to invoices, with partial PUTs', async () => { + const partyEmailAddresses = [{ id: '901', toAddresses: 'info@example.com' }, { toAddresses: 'rechnung@example.com' }]; + await run('weclapp_set_party_emails', { partyId: '7992', partyEmailAddresses }); + expect(sent().method).toBe('PUT'); + expect(sent().url).toBe(`${base}/party/id/7992`); + expect(sent().params).toEqual({ ignoreMissingProperties: 'true' }); + expect(sent().data).toEqual({ partyEmailAddresses }); + jest.clearAllMocks(); + await run('weclapp_set_party_emails', { partyId: '7992', salesInvoiceEmailAddressesId: '902', salesOrderEmailAddressesId: '902', version: '4' }); + expect(sent().data).toEqual({ salesInvoiceEmailAddressesId: '902', salesOrderEmailAddressesId: '902', version: '4' }); + }); + + it('sets the payment state of a sales open item, installed switched off', async () => { + const t = tool('weclapp_set_sales_open_item_payment_state'); + expect(t.enabled).toBe(false); + expect(t.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(t.parameters.required).toEqual(['salesOpenItemId', 'paymentState']); + await run('weclapp_set_sales_open_item_payment_state', { salesOpenItemId: '612', paymentState: 'PAID', datePaid: 1767225600000 }); + expect(sent().method).toBe('POST'); + expect(sent().url).toBe(`${base}/salesOpenItem/id/612/updatePaymentState`); + expect(sent().data).toEqual({ paymentState: 'PAID', datePaid: 1767225600000 }); + }); + + it('reads open items, stock movements and production orders with weclapp filters', async () => { + for (const [name, path] of [ + ['weclapp_list_sales_open_items', '/salesOpenItem'], + ['weclapp_list_warehouse_stock_movements', '/warehouseStockMovement'], + ['weclapp_list_production_orders', '/productionOrder'], + ]) { + jest.clearAllMocks(); + await run(name, { filter: 'salesInvoiceId-in=[4711,4712]', pageSize: 50 }); + expect(sent().method).toBe('GET'); + expect(sent().url).toBe(`${base}${path}`); + expect(sent().params).toEqual({ pageSize: 50, 'salesInvoiceId-in': '[4711,4712]' }); + } + }); + + it('reads the current user and the translations of a record', async () => { + await run('weclapp_get_current_user', {}); + expect(sent().url).toBe(`${base}/user/currentUser`); + jest.clearAllMocks(); + await run('weclapp_get_property_translations', { entityName: 'article', entityId: '5101', locale: 'en', propertyNames: ['name', 'description'] }); + expect(sent().url).toBe(`${base}/propertyTranslation/readPropertyTranslations`); + expect(sent().params).toEqual({ entityName: 'article', entityId: '5101', locale: 'en', propertyNames: ['name', 'description'] }); + }); + it('installs every delete switched off', () => { const deletes = writes.filter((t) => t.endpointMapping.method === 'DELETE'); expect(deletes.map((t) => t.name).sort()).toEqual([ diff --git a/packages/backend/src/adapters/intl/amocrm.json b/packages/backend/src/adapters/intl/amocrm.json index aafb9e52d..0fb970ca3 100644 --- a/packages/backend/src/adapters/intl/amocrm.json +++ b/packages/backend/src/adapters/intl/amocrm.json @@ -3,7 +3,7 @@ "name": "amoCRM", "aliases": ["amo", "amo CRM", "амо", "амоCRM", "амоЦРМ", "амо црм"], "description": "Let your AI work in amoCRM: find and update deals across pipelines, look up contacts and companies, read tasks, notes and the event log, and log new tasks and notes. For accounts on amocrm.ru, with a long-lived token from an integration in your account.", - "instructions": "## amoCRM\nThis connector works with one amoCRM account on `amocrm.ru` through the **API v4** (`https://.amocrm.ru/api/v4`): deals (leads) with pipelines and stages, contacts, companies, custom fields, tasks, notes, users and the account's event log. It can create and update deals, create contacts and tasks, and add notes. It authenticates with a **long-lived token** (долгосрочный токен) of an integration in your own account.\n\n**Which connector**: amoCRM and Kommo are the same product. Accounts at `.amocrm.ru` use this connector; the old international addresses `.amocrm.com` forward there, so enter the same subdomain. Accounts at `.kommo.com` use the **Kommo** connector.\n\n**Setup (account administrator, about 5 minutes)**\n1. In amoCRM open **амоМаркет** (amoMarket) in the left menu, click **⋯** in the top right (next to **+ WEB HOOKS**) and choose **Создать интеграцию** (Create integration), then **Внешняя интеграция** (external integration).\n2. Enter a **Ссылка для перенаправления** (redirect URL): the form requires one, but the long-lived token never uses it, so any https address of yours will do. Tick the accesses under **Предоставить доступ**, give the integration a name and description, and click **Сохранить** (Save).\n3. Open the integration's **Ключи и доступы** (Keys and scopes) tab and click **Сгенерировать токен** (Generate token). Pick an expiry date (1 day to 5 years) and copy the token right away: amoCRM shows it only once.\n4. Paste it into `AMOCRM_ACCESS_TOKEN`, and your account's subdomain into `AMOCRM_SUBDOMAIN`: for `https://acme.amocrm.ru` enter `acme`.\n5. The token acts as the administrator who created it. Revoke it on the integration's **Выданные доступы** tab with **Отозвать доступ**; when it expires, generate a new one.\n\n**Read-only setup**: amoCRM tokens cannot be limited to reading, so switch off `amocrm_create_lead`, `amocrm_update_lead`, `amocrm_create_contact`, `amocrm_create_task` and `amocrm_add_note`. Nothing in this connector deletes data.\n\n**Ids and values**: every id is an integer, except event ids, which are strings. Deals are called leads in the API. Deal stages are `status_id` values inside a `pipeline_id` from `amocrm_list_pipelines`; 142 is Closed-won (Успешно реализовано) and 143 Closed-lost (Закрыто и не реализовано) in every pipeline. Responsible users come from `amocrm_list_users`. Phone, email and all other extra data live in `custom_fields_values`: `[{\"field_id\": 123, \"values\": [{\"value\": \"...\"}]}]`, select options by `enum_id`, contact phone and email by `field_code` PHONE or EMAIL with `enum_code` such as WORK; `amocrm_list_custom_fields` lists ids and options. Dates are Unix timestamps in seconds. Linked entities are in `_embedded`; add `with=contacts` (leads) or `with=leads` (contacts) to get them.\n\n**Notes and events**: `amocrm_list_notes` reads one record's timeline; `amocrm_list_notes_by_type` reads the notes of all leads, contacts or companies at once, filtered by note type, record ids or update time (e.g. every call logged this week). `amocrm_list_events` reads the account's event log: stage changes (`lead_status_changed`), new records, field and responsible-user changes, calls, chat messages, completed tasks. Its entity filter uses singular names (`lead`, `contact`, `company`, `customer`, `task`), unlike the other tools.\n\n**Pagination**: list tools take `page` (from 1) and `limit` (up to 250; 100 for `amocrm_list_events`). The response's `_links.next` shows that more exists; an empty answer (HTTP 204, no body) means there are no results or no more pages.\n\n**Rate limits**: at most 7 requests per second per integration and 50 per account. Over it amoCRM answers 429; repeated violations block the account's API access with 403 for every call. Avoid paging through all deals or events when a filter or search does the job.\n\n**Errors**: 401 means the token is wrong, expired or revoked, or no account has this subdomain on amocrm.ru (a Kommo account belongs in the Kommo connector). 402 means the subscription has ended: writes stop at once, reads 30 days later. 403 can mean the token's user lacks rights or API access was blocked after too many requests. 404 is a wrong id. 400 lists the invalid fields under `validation-errors`, for example a stage that does not belong to the pipeline or a custom field value of the wrong type.\n\n**Out of scope here**: deleting entities, customers (покупатели), unsorted (incoming) leads, chats and messages, lists (catalogs), webhooks and Salesbot.", + "instructions": "## amoCRM\nThis connector works with one amoCRM account on `amocrm.ru` through the **API v4** (`https://.amocrm.ru/api/v4`): deals (leads) with pipelines and stages, contacts, companies, custom fields, tasks, notes, users and the account's event log. It can create and update deals, create contacts and tasks, and add notes. It authenticates with a **long-lived token** (долгосрочный токен) of an integration in your own account.\n\n**Which connector**: amoCRM and Kommo are the same product. Accounts at `.amocrm.ru` use this connector; the old international addresses `.amocrm.com` forward there, so enter the same subdomain. Accounts at `.kommo.com` use the **Kommo** connector.\n\n**Setup (account administrator, about 5 minutes)**\n1. In amoCRM open **амоМаркет** (amoMarket) in the left menu, click **⋯** in the top right (next to **+ WEB HOOKS**) and choose **Создать интеграцию** (Create integration), then **Внешняя интеграция** (external integration).\n2. Enter a **Ссылка для перенаправления** (redirect URL): the form requires one, but the long-lived token never uses it, so any https address of yours will do. Tick the accesses under **Предоставить доступ**, give the integration a name and description, and click **Сохранить** (Save).\n3. Open the integration's **Ключи и доступы** (Keys and scopes) tab and click **Сгенерировать токен** (Generate token). Pick an expiry date (1 day to 5 years) and copy the token right away: amoCRM shows it only once.\n4. Paste it into `AMOCRM_ACCESS_TOKEN`, and your account's subdomain into `AMOCRM_SUBDOMAIN`: for `https://acme.amocrm.ru` enter `acme`.\n5. The token acts as the administrator who created it. Revoke it on the integration's **Выданные доступы** tab with **Отозвать доступ**; when it expires, generate a new one.\n\n**Read-only setup**: amoCRM tokens cannot be limited to reading, so switch off `amocrm_create_lead`, `amocrm_update_lead`, `amocrm_create_contact`, `amocrm_create_task` and `amocrm_add_note`. Nothing in this connector deletes data.\n\n**Ids and values**: every id is an integer, except event ids, which are strings. Deals are called leads in the API. Deal stages are `status_id` values inside a `pipeline_id` from `amocrm_list_pipelines`; 142 is Closed-won (Успешно реализовано) and 143 Closed-lost (Закрыто и не реализовано) in every pipeline. Loss reasons (the `loss_reason_id` of a lost deal) come from `amocrm_list_loss_reasons`. Responsible users come from `amocrm_list_users`. Phone, email and all other extra data live in `custom_fields_values`: `[{\"field_id\": 123, \"values\": [{\"value\": \"...\"}]}]`, select options by `enum_id`, contact phone and email by `field_code` PHONE or EMAIL with `enum_code` such as WORK; `amocrm_list_custom_fields` lists ids and options. Dates are Unix timestamps in seconds. Linked entities are in `_embedded`; add `with=contacts` (leads) or `with=leads` (contacts) to get them.\n\n**Where to start**: `amocrm_get_account` (account, currency, task types), then `amocrm_list_leads` / `amocrm_get_lead`, `amocrm_list_contacts` / `amocrm_get_contact`, `amocrm_list_companies` and `amocrm_list_tasks`.\n\n**Notes and events**: `amocrm_list_notes` reads one record's timeline; `amocrm_list_notes_by_type` reads the notes of all leads, contacts or companies at once, filtered by note type, record ids or update time (e.g. every call logged this week). `amocrm_list_events` reads the account's event log: stage changes (`lead_status_changed`), new records, field and responsible-user changes, calls, chat messages, completed tasks. Its entity filter uses singular names (`lead`, `contact`, `company`, `customer`, `task`), unlike the other tools.\n\n**Pagination**: list tools take `page` (from 1) and `limit` (up to 250; 100 for `amocrm_list_events`). The response's `_links.next` shows that more exists; an empty answer (HTTP 204, no body) means there are no results or no more pages.\n\n**Rate limits**: at most 7 requests per second per integration and 50 per account. Over it amoCRM answers 429; repeated violations block the account's API access with 403 for every call. Avoid paging through all deals or events when a filter or search does the job.\n\n**Errors**: 401 means the token is wrong, expired or revoked, or no account has this subdomain on amocrm.ru (a Kommo account belongs in the Kommo connector). 402 means the subscription has ended: writes stop at once, reads 30 days later. 403 can mean the token's user lacks rights or API access was blocked after too many requests. 404 is a wrong id. 400 lists the invalid fields under `validation-errors`, for example a stage that does not belong to the pipeline or a custom field value of the wrong type.\n\n**Out of scope here**: deleting entities, customers (покупатели), unsorted (incoming) leads, chats and messages, lists (catalogs), webhooks and Salesbot.", "region": "intl", "category": "crm", "icon": "amocrm", @@ -577,6 +577,18 @@ "path": "/leads/pipelines" } }, + { + "name": "amocrm_list_loss_reasons", + "description": "List the account's loss reasons (причины отказа) for deals: id, name and sort order. A lead closed as lost only carries loss_reason_id; use this list to name the reason, or to pick the id for amocrm_update_lead when closing a deal as lost.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/leads/loss_reasons" + } + }, { "name": "amocrm_list_custom_fields", "description": "List the custom fields of leads, contacts or companies: id, name, code (PHONE, EMAIL for contacts), type and, for select fields, the enum options with their ids. Use them to read and write custom_fields_values.", diff --git a/packages/backend/src/adapters/intl/amocrm.live.spec.ts b/packages/backend/src/adapters/intl/amocrm.live.spec.ts index 7c812878e..9b9707a48 100644 --- a/packages/backend/src/adapters/intl/amocrm.live.spec.ts +++ b/packages/backend/src/adapters/intl/amocrm.live.spec.ts @@ -54,6 +54,8 @@ const tool = (name: string) => { return t; }; const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +// Tools the Kommo adapter does not have yet; same API, so they map 1:1 there too. +const AMOCRM_ONLY = ['amocrm_list_loss_reasons']; const WRITES = ['amocrm_add_note', 'amocrm_create_contact', 'amocrm_create_lead', 'amocrm_create_task', 'amocrm_update_lead']; describe('amocrm adapter: static spec conformance', () => { @@ -81,9 +83,9 @@ describe('amocrm adapter: static spec conformance', () => { expect(() => normalizeSubdomainVariable('AMOCRM_SUBDOMAIN', 'acme.kommo.com', '.amocrm.ru', { singleLabel: true })).toThrow(/AMOCRM_SUBDOMAIN/); }); - it('offers the same tools as the Kommo adapter, under the amocrm_ prefix', () => { - expect(a.tools.map((t) => t.name)).toEqual(kommo.tools.map((t) => t.name.replace(/^kommo_/, 'amocrm_'))); - for (const t of a.tools) { + it('offers the same tools as the Kommo adapter, under the amocrm_ prefix, plus the amoCRM-only ones', () => { + expect(a.tools.map((t) => t.name).filter((n) => !AMOCRM_ONLY.includes(n))).toEqual(kommo.tools.map((t) => t.name.replace(/^kommo_/, 'amocrm_'))); + for (const t of a.tools.filter((x) => !AMOCRM_ONLY.includes(x.name))) { const twin = kommo.tools.find((k) => k.name === t.name.replace(/^amocrm_/, 'kommo_'))!; expect(t.endpointMapping).toEqual(twin.endpointMapping); expect(Object.keys(t.parameters.properties ?? {})).toEqual(Object.keys(twin.parameters.properties ?? {})); @@ -112,6 +114,11 @@ describe('amocrm adapter: static spec conformance', () => { expect(a.instructions).toMatch(/HTTP 204/); }); + it('lists the loss reasons that lost deals point to', () => { + expect(tool('amocrm_list_loss_reasons').endpointMapping).toEqual({ method: 'GET', path: '/leads/loss_reasons' }); + expect(a.instructions).toContain('amocrm_list_loss_reasons'); + }); + it('reads the event log and the notes of a whole entity type', () => { expect(tool('amocrm_list_events').endpointMapping).toMatchObject({ method: 'GET', path: '/events' }); expect(tool('amocrm_list_notes_by_type').endpointMapping).toMatchObject({ method: 'GET', path: '/{entity_type}/notes' }); @@ -238,6 +245,8 @@ live('amocrm adapter: live calls (read-only)', () => { expect(Array.isArray(users._embedded.users)).toBe(true); const pipelines = await run('amocrm_list_pipelines'); expect(Array.isArray(pipelines._embedded.pipelines)).toBe(true); + const reasons = await run('amocrm_list_loss_reasons'); + if (reasons) expect(Array.isArray(reasons._embedded.loss_reasons)).toBe(true); }, 30000); it('lists leads, contacts and lead custom fields', async () => { diff --git a/packages/backend/src/adapters/intl/chatwoot.json b/packages/backend/src/adapters/intl/chatwoot.json index 91b4a1b3d..1c75dec81 100644 --- a/packages/backend/src/adapters/intl/chatwoot.json +++ b/packages/backend/src/adapters/intl/chatwoot.json @@ -11,7 +11,7 @@ "category": "support", "icon": "chatwoot", "docsUrl": "https://developers.chatwoot.com/api-reference/introduction", - "instructions": "## Chatwoot\nThis connector uses the **Chatwoot Application API** (`/api/v1` and the `/api/v2` reports) of Chatwoot Cloud or your own self-hosted Chatwoot: inboxes, conversations and their messages, contacts, labels, agents, teams, canned responses and the support reports. Replies, private notes and new conversations are included but installed switched off.\n\n**Setup (about 2 minutes)**\n1. Sign in to Chatwoot with the user the AI should act as. Agents only see conversations of the inboxes they belong to and cannot read reports, so use an **administrator** for the full picture.\n2. Click your avatar, **Profile Settings**, and copy the **Access Token** at the bottom of the page into `CHATWOOT_API_TOKEN`. It does not expire; reset it on the same page.\n3. `CHATWOOT_URL` is the address you open Chatwoot at: `https://app.chatwoot.com` on Chatwoot Cloud, or your own domain (e.g. `https://chat.example.com`) when self-hosted. No path, no `/api`.\n4. `CHATWOOT_ACCOUNT_ID` is the number after `/app/accounts/` in the browser address bar (e.g. `https://app.chatwoot.com/app/accounts/3/dashboard` means 3). `chatwoot_get_profile` also lists every account of the token under `accounts`.\n5. Install and run `chatwoot_get_profile` to check the token.\n\nThe account token is not the Platform App token of a self-hosted super admin (`/platform/api`); this connector does not use the Platform API.\n\n**Self-hosted behind nginx**: the token travels in the `api_access_token` header. nginx drops headers with an underscore unless `underscores_in_headers on;` is set, which shows up as 401 on every call although the token is right.\n\n**Workflow**\n- Find things first: `chatwoot_list_inboxes`, `chatwoot_list_agents`, `chatwoot_list_teams`, `chatwoot_list_labels`, `chatwoot_list_inbox_agents`, `chatwoot_list_team_members`, `chatwoot_list_canned_responses`, `chatwoot_list_custom_attributes`, `chatwoot_get_account`.\n- Conversations: `chatwoot_list_conversations` (status open by default, 25 per page; page until the list is empty), `chatwoot_count_conversations` for counts only, `chatwoot_filter_conversations` for conditions such as \"created after a date and assigned to agent 4\", `chatwoot_get_conversation`, then `chatwoot_list_messages` for the thread. Message types: 0 incoming from the contact, 1 outgoing, 2 activity, 3 template; private notes have `private: true`.\n- Contacts: `chatwoot_search_contacts` (name, e-mail, phone, identifier), `chatwoot_list_contacts`, `chatwoot_get_contact`, `chatwoot_list_contact_conversations`.\n- Reports (administrator token): `chatwoot_get_report_summary` for a period with the previous period alongside, `chatwoot_get_report_timeseries` for a metric over time, `chatwoot_get_summary_by` to compare agents, inboxes or teams, `chatwoot_get_live_conversation_stats` for what is open and unattended now.\n\n**Dates**: conversation and message times, and the `since` / `until` of the reports, are Unix timestamps in seconds (UTC). Convert before quoting a time to the user. Report times (`avg_first_response_time`, `avg_resolution_time`, `reply_time`) are seconds.\n\n**Labels**: `chatwoot_set_conversation_labels` replaces the whole label list of a conversation. To add one, send the current labels plus the new title; to remove one, send the list without it.\n\n**Writes switched off**: `chatwoot_send_message` (reply or private note) and `chatwoot_create_conversation` reach the customer at once on WhatsApp, e-mail or chat, so an admin turns them on deliberately. Before each call show the user the text and the recipient and wait for a yes. A private note (`private: true`) is internal. WhatsApp Cloud API inboxes only deliver free text within 24 hours of the contact's last message; outside that window Chatwoot needs an approved template, which this connector does not send.\n\n**Errors**: 401 means a wrong or reset token (or nginx dropping the header). 404 on every call usually means a wrong `CHATWOOT_URL` or `CHATWOOT_ACCOUNT_ID`; 404 on one conversation means an id the user cannot see. Reports answer 401 or 403 for agents who are not administrators.", + "instructions": "## Chatwoot\nThis connector uses the **Chatwoot Application API** (`/api/v1` and the `/api/v2` reports) of Chatwoot Cloud or your own self-hosted Chatwoot: inboxes, conversations and their messages, contacts, labels, agents, teams, canned responses and the support reports. Replies, private notes and new conversations are included but installed switched off.\n\n**Setup (about 2 minutes)**\n1. Sign in to Chatwoot with the user the AI should act as. Agents only see conversations of the inboxes they belong to and cannot read reports, so use an **administrator** for the full picture.\n2. Click your avatar, **Profile Settings**, and copy the **Access Token** at the bottom of the page into `CHATWOOT_API_TOKEN`. It does not expire; reset it on the same page.\n3. `CHATWOOT_URL` is the address you open Chatwoot at: `https://app.chatwoot.com` on Chatwoot Cloud, or your own domain (e.g. `https://chat.example.com`) when self-hosted. No path, no `/api`.\n4. `CHATWOOT_ACCOUNT_ID` is the number after `/app/accounts/` in the browser address bar (e.g. `https://app.chatwoot.com/app/accounts/3/dashboard` means 3). `chatwoot_get_profile` also lists every account of the token under `accounts`.\n5. Install and run `chatwoot_get_profile` to check the token.\n\nThe account token is not the Platform App token of a self-hosted super admin (`/platform/api`); this connector does not use the Platform API.\n\n**Self-hosted behind nginx**: the token travels in the `api_access_token` header. nginx drops headers with an underscore unless `underscores_in_headers on;` is set, which shows up as 401 on every call although the token is right.\n\n**Workflow**\n- Find things first: `chatwoot_list_inboxes`, `chatwoot_list_agents`, `chatwoot_list_teams`, `chatwoot_list_labels`, `chatwoot_list_inbox_agents`, `chatwoot_list_team_members`, `chatwoot_list_canned_responses`, `chatwoot_list_custom_attributes`, `chatwoot_get_account`.\n- Conversations: `chatwoot_list_conversations` (status open by default, 25 per page; page until the list is empty), `chatwoot_count_conversations` for counts only, `chatwoot_filter_conversations` for conditions such as \"created after a date and assigned to agent 4\", `chatwoot_get_conversation`, then `chatwoot_list_messages` for the thread. Message types: 0 incoming from the contact, 1 outgoing, 2 activity, 3 template; private notes have `private: true`.\n- Contacts: `chatwoot_search_contacts` (name, e-mail, phone, identifier), `chatwoot_list_contacts`, `chatwoot_get_contact`, `chatwoot_list_contact_conversations`.\n- Reports (administrator token): `chatwoot_get_report_summary` for a period with the previous period alongside, `chatwoot_get_report_timeseries` for a metric over time, `chatwoot_get_summary_by` to compare agents, inboxes or teams, `chatwoot_get_live_conversation_stats` for what is open and unattended now.\n\n**Dates**: conversation and message times, and the `since` / `until` of the reports, are Unix timestamps in seconds (UTC). Convert before quoting a time to the user. Report times (`avg_first_response_time`, `avg_resolution_time`, `reply_time`) are seconds.\n\n**Labels**: `chatwoot_set_conversation_labels` replaces the whole label list of a conversation. To add one, send the current labels plus the new title; to remove one, send the list without it.\n\n**Writes switched off**: `chatwoot_send_message` (reply or private note) and `chatwoot_create_conversation` reach the customer at once on WhatsApp, e-mail or chat, so an admin turns them on deliberately. Before each call show the user the text and the recipient and wait for a yes. A private note (`private: true`) is internal. WhatsApp Cloud API inboxes only deliver free text within 24 hours of the contact's last message; outside that window Chatwoot needs an approved template, which this connector does not send.\n\n**Managing agents (switched off)**: `chatwoot_add_agent` invites a person (Chatwoot e-mails them at once and, on Chatwoot Cloud, the seat is billed), `chatwoot_update_agent` changes role or availability (Chatwoot wants the role on every update), `chatwoot_remove_agent` takes their access away. All three need an administrator token and are installed switched off; take the agent id from `chatwoot_list_agents`, show the change and wait for a yes.\n\n**Errors**: 401 means a wrong or reset token (or nginx dropping the header). 404 on every call usually means a wrong `CHATWOOT_URL` or `CHATWOOT_ACCOUNT_ID`; 404 on one conversation means an id the user cannot see. Reports answer 401 or 403 for agents who are not administrators.", "prerequisites": "A Chatwoot account (Cloud or self-hosted) and the access token of a user from Profile Settings; an administrator token is needed for the reports.", "requiredEnvVars": [ "CHATWOOT_URL", @@ -172,6 +172,145 @@ "path": "/api/v1/accounts/{{CHATWOOT_ACCOUNT_ID}}/agents" } }, + { + "name": "chatwoot_add_agent", + "description": "Invite a person to the account as an agent or administrator. Chatwoot sends them an invitation e-mail at once to set a password, and on Chatwoot Cloud every agent counts toward the paid seats. Needs an administrator token. Installed switched off. Show the name, e-mail and role to the user and wait for a yes before calling.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full name of the agent." + }, + "email": { + "type": "string", + "description": "E-mail address the invitation goes to." + }, + "role": { + "type": "string", + "enum": [ + "agent", + "administrator" + ], + "description": "agent sees only the conversations of their inboxes; administrator manages the whole account, its settings and reports." + }, + "availability": { + "type": "string", + "enum": [ + "online", + "busy", + "offline" + ], + "description": "Availability the agent starts with." + }, + "auto_offline": { + "type": "boolean", + "description": "true marks the agent offline automatically when they are away from Chatwoot." + } + }, + "required": [ + "name", + "email", + "role" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v1/accounts/{{CHATWOOT_ACCOUNT_ID}}/agents", + "bodyMapping": { + "name": "$name", + "email": "$email", + "role": "$role", + "availability": "$availability", + "auto_offline": "$auto_offline" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + }, + "enabled": false + }, + { + "name": "chatwoot_update_agent", + "description": "Change an agent's role (agent or administrator), availability or auto-offline setting. Promoting to administrator gives access to every inbox, the settings and the reports. Needs an administrator token. Installed switched off. Show the change to the user and wait for a yes.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "Agent id from chatwoot_list_agents (the user id, not the e-mail)." + }, + "role": { + "type": "string", + "enum": [ + "agent", + "administrator" + ], + "description": "Role to set; Chatwoot requires it on every update, so send the current role to keep it." + }, + "availability": { + "type": "string", + "enum": [ + "online", + "busy", + "offline" + ], + "description": "New availability." + }, + "auto_offline": { + "type": "boolean", + "description": "true marks the agent offline automatically when they are away from Chatwoot." + } + }, + "required": [ + "agent_id", + "role" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/api/v1/accounts/{{CHATWOOT_ACCOUNT_ID}}/agents/{agent_id}", + "bodyMapping": { + "role": "$role", + "availability": "$availability", + "auto_offline": "$auto_offline" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + }, + "enabled": false + }, + { + "name": "chatwoot_remove_agent", + "description": "Remove an agent from the account. They lose access to the account at once; to undo it, invite them again. Needs an administrator token. Installed switched off. Name the agent to the user and wait for a yes.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "integer", + "description": "Agent id from chatwoot_list_agents (the user id, not the e-mail)." + } + }, + "required": [ + "agent_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/api/v1/accounts/{{CHATWOOT_ACCOUNT_ID}}/agents/{agent_id}" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + }, + "enabled": false + }, { "name": "chatwoot_list_teams", "description": "List the teams of the account (id, name, description, whether auto-assignment is on). The id is what team_id takes.", diff --git a/packages/backend/src/adapters/intl/chatwoot.live.spec.ts b/packages/backend/src/adapters/intl/chatwoot.live.spec.ts index f85d8aed3..50a5dacc6 100644 --- a/packages/backend/src/adapters/intl/chatwoot.live.spec.ts +++ b/packages/backend/src/adapters/intl/chatwoot.live.spec.ts @@ -4,8 +4,6 @@ import { applyResponseTransform } from '../../connectors/response-transform.util /** * Chatwoot Application API (developers.chatwoot.com, swagger in the chatwoot repository): * api_access_token header, /api/v1/accounts/{id}/... and the /api/v2 reports. - * Built from a customer's hand-built Cloud connector (self-hosted Chatwoot) and checked - * read-only with its token on 22 Oct 2026. * Live part: CHATWOOT_URL=... CHATWOOT_API_TOKEN=... CHATWOOT_ACCOUNT_ID=... RUN_CHATWOOT_LIVE=1 npx jest src/adapters/intl/chatwoot * (reads only: profile, inboxes, open conversations, contacts). */ @@ -54,9 +52,7 @@ const used = (t: Tool) => { return out; }; const PREFIX = 'chatwoot_'; -const WRITES: string[] = ["chatwoot_create_conversation", "chatwoot_send_message"]; -// Tool names of the customers' own connectors for this API on the Cloud (read 22 Oct 2026). -const HAND_BUILT: string[] = ["add_new_agent_to_account", "contactsearch", "conversation_add_labels", "conversationfilter", "conversationlist", "create_a_new_message_in_a_conversation", "delete_agent_from_account", "fetchprofile", "get_account_agents", "get_details_of_a_conversation", "get_inbox_members", "get_sso_url_of_a_user", "getconversationmessages", "list_all_account_users", "list_all_labels", "list_all_messages", "listallinboxes", "newconversation"]; +const WRITES: string[] = ["chatwoot_add_agent", "chatwoot_create_conversation", "chatwoot_remove_agent", "chatwoot_send_message", "chatwoot_update_agent"]; describe('chatwoot adapter: static spec conformance', () => { @@ -87,6 +83,17 @@ describe('chatwoot adapter: static spec conformance', () => { expect(tool('chatwoot_set_conversation_labels').description).toMatch(/REPLACES/); }); + it('maps agent management to the documented Agents routes, switched off', () => { + const ACC = '/api/v1/accounts/{{CHATWOOT_ACCOUNT_ID}}'; + expect(tool('chatwoot_add_agent').endpointMapping).toEqual({ method: 'POST', path: `${ACC}/agents`, bodyMapping: { name: '$name', email: '$email', role: '$role', availability: '$availability', auto_offline: '$auto_offline' } }); + expect(tool('chatwoot_add_agent').parameters?.required).toEqual(['name', 'email', 'role']); + expect(tool('chatwoot_update_agent').endpointMapping).toEqual({ method: 'PATCH', path: `${ACC}/agents/{agent_id}`, bodyMapping: { role: '$role', availability: '$availability', auto_offline: '$auto_offline' } }); + expect(tool('chatwoot_update_agent').parameters?.required).toEqual(['agent_id', 'role']); + expect(tool('chatwoot_remove_agent').endpointMapping).toEqual({ method: 'DELETE', path: `${ACC}/agents/{agent_id}` }); + for (const n of ['chatwoot_add_agent', 'chatwoot_update_agent', 'chatwoot_remove_agent']) expect(tool(n).enabled).toBe(false); + expect(tool('chatwoot_remove_agent').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + }); + it('never hands channel secrets or the user token to the model', () => { const inbox = applyResponseTransform( { payload: [{ id: 1, name: 'WhatsApp', channel_type: 'Channel::Whatsapp', provider_config: { api_key: 'k' }, hmac_token: 'h', website_token: 'w', imap_password: 'p' }] }, @@ -136,11 +143,6 @@ describe('chatwoot adapter: static spec conformance', () => { for (const n of WRITES) expect(tool(n).annotations?.readOnlyHint).toBe(false); }); - it('never reuses the tool names of hand-built Cloud connectors for the same API', () => { - const names = new Set(a.tools.map((t) => t.name)); - for (const n of HAND_BUILT) expect(`${n}:${names.has(n)}`).toBe(`${n}:false`); - }); - it('probes with a read tool and documents every tool in the instructions', () => { const probe = tool(a.probe.tool); expect(probe.annotations?.readOnlyHint === false).toBe(false); diff --git a/packages/backend/src/adapters/intl/daftra.json b/packages/backend/src/adapters/intl/daftra.json index 3cb850654..b92d4cf9f 100644 --- a/packages/backend/src/adapters/intl/daftra.json +++ b/packages/backend/src/adapters/intl/daftra.json @@ -3,7 +3,7 @@ "name": "Daftra", "description": "Work with Daftra, the cloud accounting and ERP of the Middle East, from your AI: look up invoices, clients, products, stock and the ledger, and create invoices, payments, expenses, purchases and journal entries with confirmation.", "aliases": ["Daftara", "دفترة"], - "instructions": "## Daftra\nThis connector calls the **Daftra REST API** (`https://.daftra.com/api2`) of one Daftra account with an API key: sales invoices, credit notes and refunds, payments, clients and suppliers, products and stock, purchase invoices, expenses, treasuries and the general ledger (journals and chart of accounts).\n\n**Getting credentials (about 2 minutes)**\n1. Sign in to Daftra as an administrator.\n2. Open **Settings > API > API Keys** and click **Generate API key**; give it a name such as AnythingMCP.\n3. Paste the key into `DAFTRA_API_KEY` and the first part of your Daftra address into `DAFTRA_SUBDOMAIN` (`acme` for `acme.daftra.com`). The key is sent in the `APIKEY` header, only to your Daftra account, and acts with that account's permissions.\n\n**Request shape**: Daftra wraps every record in its model name. Writes send `{\"Invoice\": {...}, \"InvoiceItem\": [...]}`, `{\"PurchaseOrder\": {...}, \"PurchaseOrderItem\": [...]}` or `{\"Journal\": {...}, \"JournalTransaction\": [...]}`; the tools build that envelope. Lists answer with `data` (each row under its model name) and a pagination block; page with `page` and `limit` (up to 1000).\n\n**Ids**: every tool takes the internal `id`, not the printed number (an invoice `no` such as 000123). Find ids with the list tools; `daftra_list_stores`, `daftra_list_treasuries` and `daftra_list_journal_accounts` give the ids that documents refer to; tax ids appear on existing invoice lines.\n\n**Writes change the books.** Invoices, payments, expenses, journals, purchases, refunds and stock movements post to the ledger and to stock. Describe the change and wait for the user's yes before any write. Deletes (`daftra_delete_invoice`, `daftra_delete_invoice_payment`, `daftra_delete_expense`) are installed switched off.\n\n**Dates** are `YYYY-MM-DD`; amounts are in the document currency.\n\n**Common errors**\n- 401 or 403: the API key is wrong, deleted or lacks permission. Generate a new one under Settings > API > API Keys.\n- 404 on every call: the subdomain is wrong. It is the part before `.daftra.com` in the address you sign in at.\n- 400 or 422: a required field is missing or an id does not exist; the message names it.\n\n**Cloud and self-hosted**: both call the account's own `daftra.com` subdomain over the internet; nothing else differs.\n\n**Out of scope here**: estimates, incomes and taxes (405 through the API key), OAuth sign-in, work orders, appointments, staff, time tracking, notes and attachments.", + "instructions": "## Daftra\nThis connector calls the **Daftra REST API** (`https://.daftra.com/api2`) of one Daftra account with an API key: sales invoices, credit notes and refunds, payments, clients and suppliers, products and stock, purchase invoices, expenses, treasuries and the general ledger (journals and chart of accounts).\n\n**Getting credentials (about 2 minutes)**\n1. As a Daftra administrator, open **Settings > API > API Keys** and click **Generate API key**.\n2. Paste the key into `DAFTRA_API_KEY` and the first part of your Daftra address into `DAFTRA_SUBDOMAIN` (`acme` for `acme.daftra.com`). The key is sent in the `APIKEY` header, only to your Daftra account, and acts with that account's permissions.\n\n**Request shape**: Daftra wraps every record in its model name. Writes send e.g. `{\"Journal\": {...}, \"JournalTransaction\": [...]}`; the tools build that envelope. Lists answer with `data` (each row under its model name) and a pagination block; page with `page` and `limit` (up to 1000).\n\n**Ids**: every tool takes the internal `id`, not the printed number (an invoice `no` such as 000123). Find ids with the list tools; `daftra_list_stores`, `daftra_list_treasuries` and `daftra_list_journal_accounts` give the ids that documents refer to; tax ids appear on existing invoice lines.\n\n**Writes change the books.** Invoices, payments, expenses, journals, purchases, refunds and stock movements post to the ledger and stock; `daftra_create_treasury` and `daftra_create_journal_category` change the account setup. Describe the change and wait for the user's yes before any write. Installed switched off: the deletes (`daftra_delete_invoice`, `daftra_delete_invoice_payment`, `daftra_delete_expense`, `daftra_delete_journal`), `daftra_update_journal` (replaces every line) and `daftra_set_invoice_draft_status` (issuing is final).\n\n**Dates** are `YYYY-MM-DD`; amounts are in the document currency.\n\n**Common errors**\n- 401 or 403: the API key is wrong, deleted or lacks permission; generate a new one.\n- 404 on every call: the subdomain is wrong (the part before `.daftra.com`).\n- 400 or 422: a required field is missing or an id does not exist; the message names it.\n\n**Cloud and self-hosted**: both call the account's own `daftra.com` subdomain over the internet; nothing else differs.\n\n**Out of scope here**: estimates, incomes and taxes (405 through the API key), OAuth sign-in, work orders, appointments, staff, time tracking, notes and attachments.", "region": "intl", "category": "accounting", "icon": "daftra", @@ -797,6 +797,37 @@ } } }, + { + "name": "daftra_set_invoice_draft_status", + "enabled": false, + "description": "Issue a draft sales invoice (draft_status 0: it gets its official sequential number and posts its payment journals; with e-invoicing it is reported and can no longer be deleted) or move an issued invoice back to draft (1, refused once it has completed payments or refunds). Daftra exposes this as a GET, but it changes the invoice. Installed switched off. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "description": "Internal id of the sales invoice, from daftra_list_invoices." + }, + "draft_status": { + "type": "integer", + "enum": [0, 1], + "description": "0 issue the invoice, 1 set it back to draft." + } + }, + "required": ["id", "draft_status"] + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices/update_draft/{id}/{draft_status}.json" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + } + }, { "name": "daftra_create_client", "description": "Create a client (customer) with name and contact details. Returns the new client id for invoices. Check with daftra_list_clients first that the client does not exist yet. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", @@ -1330,6 +1361,57 @@ } } }, + { + "name": "daftra_update_journal", + "enabled": false, + "description": "Edit a manual journal entry: header in Journal and the COMPLETE set of lines in JournalTransaction (a line with id is updated, a line without id is added, any existing line left out is deleted). Total debit must equal total credit. Read the entry with daftra_get_journal first. Installed switched off. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "description": "Internal id of the journal entry, from daftra_list_journals." + }, + "Journal": { + "type": "object", + "description": "Header: date (YYYY-MM-DD, in an open financial period), currency_code (always send it, it is copied to every line), description, draft (0 posted, 1 draft). Send attachment with the current file ids to keep them; leaving it out detaches them.", + "properties": { + "date": { + "type": "string", + "description": "Entry date, YYYY-MM-DD." + }, + "currency_code": { + "type": "string", + "description": "Currency of the entry, e.g. SAR." + }, + "description": { + "type": "string", + "description": "What the entry is for." + } + }, + "required": ["currency_code"] + }, + "JournalTransaction": { + "type": "array", + "description": "All lines that should remain. Each: id (to keep an existing line), journal_account_id, currency_debit or currency_credit (the other 0), description. Lines carrying only debit/credit are discarded.", + "items": { + "type": "object" + }, + "minItems": 2 + } + }, + "required": ["id", "Journal", "JournalTransaction"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/journals/{id}.json", + "bodyMapping": { + "Journal": "$Journal", + "JournalTransaction": "$JournalTransaction" + } + } + }, { "name": "daftra_create_journal_account", "description": "Add a posting account to the chart of accounts under an existing parent category. The code must be unique. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", @@ -1369,6 +1451,106 @@ } } }, + { + "name": "daftra_create_journal_category", + "description": "Add a category (a folder of the chart of accounts that groups sub-categories and posting accounts). The numeric code must not be used by any category or account: a used code answers with a redirect instead of an error, so check daftra_list_journal_categories and daftra_list_journal_accounts first. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Category name as shown in the chart of accounts." + }, + "code": { + "type": "string", + "description": "Numeric code, unique across categories and accounts; by convention it extends the parent's code (51 under 5)." + }, + "journal_cat_id": { + "type": "integer", + "description": "Parent category id from daftra_list_journal_categories; -1 or left out for a top-level category." + }, + "type": { + "type": "integer", + "enum": [0, 1], + "description": "Normal balance side: 1 debit (assets, expenses), 0 credit (liabilities, income, equity). Defaults to 0." + } + }, + "required": ["name", "code"] + }, + "endpointMapping": { + "method": "POST", + "path": "/journal_cats.json", + "bodyMapping": { + "JournalCat": { + "name": "$name", + "code": "$code", + "journal_cat_id": "$journal_cat_id", + "type": "$type" + } + } + } + }, + { + "name": "daftra_create_treasury", + "description": "Add a treasury (cash box) or bank account that payments and expenses can be paid from or into. Created inactive unless active is 1; is_primary 1 makes it the default treasury for everyone. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the treasury or bank account." + }, + "type": { + "type": "integer", + "enum": [1, 3], + "description": "1 bank account, 3 treasury (cash). Defaults to 3." + }, + "active": { + "type": "integer", + "enum": [0, 1], + "description": "1 active, 0 inactive (the default when left out)." + }, + "is_primary": { + "type": "integer", + "enum": [0, 1], + "description": "1 makes it the default treasury and clears the flag on all others. Defaults to 0." + }, + "type_name": { + "type": "string", + "description": "Bank name, for bank accounts." + }, + "type_account_number": { + "type": "string", + "description": "Bank account number, for bank accounts." + }, + "currency_code": { + "type": "string", + "description": "ISO currency code, for bank accounts (cash treasuries use the account currency)." + }, + "description": { + "type": "string", + "description": "Free-text description." + } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/treasuries.json", + "bodyMapping": { + "Treasury": { + "name": "$name", + "type": "$type", + "active": "$active", + "is_primary": "$is_primary", + "type_name": "$type_name", + "type_account_number": "$type_account_number", + "currency_code": "$currency_code", + "description": "$description" + } + } + } + }, { "name": "daftra_create_stock_transaction", "description": "Record a manual stock movement (goods in or out) for one product in one warehouse, for example to correct a balance. Changes stock levels and their valuation. Changes live accounting data in Daftra: show the user what will be written and get their confirmation before calling.", @@ -1505,6 +1687,26 @@ "method": "DELETE", "path": "/expenses/{id}.json" } + }, + { + "name": "daftra_delete_journal", + "enabled": false, + "description": "Delete a manual journal entry by its internal id. Journals generated from an invoice, payment or other record, and entries dated in a closed financial period, cannot be deleted. Cannot be undone. Installed switched off. Confirm the entry (date, description, amounts) with the user before calling.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "description": "Internal id of the journal entry, from daftra_list_journals." + } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/journals/{id}.json" + } } ] } diff --git a/packages/backend/src/adapters/intl/daftra.live.spec.ts b/packages/backend/src/adapters/intl/daftra.live.spec.ts index 17d53fbb5..6af2fae23 100644 --- a/packages/backend/src/adapters/intl/daftra.live.spec.ts +++ b/packages/backend/src/adapters/intl/daftra.live.spec.ts @@ -96,6 +96,30 @@ describe('Daftra adapter — static contract', () => { InvoicePayment: { invoice_id: '$invoice_id', amount: '$amount' }, }); expect(byName.daftra_update_product.endpointMapping).toMatchObject({ method: 'PUT', path: '/products/{id}.json' }); + expect(byName.daftra_update_journal.endpointMapping).toEqual({ + method: 'PUT', + path: '/journals/{id}.json', + bodyMapping: { Journal: '$Journal', JournalTransaction: '$JournalTransaction' }, + }); + expect(byName.daftra_update_journal.parameters.required).toEqual(['id', 'Journal', 'JournalTransaction']); + expect(byName.daftra_create_treasury.endpointMapping).toMatchObject({ + method: 'POST', + path: '/treasuries.json', + bodyMapping: { Treasury: { name: '$name', type: '$type', active: '$active', currency_code: '$currency_code' } }, + }); + expect(byName.daftra_create_journal_category.endpointMapping).toEqual({ + method: 'POST', + path: '/journal_cats.json', + bodyMapping: { JournalCat: { name: '$name', code: '$code', journal_cat_id: '$journal_cat_id', type: '$type' } }, + }); + }); + + it('maps the invoice draft switch, a GET that writes, as a write', () => { + const tool = byName.daftra_set_invoice_draft_status as Tool & { annotations?: Record }; + expect(tool.endpointMapping).toEqual({ method: 'GET', path: '/invoices/update_draft/{id}/{draft_status}.json' }); + expect(tool.parameters.required).toEqual(['id', 'draft_status']); + expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(tool.description).toMatch(/confirm/i); }); it('asks for confirmation on writes and installs deletes switched off', () => { @@ -103,9 +127,19 @@ describe('Daftra adapter — static contract', () => { expect(writes.length).toBeGreaterThan(0); for (const tool of writes) expect(tool.description).toMatch(/confirm/i); const deletes = a.tools.filter((tool) => tool.endpointMapping.method === 'DELETE'); - expect(deletes.map((tool) => tool.name).sort()).toEqual(['daftra_delete_expense', 'daftra_delete_invoice', 'daftra_delete_invoice_payment']); + expect(deletes.map((tool) => tool.name).sort()).toEqual([ + 'daftra_delete_expense', + 'daftra_delete_invoice', + 'daftra_delete_invoice_payment', + 'daftra_delete_journal', + ]); for (const tool of deletes) expect(tool.enabled).toBe(false); - for (const tool of a.tools.filter((t) => t.endpointMapping.method !== 'DELETE')) expect(tool.enabled).not.toBe(false); + // Rewriting a posted journal and issuing an invoice are hard to undo: off as well. + const offWrites = ['daftra_update_journal', 'daftra_set_invoice_draft_status']; + for (const name of offWrites) expect(byName[name].enabled).toBe(false); + for (const tool of a.tools.filter((t) => t.endpointMapping.method !== 'DELETE' && !offWrites.includes(t.name))) { + expect(tool.enabled).not.toBe(false); + } }); it('keeps the instructions free of em dashes and within the house limits', () => { diff --git a/packages/backend/src/adapters/intl/etsy.json b/packages/backend/src/adapters/intl/etsy.json index f1053c93a..6a410b278 100644 --- a/packages/backend/src/adapters/intl/etsy.json +++ b/packages/backend/src/adapters/intl/etsy.json @@ -2,7 +2,7 @@ "slug": "etsy", "name": "Etsy", "description": "Run an Etsy shop from any AI agent: create draft listings, edit and publish them, add photos, set price and stock, add tracking to orders, and read listings, orders, receipts and reviews. OAuth2 with automatic refresh-token rotation + x-api-key dual auth.", - "instructions": "This connector uses the Etsy Open API v3 (developers.etsy.com). You authorize it once, in AnythingMCP; from then on the short-lived access token (1 hour) is renewed automatically, and the refresh token Etsy replaces on every renewal is stored for you.\n\n**Setup**:\n1. Register an app at https://www.etsy.com/developers/your-apps → **Create New App**.\n2. In the app's settings, add this **callback URL**: `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. Etsy compares it character for character (https, upper/lower case, no trailing slash).\n3. Note the app's **Keystring** and **Shared secret**. Enter them as `ETSY_CLIENT_ID` and `ETSY_CLIENT_SECRET`, leave `ETSY_REFRESH_TOKEN` empty, and install.\n4. Open the connector and click **Authorize with Provider**. Sign in to Etsy, approve, and you land back on the connector page with the tools ready. AnythingMCP runs Etsy's OAuth2 PKCE flow (S256) for you.\n\n**Scopes**: the connector asks for `email_r shops_r listings_r listings_w transactions_r transactions_w`: read your account, shops, listings and orders, edit listings, and add tracking to orders. It cannot delete listings. **Connectors installed before October 2026** asked only for the read scopes: to use the write tools, open the connector's OAuth settings, set the scopes above and click **Authorize with Provider** again; until then Etsy answers \"Access token lacks scope\".\n\n**Already have a refresh token?** You can paste it into `ETSY_REFRESH_TOKEN` instead of step 4, if you obtained it yourself through Etsy's OAuth2 PKCE flow for the same app. Connectors set up that way keep working as they are; there is nothing to do. Clicking **Authorize with Provider** on one replaces its token with a new one, and needs the callback URL from step 2.\n\n**Authentication**: the connector attaches both headers on every request:\n - `Authorization: Bearer `\n - `x-api-key: ${ETSY_CLIENT_ID}:${ETSY_CLIENT_SECRET}`\n\nEtsy requires the API key on **every** request, OAuth or not, and it must carry the\nKeystring AND the Shared secret separated by a colon. The Keystring alone earns a\n`403 {\"error\":\"Invalid API key: should be in the format 'keystring:shared_secret'.\"}`\non every call.\n\nThe access token is kept in memory and written back, together with the rotated refresh token, into this connector's own encrypted configuration, so a restart does not lose it. Per Etsy's policy, **refresh tokens rotate on each use**; AnythingMCP stores the new one from every refresh response and re-uses it automatically.\n\n**If a call says the access token could not be renewed**: an Etsy refresh token works once and expires after 90 days unused. Click **Authorize with Provider** on the connector page to get a new one.\n\n**Shop ID**: every account has a shop_id. Get it from `etsy_get_authenticated_user` then `etsy_get_user_shops`.\n\n**Pagination**: `?limit=N&offset=M` (limit max 100).\n\n**Rate limits**: 10k req/24h per app. On 429 back off.\n\n**Creating a listing** takes a few steps, in this order: find the category with `etsy_list_seller_taxonomy`; for a physical item get a shipping profile (`etsy_list_shipping_profiles`), a processing profile (`etsy_list_processing_profiles`) and, outside the EU, a return policy (`etsy_list_return_policies`); create the draft with `etsy_create_listing_draft`; add at least one photo with `etsy_add_listing_image`; publish with `etsy_edit_listing` and `state=active`. Change price or stock with `etsy_read_listing_inventory` then `etsy_set_listing_inventory`, which replaces the whole inventory. Etsy rejects titles with more than three words in capitals and tags longer than 20 characters; a shop on vacation cannot change listings.\n\n**Out of scope here**: deleting listings, payment account, billing, taxonomy edits.", + "instructions": "This connector uses the Etsy Open API v3 (developers.etsy.com). You authorize it once, in AnythingMCP; from then on the short-lived access token (1 hour) is renewed automatically, and the refresh token Etsy replaces on every renewal is stored for you.\n\n**Setup**:\n1. Register an app at https://www.etsy.com/developers/your-apps → **Create New App**.\n2. In the app's settings, add this **callback URL**: `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. Etsy compares it character for character (https, upper/lower case, no trailing slash).\n3. Note the app's **Keystring** and **Shared secret**. Enter them as `ETSY_CLIENT_ID` and `ETSY_CLIENT_SECRET`, leave `ETSY_REFRESH_TOKEN` empty, and install.\n4. Open the connector and click **Authorize with Provider**. Sign in to Etsy, approve, and you land back on the connector page with the tools ready. AnythingMCP runs Etsy's OAuth2 PKCE flow (S256) for you.\n\n**Scopes**: the connector asks for `email_r shops_r listings_r listings_w transactions_r transactions_w`: read your account, shops, listings and orders, edit listings, and add tracking to orders. It cannot delete listings. **Connectors installed before October 2026** asked only for the read scopes: to use the write tools, open the connector's OAuth settings, set the scopes above and click **Authorize with Provider** again; until then Etsy answers \"Access token lacks scope\".\n\n**Already have a refresh token?** You can paste it into `ETSY_REFRESH_TOKEN` instead of step 4, if you obtained it yourself through Etsy's OAuth2 PKCE flow for the same app. Connectors set up that way keep working as they are; there is nothing to do. Clicking **Authorize with Provider** on one replaces its token with a new one, and needs the callback URL from step 2.\n\n**Authentication**: the connector attaches both headers on every request:\n - `Authorization: Bearer `\n - `x-api-key: ${ETSY_CLIENT_ID}:${ETSY_CLIENT_SECRET}`\n\nEtsy requires the API key on **every** request, OAuth or not, and it must carry the\nKeystring AND the Shared secret separated by a colon. The Keystring alone earns a\n`403 {\"error\":\"Invalid API key: should be in the format 'keystring:shared_secret'.\"}`\non every call.\n\nThe access token is kept in memory and written back, together with the rotated refresh token, into this connector's own encrypted configuration, so a restart does not lose it. Per Etsy's policy, **refresh tokens rotate on each use**; AnythingMCP stores the new one from every refresh response and re-uses it automatically.\n\n**If a call says the access token could not be renewed**: an Etsy refresh token works once and expires after 90 days unused. Click **Authorize with Provider** on the connector page to get a new one.\n\n**Shop ID**: every account has a shop_id. Get it from `etsy_get_authenticated_user` then `etsy_get_user_shops`.\n\n**Reading the shop**: `etsy_get_listings_by_shop` lists your own listings in any state (active, draft, inactive, sold out); `etsy_get_shop_listings_active` is the public view any visitor sees; `etsy_list_shop_sections` and `etsy_get_listings_by_shop_section` go by shop section; `etsy_get_shop_reviews` lists the reviews. `etsy_list_taxonomy_properties` gives the properties (size, colour, material) a category allows.\n\n**Orders and sales**: `etsy_get_shop_receipts` lists orders and `etsy_get_shop_receipt` reads one with its line items. `etsy_get_shop_transactions` lists the sold line items of the whole shop (listing, SKU, quantity, price, variations), the view for sales per product. `etsy_get_listings_by_ids` then reads up to 100 of the listings behind them in one call. `etsy_add_order_tracking` marks an order as shipped with its tracking code, and Etsy e-mails the buyer at once.\n\n**Pagination**: `?limit=N&offset=M` (limit max 100).\n\n**Rate limits**: 10k req/24h per app. On 429 back off.\n\n**Creating a listing** takes a few steps, in this order: find the category with `etsy_list_seller_taxonomy`; for a physical item get a shipping profile (`etsy_list_shipping_profiles`), a processing profile (`etsy_list_processing_profiles`) and, outside the EU, a return policy (`etsy_list_return_policies`); create the draft with `etsy_create_listing_draft`; add at least one photo with `etsy_add_listing_image`; publish with `etsy_edit_listing` and `state=active`. Change price or stock with `etsy_read_listing_inventory` then `etsy_set_listing_inventory`, which replaces the whole inventory. Etsy rejects titles with more than three words in capitals and tags longer than 20 characters; a shop on vacation cannot change listings.\n\n**Out of scope here**: deleting listings, payment account, billing, taxonomy edits.", "region": "intl", "category": "e-commerce", "icon": "etsy", @@ -368,7 +368,7 @@ }, { "name": "etsy_get_listing", - "description": "Fetch one listing by listing_id with full details.", + "description": "Fetch one listing by listing_id with full details: title, description, state, price, quantity, tags, URL and the optional includes.", "parameters": { "type": "object", "properties": { @@ -393,9 +393,47 @@ } } }, + { + "name": "etsy_get_listings_by_ids", + "description": "Fetch up to 100 listings in one call by their listing ids, with the same details as etsy_get_listing (title, description, state, price, quantity, tags, URL). Use it to look up the listings behind a page of orders or transactions instead of one call per listing.", + "parameters": { + "type": "object", + "properties": { + "listing_ids": { + "type": "string", + "description": "Comma-separated listing ids, at most 100, e.g. \"1234567890,1234567891\" (listing_id from etsy_get_shop_transactions, etsy_get_shop_receipts or the listing tools)." + }, + "includes": { + "type": "string", + "description": "Optional extra data, comma-separated, any of: Images, Shop, User, Translations, Videos, Personalization, BuyerPrice." + }, + "currency": { + "type": "string", + "description": "Optional ISO 4217 currency code (e.g. EUR, USD) to convert the listing prices to." + }, + "buyer_country": { + "type": "string", + "description": "Optional ISO 3166-1 alpha-2 country of the buyer (e.g. DE, GB), for buyer-facing prices with VAT and included shipping. It does not filter the listings." + } + }, + "required": [ + "listing_ids" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/listings/batch", + "queryParams": { + "listing_ids": "$listing_ids", + "includes": "$includes", + "currency": "$currency", + "buyer_country": "$buyer_country" + } + } + }, { "name": "etsy_get_shop_receipts", - "description": "List orders (receipts) for the shop.", + "description": "List the shop's orders (receipts) with buyer, totals, payment and shipping status, filtered by creation or change date and by paid or shipped state. Pages with limit and offset.", "parameters": { "type": "object", "properties": { @@ -490,9 +528,45 @@ "path": "/shops/{shop_id}/receipts/{receipt_id}" } }, + { + "name": "etsy_get_shop_transactions", + "description": "List the shop's sold items (transactions, one per listing line of an order): transaction_id, receipt_id, listing_id, title, sku, quantity, price, shipping cost, variations, buyer user id and the paid, shipped and expected ship dates. Use it for sales per product or SKU; etsy_get_shop_receipts is the order-level view. Pages with limit and offset.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "minimum": 1, + "description": "Shop ID (from etsy_get_user_shops)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Per page, 1 to 100 (default 25)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Pagination offset (0 = first page)." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/transactions", + "queryParams": { + "limit": "$limit", + "offset": "$offset" + } + } + }, { "name": "etsy_get_shop_reviews", - "description": "List reviews (transactions with feedback) for the shop.", + "description": "List the shop's reviews (transactions with feedback): rating, review text, listing id and date, optionally since min_created. Pages with limit and offset.", "parameters": { "type": "object", "properties": { diff --git a/packages/backend/src/adapters/intl/etsy.live.spec.ts b/packages/backend/src/adapters/intl/etsy.live.spec.ts index 2527b8975..15250be2a 100644 --- a/packages/backend/src/adapters/intl/etsy.live.spec.ts +++ b/packages/backend/src/adapters/intl/etsy.live.spec.ts @@ -46,6 +46,47 @@ describe('etsy adapter — static spec conformance', () => { expect(tool.endpointMapping.queryParams.shop_section_ids).toBe('$shop_section_ids'); }); + /** + * getShopReceiptTransactionsByShop and getListingsByListingIds from Etsy's + * OpenAPI spec (https://developers.etsy.com/documentation/reference): + * read-only GETs, listing ids as one comma-separated value like the other + * id lists in this adapter. + */ + it('lists shop transactions and batches listings with the paths from the Etsy OpenAPI spec', () => { + const tools = (adapter as any).tools as Array; + const byName = (n: string) => tools.find((t) => t.name === n); + const tx = byName('etsy_get_shop_transactions'); + expect(tx.endpointMapping).toEqual({ + method: 'GET', + path: '/shops/{shop_id}/transactions', + queryParams: { limit: '$limit', offset: '$offset' }, + }); + expect(tx.parameters.required).toEqual(['shop_id']); + expect(Object.keys(tx.parameters.properties).sort()).toEqual(['limit', 'offset', 'shop_id']); + const batch = byName('etsy_get_listings_by_ids'); + expect(batch.endpointMapping).toEqual({ + method: 'GET', + path: '/listings/batch', + queryParams: { + listing_ids: '$listing_ids', + includes: '$includes', + currency: '$currency', + buyer_country: '$buyer_country', + }, + }); + expect(batch.parameters.required).toEqual(['listing_ids']); + expect(Object.keys(batch.parameters.properties).sort()).toEqual(['buyer_country', 'currency', 'includes', 'listing_ids']); + for (const t of [tx, batch]) { + expect(t.enabled).toBeUndefined(); + expect(a.instructions).toContain(t.name); + } + }); + + it('names every tool in the instructions and writes no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(adapter)).not.toMatch(/—/); + }); + it('targets the official Etsy Open API v3 base URL', () => { expect(a.connector.baseUrl).toBe('https://openapi.etsy.com/v3/application'); }); diff --git a/packages/backend/src/adapters/intl/exercise-com.json b/packages/backend/src/adapters/intl/exercise-com.json index 4c1343811..bddea8a9e 100644 --- a/packages/backend/src/adapters/intl/exercise-com.json +++ b/packages/backend/src/adapters/intl/exercise-com.json @@ -1,12 +1,12 @@ { "slug": "exercise-com", "name": "Exercise.com", - "description": "Exercise.com gym and coaching software: members and staff, appointments and classes, scheduled occurrences and rosters, visits and check-ins, calendar, services, locations, availability schedules, waitlists, assessments, groups and logged workouts. Tenant API v4 on your own platform host.", + "description": "Exercise.com gym and coaching software: members and staff, appointments and classes, scheduled occurrences and rosters, visits and check-ins, calendar, services, locations, availability schedules, waitlists, assessments, groups and group calendars, purchases, appointment packages and logged workouts with per-exercise history. Tenant API v4 on your own platform host.", "region": "intl", "category": "sports", "icon": "exercise-com", "docsUrl": "https://www.exercise.com/support/how-to-use-the-exercise-com-api-keys/", - "instructions": "**Credentials**: Exercise.com runs each business on its own host (your custom-branded platform, e.g. `train.yourgym.com` or `yourgym.exercise.com`). Put that host, without https://, in `EXERCISE_HOST`; the API is served from it at `/api/v4`.\n\nAPI access is a paid add-on: ask your Exercise.com account representative to enable it. The v4 API takes two credentials on each call, as its reference documents (security `api_key` and `bearer` together):\n1. `EXERCISE_API_KEY`: create it under **Account > Customize Platform > Developers > Add** and copy it at once (it is shown only once). Sent in the `API-TOKEN` header.\n2. `EXERCISE_ACCESS_TOKEN`: the bearer token of the account the API acts as (a Personal API Token issued in the product, or the token provided with API access), sent as `Authorization: Bearer`. Use a token of the owner or an admin to see every member.\nMost member, booking and service reads answer with the access token alone, but schedules, assessment templates and logged workouts answer 401 unless both are sent.\n\n**Where to start**: `exercise_com_list_users` gives member and staff ids (`user_id`). For bookings use `exercise_com_get_calendar` with `affinity=schedule` and a Unix-time window, or `exercise_com_list_appointments` / `exercise_com_list_scheduled_appointments`, then `exercise_com_list_visits` with `appointment_id` for who booked and attended.\n\n**Filtering**: most list tools take `filter`, Ransack predicates written as a query-string fragment: `q[status_eq]=completed&q[created_at_gteq]=2026-09-01`. Keys are `_` such as `_eq`, `_in`, `_gteq`, `_lteq`, `_cont`; `q[s]=created_at desc` sorts. Unknown fields are ignored or rejected depending on the resource, so start without a filter and look at the field names in the answer.\n\n**Appointments need a date window**: `exercise_com_list_appointments` over the whole history is slow enough to fail with HTTP 500, so its `filter` is required; pass `q[start_time_gteq]` and `q[start_time_lt]`.\n\n**Paging**: `per` and `page` (from 1). When either is set, the answer includes `meta.total`. Without them some lists return everything. `pluck_columns` returns a plain array of just those stored columns for every match (it can ignore `per`), handy for id lookups; computed fields such as `service_name` are not columns and make the call fail.\n\n**Times**: the calendar takes Unix timestamps in seconds for `start` and `end`; other filters take dates or ISO 8601 times. Visits carry the time zone of the business.\n\n**Errors**: 401 `Access denied` means a wrong host, a wrong or revoked token, or API access not enabled; 403 means the token's account may not see the resource; 404 a wrong id.\n\nThe interactive reference is at https://train.demoexercise.com/api-docs/ (\"API V4 Docs\"); its login is given in Exercise.com's support article on API keys (the docsUrl of this connector).\n\nPackages, user packages, invoices and charges are not part of the documented v4 tenant API; Exercise.com documents them only in its newer API at api.exercise.com.", + "instructions": "**Credentials**: Exercise.com runs each business on its own host (your custom-branded platform, e.g. `train.yourgym.com` or `yourgym.exercise.com`). Put that host, without https://, in `EXERCISE_HOST`; the API is served from it at `/api/v4`.\n\nAPI access is a paid add-on: ask your Exercise.com account representative to enable it. The v4 API takes two credentials on each call, as its reference documents (security `api_key` and `bearer` together):\n1. `EXERCISE_API_KEY`: create it under **Account > Customize Platform > Developers > Add** and copy it at once (it is shown only once). Sent in the `API-TOKEN` header.\n2. `EXERCISE_ACCESS_TOKEN`: the bearer token of the account the API acts as (a Personal API Token issued in the product, or the token provided with API access), sent as `Authorization: Bearer`. Use a token of the owner or an admin to see every member.\nMost member, booking and service reads answer with the access token alone, but schedules, assessment templates and logged workouts answer 401 unless both are sent.\n\n**Where to start**: `exercise_com_list_users` gives member and staff ids (`user_id`). For bookings use `exercise_com_get_calendar` with `affinity=schedule` and a Unix-time window, or `exercise_com_list_appointments` / `exercise_com_list_scheduled_appointments`, then `exercise_com_list_visits` with `appointment_id` for who booked and attended.\n\n**Tools by area**:\n- Members and staff: `exercise_com_list_users`, `exercise_com_get_user`, `exercise_com_list_purchased_items` (what a member bought and how they pay).\n- Bookings: `exercise_com_list_appointments`, `exercise_com_get_appointment`, `exercise_com_list_appointment_packages` (packages that pay for an appointment), `exercise_com_list_scheduled_appointments`, `exercise_com_get_scheduled_appointment`, `exercise_com_list_scheduled_appointment_members` (rosters; `for_user` for one member), `exercise_com_list_visits` (`for_user` for one member's bookings), `exercise_com_get_visit`, `exercise_com_list_waitlist_entries`, `exercise_com_get_calendar`.\n- Setup: `exercise_com_list_services`, `exercise_com_get_service`, `exercise_com_list_locations`, `exercise_com_list_schedules`, `exercise_com_list_groups`, `exercise_com_list_group_calendars`.\n- Assessments: `exercise_com_list_assessments`, `exercise_com_get_assessment` (template with its questions), `exercise_com_list_user_assessments`, `exercise_com_get_user_assessment` (a member's answers).\n- Training: `exercise_com_list_workouts`, `exercise_com_list_user_workout_exercises` (a member's logged sets per exercise over time).\n- Writes: `exercise_com_book_visit` books a member into an appointment, `exercise_com_cancel_visit` cancels a booking; confirm with the user first.\n\n**Filtering**: most list tools take `filter`, Ransack predicates written as a query-string fragment: `q[status_eq]=completed&q[created_at_gteq]=2026-09-01`. Keys are `_` such as `_eq`, `_in`, `_gteq`, `_lteq`, `_cont`; `q[s]=created_at desc` sorts. Unknown fields are ignored or rejected depending on the resource, so start without a filter and look at the field names in the answer.\n\n**Appointments need a date window**: `exercise_com_list_appointments` over the whole history is slow enough to fail with HTTP 500, so its `filter` is required; pass `q[start_time_gteq]` and `q[start_time_lt]`.\n\n**Paging**: `per` and `page` (from 1). When either is set, the answer includes `meta.total`. Without them some lists return everything. `pluck_columns` returns a plain array of just those stored columns for every match (it can ignore `per`), handy for id lookups; computed fields such as `service_name` are not columns and make the call fail.\n\n**Times**: the calendar takes Unix timestamps in seconds for `start` and `end`; other filters take dates or ISO 8601 times. Visits carry the time zone of the business.\n\n**Errors**: 401 `Access denied` means a wrong host, a wrong or revoked token, or API access not enabled; 403 means the token's account may not see the resource; 404 a wrong id.\n\nThe interactive reference is at https://train.demoexercise.com/api-docs/ (\"API V4 Docs\"); its login is given in Exercise.com's support article on API keys (the docsUrl of this connector).\n\nA member's package balances (credits left), invoices and charges are not part of the documented v4 tenant API, so this connector does not read them; `exercise_com_list_purchased_items` shows what was bought and `exercise_com_list_appointment_packages` which packages fit an appointment.", "prerequisites": "API access is a paid add-on of Exercise.com: your account representative enables it. You need an API key from **Account > Customize Platform > Developers** and an access token of the owner or an admin account.", "requiredEnvVars": [ "EXERCISE_HOST", @@ -131,7 +131,7 @@ }, { "name": "exercise_com_list_visits", - "description": "List visits (bookings and check-ins of a member for an appointment or class) with status, registered, completed, no-show and cancelled times, payment and package used. Filter by appointment or with a Ransack filter.", + "description": "List visits (bookings and check-ins of a member for an appointment or class) with status, registered, completed, no-show and cancelled times, payment and package used. Filter by appointment, by member (for_user), by ids or with a Ransack filter.", "parameters": { "type": "object", "properties": { @@ -143,6 +143,14 @@ "type": "string", "description": "Pass true to return only finished visits for appointments and check-ins." }, + "for_user": { + "type": "string", + "description": "A user id: only the visits that user can access (their own bookings and check-ins)." + }, + "with_ids": { + "type": "string", + "description": "Comma-separated visit ids to fetch; paging is ignored when set." + }, "filter": { "type": "string", "description": "Ransack filter as a query-string fragment of q[...] predicates joined with &, e.g. q[status_eq]=completed&q[created_at_gteq]=2026-09-01. Predicates: _eq, _not_eq, _in, _lt, _lteq, _gt, _gteq, _cont, _null; q[s]=created_at desc sorts." @@ -167,6 +175,8 @@ "queryParams": { "appointment_id": "$appointment_id", "active_visits": "$active_visits", + "for_user": "$for_user", + "with_ids": "$with_ids", "__rawquery": "$filter", "pluck_columns": "$pluck_columns", "per": "$per", @@ -252,6 +262,26 @@ "path": "/api/v4/fbm/appointments/{id}" } }, + { + "name": "exercise_com_list_appointment_packages", + "description": "List the packages (class packs, memberships with visit credits) that can be used to book one appointment: id, name, price options, number of visits, expiry and the services they cover. Use it to tell a member which package pays for a class.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Appointment id from exercise_com_list_appointments." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v4/fbm/appointments/{id}/packages" + } + }, { "name": "exercise_com_list_scheduled_appointments", "description": "List scheduled appointments: the dated occurrences of recurring classes and sessions, with start and end, trainer, location and capacity.", @@ -309,7 +339,7 @@ }, { "name": "exercise_com_list_scheduled_appointment_members", - "description": "List the members booked into scheduled appointments (class rosters). Pass scheduled_appointment_id for one occurrence's roster.", + "description": "List the members booked into scheduled appointments (class rosters). Pass scheduled_appointment_id for one occurrence's roster. Pass for_user for the classes one member is booked into.", "parameters": { "type": "object", "properties": { @@ -321,6 +351,10 @@ "type": "integer", "description": "Together with scheduled_appointment_id: leave out members whose visit for this appointment was deleted." }, + "for_user": { + "type": "string", + "description": "A user id: only the roster entries of this member." + }, "filter": { "type": "string", "description": "Ransack filter as a query-string fragment of q[...] predicates joined with &, e.g. q[status_eq]=completed&q[created_at_gteq]=2026-09-01. Predicates: _eq, _not_eq, _in, _lt, _lteq, _gt, _gteq, _cont, _null; q[s]=created_at desc sorts." @@ -345,6 +379,7 @@ "queryParams": { "scheduled_appointment_id": "$scheduled_appointment_id", "appointment_id": "$appointment_id", + "for_user": "$for_user", "__rawquery": "$filter", "pluck_columns": "$pluck_columns", "per": "$per", @@ -627,6 +662,26 @@ } } }, + { + "name": "exercise_com_get_assessment", + "description": "Return one assessment template by id: name, description, its questions and fields, scoring formula and assignment settings. Members' answers are user assessments (exercise_com_list_user_assessments).", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Assessment id from exercise_com_list_assessments." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v4/assessments/{id}" + } + }, { "name": "exercise_com_list_user_assessments", "description": "List completed or assigned assessments of members, with answers, scores and dates. Filter by user_id for one member's results.", @@ -717,6 +772,31 @@ } } }, + { + "name": "exercise_com_list_group_calendars", + "description": "List the group calendars: the calendars attached to groups, used for group events and classes.", + "parameters": { + "type": "object", + "properties": { + "per": { + "type": "integer", + "description": "Records per page. When per or page is set the answer carries meta.total with the total count." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v4/groups/calendars", + "queryParams": { + "per": "$per", + "page": "$page" + } + } + }, { "name": "exercise_com_list_workouts", "description": "List completed (logged) workouts with date, user and summary. Defaults to the API user's own; set for_trainer=true for the whole platform, and start_date with end_date for a period.", @@ -782,6 +862,78 @@ } } }, + { + "name": "exercise_com_list_user_workout_exercises", + "description": "Return a member's logged history of workout exercises (sets, reps, weights), oldest first, paginated. Pass exercise_id for one exercise's progress, with_date=true to include the workout date (it lives on the workout, not the exercise), and pluck_columns to keep answers small.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "User id from exercise_com_list_users." + }, + "exercise_id": { + "type": "integer", + "description": "Only the history of this exercise." + }, + "with_date": { + "type": "boolean", + "description": "true to include the date of the workout each exercise belongs to." + }, + "sort": { + "type": "string", + "description": "Sort order sent as q[s]; workout_workout_date desc returns the newest first." + }, + "pluck_columns": { + "type": "string", + "description": "Comma-separated stored columns to return instead of full records, e.g. id,first_name,last_name,email. The answer is then a plain array of every match (per and page may be ignored); computed fields such as service_name are not columns and fail." + }, + "per": { + "type": "integer", + "description": "Records per page. When per or page is set the answer carries meta.total with the total count." + }, + "page": { + "type": "integer", + "description": "Page number, starting at 1." + } + }, + "required": [ + "id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v4/users/{id}/workout_exercises", + "queryParams": { + "exercise_id": "$exercise_id", + "with_date": "$with_date", + "q[s]": "$sort", + "pluck_columns": "$pluck_columns", + "per": "$per", + "page": "$page" + } + } + }, + { + "name": "exercise_com_list_purchased_items", + "description": "List what a member has bought: per item its type and id (package, workout plan, group...) and the payment option used (purchase or subscription, amount, currency, interval).", + "parameters": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "Member's user id from exercise_com_list_users; without it the API answers for the user the access token belongs to." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v4/carts/purchased_items", + "queryParams": { + "user_id": "$user_id" + } + } + }, { "name": "exercise_com_book_visit", "description": "Book a member into an appointment by creating a visit. Booking can charge the member or use one of their packages, depending on the service settings.", diff --git a/packages/backend/src/adapters/intl/exercise-com.live.spec.ts b/packages/backend/src/adapters/intl/exercise-com.live.spec.ts index 7b485917f..f6ca7cd53 100644 --- a/packages/backend/src/adapters/intl/exercise-com.live.spec.ts +++ b/packages/backend/src/adapters/intl/exercise-com.live.spec.ts @@ -35,6 +35,38 @@ describe('exercise-com adapter - static spec conformance', () => { it('calendar needs a Unix-time window', () => { expect(tool('exercise_com_get_calendar').parameters.required).toEqual(['start', 'end']); }); + + it('maps the added documented v4 reads', () => { + expect(tool('exercise_com_get_assessment').endpointMapping).toEqual({ method: 'GET', path: '/api/v4/assessments/{id}' }); + expect(tool('exercise_com_list_appointment_packages').endpointMapping).toEqual({ method: 'GET', path: '/api/v4/fbm/appointments/{id}/packages' }); + expect(tool('exercise_com_list_group_calendars').endpointMapping).toEqual({ method: 'GET', path: '/api/v4/groups/calendars', queryParams: { per: '$per', page: '$page' } }); + expect(tool('exercise_com_list_purchased_items').endpointMapping).toEqual({ method: 'GET', path: '/api/v4/carts/purchased_items', queryParams: { user_id: '$user_id' } }); + expect(tool('exercise_com_list_user_workout_exercises').endpointMapping).toEqual({ + method: 'GET', + path: '/api/v4/users/{id}/workout_exercises', + queryParams: { exercise_id: '$exercise_id', with_date: '$with_date', 'q[s]': '$sort', pluck_columns: '$pluck_columns', per: '$per', page: '$page' }, + }); + expect(tool('exercise_com_list_visits').endpointMapping.queryParams).toMatchObject({ for_user: '$for_user', with_ids: '$with_ids' }); + expect(tool('exercise_com_list_scheduled_appointment_members').endpointMapping.queryParams).toMatchObject({ for_user: '$for_user' }); + }); + + it('every declared parameter reaches the request, path parameters are required, every tool is in the instructions', () => { + const ins = (adapter as unknown as { instructions: string }).instructions; + for (const t of a.tools) { + const used = new Set(Object.values(t.endpointMapping.queryParams ?? {}).map((v) => v.replace(/^\$/, ''))); + for (const [, p] of t.endpointMapping.path.matchAll(/\{(\w+)\}/g)) { + used.add(p); + expect(`${t.name}:${p}:${(t.parameters.required ?? []).includes(p)}`).toBe(`${t.name}:${p}:true`); + } + const walk = (v: unknown): void => { + if (typeof v === 'string' && v.startsWith('$')) used.add(v.slice(1)); + else if (v && typeof v === 'object') Object.values(v).forEach(walk); + }; + walk((t.endpointMapping as { bodyMapping?: unknown }).bodyMapping); + for (const p of Object.keys(t.parameters.properties ?? {})) expect(`${t.name}:${p}:${used.has(p)}`).toBe(`${t.name}:${p}:true`); + expect(`${t.name}:${ins.includes(t.name)}`).toBe(`${t.name}:true`); + } + }); }); // Keyless: the vendor's public demo tenant answers 401 JSON for every v4 route diff --git a/packages/backend/src/adapters/intl/gmail.json b/packages/backend/src/adapters/intl/gmail.json index bd66a6a3a..8bcb432ee 100644 --- a/packages/backend/src/adapters/intl/gmail.json +++ b/packages/backend/src/adapters/intl/gmail.json @@ -2,7 +2,7 @@ "slug": "gmail", "name": "Gmail", "description": "Let your AI work in Gmail: search the mailbox with Gmail's own search syntax, read messages and conversations as plain text, see labels and unread counts, mark mail as read, archive, star or label it, write and send new mail, reply in a conversation, and save or send drafts. Personal Gmail and Google Workspace, signed in with your Google account.", - "instructions": "## Gmail\nThis connector reads, organises and writes the signed-in user's Gmail mailbox through the **Gmail API v1** (`https://gmail.googleapis.com/gmail/v1/users/me`). It works with personal Gmail accounts and with Google Workspace mailboxes. Everything runs as the Google account that authorized the connector: the AI sees exactly what that account can see.\n\n**Setup (one-time, about 10 minutes)**\n1. Open https://console.cloud.google.com, pick or create a project, then **APIs & Services → Library** → enable **Gmail API**.\n2. **APIs & Services → OAuth consent screen**: choose **Internal** if the mailbox is on Google Workspace (simplest, see *Google verification* below), otherwise **External**. For External, set the publishing status to **In production**: while an app is in *Testing*, Google revokes its refresh tokens after 7 days and the connector stops working every week.\n3. **APIs & Services → Credentials → Create credentials → OAuth client ID** → application type **Web application** → add the authorized redirect URI `/api/mcp-oauth/callback` (on AnythingMCP Cloud: `https://cloud.anythingmcp.com/api/mcp-oauth/callback`).\n4. Paste the client ID and secret into `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` and install the connector.\n5. On the connector page, click **Authorize with Provider** and sign in with the Gmail account the AI should work with. The refresh token is stored encrypted and access tokens are renewed automatically.\n\n**Google verification (restricted scopes)**: Google classifies `gmail.modify` and `gmail.readonly` as *restricted* scopes. An **External** app that other people use needs Google's OAuth app verification, which for restricted scopes includes a security assessment; until then Google shows a \"Google hasn't verified this app\" warning and caps the app at 100 users. An **Internal** app, used only by accounts of the Google Workspace organisation that owns the Cloud project, needs no verification: it is the straightforward route for a company. For a single personal Gmail account, an External app *In production* that only you use works after you continue past the warning.\n\n**Scope**: the connector asks for `https://www.googleapis.com/auth/gmail.modify`: read mail, change labels (mark read, archive, star), create drafts and send mail. It cannot permanently delete anything. For a read-only setup, change the scope to `https://www.googleapis.com/auth/gmail.readonly` in the connector's OAuth settings, authorize again, and disable `gmail_modify_message_labels`, `gmail_modify_thread_labels`, `gmail_create_draft`, `gmail_send_draft`, `gmail_send_message` and `gmail_reply`.\n\n**Finding mail**: `gmail_list_messages` and `gmail_list_threads` take `q` in the same syntax as the Gmail search box, e.g. `from:ana@example.com`, `is:unread`, `newer_than:7d`, `after:2026/10/01 before:2026/10/08`, `has:attachment`, `subject:offer`, `label:invoices`, `in:inbox`, `-category:promotions`. They return ids only (messages: `id` and `threadId`; threads: `id`, `snippet`, `historyId`); fetch the content with `gmail_get_message` or `gmail_get_thread`. `label_ids` filters on labels that must all be present.\n\n**Reading a message**\n- `gmail_get_message`, `gmail_get_thread` and `gmail_get_draft` return each message decoded: `from`, `to`, `cc`, `replyTo`, `subject`, `date`, `messageId`, `inReplyTo`, `references`, `labelIds`, `snippet`, and `text`, the body as plain text (the `text/plain` part when there is one, otherwise the HTML part converted to text; `textFormat` says which). A very long body is cut at 20,000 characters (100,000 across a thread) and then carries `textTruncated: true` and the full `textLength`.\n- Attachments are listed under `attachments` with `filename`, `mimeType`, `size` and `attachmentId`; this connector does not download them.\n- `format: \"metadata\"` with `metadata_headers` (e.g. `[\"From\", \"To\", \"Subject\", \"Date\"]`) returns those headers, labels and `snippet` without the body. It is the cheapest way to triage many messages.\n- `format: \"minimal\"` returns only ids and labels; `raw` returns the whole message as one base64url string, undecoded, and is rarely useful here.\n\n**Labels**: system labels have fixed ids: `INBOX`, `UNREAD`, `STARRED`, `IMPORTANT`, `SENT`, `DRAFT`, `SPAM`, `TRASH`, `CATEGORY_PERSONAL`, `CATEGORY_SOCIAL`, `CATEGORY_PROMOTIONS`, `CATEGORY_UPDATES`, `CATEGORY_FORUMS`. User labels have ids such as `Label_12`: take them from `gmail_list_labels`, not from the label name. `gmail_get_label` adds the message and unread counts. With `gmail_modify_message_labels` / `gmail_modify_thread_labels`: mark as read = remove `UNREAD`; mark as unread = add `UNREAD`; archive = remove `INBOX`; star = add `STARRED`; file = add the label id (and remove `INBOX` to move it out of the inbox). Up to 100 label ids per call.\n\n**Writing mail**\n- `gmail_send_message` writes and sends a new email at once. `gmail_create_draft` saves the same kind of message as a draft instead, for the user to check in Gmail; `gmail_send_draft` later sends a draft exactly as it is, Gmail then removes the draft and the message gets the `SENT` label. When the user has not clearly asked to send, prefer a draft.\n- Recipients (`to`, `cc`, `bcc`) are lists of addresses, each `name@example.com` or `Ana Müller `. Subjects and names may use any language and emoji; the connector encodes them for mail headers. `body` is plain text with line breaks; `html_body` optionally adds an HTML version that mail clients show instead.\n- **Replying**: read the message first with `gmail_get_message` (or the last message of `gmail_get_thread`), then call `gmail_reply` with its `threadId` as `thread_id`, its `messageId` as `in_reply_to`, its `references` (when present) as `references`, its `subject` as `subject` (\"Re: \" is added when missing) and its `replyTo`, or else `from`, as `to`. For reply-all, add the other `to` and `cc` addresses, except the user's own, to `cc`. Without `thread_id` and `in_reply_to` Gmail starts a new conversation. A reply draft is `gmail_create_draft` with the same `thread_id`, `in_reply_to`, `references` and a `Re:` subject.\n- Sending cannot be undone. Gmail sends from the signed-in address and adds Date and Message-ID itself.\n\n**Pagination**: list responses carry `nextPageToken` while more results exist; call the same tool again with the same arguments and `page_token` set to it. `max_results` goes up to 500 (default 100). `resultSizeEstimate` is an estimate, not an exact count.\n\n**Rate limits**: Gmail meters quota units: 6,000 per minute per user and 1,200,000 per minute per project. A list costs 5 units, `gmail_get_message` 20, `gmail_get_thread` 40, a label change 5 (10 on a thread), creating a draft 10, sending a message or a draft 100, the profile and labels 1. Over the limit Gmail answers 429 (or 403 `userRateLimitExceeded`): wait a minute and retry. One message reaches at most 500 recipients.\n\n**Common errors**\n- 401: the authorization expired or was revoked (always after 7 days while the consent screen is in *Testing*). Click **Authorize with Provider** again.\n- 403 `insufficientPermissions` / `ACCESS_TOKEN_SCOPE_INSUFFICIENT`: the scope does not allow the call, e.g. a label change on a read-only setup. 403 `accessNotConfigured` / \"Gmail API has not been used in project\": enable the Gmail API in the Cloud project (step 1).\n- 404: the id does not exist in this mailbox, or the message was deleted. A draft id (`r-…`) is not a message id.\n- 400 `invalidArgument`: a label id that does not exist, or an invalid query parameter. 400 \"Recipient address required\": `gmail_send_message` or `gmail_reply` without a `to`.\n- An error about an e-mail address, a Message-ID or a line break comes from the connector before anything is sent: fix the argument it names.\n\n**Out of scope here**: sending or downloading attachments, forwarding with the original attachments, trash and deletion, filters, settings, send-as aliases, other users' mailboxes.", + "instructions": "## Gmail\nThis connector reads, organises and writes the signed-in user's Gmail mailbox through the **Gmail API v1** (`https://gmail.googleapis.com/gmail/v1/users/me`). It works with personal Gmail accounts and with Google Workspace mailboxes. Everything runs as the Google account that authorized the connector: the AI sees exactly what that account can see.\n\n**Setup (one-time, about 10 minutes)**\n1. Open https://console.cloud.google.com, pick or create a project, then **APIs & Services → Library** → enable **Gmail API**.\n2. **APIs & Services → OAuth consent screen**: choose **Internal** if the mailbox is on Google Workspace (simplest, see *Google verification* below), otherwise **External**. For External, set the publishing status to **In production**: while an app is in *Testing*, Google revokes its refresh tokens after 7 days and the connector stops working every week.\n3. **APIs & Services → Credentials → Create credentials → OAuth client ID** → application type **Web application** → add the authorized redirect URI `/api/mcp-oauth/callback` (on AnythingMCP Cloud: `https://cloud.anythingmcp.com/api/mcp-oauth/callback`).\n4. Paste the client ID and secret into `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` and install the connector.\n5. On the connector page, click **Authorize with Provider** and sign in with the Gmail account the AI should work with. The refresh token is stored encrypted and access tokens are renewed automatically.\n\n**Google verification (restricted scopes)**: Google classifies `gmail.modify` and `gmail.readonly` as *restricted* scopes. An **External** app that other people use needs Google's OAuth app verification, which for restricted scopes includes a security assessment; until then Google shows a \"Google hasn't verified this app\" warning and caps the app at 100 users. An **Internal** app, used only by accounts of the Google Workspace organisation that owns the Cloud project, needs no verification: it is the straightforward route for a company. For a single personal Gmail account, an External app *In production* that only you use works after you continue past the warning.\n\n**Scope**: the connector asks for `https://www.googleapis.com/auth/gmail.modify`: read mail, change labels (mark read, archive, star), create drafts and send mail. It cannot permanently delete anything. For a read-only setup, change the scope to `https://www.googleapis.com/auth/gmail.readonly` in the connector's OAuth settings, authorize again, and disable `gmail_modify_message_labels`, `gmail_modify_thread_labels`, `gmail_create_draft`, `gmail_send_draft`, `gmail_send_message` and `gmail_reply`.\n\n**Finding mail**: `gmail_list_messages` and `gmail_list_threads` take `q` in the same syntax as the Gmail search box, e.g. `from:ana@example.com`, `is:unread`, `newer_than:7d`, `after:2026/10/01 before:2026/10/08`, `has:attachment`, `subject:offer`, `label:invoices`, `in:inbox`, `-category:promotions`. They return ids only (messages: `id` and `threadId`; threads: `id`, `snippet`, `historyId`); fetch the content with `gmail_get_message` or `gmail_get_thread`. `label_ids` filters on labels that must all be present.\n\n**Reading a message**\n- `gmail_get_message`, `gmail_get_thread` and `gmail_get_draft` return each message decoded: `from`, `to`, `cc`, `replyTo`, `subject`, `date`, `messageId`, `inReplyTo`, `references`, `labelIds`, `snippet`, and `text`, the body as plain text (the `text/plain` part when there is one, otherwise the HTML part converted to text; `textFormat` says which). A very long body is cut at 20,000 characters (100,000 across a thread) and then carries `textTruncated: true` and the full `textLength`.\n- Attachments are listed under `attachments` with `filename`, `mimeType`, `size` and `attachmentId`. `gmail_get_attachment` with the message id and that `attachmentId` downloads one: it returns `size` and `data`, the file as a base64url string. Large files make very long results, so check `size` first.\n- `format: \"metadata\"` with `metadata_headers` (e.g. `[\"From\", \"To\", \"Subject\", \"Date\"]`) returns those headers, labels and `snippet` without the body. It is the cheapest way to triage many messages.\n- `format: \"minimal\"` returns only ids and labels; `raw` returns the whole message as one base64url string, undecoded, and is rarely useful here.\n\n**Labels**: system labels have fixed ids: `INBOX`, `UNREAD`, `STARRED`, `IMPORTANT`, `SENT`, `DRAFT`, `SPAM`, `TRASH`, `CATEGORY_PERSONAL`, `CATEGORY_SOCIAL`, `CATEGORY_PROMOTIONS`, `CATEGORY_UPDATES`, `CATEGORY_FORUMS`. User labels have ids such as `Label_12`: take them from `gmail_list_labels`, not from the label name. `gmail_get_label` adds the message and unread counts. With `gmail_modify_message_labels` / `gmail_modify_thread_labels`: mark as read = remove `UNREAD`; mark as unread = add `UNREAD`; archive = remove `INBOX`; star = add `STARRED`; file = add the label id (and remove `INBOX` to move it out of the inbox). Up to 100 label ids per call.\n\n**Writing mail**\n- `gmail_send_message` writes and sends a new email at once. `gmail_create_draft` saves the same kind of message as a draft instead, for the user to check in Gmail; `gmail_send_draft` later sends a draft exactly as it is, Gmail then removes the draft and the message gets the `SENT` label. When the user has not clearly asked to send, prefer a draft.\n- Recipients (`to`, `cc`, `bcc`) are lists of addresses, each `name@example.com` or `Ana Müller `. Subjects and names may use any language and emoji; the connector encodes them for mail headers. `body` is plain text with line breaks; `html_body` optionally adds an HTML version that mail clients show instead.\n- **Replying**: read the message first with `gmail_get_message` (or the last message of `gmail_get_thread`), then call `gmail_reply` with its `threadId` as `thread_id`, its `messageId` as `in_reply_to`, its `references` (when present) as `references`, its `subject` as `subject` (\"Re: \" is added when missing) and its `replyTo`, or else `from`, as `to`. For reply-all, add the other `to` and `cc` addresses, except the user's own, to `cc`. Without `thread_id` and `in_reply_to` Gmail starts a new conversation. A reply draft is `gmail_create_draft` with the same `thread_id`, `in_reply_to`, `references` and a `Re:` subject.\n- Sending cannot be undone. Gmail sends from the signed-in address and adds Date and Message-ID itself.\n\n**Pagination**: list responses carry `nextPageToken` while more results exist; call the same tool again with the same arguments and `page_token` set to it. `max_results` goes up to 500 (default 100). `resultSizeEstimate` is an estimate, not an exact count.\n\n**Rate limits**: Gmail meters quota units: 6,000 per minute per user and 1,200,000 per minute per project. A list costs 5 units, `gmail_get_message` 20, `gmail_get_thread` 40, a label change 5 (10 on a thread), creating a draft 10, sending a message or a draft 100, the profile and labels 1. Over the limit Gmail answers 429 (or 403 `userRateLimitExceeded`): wait a minute and retry. One message reaches at most 500 recipients.\n\n**Common errors**\n- 401: the authorization expired or was revoked (always after 7 days while the consent screen is in *Testing*). Click **Authorize with Provider** again.\n- 403 `insufficientPermissions` / `ACCESS_TOKEN_SCOPE_INSUFFICIENT`: the scope does not allow the call, e.g. a label change on a read-only setup. 403 `accessNotConfigured` / \"Gmail API has not been used in project\": enable the Gmail API in the Cloud project (step 1).\n- 404: the id does not exist in this mailbox, or the message was deleted. A draft id (`r-…`) is not a message id.\n- 400 `invalidArgument`: a label id that does not exist, or an invalid query parameter. 400 \"Recipient address required\": `gmail_send_message` or `gmail_reply` without a `to`.\n- An error about an e-mail address, a Message-ID or a line break comes from the connector before anything is sent: fix the argument it names.\n\n**Out of scope here**: sending or downloading attachments, forwarding with the original attachments, trash and deletion, filters, settings, send-as aliases, other users' mailboxes.", "region": "intl", "category": "email", "icon": "gmail", @@ -157,6 +157,32 @@ "decode": "gmail-message" } }, + { + "name": "gmail_get_attachment", + "description": "Download one attachment of a message by its attachmentId, from the attachments list of gmail_get_message, gmail_get_thread or gmail_get_draft. Returns size (bytes) and data: the file content as one base64url string, for handing on to a tool that stores or reads files. Check size in the attachments list first: the whole file comes back as text, so prefer attachments of a few megabytes at most.", + "parameters": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "Id of the message that carries the attachment." + }, + "attachment_id": { + "type": "string", + "description": "attachmentId from the message's attachments list." + } + }, + "required": [ + "message_id", + "attachment_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/messages/{message_id}/attachments/{attachment_id}", + "encodePathParams": true + } + }, { "name": "gmail_list_threads", "description": "Search or list conversations (threads) with Gmail search syntax and optional label filters. Returns thread ids with a snippet and history id, nextPageToken while more results exist, and an estimated total; read a thread's messages with gmail_get_thread.", diff --git a/packages/backend/src/adapters/intl/gmail.live.spec.ts b/packages/backend/src/adapters/intl/gmail.live.spec.ts index 8b3c6f91a..0afbbadcb 100644 --- a/packages/backend/src/adapters/intl/gmail.live.spec.ts +++ b/packages/backend/src/adapters/intl/gmail.live.spec.ts @@ -129,6 +129,20 @@ describe('gmail adapter: static spec conformance', () => { expect(tool('gmail_send_draft').endpointMapping.bodyMapping).toEqual({ id: '$draft_id' }); expect(tool('gmail_list_messages').endpointMapping.queryParams?.labelIds).toBe('$label_ids'); }); + + it('downloads an attachment by message id and attachmentId (users.messages.attachments.get)', () => { + const t = tool('gmail_get_attachment'); + expect(t.endpointMapping).toEqual({ + method: 'GET', + path: '/messages/{message_id}/attachments/{attachment_id}', + encodePathParams: true, + }); + expect(t.parameters?.required).toEqual(['message_id', 'attachment_id']); + expect(Object.keys(t.parameters?.properties ?? {}).sort()).toEqual(['attachment_id', 'message_id']); + expect(t.enabled).toBeUndefined(); + expect(a.instructions).toContain('gmail_get_attachment'); + expect(a.instructions).not.toMatch(/does not download/); + }); }); const TOKEN = process.env.GMAIL_ACCESS_TOKEN; @@ -189,6 +203,17 @@ live('gmail adapter: live read-only calls', () => { expect(Array.isArray(thread.messages)).toBe(true); }, 30000); + it('downloads a small attachment when the mailbox has one', async () => { + const res = await run('gmail_list_messages', { q: 'has:attachment larger:1k smaller:500k', max_results: 1 }); + if (!res.messages?.length) return; + const msg = decode('gmail_get_message', await run('gmail_get_message', { message_id: res.messages[0].id })); + const att = (msg.attachments ?? [])[0]; + if (!att?.attachmentId) return; + const file = await run('gmail_get_attachment', { message_id: msg.id, attachment_id: att.attachmentId }); + expect(typeof file.size).toBe('number'); + expect(file.data).toMatch(/^[A-Za-z0-9_-]+=*$/); + }, 30000); + it('lists drafts', async () => { const res = await run('gmail_list_drafts', { max_results: 1 }); expect(typeof res.resultSizeEstimate).toBe('number'); diff --git a/packages/backend/src/adapters/intl/magento.json b/packages/backend/src/adapters/intl/magento.json index 8681497d0..738265835 100644 --- a/packages/backend/src/adapters/intl/magento.json +++ b/packages/backend/src/adapters/intl/magento.json @@ -1,8 +1,8 @@ { "slug": "magento", "name": "Magento (Adobe Commerce)", - "description": "Drive Magento 2 / Adobe Commerce from any AI agent: products, categories, orders, customers, stock and CMS pages and blocks. 21 tools, Bearer admin-token auth, per-store base URL.", - "instructions": "This connector uses the Magento 2 REST API (devdocs.magento.com).\n\n**Setup**:\n1. As Magento admin → **System → Integrations → Add New Integration**.\n2. Name + email, then on the API tab pick scopes: at minimum Catalog (Products + Categories + Stock), Customers, Sales (Orders + Invoices + Shipments).\n3. **Activate** the integration → grant access. Magento generates an **Access Token** (this is the long-lived bearer token).\n4. Set:\n - `MAGENTO_BASE_URL` = your storefront base URL (e.g. `https://shop.example.com`)\n - `MAGENTO_ACCESS_TOKEN` = the integration access token\n - Optionally `MAGENTO_STORE_VIEW` if you want a specific store view (default 'default' or use 'all')\n\n**Authentication**: `Authorization: Bearer ${MAGENTO_ACCESS_TOKEN}`.\n\n**Path prefix**: `/rest/{store_view}/V1/...`. The adapter uses `/rest/default/V1/` as default — if you need to target a different store view, replace baseUrl accordingly or pass `?storeCode=` (Magento accepts both).\n\n**Search criteria DSL**: Magento has a unique multi-key search syntax for list endpoints:\n```\n?searchCriteria[filter_groups][0][filters][0][field]=status\n&searchCriteria[filter_groups][0][filters][0][value]=1\n&searchCriteria[filter_groups][0][filters][0][condition_type]=eq\n&searchCriteria[pageSize]=20\n&searchCriteria[currentPage]=1\n```\nIt's verbose but powerful. The adapter exposes the most common filters as flat params and lets you pass arbitrary criteria via `searchCriteria_raw` for advanced cases.\n\n**SKU as primary key**: most product endpoints use SKU (not ID) in the URL: `/V1/products/{sku}`.\n\n**Rate limits**: not enforced by Magento core — depends on hosting. On 429 back off.\n\n**Out of scope here**: GraphQL endpoint (separate), Inventory MSI deep features, B2B/Commerce-only features (shared catalogs, company hierarchies), sales rules editing.\n\n**CMS pages and blocks** always go through the `all` store code (`/rest/all/V1/cmsPage`, `/rest/all/V1/cmsBlock`), Magento's admin scope, so a page written there is not silently moved to one store view. The create and update tools change the live storefront and are installed switched off; there is no delete tool.", + "description": "Drive Magento 2 / Adobe Commerce from any AI agent: products, categories, orders, customers, stock and low-stock items, guest carts and checkout, and CMS pages and blocks. Bearer admin-token auth, per-store base URL.", + "instructions": "This connector uses the Magento 2 / Adobe Commerce REST API (developer.adobe.com/commerce/webapi/rest).\n\n**Setup**:\n1. As Magento admin open **System > Extensions > Integrations > Add New Integration**.\n2. Name and e-mail, then on the API tab grant the resources you need: Catalog (Products, Categories, Inventory), Customers, Sales (Orders), Content (Pages, Blocks).\n3. **Activate** the integration and allow access. Magento shows an **Access Token** (long-lived).\n4. Set `MAGENTO_BASE_URL` to the store address (e.g. `https://shop.example.com`, without `/rest`) and `MAGENTO_ACCESS_TOKEN` to the integration access token.\n\n**Authentication**: `Authorization: Bearer `. Since 2.4.4 this needs Stores > Configuration > Services > OAuth > Consumer Settings > \"Allow OAuth Access Tokens to be used as standalone Bearer tokens\" set to Yes.\n\n**Store views**: requests go to `/rest/default/V1/...`, the default store view. `magento_list_store_views` lists the views and their codes. The guest cart tools take the store view code as `storeCode`; CMS pages and blocks always use `all` (see below).\n\n**Search criteria**: Magento list endpoints filter with `searchCriteria[filter_groups][i][filters][j][field|value|condition_type]`, `searchCriteria[pageSize]` and `searchCriteria[currentPage]`. The search tools turn their flat parameters into these keys; filters in different groups combine with AND.\n\n**SKU as key**: product endpoints use the SKU, not the numeric id, in the URL.\n\n**Rate limits**: not enforced by Magento core; they depend on the hosting. On 429 wait and try again.\n\n**Tools**: store views `magento_list_store_views`; catalog `magento_search_products`, `magento_get_product`, `magento_create_product`, `magento_update_product`, `magento_delete_product`, `magento_update_stock`, `magento_list_low_stock_items`, `magento_list_categories`; sales `magento_search_orders`, `magento_get_order`, `magento_cancel_order`; customers `magento_search_customers`, `magento_get_customer`; CMS `magento_search_cms_pages`, `magento_get_cms_page`, `magento_search_cms_blocks`, `magento_get_cms_block`, `magento_create_cms_page`, `magento_update_cms_page`, `magento_create_cms_block`, `magento_update_cms_block`.\n\n**Low stock**: `magento_list_low_stock_items` takes a quantity threshold and the stock scope (0 for the default stock). It reads the legacy catalog inventory; with Multi-Source Inventory the per-source stock is not in this list.\n\n**Guest checkout (an order on behalf of a guest)**: the guest cart tools work in one store view (`storeCode`, `default` unless the shop has several views; keep the same code for the whole cart). Flow: `magento_create_guest_cart` returns a cart id > `magento_add_guest_cart_item` per SKU > optionally `magento_apply_guest_cart_coupon` > `magento_estimate_guest_cart_shipping` for the carrier and method codes > `magento_set_guest_cart_shipping` with the addresses and the chosen method > `magento_list_guest_cart_payment_methods` > `magento_place_guest_order` with the customer e-mail and payment method, which returns the order id. `magento_get_guest_cart` and `magento_get_guest_cart_totals` show the cart and its totals at any step. The cart writes and the order placement are installed switched off: an admin turns them on for shops that take orders this way. Before placing an order show the items, totals, address and payment method and wait for a yes.\n\n**Out of scope here**: the GraphQL endpoint, Multi-Source Inventory sources, B2B features (shared catalogs, companies) and editing sales rules.\n\n**CMS pages and blocks** always go through the `all` store code (`/rest/all/V1/cmsPage`, `/rest/all/V1/cmsBlock`), Magento's admin scope, so a page written there is not silently moved to one store view. The create and update tools change the live storefront and are installed switched off; there is no delete tool.", "region": "intl", "category": "e-commerce", "icon": "magento", @@ -40,12 +40,12 @@ {"name": "magento_list_store_views", "description": "List the store views of the Magento / Adobe Commerce instance (id, code, name, website). A cheap read that also confirms the access token works.", "parameters": {"type": "object", "properties": {}}, "endpointMapping": {"method": "GET", "path": "/store/storeViews"}}, { "name": "magento_search_products", - "description": "Search products with Magento's search criteria DSL. For simple filters use the flat params (sku/name/status/visibility). For complex multi-condition use searchCriteria_raw.", + "description": "Search products with Magento's search criteria DSL. Filter by SKUs, name, status and visibility; filters combine with AND.", "parameters": { "type": "object", "properties": { "sku_in": { "type": "string", "description": "Comma-separated SKUs (matches with condition_type=in)." }, - "name_like": { "type": "string", "description": "Name LIKE substring (auto-wraps in %)." }, + "name_like": { "type": "string", "description": "Name pattern for a LIKE match; wrap it in % for a substring, e.g. %shirt%." }, "status": { "type": "integer", "description": "1=enabled, 2=disabled." }, "visibility": { "type": "integer", "description": "1=not visible, 2=catalog only, 3=search only, 4=catalog+search." }, "pageSize": { "type": "integer", "description": "Per page (default 20, max ~300 due to URL length)." }, @@ -114,12 +114,12 @@ }, { "name": "magento_update_product", - "description": "Update a product by SKU (PUT).", + "description": "Update a product by SKU: send only the fields to change (name, price, status, visibility, custom attributes...). Changes the live catalog: confirm with the user first.", "parameters": { "type": "object", "properties": { "sku": { "type": "string", "description": "Product SKU." }, - "product": { "type": "object", "description": "Partial product object — only fields to change." } + "product": { "type": "object", "description": "Partial product object: only the fields to change." } }, "required": ["sku", "product"] }, @@ -131,7 +131,7 @@ }, { "name": "magento_delete_product", - "description": "Permanently delete a product by SKU.", + "description": "Delete a product by SKU from the catalog. This cannot be undone; to take a product offline set its status to 2 (disabled) with magento_update_product instead.", "parameters": { "type": "object", "properties": { @@ -161,9 +161,49 @@ "bodyMapping": { "stockItem": "$stockItem" } } }, + { + "name": "magento_list_low_stock_items", + "description": "List stock items whose quantity is below a threshold: product id, SKU, qty, in-stock flag and low-stock date. Uses the legacy catalog inventory (single source); on shops that run Multi-Source Inventory the per-source quantities live elsewhere and this list can be empty.", + "parameters": { + "type": "object", + "properties": { + "qty": { + "type": "number", + "description": "Threshold: items with a quantity below this value are returned, e.g. 5." + }, + "scopeId": { + "type": "integer", + "default": 0, + "description": "Stock scope (website) id; 0 is the default stock used by single-source shops." + }, + "pageSize": { + "type": "integer", + "description": "Results per page, e.g. 50." + }, + "currentPage": { + "type": "integer", + "description": "1-based page." + } + }, + "required": [ + "qty", + "scopeId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/stockItems/lowStock/", + "queryParams": { + "qty": "$qty", + "scopeId": "$scopeId", + "pageSize": "$pageSize", + "currentPage": "$currentPage" + } + } + }, { "name": "magento_list_categories", - "description": "List the category tree (or below a rootCategoryId).", + "description": "Return the category tree (id, name, active flag, product count and children) from a root category down to a depth.", "parameters": { "type": "object", "properties": { @@ -182,14 +222,14 @@ }, { "name": "magento_search_orders", - "description": "Search orders.", + "description": "Search orders by status, customer e-mail and creation date window: increment id, state, totals, items, addresses and payment. Pages with pageSize and currentPage.", "parameters": { "type": "object", "properties": { "status": { "type": "string", "description": "pending, processing, complete, closed, canceled, holded." }, "customer_email": { "type": "string", "description": "Filter by customer email." }, - "created_at_from": { "type": "string", "description": "ISO 8601 — orders created after." }, - "created_at_to": { "type": "string", "description": "ISO 8601 — created before." }, + "created_at_from": { "type": "string", "description": "ISO 8601; orders created after this time." }, + "created_at_to": { "type": "string", "description": "ISO 8601; orders created before this time." }, "pageSize": { "type": "integer", "description": "Per page." }, "currentPage": { "type": "integer", "description": "1-based page." } } @@ -239,9 +279,336 @@ }, "endpointMapping": { "method": "POST", "path": "/orders/{orderId}/cancel" } }, + { + "name": "magento_create_guest_cart", + "description": "Start a new empty guest cart (a quote, not yet an order) in a store view and return its cart id for the other guest cart tools. Installed switched off together with the rest of the cart flow.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + } + }, + "required": [ + "storeCode" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + }, + "enabled": false + }, + { + "name": "magento_get_guest_cart", + "description": "Read a guest cart: items (SKU, name, qty, price, item id), customer e-mail, addresses, currency and whether it is still active (false once an order was placed from it).", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + } + }, + "required": [ + "storeCode", + "cartId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}" + } + }, + { + "name": "magento_add_guest_cart_item", + "description": "Add a product to a guest cart by SKU and quantity. Simple products only need sku and qty; a configurable or bundle product also needs product_option with the chosen options. Returns the cart item with its price. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + }, + "sku": { + "type": "string", + "description": "Product SKU." + }, + "qty": { + "type": "number", + "description": "Quantity to add." + }, + "product_option": { + "type": "object", + "description": "Only for configurable or bundle products, e.g. {\"extension_attributes\": {\"configurable_item_options\": [{\"option_id\": \"93\", \"option_value\": 52}]}}." + } + }, + "required": [ + "storeCode", + "cartId", + "sku", + "qty" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/items", + "bodyMapping": { + "cartItem": { + "sku": "$sku", + "qty": "$qty", + "quote_id": "$cartId", + "product_option": "$product_option" + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + }, + "enabled": false + }, + { + "name": "magento_apply_guest_cart_coupon", + "description": "Apply a coupon (discount) code to a guest cart; Magento answers 404 when the code does not exist or does not apply. Check the discount with magento_get_guest_cart_totals. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + }, + "couponCode": { + "type": "string", + "description": "The coupon code." + } + }, + "required": [ + "storeCode", + "cartId", + "couponCode" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/coupons/{couponCode}", + "encodePathParams": true + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true + }, + "enabled": false + }, + { + "name": "magento_estimate_guest_cart_shipping", + "description": "List the shipping methods and prices available for a guest cart to an address: carrier_code, method_code, amount and availability. Pass the codes to magento_set_guest_cart_shipping. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + }, + "address": { + "type": "object", + "description": "Destination; country_id is enough for a rough estimate, add postcode and region for an exact one. Address object: {\"firstname\": \"...\", \"lastname\": \"...\", \"street\": [\"...\"], \"city\": \"...\", \"postcode\": \"...\", \"country_id\": \"NL\", \"region\": \"...\", \"telephone\": \"...\", \"email\": \"...\"}." + } + }, + "required": [ + "storeCode", + "cartId", + "address" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/estimate-shipping-methods", + "bodyMapping": { + "address": "$address" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "magento_set_guest_cart_shipping", + "description": "Set the shipping address, billing address and shipping method of a guest cart. Returns the payment methods available and the totals. Installed switched off.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + }, + "addressInformation": { + "type": "object", + "description": "{\"shipping_address\": {...}, \"billing_address\": {...}, \"shipping_carrier_code\": \"flatrate\", \"shipping_method_code\": \"flatrate\"}. The codes come from magento_estimate_guest_cart_shipping. Address object: {\"firstname\": \"...\", \"lastname\": \"...\", \"street\": [\"...\"], \"city\": \"...\", \"postcode\": \"...\", \"country_id\": \"NL\", \"region\": \"...\", \"telephone\": \"...\", \"email\": \"...\"}." + } + }, + "required": [ + "storeCode", + "cartId", + "addressInformation" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/shipping-information", + "bodyMapping": { + "addressInformation": "$addressInformation" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true + }, + "enabled": false + }, + { + "name": "magento_list_guest_cart_payment_methods", + "description": "List the payment methods the guest cart can use (code and title, e.g. checkmo, banktransfer, cashondelivery, free). Available once the shipping information is set.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + } + }, + "required": [ + "storeCode", + "cartId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/payment-methods" + } + }, + { + "name": "magento_get_guest_cart_totals", + "description": "Read the totals of a guest cart: subtotal, discount (with the applied coupon), shipping, tax and grand total per item and overall, in the store view's currency.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + } + }, + "required": [ + "storeCode", + "cartId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/totals" + } + }, + { + "name": "magento_place_guest_order", + "description": "Place the order from a guest cart: sets the customer e-mail, payment method and billing address and turns the cart into a real order, which Magento confirms to the customer by e-mail. Returns the order id (use magento_get_order). Creates a binding order: installed switched off. Show the items, totals, address and payment method to the user and wait for a yes.", + "parameters": { + "type": "object", + "properties": { + "storeCode": { + "type": "string", + "default": "default", + "description": "Store view code the cart belongs to (prices, currency and language of that view), from magento_list_store_views, e.g. default. Use the same code for every call on one cart." + }, + "cartId": { + "type": "string", + "description": "The guest cart id (a 32-character masked id) returned by magento_create_guest_cart." + }, + "email": { + "type": "string", + "description": "Customer e-mail for the order." + }, + "paymentMethod": { + "type": "object", + "description": "{\"method\": \"checkmo\"}; the code comes from magento_list_guest_cart_payment_methods." + }, + "billingAddress": { + "type": "object", + "description": "Billing address, if not set with the shipping information. Address object: {\"firstname\": \"...\", \"lastname\": \"...\", \"street\": [\"...\"], \"city\": \"...\", \"postcode\": \"...\", \"country_id\": \"NL\", \"region\": \"...\", \"telephone\": \"...\", \"email\": \"...\"}." + } + }, + "required": [ + "storeCode", + "cartId", + "email", + "paymentMethod" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts/{cartId}/payment-information", + "bodyMapping": { + "email": "$email", + "paymentMethod": "$paymentMethod", + "billingAddress": "$billingAddress" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + }, + "enabled": false + }, { "name": "magento_search_customers", - "description": "Search customers with the same DSL.", + "description": "Search customers by e-mail or a part of the first or last name: id, name, e-mail, group, addresses and creation date.", "parameters": { "type": "object", "properties": { @@ -272,7 +639,7 @@ }, { "name": "magento_get_customer", - "description": "Fetch one customer by ID.", + "description": "Return one customer by numeric id with name, e-mail, group, default billing and shipping addresses and custom attributes.", "parameters": { "type": "object", "properties": { diff --git a/packages/backend/src/adapters/intl/magento.live.spec.ts b/packages/backend/src/adapters/intl/magento.live.spec.ts index b2eba3543..aaedfd90c 100644 --- a/packages/backend/src/adapters/intl/magento.live.spec.ts +++ b/packages/backend/src/adapters/intl/magento.live.spec.ts @@ -20,3 +20,67 @@ describe('magento adapter: CMS pages and blocks', () => { } }); }); + +type MTool = { name: string; enabled?: boolean; parameters: { properties?: Record; required?: string[] }; endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: Record }; annotations?: { readOnlyHint?: boolean } }; +const m = adapter as unknown as { instructions: string; tools: MTool[] }; +const mt = (n: string) => m.tools.find((x) => x.name === n)!; + +describe('magento adapter: low stock and guest checkout', () => { + const G = '{{MAGENTO_BASE_URL}}/rest/{storeCode}/V1/guest-carts'; + it('lists low-stock items with the required threshold and scope', () => { + expect(mt('magento_list_low_stock_items').endpointMapping).toEqual({ + method: 'GET', + path: '/stockItems/lowStock/', + queryParams: { qty: '$qty', scopeId: '$scopeId', pageSize: '$pageSize', currentPage: '$currentPage' }, + }); + expect(mt('magento_list_low_stock_items').parameters.required).toEqual(['qty', 'scopeId']); + }); + + it('maps the guest cart flow to the documented guest-carts routes in the chosen store view', () => { + expect(mt('magento_create_guest_cart').endpointMapping).toEqual({ method: 'POST', path: G }); + expect(mt('magento_add_guest_cart_item').endpointMapping).toEqual({ + method: 'POST', + path: `${G}/{cartId}/items`, + bodyMapping: { cartItem: { sku: '$sku', qty: '$qty', quote_id: '$cartId', product_option: '$product_option' } }, + }); + expect(mt('magento_apply_guest_cart_coupon').endpointMapping).toMatchObject({ method: 'PUT', path: `${G}/{cartId}/coupons/{couponCode}` }); + expect(mt('magento_estimate_guest_cart_shipping').endpointMapping).toEqual({ method: 'POST', path: `${G}/{cartId}/estimate-shipping-methods`, bodyMapping: { address: '$address' } }); + expect(mt('magento_set_guest_cart_shipping').endpointMapping).toEqual({ method: 'POST', path: `${G}/{cartId}/shipping-information`, bodyMapping: { addressInformation: '$addressInformation' } }); + expect(mt('magento_place_guest_order').endpointMapping).toEqual({ + method: 'POST', + path: `${G}/{cartId}/payment-information`, + bodyMapping: { email: '$email', paymentMethod: '$paymentMethod', billingAddress: '$billingAddress' }, + }); + for (const n of ['magento_get_guest_cart', 'magento_get_guest_cart_totals', 'magento_list_guest_cart_payment_methods']) expect(mt(n).endpointMapping.method).toBe('GET'); + expect(mt('magento_estimate_guest_cart_shipping').annotations?.readOnlyHint).toBe(true); + }); + + it('installs every cart write and the order placement switched off; reads stay on', () => { + const cart = m.tools.filter((t) => t.endpointMapping.path.includes('/guest-carts')); + expect(cart.length).toBe(9); + for (const t of cart) { + const write = t.endpointMapping.method !== 'GET' && t.annotations?.readOnlyHint !== true; + expect(`${t.name}:${t.enabled === false}`).toBe(`${t.name}:${write}`); + if (write) expect(t.annotations?.readOnlyHint).toBe(false); + } + }); + + it('every declared parameter reaches the request, path parameters are required, every tool is in the instructions', () => { + for (const t of m.tools) { + const used = new Set(); + for (const [, p] of t.endpointMapping.path.replace(/\{\{\w+\}\}/g, '').matchAll(/\{(\w+)\}/g)) { + used.add(p); + expect(`${t.name}:${p}:${(t.parameters.required ?? []).includes(p)}`).toBe(`${t.name}:${p}:true`); + } + const walk = (v: unknown): void => { + if (typeof v === 'string' && v.startsWith('$')) used.add(v.slice(1)); + else if (v && typeof v === 'object') Object.values(v).forEach(walk); + }; + walk(t.endpointMapping.queryParams); + walk(t.endpointMapping.bodyMapping); + for (const p of Object.keys(t.parameters.properties ?? {})) expect(`${t.name}:${p}:${used.has(p)}`).toBe(`${t.name}:${p}:true`); + expect(`${t.name}:${m.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + } + expect(JSON.stringify(adapter)).not.toMatch(/\u2014/); + }); +}); diff --git a/packages/backend/src/adapters/intl/printful.json b/packages/backend/src/adapters/intl/printful.json index 4c2c97c48..9112f2ebb 100644 --- a/packages/backend/src/adapters/intl/printful.json +++ b/packages/backend/src/adapters/intl/printful.json @@ -2,7 +2,7 @@ "slug": "printful", "name": "Printful", "description": "Printful print-on-demand: browse the product catalog, check production prices and shipping rates, estimate costs, draft and track orders, and see the products of your Printful stores, including Shopify, Etsy and WooCommerce stores. Printful API with a private token.", - "instructions": "## Printful\nThis connector talks to the Printful API (`https://api.printful.com`). Printful prints and ships products on demand (apparel, embroidery, posters, canvas, mugs, phone cases…) from its own fulfilment centres in North America, Europe and elsewhere. A Printful store is either linked to a platform (Shopify, WooCommerce, Etsy, Wix…), whose orders arrive on their own, or a **Manual order / API** store created in the Printful dashboard.\n\nThe connector uses Printful API v1, the documented stable version. Product prices come from the v2 endpoint `/v2/catalog-products/{id}/prices`, which Printful labels open beta and usable in production.\n\n## Setup\n1. Sign in at https://developers.printful.com with your Printful account and open **Your tokens** (https://developers.printful.com/tokens).\n2. Create a **private token**. Pick the access level:\n - **Store**: the token reaches one store, chosen now. Simplest; store tools need no `store_id`.\n - **Account**: the token reaches every store of the account. Store tools then need `store_id` (from `printful_list_stores`); it is sent as the `X-PF-Store-Id` header. Shipping rates and orders need a store too: an account with no store yet answers `This endpoint requires store_id`, so create one first (Stores → Add store → Manual order platform / API).\n3. Pick the scopes. For everything this connector does: `orders` (read and write orders; `orders/read` for a read-only setup) and `sync_products/read` (store products). `printful_list_stores` needs the stores scope (`stores_list`) where the token form offers it. The catalog, prices and shipping rates need no scope.\n4. Set an expiry date, create the token and copy it right away: it is not shown again. Paste it into `PRINTFUL_API_TOKEN`.\n\nThe token is sent as `Authorization: Bearer `. Private tokens expire on the date you chose and cannot be refreshed: create a new one before then.\n\n## Start here\n1. `printful_get_token_scopes` shows what the token may do; `printful_list_stores` lists the stores and their ids.\n2. Catalog: `printful_list_categories` → `printful_list_catalog_products` → `printful_get_catalog_product` for the variants (size and colour) and their ids; `printful_get_product_prices` for production prices per placement and technique.\n3. Before ordering: `printful_calculate_shipping_rates` for the shipping options, `printful_estimate_order_costs` for the full cost of an order.\n\n## Orders\n- `printful_create_draft_order` creates a **draft**: Printful does not submit it for fulfillment and only charges orders that are confirmed. Review it in the dashboard (Orders) and confirm it there or with `printful_confirm_order`.\n- An item is either a catalog `variant_id` with print `files` (public image URLs with a placement `type` such as `default` or `back`), or a `sync_variant_id` / `external_variant_id` of a product already set up in the store, which carries its own files.\n- `external_id` is your own order id; any order tool also takes it as `@` instead of Printful's numeric id.\n- Addresses: `country_code` is ISO 3166-1 alpha-2; `state_code` is required for the US, Canada and Australia; a tax number is required for Brazil.\n- Statuses: `draft` (not submitted), `pending` (submitted, can still be cancelled), `inreview`, `failed`, `canceled`, `inprocess` (in production), `onhold` (a problem to resolve), `partial` (some items shipped), `fulfilled` (all shipped), `archived`. Tracking numbers and URLs are in `shipments` of `printful_get_order`.\n- Orders from a linked Shopify, Etsy or WooCommerce store arrive in Printful by themselves; do not create them again.\n\n## Store products\nPrintful has two product lists, one per store kind:\n- **Manual order / API stores**: `printful_list_store_products` and `printful_get_store_product`.\n- **Platform-linked stores** (Shopify, WooCommerce, Etsy…): `printful_list_sync_products` and `printful_get_sync_product`, which show which platform variants are synced to a Printful product.\nUse the pair that matches the kind of store (see `type` in `printful_list_stores`).\n\n## Responses and paging\nAnswers have the form `{ \"code\": 200, \"result\": …, \"paging\": { \"total\", \"offset\", \"limit\" } }`. `printful_list_orders` and `printful_list_sync_products` page with `offset` and `limit` (at most 100). Prices are decimal strings; timestamps are Unix seconds.\n\n## Write tools\n`printful_create_draft_order` is on: a draft costs nothing. `printful_confirm_order` is installed switched off, because confirming charges the store's billing method and starts production; enable it on the connector page if you want it. No tool deletes or cancels anything.\n\n## Rate limits\n120 requests per minute per store (30 per minute for unauthenticated catalog calls). Every answer carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; over the limit Printful answers 429 with `Retry-After`, and the request is not processed.\n\n## Errors\n- 401: the token is wrong, expired or deleted. Create a new one under Your tokens.\n- 403: the token lacks the scope for this call, or an account-level token was used without `store_id`.\n- 400: a field is invalid; the message in `result` or `error.message` says which (for example a missing `state_code`, an unknown variant or a file URL Printful cannot download).\n- 404: unknown id. Look ids up with the list tools rather than guessing them.", + "instructions": "## Printful\nThis connector talks to the Printful API (`https://api.printful.com`). Printful prints and ships products on demand (apparel, embroidery, posters, canvas, mugs, phone cases…) from its own fulfilment centres in North America, Europe and elsewhere. A Printful store is either linked to a platform (Shopify, WooCommerce, Etsy, Wix…), whose orders arrive on their own, or a **Manual order / API** store created in the Printful dashboard.\n\nThe connector uses Printful API v1, the documented stable version. Product prices come from the v2 endpoint `/v2/catalog-products/{id}/prices`, which Printful labels open beta and usable in production.\n\n## Setup\n1. Sign in at https://developers.printful.com with your Printful account and open **Your tokens** (https://developers.printful.com/tokens).\n2. Create a **private token**. Pick the access level:\n - **Store**: the token reaches one store, chosen now. Simplest; store tools need no `store_id`.\n - **Account**: the token reaches every store of the account. Store tools then need `store_id` (from `printful_list_stores`); it is sent as the `X-PF-Store-Id` header. Shipping rates and orders need a store too: an account with no store yet answers `This endpoint requires store_id`, so create one first (Stores → Add store → Manual order platform / API).\n3. Pick the scopes. For everything this connector does: `orders` (read and write orders; `orders/read` for a read-only setup) and `sync_products/read` (store products). `printful_list_stores` needs the stores scope (`stores_list`) where the token form offers it. The catalog, prices and shipping rates need no scope.\n4. Set an expiry date, create the token and copy it right away: it is not shown again. Paste it into `PRINTFUL_API_TOKEN`.\n\nThe token is sent as `Authorization: Bearer `. Private tokens expire on the date you chose and cannot be refreshed: create a new one before then.\n\n## Start here\n1. `printful_get_token_scopes` shows what the token may do; `printful_list_stores` lists the stores and their ids.\n2. Catalog: `printful_list_categories` → `printful_list_catalog_products` → `printful_get_catalog_product` for the variants (size and colour) and their ids; `printful_get_product_prices` for production prices per placement and technique.\n3. Variant details and fit: `printful_get_catalog_variant` for one variant, `printful_get_product_size_guide` for measurements, `printful_get_printfiles` for the print areas and the pixel size a design file needs per placement (pass `technique` for embroidery and other non-DTG techniques).\n4. Before ordering: `printful_calculate_shipping_rates` for the shipping options, `printful_estimate_order_costs` for the full cost of an order.\n\n`printful_get_webhooks` shows the webhook URL and the event types enabled for a store (scope `webhooks/read`).\n\n## Orders\n- `printful_create_draft_order` creates a **draft**: Printful does not submit it for fulfillment and only charges orders that are confirmed. Review it in the dashboard (Orders) and confirm it there or with `printful_confirm_order`.\n- An item is either a catalog `variant_id` with print `files` (public image URLs with a placement `type` such as `default` or `back`), or a `sync_variant_id` / `external_variant_id` of a product already set up in the store, which carries its own files.\n- `external_id` is your own order id; any order tool also takes it as `@` instead of Printful's numeric id.\n- Addresses: `country_code` is ISO 3166-1 alpha-2; `state_code` is required for the US, Canada and Australia; a tax number is required for Brazil.\n- Statuses: `draft` (not submitted), `pending` (submitted, can still be cancelled), `inreview`, `failed`, `canceled`, `inprocess` (in production), `onhold` (a problem to resolve), `partial` (some items shipped), `fulfilled` (all shipped), `archived`. Tracking numbers and URLs are in `shipments` of `printful_get_order`.\n- Orders from a linked Shopify, Etsy or WooCommerce store arrive in Printful by themselves; do not create them again.\n\n## Store products\nPrintful has two product lists, one per store kind:\n- **Manual order / API stores**: `printful_list_store_products` and `printful_get_store_product`.\n- **Platform-linked stores** (Shopify, WooCommerce, Etsy…): `printful_list_sync_products` and `printful_get_sync_product`, which show which platform variants are synced to a Printful product.\nUse the pair that matches the kind of store (see `type` in `printful_list_stores`).\n\n## Responses and paging\nAnswers have the form `{ \"code\": 200, \"result\": …, \"paging\": { \"total\", \"offset\", \"limit\" } }`. `printful_list_orders` and `printful_list_sync_products` page with `offset` and `limit` (at most 100). Prices are decimal strings; timestamps are Unix seconds.\n\n## Write tools\n`printful_create_draft_order` is on: a draft costs nothing. `printful_confirm_order` is installed switched off, because confirming charges the store's billing method and starts production; enable it on the connector page if you want it. No tool deletes or cancels anything.\n\n## Rate limits\n120 requests per minute per store (30 per minute for unauthenticated catalog calls). Every answer carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; over the limit Printful answers 429 with `Retry-After`, and the request is not processed.\n\n## Errors\n- 401: the token is wrong, expired or deleted. Create a new one under Your tokens.\n- 403: the token lacks the scope for this call, or an account-level token was used without `store_id`.\n- 400: a field is invalid; the message in `result` or `error.message` says which (for example a missing `state_code`, an unknown variant or a file URL Printful cannot download).\n- 404: unknown id. Look ids up with the list tools rather than guessing them.", "region": "intl", "category": "e-commerce", "icon": "printful", @@ -172,6 +172,49 @@ } } }, + { + "name": "printful_get_printfiles", + "description": "Get the print areas of a catalog product: per placement (front, back, sleeve...) the printfile size in pixels, DPI and fill mode, and which printfile each variant uses. Use it to prepare design files in the right resolution before creating an order. Products with several techniques default to DTG; pass technique for embroidery and others.", + "parameters": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "Catalog product id from printful_list_catalog_products." + }, + "technique": { + "type": "string", + "description": "Printing technique when the product has several, e.g. EMBROIDERY, DTG, SUBLIMATION, DTFILM." + }, + "orientation": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ], + "description": "Only for wall art: printfiles for this orientation." + }, + "store_id": { + "type": "string", + "description": "Printful store id from printful_list_stores. Needed only with an account-level token; a store-level token already belongs to one store." + } + }, + "required": [ + "product_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/mockup-generator/printfiles/{product_id}", + "queryParams": { + "technique": "$technique", + "orientation": "$orientation" + }, + "headers": { + "X-PF-Store-Id": "$store_id" + } + } + }, { "name": "printful_get_product_prices", "description": "Get the production prices of a catalog product, including store discounts: price per placement and technique (front, back, sleeve, embroidery…) and per variant. Shipping is not included. Uses Printful API v2 (open beta).", @@ -1097,6 +1140,26 @@ "X-PF-Store-Id": "$store_id" } } + }, + { + "name": "printful_get_webhooks", + "description": "Show the webhook set up for the store: the URL that receives Printful's notifications and the enabled event types (package_shipped, order_failed, stock_updated...) with their parameters. Needs the webhooks/read scope.", + "parameters": { + "type": "object", + "properties": { + "store_id": { + "type": "string", + "description": "Printful store id from printful_list_stores. Needed only with an account-level token; a store-level token already belongs to one store." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/webhooks", + "headers": { + "X-PF-Store-Id": "$store_id" + } + } } ] } diff --git a/packages/backend/src/adapters/intl/printful.live.spec.ts b/packages/backend/src/adapters/intl/printful.live.spec.ts index 545db554c..1d5fe2563 100644 --- a/packages/backend/src/adapters/intl/printful.live.spec.ts +++ b/packages/backend/src/adapters/intl/printful.live.spec.ts @@ -8,7 +8,7 @@ import { LoginTokenService } from '../../connectors/engines/login-token.service' * token and calls read-only tools (scopes, stores, catalog, v2 prices, * shipping rates, cost estimate, order list) through the real RestEngine. * PRINTFUL_STORE_ID is needed only for an account-level token: - * PRINTFUL_API_TOKEN=xxx [PRINTFUL_STORE_ID=123] npx jest src/adapters/intl/printful.live.spec.ts + * RUN_PRINTFUL_LIVE=1 PRINTFUL_API_TOKEN=xxx [PRINTFUL_STORE_ID=123] npx jest src/adapters/intl/printful.live.spec.ts */ type Mapping = { @@ -117,6 +117,21 @@ describe('printful adapter: static spec conformance', () => { ); }); + it('reads print areas and the webhook setup from the documented v1 routes', () => { + expect(tool('printful_get_printfiles').endpointMapping).toEqual({ + method: 'GET', + path: '/mockup-generator/printfiles/{product_id}', + queryParams: { technique: '$technique', orientation: '$orientation' }, + headers: { 'X-PF-Store-Id': '$store_id' }, + }); + expect(tool('printful_get_printfiles').parameters?.required).toEqual(['product_id']); + expect(tool('printful_get_webhooks').endpointMapping).toEqual({ method: 'GET', path: '/webhooks', headers: { 'X-PF-Store-Id': '$store_id' } }); + }); + + it('names every tool in the instructions', () => { + for (const t of a.tools) expect(`${t.name}:${(adapter as unknown as { instructions: string }).instructions.includes(t.name)}`).toBe(`${t.name}:true`); + }); + it('POST quotes are marked read-only', () => { for (const n of ['printful_calculate_shipping_rates', 'printful_estimate_order_costs']) { expect(tool(n).endpointMapping.method).toBe('POST'); @@ -127,7 +142,7 @@ describe('printful adapter: static spec conformance', () => { const TOKEN = process.env.PRINTFUL_API_TOKEN; const STORE_ID = process.env.PRINTFUL_STORE_ID; -const live = TOKEN ? describe : describe.skip; +const live = process.env.RUN_PRINTFUL_LIVE === '1' && TOKEN ? describe : describe.skip; live('printful adapter: live read-only calls', () => { const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); @@ -164,6 +179,8 @@ live('printful adapter: live read-only calls', () => { variantId = p.result.variants[0].id; const v = await run('printful_get_catalog_variant', { variant_id: variantId }); expect(v.result.variant.id).toBe(variantId); + const files = await run('printful_get_printfiles', { product_id: productId, ...store }); + expect(Array.isArray(files.result.printfiles)).toBe(true); }, 30000); it('gets v2 prices and v1 shipping rates', async () => { diff --git a/packages/backend/src/adapters/intl/sportmonks.json b/packages/backend/src/adapters/intl/sportmonks.json index 339879226..9775c93e3 100644 --- a/packages/backend/src/adapters/intl/sportmonks.json +++ b/packages/backend/src/adapters/intl/sportmonks.json @@ -2,7 +2,7 @@ "slug": "sportmonks", "name": "SportMonks Football", "description": "SportMonks Football API v3: leagues, seasons, fixtures and results, livescores, standings, top scorers, teams, squads, players, venues, pre-match odds and predictions, with SportMonks include and filter syntax. Coverage depends on your SportMonks plan.", - "instructions": "## SportMonks Football\nThis connector reads the SportMonks Football API v3 (`https://api.sportmonks.com/v3`): leagues and seasons, fixtures and results, livescores, standings, top scorers, teams and squads, players, venues, pre-match odds and predictions. Every tool only reads.\n\n**Setup.** Sign in at https://my.sportmonks.com, open API, create an API token and paste it into `SPORTMONKS_API_TOKEN`. It is sent in the `Authorization` header. A free plan exists (a few leagues); paid plans add leagues and add-ons such as odds and predictions.\n\n**Plan coverage.** Data outside your plan does not raise an error: the answer is empty with the message \"No result(s) found ... you don't have access to it via your current subscription\". Call `sportmonks_list_my_leagues` first and work with those leagues. Odds and predictions need the matching add-on.\n\n**Includes.** Base answers only carry ids. Ask for related data with `include`, separated by semicolons and nested with dots: `participants;scores;state;events.type;lineups.player`. Ids such as `type_id`, `state_id` or `position_id` are explained by adding `.type` (or `state`, `position`) to the include, or by `sportmonks_list_types`. Each tool lists useful includes. Keep includes to what you need: they make answers much larger.\n\n**Filters, fields and paging.** `filters` takes SportMonks filters separated by semicolons, e.g. `fixtureLeagues:9` on fixture lists, `eventTypes:14,15` on events, `markets:1;bookmakers:2` on odds, `seasonTopscorerTypes:208` on top scorers. `select` returns only the fields you list. Lists are paged (`per_page` up to 50); when `pagination.has_more` is true, call again with the next `page`.\n\n**Ids.** League ids: 8 Premier League, 9 Championship, 82 Bundesliga, 384 Serie A, 564 LaLiga, 301 Ligue 1, 2 Champions League. The standings, top scorer and squad tools take a season id: get it from `sportmonks_get_league` with include `currentSeason`. Fixture lists give fixture ids for the match, odds and prediction tools. Teams and players: use the search tools.\n\n**Reading the data.** Times are UTC unless you pass `timezone`. `result_info` is a sentence such as \"Arsenal won after full-time.\"; scores are in the `scores` include (`description` CURRENT is the latest score). Odds values are decimal.\n\n**Rate limit.** 3,000 calls per hour per entity (fixtures, teams, players ...). Every answer carries `rate_limit.remaining` and `resets_in_seconds`; on 429 wait until the reset. Prefer one list call with a filter over many single calls.", + "instructions": "## SportMonks Football\nThis connector reads the SportMonks Football API v3 (`https://api.sportmonks.com/v3`): leagues and seasons, fixtures and results, livescores, standings, top scorers, teams and squads, players, venues, pre-match odds and predictions. Every tool only reads.\n\n**Setup.** Sign in at https://my.sportmonks.com, open API, create an API token and paste it into `SPORTMONKS_API_TOKEN`. It is sent in the `Authorization` header. A free plan exists (a few leagues); paid plans add leagues and add-ons such as odds and predictions.\n\n**Plan coverage.** Data outside your plan does not raise an error: the answer is empty with the message \"No result(s) found ... you don't have access to it via your current subscription\". Call `sportmonks_list_my_leagues` first and work with those leagues. Odds and predictions need the matching add-on.\n\n**Includes.** Base answers only carry ids. Ask for related data with `include`, separated by semicolons and nested with dots: `participants;scores;state;events.type;lineups.player`. Ids such as `type_id`, `state_id` or `position_id` are explained by adding `.type` (or `state`, `position`) to the include, or by `sportmonks_list_types`. Each tool lists useful includes. Keep includes to what you need: they make answers much larger.\n\n**Filters, fields and paging.** `filters` takes SportMonks filters separated by semicolons, e.g. `fixtureLeagues:9` on fixture lists, `eventTypes:14,15` on events, `markets:1;bookmakers:2` on odds (`sportmonks_get_prematch_odds`), `seasonTopscorerTypes:208` on top scorers. `select` returns only the fields you list. Lists are paged (`per_page` up to 50); when `pagination.has_more` is true, call again with the next `page`.\n\n**Ids.** League ids: 8 Premier League, 9 Championship, 82 Bundesliga, 384 Serie A, 564 LaLiga, 301 Ligue 1, 2 Champions League. The standings, top scorer and squad tools take a season id: get it from `sportmonks_get_league` with include `currentSeason`. Fixture lists give fixture ids for the match, odds and prediction tools. For one market of a fixture use `sportmonks_get_prematch_odds_by_market` (market ids from `sportmonks_list_odds_markets`), for one bookmaker `sportmonks_get_prematch_odds_by_bookmaker` (ids from `sportmonks_list_bookmakers`); both are smaller than the full odds list. Teams and players: use the search tools.\n\n**Reading the data.** Times are UTC unless you pass `timezone`. `result_info` is a sentence such as \"Arsenal won after full-time.\"; scores are in the `scores` include (`description` CURRENT is the latest score). Odds values are decimal.\n\n**Rate limit.** 3,000 calls per hour per entity (fixtures, teams, players ...). Every answer carries `rate_limit.remaining` and `resets_in_seconds`; on 429 wait until the reset. Prefer one list call with a filter over many single calls.", "region": "intl", "category": "sports", "icon": "sportmonks", @@ -1480,6 +1480,130 @@ "openWorldHint": true } }, + { + "name": "sportmonks_get_prematch_odds_by_market", + "description": "Pre-match odds of one fixture in one market (e.g. 1 match winner, 2 double chance, 14 both teams to score; ids from sportmonks_list_odds_markets), from every bookmaker in your plan. Narrow to bookmakers with filters bookmakers:2,9. Not paged.", + "parameters": { + "type": "object", + "properties": { + "fixture_id": { + "type": "integer", + "minimum": 1, + "description": "SportMonks fixture id, from the fixture list tools." + }, + "market_id": { + "type": "integer", + "minimum": 1, + "description": "Odds market id, from sportmonks_list_odds_markets (1 = match winner)." + }, + "include": { + "type": "string", + "description": "Related data to add, separated by semicolons (one level only): market, bookmaker, fixture." + }, + "select": { + "type": "string", + "description": "Comma separated fields to return, e.g. name,starting_at. Cuts the response down." + }, + "filters": { + "type": "string", + "description": "Static filters: bookmakers:2,9 (bookmaker ids) or winningOdds. Leave out for none." + }, + "locale": { + "type": "string", + "description": "Language code to translate name fields, e.g. es or de. Leave out for English." + } + }, + "required": [ + "fixture_id", + "market_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/football/odds/pre-match/fixtures/{fixture_id}/markets/{market_id}", + "queryParams": { + "include": "$include", + "select": "$select", + "filters": "$filters", + "locale": "$locale" + } + }, + "responseMapping": { + "transform": { + "mode": "jmespath", + "expression": "{data: data, pagination: pagination.{has_more: has_more, current_page: current_page, next_page: next_page}, rate_limit: rate_limit.{remaining: remaining, resets_in_seconds: resets_in_seconds, requested_entity: requested_entity}, message: message} | from_items(items(@)[?[1] != null])", + "maxBytes": 150000 + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, + { + "name": "sportmonks_get_prematch_odds_by_bookmaker", + "description": "Pre-match odds of one fixture from one bookmaker (ids from sportmonks_list_bookmakers, e.g. 2 bet365), across all markets in your plan. Narrow to markets with filters markets:1,14. Not paged.", + "parameters": { + "type": "object", + "properties": { + "fixture_id": { + "type": "integer", + "minimum": 1, + "description": "SportMonks fixture id, from the fixture list tools." + }, + "bookmaker_id": { + "type": "integer", + "minimum": 1, + "description": "Bookmaker id, from sportmonks_list_bookmakers (2 = bet365)." + }, + "include": { + "type": "string", + "description": "Related data to add, separated by semicolons (one level only): market, bookmaker, fixture." + }, + "select": { + "type": "string", + "description": "Comma separated fields to return, e.g. name,starting_at. Cuts the response down." + }, + "filters": { + "type": "string", + "description": "Static filters: markets:1,14 (market ids) or winningOdds. Leave out for none." + }, + "locale": { + "type": "string", + "description": "Language code to translate name fields, e.g. es or de. Leave out for English." + } + }, + "required": [ + "fixture_id", + "bookmaker_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/football/odds/pre-match/fixtures/{fixture_id}/bookmakers/{bookmaker_id}", + "queryParams": { + "include": "$include", + "select": "$select", + "filters": "$filters", + "locale": "$locale" + } + }, + "responseMapping": { + "transform": { + "mode": "jmespath", + "expression": "{data: data, pagination: pagination.{has_more: has_more, current_page: current_page, next_page: next_page}, rate_limit: rate_limit.{remaining: remaining, resets_in_seconds: resets_in_seconds, requested_entity: requested_entity}, message: message} | from_items(items(@)[?[1] != null])", + "maxBytes": 150000 + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }, { "name": "sportmonks_list_odds_markets", "description": "Betting markets with id and name, e.g. 1 fulltime result, 2 double chance, 14 both teams to score, 80 goals over/under, for the markets filter of the odds tool.", diff --git a/packages/backend/src/adapters/intl/sportmonks.live.spec.ts b/packages/backend/src/adapters/intl/sportmonks.live.spec.ts index 1b6f1076b..5b47f03bf 100644 --- a/packages/backend/src/adapters/intl/sportmonks.live.spec.ts +++ b/packages/backend/src/adapters/intl/sportmonks.live.spec.ts @@ -45,6 +45,17 @@ describe('sportmonks adapter: static spec conformance', () => { } }); + it('maps pre-match odds of one fixture by market and by bookmaker', () => { + const byMarket = tool('sportmonks_get_prematch_odds_by_market'); + expect(byMarket.endpointMapping.path).toBe('/football/odds/pre-match/fixtures/{fixture_id}/markets/{market_id}'); + expect(byMarket.parameters.required).toEqual(['fixture_id', 'market_id']); + expect(byMarket.endpointMapping.queryParams).toEqual({ include: '$include', select: '$select', filters: '$filters', locale: '$locale' }); + const byBookmaker = tool('sportmonks_get_prematch_odds_by_bookmaker'); + expect(byBookmaker.endpointMapping.path).toBe('/football/odds/pre-match/fixtures/{fixture_id}/bookmakers/{bookmaker_id}'); + expect(byBookmaker.parameters.required).toEqual(['fixture_id', 'bookmaker_id']); + expect(byBookmaker.endpointMapping.queryParams).toEqual(byMarket.endpointMapping.queryParams); + }); + it('keeps data, paging, rate limit and the plan message, and drops the subscription block', () => { const raw = { data: [{ id: 9, name: 'Championship' }], diff --git a/packages/backend/src/adapters/intl/telegram-bot.json b/packages/backend/src/adapters/intl/telegram-bot.json index 9b5bf2149..4878ab4f4 100644 --- a/packages/backend/src/adapters/intl/telegram-bot.json +++ b/packages/backend/src/adapters/intl/telegram-bot.json @@ -3,7 +3,7 @@ "name": "Telegram Bot", "aliases": ["BotFather"], "description": "Send Telegram messages, photos, albums, documents, audio, video, animations, stickers, contacts, venues, locations and polls from any AI agent via the Telegram Bot API. Bot-token auth. Outbound plus a lightweight read of recent updates (no long-polling).", - "instructions": "This connector uses the official Telegram Bot API (https://core.telegram.org/bots/api).\n\n**Setup**:\n1. In Telegram, open chat with **@BotFather** → `/newbot` → pick a display name and a username ending in `bot`.\n2. BotFather replies with an HTTP API token like `123456789:ABCDEFghijklmnopqrstuvwxyz`. **Treat as a secret**: anyone with it can send as your bot.\n3. Set `TELEGRAM_BOT_TOKEN` to the full token.\n\n**Authentication model** (Telegram-specific): the bot token goes in the URL path, NOT in a header. The base URL is `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}` and every endpoint is appended as `/methodName`. The adapter substitutes the token via the `{{TELEGRAM_BOT_TOKEN}}` env-var template in `baseUrl`.\n\n**chat_id model**: every Telegram chat (user, group, channel) has a numeric `chat_id`. For groups and channels the bot must be ADDED first; for users, the user must have sent a `/start` to the bot at least once (Telegram's privacy rule). Once you have a chat_id (extract it from `getUpdates`), pin it.\n\n**Channels**: for **public channels** you can also pass `@channelusername` (with the `@`) instead of a numeric chat_id. Private channels REQUIRE the numeric ID, which always starts with `-100`.\n\n**Markdown vs HTML**: messages support 3 parse modes: `MarkdownV2` (current, escape special chars), `HTML` (``, ``, ``), or no formatting. Telegram's `MarkdownV2` requires escaping `_ * [ ] ( ) ~ ` > # + - = | { } . !`; agents that don't escape will get 400 'can't parse entities'. The HTML mode is easier for agents to compose safely.\n\n**File send model**: media tools (sendPhoto, sendDocument, sendAudio, etc.) accept the media as:\n - **HTTPS URL** (recommended on AnythingMCP): Telegram fetches it server-side. URL must be ≤ 5MB for photos, ≤ 20MB for documents via URL.\n - **file_id**: a string returned from a previous send (caching: reuse the same file_id to re-send the same content without re-uploading).\n - **multipart binary**: NOT supported by this connector in a single tool call. Pre-upload elsewhere and pass the resulting URL or file_id.\n\n**Albums**: `telegram_bot_send_album` sends 2 to 10 photos and videos as one grouped message (or 2 to 10 documents, or 2 to 10 audio files). Put the caption on the first item. If Telegram answers `failed to send message #N with the error message WEBPAGE_CURL_FAILED`, it could not download item N: the URL must be public and point straight at the file, not at a web page.\n\n**getUpdates vs webhooks**: this connector exposes `telegram_get_updates` (long-polling pull) for one-shot reading of recent inbound messages. For continuous inbound stream (chatbot), use Telegram's webhook (setWebhook) and host the receiver yourself, outside the MCP request/response model.\n\n**Rate limits**: 30 messages/sec to different chats, 1 msg/sec to the same chat, 20 messages/minute to the same group. On 429, honor the `retry_after` in the response.\n\n**Out of scope**: payments, inline mode, callback query answers (these usually require a long-running webhook), passport, game scores, sticker management.\n\n**Files the bot received**: a message in `telegram_bot_get_updates` carries a `file_id` (`photo[-1]` is the largest size, or `document`, `voice`, `video`). `telegram_bot_get_file` turns it into a `file_path`, and `telegram_bot_download_file` fetches the file. An image comes back as an image the assistant can see, any other file as an attachment. A `file_id` only works with the bot that received the file, and the Bot API serves files up to 20 MB.", + "instructions": "This connector uses the official Telegram Bot API (https://core.telegram.org/bots/api).\n\n**Setup**:\n1. In Telegram, open chat with **@BotFather** → `/newbot` → pick a display name and a username ending in `bot`.\n2. BotFather replies with an HTTP API token like `123456789:ABCDEFghijklmnopqrstuvwxyz`. **Treat as a secret**: anyone with it can send as your bot.\n3. Set `TELEGRAM_BOT_TOKEN` to the full token.\n\n**Authentication model** (Telegram-specific): the bot token goes in the URL path, NOT in a header. The base URL is `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}` and every endpoint is appended as `/methodName`. The adapter substitutes the token via the `{{TELEGRAM_BOT_TOKEN}}` env-var template in `baseUrl`.\n\n**chat_id model**: every Telegram chat (user, group, channel) has a numeric `chat_id`. For groups and channels the bot must be ADDED first; for users, the user must have sent a `/start` to the bot at least once (Telegram's privacy rule). Once you have a chat_id (extract it from `getUpdates`), pin it.\n\n**Channels**: for **public channels** you can also pass `@channelusername` (with the `@`) instead of a numeric chat_id. Private channels REQUIRE the numeric ID, which always starts with `-100`.\n\n**Markdown vs HTML**: messages support 3 parse modes: `MarkdownV2` (current, escape special chars), `HTML` (``, ``, ``), or no formatting. Telegram's `MarkdownV2` requires escaping `_ * [ ] ( ) ~ ` > # + - = | { } . !`; agents that don't escape will get 400 'can't parse entities'. The HTML mode is easier for agents to compose safely.\n\n**File send model**: media tools (sendPhoto, sendDocument, sendAudio, etc.) accept the media as:\n - **HTTPS URL** (recommended on AnythingMCP): Telegram fetches it server-side. URL must be ≤ 5MB for photos, ≤ 20MB for documents via URL.\n - **file_id**: a string returned from a previous send (caching: reuse the same file_id to re-send the same content without re-uploading).\n - **multipart binary**: NOT supported by this connector in a single tool call. Pre-upload elsewhere and pass the resulting URL or file_id.\n\n**Albums**: `telegram_bot_send_album` sends 2 to 10 photos and videos as one grouped message (or 2 to 10 documents, or 2 to 10 audio files). Put the caption on the first item. If Telegram answers `failed to send message #N with the error message WEBPAGE_CURL_FAILED`, it could not download item N: the URL must be public and point straight at the file, not at a web page.\n\n**getUpdates vs webhooks**: this connector exposes `telegram_bot_get_updates` (long-polling pull) for one-shot reading of recent inbound messages. For continuous inbound stream (chatbot), use Telegram's webhook (setWebhook) and host the receiver yourself, outside the MCP request/response model.\n\n**Tools**: `telegram_bot_get_me` checks the token and returns the bot's id and username. Sending: `telegram_bot_send_message` (text), `telegram_bot_send_photo`, `telegram_bot_send_document`, `telegram_bot_send_audio` (music player), `telegram_bot_send_voice` (voice note), `telegram_bot_send_video`, `telegram_bot_send_animation` (GIF), `telegram_bot_send_album`, `telegram_bot_send_sticker`, `telegram_bot_send_location`, `telegram_bot_send_venue`, `telegram_bot_send_contact` and `telegram_bot_send_poll`; each one delivers to the chat at once. `telegram_bot_edit_message_text` changes a message the bot sent, `telegram_bot_delete_message` removes one. `telegram_bot_get_chat` reads a chat's title, type and description. `telegram_bot_fetch_sticker_set` reads a sticker or custom emoji set by name, for the file_id to send with `telegram_bot_send_sticker`.\n\n**Business accounts**: when a Telegram Business user connects the bot to their account, the bot receives a `business_connection` update whose `id` is the business connection id (inbound business messages carry it too). Pass it as `business_connection_id` to the send tools and to `telegram_bot_edit_message_text` to write on behalf of that account; the bot then needs the reply right on the connection, and Telegram only lets it answer chats active in the last 24 hours.\n\n**Rate limits**: 30 messages/sec to different chats, 1 msg/sec to the same chat, 20 messages/minute to the same group. On 429, honor the `retry_after` in the response.\n\n**Out of scope**: payments, inline mode, callback query answers (these usually require a long-running webhook), passport, game scores, sticker management.\n\n**Files the bot received**: a message in `telegram_bot_get_updates` carries a `file_id` (`photo[-1]` is the largest size, or `document`, `voice`, `video`). `telegram_bot_get_file` turns it into a `file_path`, and `telegram_bot_download_file` fetches the file. An image comes back as an image the assistant can see, any other file as an attachment. A `file_id` only works with the bot that received the file, and the Bot API serves files up to 20 MB.", "region": "intl", "category": "messaging", "icon": "telegram", @@ -112,6 +112,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Numeric chat_id OR '@channelusername' for public channels. For users, they must have started the bot first." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "text": { "type": "string", "description": "Message text, max 4096 chars. For longer, split into multiple sends." }, "parse_mode": { "type": "string", "description": "MarkdownV2, HTML, or omit for no formatting. HTML is safer for agents." }, "disable_web_page_preview": { "type": "boolean", "description": "If true, no link preview is shown for URLs in the text." }, @@ -126,6 +127,7 @@ "path": "/sendMessage", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "text": "$text", "parse_mode": "$parse_mode", "disable_web_page_preview": "$disable_web_page_preview", @@ -142,6 +144,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID or @channelusername." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "photo": { "type": "string", "description": "Either a public HTTPS URL OR a file_id from a prior send." }, "caption": { "type": "string", "description": "Caption ≤1024 chars." }, "parse_mode": { "type": "string", "description": "MarkdownV2, HTML, or omit." }, @@ -155,6 +158,7 @@ "path": "/sendPhoto", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "photo": "$photo", "caption": "$caption", "parse_mode": "$parse_mode", @@ -170,6 +174,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID or @channelusername." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "document": { "type": "string", "description": "Public HTTPS URL or file_id." }, "caption": { "type": "string", "description": "Caption ≤1024 chars." }, "parse_mode": { "type": "string", "description": "MarkdownV2, HTML, or omit." }, @@ -183,6 +188,7 @@ "path": "/sendDocument", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "document": "$document", "caption": "$caption", "parse_mode": "$parse_mode", @@ -198,6 +204,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID or @channelusername." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "audio": { "type": "string", "description": "HTTPS URL or file_id." }, "caption": { "type": "string", "description": "Optional caption." }, "duration": { "type": "integer", "description": "Duration in seconds (improves UX)." }, @@ -212,6 +219,7 @@ "path": "/sendAudio", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "audio": "$audio", "caption": "$caption", "duration": "$duration", @@ -223,11 +231,12 @@ }, { "name": "telegram_bot_send_voice", - "description": "Send a voice note (OGG-Opus encoded). Telegram renders it as a microphone bubble with waveform — the canonical 'voice memo' format. Other audio codecs use sendAudio instead.", + "description": "Send a voice note (OGG-Opus encoded). Telegram renders it as a microphone bubble with waveform, the canonical 'voice memo' format. Other audio codecs use sendAudio instead.", "parameters": { "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "voice": { "type": "string", "description": "HTTPS URL of an OGG-Opus file OR a file_id." }, "caption": { "type": "string", "description": "Optional caption." }, "duration": { "type": "integer", "description": "Duration in seconds." } @@ -239,6 +248,7 @@ "path": "/sendVoice", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "voice": "$voice", "caption": "$caption", "duration": "$duration" @@ -252,6 +262,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "video": { "type": "string", "description": "HTTPS URL of MP4 or file_id." }, "caption": { "type": "string", "description": "Caption ≤1024 chars." }, "duration": { "type": "integer", "description": "Duration seconds." }, @@ -267,6 +278,7 @@ "path": "/sendVideo", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "video": "$video", "caption": "$caption", "duration": "$duration", @@ -284,6 +296,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "latitude": { "type": "number", "description": "Latitude decimal degrees." }, "longitude": { "type": "number", "description": "Longitude decimal degrees." }, "live_period": { "type": "integer", "description": "Seconds the location stays live (60-86400). Omit for static." }, @@ -296,6 +309,7 @@ "path": "/sendLocation", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "latitude": "$latitude", "longitude": "$longitude", "live_period": "$live_period", @@ -310,12 +324,13 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID." }, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "question": { "type": "string", "description": "Poll question (1-300 chars)." }, "options": { "type": "array", "description": "Array of 2-10 string options." }, "type": { "type": "string", "description": "regular or quiz." }, "is_anonymous": { "type": "boolean", "description": "Default true." }, "allows_multiple_answers": { "type": "boolean", "description": "Only for type=regular." }, - "correct_option_id": { "type": "integer", "description": "0-based index — required for type=quiz." }, + "correct_option_id": { "type": "integer", "description": "0-based index, required for type=quiz." }, "explanation": { "type": "string", "description": "Explanation shown when a quiz answer is wrong (≤200 chars)." }, "open_period": { "type": "integer", "description": "Seconds before the poll auto-closes (5-600)." } }, @@ -326,6 +341,7 @@ "path": "/sendPoll", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "question": "$question", "options": "$options", "type": "$type", @@ -344,6 +360,7 @@ "type": "object", "properties": { "chat_id": { "type": "string", "description": "Chat ID containing the message." }, + "business_connection_id": { "type": "string", "description": "Business connection id, when the message was sent on behalf of a connected business account." }, "message_id": { "type": "integer", "description": "message_id of the message to edit." }, "text": { "type": "string", "description": "New text." }, "parse_mode": { "type": "string", "description": "MarkdownV2 / HTML." }, @@ -357,6 +374,7 @@ "path": "/editMessageText", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "message_id": "$message_id", "text": "$text", "parse_mode": "$parse_mode", @@ -399,7 +417,8 @@ "method": "POST", "path": "/getChat", "bodyMapping": { "chat_id": "$chat_id" } - } + }, + "annotations": { "readOnlyHint": true } }, { "name": "telegram_bot_get_file", @@ -439,7 +458,7 @@ }, { "name": "telegram_bot_get_updates", - "description": "Long-poll recent inbound messages (one-shot — pulls all updates since the last `offset+1`). For continuous inbound, use webhooks instead. Telegram queues updates for 24 hours if not consumed; consuming with this tool clears them.", + "description": "Long-poll recent inbound messages (one-shot: pulls all updates since the last `offset+1`). For continuous inbound, use webhooks instead. Telegram queues updates for 24 hours if not consumed; consuming with this tool clears them.", "parameters": { "type": "object", "properties": { @@ -497,6 +516,7 @@ "type": "object", "properties": { "chat_id": {"type": "string", "description": "Chat ID or @channelusername."}, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "animation": {"type": "string", "description": "HTTPS URL of a GIF or MP4 (up to 20 MB) or a file_id."}, "caption": {"type": "string", "description": "Caption, at most 1024 characters."}, "parse_mode": {"type": "string", "description": "MarkdownV2, HTML, or omit."}, @@ -511,6 +531,7 @@ "path": "/sendAnimation", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "animation": "$animation", "caption": "$caption", "parse_mode": "$parse_mode", @@ -527,6 +548,7 @@ "type": "object", "properties": { "chat_id": {"type": "string", "description": "Chat ID or @channelusername."}, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "sticker": {"type": "string", "description": "Sticker file_id, or HTTPS URL of a .WEBP file."}, "disable_notification": {"type": "boolean", "description": "Silent send."}, "reply_parameters": {"type": "object", "description": "Reply target, e.g. {\"message_id\": 42}."} @@ -538,12 +560,30 @@ "path": "/sendSticker", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "sticker": "$sticker", "disable_notification": "$disable_notification", "reply_parameters": "$reply_parameters" } } }, + { + "name": "telegram_bot_fetch_sticker_set", + "description": "Read a sticker set (or custom emoji set) by its name: title, sticker_type (regular, mask, custom_emoji) and every sticker with its emoji, file_id and custom_emoji_id. Use a file_id with telegram_bot_send_sticker. The name is the part after t.me/addstickers/ or t.me/addemoji/, or sticker.set_name from a message in telegram_bot_get_updates. Read-only.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Short name of the set, e.g. the last part of https://t.me/addstickers/." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/getStickerSet", + "bodyMapping": { "name": "$name" } + }, + "annotations": { "readOnlyHint": true } + }, { "name": "telegram_bot_send_contact", "description": "Send a phone contact card that the recipient can save or call. Needs a phone number and a first name; an optional vCard adds more fields.", @@ -551,6 +591,7 @@ "type": "object", "properties": { "chat_id": {"type": "string", "description": "Chat ID or @channelusername."}, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "phone_number": {"type": "string", "description": "Phone number, ideally in international format, e.g. +4915112345678."}, "first_name": {"type": "string", "description": "Contact's first name."}, "last_name": {"type": "string", "description": "Contact's last name."}, @@ -564,6 +605,7 @@ "path": "/sendContact", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "phone_number": "$phone_number", "first_name": "$first_name", "last_name": "$last_name", @@ -579,6 +621,7 @@ "type": "object", "properties": { "chat_id": {"type": "string", "description": "Chat ID or @channelusername."}, + "business_connection_id": { "type": "string", "description": "Business connection id, to send on behalf of a connected business account." }, "latitude": {"type": "number", "description": "Latitude in decimal degrees."}, "longitude": {"type": "number", "description": "Longitude in decimal degrees."}, "title": {"type": "string", "description": "Name of the venue."}, @@ -593,6 +636,7 @@ "path": "/sendVenue", "bodyMapping": { "chat_id": "$chat_id", + "business_connection_id": "$business_connection_id", "latitude": "$latitude", "longitude": "$longitude", "title": "$title", diff --git a/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts b/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts index e1636dc97..5d56fe2df 100644 --- a/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts +++ b/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts @@ -135,6 +135,39 @@ describe('telegram-bot adapter: albums and media sends', () => { } }); + it('reads a sticker set by name through getStickerSet, marked read-only', async () => { + mockedAxios.mockResolvedValue({ data: { ok: true, result: { name: 'Animals', stickers: [] } } }); + await call('telegram_bot_fetch_sticker_set', { name: 'Animals' }); + expect(sent()).toEqual(expect.objectContaining({ method: 'POST', url: `${BASE}/getStickerSet` })); + expect(sent().data).toEqual({ name: 'Animals' }); + const t = tool('telegram_bot_fetch_sticker_set'); + expect(t.parameters.required).toEqual(['name']); + expect((t as unknown as { annotations: { readOnlyHint: boolean } }).annotations.readOnlyHint).toBe(true); + expect((tool('telegram_bot_get_chat') as unknown as { annotations: { readOnlyHint: boolean } }).annotations.readOnlyHint).toBe(true); + }); + + // Bot API: every send method and editMessageText take business_connection_id + // (https://core.telegram.org/bots/api#sendmessage); deleteMessage does not. + it('sends business_connection_id on every send tool and on edits', async () => { + const sends = a.tools.filter((t) => /^telegram_bot_(send_|edit_message_text$)/.test(t.name)); + expect(sends.length).toBe(14); + for (const t of sends) { + const m = t.endpointMapping as unknown as { bodyMapping: Record }; + const props = (t as unknown as { parameters: { properties: Record } }).parameters.properties; + expect(`${t.name}:${m.bodyMapping.business_connection_id}`).toBe(`${t.name}:$business_connection_id`); + expect(`${t.name}:${'business_connection_id' in props}`).toBe(`${t.name}:true`); + } + mockedAxios.mockResolvedValue({ data: { ok: true } }); + await call('telegram_bot_send_message', { chat_id: '42', text: 'Hi', business_connection_id: 'bc-1' }); + expect(sent().data).toEqual({ chat_id: '42', text: 'Hi', business_connection_id: 'bc-1' }); + }); + + it('names every tool in the instructions and writes no em dash', () => { + const instructions = (adapter as unknown as { instructions: string }).instructions; + for (const t of a.tools) expect(`${t.name}:${instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(adapter)).not.toMatch(/—/); + }); + it('keeps the bot token out of every tool path', () => { // The file download lives on another path (/file/bot/…), so it // names the variable; every other tool is a bare method under the base URL. diff --git a/packages/backend/src/adapters/intl/trendyol.json b/packages/backend/src/adapters/intl/trendyol.json index 6bc8c08f9..5b7271b63 100644 --- a/packages/backend/src/adapters/intl/trendyol.json +++ b/packages/backend/src/adapters/intl/trendyol.json @@ -2,7 +2,7 @@ "slug": "trendyol", "name": "Trendyol", "description": "Run a Trendyol seller account from an AI agent: products (Product v2 listing, creation, content, archive), stock and prices, buybox, batch results, shipment packages with status, tracking, carrier and invoice link, returns, customer questions and answers, categories, attributes and brands, addresses and finance settlements. Basic auth with the API key and secret.", - "instructions": "## Trendyol\nThis connector uses the **Trendyol seller integration API** on `https://apigw.trendyol.com/integration` (the old `api.trendyol.com/sapigw` host is no longer the documented one): products on the current Product v2 services, stock and prices, shipment packages (orders), returns, customer questions, the category, attribute and brand lookups, addresses and finance.\n\n**Setup (about 5 minutes)**\n1. Sign in to the Trendyol seller panel (partner.trendyol.com) with the **master user** (admin role); other users do not see the API data.\n2. Open your store name (top right) > **Hesap Bilgilerim** > **Entegrasyon Bilgileri**. Copy the **Satıcı ID** into `TRENDYOL_SELLER_ID`, the **API Key** into `TRENDYOL_API_KEY` and the **API Secret Key** into `TRENDYOL_API_SECRET`.\n3. Set `TRENDYOL_API_HOST` to `https://apigw.trendyol.com`.\n4. Only if you sell on an international Trendyol storefront: set `TRENDYOL_STOREFRONT_CODE` to its two-letter country code; it is sent as the `storeFrontCode` header. Leave it empty for Trendyol Turkey.\n5. Install and run `trendyol_list_stock_and_prices`.\n\n**How requests are signed**: HTTP Basic with the API key as user name and the secret as password, plus the `User-Agent` header Trendyol requires, built as ` - SelfIntegration` from `TRENDYOL_SELLER_ID`. Requests without it are refused with 403. A 401 with `ClientApiAuthenticationException` or `basic.authorization.failed` means wrong credentials.\n\n**Stage (test) environment**: `https://stageapigw.trendyol.com` uses separate stage credentials, and Trendyol only lets in IP addresses it has whitelisted; any other address gets 503. Use it from a self-hosted installation whose IP you registered with Trendyol, not for your live store.\n\n**Dates are epoch milliseconds** (e.g. `1759276800000` = 2025-10-01 00:00 UTC), in Turkey time (UTC+3) for orders. Convert before calling and when reading `orderDate`, `creationDate` and similar fields.\n\n**Orders** are shipment packages. `trendyol_list_orders` uses the v2 order endpoint, which Trendyol made mandatory on 15 October 2026: it reaches at most the last month, a date window of at most 2 weeks (without dates the last week) and the first 10,000 packages of a filter, so narrow the window or the status for more. The usual flow: status `Created` (to process) > `trendyol_update_package_status` with `Picking` > with `Invoiced` and the invoice number > Trendyol's carrier picks it up (`Shipped`, then `Delivered`). Sellers on the international storefronts who ship with their own carrier send the tracking number with `trendyol_update_tracking_number` after Picking. `trendyol_send_invoice_link` sends the e-invoice link to the customer.\n\n**Products** use Trendyol's Product v2 services (the old product filter is being switched off): `trendyol_list_products` lists approved products grouped by content with their variants, `trendyol_list_unapproved_products` the ones pending or rejected (with the reasons), `trendyol_list_stock_and_prices` only stock and prices. To create a product, find a leaf category with `trendyol_list_categories` (pass `name`), its attributes with `trendyol_get_category_attributes` and their values with `trendyol_list_attribute_values`, the brand with `trendyol_search_brands`, the addresses with `trendyol_list_addresses`, then call `trendyol_create_products`.\n\n**Every product write is asynchronous.** Create, update, stock and price, archive and delete return a `batchRequestId`; call `trendyol_get_batch_request` until `status` is `COMPLETED` and read each item's `status` and `failureReasons`. A 200 on the write only means the request was queued. Results stay available for 4 hours.\n\n**Writes: confirm first.** Price and stock updates, content changes, package status, tracking, carrier change, invoice link, approving returns, answering questions, archiving and creating products act on the live store. Before each call, show the user what will change (barcodes with old and new price or stock, package and lines, the answer text) and wait for a yes. `trendyol_delete_products`, `trendyol_report_unsupplied` (cancels order lines) and `trendyol_reject_claim_items` are installed switched off.\n\n**Prices and stock**: `trendyol_update_price_and_inventory` takes up to 1,000 barcodes, at most 20,000 units per product; `salePrice` may not exceed `listPrice`. Trendyol refuses the same request again within 15 minutes and more than 30 price updates a minute for one barcode.\n\n**Finance**: `trendyol_list_settlements` (sales, returns, discounts, commissions) and `trendyol_list_other_financials` (payouts, transfers, invoices) need a transaction type and either a window of at most 15 days or a `paymentDate` / `paymentOrderId`.\n\n**Rate limits**: at most 50 requests per 10 seconds on one endpoint (429 `too.many.requests`), plus per-minute limits per service group (product reads, product writes, stock and price writes) that depend on the store's listing tier. Do not loop over pages faster than needed.\n\n**Lookups need no seller data**: categories, attributes, attribute values, brands, cargo providers and return rejection reasons are the same for every seller on a storefront; the attribute and category names come in Turkish on the TR storefront.", + "instructions": "## Trendyol\nThis connector uses the **Trendyol seller integration API** on `https://apigw.trendyol.com/integration` (the old `api.trendyol.com/sapigw` host is no longer the documented one): products on the current Product v2 services, stock and prices, shipment packages (orders), returns, customer questions, the category, attribute and brand lookups, addresses and finance.\n\n**Setup (about 5 minutes)**\n1. Sign in to the Trendyol seller panel (partner.trendyol.com) with the **master user** (admin role); other users do not see the API data.\n2. Open your store name (top right) > **Hesap Bilgilerim** > **Entegrasyon Bilgileri**. Copy the **Satıcı ID** into `TRENDYOL_SELLER_ID`, the **API Key** into `TRENDYOL_API_KEY` and the **API Secret Key** into `TRENDYOL_API_SECRET`.\n3. Set `TRENDYOL_API_HOST` to `https://apigw.trendyol.com`.\n4. Only if you sell on an international Trendyol storefront: set `TRENDYOL_STOREFRONT_CODE` to its two-letter country code; it is sent as the `storeFrontCode` header. Leave it empty for Trendyol Turkey.\n5. Install and run `trendyol_list_stock_and_prices`.\n\n**How requests are signed**: HTTP Basic with the API key as user name and the secret as password, plus the `User-Agent` header Trendyol requires, built as ` - SelfIntegration` from `TRENDYOL_SELLER_ID`. Requests without it are refused with 403. A 401 with `ClientApiAuthenticationException` or `basic.authorization.failed` means wrong credentials.\n\n**Stage (test) environment**: `https://stageapigw.trendyol.com` uses separate stage credentials, and Trendyol only lets in IP addresses it has whitelisted; any other address gets 503. Use it from a self-hosted installation whose IP you registered with Trendyol, not for your live store.\n\n**Dates are epoch milliseconds** (e.g. `1759276800000` = 2025-10-01 00:00 UTC), in Turkey time (UTC+3) for orders. Convert before calling and when reading `orderDate`, `creationDate` and similar fields.\n\n**Orders** are shipment packages. `trendyol_list_orders` uses the v2 order endpoint, which Trendyol made mandatory on 15 October 2026: it reaches at most the last month, a date window of at most 2 weeks (without dates the last week) and the first 10,000 packages of a filter, so narrow the window or the status for more. The usual flow: status `Created` (to process) > `trendyol_update_package_status` with `Picking` > with `Invoiced` and the invoice number > Trendyol's carrier picks it up (`Shipped`, then `Delivered`). Sellers on the international storefronts who ship with their own carrier send the tracking number with `trendyol_update_tracking_number` after Picking. `trendyol_send_invoice_link` sends the e-invoice link to the customer.\n\n**Products** use Trendyol's Product v2 services (the old product filter is being switched off): `trendyol_list_products` lists approved products grouped by content with their variants, `trendyol_list_unapproved_products` the ones pending or rejected (with the reasons), `trendyol_list_stock_and_prices` only stock and prices. To create a product, find a leaf category with `trendyol_list_categories` (pass `name`), its attributes with `trendyol_get_category_attributes` and their values with `trendyol_list_attribute_values`, the brand with `trendyol_search_brands`, the addresses with `trendyol_list_addresses`, then call `trendyol_create_products`.\n\n**Every product write is asynchronous.** Create, update, stock and price, archive and delete return a `batchRequestId`; call `trendyol_get_batch_request` until `status` is `COMPLETED` and read each item's `status` and `failureReasons`. A 200 on the write only means the request was queued. Results stay available for 4 hours.\n\n**Writes: confirm first.** Price and stock updates, content changes, package status, tracking, carrier change, invoice link, approving returns, answering questions, archiving and creating products act on the live store. Before each call, show the user what will change (barcodes with old and new price or stock, package and lines, the answer text) and wait for a yes. `trendyol_delete_products`, `trendyol_report_unsupplied` (cancels order lines) and `trendyol_reject_claim_items` are installed switched off.\n\n**Prices and stock**: `trendyol_update_price_and_inventory` takes up to 1,000 barcodes, at most 20,000 units per product; `salePrice` may not exceed `listPrice`. Trendyol refuses the same request again within 15 minutes and more than 30 price updates a minute for one barcode.\n\n**More tools**: `trendyol_get_product` (approval and archive state of one barcode), `trendyol_get_buybox` (buybox position and price, up to 10 barcodes), `trendyol_update_product_content` (title, description, images and attributes of approved products), `trendyol_update_unapproved_products` (fix products that are pending or rejected), `trendyol_archive_products` (archive or restore by barcode), `trendyol_change_cargo_provider` (carrier of a package), `trendyol_list_brands` and `trendyol_list_cargo_providers` (lookups).\n\n**Returns (claims)**: `trendyol_list_claims` lists returns with their items, `trendyol_get_claim_item_audit` shows one item's history, `trendyol_approve_claim_items` accepts returned items, and `trendyol_reject_claim_items` (switched off) opens an issue with a reason from `trendyol_list_claim_issue_reasons`.\n\n**Customer questions**: `trendyol_list_questions` (status `WAITING_FOR_ANSWER` for open ones), `trendyol_get_question`, and `trendyol_answer_question` after the user approved the text.\n\n**Finance**: `trendyol_list_settlements` (sales, returns, discounts, commissions) and `trendyol_list_other_financials` (payouts, transfers, invoices) need a transaction type and either a window of at most 15 days or a `paymentDate` / `paymentOrderId`.\n\n**Rate limits**: at most 50 requests per 10 seconds on one endpoint (429 `too.many.requests`), plus per-minute limits per service group (product reads, product writes, stock and price writes) that depend on the store's listing tier. Do not loop over pages faster than needed.\n\n**Lookups need no seller data**: categories, attributes, attribute values, brands, cargo providers and return rejection reasons are the same for every seller on a storefront; the attribute and category names come in Turkish on the TR storefront.", "region": "intl", "category": "e-commerce", "icon": "trendyol", diff --git a/packages/backend/src/adapters/intl/trendyol.live.spec.ts b/packages/backend/src/adapters/intl/trendyol.live.spec.ts index 28f15f498..6d1f07c05 100644 --- a/packages/backend/src/adapters/intl/trendyol.live.spec.ts +++ b/packages/backend/src/adapters/intl/trendyol.live.spec.ts @@ -147,7 +147,7 @@ describe('trendyol adapter: static spec conformance', () => { mockedFetchOutbound.mockReset(); }); - it('is listed (verified against the official reference, public lookups and customer traffic on 12 Oct 2026)', () => { + it('is listed (verified against the official reference and public lookups on 12 Oct 2026)', () => { expect(a.unlisted).toBeUndefined(); }); @@ -243,6 +243,7 @@ describe('trendyol adapter: static spec conformance', () => { ].map((m) => m[0]); expect(mentioned.length).toBeGreaterThan(10); for (const name of mentioned) expect(names).toContain(name); + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); expect(JSON.stringify(adapter)).not.toMatch(/[–—]/); }); diff --git a/packages/backend/src/adapters/intl/weeek.json b/packages/backend/src/adapters/intl/weeek.json index d4815992b..0c70aa3be 100644 --- a/packages/backend/src/adapters/intl/weeek.json +++ b/packages/backend/src/adapters/intl/weeek.json @@ -2,7 +2,7 @@ "slug": "weeek", "name": "Weeek", "description": "Manage Weeek projects and the CRM from your AI: find and update tasks, move them across boards, assign people, comment and log time, and work with deals, contacts and organizations, with confirmation before changes.", - "instructions": "## Weeek\nThis connector calls the **Weeek Public API** (`https://api.weeek.net/public/v1`) of one Weeek workspace with an API token: projects, boards and columns, tasks with comments, assignees and time entries, tags and members, and the CRM (funnels, deals, contacts, organizations).\n\n**Getting credentials (about 1 minute)**\n1. In Weeek, open the workspace settings and the **API** section.\n2. Create a token and copy it.\n3. Paste it into `WEEEK_API_TOKEN`. It is sent as `Authorization: Bearer`, only to Weeek. Everything the connector does is done as the user who created the token, with that user's access.\n\n**Finding ids**: `weeek_list_projects` gives project ids, `weeek_list_boards` the boards of a project and `weeek_list_board_columns` the columns of a board. A new task needs `locations` such as `[{\"projectId\": 12, \"boardColumnId\": 34}]`. People are user ids (UUIDs) from `weeek_list_members`. In the CRM, `weeek_list_funnels` and `weeek_list_funnel_statuses` lead to the status id that `weeek_list_deals` and `weeek_create_deal` need.\n\n**Formats**: Weeek wants flags as 0 or 1, not true or false (`isPrivate`, `completed`, `isOvertime`). Task filters and the planned `day` use `dd.mm.yyyy`; `startDate` and `dueDate` on a task update use `YYYY-MM-DD`; date-times are ISO 8601. Priority is 0 low, 1 medium, 2 high, 3 hold.\n\n**Paging**: `weeek_list_tasks` pages with `perPage` and `offset` and answers `hasMore`; comments, portfolios, deals, contacts and organizations page with `limit` and `offset`.\n\n**Writes are visible to the team.** Creating, moving, completing or commenting on tasks and changing deals notifies other members. Describe the change and wait for the user's yes before any write. `weeek_delete_task` is installed switched off.\n\n**Common errors**\n- 401: the token is wrong or was deleted. Create a new one in the workspace settings under API.\n- 403: the token's user cannot see or change that project or deal.\n- 404: an id does not exist in this workspace.\n- 422: a field is missing or has the wrong format (often true/false instead of 0/1, or a date in the wrong format); the message names it.\n\n**Cloud and self-hosted**: both call api.weeek.net over the internet; nothing else differs.\n\n**Out of scope here**: file uploads, custom field and funnel configuration, timers, and deleting projects, boards, deals or contacts.", + "instructions": "## Weeek\nThis connector calls the **Weeek Public API** (`https://api.weeek.net/public/v1`) of one Weeek workspace with an API token: projects, boards and columns, tasks with comments, assignees and time entries, tags and members, and the CRM (funnels, deals, contacts, organizations).\n\n**Getting credentials (about 1 minute)**\n1. In Weeek, open the workspace settings and the **API** section.\n2. Create a token and copy it.\n3. Paste it into `WEEEK_API_TOKEN`. It is sent as `Authorization: Bearer`, only to Weeek. Everything the connector does is done as the user who created the token, with that user's access.\n\n**Finding ids**: `weeek_list_projects` gives project ids, `weeek_list_boards` the boards of a project and `weeek_list_board_columns` the columns of a board. A new task needs `locations` such as `[{\"projectId\": 12, \"boardColumnId\": 34}]`. `weeek_remove_task_from_project` takes a task out of one project without deleting it. People are user ids (UUIDs) from `weeek_list_members`. In the CRM, `weeek_list_funnels` and `weeek_list_funnel_statuses` lead to the status id that `weeek_list_deals` and `weeek_create_deal` need.\n\n**Formats**: Weeek wants flags as 0 or 1, not true or false (`isPrivate`, `completed`, `isOvertime`). Task filters and the planned `day` use `dd.mm.yyyy`; `startDate` and `dueDate` on a task update use `YYYY-MM-DD`; date-times are ISO 8601. Priority is 0 low, 1 medium, 2 high, 3 hold.\n\n**Paging**: `weeek_list_tasks` pages with `perPage` and `offset` and answers `hasMore`; comments, portfolios, deals, contacts and organizations page with `limit` and `offset`.\n\n**Writes are visible to the team.** Creating, moving, completing or commenting on tasks and changing deals notifies other members. Describe the change and wait for the user's yes before any write. `weeek_delete_task` is installed switched off.\n\n**Common errors**\n- 401: the token is wrong or was deleted. Create a new one in the workspace settings under API.\n- 403: the token's user cannot see or change that project or deal.\n- 404: an id does not exist in this workspace.\n- 422: a field is missing or has the wrong format (often true/false instead of 0/1, or a date in the wrong format); the message names it.\n\n**Cloud and self-hosted**: both call api.weeek.net over the internet; nothing else differs.\n\n**Out of scope here**: file uploads, custom field and funnel configuration, timers, and deleting projects, boards, deals or contacts.", "region": "intl", "category": "project-management", "icon": "weeek", @@ -942,6 +942,35 @@ } } }, + { + "name": "weeek_remove_task_from_project", + "description": "Take a task out of one of its projects (and that project's board); the task itself is not deleted and can be put back with weeek_add_task_to_project. Changes the shared Weeek workspace: tell the user what will change and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "description": "Task id (the number in the task link, also returned by weeek_list_tasks)." + }, + "projectId": { + "type": "integer", + "description": "Project id to remove the task from (one of the task's locations, see weeek_get_task)." + } + }, + "required": [ + "id", + "projectId" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/tm/tasks/{id}/locations", + "bodyMapping": { + "projectId": "$projectId" + } + } + }, { "name": "weeek_add_task_assignees", "description": "Assign one or more workspace members to a task. Changes the shared Weeek workspace: tell the user what will change and get their confirmation before calling.", diff --git a/packages/backend/src/adapters/intl/weeek.live.spec.ts b/packages/backend/src/adapters/intl/weeek.live.spec.ts index 7048e2986..81c823cbf 100644 --- a/packages/backend/src/adapters/intl/weeek.live.spec.ts +++ b/packages/backend/src/adapters/intl/weeek.live.spec.ts @@ -77,6 +77,16 @@ describe('Weeek adapter — static contract', () => { expect(byName.weeek_list_deals.endpointMapping.path).toBe('/crm/statuses/{statusId}/deals'); }); + it('removes a task from one project with the project id in the DELETE body', () => { + expect(byName.weeek_remove_task_from_project.endpointMapping).toEqual({ + method: 'DELETE', + path: '/tm/tasks/{id}/locations', + bodyMapping: { projectId: '$projectId' }, + }); + expect(byName.weeek_remove_task_from_project.parameters.required).toEqual(['id', 'projectId']); + expect(byName.weeek_remove_task_from_project.enabled).not.toBe(false); + }); + it('sends Weeek flags as 0 or 1, never booleans', () => { expect(byName.weeek_create_project.parameters.properties!.isPrivate.enum).toEqual([0, 1]); expect(byName.weeek_list_tasks.parameters.properties!.completed.enum).toEqual([0, 1]); diff --git a/packages/backend/src/adapters/intl/zenhub.json b/packages/backend/src/adapters/intl/zenhub.json index 0a90f08bb..a2e1d11a5 100644 --- a/packages/backend/src/adapters/intl/zenhub.json +++ b/packages/backend/src/adapters/intl/zenhub.json @@ -2,7 +2,7 @@ "slug": "zenhub", "name": "Zenhub", "description": "Work with Zenhub boards from an AI agent through the GraphQL API: workspaces, pipelines and the issues in them, issue details, sprints and their issues, epics and their child issues, estimates; move issues, set estimates, plan sprints, create issues and comment. 17 typed tools plus a generic query tool, personal API key.", - "instructions": "This connector uses the Zenhub GraphQL API (`https://api.zenhub.com/public/graphql`). Schema and examples: https://developers.zenhub.com/ (the Explorer shows the full schema).\n\n**Setup**:\n1. In Zenhub open the dashboard's **API** section (https://app.zenhub.com/settings/tokens) and create a **Personal API Key** for the GraphQL API. A user can have up to 5 keys.\n2. Copy the key (it is shown once) into `ZENHUB_API_KEY`.\n\n**Authentication**: `Authorization: Bearer `. A 401 means the key is wrong or revoked. The key acts as its user: workspaces and repositories that user cannot see are missing from every result.\n\n**Where to start**: `zenhub_get_viewer` checks the key and lists the organizations. Find a workspace with `zenhub_search_workspaces` (by name) or `zenhub_list_organizations`. Then `zenhub_list_pipelines` for the board columns and `zenhub_list_pipeline_issues` for the issues in one column. Sprints: `zenhub_list_sprints` then `zenhub_list_sprint_issues`. Epics: `zenhub_list_epics` then `zenhub_list_epic_issues`.\n\n**Ids**: Zenhub uses its own opaque ids (for example Z2lkOi8vcmFwdG9yL0lzc3VlLzEwNzQyOQ) for issues, pipelines, sprints, epics and repositories; workspace ids are 24-character hex strings. An issue number plus a repository identifies an issue in `zenhub_get_issue`; the mutations need the Zenhub issue `id`.\n\n**Limits**: each API key gets 90 seconds of processing time per minute (token bucket, headers `X-RateLimit-Limit`, `X-RateLimit-Used`, `X-RateLimit-Remaining`) and at most 30 concurrent requests. A single query may not exceed a complexity of 200 points: every field counts 1 and a connection multiplies its children by `first`. That is why the list tools default to 10 to 30 items and request few fields. Avoid polling; Zenhub offers webhooks for change notifications.\n\n**Pagination**: Relay cursors. Pass `pageInfo.endCursor` back as `after` while `pageInfo.hasNextPage` is true. `first` may not exceed 100, and the complexity limit usually bites first.\n\n**Errors**: a GraphQL answer can be HTTP 200 with an `errors` list; the tool then reports the message. \"Field doesn't exist\" means the query does not match the schema.\n\n**Beyond the typed tools**: every GraphQL connector also gets `zenhub_graphql_query` (read-only, any query document), `zenhub_graphql_mutation`, and a schema tool, for operations not covered here (releases, roadmaps, dependencies, priorities, labels).\n\n**Writes**: `zenhub_move_issue`, `zenhub_set_estimate`, `zenhub_add_issues_to_sprints`, `zenhub_create_issue` and `zenhub_add_comment` change the board or post content visible to the team.", + "instructions": "This connector uses the Zenhub GraphQL API (`https://api.zenhub.com/public/graphql`). Schema and examples: https://developers.zenhub.com/ (the Explorer shows the full schema).\n\n**Setup**:\n1. In Zenhub open the dashboard's **API** section (https://app.zenhub.com/settings/tokens) and create a **Personal API Key** for the GraphQL API. A user can have up to 5 keys.\n2. Copy the key (it is shown once) into `ZENHUB_API_KEY`.\n\n**Authentication**: `Authorization: Bearer `. A 401 means the key is wrong or revoked. The key acts as its user: workspaces and repositories that user cannot see are missing from every result.\n\n**Where to start**: `zenhub_get_viewer` checks the key and lists the organizations. Find a workspace with `zenhub_search_workspaces` (by name) or `zenhub_list_organizations`. `zenhub_get_workspace` shows its repositories, the active sprint and the velocity. Then `zenhub_list_pipelines` for the board columns and `zenhub_list_pipeline_issues` for the issues in one column. Sprints: `zenhub_list_sprints` then `zenhub_list_sprint_issues`. Epics: `zenhub_list_epics` then `zenhub_list_epic_issues`.\n\n**Ids**: Zenhub uses its own opaque ids (for example Z2lkOi8vcmFwdG9yL0lzc3VlLzEwNzQyOQ) for issues, pipelines, sprints, epics and repositories; workspace ids are 24-character hex strings. An issue number plus a repository identifies an issue in `zenhub_get_issue`; the mutations need the Zenhub issue `id`.\n\n**Limits**: each API key gets 90 seconds of processing time per minute (token bucket, headers `X-RateLimit-Limit`, `X-RateLimit-Used`, `X-RateLimit-Remaining`) and at most 30 concurrent requests. A single query may not exceed a complexity of 200 points: every field counts 1 and a connection multiplies its children by `first`. That is why the list tools default to 10 to 30 items and request few fields. Avoid polling; Zenhub offers webhooks for change notifications.\n\n**Pagination**: Relay cursors. Pass `pageInfo.endCursor` back as `after` while `pageInfo.hasNextPage` is true. `first` may not exceed 100, and the complexity limit usually bites first.\n\n**Errors**: a GraphQL answer can be HTTP 200 with an `errors` list; the tool then reports the message. \"Field doesn't exist\" means the query does not match the schema.\n\n**Beyond the typed tools**: every GraphQL connector also gets `zenhub_graphql_schema` (the schema, by type or keyword), `zenhub_graphql_query` and `zenhub_graphql_mutation`, for operations not covered here (releases, roadmaps, dependencies, priorities, labels). Use `zenhub_graphql_query` for `query` documents only: check the field names with `zenhub_graphql_schema` first and keep `first` small, because the 200-point complexity limit applies to it too. `zenhub_graphql_mutation` can change or delete anything the key's user may change, so it is installed switched off; an admin turns it on per connector when needed. Prefer the typed write tools below.\n\n**Writes**: `zenhub_move_issue`, `zenhub_set_estimate` (check the allowed values with `zenhub_get_estimate_values` first), `zenhub_add_issues_to_sprints`, `zenhub_create_issue` and `zenhub_add_comment` change the board or post content visible to the team.", "region": "intl", "category": "project-management", "icon": "zenhub", @@ -31,6 +31,12 @@ "authType": "BEARER_TOKEN", "authConfig": { "token": "{{ZENHUB_API_KEY}}" + }, + "config": { + "graphqlBuiltinsOff": [ + "mutation", + "subscription" + ] } }, "probe": { diff --git a/packages/backend/src/adapters/intl/zenhub.live.spec.ts b/packages/backend/src/adapters/intl/zenhub.live.spec.ts index 05d4c4770..baea81b8a 100644 --- a/packages/backend/src/adapters/intl/zenhub.live.spec.ts +++ b/packages/backend/src/adapters/intl/zenhub.live.spec.ts @@ -1,4 +1,5 @@ import * as adapter from './zenhub.json'; +import { getAdapter } from '../catalog'; type Tool = { name: string; @@ -9,7 +10,14 @@ type Tool = { }; const a = adapter as unknown as { probe: { tool: string }; - connector: { type: string; baseUrl: string; authType: string; authConfig: Record }; + instructions: string; + connector: { + type: string; + baseUrl: string; + authType: string; + authConfig: Record; + config?: { graphqlBuiltinsOff?: string[] }; + }; tools: Tool[]; }; /** Variables an operation declares: `$name: Type` in its header. */ @@ -74,4 +82,22 @@ describe('zenhub adapter: static spec conformance', () => { expect(t.parameters.properties!.filters.default).toEqual({}); expect(t.endpointMapping.path).toContain('$filters: IssueSearchFiltersInput! = {}'); }); + + it('installs the generic mutation and subscription helpers switched off, the query helper on', () => { + expect(a.connector.config?.graphqlBuiltinsOff).toEqual(['mutation', 'subscription']); + expect(a.instructions).toContain('zenhub_graphql_query'); + expect(a.instructions).toMatch(/zenhub_graphql_mutation[^.]*installed switched off/); + // The helpers exist once the catalog has been regenerated; check how they install. + const listed = getAdapter('zenhub'); + if (!listed) return; + const byName = new Map((listed.tools as Array<{ name: string; enabled?: boolean }>).map((t) => [t.name, t])); + expect(byName.get('zenhub_graphql_mutation')?.enabled).toBe(false); + expect(byName.get('zenhub_graphql_subscription')?.enabled).toBe(false); + expect(byName.get('zenhub_graphql_query')?.enabled).not.toBe(false); + }); + + it('names every typed tool in the instructions and writes no em dash', () => { + for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); + expect(JSON.stringify(adapter)).not.toMatch(/\u2014/); + }); }); diff --git a/packages/backend/src/adapters/it/fatture-in-cloud.json b/packages/backend/src/adapters/it/fatture-in-cloud.json index 6c9e93bb9..81a51732e 100644 --- a/packages/backend/src/adapters/it/fatture-in-cloud.json +++ b/packages/backend/src/adapters/it/fatture-in-cloud.json @@ -1,12 +1,12 @@ { "slug": "fatture-in-cloud", "name": "Fatture in Cloud", - "description": "Fatture in Cloud — the invoicing app of 580,000 Italian partite IVA: issued and received documents, clients, suppliers, products, receipts the VAT-relevant e-invoice status and the FatturaPA XML. REST API v2, OAuth 2.0.", + "description": "Fatture in Cloud, the invoicing app of 580,000 Italian partite IVA: issued and received documents, clients, suppliers, products, receipts the VAT-relevant e-invoice status and the FatturaPA XML. REST API v2, OAuth 2.0.", "region": "it", "category": "accounting", "icon": "fatture-in-cloud", "docsUrl": "https://developers.fattureincloud.it/api-reference/", - "instructions": "**Setup**\n1. Register an application in the **Fatture in Cloud developer console** (developers.fattureincloud.it) with the redirect URL `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. You receive a client id and client secret.\n2. Set `FIC_CLIENT_ID`, `FIC_CLIENT_SECRET` and `FIC_COMPANY_ID`, leave `FIC_REFRESH_TOKEN` empty, and install. If you do not know the company id yet, see below.\n3. Open the connector and click **Authorize with Provider**. Sign in to Fatture in Cloud, approve, and you land back on the connector page. AnythingMCP refreshes the access token on its own.\n\n**Scopes**: issued documents have one scope per type, and the connector asks for `issued_documents.invoices:r`, `issued_documents.credit_notes:r`, `issued_documents.quotes:r`, `issued_documents.proformas:r`, `issued_documents.receipts:r`, `issued_documents.orders:r` and `issued_documents.delivery_notes:r`. The other tools need `entity.clients:r`, `entity.suppliers:r`, `received_documents:r`, `products:r` and `receipts:r`, which it asks for as well. All of them are read-only; the `:a` variants grant write access. To read other document types, add their scopes in the connector's OAuth settings and authorize again.\n\n**Already have a refresh token?** You can paste it into `FIC_REFRESH_TOKEN` instead of step 3, if you obtained it yourself for the same application with the scopes above.\n\n**Every path carries a company id.** A Fatture in Cloud account can hold several companies (aziende) and each has its own numeric id; `FIC_COMPANY_ID` fixes this connector to one. `fatture_in_cloud_list_companies` is the exception — it is the one call that works without it, and it tells you which ids exist.\n\n**Issued vs received.** `issued_documents` are the documents you send (invoices, quotes, credit notes, delivery notes); `received_documents` are supplier invoices you have recorded. The `type` parameter narrows within each, and both list calls require it: issued `invoice`, `quote`, `proforma`, `credit_note`, `order`, `receipt`, `delivery_note` and more; received `expense`, `passive_credit_note`, `passive_delivery_note`, `self_invoice`.\n\n**Filtering and paging.** The list tools' `filter` argument is sent as Fatture in Cloud's `q` query: `field = 'value'`, `name contains 'Luca'`, comparisons such as `date >= '2026-01-01'`, combined with `and`/`or` and parentheses; strings in single quotes. Pages default to 5 rows; `per_page` goes up to 100.\n\n**Fatturazione elettronica.** Italian e-invoices carry an `e_invoice` flag and an `ei_status` that tracks the document through the Sistema di Interscambio: `not_sent`, `sent`, `pending`, `accepted`, `rejected`. A rejected invoice is a real accounting problem and worth asking about by name. `fatture_in_cloud_get_e_invoice_xml` returns the FatturaPA XML as it was sent, the authoritative source for the VAT breakdown.\n\n**`fieldset=detailed`** returns the line items and payment schedule; the default returns headers only. Ask for detail when you need it and not before, because documents are large.\n\n**Cloud reachability**: api-v2.fattureincloud.it is public with a valid certificate.", + "instructions": "**Setup**\n1. Register an application in the **Fatture in Cloud developer console** (developers.fattureincloud.it) with the redirect URL `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. You receive a client id and client secret.\n2. Set `FIC_CLIENT_ID`, `FIC_CLIENT_SECRET` and `FIC_COMPANY_ID`, leave `FIC_REFRESH_TOKEN` empty, and install. If you do not know the company id yet, see below.\n3. Open the connector and click **Authorize with Provider**. Sign in to Fatture in Cloud, approve, and you land back on the connector page. AnythingMCP refreshes the access token on its own.\n\n**Scopes**: issued documents have one scope per type, and the connector asks for `issued_documents.invoices:r`, `issued_documents.credit_notes:r`, `issued_documents.quotes:r`, `issued_documents.proformas:r`, `issued_documents.receipts:r`, `issued_documents.orders:r` and `issued_documents.delivery_notes:r`. The other tools need `entity.clients:r`, `entity.suppliers:r`, `received_documents:r`, `products:r`, `receipts:r` and `taxes:r` (F24 list, `fatture_in_cloud_list_f24`), which it asks for as well. A connection authorized before `taxes:r` was added answers 403 on the F24 list until you authorize again. All of them are read-only; the `:a` variants grant write access. To read other document types, add their scopes in the connector's OAuth settings and authorize again.\n\n**Already have a refresh token?** You can paste it into `FIC_REFRESH_TOKEN` instead of step 3, if you obtained it yourself for the same application with the scopes above.\n\n**Every path carries a company id.** A Fatture in Cloud account can hold several companies (aziende) and each has its own numeric id; `FIC_COMPANY_ID` fixes this connector to one. `fatture_in_cloud_list_companies` is the exception: it is the one call that works without it, and it tells you which ids exist.\n\n**Issued vs received.** `issued_documents` are the documents you send (invoices, quotes, credit notes, delivery notes); `received_documents` are supplier invoices you have recorded. The `type` parameter narrows within each, and both list calls require it: issued `invoice`, `quote`, `proforma`, `credit_note`, `order`, `receipt`, `delivery_note` and more; received `expense`, `passive_credit_note`, `passive_delivery_note`, `self_invoice`.\n\n**Filtering and paging.** The list tools' `filter` argument is sent as Fatture in Cloud's `q` query: `field = 'value'`, `name contains 'Luca'`, comparisons such as `date >= '2026-01-01'`, combined with `and`/`or` and parentheses; strings in single quotes. Pages default to 5 rows; `per_page` goes up to 100.\n\n**Fatturazione elettronica.** Italian e-invoices carry an `e_invoice` flag and an `ei_status` that tracks the document through the Sistema di Interscambio: `not_sent`, `sent`, `pending`, `accepted`, `rejected`. A rejected invoice is a real accounting problem and worth asking about by name. `fatture_in_cloud_get_e_invoice_xml` returns the FatturaPA XML as it was sent, the authoritative source for the VAT breakdown.\n\n**`fieldset=detailed`** returns the line items and payment schedule; the default returns headers only. Ask for detail when you need it and not before, because documents are large.\n\n**Cloud reachability**: api-v2.fattureincloud.it is public with a valid certificate.", "requiredEnvVars": [ "FIC_CLIENT_ID", "FIC_CLIENT_SECRET", @@ -35,7 +35,7 @@ "refreshToken": "{{FIC_REFRESH_TOKEN}}", "authorizationUrl": "https://api-v2.fattureincloud.it/oauth/authorize", "tokenUrl": "https://api-v2.fattureincloud.it/oauth/token", - "scopes": "entity.clients:r entity.suppliers:r products:r receipts:r received_documents:r issued_documents.invoices:r issued_documents.credit_notes:r issued_documents.quotes:r issued_documents.proformas:r issued_documents.receipts:r issued_documents.orders:r issued_documents.delivery_notes:r" + "scopes": "entity.clients:r entity.suppliers:r products:r receipts:r received_documents:r taxes:r issued_documents.invoices:r issued_documents.credit_notes:r issued_documents.quotes:r issued_documents.proformas:r issued_documents.receipts:r issued_documents.orders:r issued_documents.delivery_notes:r" }, "headers": { "Accept": "application/json" @@ -48,7 +48,7 @@ "tools": [ { "name": "fatture_in_cloud_list_companies", - "description": "List the companies (aziende) this account can reach, with their id, name and VAT number. The one call that works without a company id — use it to find the right FIC_COMPANY_ID.", + "description": "List the companies (aziende) this account can reach, with their id, name and VAT number. The one call that works without a company id; use it to find the right FIC_COMPANY_ID.", "parameters": { "type": "object", "properties": {} @@ -60,7 +60,7 @@ }, { "name": "fatture_in_cloud_list_issued_documents", - "description": "List documents you issued — invoices, quotes, credit notes, proformas — with their number, date, client, total and payment status.", + "description": "List documents you issued (invoices, quotes, credit notes, proformas) with their number, date, client, total and payment status.", "parameters": { "type": "object", "properties": { @@ -144,7 +144,7 @@ }, { "name": "fatture_in_cloud_list_received_documents", - "description": "List supplier documents you have recorded — purchase invoices, credit notes and expenses — with their supplier, date, total and deductibility.", + "description": "List supplier documents you have recorded (purchase invoices, credit notes and expenses) with their supplier, date, total and deductibility.", "parameters": { "type": "object", "properties": { @@ -293,7 +293,7 @@ }, { "name": "fatture_in_cloud_list_receipts", - "description": "List receipts (corrispettivi) with their date, amount and payment method — the till-side record, separate from invoices.", + "description": "List receipts (corrispettivi) with their date, amount and payment method: the till-side record, separate from invoices.", "parameters": { "type": "object", "properties": { @@ -321,6 +321,41 @@ } } }, + { + "name": "fatture_in_cloud_list_f24", + "description": "List the F24 tax payments recorded in the Tasse section (VAT, withholding, contributions): description, amount, due date, paid or not paid, and the payment account. Needs the taxes:r scope.", + "parameters": { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "Filter expression (sent as the `q` query), e.g. due_date >= '2026-01-01' or status = 'not_paid'." + }, + "sort": { + "type": "string", + "description": "Field to sort by; prefix with - for descending, e.g. -due_date." + }, + "page": { + "type": "number", + "description": "Page number, starting at 1." + }, + "per_page": { + "type": "number", + "description": "Rows per page, 1–100 (default 5)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/c/{FIC_COMPANY_ID}/taxes", + "queryParams": { + "q": "$filter", + "sort": "$sort", + "page": "$page", + "per_page": "$per_page" + } + } + }, { "name": "fatture_in_cloud_get_company_info", "description": "Read the configured company's own details: name, VAT number, tax regime, address and plan. Confirms FIC_COMPANY_ID points where you think it does.", diff --git a/packages/backend/src/adapters/it/fatture-in-cloud.live.spec.ts b/packages/backend/src/adapters/it/fatture-in-cloud.live.spec.ts index 6d669a7d9..06ec53e6c 100644 --- a/packages/backend/src/adapters/it/fatture-in-cloud.live.spec.ts +++ b/packages/backend/src/adapters/it/fatture-in-cloud.live.spec.ts @@ -42,7 +42,7 @@ describe('fatture-in-cloud adapter — static spec conformance', () => { it("sends list filters as `q`, the only filter parameter the API reads", () => { const lists = a.tools.filter((t) => t.name.startsWith('fatture_in_cloud_list_') && t.endpointMapping.queryParams); - expect(lists.length).toBe(6); + expect(lists.length).toBe(7); for (const t of lists) { const q = t.endpointMapping.queryParams as Record; expect(q.q).toBe('$filter'); @@ -82,6 +82,14 @@ describe('fatture-in-cloud adapter: e-invoice XML and received documents', () => path: '/c/{FIC_COMPANY_ID}/issued_documents/{documentId}/e_invoice/xml', }); }); + it('lists the F24 tax payments and asks for the taxes:r scope', () => { + expect(t('fatture_in_cloud_list_f24').endpointMapping).toEqual({ + method: 'GET', + path: '/c/{FIC_COMPANY_ID}/taxes', + queryParams: { q: '$filter', sort: '$sort', page: '$page', per_page: '$per_page' }, + }); + expect(String(a.connector.authConfig?.scopes).split(' ')).toContain('taxes:r'); + }); it('reads one received document in detail', () => { expect(t('fatture_in_cloud_get_received_document').endpointMapping).toMatchObject({ method: 'GET', diff --git a/packages/backend/src/adapters/se/fortnox.json b/packages/backend/src/adapters/se/fortnox.json index c8aec0b32..c44599883 100644 --- a/packages/backend/src/adapters/se/fortnox.json +++ b/packages/backend/src/adapters/se/fortnox.json @@ -1,12 +1,12 @@ { "slug": "fortnox", "name": "Fortnox", - "description": "Fortnox — the Swedish SMB accounting standard: customers, invoices, supplier invoices, articles, orders, vouchers and the account chart. REST API v3, OAuth 2.0.", + "description": "Fortnox, the Swedish SMB accounting standard: customers, invoices, supplier invoices, articles, orders, vouchers and the account chart. REST API v3, OAuth 2.0.", "region": "se", "category": "accounting", "icon": "fortnox", "docsUrl": "https://www.fortnox.se/developer/", - "instructions": "**Setup**\n1. Register at the **Fortnox Developer Portal** and create an integration. Add the redirect URI `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself, and enable the scopes `settings`, `customer`, `invoice`, `supplierinvoice`, `order`, `article` and `bookkeeping`. You receive a **client id** and **client secret**.\n2. Set `FORTNOX_CLIENT_ID` and `FORTNOX_CLIENT_SECRET`, leave `FORTNOX_REFRESH_TOKEN` empty, and install.\n3. Open the connector and click **Authorize with Provider**. Sign in with a user of the customer's Fortnox account, approve, and you land back on the connector page. AnythingMCP refreshes the access token on its own.\n\n**Scopes**: `settings customer invoice supplierinvoice order article bookkeeping`, one per tool area. Fortnox scopes always grant read and write. The authorization URL carries `access_type=offline`, without which Fortnox issues no refresh token.\n\n**Already have a refresh token?** You can paste it into `FORTNOX_REFRESH_TOKEN` instead of step 3, if you obtained it yourself for the same integration and have not used it since (see below).\n\n**The legacy Access-Token header is gone.** Fortnox retired the old `Access-Token` + `Client-Secret` header pair; if you find an example using those, it is pre-2022 and will not work. OAuth 2.0 is the only path now.\n\n**Refresh tokens rotate.** Fortnox issues a new refresh token on every refresh and invalidates the old one. AnythingMCP persists the rotated value back into the connector, but a refresh token you tested by hand, or share with another script, is spent and the connector fails with `invalid_grant`. Authorize again from the connector page to get a fresh one.\n\n**Rate limit** is roughly 25 requests per 5 seconds per token, answered with 429. Pace bulk work.\n\n**Responses wrap the rows** in the resource name: `{\"Customers\": [...], \"MetaInformation\": {...}}`. The paging totals live in `MetaInformation` (`@TotalPages`, `@TotalResources`).\n\n**`financialyear` matters for vouchers.** Fortnox scopes bookkeeping to a financial year id; without one it uses the current year, which is a surprise in January.\n\n**Cloud reachability**: api.fortnox.se is public with a valid certificate.", + "instructions": "**Setup**\n1. Register at the **Fortnox Developer Portal** and create an integration. Add the redirect URI `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself, and enable the scopes `settings`, `customer`, `invoice`, `supplierinvoice`, `order`, `article` and `bookkeeping` (add `inbox` too if you want `fortnox_list_inbox`, see Scopes). You receive a **client id** and **client secret**.\n2. Set `FORTNOX_CLIENT_ID` and `FORTNOX_CLIENT_SECRET`, leave `FORTNOX_REFRESH_TOKEN` empty, and install.\n3. Open the connector and click **Authorize with Provider**. Sign in with a user of the customer's Fortnox account, approve, and you land back on the connector page. AnythingMCP refreshes the access token on its own.\n\n**Scopes**: `settings customer invoice supplierinvoice order article bookkeeping`, one per tool area. `fortnox_list_inbox` also needs `inbox`: enable it on the integration in the Fortnox Developer Portal, add it to the connector's OAuth scopes, and authorize again; until then it answers 403. Fortnox refuses an authorization that asks for a scope the integration does not have, so it is not requested by default. Fortnox scopes always grant read and write. The authorization URL carries `access_type=offline`, without which Fortnox issues no refresh token.\n\n**Already have a refresh token?** You can paste it into `FORTNOX_REFRESH_TOKEN` instead of step 3, if you obtained it yourself for the same integration and have not used it since (see below).\n\n**The legacy Access-Token header is gone.** Fortnox retired the old `Access-Token` + `Client-Secret` header pair; if you find an example using those, it is pre-2022 and will not work. OAuth 2.0 is the only path now.\n\n**Refresh tokens rotate.** Fortnox issues a new refresh token on every refresh and invalidates the old one. AnythingMCP persists the rotated value back into the connector, but a refresh token you tested by hand, or share with another script, is spent and the connector fails with `invalid_grant`. Authorize again from the connector page to get a fresh one.\n\n**Invoices and attachments.** `fortnox_create_invoice` saves an unbooked draft (installed switched off); booking and sending stay in Fortnox. To attach a PDF, the file must already be in the inbox (upload it in Fortnox): `fortnox_list_inbox` with path `Inbox_kf` gives its `ArchiveFileId`, `fortnox_attach_files` links it to the invoice (entityType `F`, entityId = the invoice DocumentNumber), `fortnox_list_attachments` shows what is attached, and `fortnox_detach_file` (installed switched off) removes a link without deleting the file. The attachment tools call `api.fortnox.se/api/fileattachments` with the same token.\n\n**Rate limit** is roughly 25 requests per 5 seconds per token, answered with 429. Pace bulk work.\n\n**Responses wrap the rows** in the resource name: `{\"Customers\": [...], \"MetaInformation\": {...}}`. The paging totals live in `MetaInformation` (`@TotalPages`, `@TotalResources`).\n\n**`financialyear` matters for vouchers.** Fortnox scopes bookkeeping to a financial year id; without one it uses the current year, which is a surprise in January.\n\n**Cloud reachability**: api.fortnox.se is public with a valid certificate.", "requiredEnvVars": [ "FORTNOX_CLIENT_ID", "FORTNOX_CLIENT_SECRET" @@ -183,9 +183,161 @@ "path": "/invoices/{documentNumber}" } }, + { + "name": "fortnox_create_invoice", + "enabled": false, + "description": "Create a customer invoice as an unbooked draft: customer, dates and rows. Fields left out take the predefined values from the Fortnox settings (terms of payment, accounts). The draft is neither booked nor sent; booking and sending happen in Fortnox. Installed switched off. Show the user the customer, rows and totals and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "Invoice": { + "type": "object", + "description": "The invoice. CustomerNumber is required; usual fields: InvoiceDate and DueDate (YYYY-MM-DD), YourReference, OurReference, YourOrderNumber, Currency, Comments, Remarks and InvoiceRows, e.g. [{\"ArticleNumber\": \"100\", \"DeliveredQuantity\": 2, \"Price\": 450}] or a free-text row [{\"Description\": \"Consulting May\", \"DeliveredQuantity\": 8, \"Price\": 950, \"AccountNumber\": 3041}].", + "properties": { + "CustomerNumber": { + "type": "string", + "description": "Customer number, from fortnox_list_customers." + }, + "InvoiceRows": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Rows with ArticleNumber or Description, DeliveredQuantity, Price, optionally AccountNumber, VAT, Discount, Unit." + } + }, + "required": [ + "CustomerNumber" + ] + } + }, + "required": [ + "Invoice" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/invoices", + "bodyMapping": { + "Invoice": "$Invoice" + } + } + }, + { + "name": "fortnox_list_attachments", + "description": "List the files attached to one invoice, order or offer: attachment id, fileId and whether the file goes out with the document (includeOnSend). Uses the Fortnox File Attachments API.", + "parameters": { + "type": "object", + "properties": { + "entity_id": { + "type": "integer", + "description": "Internal id of the document (the invoice DocumentNumber for type F)." + }, + "entity_type": { + "type": "string", + "enum": [ + "F", + "O", + "OF", + "C", + "LGR_IO", + "LGR_IG" + ], + "description": "Document type: F customer invoice, O order, OF offer, C contract, LGR_IO / LGR_IG warehouse documents." + } + }, + "required": [ + "entity_id", + "entity_type" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.fortnox.se/api/fileattachments/attachments-v1", + "queryParams": { + "entityid": "$entity_id", + "entitytype": "$entity_type" + } + } + }, + { + "name": "fortnox_attach_files", + "description": "Attach files that are already in the Fortnox inbox or archive to one or more invoices, orders or offers, optionally to be sent with the document. Detaching later leaves the file in the archive. Changes the customer's documents in Fortnox: tell the user what will be attached where and get their confirmation before calling.", + "parameters": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "minItems": 1, + "description": "One entry per file and document: entityId (document id), entityType (F invoice, O order, OF offer), fileId (the ArchiveFileId from fortnox_list_inbox) and includeOnSend (true to send it with the document).", + "items": { + "type": "object", + "properties": { + "entityId": { + "type": "integer" + }, + "entityType": { + "type": "string", + "enum": [ + "F", + "O", + "OF", + "C", + "LGR_IO", + "LGR_IG" + ] + }, + "fileId": { + "type": "string" + }, + "includeOnSend": { + "type": "boolean" + } + }, + "required": [ + "entityId", + "entityType", + "fileId" + ] + } + } + }, + "required": [ + "attachments" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "https://api.fortnox.se/api/fileattachments/attachments-v1", + "bodyMapping": { + "__raw": "$attachments" + } + } + }, + { + "name": "fortnox_detach_file", + "enabled": false, + "description": "Detach one file from its invoice, order or offer by attachment id; the file itself stays in the Fortnox archive. Installed switched off. Confirm the document and file with the user before calling.", + "parameters": { + "type": "object", + "properties": { + "attachment_id": { + "type": "string", + "description": "Attachment id (a UUID) from fortnox_list_attachments, not the fileId." + } + }, + "required": [ + "attachment_id" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "https://api.fortnox.se/api/fileattachments/attachments-v1/{attachment_id}" + } + }, { "name": "fortnox_list_supplier_invoices", - "description": "List supplier invoices with their supplier, date, due date, total and payment state — the payables side of the ledger.", + "description": "List supplier invoices with their supplier, date, due date, total and payment state: the payables side of the ledger.", "parameters": { "type": "object", "properties": { @@ -295,7 +447,7 @@ }, { "name": "fortnox_list_vouchers", - "description": "List bookkeeping vouchers in a financial year with their series, number, date and description — the ledger underneath the documents.", + "description": "List bookkeeping vouchers in a financial year with their series, number, date and description: the ledger underneath the documents.", "parameters": { "type": "object", "properties": { @@ -309,7 +461,7 @@ }, "financialyear": { "type": "number", - "description": "Financial year id. Omitted, Fortnox uses the current year — a surprise in January." + "description": "Financial year id. Omitted, Fortnox uses the current year, a surprise in January." }, "fromdate": { "type": "string", @@ -362,6 +514,26 @@ "financialyear": "$financialyear" } } + }, + { + "name": "fortnox_list_inbox", + "description": "List the files and sub-folders of the Fortnox inbox (Inkorgen) or of one of its folders, with each file's Id, Name, Size and ArchiveFileId. The ArchiveFileId is what fortnox_attach_files needs.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Folder path: Inbox_kf customer invoices, Inbox_s supplier invoices, Inbox_v vouchers, Inbox_o orders, Inbox_of offers, Inbox_a asset register, Inbox_d daily takings, Inbox_b bank files, Inbox_l payroll. Left out: the root with its folders." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/inbox", + "queryParams": { + "path": "$path" + } + } } ] } diff --git a/packages/backend/src/adapters/se/fortnox.live.spec.ts b/packages/backend/src/adapters/se/fortnox.live.spec.ts index 84c00ed03..d77e568b9 100644 --- a/packages/backend/src/adapters/se/fortnox.live.spec.ts +++ b/packages/backend/src/adapters/se/fortnox.live.spec.ts @@ -37,6 +37,39 @@ describe('fortnox adapter — static spec conformance', () => { it("warns that vouchers default to the current financial year", () => { expect((adapter as unknown as { instructions: string }).instructions).toContain('a surprise in January'); }); + + const tool = (name: string) => { + const found = (adapter as unknown as { + tools: Array<{ name: string; enabled?: boolean; parameters: { required?: string[] }; endpointMapping: Record }>; + }).tools.find((t) => t.name === name); + if (!found) throw new Error(`no tool ${name}`); + return found; + }; + const attachments = 'https://api.fortnox.se/api/fileattachments/attachments-v1'; + + it("creates invoices as drafts, installed switched off", () => { + expect(tool('fortnox_create_invoice').endpointMapping).toEqual({ method: 'POST', path: '/invoices', bodyMapping: { Invoice: '$Invoice' } }); + expect(tool('fortnox_create_invoice').enabled).toBe(false); + }); + + it("lists inbox folders by path; the inbox scope stays opt-in so existing integrations can still authorize", () => { + expect(tool('fortnox_list_inbox').endpointMapping).toEqual({ method: 'GET', path: '/inbox', queryParams: { path: '$path' } }); + expect(String(a.connector.authConfig?.scopes).split(' ')).not.toContain('inbox'); + expect((adapter as unknown as { instructions: string }).instructions).toMatch(/fortnox_list_inbox` also needs `inbox`/); + }); + + it("maps the File Attachments API on its own base path", () => { + expect(tool('fortnox_list_attachments').endpointMapping).toEqual({ + method: 'GET', + path: attachments, + queryParams: { entityid: '$entity_id', entitytype: '$entity_type' }, + }); + expect(tool('fortnox_list_attachments').parameters.required).toEqual(['entity_id', 'entity_type']); + // The API takes a JSON array of attachments as the whole body. + expect(tool('fortnox_attach_files').endpointMapping).toEqual({ method: 'POST', path: attachments, bodyMapping: { __raw: '$attachments' } }); + expect(tool('fortnox_detach_file').endpointMapping).toEqual({ method: 'DELETE', path: `${attachments}/{attachment_id}` }); + expect(tool('fortnox_detach_file').enabled).toBe(false); + }); }); // Opt-in live check. Needs real credentials; skipped in CI. From b150e5f63a90184e809f221f014caf36abf041ef Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:56:16 +0200 Subject: [PATCH 3/6] Regenerate the catalog and update the quoted adapter count to 544 --- CITATION.cff | 2 +- README.de.md | 6 ++--- README.ja.md | 6 ++--- README.md | 6 ++--- README.zh-CN.md | 6 ++--- glama.json | 2 +- package.json | 2 +- packages/backend/src/adapters/catalog.ts | 30 ++++++++++++++++++++++++ server.json | 2 +- 9 files changed, 46 insertions(+), 16 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 03698be3e..9bc5d39d4 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 529 pre-built adapters, with a focus on ERP and e-commerce + It ships 544 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 227f90937..a1a1735b2 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: 529 Connectors, 24 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 544 Connectors, 25 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 529 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 24 davon kommen ohne API-Schlüssel aus. + Es bringt 544 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 25 davon kommen ohne API-Schlüssel aus.

@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec ## Connector-Katalog -529 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. +544 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 5cc326373..a000831db 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

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

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

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

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

AnythingMCP:自行托管的 MCP 网关

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

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

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 529 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 544 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/glama.json b/glama.json index bd82934c6..729dff7fc 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. 529 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. 544 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 6dd11d283..9f934ec26 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.47.2", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 529 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. 544 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 cc36f2d8b..7e7baba4c 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -69,6 +69,7 @@ import * as timetac from './de/timetac.json'; import * as viesVat from './de/vies-vat.json'; import * as weclapp from './de/weclapp.json'; import * as xentral from './de/xentral.json'; +import * as xovi from './de/xovi.json'; import * as zalandoZds from './de/zalando-zds.json'; import * as zammad from './de/zammad.json'; import * as znuny from './de/znuny.json'; @@ -121,6 +122,7 @@ import * as binderp from './intl/binderp.json'; import * as bingWebmaster from './intl/bing-webmaster.json'; import * as bitbucket from './intl/bitbucket.json'; import * as bitrix24 from './intl/bitrix24.json'; +import * as blockcypher from './intl/blockcypher.json'; import * as bluesky from './intl/bluesky.json'; import * as botpress from './intl/botpress.json'; import * as box from './intl/box.json'; @@ -136,6 +138,7 @@ import * as calendly from './intl/calendly.json'; import * as canva from './intl/canva.json'; import * as chargebee from './intl/chargebee.json'; import * as chatwoot from './intl/chatwoot.json'; +import * as checko from './intl/checko.json'; import * as circleci from './intl/circleci.json'; import * as claap from './intl/claap.json'; import * as clearbit from './intl/clearbit.json'; @@ -247,6 +250,7 @@ import * as harvest from './intl/harvest.json'; import * as heap from './intl/heap.json'; import * as height from './intl/height.json'; import * as helium10 from './intl/helium10.json'; +import * as helius from './intl/helius.json'; import * as helpScout from './intl/help-scout.json'; import * as hetznerCloud from './intl/hetzner-cloud.json'; import * as heygen from './intl/heygen.json'; @@ -261,6 +265,7 @@ import * as instagram from './intl/instagram.json'; import * as instantly from './intl/instantly.json'; import * as intercom from './intl/intercom.json'; import * as invoiceNinja from './intl/invoice-ninja.json'; +import * as invoice123 from './intl/invoice123.json'; import * as invoiced from './intl/invoiced.json'; import * as iterable from './intl/iterable.json'; import * as jev from './intl/jev.json'; @@ -295,6 +300,7 @@ import * as mastodon from './intl/mastodon.json'; import * as matomo from './intl/matomo.json'; import * as mautic from './intl/mautic.json'; import * as medium from './intl/medium.json'; +import * as medmis from './intl/medmis.json'; import * as mercury from './intl/mercury.json'; import * as messagebird from './intl/messagebird.json'; import * as metaAds from './intl/meta-ads.json'; @@ -324,6 +330,7 @@ import * as mssql from './intl/mssql.json'; import * as mysql from './intl/mysql.json'; import * as neon from './intl/neon.json'; import * as netlify from './intl/netlify.json'; +import * as netskope from './intl/netskope.json'; import * as netsuite from './intl/netsuite.json'; import * as neverbounce from './intl/neverbounce.json'; import * as newRelic from './intl/new-relic.json'; @@ -331,6 +338,7 @@ import * as newsapi from './intl/newsapi.json'; import * as nextcloud from './intl/nextcloud.json'; import * as nimble from './intl/nimble.json'; import * as nominatim from './intl/nominatim.json'; +import * as noonPayments from './intl/noon-payments.json'; import * as notion from './intl/notion.json'; import * as novaPoshta from './intl/nova-poshta.json'; import * as nutshellCrm from './intl/nutshell-crm.json'; @@ -399,6 +407,7 @@ import * as salesflare from './intl/salesflare.json'; import * as salesforce from './intl/salesforce.json'; import * as salesloft from './intl/salesloft.json'; import * as sanity from './intl/sanity.json'; +import * as sapC4c from './intl/sap-c4c.json'; import * as sapConcur from './intl/sap-concur.json'; import * as sapS4hanaCloud from './intl/sap-s4hana-cloud.json'; import * as sapS4hanaHana from './intl/sap-s4hana-hana.json'; @@ -442,12 +451,16 @@ 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 tabby from './intl/tabby.json'; import * as tally from './intl/tally.json'; +import * as tamara from './intl/tamara.json'; import * as tavily from './intl/tavily.json'; +import * as tbank from './intl/tbank.json'; import * as teamworkProjects from './intl/teamwork-projects.json'; import * as ted from './intl/ted.json'; import * as telegramBot from './intl/telegram-bot.json'; import * as telnyx from './intl/telnyx.json'; +import * as tezga from './intl/tezga.json'; import * as theOddsApi from './intl/the-odds-api.json'; import * as ticktick from './intl/ticktick.json'; import * as tidio from './intl/tidio.json'; @@ -460,6 +473,7 @@ import * as tradingview from './intl/tradingview.json'; import * as trello from './intl/trello.json'; import * as trendyol from './intl/trendyol.json'; import * as trenitalia from './intl/trenitalia.json'; +import * as trybe from './intl/trybe.json'; import * as twelveData from './intl/twelve-data.json'; import * as twilio from './intl/twilio.json'; import * as twitch from './intl/twitch.json'; @@ -537,6 +551,7 @@ import * as droppery from './nl/droppery.json'; import * as exactOnline from './nl/exact-online.json'; import * as moneybird from './nl/moneybird.json'; import * as topdesk from './nl/topdesk.json'; +import * as falco from './be/falco.json'; import * as teamleader from './be/teamleader.json'; import * as bexio from './ch/bexio.json'; import * as fortnox from './se/fortnox.json'; @@ -838,6 +853,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ viesVat as unknown as AdapterDefinition, weclapp as unknown as AdapterDefinition, xentral as unknown as AdapterDefinition, + xovi as unknown as AdapterDefinition, zalandoZds as unknown as AdapterDefinition, zammad as unknown as AdapterDefinition, znuny as unknown as AdapterDefinition, @@ -890,6 +906,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ bingWebmaster as unknown as AdapterDefinition, bitbucket as unknown as AdapterDefinition, bitrix24 as unknown as AdapterDefinition, + blockcypher as unknown as AdapterDefinition, bluesky as unknown as AdapterDefinition, botpress as unknown as AdapterDefinition, box as unknown as AdapterDefinition, @@ -905,6 +922,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ canva as unknown as AdapterDefinition, chargebee as unknown as AdapterDefinition, chatwoot as unknown as AdapterDefinition, + checko as unknown as AdapterDefinition, circleci as unknown as AdapterDefinition, claap as unknown as AdapterDefinition, clearbit as unknown as AdapterDefinition, @@ -1016,6 +1034,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ heap as unknown as AdapterDefinition, height as unknown as AdapterDefinition, helium10 as unknown as AdapterDefinition, + helius as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, hetznerCloud as unknown as AdapterDefinition, heygen as unknown as AdapterDefinition, @@ -1030,6 +1049,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ instantly as unknown as AdapterDefinition, intercom as unknown as AdapterDefinition, invoiceNinja as unknown as AdapterDefinition, + invoice123 as unknown as AdapterDefinition, invoiced as unknown as AdapterDefinition, iterable as unknown as AdapterDefinition, jev as unknown as AdapterDefinition, @@ -1064,6 +1084,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ matomo as unknown as AdapterDefinition, mautic as unknown as AdapterDefinition, medium as unknown as AdapterDefinition, + medmis as unknown as AdapterDefinition, mercury as unknown as AdapterDefinition, messagebird as unknown as AdapterDefinition, metaAds as unknown as AdapterDefinition, @@ -1093,6 +1114,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mysql as unknown as AdapterDefinition, neon as unknown as AdapterDefinition, netlify as unknown as AdapterDefinition, + netskope as unknown as AdapterDefinition, netsuite as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, newRelic as unknown as AdapterDefinition, @@ -1100,6 +1122,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ nextcloud as unknown as AdapterDefinition, nimble as unknown as AdapterDefinition, nominatim as unknown as AdapterDefinition, + noonPayments as unknown as AdapterDefinition, notion as unknown as AdapterDefinition, novaPoshta as unknown as AdapterDefinition, nutshellCrm as unknown as AdapterDefinition, @@ -1168,6 +1191,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ salesforce as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, sanity as unknown as AdapterDefinition, + sapC4c as unknown as AdapterDefinition, sapConcur as unknown as AdapterDefinition, sapS4hanaCloud as unknown as AdapterDefinition, sapS4hanaHana as unknown as AdapterDefinition, @@ -1211,12 +1235,16 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ supabase as unknown as AdapterDefinition, surveymonkey as unknown as AdapterDefinition, synology as unknown as AdapterDefinition, + tabby as unknown as AdapterDefinition, tally as unknown as AdapterDefinition, + tamara as unknown as AdapterDefinition, tavily as unknown as AdapterDefinition, + tbank as unknown as AdapterDefinition, teamworkProjects as unknown as AdapterDefinition, ted as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, telnyx as unknown as AdapterDefinition, + tezga as unknown as AdapterDefinition, theOddsApi as unknown as AdapterDefinition, ticktick as unknown as AdapterDefinition, tidio as unknown as AdapterDefinition, @@ -1229,6 +1257,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ trello as unknown as AdapterDefinition, trendyol as unknown as AdapterDefinition, trenitalia as unknown as AdapterDefinition, + trybe as unknown as AdapterDefinition, twelveData as unknown as AdapterDefinition, twilio as unknown as AdapterDefinition, twitch as unknown as AdapterDefinition, @@ -1306,6 +1335,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ exactOnline as unknown as AdapterDefinition, moneybird as unknown as AdapterDefinition, topdesk as unknown as AdapterDefinition, + falco as unknown as AdapterDefinition, teamleader as unknown as AdapterDefinition, bexio as unknown as AdapterDefinition, fortnox as unknown as AdapterDefinition, diff --git a/server.json b/server.json index 78bb69166..db12f19e4 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.HelpCode-ai/anythingmcp", "title": "AnythingMCP", - "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 529 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 544 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github" From 72e2c7f8ac3351917860e8caee21f546be245901 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:09:20 +0200 Subject: [PATCH 4/6] Falco: say that setting a purchase's payment status installs switched off --- packages/backend/src/adapters/be/falco.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/adapters/be/falco.json b/packages/backend/src/adapters/be/falco.json index 596391238..065c0857d 100644 --- a/packages/backend/src/adapters/be/falco.json +++ b/packages/backend/src/adapters/be/falco.json @@ -7,7 +7,7 @@ "Falco boekhouding", "Falco comptabilite" ], - "description": "Falco, Belgian accounting and Peppol e-invoicing: purchase and sale documents with their files, invoices received over Peppol, Peppol participant lookups, and the customers, articles and invoices of the Falco invoicing module. Mostly read-only; Peppol invoice responses are installed switched off.", + "description": "Falco, Belgian accounting and Peppol e-invoicing: purchase and sale documents with their files, invoices received over Peppol, Peppol participant lookups, and the customers, articles and invoices of the Falco invoicing module. Mostly read-only; setting a purchase's payment status and Peppol invoice responses are installed switched off.", "region": "be", "category": "accounting", "icon": "falco", From fcf7ff878dce92708fd1c6c5e3b640b61366e966 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:09:28 +0200 Subject: [PATCH 5/6] Correct the keyless count in the README banner text --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb74e148a..1c3fe87f7 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: 544 connectors, 23 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 544 connectors, 25 of them with no API key.

AnythingMCP: self-hosted MCP gateway

From 0d1caf05bebc586e17a3b48039485880638083dd Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:23:58 +0200 Subject: [PATCH 6/6] Match secret env var names without a mixed-anchor regex --- packages/backend/src/adapters/be/falco.live.spec.ts | 2 +- packages/backend/src/adapters/de/xovi.live.spec.ts | 2 +- packages/backend/src/adapters/intl/invoice123.live.spec.ts | 2 +- packages/backend/src/adapters/intl/netskope.live.spec.ts | 2 +- packages/backend/src/adapters/intl/sap-c4c.live.spec.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/adapters/be/falco.live.spec.ts b/packages/backend/src/adapters/be/falco.live.spec.ts index bda405f7b..4da2ea501 100644 --- a/packages/backend/src/adapters/be/falco.live.spec.ts +++ b/packages/backend/src/adapters/be/falco.live.spec.ts @@ -135,7 +135,7 @@ describe('falco adapter: static spec conformance', () => { it('documents every tool in the instructions, marks secrets and has no em dash', () => { for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); - for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN/.test(name) || name.endsWith('_KEY')) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); expect(JSON.stringify(a)).not.toMatch(/\u2014/); if (a.probe) { const probe = tool(a.probe.tool); diff --git a/packages/backend/src/adapters/de/xovi.live.spec.ts b/packages/backend/src/adapters/de/xovi.live.spec.ts index 3dfd69a88..bcc856079 100644 --- a/packages/backend/src/adapters/de/xovi.live.spec.ts +++ b/packages/backend/src/adapters/de/xovi.live.spec.ts @@ -139,7 +139,7 @@ describe('xovi adapter: static spec conformance', () => { it('documents every tool in the instructions, marks secrets and has no em dash', () => { for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); - for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN/.test(name) || name.endsWith('_KEY')) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); expect(JSON.stringify(a)).not.toMatch(/\u2014/); if (a.probe) { const probe = tool(a.probe.tool); diff --git a/packages/backend/src/adapters/intl/invoice123.live.spec.ts b/packages/backend/src/adapters/intl/invoice123.live.spec.ts index 6211a13e5..291744891 100644 --- a/packages/backend/src/adapters/intl/invoice123.live.spec.ts +++ b/packages/backend/src/adapters/intl/invoice123.live.spec.ts @@ -125,7 +125,7 @@ describe('invoice123 adapter: static spec conformance', () => { it('documents every tool in the instructions, marks secrets and has no em dash', () => { for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); - for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN/.test(name) || name.endsWith('_KEY')) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); expect(JSON.stringify(a)).not.toMatch(/\u2014/); if (a.probe) { const probe = tool(a.probe.tool); diff --git a/packages/backend/src/adapters/intl/netskope.live.spec.ts b/packages/backend/src/adapters/intl/netskope.live.spec.ts index f7d1fc662..e518328bb 100644 --- a/packages/backend/src/adapters/intl/netskope.live.spec.ts +++ b/packages/backend/src/adapters/intl/netskope.live.spec.ts @@ -129,7 +129,7 @@ describe('netskope adapter: static spec conformance', () => { it('documents every tool in the instructions, marks secrets and has no em dash', () => { for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); - for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN/.test(name) || name.endsWith('_KEY')) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); expect(JSON.stringify(a)).not.toMatch(/\u2014/); if (a.probe) { const probe = tool(a.probe.tool); diff --git a/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts b/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts index b6aea4778..fc9dfb5d4 100644 --- a/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts +++ b/packages/backend/src/adapters/intl/sap-c4c.live.spec.ts @@ -145,7 +145,7 @@ describe('sap-c4c adapter: static spec conformance', () => { it('documents every tool in the instructions, marks secrets and has no em dash', () => { for (const t of a.tools) expect(`${t.name}:${a.instructions.includes(t.name)}`).toBe(`${t.name}:true`); - for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN|_KEY$/.test(name)) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); + for (const [name, meta] of Object.entries(a.envVarMeta ?? {})) if (/PASSWORD|SECRET|TOKEN/.test(name) || name.endsWith('_KEY')) expect(`${name}:${meta.secret}`).toBe(`${name}:true`); expect(JSON.stringify(a)).not.toMatch(/\u2014/); if (a.probe) { const probe = tool(a.probe.tool);