diff --git a/CITATION.cff b/CITATION.cff index 9bc5d39d..9bd34e43 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 544 pre-built adapters, with a focus on ERP and e-commerce + It ships 545 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 a1a1735b..6ce37c00 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: 544 Connectors, 25 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 545 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 544 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 25 davon kommen ohne API-Schlüssel aus. + Es bringt 545 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 -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. +545 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 a000831d..bdcba06b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

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

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

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

AnythingMCP:自行托管的 MCP 网关

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

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

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 544 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 545 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/docs/connectors/login-token-auth.md b/docs/connectors/login-token-auth.md index 620f903c..c95083e8 100644 --- a/docs/connectors/login-token-auth.md +++ b/docs/connectors/login-token-auth.md @@ -97,6 +97,16 @@ The token cache is keyed by `connectorId` (when available) or by `loginUrl|usern "outputParam": "passwordHashed" // name exposed to the body template (default: passwordHashed) }, + // ─── Or: a JWT signed with the account's private key (no password) ─ + "signedJwt": { + "privateKey": "{{SERVICE_PRIVATE_KEY}}", // PEM, PKCS#1 / PKCS#8 / SEC1 + "algorithm": "RS256", // RS256 (default) | RS384 | RS512 | PS256 | ES256 | ES384 + "claims": { "sub": "{{SERVICE_KEY_ID}}" }, + "keyId": "…", // optional `kid` header + "ttlSeconds": 300, // optional; adds exp = iat + ttlSeconds + "outputParam": "signedJwt" // name exposed to the body template (default: signedJwt) + }, + // ─── Where to find the token & expiry in the login response ──── "tokenJsonPath": "data.signIn.jwtToken.token", "expiryJsonPath": "data.signIn.jwtToken.expiredAt", @@ -133,9 +143,30 @@ The placeholders below are substituted by the engine when rendering `loginBody` | `${passwordHashed}` | output of the password preprocessor (renamed via `outputParam`) | | `${aud}` | `authConfig.aud` | | `${otp}` | `authConfig.otp` | +| `${signedJwt}` | a JWT signed for this login (see below; renamed via `outputParam`) | In `headerTemplate` and `extraHeaders`, only `${token}` and `${aud}` are available (these refer to the issued token, not the credentials). +### Signed JWT logins + +Some APIs take no password: the client signs a short JWT with the private key +of a service account and posts it to the login endpoint (noon's Partner API: +RS256, `sub` = the key id, then the session comes back as cookies). With +`signedJwt` set, `username` and `password` are not required, and every login +signs a new token: `iat` is the current time in whole seconds, `jti` a random +UUID, then the configured `claims`; `exp` is added only when `ttlSeconds` is +set. A claim whose `{{VARIABLE}}` has no value stops the login with an error +that names it. The key is read as for OAuth2 `private_key_jwt` client +assertions: a PEM pasted on one line or with literal `\n` works, an encrypted +key is refused, and no error message contains key material. + +```json +"loginBody": { "token": "${signedJwt}", "default_project_code": "{{NOON_PROJECT_CODE}}" }, +"signedJwt": { "privateKey": "{{NOON_PRIVATE_KEY}}", "algorithm": "RS256", "claims": { "sub": "{{NOON_KEY_ID}}" } }, +"tokenSource": "cookie", +"cookieMode": "all" +``` + ### Expiry formats | `expiryFormat` | Value at `expiryJsonPath` | @@ -172,6 +203,7 @@ whole body, for logins that answer with a bare token string). ## Security notes +- **Private keys never leave the server.** With `signedJwt`, only the signed token is sent; the key stays in encrypted storage like a password. - **Plain passwords never leave the server.** Only the bcrypt hash is sent over the wire; the plain password lives only in encrypted `authConfig` storage. - The encrypted `authConfig` is decrypted in-process at request time and never logged. - Tokens persisted in `connector_auth_cache` are encrypted with the same AES-256-GCM key (`ENCRYPTION_KEY` env var). diff --git a/docs/tool-definition.md b/docs/tool-definition.md index c5b042af..eeb13ad8 100644 --- a/docs/tool-definition.md +++ b/docs/tool-definition.md @@ -941,7 +941,7 @@ to every connector type, not just REST. ## Authentication: `LOGIN_TOKEN` -In addition to the standard `NONE` / `API_KEY` / `BEARER_TOKEN` / `BASIC_AUTH` / `OAUTH2` / `QUERY_AUTH` / `WS_SECURITY` / `CERTIFICATE` / `CONNECTION_STRING` schemes, the connector spec supports `LOGIN_TOKEN` for APIs that issue a long-lived bearer **in exchange for a credentials POST** — optionally requiring the password to be **bcrypt-hashed with a salt fetched from the remote service** (Sorare-style). +In addition to the standard `NONE` / `API_KEY` / `BEARER_TOKEN` / `BASIC_AUTH` / `OAUTH2` / `QUERY_AUTH` / `WS_SECURITY` / `CERTIFICATE` / `CONNECTION_STRING` schemes, the connector spec supports `LOGIN_TOKEN` for APIs that issue a long-lived bearer **in exchange for a credentials POST** — optionally requiring the password to be **bcrypt-hashed with a salt fetched from the remote service** (Sorare-style). APIs that log in with a JWT the client signs with its own private key instead of a password (noon's Partner API) use `signedJwt`. The engine handles salt fetch → bcrypt → login → token cache → proactive refresh → re-login-on-401 automatically. Adapter authors declare the full flow as JSON: diff --git a/glama.json b/glama.json index 729dff7f..944e8c40 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. 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." + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 545 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 9f934ec2..50cdece5 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. 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).", + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 545 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.spec.ts b/packages/backend/src/adapters/catalog.spec.ts index c53feac9..76ade48f 100644 --- a/packages/backend/src/adapters/catalog.spec.ts +++ b/packages/backend/src/adapters/catalog.spec.ts @@ -368,15 +368,25 @@ describe('adapter catalog', () => { expect(typeof cfg.loginUrl).toBe('string'); // Same rule as LoginTokenService: a token read from a cookie has no // JSON path, it names the cookie instead. + // With cookieMode "all" every cookie is the token and cookieName is + // optional. if (cfg.tokenSource === 'cookie') { - expect(typeof cfg.cookieName).toBe('string'); + if (cfg.cookieMode !== 'all') expect(typeof cfg.cookieName).toBe('string'); } else { expect(typeof cfg.tokenJsonPath).toBe('string'); } - // performLogin refuses to start without both, even when the body - // does not use them. - expect(typeof cfg.username).toBe('string'); - expect(typeof cfg.password).toBe('string'); + if (cfg.signedJwt) { + // A signed-JWT login needs a key and has to send the token. + const jwt = cfg.signedJwt as Record; + expect(typeof jwt.privateKey).toBe('string'); + const param = typeof jwt.outputParam === 'string' ? jwt.outputParam : 'signedJwt'; + expect(JSON.stringify(cfg.loginBody ?? cfg.loginBodyTemplate ?? '')).toContain(`\${${param}}`); + } else { + // performLogin refuses to start without both, even when the body + // does not use them. + expect(typeof cfg.username).toBe('string'); + expect(typeof cfg.password).toBe('string'); + } if (cfg.passwordHashing) { const ph = cfg.passwordHashing as Record; expect(VALID_PASSWORD_HASHING_SCHEMES.has(String(ph.scheme))).toBe(true); diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 7e7baba4..5e7fd6c9 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -338,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 noonMarketplace from './intl/noon-marketplace.json'; import * as noonPayments from './intl/noon-payments.json'; import * as notion from './intl/notion.json'; import * as novaPoshta from './intl/nova-poshta.json'; @@ -1122,6 +1123,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ nextcloud as unknown as AdapterDefinition, nimble as unknown as AdapterDefinition, nominatim as unknown as AdapterDefinition, + noonMarketplace as unknown as AdapterDefinition, noonPayments as unknown as AdapterDefinition, notion as unknown as AdapterDefinition, novaPoshta as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/noon-marketplace.json b/packages/backend/src/adapters/intl/noon-marketplace.json new file mode 100644 index 00000000..5d9950b3 --- /dev/null +++ b/packages/backend/src/adapters/intl/noon-marketplace.json @@ -0,0 +1,1144 @@ +{ + "slug": "noon-marketplace", + "name": "noon Marketplace", + "aliases": [ + "noon Partners", + "noon.com" + ], + "description": "Sell on noon.com (UAE, Saudi Arabia, Egypt) from an AI agent through the noon Partner API: warehouses, catalog content and product groups, offers, prices, stock, FBPI orders and shipments, purchase orders, return lookups and bulk exports. Service account login with a signed JWT; price, stock, product and order writes install switched off.", + "region": "intl", + "category": "e-commerce", + "icon": "noon-marketplace", + "docsUrl": "https://noon-docs.noonpartners.dev/docs/getting-started/intro", + "instructions": "## noon Marketplace\nThis connector uses the **noon Partner API** on `https://noon-api-gateway.noon.partners`, the API noon offers sellers on noon.com in the UAE (`ae`), Saudi Arabia (`sa`) and Egypt (`eg`): warehouses, catalog content, offers, prices, stock, FBPI orders and shipments, purchase orders, returns and bulk exports.\n\n**Setup (about 5 minutes)**\n1. Sign in to the noon Partner portal and open the **Access App** (or **User & Access** > **User access**), then **API Users** in the left sidebar.\n2. Click **Add Service Account**, enter a display name and username, and pick a role. **Project Viewer** is enough for the read tools; the write tools need a role that may change catalog, prices, stock or orders (**Project Owner** grants everything). You can restrict the key to IP addresses and give it an expiry; without them it works from any IP and never expires. Each partner can create one service account with up to five keys.\n3. When you click **Add**, a `.json` key file downloads. It is shown only once; if you lose it, create a new key.\n4. Copy three fields of that file: `key_id` into `NOON_KEY_ID`, `private_key` into `NOON_PRIVATE_KEY` (the whole PEM; pasting it on one line with the `\\n` sequences from the file is fine) and `project_code` into `NOON_PROJECT_CODE`.\n5. Install and run `noon_marketplace_whoami`.\n\n**How login works.** There is no static API key. For every login the connector signs a fresh RS256 JWT with the private key (`sub` = key id, `iat` = now in seconds, a random `jti`) and posts it, with the project code as `default_project_code`, to `/identity/public/v1/api/login`. noon answers with session cookies, which are sent on every call. A session lasts 30 days; the connector signs in again before it ends and whenever a call returns 401. noon rejects a token whose `iat` is more than five minutes off its clock, so a 401 on login with the right key usually means the server clock is wrong. Every request carries a `User-Agent` (noon may reject requests without one) and the `X-Project` header with the project code, which the content API requires.\n\n**Identifiers**\n- `partner_sku`: your own SKU, unique across all your products. Prices, stock and offers are keyed by it.\n- `sku_parent` (starts with `Z`): noon's id of a product with all its variants, returned by `noon_marketplace_upsert_product`. `noon_marketplace_get_content` and product groups use it.\n- `sku` (noon SKU, the nsku, starts with `N`) and `psku_code` (starts with `P`): noon's ids of one variant.\n- `warehouse_code`: from `noon_marketplace_list_warehouses`. FBPI orders and shipments belong to an integration warehouse.\n- `country_code`: `ae`, `sa` or `eg`, lower case. Prices are per SKU and country, in AED, SAR or EGP.\n- `fbpi_order_nr`, `mp_item_nr` (one item of an order) and your own `integration_shipment_nr` identify orders and shipments. `merchant_code` starts with `STR`.\n\n**Tools**\n- Account: `noon_marketplace_whoami`, `noon_marketplace_list_warehouses`.\n- Catalog content: `noon_marketplace_list_categories` (all category codes), `noon_marketplace_list_category_attributes` (what a category requires and how values are validated), `noon_marketplace_get_content` (completeness, QC status and why a product is not live), `noon_marketplace_upsert_product`, `noon_marketplace_get_product_group`, `noon_marketplace_upsert_product_group`, `noon_marketplace_get_import_status`.\n- Offers, prices and stock: `noon_marketplace_get_product_offers` (price, stock, live status and offer issues per country), `noon_marketplace_get_prices`, `noon_marketplace_upsert_prices`, `noon_marketplace_get_stock`, `noon_marketplace_update_stock`.\n- FBPI orders (fulfilled from your own warehouse): `noon_marketplace_list_orders`, `noon_marketplace_get_order`, `noon_marketplace_get_order_customer`, `noon_marketplace_mark_items_unavailable`, `noon_marketplace_generate_awbs`, `noon_marketplace_create_shipment`, `noon_marketplace_get_shipment`, `noon_marketplace_cancel_shipment`. After a shipment is created, the manifest for noon's pickup is completed in Seller Lab.\n- Purchase orders and returns: `noon_marketplace_get_purchase_order`, `noon_marketplace_list_return_references`.\n- Bulk exports: `noon_marketplace_list_export_categories`, then `noon_marketplace_create_export`, then poll `noon_marketplace_get_export_status` until it has a `download_url`.\n\nSeveral reads are POST requests with a JSON body (noon's API style); they change nothing.\n\n**Switched off at install.** `noon_marketplace_upsert_product`, `noon_marketplace_upsert_product_group`, `noon_marketplace_upsert_prices`, `noon_marketplace_update_stock`, `noon_marketplace_mark_items_unavailable`, `noon_marketplace_create_shipment` and `noon_marketplace_cancel_shipment` change what customers see and buy (live prices, sellable quantity, published content) or act on real orders, and some cannot be undone. Turn on the ones a workspace needs in the connector's tool settings. `noon_marketplace_generate_awbs` and `noon_marketplace_create_export` stay on: they change no data.\n\n**Batch results.** Price, stock and product calls answer 200 even when some items failed. Check `status.status_code` (`OK`, `status_id` 0) on every returned item and resend only the failed ones. `noon_marketplace_update_stock` sets an absolute quantity, it does not add. In `noon_marketplace_upsert_prices`, a field left out or sent as null keeps its value. `noon_marketplace_upsert_product` returning `status_id` 3 means the product was stored with content problems: read them with `noon_marketplace_get_content`.\n\n**Paging.** List tools return `next_token`; call again with it, and with the same filters, until it is empty.\n\n**Rate limits.** Limits apply per project and endpoint, with a main window and a shorter burst window checked together. Most endpoints allow 1500 requests per 60 seconds; `noon_marketplace_list_warehouses` allows 30 per 60 seconds with a burst of 15 per 30 seconds. A 429 says to wait; the `X-Ratelimit-Retry-After` header gives the seconds. Prefer one batch call over many single-item calls.\n\n**Errors.** Errors come as `{\"error\": {\"code\", \"message\", \"fields\": [{\"name\", \"descriptions\"}]}}`. `PERMISSION_DENIED` (403): the service account lacks a role for that call; add one under User Access > Manage Role in the Partner portal. `INVALID_ARGUMENT` (400): read `fields` for the field and the reason. `NOT_FOUND` on a price item: the SKU has no pricing record in that country yet, so set a price first. A 401 means the login or session failed (wrong or deactivated key, wrong clock).", + "requiredEnvVars": [ + "NOON_KEY_ID", + "NOON_PRIVATE_KEY", + "NOON_PROJECT_CODE" + ], + "envVarMeta": { + "NOON_KEY_ID": { + "label": "Key ID", + "kind": "credential", + "help": "The key_id field of the service account key file (.json) downloaded from the noon Partner portal (User Access > API Users > Add Service Account).", + "link": "https://noon-docs.noonpartners.dev/docs/authentication/getting-credentials" + }, + "NOON_PRIVATE_KEY": { + "label": "Private key", + "kind": "credential", + "secret": true, + "help": "The private_key field of the same key file: the whole PEM from -----BEGIN PRIVATE KEY----- to -----END PRIVATE KEY-----. You can paste it on one line, with the \\n sequences exactly as they appear in the file.", + "link": "https://noon-docs.noonpartners.dev/docs/authentication/getting-credentials", + "pattern": "-----BEGIN [A-Z ]*PRIVATE KEY-----", + "patternMessage": "Paste the whole private_key value, including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines." + }, + "NOON_PROJECT_CODE": { + "label": "Project code", + "kind": "setting", + "help": "The project_code field of the same key file: the noon project the session and every call are scoped to.", + "example": "PRJ152772", + "link": "https://noon-docs.noonpartners.dev/docs/authentication/getting-credentials" + } + }, + "verifyHints": { + "401": "noon refused the login. Check that NOON_KEY_ID and NOON_PRIVATE_KEY come from the same key file, that the key is not deactivated or expired, and that the server clock is correct: a signed token more than five minutes off noon's clock is rejected.", + "403": "PERMISSION_DENIED: the service account has no role for this call. In the noon Partner portal open User Access, Manage Role on the service account, add Project Viewer (reads) or Project Owner (everything), then verify again.", + "rate_limited": "noon limits each endpoint per project with a main and a burst window (most endpoints 1500 requests per minute, warehouse listing 30 per minute). Wait for the X-Ratelimit-Retry-After seconds and try again." + }, + "connector": { + "name": "noon Partner API", + "type": "REST", + "baseUrl": "https://noon-api-gateway.noon.partners", + "authType": "LOGIN_TOKEN", + "authConfig": { + "loginUrl": "https://noon-api-gateway.noon.partners/identity/public/v1/api/login", + "loginMethod": "POST", + "loginHeaders": { + "Content-Type": "application/json", + "User-Agent": "AnythingMCP/1.0" + }, + "loginBody": { + "token": "${signedJwt}", + "default_project_code": "{{NOON_PROJECT_CODE}}" + }, + "signedJwt": { + "privateKey": "{{NOON_PRIVATE_KEY}}", + "algorithm": "RS256", + "claims": { + "sub": "{{NOON_KEY_ID}}" + } + }, + "tokenSource": "cookie", + "cookieMode": "all", + "tokenTTLSeconds": 2592000, + "refreshOn401": true, + "headerName": "Cookie", + "headerTemplate": "${token}" + }, + "headers": { + "Accept": "application/json", + "User-Agent": "AnythingMCP/1.0", + "X-Project": "{{NOON_PROJECT_CODE}}" + } + }, + "probe": { + "tool": "noon_marketplace_whoami" + }, + "tools": [ + { + "name": "noon_marketplace_whoami", + "description": "Show which service account the session belongs to (user_code and username). A cheap way to check that the login works.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/identity/v1/whoami" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_marketplace_list_warehouses", + "description": "List the warehouses of the partner with warehouse_code, display name, fulfillment system code and whether each is active. Use the codes for stock, orders and shipments. Up to 500 per page; call again with next_token while it is not empty. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "fulfillment_system_code": { + "type": "string", + "description": "Only warehouses of this fulfillment system code. Leave empty for all." + }, + "next_token": { + "type": "string", + "description": "next_token from the previous page; leave empty for the first page. Keep the same filter while paging." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/warehouse-platform/v1/warehouses/list", + "bodyMapping": { + "next_token": "$next_token", + "filters": { + "fulfillment_system_code": "$fulfillment_system_code" + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_list_categories", + "description": "List every product category code of the noon catalog, each a full family-type-subtype path such as electronics-mobiles-smartphones. The list is long; pick the code that fits the product and pass it to noon_marketplace_list_category_attributes. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "POST", + "path": "/content/v1/categories/list", + "bodyMapping": {} + }, + "responseMapping": { + "cacheTtl": 3600 + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_list_category_attributes", + "description": "List the attributes a category accepts, with is_mandatory, attribute_type, is_localizable (send EN and AR values), is_multivalued, the allowed options, metric units and length or range limits, and is_grouping (usable in product groups). Read it before noon_marketplace_upsert_product. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "category_code": { + "type": "string", + "description": "Category code from noon_marketplace_list_categories." + } + }, + "required": [ + "category_code" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/content/v1/categories/attributes/list", + "bodyMapping": { + "category_code": "$category_code" + } + }, + "responseMapping": { + "cacheTtl": 3600 + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_content", + "description": "Read a product's content by sku_parent: its attribute values, images with review status, and per language the completeness, missing and invalid attributes, QC status with rejection reasons and the overall status. Use it to find out why a product is not live. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "sku_parent": { + "type": "string", + "description": "sku_parent of the product (starts with Z), as returned by noon_marketplace_upsert_product." + } + }, + "required": [ + "sku_parent" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/content/v1/product/content/get", + "bodyMapping": { + "sku_parent": "$sku_parent" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_upsert_product", + "description": "Create a product, or update the one its partner SKUs belong to, in the noon catalog: brand, category, images and attribute values, plus one entry per variant SKU. Returns the sku_parent and the variants with their noon sku. status.status_id 3 means the product was stored but has content problems: check it with noon_marketplace_get_content. Submitted content goes to noon's QC and can go live. Installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "skus": { + "type": "array", + "description": "Variants: one per partner SKU. Every SKU needs size when there is more than one; all must belong to the same product on an update.", + "items": { + "type": "object", + "properties": { + "partner_sku": { + "type": "string", + "description": "Your unique SKU for this variant, unique across all your products." + }, + "size": { + "type": "string", + "description": "Size as printed on the label, e.g. 42 or Large. Optional for a single-SKU product." + } + }, + "required": [ + "partner_sku" + ] + } + }, + "brand": { + "type": "string", + "description": "Brand name." + }, + "category": { + "type": "string", + "description": "Category code from noon_marketplace_list_categories." + }, + "images": { + "type": "array", + "description": "Product images by public URL, ordered by sort starting at 1.", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Public image URL." + }, + "sort": { + "type": "integer", + "description": "Position, starting at 1." + } + }, + "required": [ + "url", + "sort" + ] + } + }, + "attributes": { + "type": "object", + "description": "Map of attribute_code to {\"values\": [...]}. Each value is {\"value\": ...}, plus \"language\": \"LANGUAGE_EN\" or \"LANGUAGE_AR\" for localizable attributes and \"sort\" (from 1) for multivalued ones. A metric attribute needs a matching _unit attribute in the same call. Example: {\"product_title\": {\"values\": [{\"value\": \"Acme Runner\", \"language\": \"LANGUAGE_EN\"}, {\"value\": \"...\", \"language\": \"LANGUAGE_AR\"}]}}." + } + }, + "required": [ + "skus", + "brand", + "category", + "images", + "attributes" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/content/v1/product/upsert", + "bodyMapping": { + "skus": "$skus", + "brand": "$brand", + "category": "$category", + "images": "$images", + "attributes": "$attributes" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_product_group", + "description": "Read a product group (variants shown together on one page): the attribute codes it varies by and its member products with their values for those attributes. Identify the group by your partner_group_ref or by the sku_parent of any product in it. Large groups are paged: call again with next_token until it is empty. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "partner_group_ref": { + "type": "string", + "description": "Your reference for the group (max 255 characters)." + }, + "sku_parent": { + "type": "string", + "description": "sku_parent of any product in the group." + }, + "page_size": { + "type": "integer", + "description": "Products per page, at most 200.", + "default": 50, + "minimum": 1, + "maximum": 200 + }, + "next_token": { + "type": "string", + "description": "next_token from the previous page; leave empty for the first page." + } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/content/v1/product/group/get", + "bodyMapping": { + "partner_group_ref": "$partner_group_ref", + "sku_parent": "$sku_parent", + "next_token": "$next_token", + "page_size": "$page_size" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_upsert_product_group", + "description": "Create a product group or change one. attribute_codes and sku_parents are FULL lists: what you send REPLACES the current list and anything left out is removed from the group (the products themselves stay). To add an entry, read the group with noon_marketplace_get_product_group and send the current list plus the new entry. Leave a list out on an update to keep it. A new group needs both lists and a category or brand. Installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "partner_group_ref": { + "type": "string", + "description": "Your reference for the group, unique in your catalog (matched ignoring case and punctuation)." + }, + "category": { + "type": "string", + "description": "Category code; category or brand is required to create a group and cannot be changed later." + }, + "brand": { + "type": "string", + "description": "Brand of the group; category or brand is required to create a group and cannot be changed later." + }, + "attribute_codes": { + "type": "array", + "description": "Attributes the products vary by, e.g. colour_name; each must have is_grouping true. Max 100. Replaces the current list.", + "items": { + "type": "string" + } + }, + "sku_parents": { + "type": "array", + "description": "Products in the group, by sku_parent. Max 2000; a product can be in one group only. Replaces the current list.", + "items": { + "type": "string" + } + } + }, + "required": [ + "partner_group_ref" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/content/v1/product/group/upsert", + "bodyMapping": { + "partner_group_ref": "$partner_group_ref", + "category": "$category", + "brand": "$brand", + "attribute_codes": "$attribute_codes", + "sku_parents": "$sku_parents" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_product_offers", + "description": "Show a partner SKU's offers in every country and business model: noon sku, title, brand, and per offer the price and currency, whether it is active, the net stock, whether it is live on the storefront and the issues keeping it offline.", + "parameters": { + "type": "object", + "properties": { + "partner_sku": { + "type": "string", + "description": "Your partner SKU, as used in your own system and in Seller Lab." + } + }, + "required": [ + "partner_sku" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/offer/v1/product/{partner_sku}", + "encodePathParams": true + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_marketplace_get_prices", + "description": "Read price, MSRP and active state for partner SKUs, one item per SKU and country. Each item comes back with its own status; NOT_FOUND means the SKU has no pricing record in that country yet. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "SKU and country pairs to read.", + "items": { + "type": "object", + "properties": { + "partner_sku": { + "type": "string", + "description": "Your partner SKU, as used in your own system and in Seller Lab." + }, + "country_code": { + "type": "string", + "description": "Country of the listing: ae (UAE), sa (Saudi Arabia) or eg (Egypt).", + "enum": [ + "ae", + "sa", + "eg" + ] + } + }, + "required": [ + "partner_sku", + "country_code" + ] + }, + "minItems": 1 + } + }, + "required": [ + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/pricing/v1/pricing/get", + "bodyMapping": { + "items": "$items" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_upsert_prices", + "description": "Set the selling price, MSRP (crossed-out reference price) and/or active state of partner SKUs, one item per SKU and country. Only the fields you send change; null never clears a value. is_active true puts a priced SKU on sale, false stops selling it. A 200 can still contain failed items: check status.status_code on each and resend only the failed ones. Changes live prices: installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "Price updates, one per SKU and country.", + "items": { + "type": "object", + "properties": { + "partner_sku": { + "type": "string", + "description": "Your partner SKU, as used in your own system and in Seller Lab." + }, + "country_code": { + "type": "string", + "description": "Country of the listing: ae (UAE), sa (Saudi Arabia) or eg (Egypt).", + "enum": [ + "ae", + "sa", + "eg" + ] + }, + "price": { + "type": "number", + "description": "Selling price in the country's currency (AED, SAR, EGP)." + }, + "msrp": { + "type": "number", + "description": "Reference price shown crossed out." + }, + "is_active": { + "type": "boolean", + "description": "true to sell the SKU in this country, false to stop." + } + }, + "required": [ + "partner_sku", + "country_code" + ] + }, + "minItems": 1 + } + }, + "required": [ + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/pricing/v1/pricing/upsert", + "bodyMapping": { + "items": "$items" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_stock", + "description": "Read the available quantity, processing time and last update time for warehouse and partner SKU pairs. Each item has its own status. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "Warehouse and SKU pairs to read.", + "items": { + "type": "object", + "properties": { + "warehouse_code": { + "type": "string", + "description": "Warehouse code, from noon_marketplace_list_warehouses." + }, + "partner_sku": { + "type": "string", + "description": "Your partner SKU, as used in your own system and in Seller Lab." + } + }, + "required": [ + "warehouse_code", + "partner_sku" + ] + }, + "minItems": 1 + } + }, + "required": [ + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/stock/v1/stock-list", + "bodyMapping": { + "items": "$items" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_update_stock", + "description": "Set the available quantity of partner SKUs in your warehouses. qty is ABSOLUTE: it becomes the new quantity noon sells, it is not added to the current one. A 200 can still contain failed items (e.g. a SKU unknown to noon): check status on each. Changes what customers can buy: installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "Stock updates, one per warehouse and SKU.", + "items": { + "type": "object", + "properties": { + "warehouse_code": { + "type": "string", + "description": "Warehouse code, from noon_marketplace_list_warehouses." + }, + "partner_sku": { + "type": "string", + "description": "Your partner SKU, as used in your own system and in Seller Lab." + }, + "qty": { + "type": "integer", + "minimum": 0, + "description": "New available quantity (absolute)." + }, + "processing_time": { + "type": "string", + "description": "Processing time for this SKU in this warehouse, in the format noon_marketplace_get_stock returns." + } + }, + "required": [ + "warehouse_code", + "partner_sku" + ] + }, + "minItems": 1 + } + }, + "required": [ + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/stock/v1/stock-update", + "bodyMapping": { + "items": "$items" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_list_orders", + "description": "List FBPI orders (fulfilled from your own warehouse) of one integration warehouse, optionally created within a UTC window, 50 per page. Each order has its marketplace (noon or namshi_v2), country, currency and items with mp_item_nr, partner SKU, marketplace status, integration status and invoiced price. Call again with next_token and the same filters while it is not empty. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "warehouse_code": { + "type": "string", + "description": "Integration warehouse code, from noon_marketplace_list_warehouses." + }, + "created_after": { + "type": "string", + "description": "Only orders created at or after this UTC time, YYYY-MM-DDTHH:MM:SSZ." + }, + "created_before": { + "type": "string", + "description": "Only orders created at or before this UTC time, YYYY-MM-DDTHH:MM:SSZ." + }, + "next_token": { + "type": "string", + "description": "next_token from the previous page; leave empty for the first page." + } + }, + "required": [ + "warehouse_code" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fbpi/v1/fbpi-orders/list", + "queryParams": { + "next_token": "$next_token" + }, + "bodyMapping": { + "warehouse_code": "$warehouse_code", + "created_after": "$created_after", + "created_before": "$created_before" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_order", + "description": "Read one FBPI order: marketplace and order number, countries, merchant, currency, warehouse, creation time and the items with mp_item_nr, partner SKU, marketplace status (confirmed or cancelled), integration status (acknowledged, out of stock, shipped), invoiced price and cancellation reason.", + "parameters": { + "type": "object", + "properties": { + "fbpi_order_nr": { + "type": "string", + "description": "FBPI order number (fbpi_order_nr), from noon_marketplace_list_orders or the order webhook." + } + }, + "required": [ + "fbpi_order_nr" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/fbpi/v1/fbpi-order/{fbpi_order_nr}/get" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_marketplace_get_order_customer", + "description": "Read the customer of an FBPI order: first and last name, city and administrative division, in Latin characters. Personal data: fetch it only when needed for the shipment or invoice.", + "parameters": { + "type": "object", + "properties": { + "fbpi_order_nr": { + "type": "string", + "description": "FBPI order number (fbpi_order_nr), from noon_marketplace_list_orders or the order webhook." + } + }, + "required": [ + "fbpi_order_nr" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/fbpi/v1/fbpi-order/{fbpi_order_nr}/customer-details/get" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_marketplace_mark_items_unavailable", + "description": "Mark items of an FBPI order as out of stock before any shipment is created. noon cancels those items for the customer; the rest of the order goes on to fulfilment. Cannot be undone: installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "fbpi_order_nr": { + "type": "string", + "description": "FBPI order number (fbpi_order_nr), from noon_marketplace_list_orders or the order webhook." + }, + "items": { + "type": "array", + "description": "Items to cancel.", + "items": { + "type": "object", + "properties": { + "mp_item_nr": { + "type": "string", + "description": "Marketplace item number from the order." + }, + "status": { + "type": "string", + "description": "Always UPDATE_ORDER_REQUEST_ITEM_STATUS_OUT_OF_STOCK.", + "enum": [ + "UPDATE_ORDER_REQUEST_ITEM_STATUS_OUT_OF_STOCK" + ] + } + }, + "required": [ + "mp_item_nr", + "status" + ] + }, + "minItems": 1 + } + }, + "required": [ + "fbpi_order_nr", + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fbpi/v1/fbpi-order/update", + "bodyMapping": { + "fbpi_order_nr": "$fbpi_order_nr", + "items": "$items" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_generate_awbs", + "description": "Generate noon logistics AWB (air waybill) numbers for shipments handed to noon's courier, one per parcel or up to 1000 at once to keep in stock. Changes no order; the numbers are used with noon_marketplace_create_shipment and courier noon.", + "parameters": { + "type": "object", + "properties": { + "country_code": { + "type": "string", + "description": "Country of the listing: ae (UAE), sa (Saudi Arabia) or eg (Egypt).", + "enum": [ + "ae", + "sa", + "eg" + ] + }, + "qty": { + "type": "integer", + "description": "How many AWB numbers to generate, 1 to 1000.", + "minimum": 1, + "maximum": 1000 + } + }, + "required": [ + "country_code", + "qty" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fbpi/v1/shipment/noon-logistics-awbs/get", + "bodyMapping": { + "country_code": "$country_code", + "qty": "$qty" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "noon_marketplace_create_shipment", + "description": "Create a shipment for an FBPI order: your unique shipment number, the AWB (noon-generated or your courier's) and the items in the parcel. After this, complete the manifest in Seller Lab for noon's pickup. Installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "warehouse_code": { + "type": "string", + "description": "Integration warehouse code handling the order." + }, + "integration_shipment_nr": { + "type": "string", + "description": "Your unique number for this shipment, any format." + }, + "fbpi_order_nr": { + "type": "string", + "description": "FBPI order number (fbpi_order_nr), from noon_marketplace_list_orders or the order webhook." + }, + "awbs": { + "type": "array", + "description": "Courier and AWB pairs for the parcel.", + "items": { + "type": "object", + "properties": { + "courier": { + "type": "string", + "description": "noon for a noon logistics AWB, otherwise the name of your own courier." + }, + "awb_nr": { + "type": "string", + "description": "AWB number: one from noon_marketplace_generate_awbs, or your courier's." + } + }, + "required": [ + "courier", + "awb_nr" + ] + } + }, + "items": { + "type": "array", + "description": "Items in this shipment.", + "items": { + "type": "object", + "properties": { + "mp_item_nr": { + "type": "string", + "description": "Marketplace item number from the order." + } + }, + "required": [ + "mp_item_nr" + ] + }, + "minItems": 1 + } + }, + "required": [ + "warehouse_code", + "integration_shipment_nr", + "fbpi_order_nr", + "awbs", + "items" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fbpi/v1/shipment/create", + "bodyMapping": { + "warehouse_code": "$warehouse_code", + "integration_shipment_nr": "$integration_shipment_nr", + "fbpi_order_nr": "$fbpi_order_nr", + "awbs": "$awbs", + "items": "$items" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "noon_marketplace_get_shipment", + "description": "Read a shipment by warehouse and your shipment number: its FBPI order, AWBs with courier, and items. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "warehouse_code": { + "type": "string", + "description": "Integration warehouse code." + }, + "integration_shipment_nr": { + "type": "string", + "description": "Your shipment number (integration_shipment_nr), the one sent when the shipment was created." + } + }, + "required": [ + "warehouse_code", + "integration_shipment_nr" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fbpi/v1/shipment/get", + "bodyMapping": { + "warehouse_code": "$warehouse_code", + "integration_shipment_nr": "$integration_shipment_nr" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_cancel_shipment", + "description": "Cancel a shipment. Every item in it is cancelled too. Installed switched off.", + "enabled": false, + "parameters": { + "type": "object", + "properties": { + "warehouse_code": { + "type": "string", + "description": "Integration warehouse code." + }, + "integration_shipment_nr": { + "type": "string", + "description": "Your shipment number (integration_shipment_nr), the one sent when the shipment was created." + } + }, + "required": [ + "warehouse_code", + "integration_shipment_nr" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/fbpi/v1/shipment/cancel", + "bodyMapping": { + "warehouse_code": "$warehouse_code", + "integration_shipment_nr": "$integration_shipment_nr" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_purchase_order", + "description": "Read a purchase order by number: currency, merchant, warehouse, status (pending or released), release date and the lines with partner SKU, quantity and unit cost.", + "parameters": { + "type": "object", + "properties": { + "po_nr": { + "type": "string", + "description": "Purchase order number." + } + }, + "required": [ + "po_nr" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/fbpo/v1/po/{po_nr}/get" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_marketplace_list_return_references", + "description": "Find returned items by a barcode (an AWB number or a returns barcode): marketplace, order number, purchase item number, partner SKU, merchant and the barcode references of each item, up to 200. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "barcode": { + "type": "string", + "description": "Barcode on the returned parcel or item." + }, + "merchant_codes": { + "type": "array", + "description": "Merchant codes (STR...) to search in, at most 200. Leave out to search the project of this connector.", + "items": { + "type": "string" + } + } + }, + "required": [ + "barcode" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/returns/v1/return-references/list", + "bodyMapping": { + "barcode": "$barcode", + "merchant_codes": "$merchant_codes" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_list_export_categories", + "description": "List the report types that can be exported (export_category_code) with the params each one takes. Start here before noon_marketplace_create_export.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/impex/v1/export/category/list" + }, + "responseMapping": { + "cacheTtl": 3600 + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "noon_marketplace_create_export", + "description": "Start an export (a bulk report file) of one export category. Returns an export_code; poll noon_marketplace_get_export_status until it is done and has a download_url. Changes no data.", + "parameters": { + "type": "object", + "properties": { + "export_category_code": { + "type": "string", + "description": "Export category from noon_marketplace_list_export_categories." + }, + "params": { + "type": "object", + "description": "Parameters of the export, with the keys noon_marketplace_list_export_categories lists for that category. Send {} when it takes none." + } + }, + "required": [ + "export_category_code", + "params" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/impex/v1/export/create", + "bodyMapping": { + "export_category_code": "$export_category_code", + "params": "$params" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "noon_marketplace_get_export_status", + "description": "Check an export: its status, parameters, who created it, timestamps and, once finished, the download_url of the file. Read-only although it is a POST.", + "parameters": { + "type": "object", + "properties": { + "export_code": { + "type": "string", + "description": "export_code returned by noon_marketplace_create_export." + } + }, + "required": [ + "export_code" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/impex/v1/export/status", + "bodyMapping": { + "export_code": "$export_code" + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true + } + }, + { + "name": "noon_marketplace_get_import_status", + "description": "Check a catalog import (barcode, SKU backfill or SKU grouping import): status (pending, processing, completed, failed, partial), sub-status, the processing report with success and failure counts, and timestamps.", + "parameters": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "import_reference returned when the import was created, or your own client_reference." + } + }, + "required": [ + "reference" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/catplat/v1/import/status/{reference}", + "encodePathParams": true + }, + "annotations": { + "readOnlyHint": true + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/noon-marketplace.live.spec.ts b/packages/backend/src/adapters/intl/noon-marketplace.live.spec.ts new file mode 100644 index 00000000..1e63d2c3 --- /dev/null +++ b/packages/backend/src/adapters/intl/noon-marketplace.live.spec.ts @@ -0,0 +1,246 @@ +import * as adapter from './noon-marketplace.json'; +import { createPrivateKey, randomUUID, sign } from 'node:crypto'; + +/** + * noon Partner API (noon-docs.noonpartners.dev): one gateway at noon-api-gateway.noon.partners. + * Login: a fresh RS256 JWT {sub: key_id, iat, jti} signed with the service account's private key, + * POSTed with the project code to /identity/public/v1/api/login; the session comes back as cookies + * (30 days). Every request needs a User-Agent; the content API also wants X-Project. + * Live part (reads only: whoami, warehouses, categories, export categories): + * NOON_KEY_ID=... NOON_PRIVATE_KEY="$(jq -r .private_key key.json)" NOON_PROJECT_CODE=... \ + * RUN_NOON_MARKETPLACE_LIVE=1 npx jest src/adapters/intl/noon-marketplace + */ + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters?: { properties?: Record; required?: string[] }; + endpointMapping: { + method: string; + path: string; + queryParams?: Record; + bodyMapping?: unknown; + headers?: Record; + encodePathParams?: boolean; + }; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; +}; +const a = adapter as unknown as { + slug: string; + region: string; + category: string; + icon: string; + docsUrl: string; + requiredEnvVars: string[]; + envVarMeta?: Record; + probe: { tool: string; params?: Record }; + instructions: string; + connector: { + type: string; + baseUrl: string; + authType: string; + authConfig: Record; + headers?: Record; + }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +// 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 = 'noon_marketplace_'; +const WRITES_OFF = [ + 'noon_marketplace_upsert_product', + 'noon_marketplace_upsert_product_group', + 'noon_marketplace_upsert_prices', + 'noon_marketplace_update_stock', + 'noon_marketplace_mark_items_unavailable', + 'noon_marketplace_create_shipment', + 'noon_marketplace_cancel_shipment', +]; +const WRITES_ON = ['noon_marketplace_generate_awbs', 'noon_marketplace_create_export']; + +// Documented route of every tool: [method, path, body keys (null = no body mapping)]. +const ROUTES: Record = { + whoami: ['GET', '/identity/v1/whoami', null], + list_warehouses: ['POST', '/warehouse-platform/v1/warehouses/list', ['next_token', 'filters']], + list_categories: ['POST', '/content/v1/categories/list', []], + list_category_attributes: ['POST', '/content/v1/categories/attributes/list', ['category_code']], + get_content: ['POST', '/content/v1/product/content/get', ['sku_parent']], + upsert_product: ['POST', '/content/v1/product/upsert', ['skus', 'brand', 'category', 'images', 'attributes']], + get_product_group: ['POST', '/content/v1/product/group/get', ['partner_group_ref', 'sku_parent', 'next_token', 'page_size']], + upsert_product_group: ['POST', '/content/v1/product/group/upsert', ['partner_group_ref', 'category', 'brand', 'attribute_codes', 'sku_parents']], + get_product_offers: ['GET', '/offer/v1/product/{partner_sku}', null], + get_prices: ['POST', '/pricing/v1/pricing/get', ['items']], + upsert_prices: ['POST', '/pricing/v1/pricing/upsert', ['items']], + get_stock: ['POST', '/stock/v1/stock-list', ['items']], + update_stock: ['POST', '/stock/v1/stock-update', ['items']], + list_orders: ['POST', '/fbpi/v1/fbpi-orders/list', ['warehouse_code', 'created_after', 'created_before']], + get_order: ['GET', '/fbpi/v1/fbpi-order/{fbpi_order_nr}/get', null], + get_order_customer: ['GET', '/fbpi/v1/fbpi-order/{fbpi_order_nr}/customer-details/get', null], + mark_items_unavailable: ['POST', '/fbpi/v1/fbpi-order/update', ['fbpi_order_nr', 'items']], + generate_awbs: ['POST', '/fbpi/v1/shipment/noon-logistics-awbs/get', ['country_code', 'qty']], + create_shipment: ['POST', '/fbpi/v1/shipment/create', ['warehouse_code', 'integration_shipment_nr', 'fbpi_order_nr', 'awbs', 'items']], + get_shipment: ['POST', '/fbpi/v1/shipment/get', ['warehouse_code', 'integration_shipment_nr']], + cancel_shipment: ['POST', '/fbpi/v1/shipment/cancel', ['warehouse_code', 'integration_shipment_nr']], + get_purchase_order: ['GET', '/fbpo/v1/po/{po_nr}/get', null], + list_return_references: ['POST', '/returns/v1/return-references/list', ['barcode', 'merchant_codes']], + list_export_categories: ['GET', '/impex/v1/export/category/list', null], + create_export: ['POST', '/impex/v1/export/create', ['export_category_code', 'params']], + get_export_status: ['POST', '/impex/v1/export/status', ['export_code']], + get_import_status: ['GET', '/catplat/v1/import/status/{reference}', null], +}; + +describe('noon-marketplace adapter: static spec conformance', () => { + it('is an international e-commerce adapter for the noon gateway', () => { + expect(a.slug).toBe('noon-marketplace'); + expect(a.region).toBe('intl'); + expect(a.category).toBe('e-commerce'); + expect(a.icon).toBe('noon-marketplace'); + expect(a.docsUrl).toBe('https://noon-docs.noonpartners.dev/docs/getting-started/intro'); + expect(a.connector.type).toBe('REST'); + expect(a.connector.baseUrl).toBe('https://noon-api-gateway.noon.partners'); + expect(a.requiredEnvVars).toEqual(['NOON_KEY_ID', 'NOON_PRIVATE_KEY', 'NOON_PROJECT_CODE']); + expect(a.envVarMeta?.NOON_PRIVATE_KEY.secret).toBe(true); + expect(a.envVarMeta?.NOON_PRIVATE_KEY.kind).toBe('credential'); + expect(new RegExp(a.envVarMeta?.NOON_PRIVATE_KEY.pattern as string).test('-----BEGIN PRIVATE KEY-----\\nMIIE...')).toBe(true); + }); + + it('logs in with a signed RS256 JWT (sub = key id) and the project code, and keeps the session cookies', () => { + const ac = a.connector.authConfig; + expect(a.connector.authType).toBe('LOGIN_TOKEN'); + expect(ac.loginUrl).toBe('https://noon-api-gateway.noon.partners/identity/public/v1/api/login'); + expect(ac.loginMethod).toBe('POST'); + expect(ac.loginBody).toEqual({ token: '${signedJwt}', default_project_code: '{{NOON_PROJECT_CODE}}' }); + expect(ac.signedJwt).toEqual({ privateKey: '{{NOON_PRIVATE_KEY}}', algorithm: 'RS256', claims: { sub: '{{NOON_KEY_ID}}' } }); + expect(ac).not.toHaveProperty('username'); + expect(ac).not.toHaveProperty('password'); + expect(ac).toMatchObject({ tokenSource: 'cookie', cookieMode: 'all', refreshOn401: true, headerName: 'Cookie', headerTemplate: '${token}' }); + // Sessions last 30 days (Getting Your Credentials, Terminology: Session Cookie). + expect(ac.tokenTTLSeconds).toBe(30 * 24 * 60 * 60); + }); + + it('sends a User-Agent on the login and on every call, and X-Project with the project code', () => { + const lh = a.connector.authConfig.loginHeaders as Record; + expect(lh['User-Agent']).toMatch(/\S/); + expect(lh['Content-Type']).toBe('application/json'); + expect(a.connector.headers?.['User-Agent']).toMatch(/\S/); + expect(a.connector.headers?.['X-Project']).toBe('{{NOON_PROJECT_CODE}}'); + expect(a.connector.headers?.Accept).toBe('application/json'); + }); + + it('maps every tool to its documented route and body fields', () => { + expect(a.tools.map((t) => t.name).sort()).toEqual(Object.keys(ROUTES).map((n) => PREFIX + n).sort()); + for (const [n, [method, path, body]] of Object.entries(ROUTES)) { + const m = tool(PREFIX + n).endpointMapping; + expect(`${n}:${m.method} ${m.path}`).toBe(`${n}:${method} ${path}`); + if (body === null) expect(`${n}:${JSON.stringify(m.bodyMapping)}`).toBe(`${n}:undefined`); + else expect(`${n}:${Object.keys(m.bodyMapping as object).sort().join(',')}`).toBe(`${n}:${[...body].sort().join(',')}`); + } + expect(tool('noon_marketplace_list_warehouses').endpointMapping.bodyMapping).toEqual({ + next_token: '$next_token', + filters: { fulfillment_system_code: '$fulfillment_system_code' }, + }); + expect(tool('noon_marketplace_list_orders').endpointMapping.queryParams).toEqual({ next_token: '$next_token' }); + for (const n of ['get_prices', 'upsert_prices', 'get_stock', 'update_stock']) { + expect(tool(PREFIX + n).endpointMapping.bodyMapping).toEqual({ items: '$items' }); + expect((tool(PREFIX + n).parameters?.properties?.items as { type: string }).type).toBe('array'); + } + const priceItem = (tool('noon_marketplace_upsert_prices').parameters?.properties?.items as { items: { properties: Record; required: string[] } }).items; + expect(Object.keys(priceItem.properties).sort()).toEqual(['country_code', 'is_active', 'msrp', 'partner_sku', 'price']); + expect(priceItem.required).toEqual(['partner_sku', 'country_code']); + const stockItem = (tool('noon_marketplace_update_stock').parameters?.properties?.items as { items: { properties: Record } }).items; + expect(Object.keys(stockItem.properties).sort()).toEqual(['partner_sku', 'processing_time', 'qty', 'warehouse_code']); + expect(tool('noon_marketplace_get_product_offers').endpointMapping.encodePathParams).toBe(true); + expect(tool('noon_marketplace_get_import_status').endpointMapping.encodePathParams).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('installs price, stock, product and order writes 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_OFF].sort()); + for (const n of [...WRITES_OFF, ...WRITES_ON]) expect(`${n}:${tool(n).annotations?.readOnlyHint}`).toBe(`${n}:false`); + for (const n of WRITES_ON) expect(`${n}:${tool(n).enabled}`).toBe(`${n}:undefined`); + for (const t of a.tools) { + if (t.annotations?.readOnlyHint === false) continue; + expect(`${t.name}:${t.enabled}`).toBe(`${t.name}:undefined`); + expect(`${t.name}:${t.annotations?.readOnlyHint}`).toBe(`${t.name}:true`); + } + for (const n of ['noon_marketplace_cancel_shipment', 'noon_marketplace_upsert_product_group', 'noon_marketplace_mark_items_unavailable']) { + expect(tool(n).annotations?.destructiveHint).toBe(true); + } + }); + + it('probes with whoami and documents every tool in the instructions', () => { + expect(a.probe.tool).toBe('noon_marketplace_whoami'); + expect(tool(a.probe.tool).endpointMapping.method).toBe('GET'); + 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 env = process.env; +const live = + env.RUN_NOON_MARKETPLACE_LIVE === '1' && env.NOON_KEY_ID && env.NOON_PRIVATE_KEY && env.NOON_PROJECT_CODE ? describe : describe.skip; +live('noon-marketplace adapter: live (read-only)', () => { + const base = 'https://noon-api-gateway.noon.partners'; + const UA = 'AnythingMCP/1.0'; + const b64url = (v: string | Buffer) => Buffer.from(v).toString('base64url'); + const signedJwt = () => { + const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const payload = b64url(JSON.stringify({ sub: env.NOON_KEY_ID, iat: Math.floor(Date.now() / 1000), jti: randomUUID() })); + const key = createPrivateKey((env.NOON_PRIVATE_KEY as string).replace(/\\n/g, '\n')); + return `${header}.${payload}.${b64url(sign('RSA-SHA256', Buffer.from(`${header}.${payload}`), key))}`; + }; + + it('logs in with a signed JWT and reads whoami, warehouses, categories and export categories', async () => { + const login = await fetch(a.connector.authConfig.loginUrl as string, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': UA }, + body: JSON.stringify({ token: signedJwt(), default_project_code: env.NOON_PROJECT_CODE }), + }); + expect(login.status).toBe(200); + const cookies = login.headers.getSetCookie().map((c) => c.split(';')[0]); + expect(cookies.length).toBeGreaterThan(0); + const headers = { Cookie: cookies.join('; '), Accept: 'application/json', 'User-Agent': UA, 'X-Project': env.NOON_PROJECT_CODE as string }; + const post = (path: string, body: unknown) => + fetch(`${base}${path}`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + + const me = (await (await fetch(`${base}/identity/v1/whoami`, { headers })).json()) as { user_code: string; username: string }; + expect(typeof me.user_code).toBe('string'); + const wh = (await (await post('/warehouse-platform/v1/warehouses/list', { filters: {} })).json()) as { warehouses: unknown[] }; + expect(Array.isArray(wh.warehouses)).toBe(true); + const cats = (await (await post('/content/v1/categories/list', {})).json()) as { categories: string[] }; + expect(cats.categories.length).toBeGreaterThan(0); + const exp = (await (await fetch(`${base}/impex/v1/export/category/list`, { headers })).json()) as { export_categories: unknown[] }; + expect(Array.isArray(exp.export_categories)).toBe(true); + }, 60_000); +}); diff --git a/packages/backend/src/connectors/connector-error.util.ts b/packages/backend/src/connectors/connector-error.util.ts index 1a267d37..cecb9da6 100644 --- a/packages/backend/src/connectors/connector-error.util.ts +++ b/packages/backend/src/connectors/connector-error.util.ts @@ -25,7 +25,7 @@ const AUTH_HINTS: Record = { BASIC_AUTH: 'The username/password (or API key used as username) was rejected. Verify both fields.', LOGIN_TOKEN: - 'Login failed — the email/password used to mint the session token was rejected.', + 'Login failed — the credentials used to mint the session token (email and password, or a signing key) were rejected.', QUERY_AUTH: 'The API key sent as a query parameter was rejected.', // Auth type NONE also covers keys carried in the address or the body (a // Telegram bot token is part of the URL), so "no credentials" can be wrong. diff --git a/packages/backend/src/connectors/engines/client-assertion.util.ts b/packages/backend/src/connectors/engines/client-assertion.util.ts index c4a6ec5a..82990466 100644 --- a/packages/backend/src/connectors/engines/client-assertion.util.ts +++ b/packages/backend/src/connectors/engines/client-assertion.util.ts @@ -170,25 +170,49 @@ export function parsePrivateKey(value: string): KeyObject { const b64url = (input: Buffer | string): string => Buffer.from(input).toString('base64url'); -/** Signs a client assertion JWT. `now` is injectable for tests. */ -export function signClientAssertion( - settings: ClientAssertionSettings, - now: number = Date.now(), -): string { - const algorithm = (settings.algorithm || 'RS256').toUpperCase() as Algorithm; +/** + * Signs a JWT over `payload` with a private key, as is. The checks the key + * and algorithm need are here; which claims go in is the caller's business + * (signClientAssertion below, and LOGIN_TOKEN's `signedJwt` login). + */ +export function signJwt(opts: { + privateKey: string; + algorithm?: string; + keyId?: string; + payload: Record; +}): string { + const algorithm = (opts.algorithm || 'RS256').toUpperCase() as Algorithm; const alg = ALGORITHMS[algorithm]; if (!alg) { throw new ClientAssertionError( - `unsupported algorithm "${settings.algorithm}" (use one of ${Object.keys(ALGORITHMS).join(', ')})`, + `unsupported algorithm "${opts.algorithm}" (use one of ${Object.keys(ALGORITHMS).join(', ')})`, ); } - const key = parsePrivateKey(settings.privateKey); + const key = parsePrivateKey(opts.privateKey); if (key.asymmetricKeyType !== alg.keyType) { throw new ClientAssertionError( `${algorithm} needs an ${alg.keyType.toUpperCase()} key, the configured key is ${String(key.asymmetricKeyType).toUpperCase()}`, ); } + const header: Record = { alg: algorithm, typ: 'JWT' }; + if (opts.keyId) header.kid = opts.keyId; + const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(opts.payload))}`; + const signature = sign(alg.hash, Buffer.from(signingInput), { + key, + ...(alg.pss + ? { padding: 6 /* RSA_PKCS1_PSS_PADDING */, saltLength: 32 } + : {}), + ...(alg.keyType === 'ec' ? { dsaEncoding: 'ieee-p1363' as const } : {}), + }); + return `${signingInput}.${b64url(signature)}`; +} + +/** Signs a client assertion JWT. `now` is injectable for tests. */ +export function signClientAssertion( + settings: ClientAssertionSettings, + now: number = Date.now(), +): string { const ttl = Math.min( Math.max(Math.floor(settings.ttlSeconds ?? DEFAULT_TTL_SECONDS), 1), MAX_TTL_SECONDS, @@ -216,17 +240,12 @@ export function signClientAssertion( } } - const header: Record = { alg: algorithm, typ: 'JWT' }; - if (settings.keyId) header.kid = settings.keyId; - const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`; - const signature = sign(alg.hash, Buffer.from(signingInput), { - key, - ...(alg.pss - ? { padding: 6 /* RSA_PKCS1_PSS_PADDING */, saltLength: 32 } - : {}), - ...(alg.keyType === 'ec' ? { dsaEncoding: 'ieee-p1363' as const } : {}), + return signJwt({ + privateKey: settings.privateKey, + algorithm: settings.algorithm, + keyId: settings.keyId, + payload: claims, }); - return `${signingInput}.${b64url(signature)}`; } /** The two form fields a token request carries under private_key_jwt. */ diff --git a/packages/backend/src/connectors/engines/login-token.service.spec.ts b/packages/backend/src/connectors/engines/login-token.service.spec.ts index 31dae09d..d76c2a57 100644 --- a/packages/backend/src/connectors/engines/login-token.service.spec.ts +++ b/packages/backend/src/connectors/engines/login-token.service.spec.ts @@ -11,7 +11,9 @@ import { extractLoginRefusal, LoginTokenAuthConfig, proactiveRefreshWindowMs, + signLoginJwt, } from './login-token.service'; +import { generateKeyPairSync, verify } from 'crypto'; import { injectLoginTokenHeaders } from './rest.engine'; import signavio from '../../adapters/intl/sap-signavio.json'; import { encrypt } from '../../common/crypto/encryption.util'; @@ -734,6 +736,104 @@ describe('LoginTokenService — cookieMode=all (SAP Signavio pattern)', () => { }); }); +describe('LoginTokenService — signedJwt login (noon Partner API pattern)', () => { + let service: LoginTokenService; + const encryptionKey = 'test-encryption-key-32-chars!!!!'; + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const pem = privateKey.export({ type: 'pkcs1', format: 'pem' }).toString(); + + const decode = (jwt: string) => { + const [h, p, sig] = jwt.split('.'); + const parse = (part: string) => JSON.parse(Buffer.from(part, 'base64url').toString()); + return { + header: parse(h), + payload: parse(p), + valid: verify('sha256', Buffer.from(`${h}.${p}`), publicKey, Buffer.from(sig, 'base64url')), + }; + }; + + const noonAuth = { + loginUrl: 'https://noon-api-gateway.noon.partners/identity/public/v1/api/login', + loginMethod: 'POST', + loginHeaders: { 'User-Agent': 'AnythingMCP/1.0' }, + loginBody: { token: '${signedJwt}', default_project_code: 'PRJ1' }, + signedJwt: { privateKey: pem, algorithm: 'RS256', claims: { sub: 'key-abc' } }, + tokenSource: 'cookie', + cookieMode: 'all', + headerName: 'Cookie', + headerTemplate: '${token}', + } as unknown as LoginTokenAuthConfig; + + beforeEach(() => { + const mockPrisma = { connector: { findUnique: jest.fn(), update: jest.fn() } } as any; + const mockConfigService = { get: jest.fn().mockReturnValue(encryptionKey) } as any; + service = new LoginTokenService(mockPrisma, mockConfigService); + jest.clearAllMocks(); + }); + + it('logs in with a freshly signed RS256 token instead of a username and password', async () => { + (mockedAxios as unknown as jest.Mock).mockResolvedValue({ + data: {}, + headers: { 'set-cookie': ['session=s1; Path=/; HttpOnly', 'route=r2; Path=/'] }, + }); + const before = Math.floor(Date.now() / 1000); + const bundle = await service.getToken(noonAuth, 'conn-noon'); + expect(bundle.token).toBe('session=s1; route=r2'); + + const call = (mockedAxios as unknown as jest.Mock).mock.calls[0][0]; + expect(call.headers['User-Agent']).toBe('AnythingMCP/1.0'); + expect(call.data.default_project_code).toBe('PRJ1'); + const jwt = decode(call.data.token); + expect(jwt.valid).toBe(true); + expect(jwt.header).toEqual({ alg: 'RS256', typ: 'JWT' }); + expect(jwt.payload.sub).toBe('key-abc'); + expect(jwt.payload.iat).toBeGreaterThanOrEqual(before); + expect(Number.isInteger(jwt.payload.iat)).toBe(true); + expect(jwt.payload.jti).toMatch(/^[0-9a-f-]{36}$/); + expect(jwt.payload.exp).toBeUndefined(); + }); + + it('signs a new token, with a new jti, for every login', async () => { + (mockedAxios as unknown as jest.Mock).mockResolvedValue({ data: {}, headers: { 'set-cookie': 'session=s1' } }); + await service.forceRelogin(noonAuth, 'conn-noon-2'); + await service.forceRelogin(noonAuth, 'conn-noon-2'); + const calls = (mockedAxios as unknown as jest.Mock).mock.calls; + expect(decode(calls[0][0].data.token).payload.jti).not.toBe(decode(calls[1][0].data.token).payload.jti); + }); + + it('adds exp only when a lifetime is configured, and a kid when a key id is', () => { + const jwt = decode( + signLoginJwt({ privateKey: pem, keyId: 'k1', ttlSeconds: 120, claims: { sub: 'key-abc' } }, 1_700_000_000_500), + ); + expect(jwt.header.kid).toBe('k1'); + expect(jwt.payload.iat).toBe(1_700_000_000); + expect(jwt.payload.exp).toBe(1_700_000_120); + }); + + it('names an empty claim variable instead of signing a token the API will refuse', () => { + expect(() => signLoginJwt({ privateKey: pem, claims: { sub: '{{NOON_KEY_ID}}' } })).toThrow( + 'LOGIN_TOKEN: signedJwt: the "sub" claim is not set ({{NOON_KEY_ID}} has no value)', + ); + }); + + it('explains a bad key without quoting it', () => { + const notPem = 'MIIEowIBAAKCAQEAsecretmaterial'; + let message = ''; + try { + signLoginJwt({ privateKey: notPem, claims: { sub: 'k' } }); + } catch (err) { + message = (err as Error).message; + } + expect(message).toMatch(/^LOGIN_TOKEN: signedJwt: the private key is not a PEM key/); + expect(message).not.toContain('secretmaterial'); + }); + + it('still asks for username and password when there is no signedJwt', async () => { + const { signedJwt: _omit, ...withoutJwt } = noonAuth as LoginTokenAuthConfig & { signedJwt?: unknown }; + await expect(service.getToken(withoutJwt as LoginTokenAuthConfig, 'conn-noon-3')).rejects.toThrow(/username/); + }); +}); + /** * A login that is refused answers 200 with the reason in the body. Reporting * "token not found at " describes our own configuration and diff --git a/packages/backend/src/connectors/engines/login-token.service.ts b/packages/backend/src/connectors/engines/login-token.service.ts index beefd295..0ee6128b 100644 --- a/packages/backend/src/connectors/engines/login-token.service.ts +++ b/packages/backend/src/connectors/engines/login-token.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; +import { randomUUID } from 'crypto'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../common/prisma.service'; import { encrypt, decrypt } from '../../common/crypto/encryption.util'; @@ -9,6 +10,8 @@ import { assertSafeOutboundUrl } from '../../common/ssrf.util'; import { outboundAxiosOptions } from '../../common/outbound-http'; import { urlForLog } from '../../common/log-safe-url.util'; import { createUnblockerProxyAgent } from './unblocker-proxy-agent'; +import { ClientAssertionError, signJwt } from './client-assertion.util'; +import { findUnresolvedPlaceholders } from '../../common/unresolved-placeholders.util'; const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days const DEFAULT_PROACTIVE_REFRESH_SECONDS = 24 * 60 * 60; // 1 day @@ -71,11 +74,30 @@ export interface LoginTokenAuthConfig { // …or a raw string template (deprecated, brittle for GraphQL mutations). loginBodyTemplate?: string; loginHeaders?: Record; + // Required, unless the login proves who it is with `signedJwt` instead. username: string; password: string; aud?: string; otp?: string; + /** + * Sign a JWT with the account's private key for each login, for APIs whose + * login takes a client-signed token instead of a password (noon's Partner + * API: RS256, `sub` = the key id). The token is `${signedJwt}` (or + * `${outputParam}`) in loginBody/loginBodyTemplate. Every login signs a new + * one: `iat` = now in whole seconds and a random `jti`, then `claims`; + * `exp` = iat + ttlSeconds only when ttlSeconds is set. The key is read as + * in client-assertion.util.ts (PEM, also pasted on one line). + */ + signedJwt?: { + privateKey: string; + algorithm?: string; // default RS256 + keyId?: string; // `kid` header + ttlSeconds?: number; + claims?: Record; + outputParam?: string; // template param name (default: signedJwt) + }; + passwordHashing?: { scheme: 'bcrypt' | 'none'; saltSource?: { @@ -275,21 +297,31 @@ export class LoginTokenService { request: LoginRequestOptions, ): Promise { this.requireString(authConfig.loginUrl, 'loginUrl'); - this.requireString(authConfig.username, 'username'); - this.requireString(authConfig.password, 'password'); + if (!authConfig.signedJwt) { + this.requireString(authConfig.username, 'username'); + this.requireString(authConfig.password, 'password'); + } if (authConfig.tokenSource !== 'cookie') { this.requireString(authConfig.tokenJsonPath, 'tokenJsonPath'); } - const passwordToSend = await this.preparePassword(authConfig, request); + const passwordToSend = authConfig.signedJwt && authConfig.password === undefined + ? '' + : await this.preparePassword(authConfig, request); + const signedToken = authConfig.signedJwt + ? signLoginJwt(authConfig.signedJwt) + : undefined; const templateParams: Record = { - username: authConfig.username, + username: authConfig.username ?? '', [authConfig.passwordHashing?.outputParam || 'passwordHashed']: passwordToSend, password: passwordToSend, aud: authConfig.aud || '', otp: authConfig.otp || '', + ...(signedToken !== undefined && { + [authConfig.signedJwt?.outputParam || 'signedJwt']: signedToken, + }), }; const method = (authConfig.loginMethod || 'POST').toUpperCase(); @@ -321,7 +353,7 @@ export class LoginTokenService { } this.logger.debug( - `LOGIN_TOKEN: logging in via ${method} ${urlForLog(url, { secrets: [authConfig.password, passwordToSend] })}${request.proxyUrl ? ' (via proxy)' : ''}`, + `LOGIN_TOKEN: logging in via ${method} ${urlForLog(url, { secrets: [authConfig.password, passwordToSend, signedToken ?? ''] })}${request.proxyUrl ? ' (via proxy)' : ''}`, ); const response = await axios({ method, @@ -504,7 +536,8 @@ export class LoginTokenService { connectorId?: string, ): string { if (connectorId) return connectorId; - return `${authConfig.loginUrl}|${authConfig.username}`; + const who = authConfig.username ?? authConfig.signedJwt?.claims?.sub; + return `${authConfig.loginUrl}|${String(who ?? '')}`; } private async loadFromDb( @@ -598,6 +631,49 @@ export function jsonPath(value: unknown, path: string): unknown { * awareness — use `loginBody` (structured) instead of `loginBodyTemplate` (raw) * when the body contains nested string contexts (e.g. GraphQL mutations). */ +/** + * The JWT a `signedJwt` login sends. Errors name what is wrong (key missing, + * not PEM, wrong type, a claim whose variable is empty) and never carry key + * material. Exported for the tests. + */ +export function signLoginJwt( + cfg: NonNullable, + now: number = Date.now(), +): string { + const claims = cfg.claims ?? {}; + for (const [name, v] of Object.entries(claims)) { + if (typeof v !== 'string') continue; + const unresolved = findUnresolvedPlaceholders(v); + if (!v.trim() || unresolved.length > 0) { + throw new Error( + `LOGIN_TOKEN: signedJwt: the "${name}" claim is not set` + + (unresolved.length > 0 ? ` ({{${unresolved[0]}}} has no value)` : ''), + ); + } + } + const iat = Math.floor(now / 1000); + const ttl = Number(cfg.ttlSeconds); + const payload: Record = { + iat, + jti: randomUUID(), + ...claims, + ...(Number.isFinite(ttl) && ttl > 0 ? { exp: iat + Math.floor(ttl) } : {}), + }; + try { + return signJwt({ + privateKey: cfg.privateKey, + algorithm: cfg.algorithm, + keyId: cfg.keyId, + payload, + }); + } catch (err) { + if (err instanceof ClientAssertionError) { + throw new Error(`LOGIN_TOKEN: signedJwt: ${err.message.replace(/^client assertion: /, '')}`); + } + throw err; + } +} + export function renderTemplate( template: string, params: Record, diff --git a/packages/frontend/public/logos/connectors/noon-marketplace.svg b/packages/frontend/public/logos/connectors/noon-marketplace.svg new file mode 100644 index 00000000..521984fc --- /dev/null +++ b/packages/frontend/public/logos/connectors/noon-marketplace.svg @@ -0,0 +1 @@ +noon diff --git a/server.json b/server.json index db12f19e..cf52ae00 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. 544 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 545 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github"