From 17bf616422aba2837b2cf82a17f7a296611d3bf0 Mon Sep 17 00:00:00 2001 From: Matteo Date: Mon, 28 Sep 2026 11:48:59 +0200 Subject: [PATCH] feat(adapters): Firma.dev e-signature Firma.dev asked to be in the E-Signature category next to Dropbox Sign. 13 tools on their REST API: templates and their recipient slots, a draft signing request from a template, send, resend and cancel, the list of signing requests (trimmed to what a model needs), one request with its recipients, the audit trail and the signed PDF's download URL, plus the company account and its remaining credits. The key goes in the Authorization header without a prefix. Every Firma workspace has a test key that consumes no credits and watermarks the documents; the instructions start people there. Creating a request makes a draft; sending it emails the signers and spends a credit, so send, resend and cancel are advertised as open-world writes and the instructions ask for the user's go-ahead first. No workspace tools: their responses carry API keys. Verified: routes answer 401 without a key and INVALID_API_KEY with a wrong one (so the header is read); 21 static tests through the real engine and the list transform. Live reads are in the spec behind RUN_FIRMA_LIVE for a test key. --- CITATION.cff | 2 +- README.de.md | 8 +- README.ja.md | 8 +- README.md | 8 +- README.zh-CN.md | 8 +- glama.json | 2 +- package.json | 2 +- packages/backend/src/adapters/catalog.ts | 2 + packages/backend/src/adapters/intl/firma.json | 486 ++++++++++++++++++ .../src/adapters/intl/firma.live.spec.ts | 201 ++++++++ .../public/logos/connectors/firma.svg | 1 + server.json | 2 +- 12 files changed, 710 insertions(+), 20 deletions(-) create mode 100644 packages/backend/src/adapters/intl/firma.json create mode 100644 packages/backend/src/adapters/intl/firma.live.spec.ts create mode 100644 packages/frontend/public/logos/connectors/firma.svg diff --git a/CITATION.cff b/CITATION.cff index ed0f5172..d71af617 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 264 pre-built adapters, with a focus on ERP and e-commerce + It ships 265 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 b1282721..f5dc8f6e 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: 264 Connectors, 21 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 265 Connectors, 21 davon ohne API-Schlüssel.

AnythingMCP

@@ -10,7 +10,7 @@

Mach aus jeder REST-/OpenAPI-, SOAP-, GraphQL- oder SQL-API MCP-Tools für Claude, ChatGPT und Copilot.
- Selbst gehosteter MCP-Server und MCP-Gateway, ohne Code. 264 fertige Adapter, auch für ERP und E-Commerce: SAP Business One, Xentral, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland und viele mehr. + Selbst gehosteter MCP-Server und MCP-Gateway, ohne Code. 265 fertige Adapter, auch für ERP und E-Commerce: SAP Business One, Xentral, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland und viele mehr.

@@ -40,7 +40,7 @@ docker compose up -d # → http://localhost:3000 Drei Begriffe tauchen immer wieder auf und bezeichnen unterschiedliche Dinge: -- Ein **Adapter** ist eine der 264 JSON-Definitionen in diesem Repository — SAP Business One, Odoo, weclapp, Xentral, Shopware, WooCommerce, Amazon Seller, DHL und viele weitere. 21 davon benötigen überhaupt keinen API-Schlüssel; bei den anderen gibst du deine Zugangsdaten beim Import an. +- Ein **Adapter** ist eine der 265 JSON-Definitionen in diesem Repository — SAP Business One, Odoo, weclapp, Xentral, Shopware, WooCommerce, Amazon Seller, DHL und viele weitere. 21 davon benötigen überhaupt keinen API-Schlüssel; bei den anderen gibst du deine Zugangsdaten beim Import an. - Ein **Connector** entsteht, wenn du einen Adapter oder deine eigene OpenAPI-Spezifikation, Postman-Collection, WSDL, einen GraphQL-Endpunkt oder eine Datenbank in deinem Workspace konfigurierst. In wenigen Minuten lässt sich so eine Verbindung einrichten, ohne einen MCP-Server zu programmieren. - Ein **MCP-Server** ist die URL, die du Claude übergibst. Er stellt ausschließlich die Connectors bereit, die du ihm zuweist. @@ -334,7 +334,7 @@ KI-Clients sprechen MCP, deine Systeme dagegen REST, SOAP, GraphQL und SQL. Eine ## Der Adapterkatalog -264 Adapter mit mehr als 2.400 Tools. **21 benötigen keinen API-Schlüssel**. Bei den übrigen gibst du deine Zugangsdaten beim Import an; danach stehen die Tools sofort bereit. Für jeden Adapter gibt es auf [anythingmcp.com/guides](https://anythingmcp.com/guides) eine Einrichtungsanleitung in sieben Sprachen. +265 Adapter mit mehr als 2.400 Tools. **21 benötigen keinen API-Schlüssel**. Bei den übrigen gibst du deine Zugangsdaten beim Import an; danach stehen die Tools sofort bereit. Für jeden Adapter gibt es auf [anythingmcp.com/guides](https://anythingmcp.com/guides) eine Einrichtungsanleitung in sieben Sprachen. | Kategorie | Beispiele | |---|---| diff --git a/README.ja.md b/README.ja.md index 454b5c97..a693b21b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

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

AnythingMCP

@@ -10,7 +10,7 @@

REST/OpenAPI、SOAP、GraphQL、SQL のあらゆる API を、Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- コード不要のセルフホスト型 MCP サーバー兼ゲートウェイです。SAP Business One、Odoo、Xentral、weclapp、Shopware、WooCommerce、Amazon Seller、Kaufland など、ERP や E コマースを含む 264 種類の既製アダプターを用意しています。 + コード不要のセルフホスト型 MCP サーバー兼ゲートウェイです。SAP Business One、Odoo、Xentral、weclapp、Shopware、WooCommerce、Amazon Seller、Kaufland など、ERP や E コマースを含む 265 種類の既製アダプターを用意しています。

@@ -40,7 +40,7 @@ docker compose up -d # → http://localhost:3000 この文書で繰り返し使う 3 つの用語は、それぞれ異なるものを指します。 -- **アダプター(adapter)**は、このリポジトリに含まれる 264 個の JSON 定義のいずれかです。SAP Business One、Odoo、weclapp、Xentral、Shopware、WooCommerce、Amazon Seller、DHL などがあります。そのうち 21 個は API キーを一切必要とせず、それ以外はインポート時に認証情報を設定します。 +- **アダプター(adapter)**は、このリポジトリに含まれる 265 個の JSON 定義のいずれかです。SAP Business One、Odoo、weclapp、Xentral、Shopware、WooCommerce、Amazon Seller、DHL などがあります。そのうち 21 個は API キーを一切必要とせず、それ以外はインポート時に認証情報を設定します。 - **コネクター(connector)**は、アダプター、または独自の OpenAPI 仕様・Postman コレクション・WSDL・GraphQL エンドポイント・データベースを、ワークスペース内で設定したものです。接続先を指定すれば、MCP サーバーを書くことなく数分で設定できます。 - **MCP サーバー**は、Claude に渡す URL です。そのサーバーに割り当てたコネクターだけを公開します。 @@ -334,7 +334,7 @@ AI クライアントは MCP を使いますが、業務システムは REST、S ## アダプターカタログ -264 個のアダプターで、2,400 以上のツールを公開できます。**21 個は API キーが不要**です。それ以外はインポート時に認証情報を設定すれば、すぐにツールを利用できます。各アダプターには [anythingmcp.com/guides](https://anythingmcp.com/guides) で 7 言語のセットアップガイドを用意しています。 +265 個のアダプターで、2,400 以上のツールを公開できます。**21 個は API キーが不要**です。それ以外はインポート時に認証情報を設定すれば、すぐにツールを利用できます。各アダプターには [anythingmcp.com/guides](https://anythingmcp.com/guides) で 7 言語のセットアップガイドを用意しています。 | カテゴリー | 例 | |---|---| diff --git a/README.md b/README.md index f7d40b8f..bc355c54 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: 264 connectors, 21 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 265 connectors, 21 of them with no API key.

AnythingMCP

@@ -10,7 +10,7 @@

Turn any REST/OpenAPI, SOAP, GraphQL or SQL API into MCP tools for Claude, ChatGPT and Copilot.
- A self-hosted MCP server and gateway, no code, 264 ready connectors including ERP and e-commerce: SAP Business One, Odoo, Xentral, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland and more. + A self-hosted MCP server and gateway, no code, 265 ready connectors including ERP and e-commerce: SAP Business One, Odoo, Xentral, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland and more.

@@ -43,7 +43,7 @@ docker compose up -d # → http://localhost:3000 Three words appear throughout and mean three different things: -- an **adapter** is one of the 264 JSON definitions that ship in this repo — SAP Business One, Odoo, weclapp, Xentral, Shopware, WooCommerce, Amazon Seller, DHL and the rest. 21 of them need no API key at all; the others ask for your credentials at import. +- an **adapter** is one of the 265 JSON definitions that ship in this repo — SAP Business One, Odoo, weclapp, Xentral, Shopware, WooCommerce, Amazon Seller, DHL and the rest. 21 of them need no API key at all; the others ask for your credentials at import. - a **connector** is an adapter, or your own OpenAPI spec / Postman collection / WSDL / GraphQL endpoint / database, once you have configured it in your workspace. Anything you can point at, in minutes, without writing an MCP server. - an **MCP server** is the URL you hand to Claude. It exposes the connectors you assign to it, and nothing else. @@ -335,7 +335,7 @@ AI clients speak MCP, but your systems speak REST, SOAP, GraphQL and SQL. Writin ## The adapter catalog -264 adapters, exposing 2,400+ tools. **21 need no API key**; the rest ask for your credentials at import and the tools are available immediately. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. +265 adapters, exposing 2,400+ tools. **21 need no API key**; the rest ask for your credentials at import and the tools are available immediately. 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 0fe66500..9ab18086 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,5 @@

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

AnythingMCP

@@ -10,7 +10,7 @@

将任意 REST/OpenAPI、SOAP、GraphQL 或 SQL API 转化为 Claude、ChatGPT 和 Copilot 可用的 MCP 工具。
- 自行托管的 MCP 服务器与网关,无需编写代码,264 个现成适配器开箱即用,涵盖 ERP 和电子商务:SAP Business One、Odoo、Xentral、weclapp、Shopware、WooCommerce、Amazon Seller、Kaufland 等。 + 自行托管的 MCP 服务器与网关,无需编写代码,265 个现成适配器开箱即用,涵盖 ERP 和电子商务:SAP Business One、Odoo、Xentral、weclapp、Shopware、WooCommerce、Amazon Seller、Kaufland 等。

@@ -40,7 +40,7 @@ docker compose up -d # → http://localhost:3000 本文反复使用的三个术语,分别指不同的概念: -- **适配器(adapter)**:本仓库随附的 264 个 JSON 定义之一,例如 SAP Business One、Odoo、weclapp、Xentral、Shopware、WooCommerce、Amazon Seller、DHL 等。其中 21 个完全不需要 API 密钥,其余适配器会在导入时要求你提供相应凭据。 +- **适配器(adapter)**:本仓库随附的 265 个 JSON 定义之一,例如 SAP Business One、Odoo、weclapp、Xentral、Shopware、WooCommerce、Amazon Seller、DHL 等。其中 21 个完全不需要 API 密钥,其余适配器会在导入时要求你提供相应凭据。 - **连接器(connector)**:在工作区中配置好的适配器,或你自己的 OpenAPI 规范、Postman 集合、WSDL、GraphQL 端点或数据库。只要有可连接的目标,就能在几分钟内完成配置,无需编写 MCP 服务器。 - **MCP 服务器**:你提供给 Claude 的那个 URL。它只公开分配给它的连接器,不会公开其他连接器。 @@ -334,7 +334,7 @@ AI 客户端使用 MCP,而你的系统使用 REST、SOAP、GraphQL 和 SQL。 ## 适配器目录 -264 个适配器,提供 2,400 多个工具。**其中 21 个不需要 API 密钥**,其余适配器会在导入时要求提供凭据,导入后工具即可立即使用。每个适配器都在 [anythingmcp.com/guides](https://anythingmcp.com/guides) 上提供七种语言的设置指南。 +265 个适配器,提供 2,400 多个工具。**其中 21 个不需要 API 密钥**,其余适配器会在导入时要求提供凭据,导入后工具即可立即使用。每个适配器都在 [anythingmcp.com/guides](https://anythingmcp.com/guides) 上提供七种语言的设置指南。 | 分类 | 示例 | |---|---| diff --git a/glama.json b/glama.json index 9c394f71..22d16b5a 100644 --- a/glama.json +++ b/glama.json @@ -3,5 +3,5 @@ "maintainers": [ "keysersoft" ], - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 264 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. 265 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 e2e00aab..2af75d72 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.14.1", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 264 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. 265 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 a7e622a5..da015732 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -108,6 +108,7 @@ import * as etsy from './intl/etsy.json'; import * as fathom from './intl/fathom.json'; import * as fhir from './intl/fhir.json'; import * as fillout from './intl/fillout.json'; +import * as firma from './intl/firma.json'; import * as flutterwave from './intl/flutterwave.json'; import * as folk from './intl/folk.json'; import * as freshbooks from './intl/freshbooks.json'; @@ -530,6 +531,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ fathom as unknown as AdapterDefinition, fhir as unknown as AdapterDefinition, fillout as unknown as AdapterDefinition, + firma as unknown as AdapterDefinition, flutterwave as unknown as AdapterDefinition, folk as unknown as AdapterDefinition, freshbooks as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/firma.json b/packages/backend/src/adapters/intl/firma.json new file mode 100644 index 00000000..664628b4 --- /dev/null +++ b/packages/backend/src/adapters/intl/firma.json @@ -0,0 +1,486 @@ +{ + "slug": "firma", + "name": "Firma.dev", + "description": "Firma.dev e-signature from any AI agent: templates, signing requests from a template, sending and reminders, recipient status, audit trail and signed PDFs. 13 tools, API-key auth, with free test keys.", + "instructions": "This connector drives **Firma.dev** (api.firma.dev), a developer-first e-signature API: templates, signing requests (envelopes), their recipients, audit trail and signed documents. 13 tools.\n\n**Setup**\n1. Sign up at https://firma.dev (free, no card) and open your workspace in the dashboard.\n2. Copy an API key into `FIRMA_API_KEY`. Every workspace has two: the **test** key creates watermarked test requests and consumes **no credits**, the **live** key sends real, legally binding requests and consumes one credit per envelope (about €0.049). Start with the test key.\n3. The key is sent as the `Authorization` header, without a Bearer prefix. It is stored encrypted.\n\nA workspace key only sees that workspace's templates and requests. The account's main key (protected workspace) sees the company account too.\n\n**The usual flow: a document from a template**\n1. `firma_list_templates` (filter by `name`) and `firma_get_template` to find the template.\n2. `firma_list_template_recipients` for its recipient slots: each has an `id` (use it as `template_user_id`), a `designation` (Signer, Approver, CC) and an `order`.\n3. `firma_create_signing_request_from_template` with the real people for those slots. This creates a **draft**: nothing is emailed and no credit is used.\n4. Show the user the draft (name, recipients, emails) and ask for confirmation. Then `firma_send_signing_request`: this emails every signer and, on a live key, consumes one credit. `firma_get_company` shows the remaining `credits`.\n5. Track it with `firma_get_signing_request` (status flags and timestamps), `firma_list_signing_request_recipients` (who signed, who declined and why, who is still missing fields) and `firma_get_signing_request_audit`.\n6. When it is finished, `firma_get_signed_document_url` returns a short-lived download URL for the signed PDF.\n\n**Rules**\n- Never send, resend or cancel without the user's explicit go-ahead: these email real people.\n- `firma_resend_signing_request` only reaches recipients who are eligible to sign now (with a signing order, only the current step) and never people who already signed.\n- `firma_cancel_signing_request` works only on sent requests that are not finished or cancelled; set `notify_signers` to tell them.\n- Status values for filtering: `not_sent`, `in_progress`, `finished`, `cancelled`, `declined`, `expired` (comma-separated for several).\n- Dates are ISO 8601. Lists are paginated: `pagination.total_pages` tells you whether to ask for the next `page`.\n\n**Errors**: 401 `INVALID_API_KEY` = wrong or revoked key; 402 or a credits message = the account needs credits (or use the test key); 429 = rate limit (200 reads and 120 writes per minute per key), wait for `Retry-After`.", + "region": "intl", + "category": "e-signature", + "icon": "firma", + "docsUrl": "https://docs.firma.dev", + "requiredEnvVars": [ + "FIRMA_API_KEY" + ], + "connector": { + "name": "Firma.dev", + "type": "REST", + "baseUrl": "https://api.firma.dev/functions/v1/signing-request-api", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "{{FIRMA_API_KEY}}" + } + }, + "tools": [ + { + "name": "firma_get_company", + "description": "The Firma.dev company account behind the key: name, owner, language and the remaining envelope credits. Use it before sending to check credits. Needs the account's main key; a workspace key may get 403.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/company" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_list_templates", + "description": "List the workspace's templates (reusable documents with recipient slots and fields), newest first unless sorted otherwise. Filter by name to find the one to send.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Part of the template name, case-insensitive." + }, + "page": { + "type": "integer", + "minimum": 1, + "description": "Page number, starting at 1." + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Items per page (default 20)." + }, + "sort_by": { + "type": "string", + "enum": [ + "name", + "created_on", + "last_changed_on" + ], + "description": "Field to sort by." + }, + "sort_order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort direction." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/templates", + "queryParams": { + "name": "$name", + "page": "$page", + "page_size": "$page_size", + "sort_by": "$sort_by", + "sort_order": "$sort_order" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_get_template", + "description": "One template in full: its settings, document, fields and reminders, as it would be copied into a signing request.", + "parameters": { + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "The template id, as returned by the list tools." + } + }, + "required": [ + "template_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/templates/{template_id}" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_list_template_recipients", + "description": "The recipient slots of a template: id (pass it as template_user_id when creating a signing request), designation (Signer, Approver, CC), signing order and any preset name or email. Call it before firma_create_signing_request_from_template.", + "parameters": { + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "The template id, as returned by the list tools." + } + }, + "required": [ + "template_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/templates/{template_id}/users" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_list_signing_requests", + "description": "List signing requests (envelopes) with their status and dates, filtered by status, name, signer or creation date. Returns a trimmed view; use firma_get_signing_request for one request in full.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "not_sent, in_progress, finished, cancelled, declined or expired; comma-separate several, e.g. 'in_progress,declined'." + }, + "name": { + "type": "string", + "description": "Part of the request name, case-insensitive." + }, + "signer_email": { + "type": "string", + "description": "Exact email of a signer." + }, + "signer_name": { + "type": "string", + "description": "Part of a signer's name." + }, + "created_after": { + "type": "string", + "description": "ISO 8601 date or timestamp." + }, + "created_before": { + "type": "string", + "description": "ISO 8601 date or timestamp." + }, + "page": { + "type": "integer", + "minimum": 1, + "description": "Page number, starting at 1." + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Items per page (default 20)." + }, + "sort_by": { + "type": "string", + "enum": [ + "name", + "created_on", + "expiration_hours", + "sent_on", + "finished_on" + ], + "description": "Field to sort by." + }, + "sort_order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort direction." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/signing-requests", + "queryParams": { + "status": "$status", + "name": "$name", + "signer_email": "$signer_email", + "signer_name": "$signer_name", + "created_after": "$created_after", + "created_before": "$created_before", + "page": "$page", + "page_size": "$page_size", + "sort_by": "$sort_by", + "sort_order": "$sort_order" + } + }, + "responseMapping": { + "transform": { + "mode": "jmespath", + "expression": "{results: results[].{id: id, name: name, status: status, template_id: template_id, credit_cost: credit_cost, created_date: created_date, sent_date: sent_date, finished_date: finished_date, cancelled_date: cancelled_date, declined_date: declined_date, expires_at: expires_at, recipients: recipients[].{name: name, email: email, designation: designation, finished_on: finished_on, declined_on: declined_on}}, pagination: pagination}" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_get_signing_request", + "description": "One signing request in full: status flags (sent, finished, cancelled, declined, expired), timestamps, expiry, credit cost and settings.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + } + }, + "required": [ + "signing_request_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/signing-requests/{signing_request_id}" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_list_signing_request_recipients", + "description": "The recipients of a signing request with where each one stands: finished_on (signed), declined_on and decline_reason, signing order, and missing_fields / ready_to_send before sending. Use it to answer 'who still has to sign'.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + } + }, + "required": [ + "signing_request_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/signing-requests/{signing_request_id}/users" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_get_signing_request_audit", + "description": "The audit trail of a signing request in chronological order: created, edited, sent and cancelled by the sender; viewed, signed, declined and downloaded by each signer.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + } + }, + "required": [ + "signing_request_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/signing-requests/{signing_request_id}/audit" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_get_signed_document_url", + "description": "A short-lived download URL for the signed PDF of a finished signing request (or the partially signed PDF when partial download is enabled). Give the user the URL; it expires at expires_at.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + } + }, + "required": [ + "signing_request_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/signing-requests/{signing_request_id}/download" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "firma_create_signing_request_from_template", + "description": "Create a DRAFT signing request from a template, with the real recipients filled into the template's slots. Nothing is emailed and no credit is used until firma_send_signing_request. Get the slot ids with firma_list_template_recipients.", + "parameters": { + "type": "object", + "properties": { + "template_id": { + "type": "string", + "description": "The template id, as returned by the list tools." + }, + "name": { + "type": "string", + "description": "Name of the signing request, e.g. 'NDA - Acme GmbH'. Defaults to the template's name." + }, + "recipients": { + "type": "array", + "description": "One entry per template slot you fill: {\"template_user_id\": \"\", \"first_name\": \"Ada\", \"last_name\": \"Lovelace\", \"email\": \"ada@example.com\", \"designation\": \"Signer\"}. designation is Signer, Approver or CC; first_name, email and designation are required. Omit to keep the template's own recipients.", + "items": { + "type": "object" + } + }, + "expiration_hours": { + "type": "integer", + "minimum": 1, + "description": "Hours until the request expires once sent (default 168 = 7 days)." + }, + "description": { + "type": "string", + "description": "Optional description shown to recipients." + } + }, + "required": [ + "template_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/signing-requests", + "bodyMapping": { + "template_id": "$template_id", + "name": "$name", + "recipients": "$recipients", + "expiration_hours": "$expiration_hours", + "description": "$description" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "firma_send_signing_request", + "description": "Send a draft signing request: every signer gets the signing email. On a live key this consumes one credit and cannot be undone (only cancelled). Ask the user to confirm the recipients first.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + } + }, + "required": [ + "signing_request_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/signing-requests/{signing_request_id}/send" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + } + }, + { + "name": "firma_resend_signing_request", + "description": "Email the signing link again to recipients who can sign now and have not signed yet, optionally with a short message. Recipient ids come from firma_list_signing_request_recipients.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + }, + "recipient_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ids of the recipients to remind." + }, + "custom_message": { + "type": "string", + "description": "Optional message added to the reminder email." + } + }, + "required": [ + "signing_request_id", + "recipient_ids" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/signing-requests/{signing_request_id}/resend", + "bodyMapping": { + "recipient_ids": "$recipient_ids", + "custom_message": "$custom_message" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + } + }, + { + "name": "firma_cancel_signing_request", + "description": "Cancel a sent signing request that is not finished yet; signers can no longer sign it. Only with the user's explicit go-ahead.", + "parameters": { + "type": "object", + "properties": { + "signing_request_id": { + "type": "string", + "description": "The signing request id, as returned by the list tools." + }, + "reason": { + "type": "string", + "description": "Optional reason, shown to signers when they are notified." + }, + "notify_signers": { + "type": "boolean", + "description": "Email the signers about the cancellation." + } + }, + "required": [ + "signing_request_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/signing-requests/{signing_request_id}/cancel", + "bodyMapping": { + "reason": "$reason", + "notify_signers": "$notify_signers" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": true + } + } + ], + "probe": { + "tool": "firma_list_templates", + "params": { + "page_size": 1 + } + } +} diff --git a/packages/backend/src/adapters/intl/firma.live.spec.ts b/packages/backend/src/adapters/intl/firma.live.spec.ts new file mode 100644 index 00000000..4bc6de31 --- /dev/null +++ b/packages/backend/src/adapters/intl/firma.live.spec.ts @@ -0,0 +1,201 @@ +import * as adapter from './firma.json'; +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'; +import { applyResponseTransform } from '../../connectors/response-transform.util'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; + +/** + * Firma.dev adapter. + * + * 1. Static — always runs: the key goes in `Authorization` with no Bearer + * prefix, every tool reaches the right path with the right body, the + * list view is trimmed, and the tools that email real people or spend a + * credit are not advertised as read-only. + * + * 2. Live — skipped unless RUN_FIRMA_LIVE is set. Reads only, so a test key + * is enough and nothing is sent or charged: + * + * RUN_FIRMA_LIVE=1 FIRMA_API_KEY= \ + * npx jest src/adapters/intl/firma.live.spec.ts + */ + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + const mocked = jest.fn(); + return { + __esModule: true, + default: Object.assign(mocked, { __actual: actual.default }), + AxiosError: actual.AxiosError, + }; +}); +const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios }; + +type Tool = { + name: string; + description: string; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; bodyMapping?: Record }; + responseMapping?: { transform: Record }; + annotations?: Record; +}; +const a = adapter as unknown as { + instructions: string; + requiredEnvVars: string[]; + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`no tool ${name}`); + return t; +}; + +const engine = () => + new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); +const config = (key = 'firma_test_key') => ({ + baseUrl: a.connector.baseUrl, + authType: a.connector.authType, + authConfig: { ...a.connector.authConfig, apiKey: key }, +}); +const BASE = 'https://api.firma.dev/functions/v1/signing-request-api'; + +const call = async (name: string, params: Record) => { + mockedAxios.mockResolvedValue({ data: {} }); + await engine().execute(config(), tool(name).endpointMapping as any, params); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0]; +}; + +describe('firma adapter — static spec conformance', () => { + beforeEach(() => mockedAxios.mockReset()); + + it('sends the key as a bare Authorization header', async () => { + expect(a.requiredEnvVars).toEqual(['FIRMA_API_KEY']); + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig).toEqual({ + headerName: 'Authorization', + apiKey: '{{FIRMA_API_KEY}}', + }); + const req = await call('firma_get_company', {}); + expect(req.headers.Authorization).toBe('firma_test_key'); + }); + + it.each([ + ['firma_get_company', {}, 'GET', '/company'], + ['firma_list_templates', { name: 'NDA' }, 'GET', '/templates'], + ['firma_get_template', { template_id: 't1' }, 'GET', '/templates/t1'], + ['firma_list_template_recipients', { template_id: 't1' }, 'GET', '/templates/t1/users'], + ['firma_list_signing_requests', { status: 'in_progress' }, 'GET', '/signing-requests'], + ['firma_get_signing_request', { signing_request_id: 's1' }, 'GET', '/signing-requests/s1'], + ['firma_list_signing_request_recipients', { signing_request_id: 's1' }, 'GET', '/signing-requests/s1/users'], + ['firma_get_signing_request_audit', { signing_request_id: 's1' }, 'GET', '/signing-requests/s1/audit'], + ['firma_get_signed_document_url', { signing_request_id: 's1' }, 'GET', '/signing-requests/s1/download'], + ['firma_create_signing_request_from_template', { template_id: 't1' }, 'POST', '/signing-requests'], + ['firma_send_signing_request', { signing_request_id: 's1' }, 'POST', '/signing-requests/s1/send'], + ['firma_resend_signing_request', { signing_request_id: 's1', recipient_ids: ['u1'] }, 'POST', '/signing-requests/s1/resend'], + ['firma_cancel_signing_request', { signing_request_id: 's1' }, 'POST', '/signing-requests/s1/cancel'], + ])('%s calls %s %s', async (name, params, method, path) => { + const req = await call(name, params); + expect(req.method).toBe(method); + expect(req.url).toBe(BASE + path); + }); + + it('covers every tool in the table above', () => { + expect(a.tools).toHaveLength(13); + expect(a.instructions).toContain('13 tools'); + }); + + it('creates a draft from a template with the recipients in their slots', async () => { + const recipients = [ + { template_user_id: 'slot-1', first_name: 'Ada', email: 'ada@example.com', designation: 'Signer' }, + ]; + const req = await call('firma_create_signing_request_from_template', { + template_id: 't1', + name: 'NDA - Acme', + recipients, + expiration_hours: 72, + }); + expect(req.data).toEqual({ template_id: 't1', name: 'NDA - Acme', recipients, expiration_hours: 72 }); + }); + + it('sends only what was given on resend and cancel', async () => { + expect((await call('firma_resend_signing_request', { signing_request_id: 's1', recipient_ids: ['u1'] })).data) + .toEqual({ recipient_ids: ['u1'] }); + expect((await call('firma_cancel_signing_request', { signing_request_id: 's1', notify_signers: true })).data) + .toEqual({ notify_signers: true }); + }); + + it('trims the list of signing requests to what a model needs', () => { + const raw = { + results: [ + { + id: 's1', name: 'NDA', status: 'in_progress', template_id: 't1', credit_cost: 1, + created_date: '2026-09-28', sent_date: '2026-09-28', document_url: 'https://signed.example/x.pdf', + fields: [{ id: 'f1' }], settings: { allow_download: true }, + recipients: [{ name: 'Ada', email: 'ada@example.com', designation: 'Signer', finished_on: null, phone_number: '+49' }], + }, + ], + pagination: { current_page: 1, page_size: 20, total_count: 1, total_pages: 1 }, + }; + const out = applyResponseTransform(raw, tool('firma_list_signing_requests').responseMapping as any).value as any; + expect(out.pagination.total_pages).toBe(1); + expect(out.results[0]).toMatchObject({ id: 's1', status: 'in_progress', credit_cost: 1 }); + expect(out.results[0]).not.toHaveProperty('document_url'); + expect(out.results[0]).not.toHaveProperty('fields'); + expect(out.results[0].recipients[0]).toEqual({ + name: 'Ada', email: 'ada@example.com', designation: 'Signer', finished_on: null, declined_on: null, + }); + }); + + it('marks reads read-only and never calls a send or cancel read-only', () => { + const ann = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + for (const t of a.tools.filter((x) => x.endpointMapping.method === 'GET')) { + expect({ tool: t.name, ro: ann(t).readOnlyHint }).toEqual({ tool: t.name, ro: true }); + } + expect(ann(tool('firma_send_signing_request'))).toMatchObject({ readOnlyHint: false, openWorldHint: true }); + expect(ann(tool('firma_resend_signing_request'))).toMatchObject({ readOnlyHint: false, openWorldHint: true }); + expect(ann(tool('firma_cancel_signing_request'))).toMatchObject({ readOnlyHint: false, destructiveHint: true }); + expect(ann(tool('firma_create_signing_request_from_template'))).toMatchObject({ readOnlyHint: false, destructiveHint: false }); + }); + + it('exposes no workspace tool: those responses carry API keys', () => { + for (const t of a.tools) expect(t.endpointMapping.path).not.toMatch(/workspace/); + }); + + it('only names tools that exist', () => { + const names = new Set(a.tools.map((t) => t.name)); + const mentioned = [ + ...a.instructions.matchAll(/\bfirma_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bfirma_[a-z_]+/g)]), + ].map((m) => m[0]); + expect(mentioned.length).toBeGreaterThan(10); + for (const n of mentioned) expect(names).toContain(n); + }); +}); + +const live = process.env.RUN_FIRMA_LIVE ? describe : describe.skip; +live('firma adapter — live API (reads only)', () => { + beforeAll(() => { + if (!process.env.FIRMA_API_KEY) throw new Error('Set FIRMA_API_KEY (a test key is enough)'); + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + }); + const run = (name: string, params: Record = {}) => + engine().execute(config(process.env.FIRMA_API_KEY), tool(name).endpointMapping as any, params) as Promise; + + it('lists templates', async () => { + const out = await run('firma_list_templates', { page_size: 5 }); + expect(Array.isArray(out.results)).toBe(true); + }, 30_000); + + it('lists signing requests and reads the first one', async () => { + const out = await run('firma_list_signing_requests', { page_size: 5 }); + expect(out.pagination).toBeDefined(); + if (out.results.length) { + const id = out.results[0].id; + expect((await run('firma_get_signing_request', { signing_request_id: id })).id).toBe(id); + expect(Array.isArray((await run('firma_list_signing_request_recipients', { signing_request_id: id })).results)).toBe(true); + } + }, 30_000); +}); diff --git a/packages/frontend/public/logos/connectors/firma.svg b/packages/frontend/public/logos/connectors/firma.svg new file mode 100644 index 00000000..beb55d87 --- /dev/null +++ b/packages/frontend/public/logos/connectors/firma.svg @@ -0,0 +1 @@ +Firma.dev diff --git a/server.json b/server.json index c77f4a46..71ba1091 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.HelpCode-ai/anythingmcp", - "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 264 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 265 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github"