From 4c92d1f37126eeec0c1178e21883448b9111f81f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:50:50 +0000 Subject: [PATCH] Audit fixes: security hardening, bug fixes, throttle retries, and docs UI refresh Security - Fix all 10 npm audit vulnerabilities (vite/vitest chain, qs) via npm audit fix - get-token: cryptographically secure OAuth state (crypto.randomBytes instead of Math.random) - get-token: HTML-escape reflected OAuth params in callback pages (XSS fix), URL-encode client_id - config: validate and normalize SHOPIFY_STORE_NAME (accepts bare name, full domain, or URL; rejects unsafe characters before URL interpolation) Bug fixes - update_webhook: remove misleading topic param (Shopify does not allow topic changes after creation) - create/update_product_image: implement previously dead position and variant_ids params via productReorderMedia and productVariantAppendMedia - pages: throw on mutation userErrors instead of silently returning them; use shared gid helper - list_inventory_levels: wire up the declared-but-ignored page_info cursor - create_product: null checks on chained mutation results, and clear partial-failure errors that include the created product ID - index: report real package version instead of hardcoded 1.0.0 Performance and reliability - ShopifyClient: automatic retry with exponential backoff on HTTP 429 (honors Retry-After) and GraphQL THROTTLED errors - searchQuery helper: quote and escape values containing spaces or quotes so title filters with spaces work - throwOnUserErrors: include field paths in error messages - menus: replace hand-duplicated 3-level nested update schema with a recursive schema; accept numeric IDs or GIDs Docs and UI - docs page: 80 to 136 tools, 12 to 18 categories, drop stale REST claim, add Pages/Menus/Files/Themes/Bundles/Apps cards, modern Discounts card, both auth modes documented, full scope list, copy-button success feedback - README: correct tool descriptions for webhooks, images, and menus - AGENTS.md: remove stale note about 6 failing tests Tests: 93 to 112 passing (new coverage for retries, store-name normalization, and shared helpers) https://claude.ai/code/session_01TyAU1S8RuL3fyR7S7B2NYd --- AGENTS.md | 4 +- README.md | 8 +- docs/index.html | 172 ++++++++++++++++----- package-lock.json | 283 +++++++++++++++++----------------- src/config.ts | 25 ++- src/get-token.ts | 52 +++++-- src/index.ts | 4 +- src/shopify-client.ts | 94 +++++++---- src/tools/graphql-helpers.ts | 11 +- src/tools/images.ts | 77 +++++++-- src/tools/inventory.ts | 7 +- src/tools/menus.ts | 50 +++--- src/tools/pages.ts | 22 +-- src/tools/products.ts | 30 ++-- src/tools/webhooks.ts | 2 +- tests/config.test.ts | 24 +++ tests/graphql-helpers.test.ts | 69 +++++++++ tests/shopify-client.test.ts | 52 +++++++ tests/tools.test.ts | 4 +- 19 files changed, 697 insertions(+), 293 deletions(-) create mode 100644 tests/graphql-helpers.test.ts diff --git a/AGENTS.md b/AGENTS.md index b383f49..d934666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,5 +40,5 @@ This is a TypeScript CommonJS MCP server for the Shopify Admin API. Keep changes ## Validation Notes -- As of this file's creation, `npm test` reports 87 passing tests and 6 existing failures in [tests/auth.test.ts](tests/auth.test.ts) and [tests/config.test.ts](tests/config.test.ts). The failures reflect stale expectations around client-credentials auth and required credential errors; do not treat them as caused by unrelated changes. -- [tests/shopify-client.test.ts](tests/shopify-client.test.ts) and [tests/tools.test.ts](tests/tools.test.ts) currently pass and are useful focused checks for client behavior and tool-registration changes. +- `npm test` should pass fully; treat any failure as caused by your change until proven otherwise. +- [tests/shopify-client.test.ts](tests/shopify-client.test.ts) and [tests/tools.test.ts](tests/tools.test.ts) are useful focused checks for client behavior and tool-registration changes; [tests/graphql-helpers.test.ts](tests/graphql-helpers.test.ts) covers the shared helpers. diff --git a/README.md b/README.md index 7f84814..166a573 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,8 @@ Then point your MCP client to the built output: |------|-------------| | `list_product_images` | List all images for a product | | `get_product_image` | Get a specific product image | -| `create_product_image` | Add an image to a product (by URL or base64) | -| `update_product_image` | Update image alt text or position | +| `create_product_image` | Add an image to a product by URL, with optional position and variant assignment | +| `update_product_image` | Update image alt text, position, or variant assignments | | `delete_product_image` | Remove an image from a product | ### Variants (5) @@ -308,7 +308,7 @@ Modern Shopify GraphQL discount API for automatic discounts (applied without a c | `list_webhooks` | List all registered webhooks | | `get_webhook` | Get a webhook by ID | | `create_webhook` | Register a new webhook | -| `update_webhook` | Update a webhook URL or topic | +| `update_webhook` | Update a webhook callback URL (topic cannot be changed after creation) | | `delete_webhook` | Remove a webhook | ### Menus (5) @@ -316,7 +316,7 @@ Modern Shopify GraphQL discount API for automatic discounts (applied without a c | Tool | Description | |------|-------------| | `list_menus` | List navigation menus | -| `get_menu` | Get a navigation menu by ID | +| `get_menu` | Get a navigation menu by handle, including nested items | | `create_menu` | Create a navigation menu | | `update_menu` | Update a navigation menu | | `delete_menu` | Delete a navigation menu | diff --git a/docs/index.html b/docs/index.html index 82274cb..9635a8b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,9 +4,9 @@ Kockatoos Shopify MCP Server - + - + @@ -396,9 +396,9 @@
-
MCP Server · 80 Tools
+
MCP Server · 136 Tools

Give your AI agents
full Shopify access

-

An MCP server that connects AI agents to the Shopify Admin API. Manage products, orders, customers, inventory, and more — using REST and GraphQL under the hood.

+

An MCP server that connects AI agents to the Shopify Admin API. Manage products, orders, customers, inventory, discounts, themes, and more — powered by the Admin GraphQL API.

Get Started View on GitHub @@ -416,16 +416,16 @@

Give your AI agents
full Shopify access

-
80
+
136
Tools
-
12
+
18
Categories
-
REST + GraphQL
-
API Support
+
GraphQL
+
Admin API
OAuth
@@ -438,7 +438,7 @@

Give your AI agents
full Shopify access

Available Tools

-

80 tools across 12 categories covering the full Shopify Admin API surface.

+

136 tools across 18 categories covering the full Shopify Admin API surface.

@@ -455,7 +455,7 @@

Shop

Products

- 10 tools + 11 tools
  • list_products — List with filters and pagination
  • @@ -480,8 +480,8 @@

    Product Images

    • list_product_images — List all images
    • get_product_image — Get a specific image
    • -
    • create_product_image — Add by URL or base64
    • -
    • update_product_image — Update alt text or position
    • +
    • create_product_image — Add by URL, set position and variants
    • +
    • update_product_image — Update alt text, position, or variant links
    • delete_product_image — Remove an image
@@ -503,18 +503,18 @@

Variants

Collections

- 10 tools + 13 tools
  • list_custom_collections — List manual collections
  • list_smart_collections — List automated collections
  • -
  • get_custom_collection — Get by ID
  • -
  • get_smart_collection — Get by ID
  • -
  • create_custom_collection — Create manual collection
  • -
  • update_custom_collection — Update collection
  • +
  • get_custom_collection / get_smart_collection — Get by ID
  • +
  • create_custom_collection / create_smart_collection — Create collections
  • +
  • update_custom_collection / update_smart_collection — Update collections
  • delete_custom_collection — Delete collection
  • add_product_to_collection — Add a product
  • remove_product_from_collection — Remove a product
  • +
  • reorder_collection_products — Reorder manual collections
  • list_collection_products — List products in collection
@@ -588,17 +588,16 @@

Draft Orders

Discounts

- 8 tools + 30 tools
    -
  • list_price_rules — List all price rules
  • -
  • get_price_rule — Get by ID
  • -
  • create_price_rule — Create rule (%, fixed, shipping)
  • -
  • update_price_rule — Update rule
  • -
  • delete_price_rule — Delete rule
  • -
  • list_discount_codes — List codes for a rule
  • -
  • create_discount_code — Create a code
  • -
  • delete_discount_code — Delete a code
  • +
  • list_code_discounts / list_automatic_discounts — List discounts
  • +
  • get_code_discount / get_automatic_discount — Get by ID
  • +
  • create_code_discount_basic|bxgy|free_shipping — Create code discounts
  • +
  • create_automatic_discount_basic|bxgy|free_shipping — Create automatic discounts
  • +
  • update_*_discount_* — Update any discount type
  • +
  • activate_* / deactivate_* / delete_* — Manage discount lifecycle
  • +
  • *_price_rule, *_discount_code — Legacy-named compatibility wrappers (GraphQL under the hood)
@@ -625,11 +624,94 @@

Webhooks

  • list_webhooks — List all webhooks
  • get_webhook — Get by ID
  • create_webhook — Register a webhook
  • -
  • update_webhook — Update URL or topic
  • +
  • update_webhook — Update callback URL
  • delete_webhook — Remove a webhook
  • +
    +
    +

    Pages

    + 5 tools +
    +
      +
    • list_pages — List online store pages with filters
    • +
    • get_page — Full details with body HTML
    • +
    • create_page — Create a page
    • +
    • update_page — Update content, handle, publish state
    • +
    • delete_page — Permanently delete
    • +
    +
    + +
    +
    +

    Menus

    + 5 tools +
    +
      +
    • list_menus — List navigation menus
    • +
    • get_menu — Get full nested item tree by handle
    • +
    • create_menu — Create with nested items
    • +
    • update_menu — Replace title, handle, items
    • +
    • delete_menu — Delete non-default menus
    • +
    +
    + +
    +
    +

    Files

    + 5 tools +
    +
      +
    • list_files — List files in Content → Files
    • +
    • create_file — Upload from an external URL
    • +
    • update_file — Rename or update alt text
    • +
    • delete_files — Delete one or more files
    • +
    • stage_upload — Create staged upload targets
    • +
    +
    + +
    +
    +

    Themes

    + 10 tools +
    +
      +
    • list_themes / get_theme — Inspect installed themes
    • +
    • create_theme — Install from a ZIP URL
    • +
    • update_theme — Rename a theme
    • +
    • publish_theme — Make a theme live
    • +
    • delete_theme — Remove a theme
    • +
    • list_theme_files / get_theme_files — Browse and read files
    • +
    • upsert_theme_files — Create or update files
    • +
    • delete_theme_files — Delete files
    • +
    +
    + +
    +
    +

    Bundles

    + 4 tools +
    +
      +
    • create_bundle — Create a fixed product bundle
    • +
    • update_bundle — Update bundle components
    • +
    • get_bundle — Get bundle with components
    • +
    • get_bundle_operation — Poll async bundle operations
    • +
    +
    + +
    +
    +

    Apps

    + 2 tools +
    +
      +
    • list_app_installations — List installed apps
    • +
    • get_app_installation — Get install details and scopes
    • +
    +
    +
    @@ -666,15 +748,20 @@

    Quick Setup

    Required Your store name (not the full .myshopify.com domain) + + SHOPIFY_ACCESS_TOKEN + Option A + Admin API access token (shpat_...) — works for any app type + SHOPIFY_CLIENT_ID - Required - App client ID + Option B + App client ID (store-created custom apps) SHOPIFY_CLIENT_SECRET - Required - App client secret + Option B + App client secret (store-created custom apps) SHOPIFY_API_VERSION @@ -689,12 +776,16 @@

    Quick Setup

    Authentication

    -

    Uses Shopify's client_credentials OAuth grant. No browser auth needed.

    +

    Two ways to authenticate: a direct Admin API access token, or the client_credentials OAuth grant for store-created custom apps.

    +

    Option A — Access token

    +

    Set SHOPIFY_ACCESS_TOKEN to an Admin API token. For Partner Dashboard apps, run npx shopify-mcp-get-token to complete browser OAuth once and obtain a token.

    +

    Option B — Client credentials

    +

    For custom apps created in the Shopify admin, set SHOPIFY_CLIENT_ID and SHOPIFY_CLIENT_SECRET. Tokens are fetched and refreshed automatically — no browser auth needed.

    Required App Scopes

    -

    Configure in Shopify Admin → Apps and sales channels → Develop apps → API access.

    +

    Configure in Shopify Admin → Apps and sales channels → Develop apps → API access. Grant only the scopes for the tool groups you plan to use.

    read_products, write_products
    read_orders, write_orders
    @@ -702,10 +793,14 @@

    Required App Scopes

    read_inventory, write_inventory
    read_locations
    read_draft_orders, write_draft_orders
    -
    read_price_rules, write_price_rules
    read_discounts, write_discounts
    -
    read_shipping, write_shipping
    read_fulfillments, write_fulfillments
    +
    read_shipping, write_shipping
    +
    read_themes, write_themes
    +
    read_content, write_content
    +
    read_online_store_pages, write_online_store_pages
    +
    read_online_store_navigation, write_online_store_navigation
    +
    read_files, write_files
    @@ -722,7 +817,12 @@

    Required App Scopes

    diff --git a/package-lock.json b/package-lock.json index 419035d..0b2f2c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "kockatoos-shopify-mcp", + "name": "@kockatoos/shopify-mcp", "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "kockatoos-shopify-mcp", + "name": "@kockatoos/shopify-mcp", "version": "1.0.8", "license": "MIT", "dependencies": { @@ -14,7 +14,8 @@ "zod": "^4.3.6" }, "bin": { - "kockatoos-shopify-mcp": "dist/index.js" + "kockatoos-shopify-mcp": "dist/index.js", + "shopify-mcp-get-token": "dist/get-token.js" }, "devDependencies": { "@types/node": "^25.5.0", @@ -37,21 +38,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -60,9 +61,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -71,9 +72,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -151,26 +152,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -178,9 +181,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -195,9 +198,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -212,9 +215,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -229,9 +232,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -246,9 +249,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -263,9 +266,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -280,9 +283,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -297,9 +300,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -314,9 +317,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -331,9 +334,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -348,9 +351,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -365,9 +368,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -382,9 +385,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -392,16 +395,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -416,9 +421,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -433,9 +438,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -475,9 +480,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1103,12 +1108,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1127,9 +1132,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -1297,9 +1302,9 @@ } }, "node_modules/hono": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", - "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1348,9 +1353,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1738,9 +1743,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1837,9 +1842,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1861,9 +1866,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1883,9 +1888,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1903,7 +1908,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1925,9 +1930,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1973,14 +1978,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1989,21 +1994,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/router": { @@ -2230,14 +2235,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2378,17 +2383,17 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -2404,8 +2409,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/src/config.ts b/src/config.ts index 4db47e5..84b045e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,16 +17,37 @@ export interface ShopifyConfig { apiVersion: string; } +/** + * Normalizes a store identifier to the bare subdomain. Accepts "my-store", + * "my-store.myshopify.com", or a full URL, and validates the result so it + * can be safely interpolated into https://.myshopify.com URLs. + */ +export function normalizeStoreName(raw: string): string { + const storeName = raw + .trim() + .replace(/^https?:\/\//i, "") + .replace(/\.myshopify\.com.*$/i, "") + .replace(/\/.*$/, ""); + if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(storeName)) { + throw new Error( + `Invalid SHOPIFY_STORE_NAME: "${raw}". Use the store subdomain only, ` + + `e.g. "my-store" for my-store.myshopify.com.` + ); + } + return storeName; +} + export function loadConfig(): ShopifyConfig { - const storeName = process.env.SHOPIFY_STORE_NAME; + const rawStoreName = process.env.SHOPIFY_STORE_NAME; const accessToken = process.env.SHOPIFY_ACCESS_TOKEN; const clientId = process.env.SHOPIFY_CLIENT_ID; const clientSecret = process.env.SHOPIFY_CLIENT_SECRET; const apiVersion = process.env.SHOPIFY_API_VERSION || "2026-01"; - if (!storeName) { + if (!rawStoreName) { throw new Error("Missing required environment variable: SHOPIFY_STORE_NAME"); } + const storeName = normalizeStoreName(rawStoreName); if (!accessToken && !(clientId && clientSecret)) { throw new Error( diff --git a/src/get-token.ts b/src/get-token.ts index 9a27757..438613c 100644 --- a/src/get-token.ts +++ b/src/get-token.ts @@ -22,11 +22,13 @@ import dotenv from "dotenv"; import path from "path"; import http from "http"; +import crypto from "crypto"; import { exec } from "child_process"; +import { normalizeStoreName } from "./config.js"; // Load .env from CWD first, then package dir fallback -dotenv.config({ path: path.resolve(process.cwd(), ".env") }); -dotenv.config({ path: path.resolve(__dirname, "..", ".env") }); +dotenv.config({ path: path.resolve(process.cwd(), ".env"), quiet: true }); +dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true }); const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID; const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET; @@ -63,15 +65,41 @@ if (!CLIENT_ID || !CLIENT_SECRET || !STORE_NAME) { process.exit(1); } -const shop = `${STORE_NAME}.myshopify.com`; -const state = Math.random().toString(36).slice(2); +const shop = `${normalizeStoreName(STORE_NAME)}.myshopify.com`; +const state = crypto.randomBytes(16).toString("hex"); const authorizeUrl = `https://${shop}/admin/oauth/authorize` + - `?client_id=${CLIENT_ID}` + + `?client_id=${encodeURIComponent(CLIENT_ID)}` + `&scope=${encodeURIComponent(SCOPES)}` + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + `&state=${state}`; +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function callbackPage(title: string, detail: string): string { + return ` + + + +Shopify MCP + + +

    ${title}

    ${detail}
    +`; +} + function openBrowser(url: string) { const platform = process.platform; const cmd = @@ -127,7 +155,7 @@ const server = http.createServer(async (req, res) => { if (error) { res.writeHead(400, { "Content-Type": "text/html" }); - res.end(`

    OAuth error: ${error}

    You can close this tab.

    `); + res.end(callbackPage(`OAuth error: ${escapeHtml(error)}`, "

    You can close this tab.

    ")); server.close(); console.error(`\nOAuth error: ${error}`); process.exit(1); @@ -135,14 +163,14 @@ const server = http.createServer(async (req, res) => { if (returnedState !== state) { res.writeHead(400, { "Content-Type": "text/html" }); - res.end("

    State mismatch — possible CSRF. Try again.

    "); + res.end(callbackPage("State mismatch", "

    Possible CSRF. Close this tab and try again.

    ")); server.close(); process.exit(1); } if (!code) { res.writeHead(400, { "Content-Type": "text/html" }); - res.end("

    No code in callback.

    "); + res.end(callbackPage("No code in callback", "

    Close this tab and try again.

    ")); server.close(); process.exit(1); } @@ -157,15 +185,15 @@ const server = http.createServer(async (req, res) => { console.log("then restart your MCP server.\n"); res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - "

    ✅ Authorization successful!

    " + + res.end(callbackPage( + "✅ Authorization successful!", "

    Your access token has been printed to the terminal.

    " + "

    Add SHOPIFY_ACCESS_TOKEN=<token> to your MCP config's env block, then restart the MCP server.

    " + "

    You can close this tab.

    " - ); + )); } catch (err) { res.writeHead(500, { "Content-Type": "text/html" }); - res.end(`

    Token exchange failed

    ${err}
    `); + res.end(callbackPage("Token exchange failed", `

    ${escapeHtml(err instanceof Error ? err.message : String(err))}

    `)); console.error(err); } finally { server.close(); diff --git a/src/index.ts b/src/index.ts index 689c505..1d93f63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,13 +23,15 @@ import { registerThemeTools } from "./tools/themes.js"; import { registerPageTools } from "./tools/pages.js"; import { registerBundleTools } from "./tools/bundles.js"; +const { version } = require("../package.json") as { version: string }; + async function main() { const config = loadConfig(); const client = new ShopifyClient(config); const server = new McpServer({ name: "kockatoos-shopify-mcp", - version: "1.0.0", + version, }); // Register all tool groups diff --git a/src/shopify-client.ts b/src/shopify-client.ts index 94923ad..dc8d4eb 100644 --- a/src/shopify-client.ts +++ b/src/shopify-client.ts @@ -1,9 +1,23 @@ import { ShopifyConfig } from "./config.js"; import { getAccessToken } from "./auth.js"; +const MAX_RETRIES = 3; +const BASE_RETRY_DELAY_MS = 1_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isThrottledGraphQLError(errors: unknown[]): boolean { + return errors.some((error) => { + const code = (error as { extensions?: { code?: string } })?.extensions?.code; + return code === "THROTTLED"; + }); +} + /** * Shopify Admin GraphQL API client. - * Handles authentication, request building, and error formatting. + * Handles authentication, request building, rate-limit retries, and error formatting. */ export class ShopifyClient { private config: ShopifyConfig; @@ -18,37 +32,57 @@ export class ShopifyClient { const token = await getAccessToken(this.config); const url = `${this.baseUrl}/graphql.json`; - let res: Response; - try { - res = await fetch(url, { - method: "POST", - headers: { - "X-Shopify-Access-Token": token, - "Content-Type": "application/json", - }, - body: JSON.stringify({ query, variables }), - signal: AbortSignal.timeout(30_000), - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - const error = new Error(`Network error on GraphQL request: ${msg}`); - process.stderr.write(`[shopify-mcp] ${error.message}\n`); - throw error; - } + for (let attempt = 0; ; attempt++) { + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { + "X-Shopify-Access-Token": token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(30_000), + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + const error = new Error(`Network error on GraphQL request: ${msg}`); + process.stderr.write(`[shopify-mcp] ${error.message}\n`); + throw error; + } - if (!res.ok) { - const errBody = await res.text(); - const error = new Error(`Shopify GraphQL error ${res.status}: ${errBody}`); - process.stderr.write(`[shopify-mcp] ${error.message}\n`); - throw error; - } + // Rate limited at the HTTP level — back off and retry, honoring Retry-After + if (res.status === 429 && attempt < MAX_RETRIES) { + const retryAfter = Number(res.headers.get("Retry-After")); + const delay = Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : BASE_RETRY_DELAY_MS * 2 ** attempt; + process.stderr.write(`[shopify-mcp] Rate limited (429); retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})\n`); + await sleep(delay); + continue; + } + + if (!res.ok) { + const errBody = await res.text(); + const error = new Error(`Shopify GraphQL error ${res.status}: ${errBody}`); + process.stderr.write(`[shopify-mcp] ${error.message}\n`); + throw error; + } - const json = (await res.json()) as { data?: T; errors?: unknown[] }; - if (json.errors) { - const error = new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`); - process.stderr.write(`[shopify-mcp] ${error.message}\n`); - throw error; + const json = (await res.json()) as { data?: T; errors?: unknown[] }; + if (json.errors) { + // GraphQL cost-based throttling returns 200 with a THROTTLED error code + if (isThrottledGraphQLError(json.errors) && attempt < MAX_RETRIES) { + const delay = BASE_RETRY_DELAY_MS * 2 ** attempt; + process.stderr.write(`[shopify-mcp] Query throttled; retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})\n`); + await sleep(delay); + continue; + } + const error = new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`); + process.stderr.write(`[shopify-mcp] ${error.message}\n`); + throw error; + } + return json.data as T; } - return json.data as T; } } diff --git a/src/tools/graphql-helpers.ts b/src/tools/graphql-helpers.ts index 8ba7d2c..2657a44 100644 --- a/src/tools/graphql-helpers.ts +++ b/src/tools/graphql-helpers.ts @@ -26,14 +26,21 @@ export function compact>(value: T): Partial export function throwOnUserErrors(operation: string, errors?: ShopifyUserError[] | null): void { if (!errors || errors.length === 0) return; - const messages = errors.map((error) => error.message).join("; "); + const messages = errors + .map((error) => (error.field?.length ? `${error.field.join(".")}: ${error.message}` : error.message)) + .join("; "); throw new Error(`${operation} errors: ${messages}`); } export function searchQuery(parts: Record): string | undefined { const query = Object.entries(parts) .filter(([, value]) => value !== undefined && value !== "") - .map(([key, value]) => `${key}:${String(value)}`) + .map(([key, value]) => { + const text = String(value); + // Quote values with whitespace or quotes so the search syntax stays valid + const safe = /[\s"]/.test(text) ? `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : text; + return `${key}:${safe}`; + }) .join(" "); return query || undefined; } \ No newline at end of file diff --git a/src/tools/images.ts b/src/tools/images.ts index 38a9933..f020eb3 100644 --- a/src/tools/images.ts +++ b/src/tools/images.ts @@ -12,6 +12,45 @@ const MEDIA_FIELDS = ` ... on MediaImage { image { url altText width height } } `; +/** Applies optional position (reorder) and variant assignments to a media item. */ +async function applyMediaExtras( + client: ShopifyClient, + productId: string, + mediaId: string, + position?: number, + variantIds?: string[] +): Promise { + if (variantIds?.length) { + const mutation = ` + mutation AppendVariantMedia($productId: ID!, $variantMedia: [ProductVariantAppendMediaInput!]!) { + productVariantAppendMedia(productId: $productId, variantMedia: $variantMedia) { + userErrors { field message } + } + } + `; + const data = await client.graphql<{ productVariantAppendMedia: { userErrors: { field?: string[]; message: string }[] } }>(mutation, { + productId, + variantMedia: variantIds.map((variantId) => ({ variantId: gid("ProductVariant", variantId), mediaIds: [mediaId] })), + }); + throwOnUserErrors("productVariantAppendMedia", data.productVariantAppendMedia.userErrors); + } + if (position !== undefined) { + const mutation = ` + mutation ReorderProductMedia($id: ID!, $moves: [MoveInput!]!) { + productReorderMedia(id: $id, moves: $moves) { + job { id } + mediaUserErrors { field message } + } + } + `; + const data = await client.graphql<{ productReorderMedia: { mediaUserErrors: { field?: string[]; message: string }[] } }>(mutation, { + id: productId, + moves: [{ id: mediaId, newPosition: String(Math.max(0, position - 1)) }], + }); + throwOnUserErrors("productReorderMedia", data.productReorderMedia.mediaUserErrors); + } +} + export function registerImageTools(server: McpServer, client: ShopifyClient) { server.tool( "list_product_images", @@ -62,10 +101,10 @@ export function registerImageTools(server: McpServer, client: ShopifyClient) { product_id: z.string().describe("The numeric Shopify product ID."), src: z.string().describe("Image URL to upload."), alt: z.string().optional().describe("Alt text for the image."), - position: z.number().optional().describe("Position/order of the image (1 = first)."), - variant_ids: z.array(z.string()).optional().describe("Variant IDs to associate this image with."), + position: z.number().min(1).optional().describe("Position/order of the image (1 = first)."), + variant_ids: z.array(z.string()).optional().describe("Variant IDs to associate this image with. Note: newly uploaded media may still be processing; if assignment fails, retry with update_product_image once the media status is READY."), }, - async ({ product_id, src, alt }) => { + async ({ product_id, src, alt, position, variant_ids }) => { const mutation = ` mutation CreateProductMedia($productId: ID!, $media: [CreateMediaInput!]!) { productCreateMedia(productId: $productId, media: $media) { @@ -74,12 +113,16 @@ export function registerImageTools(server: McpServer, client: ShopifyClient) { } } `; - const data = await client.graphql<{ productCreateMedia: { media: unknown[]; mediaUserErrors: { field?: string[]; message: string }[] } }>(mutation, { + const data = await client.graphql<{ productCreateMedia: { media: { id: string }[]; mediaUserErrors: { field?: string[]; message: string }[] } }>(mutation, { productId: gid("Product", product_id), media: [compact({ originalSource: src, alt, mediaContentType: "IMAGE" })], }); throwOnUserErrors("productCreateMedia", data.productCreateMedia.mediaUserErrors); - return { content: [{ type: "text", text: JSON.stringify(data.productCreateMedia.media[0] ?? null, null, 2) }] }; + const created = data.productCreateMedia.media[0] ?? null; + if (created) { + await applyMediaExtras(client, gid("Product", product_id), created.id, position, variant_ids); + } + return { content: [{ type: "text", text: JSON.stringify(created, null, 2) }] }; } ); @@ -90,10 +133,10 @@ export function registerImageTools(server: McpServer, client: ShopifyClient) { product_id: z.string().describe("The numeric Shopify product ID."), image_id: z.string().describe("The numeric image ID."), alt: z.string().optional().describe("New alt text."), - position: z.number().optional().describe("New position."), - variant_ids: z.array(z.string()).optional().describe("New variant IDs to associate with."), + position: z.number().min(1).optional().describe("New position (1 = first)."), + variant_ids: z.array(z.string()).optional().describe("Variant IDs to additionally associate this image with."), }, - async ({ product_id, image_id, alt }) => { + async ({ product_id, image_id, alt, position, variant_ids }) => { const mutation = ` mutation UpdateProductMedia($productId: ID!, $media: [UpdateMediaInput!]!) { productUpdateMedia(productId: $productId, media: $media) { @@ -102,12 +145,18 @@ export function registerImageTools(server: McpServer, client: ShopifyClient) { } } `; - const data = await client.graphql<{ productUpdateMedia: { media: unknown[]; mediaUserErrors: { field?: string[]; message: string }[] } }>(mutation, { - productId: gid("Product", product_id), - media: [compact({ id: gid("MediaImage", image_id), alt })], - }); - throwOnUserErrors("productUpdateMedia", data.productUpdateMedia.mediaUserErrors); - return { content: [{ type: "text", text: JSON.stringify(data.productUpdateMedia.media[0] ?? null, null, 2) }] }; + const mediaId = gid("MediaImage", image_id); + let media: unknown = null; + if (alt !== undefined) { + const data = await client.graphql<{ productUpdateMedia: { media: unknown[]; mediaUserErrors: { field?: string[]; message: string }[] } }>(mutation, { + productId: gid("Product", product_id), + media: [{ id: mediaId, alt }], + }); + throwOnUserErrors("productUpdateMedia", data.productUpdateMedia.mediaUserErrors); + media = data.productUpdateMedia.media[0] ?? null; + } + await applyMediaExtras(client, gid("Product", product_id), mediaId, position, variant_ids); + return { content: [{ type: "text", text: JSON.stringify(media ?? { id: mediaId, updated: true }, null, 2) }] }; } ); diff --git a/src/tools/inventory.ts b/src/tools/inventory.ts index 3483015..7ea9b30 100644 --- a/src/tools/inventory.ts +++ b/src/tools/inventory.ts @@ -37,11 +37,11 @@ export function registerInventoryTools(server: McpServer, client: ShopifyClient) limit: z.number().min(1).max(250).default(50).describe("Number of results (1-250). Default: 50."), page_info: z.string().optional().describe("Cursor for pagination."), }, - async ({ location_id, limit }) => { + async ({ location_id, limit, page_info }) => { const query = ` - query InventoryLevels($locationId: ID!, $first: Int!) { + query InventoryLevels($locationId: ID!, $first: Int!, $after: String) { location(id: $locationId) { - inventoryLevels(first: $first) { + inventoryLevels(first: $first, after: $after) { nodes { ${INVENTORY_LEVEL_FIELDS} } pageInfo { hasNextPage endCursor } } @@ -51,6 +51,7 @@ export function registerInventoryTools(server: McpServer, client: ShopifyClient) const data = await client.graphql<{ location: { inventoryLevels: { nodes: unknown[]; pageInfo: unknown } } }>(query, { locationId: gid("Location", location_id), first: limit, + after: page_info ?? null, }); return { content: [{ type: "text", text: JSON.stringify({ inventory_levels: data.location.inventoryLevels.nodes, pageInfo: data.location.inventoryLevels.pageInfo }, null, 2) }] }; } diff --git a/src/tools/menus.ts b/src/tools/menus.ts index 8909d8c..dd78d80 100644 --- a/src/tools/menus.ts +++ b/src/tools/menus.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { ShopifyClient } from "../shopify-client.js"; +import { gid } from "./graphql-helpers.js"; interface MenuItemInput { title: string; @@ -38,6 +39,23 @@ const menuItemInputSchema: z.ZodType = z.lazy(() => }) ); +interface MenuItemUpdateInput extends MenuItemInput { + id?: string; + items?: MenuItemUpdateInput[]; +} + +const menuItemUpdateSchema: z.ZodType = z.lazy(() => + z.object({ + id: z.string().optional().describe("GID of existing item to update; omit to create a new item."), + title: z.string().describe("Item label."), + url: z.string().describe("URL the item links to."), + type: z.string().describe("Item type (e.g. COLLECTION, PRODUCT, PAGE, BLOG, HTTP, FRONTPAGE, CATALOG, SEARCH, SHOP_POLICY)."), + resourceId: z.string().optional().describe("GID of the linked resource."), + tags: z.array(z.string()).optional().describe("Tags to filter a collection or blog."), + items: z.array(menuItemUpdateSchema).optional().describe("Nested sub-items (max 3 levels deep)."), + }) +); + export function registerMenuTools(server: McpServer, client: ShopifyClient) { // ── List menus ───────────────────────────────────────────────────── server.tool( @@ -188,32 +206,10 @@ export function registerMenuTools(server: McpServer, client: ShopifyClient) { "update_menu", "Update an existing navigation menu's title, handle, and items. Requires write_online_store_navigation access scope.", { - id: z.string().describe("GID of the menu to update (e.g. 'gid://shopify/Menu/123')."), + id: z.string().describe("The numeric menu ID or GID (e.g. 'gid://shopify/Menu/123')."), title: z.string().describe("New title for the menu."), handle: z.string().optional().describe("New handle (cannot be changed for default menus)."), - items: z.array( - z.object({ - id: z.string().optional().describe("GID of existing item to update; omit to create a new item."), - title: z.string().describe("Item label."), - url: z.string().describe("URL the item links to."), - type: z.string().describe("Item type (e.g. COLLECTION, PRODUCT, PAGE, HTTP)."), - resourceId: z.string().optional().describe("GID of the linked resource."), - items: z.array(z.object({ - id: z.string().optional(), - title: z.string(), - url: z.string(), - type: z.string(), - resourceId: z.string().optional(), - items: z.array(z.object({ - id: z.string().optional(), - title: z.string(), - url: z.string(), - type: z.string(), - resourceId: z.string().optional(), - })).optional(), - })).optional().describe("Nested sub-items."), - }) - ).describe("Complete new item list (replaces existing items)."), + items: z.array(menuItemUpdateSchema).describe("Complete new item list (replaces existing items)."), }, async ({ id, title, handle, items }) => { const query = ` @@ -247,7 +243,7 @@ export function registerMenuTools(server: McpServer, client: ShopifyClient) { `; const data = await client.graphql<{ menuUpdate: { menu: Menu | null; userErrors: { field: string[]; message: string }[] }; - }>(query, { id, title, handle: handle ?? null, items }); + }>(query, { id: gid("Menu", id), title, handle: handle ?? null, items }); if (data.menuUpdate.userErrors.length > 0) { throw new Error(`menuUpdate errors: ${JSON.stringify(data.menuUpdate.userErrors)}`); } @@ -262,7 +258,7 @@ export function registerMenuTools(server: McpServer, client: ShopifyClient) { "delete_menu", "Delete a navigation menu. Default menus (e.g. main-menu, footer-menu) cannot be deleted. Requires write_online_store_navigation access scope.", { - id: z.string().describe("GID of the menu to delete (e.g. 'gid://shopify/Menu/123')."), + id: z.string().describe("The numeric menu ID or GID (e.g. 'gid://shopify/Menu/123') to delete."), }, async ({ id }) => { const query = ` @@ -278,7 +274,7 @@ export function registerMenuTools(server: McpServer, client: ShopifyClient) { `; const data = await client.graphql<{ menuDelete: { deletedMenuId: string | null; userErrors: { field: string[]; message: string }[] }; - }>(query, { id }); + }>(query, { id: gid("Menu", id) }); if (data.menuDelete.userErrors.length > 0) { throw new Error(`menuDelete errors: ${JSON.stringify(data.menuDelete.userErrors)}`); } diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 58789de..32cfb7d 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { ShopifyClient } from "../shopify-client.js"; +import { gid, throwOnUserErrors } from "./graphql-helpers.js"; export function registerPageTools(server: McpServer, client: ShopifyClient) { // ── List pages ───────────────────────────────────────────────────── @@ -61,7 +62,7 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { page_id: z.string().describe("The numeric Shopify page ID or GID (e.g. 'gid://shopify/Page/123')."), }, async ({ page_id }) => { - const gid = page_id.startsWith("gid://") ? page_id : `gid://shopify/Page/${page_id}`; + const pageGid = gid("Page", page_id); const gqlQuery = ` query GetPage($id: ID!) { page(id: $id) { @@ -78,7 +79,7 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { } } `; - const data = await client.graphql<{ page: unknown }>(gqlQuery, { id: gid }); + const data = await client.graphql<{ page: unknown }>(gqlQuery, { id: pageGid }); return { content: [{ type: "text", text: JSON.stringify(data.page, null, 2) }], }; @@ -128,8 +129,9 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { const data = await client.graphql<{ pageCreate: { page: unknown; userErrors: { code: string; field: string[]; message: string }[] }; }>(mutation, { page: pageInput }); + throwOnUserErrors("pageCreate", data.pageCreate.userErrors); return { - content: [{ type: "text", text: JSON.stringify(data.pageCreate, null, 2) }], + content: [{ type: "text", text: JSON.stringify(data.pageCreate.page, null, 2) }], }; } ); @@ -148,7 +150,7 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { template_suffix: z.string().optional().describe("New custom template suffix."), }, async ({ page_id, title, body, handle, is_published, published_at, template_suffix }) => { - const gid = page_id.startsWith("gid://") ? page_id : `gid://shopify/Page/${page_id}`; + const pageGid = gid("Page", page_id); const mutation = ` mutation UpdatePage($id: ID!, $page: PageUpdateInput!) { pageUpdate(id: $id, page: $page) { @@ -179,9 +181,10 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { const data = await client.graphql<{ pageUpdate: { page: unknown; userErrors: { code: string; field: string[]; message: string }[] }; - }>(mutation, { id: gid, page: pageInput }); + }>(mutation, { id: pageGid, page: pageInput }); + throwOnUserErrors("pageUpdate", data.pageUpdate.userErrors); return { - content: [{ type: "text", text: JSON.stringify(data.pageUpdate, null, 2) }], + content: [{ type: "text", text: JSON.stringify(data.pageUpdate.page, null, 2) }], }; } ); @@ -194,7 +197,7 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { page_id: z.string().describe("The numeric Shopify page ID or GID to delete."), }, async ({ page_id }) => { - const gid = page_id.startsWith("gid://") ? page_id : `gid://shopify/Page/${page_id}`; + const pageGid = gid("Page", page_id); const mutation = ` mutation DeletePage($id: ID!) { pageDelete(id: $id) { @@ -209,9 +212,10 @@ export function registerPageTools(server: McpServer, client: ShopifyClient) { `; const data = await client.graphql<{ pageDelete: { deletedPageId: string | null; userErrors: { code: string; field: string[]; message: string }[] }; - }>(mutation, { id: gid }); + }>(mutation, { id: pageGid }); + throwOnUserErrors("pageDelete", data.pageDelete.userErrors); return { - content: [{ type: "text", text: JSON.stringify(data.pageDelete, null, 2) }], + content: [{ type: "text", text: `Page ${data.pageDelete.deletedPageId ?? page_id} deleted successfully.` }], }; } ); diff --git a/src/tools/products.ts b/src/tools/products.ts index 35b4044..cef17c5 100644 --- a/src/tools/products.ts +++ b/src/tools/products.ts @@ -106,7 +106,7 @@ export function registerProductTools(server: McpServer, client: ShopifyClient) { // ── Create a product ────────────────────────────────────────────── server.tool( "create_product", - "Create a new product. At minimum provide a title. The product is created in 'draft' status by default. Variants can be provided to set prices, SKUs, and inventory.", + "Create a new product. At minimum provide a title. The product is created in 'draft' status by default. Variants can be provided to set prices, SKUs, and inventory. Note: images and variants are added in follow-up calls after the product is created; if one of those steps fails, the product still exists and the error message includes its ID so you can fix it with update tools.", { title: z.string().describe("Product title (required)."), body_html: z.string().optional().describe("Product description in HTML."), @@ -147,9 +147,13 @@ export function registerProductTools(server: McpServer, client: ShopifyClient) { product: productInput({ title, body_html, vendor, product_type, tags, status }), }); throwOnUserErrors("productCreate", data.productCreate.userErrors); + if (!data.productCreate.product) { + throw new Error("productCreate returned no product and no userErrors."); + } + const productId = data.productCreate.product.id; let product = data.productCreate.product; - if (product && images?.length) { + if (images?.length) { const mediaMutation = ` mutation CreateProductMedia($productId: ID!, $media: [CreateMediaInput!]!) { productCreateMedia(productId: $productId, media: $media) { @@ -162,14 +166,18 @@ export function registerProductTools(server: McpServer, client: ShopifyClient) { const mediaData = await client.graphql<{ productCreateMedia: { product: unknown; mediaUserErrors: { field?: string[]; message: string }[] }; }>(mediaMutation, { - productId: product.id, + productId, media: images.map((image) => ({ originalSource: image.src, mediaContentType: "IMAGE" })), }); - throwOnUserErrors("productCreateMedia", mediaData.productCreateMedia.mediaUserErrors); - product = mediaData.productCreateMedia.product as { id: string }; + try { + throwOnUserErrors("productCreateMedia", mediaData.productCreateMedia.mediaUserErrors); + } catch (err) { + throw new Error(`Product ${productId} was created, but adding images failed. ${err instanceof Error ? err.message : String(err)}`); + } + product = (mediaData.productCreateMedia.product as { id: string }) ?? product; } - if (product && variants?.length) { + if (variants?.length) { const variantsMutation = ` mutation ProductVariantsBulkCreate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { productVariantsBulkCreate(productId: $productId, variants: $variants) { @@ -182,7 +190,7 @@ export function registerProductTools(server: McpServer, client: ShopifyClient) { const variantsData = await client.graphql<{ productVariantsBulkCreate: { product: unknown; userErrors: { field?: string[]; message: string }[] }; }>(variantsMutation, { - productId: product.id, + productId, variants: variants.map((variant) => compact({ price: variant.price, inventoryQuantities: variant.inventory_quantity !== undefined ? [{ availableQuantity: variant.inventory_quantity }] : undefined, @@ -190,8 +198,12 @@ export function registerProductTools(server: McpServer, client: ShopifyClient) { optionValues: variant.title ? [{ name: variant.title, optionName: "Title" }] : undefined, })), }); - throwOnUserErrors("productVariantsBulkCreate", variantsData.productVariantsBulkCreate.userErrors); - product = variantsData.productVariantsBulkCreate.product as { id: string }; + try { + throwOnUserErrors("productVariantsBulkCreate", variantsData.productVariantsBulkCreate.userErrors); + } catch (err) { + throw new Error(`Product ${productId} was created, but adding variants failed. ${err instanceof Error ? err.message : String(err)}`); + } + product = (variantsData.productVariantsBulkCreate.product as { id: string }) ?? product; } return { diff --git a/src/tools/webhooks.ts b/src/tools/webhooks.ts index c6c14da..12555da 100644 --- a/src/tools/webhooks.ts +++ b/src/tools/webhooks.ts @@ -61,7 +61,7 @@ export function registerWebhookTools(server: McpServer, client: ShopifyClient) { } ); - server.tool("update_webhook", "Update an existing webhook subscription (change the address or topic).", { webhook_id: z.string().describe("The numeric webhook ID."), address: z.string().optional().describe("New callback URL."), topic: z.string().optional().describe("New topic.") }, async ({ webhook_id, address }) => { + server.tool("update_webhook", "Update an existing webhook subscription's callback URL. The topic cannot be changed after creation; delete and recreate the webhook to change topics.", { webhook_id: z.string().describe("The numeric webhook ID."), address: z.string().optional().describe("New callback URL.") }, async ({ webhook_id, address }) => { const mutation = `mutation WebhookSubscriptionUpdate($id: ID!, $webhookSubscription: WebhookSubscriptionInput!) { webhookSubscriptionUpdate(id: $id, webhookSubscription: $webhookSubscription) { webhookSubscription { ${WEBHOOK_FIELDS} } userErrors { field message } } }`; const data = await client.graphql<{ webhookSubscriptionUpdate: { webhookSubscription: unknown; userErrors: { field?: string[]; message: string }[] } }>(mutation, { id: gid("WebhookSubscription", webhook_id), diff --git a/tests/config.test.ts b/tests/config.test.ts index 91d6251..107284d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -83,4 +83,28 @@ describe("loadConfig", () => { const { loadConfig } = await import("../src/config.js"); expect(() => loadConfig()).toThrow("Missing credentials. Set either SHOPIFY_ACCESS_TOKEN"); }); + + it("normalizes a full .myshopify.com domain to the bare subdomain", async () => { + vi.stubEnv("SHOPIFY_STORE_NAME", "test-store.myshopify.com"); + vi.stubEnv("SHOPIFY_ACCESS_TOKEN", "shpat_test_token"); + + const { loadConfig } = await import("../src/config.js"); + expect(loadConfig().storeName).toBe("test-store"); + }); + + it("normalizes a full https URL to the bare subdomain", async () => { + vi.stubEnv("SHOPIFY_STORE_NAME", "https://test-store.myshopify.com/admin"); + vi.stubEnv("SHOPIFY_ACCESS_TOKEN", "shpat_test_token"); + + const { loadConfig } = await import("../src/config.js"); + expect(loadConfig().storeName).toBe("test-store"); + }); + + it("throws on a store name with unsafe characters", async () => { + vi.stubEnv("SHOPIFY_STORE_NAME", "bad store/../../name"); + vi.stubEnv("SHOPIFY_ACCESS_TOKEN", "shpat_test_token"); + + const { loadConfig } = await import("../src/config.js"); + expect(() => loadConfig()).toThrow("Invalid SHOPIFY_STORE_NAME"); + }); }); diff --git a/tests/graphql-helpers.test.ts b/tests/graphql-helpers.test.ts new file mode 100644 index 0000000..8d63c22 --- /dev/null +++ b/tests/graphql-helpers.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { gid, nodes, compact, throwOnUserErrors, searchQuery } from "../src/tools/graphql-helpers.js"; + +describe("gid", () => { + it("wraps numeric IDs in a GID", () => { + expect(gid("Product", "123")).toBe("gid://shopify/Product/123"); + }); + + it("passes through existing GIDs unchanged", () => { + expect(gid("Product", "gid://shopify/Product/123")).toBe("gid://shopify/Product/123"); + }); +}); + +describe("nodes", () => { + it("returns nodes when present", () => { + expect(nodes({ nodes: [1, 2] })).toEqual([1, 2]); + }); + + it("unwraps edges when nodes are absent", () => { + expect(nodes({ edges: [{ node: "a" }, { node: "b" }] })).toEqual(["a", "b"]); + }); + + it("returns an empty array for null/undefined connections", () => { + expect(nodes(null)).toEqual([]); + expect(nodes(undefined)).toEqual([]); + }); +}); + +describe("compact", () => { + it("removes undefined, null, and empty-string values", () => { + expect(compact({ a: 1, b: undefined, c: null, d: "", e: false, f: 0 })).toEqual({ a: 1, e: false, f: 0 }); + }); +}); + +describe("throwOnUserErrors", () => { + it("does nothing for empty or missing errors", () => { + expect(() => throwOnUserErrors("op", [])).not.toThrow(); + expect(() => throwOnUserErrors("op", null)).not.toThrow(); + expect(() => throwOnUserErrors("op", undefined)).not.toThrow(); + }); + + it("includes the field path in the message", () => { + expect(() => throwOnUserErrors("productCreate", [{ field: ["input", "title"], message: "is required" }])) + .toThrow("productCreate errors: input.title: is required"); + }); + + it("joins multiple errors", () => { + expect(() => throwOnUserErrors("op", [{ message: "first" }, { message: "second" }])) + .toThrow("op errors: first; second"); + }); +}); + +describe("searchQuery", () => { + it("joins simple key:value pairs", () => { + expect(searchQuery({ status: "active", vendor: "Acme" })).toBe("status:active vendor:Acme"); + }); + + it("quotes values containing spaces", () => { + expect(searchQuery({ title: "Kitchen Knife" })).toBe('title:"Kitchen Knife"'); + }); + + it("escapes embedded quotes", () => { + expect(searchQuery({ title: 'The "Best" Knife' })).toBe('title:"The \\"Best\\" Knife"'); + }); + + it("skips undefined and empty values and returns undefined when nothing remains", () => { + expect(searchQuery({ a: undefined, b: "" })).toBeUndefined(); + }); +}); diff --git a/tests/shopify-client.test.ts b/tests/shopify-client.test.ts index bb4ec3f..768df45 100644 --- a/tests/shopify-client.test.ts +++ b/tests/shopify-client.test.ts @@ -82,5 +82,57 @@ describe("ShopifyClient", () => { "Shopify GraphQL error 401" ); }); + + it("retries after a 429 response and returns data on success", async () => { + vi.useFakeTimers(); + try { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("Too Many Requests", { status: 429, headers: { "Retry-After": "1" } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: { shop: { name: "ok" } } }), { status: 200 })); + + const promise = client.graphql("{ shop { name } }"); + await vi.advanceTimersByTimeAsync(1_000); + await expect(promise).resolves.toEqual({ shop: { name: "ok" } }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("retries THROTTLED GraphQL errors and returns data on success", async () => { + vi.useFakeTimers(); + try { + const throttled = { errors: [{ message: "Throttled", extensions: { code: "THROTTLED" } }] }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify(throttled), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: { ok: true } }), { status: 200 })); + + const promise = client.graphql("{ shop { name } }"); + await vi.advanceTimersByTimeAsync(1_000); + await expect(promise).resolves.toEqual({ ok: true }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("gives up after exhausting retries on persistent 429s", async () => { + vi.useFakeTimers(); + try { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("Too Many Requests", { status: 429 })); + + const promise = client.graphql("{ shop { name } }"); + const assertion = expect(promise).rejects.toThrow("Shopify GraphQL error 429"); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; + expect(fetchSpy).toHaveBeenCalledTimes(4); // initial + 3 retries + } finally { + vi.useRealTimers(); + } + }); }); }); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 74df322..996e8b4 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -358,7 +358,7 @@ describe("update_smart_collection handler", () => { it("throws when userErrors are returned", async () => { const { client, handler } = buildServerWithHandler(); vi.spyOn(client, "graphql").mockResolvedValueOnce({ collectionUpdate: { collection: null, userErrors: [{ field: ["sortOrder"], message: "is invalid" }] } }); - await expect(handler({ collection_id: "42", sort_order: "manual" })).rejects.toThrow("collectionUpdate errors: is invalid"); + await expect(handler({ collection_id: "42", sort_order: "manual" })).rejects.toThrow("collectionUpdate errors: sortOrder: is invalid"); }); }); @@ -399,7 +399,7 @@ describe("reorder_collection_products handler", () => { vi.spyOn(client, "graphql").mockResolvedValueOnce({ collectionReorderProducts: { job: null, userErrors: [{ field: ["moves"], message: "contains an invalid product" }] } }); await expect( handler({ collection_id: "10", product_ids: ["1", "999"] }) - ).rejects.toThrow("collectionReorderProducts errors: contains an invalid product"); + ).rejects.toThrow("collectionReorderProducts errors: moves: contains an invalid product"); }); });