From 77b515cb5ee46b9f61ecdc7a975feaae975f104d Mon Sep 17 00:00:00 2001 From: Matteo Morelli Date: Mon, 5 Oct 2026 14:12:52 +0200 Subject: [PATCH 1/2] Etsy: create, edit and publish listings, photos, price and stock, order tracking Twelve tools, following Etsy's OpenAPI spec: - lookups a new listing needs: seller taxonomy and its properties, shipping profiles, processing profiles (readiness states), return policies, shop sections, listing inventory - etsy_create_listing_draft, etsy_edit_listing (incl. state=active to publish), etsy_set_listing_inventory, etsy_add_listing_image (multipart from a public URL, via the __file support from #818), etsy_add_order_tracking At least six workspaces had built write tools by hand and failed on missing required fields (taxonomy_id, readiness_state_id, who_made/when_made/is_supply together) or on read-only scopes. The new tools declare what Etsy requires and say in which order to call them. Bodies follow what already works in production: form-urlencoded, tags as one comma-separated string. Scopes gain listings_w and transactions_w (no listings_d: nothing deletes). Names avoid the ones customers gave their own tools (etsy_update_listing, etsy_create_draft_listing, ...), because the catalog update matches tools by name. Existing connectors get the tools only through an explicit update, and the instructions say how to re-authorize with the new scopes. --- packages/backend/src/adapters/intl/etsy.json | 676 +++++++++++++++++- .../src/adapters/intl/etsy.live.spec.ts | 5 +- .../adapters/intl/etsy.write-tools.spec.ts | 157 ++++ 3 files changed, 833 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/adapters/intl/etsy.write-tools.spec.ts diff --git a/packages/backend/src/adapters/intl/etsy.json b/packages/backend/src/adapters/intl/etsy.json index 686dd4bd..007a9c53 100644 --- a/packages/backend/src/adapters/intl/etsy.json +++ b/packages/backend/src/adapters/intl/etsy.json @@ -1,8 +1,8 @@ { "slug": "etsy", "name": "Etsy", - "description": "Read and update Etsy shop data (listings, transactions, receipts, reviews) from any AI agent. OAuth2 with automatic refresh-token rotation + x-api-key dual auth.", - "instructions": "This connector uses the Etsy Open API v3 (developers.etsy.com). You authorize it once, in AnythingMCP; from then on the short-lived access token (1 hour) is renewed automatically, and the refresh token Etsy replaces on every renewal is stored for you.\n\n**Setup**:\n1. Register an app at https://www.etsy.com/developers/your-apps → **Create New App**.\n2. In the app's settings, add this **callback URL**: `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. Etsy compares it character for character (https, upper/lower case, no trailing slash).\n3. Note the app's **Keystring** and **Shared secret**. Enter them as `ETSY_CLIENT_ID` and `ETSY_CLIENT_SECRET`, leave `ETSY_REFRESH_TOKEN` empty, and install.\n4. Open the connector and click **Authorize with Provider**. Sign in to Etsy, approve, and you land back on the connector page with the tools ready. AnythingMCP runs Etsy's OAuth2 PKCE flow (S256) for you.\n\n**Scopes**: the connector asks for `email_r shops_r listings_r transactions_r` (read access to your account, shops, listings and orders), enough for every tool here. For tools of your own that write, add the scope you need (e.g. `listings_w`) in the connector's OAuth settings and authorize again.\n\n**Already have a refresh token?** You can paste it into `ETSY_REFRESH_TOKEN` instead of step 4, if you obtained it yourself through Etsy's OAuth2 PKCE flow for the same app. Connectors set up that way keep working as they are; there is nothing to do. Clicking **Authorize with Provider** on one replaces its token with a new one, and needs the callback URL from step 2.\n\n**Authentication**: the connector attaches both headers on every request:\n - `Authorization: Bearer `\n - `x-api-key: ${ETSY_CLIENT_ID}:${ETSY_CLIENT_SECRET}`\n\nEtsy requires the API key on **every** request, OAuth or not, and it must carry the\nKeystring AND the Shared secret separated by a colon. The Keystring alone earns a\n`403 {\"error\":\"Invalid API key: should be in the format 'keystring:shared_secret'.\"}`\non every call.\n\nThe access token is kept in memory and written back, together with the rotated refresh token, into this connector's own encrypted configuration, so a restart does not lose it. Per Etsy's policy, **refresh tokens rotate on each use**; AnythingMCP stores the new one from every refresh response and re-uses it automatically.\n\n**If a call says the access token could not be renewed**: an Etsy refresh token works once and expires after 90 days unused. Click **Authorize with Provider** on the connector page to get a new one.\n\n**Shop ID**: every account has a shop_id. Get it from `etsy_get_authenticated_user` then `etsy_get_user_shops`.\n\n**Pagination**: `?limit=N&offset=M` (limit max 100).\n\n**Rate limits**: 10k req/24h per app. On 429 back off.\n\n**Out of scope here**: listing image uploads, payment account, billing, taxonomy edits.", + "description": "Run an Etsy shop from any AI agent: create draft listings, edit and publish them, add photos, set price and stock, add tracking to orders, and read listings, orders, receipts and reviews. OAuth2 with automatic refresh-token rotation + x-api-key dual auth.", + "instructions": "This connector uses the Etsy Open API v3 (developers.etsy.com). You authorize it once, in AnythingMCP; from then on the short-lived access token (1 hour) is renewed automatically, and the refresh token Etsy replaces on every renewal is stored for you.\n\n**Setup**:\n1. Register an app at https://www.etsy.com/developers/your-apps → **Create New App**.\n2. In the app's settings, add this **callback URL**: `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. Etsy compares it character for character (https, upper/lower case, no trailing slash).\n3. Note the app's **Keystring** and **Shared secret**. Enter them as `ETSY_CLIENT_ID` and `ETSY_CLIENT_SECRET`, leave `ETSY_REFRESH_TOKEN` empty, and install.\n4. Open the connector and click **Authorize with Provider**. Sign in to Etsy, approve, and you land back on the connector page with the tools ready. AnythingMCP runs Etsy's OAuth2 PKCE flow (S256) for you.\n\n**Scopes**: the connector asks for `email_r shops_r listings_r listings_w transactions_r transactions_w`: read your account, shops, listings and orders, edit listings, and add tracking to orders. It cannot delete listings. **Connectors installed before October 2026** asked only for the read scopes: to use the write tools, open the connector's OAuth settings, set the scopes above and click **Authorize with Provider** again; until then Etsy answers \"Access token lacks scope\".\n\n**Already have a refresh token?** You can paste it into `ETSY_REFRESH_TOKEN` instead of step 4, if you obtained it yourself through Etsy's OAuth2 PKCE flow for the same app. Connectors set up that way keep working as they are; there is nothing to do. Clicking **Authorize with Provider** on one replaces its token with a new one, and needs the callback URL from step 2.\n\n**Authentication**: the connector attaches both headers on every request:\n - `Authorization: Bearer `\n - `x-api-key: ${ETSY_CLIENT_ID}:${ETSY_CLIENT_SECRET}`\n\nEtsy requires the API key on **every** request, OAuth or not, and it must carry the\nKeystring AND the Shared secret separated by a colon. The Keystring alone earns a\n`403 {\"error\":\"Invalid API key: should be in the format 'keystring:shared_secret'.\"}`\non every call.\n\nThe access token is kept in memory and written back, together with the rotated refresh token, into this connector's own encrypted configuration, so a restart does not lose it. Per Etsy's policy, **refresh tokens rotate on each use**; AnythingMCP stores the new one from every refresh response and re-uses it automatically.\n\n**If a call says the access token could not be renewed**: an Etsy refresh token works once and expires after 90 days unused. Click **Authorize with Provider** on the connector page to get a new one.\n\n**Shop ID**: every account has a shop_id. Get it from `etsy_get_authenticated_user` then `etsy_get_user_shops`.\n\n**Pagination**: `?limit=N&offset=M` (limit max 100).\n\n**Rate limits**: 10k req/24h per app. On 429 back off.\n\n**Creating a listing** takes a few steps, in this order: find the category with `etsy_list_seller_taxonomy`; for a physical item get a shipping profile (`etsy_list_shipping_profiles`), a processing profile (`etsy_list_processing_profiles`) and, outside the EU, a return policy (`etsy_list_return_policies`); create the draft with `etsy_create_listing_draft`; add at least one photo with `etsy_add_listing_image`; publish with `etsy_edit_listing` and `state=active`. Change price or stock with `etsy_read_listing_inventory` then `etsy_set_listing_inventory`, which replaces the whole inventory. Etsy rejects titles with more than three words in capitals and tags longer than 20 characters; a shop on vacation cannot change listings.\n\n**Out of scope here**: deleting listings, payment account, billing, taxonomy edits.", "region": "intl", "category": "e-commerce", "icon": "etsy", @@ -46,7 +46,7 @@ "grant": "refresh_token", "authorizationUrl": "https://www.etsy.com/oauth/connect", "tokenUrl": "https://api.etsy.com/v3/public/oauth/token", - "scopes": "email_r shops_r listings_r transactions_r", + "scopes": "email_r shops_r listings_r listings_w transactions_r transactions_w", "clientId": "{{ETSY_CLIENT_ID}}", "clientSecret": "{{ETSY_CLIENT_SECRET}}", "refreshToken": "{{ETSY_REFRESH_TOKEN}}", @@ -431,6 +431,676 @@ "offset": "$offset" } } + }, + { + "name": "etsy_list_seller_taxonomy", + "description": "The full tree of Etsy seller categories (id, name, children). Use it to find the taxonomy_id for a new listing. The response is large: look for the closest leaf category and keep only its id.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/seller-taxonomy/nodes" + } + }, + { + "name": "etsy_list_taxonomy_properties", + "description": "Properties (size, colour, material…) and their allowed values for one seller category. Needed when an inventory product sets property_values.", + "parameters": { + "type": "object", + "properties": { + "taxonomy_id": { + "type": "integer", + "description": "Seller taxonomy category ID." + } + }, + "required": [ + "taxonomy_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/seller-taxonomy/nodes/{taxonomy_id}/properties", + "encodePathParams": true + } + }, + { + "name": "etsy_list_shipping_profiles", + "description": "The shop's shipping profiles. A physical listing needs one: pass its shipping_profile_id when creating the listing.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/shipping-profiles", + "encodePathParams": true + } + }, + { + "name": "etsy_list_processing_profiles", + "description": "The shop's processing profiles (Etsy calls them readiness states: ready to ship or made to order, with processing times). A physical listing needs one: pass its readiness_state_id.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/readiness-state-definitions", + "encodePathParams": true + } + }, + { + "name": "etsy_list_return_policies", + "description": "The shop's return policies. Publishing a physical listing needs a return_policy_id (not for EU-based shops).", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/policies/return", + "encodePathParams": true + } + }, + { + "name": "etsy_list_shop_sections", + "description": "The shop's sections, to file a listing under one with shop_section_id.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/sections", + "encodePathParams": true + } + }, + { + "name": "etsy_read_listing_inventory", + "description": "Products, prices, quantities and SKUs of a listing, per variation. Read this before etsy_set_listing_inventory: that call replaces the whole inventory.", + "parameters": { + "type": "object", + "properties": { + "listing_id": { + "type": "integer", + "description": "Listing ID." + } + }, + "required": [ + "listing_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/listings/{listing_id}/inventory", + "encodePathParams": true + } + }, + { + "name": "etsy_create_listing_draft", + "description": "Create a listing as a draft (not visible to buyers). A physical listing also needs shipping_profile_id and readiness_state_id; get them, and taxonomy_id, from the etsy_list_* tools first. Add photos with etsy_add_listing_image, then publish with etsy_edit_listing state=active.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + }, + "quantity": { + "type": "integer", + "description": "Units available (at least 1)." + }, + "price": { + "type": "number", + "description": "Price in the shop currency, e.g. 24.5." + }, + "title": { + "type": "string", + "description": "Listing title. Letters, numbers, punctuation and spaces only; \"&\" at most once; no more than 3 words starting with two capital letters." + }, + "description": { + "type": "string", + "description": "Full listing description (plain text)." + }, + "who_made": { + "type": "string", + "enum": [ + "i_did", + "someone_else", + "collective" + ], + "description": "Who made the item. Etsy needs who_made, when_made and is_supply together." + }, + "when_made": { + "type": "string", + "enum": [ + "made_to_order", + "2020_2026", + "2010_2019", + "2007_2009", + "before_2007", + "2000_2006", + "1990s", + "1980s", + "1970s", + "1960s", + "1950s", + "1940s", + "1930s", + "1920s", + "1910s", + "1900s", + "1800s", + "1700s", + "before_1700" + ], + "description": "When it was made. Etsy needs who_made, when_made and is_supply together." + }, + "is_supply": { + "type": "boolean", + "description": "true for a craft supply, false for a finished product. Etsy needs who_made, when_made and is_supply together." + }, + "taxonomy_id": { + "type": "integer", + "description": "Seller taxonomy category ID (etsy_list_seller_taxonomy)." + }, + "type": { + "type": "string", + "enum": [ + "physical", + "download", + "both" + ], + "description": "physical (default), download or both." + }, + "shipping_profile_id": { + "type": "integer", + "description": "Shipping profile ID (etsy_list_shipping_profiles). Required for physical listings." + }, + "readiness_state_id": { + "type": "integer", + "description": "Processing profile ID (etsy_list_processing_profiles). Required for physical listings." + }, + "return_policy_id": { + "type": "integer", + "description": "Return policy ID (etsy_list_return_policies). Required to publish a physical listing, except for EU-based shops." + }, + "shop_section_id": { + "type": "integer", + "description": "Shop section ID (etsy_list_shop_sections)." + }, + "tags": { + "type": "string", + "description": "Up to 13 tags, comma-separated. Each tag at most 20 characters: letters, numbers, spaces, - and ' only." + }, + "materials": { + "type": "string", + "description": "Materials, comma-separated. Letters, numbers and spaces only." + }, + "item_weight": { + "type": "number", + "description": "Item weight, in item_weight_unit." + }, + "item_weight_unit": { + "type": "string", + "enum": [ + "oz", + "lb", + "g", + "kg" + ], + "description": "Unit of item_weight." + }, + "item_length": { + "type": "number", + "description": "Item length, in item_dimensions_unit." + }, + "item_width": { + "type": "number", + "description": "Item width, in item_dimensions_unit." + }, + "item_height": { + "type": "number", + "description": "Item height, in item_dimensions_unit." + }, + "item_dimensions_unit": { + "type": "string", + "enum": [ + "in", + "ft", + "mm", + "cm", + "m", + "yd" + ], + "description": "Unit of the item dimensions." + }, + "should_auto_renew": { + "type": "boolean", + "description": "Renew automatically for four months when the listing expires." + }, + "is_taxable": { + "type": "boolean", + "description": "Apply the shop's tax rates at checkout." + } + }, + "required": [ + "shop_id", + "title", + "description", + "price", + "quantity", + "who_made", + "when_made", + "taxonomy_id" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/shops/{shop_id}/listings", + "encodePathParams": true, + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "quantity": "$quantity", + "price": "$price", + "title": "$title", + "description": "$description", + "who_made": "$who_made", + "when_made": "$when_made", + "is_supply": "$is_supply", + "taxonomy_id": "$taxonomy_id", + "type": "$type", + "shipping_profile_id": "$shipping_profile_id", + "readiness_state_id": "$readiness_state_id", + "return_policy_id": "$return_policy_id", + "shop_section_id": "$shop_section_id", + "tags": "$tags", + "materials": "$materials", + "item_weight": "$item_weight", + "item_weight_unit": "$item_weight_unit", + "item_length": "$item_length", + "item_width": "$item_width", + "item_height": "$item_height", + "item_dimensions_unit": "$item_dimensions_unit", + "should_auto_renew": "$should_auto_renew", + "is_taxable": "$is_taxable" + } + } + }, + { + "name": "etsy_edit_listing", + "description": "Change a listing: title, description, tags, category, shipping, section, or state. state=active publishes a draft (it needs at least one image) and state=inactive takes a listing off the shop. Send only the fields to change, but who_made, when_made and is_supply always together. Price and quantity are not here: use etsy_set_listing_inventory.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + }, + "listing_id": { + "type": "integer", + "description": "Listing ID." + }, + "state": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "description": "active publishes the listing (draft or inactive); inactive hides it." + }, + "title": { + "type": "string", + "description": "Listing title. Letters, numbers, punctuation and spaces only; \"&\" at most once; no more than 3 words starting with two capital letters." + }, + "description": { + "type": "string", + "description": "Full listing description (plain text)." + }, + "who_made": { + "type": "string", + "enum": [ + "i_did", + "someone_else", + "collective" + ], + "description": "Who made the item. Etsy needs who_made, when_made and is_supply together." + }, + "when_made": { + "type": "string", + "enum": [ + "made_to_order", + "2020_2026", + "2010_2019", + "2007_2009", + "before_2007", + "2000_2006", + "1990s", + "1980s", + "1970s", + "1960s", + "1950s", + "1940s", + "1930s", + "1920s", + "1910s", + "1900s", + "1800s", + "1700s", + "before_1700" + ], + "description": "When it was made. Etsy needs who_made, when_made and is_supply together." + }, + "is_supply": { + "type": "boolean", + "description": "true for a craft supply, false for a finished product. Etsy needs who_made, when_made and is_supply together." + }, + "taxonomy_id": { + "type": "integer", + "description": "Seller taxonomy category ID (etsy_list_seller_taxonomy)." + }, + "type": { + "type": "string", + "enum": [ + "physical", + "download", + "both" + ], + "description": "physical (default), download or both." + }, + "shipping_profile_id": { + "type": "integer", + "description": "Shipping profile ID (etsy_list_shipping_profiles). Required for physical listings." + }, + "return_policy_id": { + "type": "integer", + "description": "Return policy ID (etsy_list_return_policies). Required to publish a physical listing, except for EU-based shops." + }, + "shop_section_id": { + "type": "integer", + "description": "Shop section ID (etsy_list_shop_sections)." + }, + "tags": { + "type": "string", + "description": "Up to 13 tags, comma-separated. Each tag at most 20 characters: letters, numbers, spaces, - and ' only." + }, + "materials": { + "type": "string", + "description": "Materials, comma-separated. Letters, numbers and spaces only." + }, + "item_weight": { + "type": "number", + "description": "Item weight, in item_weight_unit." + }, + "item_weight_unit": { + "type": "string", + "enum": [ + "oz", + "lb", + "g", + "kg" + ], + "description": "Unit of item_weight." + }, + "item_length": { + "type": "number", + "description": "Item length, in item_dimensions_unit." + }, + "item_width": { + "type": "number", + "description": "Item width, in item_dimensions_unit." + }, + "item_height": { + "type": "number", + "description": "Item height, in item_dimensions_unit." + }, + "item_dimensions_unit": { + "type": "string", + "enum": [ + "in", + "ft", + "mm", + "cm", + "m", + "yd" + ], + "description": "Unit of the item dimensions." + }, + "should_auto_renew": { + "type": "boolean", + "description": "Renew automatically for four months when the listing expires." + }, + "is_taxable": { + "type": "boolean", + "description": "Apply the shop's tax rates at checkout." + } + }, + "required": [ + "shop_id", + "listing_id" + ] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/shops/{shop_id}/listings/{listing_id}", + "encodePathParams": true, + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "state": "$state", + "title": "$title", + "description": "$description", + "who_made": "$who_made", + "when_made": "$when_made", + "is_supply": "$is_supply", + "taxonomy_id": "$taxonomy_id", + "type": "$type", + "shipping_profile_id": "$shipping_profile_id", + "return_policy_id": "$return_policy_id", + "shop_section_id": "$shop_section_id", + "tags": "$tags", + "materials": "$materials", + "item_weight": "$item_weight", + "item_weight_unit": "$item_weight_unit", + "item_length": "$item_length", + "item_width": "$item_width", + "item_height": "$item_height", + "item_dimensions_unit": "$item_dimensions_unit", + "should_auto_renew": "$should_auto_renew", + "is_taxable": "$is_taxable" + } + } + }, + { + "name": "etsy_set_listing_inventory", + "description": "Set price, quantity and SKU of a listing, per variation. It REPLACES the whole inventory: read it with etsy_read_listing_inventory, change what you need, and send every product back. In each product keep sku and property_values; in each offering send price as a plain number (amount / divisor from the read, e.g. 2450/100 = 24.5), quantity and is_enabled. Leave out product_id, offering_id, is_deleted and scale_name.", + "parameters": { + "type": "object", + "properties": { + "listing_id": { + "type": "integer", + "description": "Listing ID." + }, + "products": { + "type": "array", + "description": "Every product of the listing, e.g. [{\"sku\": \"MUG-01\", \"property_values\": [], \"offerings\": [{\"price\": 24.5, \"quantity\": 10, \"is_enabled\": true}]}].", + "items": { + "type": "object" + } + }, + "price_on_property": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Property IDs whose values change the price (as returned by the read)." + }, + "quantity_on_property": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Property IDs whose values change the quantity." + }, + "sku_on_property": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Property IDs whose values change the SKU." + } + }, + "required": [ + "listing_id", + "products" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/listings/{listing_id}/inventory", + "encodePathParams": true, + "bodyMapping": { + "products": "$products", + "price_on_property": "$price_on_property", + "quantity_on_property": "$quantity_on_property", + "sku_on_property": "$sku_on_property" + } + } + }, + { + "name": "etsy_add_listing_image", + "description": "Add a photo to a listing from a public image URL (JPG, PNG or GIF, up to 10 MB). rank 1 is the main photo. Etsy allows up to 20 images per listing.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + }, + "listing_id": { + "type": "integer", + "description": "Listing ID." + }, + "image_url": { + "type": "string", + "description": "Public https URL of the image. AnythingMCP downloads it and uploads it to Etsy." + }, + "rank": { + "type": "integer", + "description": "Position, 1 = main photo." + }, + "overwrite": { + "type": "boolean", + "description": "Replace the image already at this rank." + }, + "alt_text": { + "type": "string", + "description": "Alt text, up to 500 characters." + } + }, + "required": [ + "shop_id", + "listing_id", + "image_url" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/shops/{shop_id}/listings/{listing_id}/images", + "encodePathParams": true, + "bodyEncoding": "form-data", + "bodyMapping": { + "image": { + "__file": "$image_url" + }, + "rank": "$rank", + "overwrite": "$overwrite", + "alt_text": "$alt_text" + } + } + }, + { + "name": "etsy_add_order_tracking", + "description": "Mark an order (receipt) as shipped with its tracking code. Etsy emails the buyer the shipping notification.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "description": "Shop ID (from etsy_get_user_shops)." + }, + "receipt_id": { + "type": "integer", + "description": "Receipt (order) ID from etsy_get_shop_receipts." + }, + "tracking_code": { + "type": "string", + "description": "Tracking number." + }, + "carrier_name": { + "type": "string", + "description": "Carrier, e.g. dhl, ups, usps, royal-mail, deutsche-post." + }, + "note_to_buyer": { + "type": "string", + "description": "Optional message in the buyer's notification." + }, + "send_bcc": { + "type": "boolean", + "description": "Also send the notification to the seller." + } + }, + "required": [ + "shop_id", + "receipt_id", + "tracking_code", + "carrier_name" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/shops/{shop_id}/receipts/{receipt_id}/tracking", + "encodePathParams": true, + "bodyMapping": { + "tracking_code": "$tracking_code", + "carrier_name": "$carrier_name", + "note_to_buyer": "$note_to_buyer", + "send_bcc": "$send_bcc" + } + } } ] } diff --git a/packages/backend/src/adapters/intl/etsy.live.spec.ts b/packages/backend/src/adapters/intl/etsy.live.spec.ts index d0222364..18b133da 100644 --- a/packages/backend/src/adapters/intl/etsy.live.spec.ts +++ b/packages/backend/src/adapters/intl/etsy.live.spec.ts @@ -59,8 +59,9 @@ describe('etsy adapter — static spec conformance', () => { it('can be authorized in the browser, and still accepts a pasted refresh token', () => { const auth = a.connector.authConfig; expect(auth.authorizationUrl).toBe('https://www.etsy.com/oauth/connect'); - // Space-separated, as Etsy documents; read-only, like every tool here. - expect(auth.scopes).toBe('email_r shops_r listings_r transactions_r'); + // Space-separated, as Etsy documents. Write scopes for the listing and + // tracking tools; no listings_d, because no tool here deletes anything. + expect(auth.scopes).toBe('email_r shops_r listings_r listings_w transactions_r transactions_w'); // Etsy takes the client in the body (client_id, and the shared secret it // checks). Basic would change the refresh request of every Etsy row. expect(auth.tokenAuthMethod).toBeUndefined(); diff --git a/packages/backend/src/adapters/intl/etsy.write-tools.spec.ts b/packages/backend/src/adapters/intl/etsy.write-tools.spec.ts new file mode 100644 index 00000000..21871fad --- /dev/null +++ b/packages/backend/src/adapters/intl/etsy.write-tools.spec.ts @@ -0,0 +1,157 @@ +import * as adapter from './etsy.json'; +import axios, { AxiosError } from 'axios'; +import FormData from 'form-data'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { fetchOutbound } from '../../common/outbound-fetch.util'; + +jest.mock('../../common/outbound-fetch.util', () => ({ + ...jest.requireActual('../../common/outbound-fetch.util'), + fetchOutbound: jest.fn(), +})); +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { __esModule: true, default: jest.fn(), AxiosError: actual.AxiosError }; +}); +const mockedAxios = axios as jest.MockedFunction; +const mockedFetchOutbound = fetchOutbound as jest.MockedFunction; +void AxiosError; + +type Tool = { + name: string; + parameters: { required?: string[]; properties: Record }; + endpointMapping: Record; +}; +const tools = (adapter as unknown as { tools: Tool[] }).tools; +const tool = (name: string) => { + const t = tools.find((x) => x.name === name); + if (!t) throw new Error(`no tool ${name}`); + return t; +}; + +/** + * The listing and order tools that write. Request shapes follow Etsy's + * OpenAPI spec and, where it is ambiguous, what already works in production: + * tools customers built themselves create drafts with form-urlencoded bodies + * and tags as one comma-separated string (54 + 35 + 23 successful calls in + * the week before these were added). + */ +describe('etsy adapter: write tools', () => { + const engine = new RestEngine( + { getAccessToken: jest.fn(), refreshToken: jest.fn() } as any, + { getToken: jest.fn(), forceRelogin: jest.fn() } as any, + ); + const config = { + baseUrl: 'https://openapi.etsy.com/v3/application', + authType: 'BEARER_TOKEN' as const, + authConfig: { token: 'etsy-token' }, + }; + const run = (name: string, params: Record) => + engine.execute(config, tool(name).endpointMapping as any, params); + const sent = () => mockedAxios.mock.calls[0][0] as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.mockResolvedValue({ data: { ok: true } }); + }); + + it('creates a draft with a form-urlencoded body and only the fields given', async () => { + await run('etsy_create_listing_draft', { + shop_id: 123, + title: 'Blue ceramic mug', + description: 'Handmade.', + price: 24.5, + quantity: 3, + who_made: 'i_did', + when_made: 'made_to_order', + is_supply: false, + taxonomy_id: 1049, + tags: 'mug,ceramic,blue', + shipping_profile_id: 77, + readiness_state_id: 88, + }); + expect(sent().method).toBe('POST'); + expect(sent().url).toBe('https://openapi.etsy.com/v3/application/shops/123/listings'); + expect(sent().headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + const body = new URLSearchParams(sent().data); + expect(body.get('tags')).toBe('mug,ceramic,blue'); + expect(body.get('price')).toBe('24.5'); + expect(body.get('is_supply')).toBe('false'); + expect(body.get('readiness_state_id')).toBe('88'); + expect(body.has('return_policy_id')).toBe(false); + expect(body.has('shop_id')).toBe(false); + }); + + it('declares what Etsy requires to create a listing', () => { + expect(tool('etsy_create_listing_draft').parameters.required).toEqual( + expect.arrayContaining(['shop_id', 'title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id']), + ); + }); + + it('edits a listing with PATCH and sends nothing it was not given', async () => { + await run('etsy_edit_listing', { shop_id: 123, listing_id: 456, state: 'active' }); + expect(sent().method).toBe('PATCH'); + expect(sent().url).toBe('https://openapi.etsy.com/v3/application/shops/123/listings/456'); + expect(sent().data).toBe('state=active'); + }); + + it('replaces the inventory with a JSON body', async () => { + const products = [{ sku: 'MUG-01', property_values: [], offerings: [{ price: 24.5, quantity: 10, is_enabled: true }] }]; + await run('etsy_set_listing_inventory', { listing_id: 456, products }); + expect(sent().method).toBe('PUT'); + expect(sent().url).toBe('https://openapi.etsy.com/v3/application/listings/456/inventory'); + expect(sent().data).toEqual({ products }); + }); + + it('uploads a photo as a multipart file fetched from the given URL', async () => { + mockedFetchOutbound.mockResolvedValueOnce({ + status: 200, + headers: { 'content-type': 'image/jpeg' }, + body: Buffer.from('jpeg-bytes'), + finalUrl: 'https://cdn.example.com/mug.jpg', + } as any); + const append = jest.spyOn(FormData.prototype, 'append'); + await run('etsy_add_listing_image', { + shop_id: 123, + listing_id: 456, + image_url: 'https://cdn.example.com/mug.jpg', + rank: 1, + }); + expect(mockedFetchOutbound).toHaveBeenCalledWith('https://cdn.example.com/mug.jpg', expect.any(Object)); + expect(sent().url).toBe('https://openapi.etsy.com/v3/application/shops/123/listings/456/images'); + expect(append).toHaveBeenCalledWith('image', expect.any(Buffer), expect.objectContaining({ filename: 'mug.jpg' })); + expect(append).toHaveBeenCalledWith('rank', '1'); + append.mockRestore(); + }); + + it('adds tracking to an order with a JSON body', async () => { + await run('etsy_add_order_tracking', { + shop_id: 123, + receipt_id: 999, + tracking_code: '00340434', + carrier_name: 'dhl', + }); + expect(sent().method).toBe('POST'); + expect(sent().url).toBe('https://openapi.etsy.com/v3/application/shops/123/receipts/999/tracking'); + expect(sent().data).toEqual({ tracking_code: '00340434', carrier_name: 'dhl' }); + }); + + it('encodes path ids on every new tool, so an id cannot reach another route', () => { + const added = [ + 'etsy_list_taxonomy_properties', 'etsy_list_shipping_profiles', 'etsy_list_processing_profiles', + 'etsy_list_return_policies', 'etsy_list_shop_sections', 'etsy_read_listing_inventory', + 'etsy_create_listing_draft', 'etsy_edit_listing', 'etsy_set_listing_inventory', + 'etsy_add_listing_image', 'etsy_add_order_tracking', + ]; + for (const name of added) expect(tool(name).endpointMapping.encodePathParams).toBe(true); + }); + + /** + * Customers already have tools of their own named etsy_update_listing and + * etsy_create_draft_listing on Etsy connectors. A catalog tool with the same + * name would be matched to theirs by the catalog update and overwrite it. + */ + it('does not reuse names customers gave their own Etsy tools', () => { + const taken = ['etsy_update_listing', 'etsy_create_draft_listing', 'etsy_update_listing_inventory', 'etsy_upload_listing_image', 'etsy_get_listing_inventory', 'etsy_get_seller_taxonomy']; + for (const name of taken) expect(tools.some((t) => t.name === name)).toBe(false); + }); +}); From 626f5935ea6656cf48f9730c311eeb351b6d12e6 Mon Sep 17 00:00:00 2001 From: Matteo Morelli Date: Mon, 5 Oct 2026 14:17:54 +0200 Subject: [PATCH 2/2] Etsy authorize specs follow the catalog's new scopes Rows installed before the browser flow have no scopes of their own and adopt the catalog's at authorization, so they now ask for the write scopes too. --- packages/backend/src/connectors/connectors.controller.spec.ts | 4 ++-- .../backend/src/connectors/oauth-authorize-settings.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/connectors/connectors.controller.spec.ts b/packages/backend/src/connectors/connectors.controller.spec.ts index eb4c6ed9..df6d3d87 100644 --- a/packages/backend/src/connectors/connectors.controller.spec.ts +++ b/packages/backend/src/connectors/connectors.controller.spec.ts @@ -683,7 +683,7 @@ describe('POST :id/oauth/authorize (REST)', () => { expect(url.origin + url.pathname).toBe('https://www.etsy.com/oauth/connect'); expect(url.searchParams.get('client_id')).toBe('keystring'); expect(url.searchParams.get('redirect_uri')).toBe(`${SERVER}/api/mcp-oauth/callback`); - expect(url.searchParams.get('scope')).toBe('email_r shops_r listings_r transactions_r'); + expect(url.searchParams.get('scope')).toBe('email_r shops_r listings_r listings_w transactions_r transactions_w'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); const [state, flow] = store.mock.calls[0]; @@ -700,7 +700,7 @@ describe('POST :id/oauth/authorize (REST)', () => { tokenAuthMethod: undefined, persistAuthConfig: { authorizationUrl: 'https://www.etsy.com/oauth/connect', - scopes: 'email_r shops_r listings_r transactions_r', + scopes: 'email_r shops_r listings_r listings_w transactions_r transactions_w', }, }); }); diff --git a/packages/backend/src/connectors/oauth-authorize-settings.spec.ts b/packages/backend/src/connectors/oauth-authorize-settings.spec.ts index 6e136e05..aff179db 100644 --- a/packages/backend/src/connectors/oauth-authorize-settings.spec.ts +++ b/packages/backend/src/connectors/oauth-authorize-settings.spec.ts @@ -70,13 +70,13 @@ describe('resolveRestAuthorizeSettings', () => { etsyCatalog, ); expect(settings.authorizationUrl).toBe('https://www.etsy.com/oauth/connect'); - expect(settings.scope).toBe('email_r shops_r listings_r transactions_r'); + expect(settings.scope).toBe('email_r shops_r listings_r listings_w transactions_r transactions_w'); expect(settings.tokenUrl).toBe('https://api.etsy.com/v3/public/oauth/token'); // Etsy authenticates the client in the body: nothing to adopt there. expect(settings.tokenAuthMethod).toBeUndefined(); expect(settings.adopted).toEqual({ authorizationUrl: 'https://www.etsy.com/oauth/connect', - scopes: 'email_r shops_r listings_r transactions_r', + scopes: 'email_r shops_r listings_r listings_w transactions_r transactions_w', }); });