diff --git a/README.md b/README.md index 36c461a3..07b3492e 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,13 @@ MCP Server for Shopify API, enabling interaction with store data through GraphQL - **Customer Management**: Full CRUD, merge, and address management (8 tools) - **Order Management**: Smart lookup, cancel, close/open, mark as paid, fulfillment, refunds (10 tools) - **Metafield Management**: Get, set, and delete metafields on any resource (3 tools) -- **Inventory Management**: Set absolute inventory quantities at locations (1 tool) -- **Tag Management**: Add/remove tags on any taggable resource (1 tool) +- **Collection Management**: List collections and get collection details with products (2 tools) +- **Configuration & Discovery**: Shop info, metafield definitions, locations, markets (5 tools) +- **Inventory & Pricing**: Set quantities, view levels/items, price lists, detailed variants (5 tools) +- **Tag Management**: Add/remove tags on any taggable resource, including bulk operations (2 tools) +- **Enhanced Order Tools**: Transactions, fulfillment orders, refund details (3 tools) +- **Field Selection**: All GET tools support a `fields` parameter to fetch only the data you need +- **Count-Only Mode**: List tools support `countOnly` to get result counts without fetching data - **Pagination & Sorting**: Cursor-based pagination and sort keys on all list queries - **Advanced Filtering**: Pass-through Shopify query syntax for all list endpoints - **GraphQL Integration**: Direct integration with Shopify's GraphQL Admin API (2026-01) @@ -164,15 +169,17 @@ shopify-mcp --clientId= --clientSecret= --domain=.myshopi **⚠️ Important:** If you see errors about "SHOPIFY_ACCESS_TOKEN environment variable is required" when using command-line arguments, you might have a different package installed. Make sure you're using `shopify-mcp`, not `shopify-mcp-server`. -## Available Tools (31) +## Available Tools (45) -### Pagination, Sorting & Filtering +### Shared Capabilities -All list query tools (`get-products`, `get-customers`, `get-orders`, `get-customer-orders`) support: +All list query tools (`get-products`, `get-customers`, `get-orders`, `get-customer-orders`, `get-collections`) support: - **Cursor-based pagination**: `after` / `before` (cursor strings), with `pageInfo` in the response (`hasNextPage`, `hasPreviousPage`, `startCursor`, `endCursor`) - **Sorting**: `sortKey` (enum specific to each resource) and `reverse` (boolean) - **Advanced filtering**: `query` or `searchQuery` parameter accepting [Shopify query syntax](https://shopify.dev/docs/api/usage/search-syntax) +- **Field selection**: `fields` parameter to fetch only specific fields, reducing response size +- **Count-only mode**: `countOnly` parameter returns `{ count: N }` without fetching any resource data — useful for sizing queries before paginating ### Product Management (8 tools) @@ -490,16 +497,64 @@ All list query tools (`get-products`, `get-customers`, `get-orders`, `get-custom - `name` (string, required): `"available"` or `"on_hand"` - `quantities` (array, required): Items with `inventoryItemId`, `locationId`, `quantity` -### Tag Management (1 tool) +### Tag Management (2 tools) 1. **`manage-tags`** - Add or remove tags on any taggable resource (orders, products, customers, draft orders, articles) - Inputs: - - `id` (string, required): GID of the resource + - `id` (string, required): GID of the resource (e.g. `gid://shopify/Product/123`) - `tags` (array of strings, required): Tags to add or remove - `action` (string, required): `"add"` or `"remove"` +2. **`manage-tags-bulk`** + + - Bulk add or remove tags on up to 100 resources in a single call (runs mutations in parallel) + - Inputs: + - `ids` (array of strings, required): Resource GIDs (up to 100). For larger sets, call multiple times + - `tags` (array of strings, required): Tags to add or remove + - `action` (string, required): `"add"` or `"remove"` + +### Collection Management (2 tools) + +1. **`get-collections`** + + - List collections with search, pagination, and sorting + - Inputs: + - `query` (string, optional): Shopify query syntax + - `limit` (number, default: 10): Maximum collections to return + - `sortKey`, `reverse`, `after`/`before`: Standard pagination and sorting + - `fields`, `countOnly`: Standard field selection and count-only mode + +2. **`get-collection-by-id`** + + - Get a single collection with full details including products, rules (smart collections), SEO, and image + - Inputs: + - `collectionId` (string, required): Collection ID (e.g. `gid://shopify/Collection/123` or just `123`) + - `productsFirst` (number, default: 25): Number of products to include (0 to skip) + - `fields` (array, optional): Collection-level fields to fetch (`id`, `title`, `handle`, `descriptionHtml`, `sortOrder`, `templateSuffix`, `updatedAt`, `productsCount`, `ruleSet`, `image`, `seo`) + +### Configuration & Discovery (4 tools) + +1. **`get-shop-info`** — Get shop details (name, domain, plan, currency, timezone, etc.) +2. **`get-metafield-definitions`** — List metafield definitions for any owner type with pagination +3. **`get-locations`** — List all active locations with addresses and fulfillment service info +4. **`get-markets`** — List all markets with regions, currencies, and domains + +### Enhanced Order & Fulfillment (3 tools) + +1. **`get-order-transactions`** — Get payment transactions for an order (gateway, status, amounts, card details) +2. **`get-fulfillment-orders`** — Get fulfillment orders for an order (line items, assigned location, status) +3. **`get-order-refund-details`** — Get refund history for an order (line items, amounts, restocking) + +### Inventory & Pricing (4 tools) + +1. **`inventory-set-quantities`** — Set absolute inventory quantities at locations with validated reason codes +2. **`get-inventory-levels`** — Get inventory levels for an item across locations +3. **`get-inventory-items`** — Get inventory items for a product (cost, tracking, country of origin) +4. **`get-price-lists`** — List price lists with fixed prices and quantity rules +5. **`get-product-variants-detailed`** — Get detailed variant info (inventory, pricing, images, metafields) + ### Order Query Filter Reference The `get-orders` tool's `query` parameter supports [Shopify search syntax](https://shopify.dev/docs/api/usage/search-syntax): diff --git a/src/lib/projection.ts b/src/lib/projection.ts new file mode 100644 index 00000000..4cda9c2f --- /dev/null +++ b/src/lib/projection.ts @@ -0,0 +1,124 @@ +import type { GraphQLClient } from "graphql-request"; +import { z } from "zod"; +import { edgesToNodes } from "./toolUtils.js"; + +// ── Field projection ────────────────────────────────────────────────── +// +// A Projection is the single source of truth for a tool's selectable fields. +// Each field maps to its GraphQL fragment. From that one definition a +// Projection derives: +// - the GraphQL selection set (selection) +// - the zod `fields` parameter, including the shared agent guidance (fieldsParam) +// - response normalisation that flattens connection-shaped values (normalize) +// +// This keeps the fragment, the enum, and the docs for a field in one place +// instead of spread across a field map, an enum, and a describe string in every +// tool. `normalize` flattens any top-level Shopify connection ({ edges: [...] }) +// on a node to an array of nodes — detected by shape, so field aliases (e.g. a +// fragment using addressesV2 under the key "addresses") are handled correctly. + +const SHARED_FIELDS_GUIDANCE = + "IMPORTANT: Always specify this to minimize token usage and avoid flooding context with unnecessary data. " + + "Only the listed fields will be fetched from the API and returned. 'id' is always included. " + + "If you are unsure which fields are needed, ask the user before fetching all fields. "; + +const COUNT_ONLY_GUIDANCE = + "IMPORTANT: Use this to check result set size before fetching data. " + + "Returns only { count: N } without any resource data, saving significant context. " + + "Recommended before paginating large result sets."; + +export interface Projection { + /** All selectable field names, in declaration order ('id' is always implicitly selected). */ + readonly fieldNames: string[]; + + /** + * GraphQL selection set for the requested fields (or all when omitted). + * Always includes 'id'. Joined with newlines — GraphQL is whitespace-insensitive, + * so callers can interpolate this at any indentation. + */ + selection(fields?: string[]): string; + + /** + * Zod parameter for the `fields` argument, carrying the shared agent guidance. + * @param opts.noun word used in the example sentence (e.g. "product", "collection"). + * @param opts.extra extra guidance appended before the "Available:" list. + */ + fieldsParam(opts?: { noun?: string; extra?: string }): z.ZodOptional< + z.ZodArray> + >; + + /** Flatten any top-level connection-shaped value ({ edges: [...] }) on a node to an array of nodes. */ + normalize>(node: T): T; +} + +/** Build a Projection from a map of field name → GraphQL fragment. */ +export function defineProjection(spec: Record): Projection { + const names = Object.keys(spec); + + return { + fieldNames: names, + + selection(fields?: string[]): string { + const selected = fields ?? names; + const ordered = [...new Set(["id", ...selected])]; + return ordered + .map((n) => spec[n]) + .filter(Boolean) + .join("\n"); + }, + + fieldsParam(opts: { noun?: string; extra?: string } = {}) { + const noun = opts.noun ?? "resource"; + const enumNames = names as [string, ...string[]]; + // Build the example from this projection's own fields so it is always a + // valid selection (not every resource has a "title" field). + const exampleField = names.find((n) => n !== "id") ?? "id"; + const description = + SHARED_FIELDS_GUIDANCE + + `Example: ["id", "${exampleField}"] returns only ${noun} GID and ${exampleField}. ` + + (opts.extra ? `${opts.extra} ` : "") + + `Available: ${names.join(", ")}`; + return z.array(z.enum(enumNames)).optional().describe(description); + }, + + normalize>(node: T): T { + const result: Record = { ...node }; + for (const [key, value] of Object.entries(result)) { + if ( + value && + typeof value === "object" && + Array.isArray((value as { edges?: unknown[] }).edges) + ) { + result[key] = edgesToNodes(value as never); + } + } + return result as T; + }, + }; +} + +/** Shared zod parameter for the `countOnly` argument. */ +export function countOnlyParam() { + return z.boolean().optional().describe(COUNT_ONLY_GUIDANCE); +} + +/** + * Run a Shopify *Count query (e.g. "productsCount", "ordersCount") and return { count }. + * Backs the `countOnly` mode of the list tools. + */ +export async function fetchCount( + client: GraphQLClient, + countField: string, + query?: string, +): Promise<{ count: number }> { + const countQuery = ` + query Count($query: String) { + ${countField}(query: $query) { count } + } + `; + const data = (await client.request(countQuery, { query })) as Record< + string, + { count: number } + >; + return { count: data[countField].count }; +} diff --git a/src/tools/createDraftOrder.ts b/src/tools/createDraftOrder.ts index a2ad0ea9..8b1e13bc 100644 --- a/src/tools/createDraftOrder.ts +++ b/src/tools/createDraftOrder.ts @@ -25,7 +25,7 @@ const CreateDraftOrderInputSchema = z.object({ ) .min(1) .describe("Line items (max 499). Use variantId for existing products or title+price for custom items."), - customerId: z.string().optional().describe("Customer GID to associate with the draft order"), + customerId: z.string().optional().describe("Customer GID to associate with the draft order, e.g. gid://shopify/Customer/123"), email: z.string().optional().describe("Customer email"), phone: z.string().optional().describe("Customer phone"), note: z.string().optional().describe("Note for the draft order"), diff --git a/src/tools/createFulfillment.ts b/src/tools/createFulfillment.ts index 9671c180..abb27f75 100644 --- a/src/tools/createFulfillment.ts +++ b/src/tools/createFulfillment.ts @@ -7,7 +7,7 @@ const CreateFulfillmentInputSchema = z.object({ lineItemsByFulfillmentOrder: z .array( z.object({ - fulfillmentOrderId: z.string().describe("The fulfillment order GID"), + fulfillmentOrderId: z.string().describe("The fulfillment order GID. Use get-fulfillment-orders to look these up."), fulfillmentOrderLineItems: z .array( z.object({ @@ -29,7 +29,7 @@ const CreateFulfillmentInputSchema = z.object({ }) .optional() .describe("Tracking information for the shipment"), - notifyCustomer: z.boolean().default(false).describe("Whether to send shipping notification to customer"), + notifyCustomer: z.boolean().default(false).describe("Whether to send shipping notification to customer. IMPORTANT: Always confirm with the user before enabling — sends a real email/SMS to the customer."), }); type CreateFulfillmentInput = z.infer; @@ -39,7 +39,7 @@ let shopifyClient: GraphQLClient; const createFulfillment = { name: "create-fulfillment", description: - "Create a fulfillment (mark items as shipped) with optional tracking info and customer notification.", + "Create a fulfillment (mark items as shipped) with optional tracking info. IMPORTANT: Call get-fulfillment-orders first to obtain the fulfillmentOrderId and line item GIDs.", schema: CreateFulfillmentInputSchema, initialize(client: GraphQLClient) { diff --git a/src/tools/createRefund.ts b/src/tools/createRefund.ts index 13e615e9..dea40d6a 100644 --- a/src/tools/createRefund.ts +++ b/src/tools/createRefund.ts @@ -18,7 +18,7 @@ const CreateRefundInputSchema = z.object({ }) ) .optional() - .describe("Line items to refund"), + .describe("Line items to refund with quantities and restock type. If omitted, you must provide 'shipping' for a shipping-only refund. Omitting both refundLineItems and shipping will result in a zero-amount refund (note-only)."), shipping: z .object({ amount: z.string().optional().describe("Shipping refund amount"), @@ -27,7 +27,7 @@ const CreateRefundInputSchema = z.object({ .optional() .describe("Shipping cost refund"), note: z.string().optional().describe("Note attached to the refund"), - notify: z.boolean().optional().describe("Whether to send refund notification to customer"), + notify: z.boolean().optional().describe("Whether to send refund notification to customer. IMPORTANT: Always confirm with the user before enabling — sends a real email/SMS to the customer. Defaults to no notification if omitted."), currency: z.string().optional().describe("Currency code if different from shop currency (presentment currency)"), }); diff --git a/src/tools/deleteCustomer.ts b/src/tools/deleteCustomer.ts index 3fb0886e..81ab586e 100644 --- a/src/tools/deleteCustomer.ts +++ b/src/tools/deleteCustomer.ts @@ -5,7 +5,7 @@ import { checkUserErrors, handleToolError } from "../lib/toolUtils.js"; // Input schema for deleting a customer const DeleteCustomerInputSchema = z.object({ - id: z.string().regex(/^\d+$/, "Customer ID must be numeric") + id: z.string().regex(/^\d+$/, "Customer ID must be numeric").describe("Numeric customer ID (e.g. 7832529321). Do not pass a full GID.") }); type DeleteCustomerInput = z.infer; diff --git a/src/tools/deleteMetafields.ts b/src/tools/deleteMetafields.ts index 782dda5e..673bc024 100644 --- a/src/tools/deleteMetafields.ts +++ b/src/tools/deleteMetafields.ts @@ -7,7 +7,7 @@ const DeleteMetafieldsInputSchema = z.object({ metafields: z .array( z.object({ - ownerId: z.string().describe("GID of the resource that owns the metafield"), + ownerId: z.string().describe("GID of the resource that owns the metafield, e.g. gid://shopify/Product/123"), namespace: z.string().describe("Metafield namespace"), key: z.string().describe("Metafield key"), }) diff --git a/src/tools/getCollectionById.ts b/src/tools/getCollectionById.ts index 65fd946f..f848a7d6 100644 --- a/src/tools/getCollectionById.ts +++ b/src/tools/getCollectionById.ts @@ -2,6 +2,22 @@ import type { GraphQLClient } from "graphql-request"; import { gql } from "graphql-request"; import { z } from "zod"; import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; + +/** Selectable fields for collection-by-id */ +const collectionByIdProjection = defineProjection({ + id: "id", + title: "title", + handle: "handle", + descriptionHtml: "descriptionHtml", + sortOrder: "sortOrder", + templateSuffix: "templateSuffix", + updatedAt: "updatedAt", + productsCount: "productsCount { count }", + ruleSet: "ruleSet { appliedDisjunctively rules { column relation condition } }", + image: "image { url altText width height }", + seo: "seo { title description }", +}); const GetCollectionByIdInputSchema = z.object({ collectionId: z @@ -19,6 +35,10 @@ const GetCollectionByIdInputSchema = z.object({ .describe( "Number of products to include (default 25, max 100, 0 to skip products)", ), + fields: collectionByIdProjection.fieldsParam({ + noun: "collection", + extra: "Products are controlled separately via 'productsFirst'.", + }), }); type GetCollectionByIdInput = z.infer; @@ -27,7 +47,7 @@ let shopifyClient: GraphQLClient; const getCollectionById = { name: "get-collection-by-id", description: - "Get a single collection with full details including products (paginated), rules for smart collections, SEO, and image", + "Get a single collection with full details including products (paginated), rules for smart collections, SEO, and image. Supports field selection via 'fields' to reduce response size.", schema: GetCollectionByIdInputSchema, initialize(client: GraphQLClient) { @@ -40,7 +60,82 @@ const getCollectionById = { ? input.collectionId : `gid://shopify/Collection/${input.collectionId}`; const productsFirst = input.productsFirst ?? 25; + const { fields } = input; + + // When fields is set, use field selection for collection-level fields + if (fields) { + const fieldSelection = collectionByIdProjection.selection(fields); + + // Append products block separately if productsFirst > 0 + const productsBlock = productsFirst > 0 ? ` + products(first: $productsFirst) { + edges { + node { + id + title + handle + status + vendor + productType + totalInventory + featuredMedia { + preview { + image { + url + altText + } + } + } + priceRangeV2 { + minVariantPrice { + amount + currencyCode + } + maxVariantPrice { + amount + currencyCode + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + }` : ""; + + const query = ` + query GetCollectionById($id: ID!${productsFirst > 0 ? ", $productsFirst: Int!" : ""}) { + collection(id: $id) { + ${fieldSelection} + ${productsBlock} + } + } + `; + + const variables: Record = { id: collectionId }; + if (productsFirst > 0) { + variables.productsFirst = productsFirst; + } + + const data: any = await shopifyClient.request(query, variables); + + if (!data.collection) { + throw new Error(`Collection not found: ${collectionId}`); + } + + const result: any = { ...data.collection }; + if (result.products) { + result.products = { + items: edgesToNodes(result.products), + pageInfo: result.products.pageInfo, + }; + } + + return { collection: result }; + } + // Default: full query (backwards compatible) const query = gql` #graphql diff --git a/src/tools/getCollections.ts b/src/tools/getCollections.ts index 4a260a88..6393a7e8 100644 --- a/src/tools/getCollections.ts +++ b/src/tools/getCollections.ts @@ -1,22 +1,38 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; + +/** Selectable fields for collections */ +const collectionProjection = defineProjection({ + id: "id", + title: "title", + handle: "handle", + description: "description", + sortOrder: "sortOrder", + productsCount: "productsCount { count }", + templateSuffix: "templateSuffix", + updatedAt: "updatedAt", + ruleSet: "ruleSet { appliedDisjunctively rules { column relation condition } }", + image: "image { url altText }", + seo: "seo { title description }", +}); const GetCollectionsInputSchema = z.object({ first: z .number() .min(1) - .max(100) + .max(250) .default(25) .optional() - .describe("Number of collections to return (default 25, max 100)"), + .describe("Number of collections to return (default 25, max 250)"), query: z .string() .optional() .describe( "Search query to filter collections (e.g. 'title:Summer' or 'collection_type:smart')", ), + fields: collectionProjection.fieldsParam({ noun: "collection" }), }); type GetCollectionsInput = z.infer; @@ -25,7 +41,7 @@ let shopifyClient: GraphQLClient; const getCollections = { name: "get-collections", description: - "Query collections (manual & smart) with optional filtering. Returns title, handle, products count, sort order, and rules for smart collections.", + "Query collections (manual & smart) with optional filtering. Supports field selection via 'fields' to reduce response size. Returns title, handle, products count, sort order, and rules for smart collections.", schema: GetCollectionsInputSchema, initialize(client: GraphQLClient) { @@ -34,39 +50,12 @@ const getCollections = { execute: async (input: GetCollectionsInput) => { try { - const query = gql` - #graphql - + const query = ` query GetCollections($first: Int!, $query: String) { collections(first: $first, query: $query) { edges { node { - id - title - handle - description - sortOrder - productsCount { - count - } - templateSuffix - updatedAt - ruleSet { - appliedDisjunctively - rules { - column - relation - condition - } - } - image { - url - altText - } - seo { - title - description - } + ${collectionProjection.selection(input.fields)} } } pageInfo { diff --git a/src/tools/getCustomerById.ts b/src/tools/getCustomerById.ts index 6625d1b2..b789a49b 100644 --- a/src/tools/getCustomerById.ts +++ b/src/tools/getCustomerById.ts @@ -1,11 +1,31 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; + +/** Selectable fields for customer-by-id */ +const customerByIdProjection = defineProjection({ + id: "id", + firstName: "firstName", + lastName: "lastName", + email: "defaultEmailAddress { emailAddress }", + phone: "defaultPhoneNumber { phoneNumber }", + createdAt: "createdAt", + updatedAt: "updatedAt", + tags: "tags", + note: "note", + taxExempt: "taxExempt", + defaultAddress: "defaultAddress { address1 address2 city provinceCode zip country phone }", + addresses: "addressesV2(first: 10) { edges { node { address1 address2 city provinceCode zip country phone } } }", + amountSpent: "amountSpent { amount currencyCode }", + numberOfOrders: "numberOfOrders", + metafields: "metafields(first: 10) { edges { node { id namespace key value } } }", +}); // Input schema for getting a customer by ID const GetCustomerByIdInputSchema = z.object({ - id: z.string().regex(/^\d+$/, "Customer ID must be numeric") + id: z.string().regex(/^\d+$/, "Customer ID must be numeric").describe("Numeric customer ID (e.g. 7832529321). Do not pass a full GID."), + fields: customerByIdProjection.fieldsParam({ noun: "customer" }), }); type GetCustomerByIdInput = z.infer; @@ -15,7 +35,7 @@ let shopifyClient: GraphQLClient; const getCustomerById = { name: "get-customer-by-id", - description: "Get a single customer by ID", + description: "Get a single customer by ID. Supports field selection via 'fields' to reduce response size.", schema: GetCustomerByIdInputSchema, // Add initialize method to set up the GraphQL client @@ -25,67 +45,15 @@ const getCustomerById = { execute: async (input: GetCustomerByIdInput) => { try { - const { id } = input; + const { id, fields } = input; // Convert numeric ID to GID format const customerGid = `gid://shopify/Customer/${id}`; - const query = gql` - #graphql - + const query = ` query GetCustomerById($id: ID!) { customer(id: $id) { - id - firstName - lastName - defaultEmailAddress { - emailAddress - } - defaultPhoneNumber { - phoneNumber - } - createdAt - updatedAt - tags - note - taxExempt - defaultAddress { - address1 - address2 - city - provinceCode - zip - country - phone - } - addressesV2(first: 10) { - edges { - node { - address1 - address2 - city - provinceCode - zip - country - phone - } - } - } - amountSpent { - amount - currencyCode - } - numberOfOrders - metafields(first: 10) { - edges { - node { - id - namespace - key - value - } - } - } + ${customerByIdProjection.selection(fields)} } } `; @@ -104,7 +72,17 @@ const getCustomerById = { const customer = data.customer; - // Format metafields if they exist + // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) + if (fields) { + const result: any = customerByIdProjection.normalize(customer); + if (result.addressesV2) { + result.addresses = result.addressesV2; + delete result.addressesV2; + } + return { customer: result }; + } + + // Default: full formatting const metafields = customer.metafields ? edgesToNodes(customer.metafields) : []; diff --git a/src/tools/getCustomerOrders.ts b/src/tools/getCustomerOrders.ts index 95d84122..695930b1 100644 --- a/src/tools/getCustomerOrders.ts +++ b/src/tools/getCustomerOrders.ts @@ -1,13 +1,32 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes, type ShopifyConnection } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; import { formatOrderSummary } from "../lib/formatters.js"; +/** Selectable fields for orders */ +const orderProjection = defineProjection({ + id: "id", + name: "name", + createdAt: "createdAt", + financialStatus: "displayFinancialStatus", + fulfillmentStatus: "displayFulfillmentStatus", + totalPrice: "totalPriceSet { shopMoney { amount currencyCode } }", + subtotalPrice: "subtotalPriceSet { shopMoney { amount currencyCode } }", + shippingPrice: "totalShippingPriceSet { shopMoney { amount currencyCode } }", + tax: "totalTaxSet { shopMoney { amount currencyCode } }", + customer: "customer { id firstName lastName defaultEmailAddress { emailAddress } }", + lineItems: "lineItems(first: 5) { edges { node { id title quantity originalTotalSet { shopMoney { amount currencyCode } } variant { id title sku } } } }", + tags: "tags", + note: "note", +}); + // Input schema for getting customer orders const GetCustomerOrdersInputSchema = z.object({ - customerId: z.string().regex(/^\d+$/, "Customer ID must be numeric"), - limit: z.number().default(10), + customerId: z.string().regex(/^\d+$/, "Customer ID must be numeric").describe("Numeric customer ID (e.g. 7832529321). Do not pass a full GID."), + limit: z.number().min(1).max(250).default(10) + .describe("Number of orders to return (default 10, max 250)"), + countOnly: countOnlyParam(), after: z.string().optional().describe("Cursor for forward pagination"), before: z.string().optional().describe("Cursor for backward pagination"), sortKey: z.enum([ @@ -15,7 +34,8 @@ const GetCustomerOrdersInputSchema = z.object({ "FULFILLMENT_STATUS", "UPDATED_AT", "CUSTOMER_NAME", "PROCESSED_AT", "ID", "RELEVANCE" ]).optional().describe("Sort key for orders"), - reverse: z.boolean().optional().describe("Reverse the sort order") + reverse: z.boolean().optional().describe("Reverse the sort order"), + fields: orderProjection.fieldsParam({ noun: "order" }), }); type GetCustomerOrdersInput = z.infer; @@ -25,7 +45,7 @@ let shopifyClient: GraphQLClient; const getCustomerOrders = { name: "get-customer-orders", - description: "Get orders for a specific customer", + description: "Get orders for a specific customer. Supports field selection via 'fields' to reduce response size.", schema: GetCustomerOrdersInputSchema, // Add initialize method to set up the GraphQL client @@ -35,75 +55,19 @@ const getCustomerOrders = { execute: async (input: GetCustomerOrdersInput) => { try { - const { customerId, limit, after, before, sortKey, reverse } = input; + const { customerId, limit, after, before, sortKey, reverse, fields, countOnly } = input; - // Query to get orders for a specific customer - const query = gql` - #graphql + // Count-only mode: return just the count + if (countOnly) { + return fetchCount(shopifyClient, "ordersCount", `customer_id:${customerId}`); + } + const query = ` query GetCustomerOrders($query: String!, $first: Int!, $after: String, $before: String, $sortKey: OrderSortKeys, $reverse: Boolean) { orders(query: $query, first: $first, after: $after, before: $before, sortKey: $sortKey, reverse: $reverse) { edges { node { - id - name - createdAt - displayFinancialStatus - displayFulfillmentStatus - totalPriceSet { - shopMoney { - amount - currencyCode - } - } - subtotalPriceSet { - shopMoney { - amount - currencyCode - } - } - totalShippingPriceSet { - shopMoney { - amount - currencyCode - } - } - totalTaxSet { - shopMoney { - amount - currencyCode - } - } - customer { - id - firstName - lastName - defaultEmailAddress { - emailAddress - } - } - lineItems(first: 5) { - edges { - node { - id - title - quantity - originalTotalSet { - shopMoney { - amount - currencyCode - } - } - variant { - id - title - sku - } - } - } - } - tags - note + ${orderProjection.selection(fields)} } } pageInfo { @@ -130,7 +94,18 @@ const getCustomerOrders = { orders: ShopifyConnection; }; - // Extract and format order data + // When custom fields are specified, return raw nodes with connection sub-fields flattened + if (fields) { + const orders = edgesToNodes(data.orders).map((order: any) => { + const result: any = orderProjection.normalize(order); + return result; + }); + return { + orders, + pageInfo: data.orders.pageInfo + }; + } + const orders = edgesToNodes(data.orders).map(formatOrderSummary); return { diff --git a/src/tools/getCustomers.ts b/src/tools/getCustomers.ts index b3c382c0..e350f20b 100644 --- a/src/tools/getCustomers.ts +++ b/src/tools/getCustomers.ts @@ -1,19 +1,38 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; + +/** Selectable fields for customers */ +const customerProjection = defineProjection({ + id: "id", + firstName: "firstName", + lastName: "lastName", + email: "defaultEmailAddress { emailAddress }", + phone: "defaultPhoneNumber { phoneNumber }", + createdAt: "createdAt", + updatedAt: "updatedAt", + tags: "tags", + defaultAddress: "defaultAddress { address1 address2 city provinceCode zip country phone }", + addresses: "addressesV2(first: 10) { edges { node { address1 address2 city provinceCode zip country phone } } }", + amountSpent: "amountSpent { amount currencyCode }", + numberOfOrders: "numberOfOrders", +}); // Input schema for getCustomers const GetCustomersInputSchema = z.object({ searchQuery: z.string().optional().describe("Freetext search or Shopify query syntax (e.g. 'country:US tag:vip orders_count:>5')"), - limit: z.number().default(10), + limit: z.number().min(1).max(250).default(10) + .describe("Number of customers to return (default 10, max 250)"), after: z.string().optional().describe("Cursor for forward pagination"), before: z.string().optional().describe("Cursor for backward pagination"), sortKey: z.enum([ "CREATED_AT", "ID", "LAST_UPDATE", "LOCATION", "NAME", "ORDERS_COUNT", "RELEVANCE", "TOTAL_SPENT", "UPDATED_AT" ]).optional().describe("Sort key for customers"), - reverse: z.boolean().optional().describe("Reverse the sort order") + reverse: z.boolean().optional().describe("Reverse the sort order"), + fields: customerProjection.fieldsParam({ noun: "customer" }), + countOnly: countOnlyParam(), }); type GetCustomersInput = z.infer; @@ -23,7 +42,7 @@ let shopifyClient: GraphQLClient; const getCustomers = { name: "get-customers", - description: "Get customers or search by name/email", + description: "Get customers or search by name/email. Supports field selection via 'fields' and 'countOnly' to get just the count.", schema: GetCustomersInputSchema, // Add initialize method to set up the GraphQL client @@ -33,54 +52,19 @@ const getCustomers = { execute: async (input: GetCustomersInput) => { try { - const { searchQuery, limit, after, before, sortKey, reverse } = input; + const { searchQuery, limit, after, before, sortKey, reverse, fields, countOnly } = input; - const query = gql` - #graphql + // Count-only mode: return just the count + if (countOnly) { + return fetchCount(shopifyClient, "customersCount", searchQuery); + } + const query = ` query GetCustomers($first: Int!, $query: String, $after: String, $before: String, $sortKey: CustomerSortKeys, $reverse: Boolean) { customers(first: $first, query: $query, after: $after, before: $before, sortKey: $sortKey, reverse: $reverse) { edges { node { - id - firstName - lastName - defaultEmailAddress { - emailAddress - } - defaultPhoneNumber { - phoneNumber - } - createdAt - updatedAt - tags - defaultAddress { - address1 - address2 - city - provinceCode - zip - country - phone - } - addressesV2(first: 10) { - edges { - node { - address1 - address2 - city - provinceCode - zip - country - phone - } - } - } - amountSpent { - amount - currencyCode - } - numberOfOrders + ${customerProjection.selection(fields)} } } pageInfo { @@ -106,7 +90,31 @@ const getCustomers = { customers: any; }; - // Extract and format customer data + // When custom fields are specified, return nodes with connection sub-fields flattened + if (fields) { + const customers = edgesToNodes(data.customers).map((customer: any) => { + const result: any = customerProjection.normalize(customer); + if (result.addressesV2) { + result.addresses = result.addressesV2; + delete result.addressesV2; + } + if (result.defaultEmailAddress) { + result.email = result.defaultEmailAddress.emailAddress; + delete result.defaultEmailAddress; + } + if (result.defaultPhoneNumber) { + result.phone = result.defaultPhoneNumber.phoneNumber; + delete result.defaultPhoneNumber; + } + return result; + }); + return { + customers, + pageInfo: data.customers.pageInfo + }; + } + + // Default: full formatting const customers = data.customers.edges.map((edge: any) => { const customer = edge.node; diff --git a/src/tools/getMetafields.ts b/src/tools/getMetafields.ts index 224c9917..15270486 100644 --- a/src/tools/getMetafields.ts +++ b/src/tools/getMetafields.ts @@ -1,11 +1,19 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError } from "../lib/toolUtils.js"; const GetMetafieldsInputSchema = z.object({ ownerId: z.string().describe("GID of the resource (product, order, customer, variant, collection, etc.)"), - namespace: z.string().optional().describe("Filter metafields by namespace"), + namespace: z.string().optional().describe("Filter metafields by namespace. Ignored when 'keys' is provided."), + keys: z + .array(z.string().regex(/^[^.]+\.[^.]+$/, "Each key must be in namespace.key format (e.g. 'custom.color')")) + .optional() + .describe( + "IMPORTANT: Always specify this when you only need specific metafields. " + + "Format: namespace.key (e.g. ['custom.color', 'custom.size']). " + + "Returns only matching metafields instead of all, significantly reducing response size. " + + "When provided, the 'namespace' parameter is ignored.", + ), first: z.number().default(25).describe("Number of metafields to return (max 50)"), after: z.string().optional().describe("Cursor for pagination"), }); @@ -17,7 +25,8 @@ let shopifyClient: GraphQLClient; const getMetafields = { name: "get-metafields", description: - "Get metafields for any Shopify resource (products, orders, customers, variants, collections, etc.). Uses the node query with HasMetafields interface.", + "Get metafields for any Shopify resource (products, orders, customers, variants, collections, etc.). " + + "Supports 'keys' filter to fetch only specific metafields by namespace.key.", schema: GetMetafieldsInputSchema, initialize(client: GraphQLClient) { @@ -26,39 +35,72 @@ const getMetafields = { execute: async (input: GetMetafieldsInput) => { try { - const query = gql` - #graphql + // When keys are provided, use the keys parameter (namespace is ignored) + const useKeys = input.keys && input.keys.length > 0; - query GetMetafields($ownerId: ID!, $first: Int!, $namespace: String, $after: String) { - node(id: $ownerId) { - ... on HasMetafields { - metafields(first: $first, namespace: $namespace, after: $after) { - edges { - node { - id - namespace - key - value - type - updatedAt + const query = useKeys + ? ` + query GetMetafields($ownerId: ID!, $first: Int!, $keys: [String!]!, $after: String) { + node(id: $ownerId) { + ... on HasMetafields { + metafields(first: $first, keys: $keys, after: $after) { + edges { + node { + id + namespace + key + value + type + updatedAt + } + } + pageInfo { + hasNextPage + endCursor } } - pageInfo { - hasNextPage - endCursor + } + } + } + ` + : ` + query GetMetafields($ownerId: ID!, $first: Int!, $namespace: String, $after: String) { + node(id: $ownerId) { + ... on HasMetafields { + metafields(first: $first, namespace: $namespace, after: $after) { + edges { + node { + id + namespace + key + value + type + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } } } } } - } - `; + `; - const data = (await shopifyClient.request(query, { + const variables: Record = { ownerId: input.ownerId, first: input.first, - ...(input.namespace && { namespace: input.namespace }), ...(input.after && { after: input.after }), - })) as { + }; + + if (useKeys) { + variables.keys = input.keys; + } else if (input.namespace) { + variables.namespace = input.namespace; + } + + const data = (await shopifyClient.request(query, variables)) as { node: { metafields?: { edges: Array<{ node: any }>; diff --git a/src/tools/getOrderById.ts b/src/tools/getOrderById.ts index 9d9c4b32..5efb3294 100644 --- a/src/tools/getOrderById.ts +++ b/src/tools/getOrderById.ts @@ -2,11 +2,46 @@ import type { GraphQLClient } from "graphql-request"; import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; import { formatLineItems, formatOrderSummary } from "../lib/formatters.js"; +/** Selectable fields for order-by-id */ +const orderByIdProjection = defineProjection({ + id: "id", + name: "name", + createdAt: "createdAt", + financialStatus: "displayFinancialStatus", + fulfillmentStatus: "displayFulfillmentStatus", + totalPrice: "totalPriceSet { shopMoney { amount currencyCode } }", + subtotalPrice: "subtotalPriceSet { shopMoney { amount currencyCode } }", + shippingPrice: "totalShippingPriceSet { shopMoney { amount currencyCode } }", + tax: "totalTaxSet { shopMoney { amount currencyCode } }", + currentTotalPrice: "currentTotalPriceSet { shopMoney { amount currencyCode } }", + customer: "customer { id firstName lastName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } }", + shippingAddress: "shippingAddress { address1 address2 city provinceCode zip country phone }", + billingAddress: "billingAddress { address1 address2 city provinceCode zip country company phone firstName lastName }", + lineItems: "lineItems(first: 20) { edges { node { id title quantity originalTotalSet { shopMoney { amount currencyCode } } variant { id title sku } } } }", + tags: "tags", + note: "note", + cancelReason: "cancelReason", + cancelledAt: "cancelledAt", + updatedAt: "updatedAt", + returnStatus: "returnStatus", + processedAt: "processedAt", + poNumber: "poNumber", + discountCodes: "discountCodes", + metafields: "metafields(first: 20) { edges { node { id namespace key value type } } }", +}); + // Input schema for getOrderById const GetOrderByIdInputSchema = z.object({ - orderId: z.string().min(1) + orderId: z + .string() + .min(1) + .describe( + "Accepts order numbers (e.g. 77713), numeric IDs, or full GIDs (gid://shopify/Order/...)", + ), + fields: orderByIdProjection.fieldsParam({ noun: "order" }), }); type GetOrderByIdInput = z.infer; @@ -16,7 +51,7 @@ let shopifyClient: GraphQLClient; const getOrderById = { name: "get-order-by-id", - description: "Get a specific order by ID", + description: "Get a specific order by ID. Supports field selection via 'fields' to reduce response size.", schema: GetOrderByIdInputSchema, // Add initialize method to set up the GraphQL client @@ -26,7 +61,7 @@ const getOrderById = { execute: async (input: GetOrderByIdInput) => { try { - const { orderId } = input; + const { orderId, fields } = input; // Smart lookup: detect format and resolve to GID let resolvedId: string; @@ -39,8 +74,6 @@ const getOrderById = { // Short number or #number — treat as order name, query by name const orderName = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; const nameQuery = gql` - #graphql - query FindOrderByName($query: String!) { orders(first: 1, query: $query) { edges { @@ -67,118 +100,10 @@ const getOrderById = { resolvedId = trimmed; } - const query = gql` - #graphql - + const query = ` query GetOrderById($id: ID!) { order(id: $id) { - id - name - createdAt - displayFinancialStatus - displayFulfillmentStatus - totalPriceSet { - shopMoney { - amount - currencyCode - } - } - subtotalPriceSet { - shopMoney { - amount - currencyCode - } - } - totalShippingPriceSet { - shopMoney { - amount - currencyCode - } - } - totalTaxSet { - shopMoney { - amount - currencyCode - } - } - customer { - id - firstName - lastName - defaultEmailAddress { - emailAddress - } - defaultPhoneNumber { - phoneNumber - } - } - shippingAddress { - address1 - address2 - city - provinceCode - zip - country - phone - } - lineItems(first: 20) { - edges { - node { - id - title - quantity - originalTotalSet { - shopMoney { - amount - currencyCode - } - } - variant { - id - title - sku - } - } - } - } - tags - note - billingAddress { - address1 - address2 - city - provinceCode - zip - country - company - phone - firstName - lastName - } - cancelReason - cancelledAt - updatedAt - returnStatus - processedAt - poNumber - discountCodes - currentTotalPriceSet { - shopMoney { - amount - currencyCode - } - } - metafields(first: 20) { - edges { - node { - id - namespace - key - value - type - } - } - } + ${orderByIdProjection.selection(fields)} } } `; @@ -195,9 +120,15 @@ const getOrderById = { throw new Error(`Order with ID ${orderId} not found`); } - // Extract and format order data const order = data.order; + // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) + if (fields) { + const result: any = orderByIdProjection.normalize(order); + return { order: result }; + } + + // Default: full formatting const base = formatOrderSummary(order); const formattedOrder = { ...base, diff --git a/src/tools/getOrders.ts b/src/tools/getOrders.ts index d4727dd9..07e9af74 100644 --- a/src/tools/getOrders.ts +++ b/src/tools/getOrders.ts @@ -1,13 +1,32 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes, type ShopifyConnection } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; import { formatOrderSummary } from "../lib/formatters.js"; +/** Selectable fields for orders */ +const orderProjection = defineProjection({ + id: "id", + name: "name", + createdAt: "createdAt", + financialStatus: "displayFinancialStatus", + fulfillmentStatus: "displayFulfillmentStatus", + totalPrice: "totalPriceSet { shopMoney { amount currencyCode } }", + subtotalPrice: "subtotalPriceSet { shopMoney { amount currencyCode } }", + shippingPrice: "totalShippingPriceSet { shopMoney { amount currencyCode } }", + tax: "totalTaxSet { shopMoney { amount currencyCode } }", + customer: "customer { id firstName lastName defaultEmailAddress { emailAddress } }", + shippingAddress: "shippingAddress { address1 address2 city provinceCode zip country phone }", + lineItems: "lineItems(first: 10) { edges { node { id title quantity originalTotalSet { shopMoney { amount currencyCode } } variant { id title sku } } } }", + tags: "tags", + note: "note", +}); + // Input schema for getOrders const GetOrdersInputSchema = z.object({ status: z.enum(["any", "open", "closed", "cancelled"]).default("any"), - limit: z.number().default(10), + limit: z.number().min(1).max(250).default(10) + .describe("Number of orders to return (default 10, max 250)"), after: z.string().optional().describe("Cursor for forward pagination"), before: z.string().optional().describe("Cursor for backward pagination"), sortKey: z.enum([ @@ -16,7 +35,9 @@ const GetOrdersInputSchema = z.object({ "ID", "RELEVANCE" ]).optional().describe("Sort key for orders"), reverse: z.boolean().optional().describe("Reverse the sort order"), - query: z.string().optional().describe("Raw query string for advanced filtering (e.g. 'financial_status:paid fulfillment_status:shipped')") + query: z.string().optional().describe("Raw query string for advanced filtering (e.g. 'financial_status:paid fulfillment_status:shipped')"), + fields: orderProjection.fieldsParam({ noun: "order" }), + countOnly: countOnlyParam(), }); type GetOrdersInput = z.infer; @@ -26,7 +47,7 @@ let shopifyClient: GraphQLClient; const getOrders = { name: "get-orders", - description: "Get orders with optional filtering by status", + description: "Get orders with optional filtering by status. Supports field selection via 'fields' and 'countOnly' to get just the count.", schema: GetOrdersInputSchema, // Add initialize method to set up the GraphQL client @@ -36,7 +57,7 @@ const getOrders = { execute: async (input: GetOrdersInput) => { try { - const { status, limit, after, before, sortKey, reverse, query: rawQuery } = input; + const { status, limit, after, before, sortKey, reverse, query: rawQuery, fields, countOnly } = input; // Build query filters const queryParts: string[] = []; @@ -48,81 +69,17 @@ const getOrders = { } const queryFilter = queryParts.join(" ") || undefined; - const query = gql` - #graphql + // Count-only mode: return just the count + if (countOnly) { + return fetchCount(shopifyClient, "ordersCount", queryFilter); + } + const query = ` query GetOrders($first: Int!, $query: String, $after: String, $before: String, $sortKey: OrderSortKeys, $reverse: Boolean) { orders(first: $first, query: $query, after: $after, before: $before, sortKey: $sortKey, reverse: $reverse) { edges { node { - id - name - createdAt - displayFinancialStatus - displayFulfillmentStatus - totalPriceSet { - shopMoney { - amount - currencyCode - } - } - subtotalPriceSet { - shopMoney { - amount - currencyCode - } - } - totalShippingPriceSet { - shopMoney { - amount - currencyCode - } - } - totalTaxSet { - shopMoney { - amount - currencyCode - } - } - customer { - id - firstName - lastName - defaultEmailAddress { - emailAddress - } - } - shippingAddress { - address1 - address2 - city - provinceCode - zip - country - phone - } - lineItems(first: 10) { - edges { - node { - id - title - quantity - originalTotalSet { - shopMoney { - amount - currencyCode - } - } - variant { - id - title - sku - } - } - } - } - tags - note + ${orderProjection.selection(fields)} } } pageInfo { @@ -148,7 +105,18 @@ const getOrders = { orders: ShopifyConnection; }; - // Extract and format order data + // When custom fields are specified, return raw nodes with connection sub-fields flattened + if (fields) { + const orders = edgesToNodes(data.orders).map((order: any) => { + const result: any = orderProjection.normalize(order); + return result; + }); + return { + orders, + pageInfo: data.orders.pageInfo + }; + } + const orders = edgesToNodes(data.orders).map(formatOrderSummary); return { diff --git a/src/tools/getProductById.ts b/src/tools/getProductById.ts index a55fac1d..e98aed87 100644 --- a/src/tools/getProductById.ts +++ b/src/tools/getProductById.ts @@ -1,11 +1,34 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; + +/** Selectable fields for product-by-id */ +const productByIdProjection = defineProjection({ + id: "id", + title: "title", + description: "description", + descriptionHtml: "descriptionHtml", + handle: "handle", + status: "status", + createdAt: "createdAt", + updatedAt: "updatedAt", + totalInventory: "totalInventory", + priceRange: "priceRangeV2 { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } }", + media: "media(first: 5) { edges { node { ... on MediaImage { id image { url altText width height } } } } }", + variants: "variants(first: 20) { edges { node { id title price inventoryQuantity sku selectedOptions { name value } } } }", + collections: "collections(first: 5) { edges { node { id title } } }", + seo: "seo { title description }", + options: "options { id name position optionValues { id name } }", + tags: "tags", + vendor: "vendor", + productType: "productType", +}); // Input schema for getProductById const GetProductByIdInputSchema = z.object({ - productId: z.string().min(1) + productId: z.string().min(1).describe("The product ID (e.g. gid://shopify/Product/123 or just 123)"), + fields: productByIdProjection.fieldsParam({ noun: "product" }), }); type GetProductByIdInput = z.infer; @@ -15,7 +38,7 @@ let shopifyClient: GraphQLClient; const getProductById = { name: "get-product-by-id", - description: "Get a specific product by ID", + description: "Get a specific product by ID. Supports field selection via 'fields' to reduce response size.", schema: GetProductByIdInputSchema, // Add initialize method to set up the GraphQL client @@ -25,86 +48,12 @@ const getProductById = { execute: async (input: GetProductByIdInput) => { try { - const { productId } = input; - - const query = gql` - #graphql + const { productId, fields } = input; + const query = ` query GetProductById($id: ID!) { product(id: $id) { - id - title - description - handle - status - createdAt - updatedAt - totalInventory - priceRangeV2 { - minVariantPrice { - amount - currencyCode - } - maxVariantPrice { - amount - currencyCode - } - } - media(first: 5) { - edges { - node { - ... on MediaImage { - id - image { - url - altText - width - height - } - } - } - } - } - variants(first: 20) { - edges { - node { - id - title - price - inventoryQuantity - sku - selectedOptions { - name - value - } - } - } - } - collections(first: 5) { - edges { - node { - id - title - } - } - } - tags - vendor - productType - descriptionHtml - seo { - title - description - } - options { - id - name - position - optionValues { - id - name - } - } + ${productByIdProjection.selection(fields)} } } `; @@ -121,9 +70,15 @@ const getProductById = { throw new Error(`Product with ID ${productId} not found`); } - // Format product data const product = data.product; + // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) + if (fields) { + const result: any = productByIdProjection.normalize(product); + return { product: result }; + } + + // Default: full formatting // Format variants const variants = product.variants.edges.map((variantEdge: any) => ({ id: variantEdge.node.id, diff --git a/src/tools/getProductVariantsDetailed.ts b/src/tools/getProductVariantsDetailed.ts index b7206d86..bd8551f0 100644 --- a/src/tools/getProductVariantsDetailed.ts +++ b/src/tools/getProductVariantsDetailed.ts @@ -1,7 +1,28 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; + +/** Selectable fields for variant nodes */ +const variantProjection = defineProjection({ + id: "id", + title: "title", + displayName: "displayName", + sku: "sku", + barcode: "barcode", + price: "price", + compareAtPrice: "compareAtPrice", + taxable: "taxable", + availableForSale: "availableForSale", + inventoryQuantity: "inventoryQuantity", + position: "position", + createdAt: "createdAt", + updatedAt: "updatedAt", + selectedOptions: "selectedOptions { name value }", + media: "media(first: 1) { edges { node { ... on MediaImage { image { url altText } } } } }", + inventoryItem: "inventoryItem { id tracked requiresShipping unitCost { amount currencyCode } measurement { weight { unit value } } }", + metafields: "metafields(first: 25) { edges { node { namespace key value type } } }", +}); const GetProductVariantsDetailedInputSchema = z.object({ productId: z @@ -17,6 +38,7 @@ const GetProductVariantsDetailedInputSchema = z.object({ .default(50) .optional() .describe("Number of variants to return (default 50, max 100)"), + fields: variantProjection.fieldsParam({ noun: "variant" }), }); type GetProductVariantsDetailedInput = z.infer< typeof GetProductVariantsDetailedInputSchema @@ -27,7 +49,7 @@ let shopifyClient: GraphQLClient; const getProductVariantsDetailed = { name: "get-product-variants-detailed", description: - "Get all variant fields for a product: pricing, inventory, barcode, weight, tax code, selected options, metafields, and image", + "Get all variant fields for a product: pricing, inventory, barcode, weight, tax code, selected options, metafields, and image. Supports field selection via 'fields' to reduce response size.", schema: GetProductVariantsDetailedInputSchema, initialize(client: GraphQLClient) { @@ -40,9 +62,7 @@ const getProductVariantsDetailed = { ? input.productId : `gid://shopify/Product/${input.productId}`; - const query = gql` - #graphql - + const query = ` query GetProductVariantsDetailed($id: ID!, $first: Int!) { product(id: $id) { id @@ -50,60 +70,7 @@ const getProductVariantsDetailed = { variants(first: $first) { edges { node { - id - title - displayName - sku - barcode - price - compareAtPrice - taxable - availableForSale - inventoryQuantity - position - createdAt - updatedAt - selectedOptions { - name - value - } - media(first: 1) { - edges { - node { - ... on MediaImage { - image { - url - altText - } - } - } - } - } - inventoryItem { - id - tracked - requiresShipping - unitCost { - amount - currencyCode - } - measurement { - weight { - unit - value - } - } - } - metafields(first: 25) { - edges { - node { - namespace - key - value - type - } - } - } + ${variantProjection.selection(input.fields)} } } pageInfo { @@ -124,6 +91,31 @@ const getProductVariantsDetailed = { throw new Error(`Product not found: ${productId}`); } + // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) + if (input.fields) { + const variants = edgesToNodes(data.product.variants).map( + (variant: any) => { + const result: any = variantProjection.normalize(variant); + if (result.media) { + const mediaNodes = result.media; + const firstImage = mediaNodes.find((m: any) => m.image) as any; + result.image = firstImage?.image ?? null; + delete result.media; + } + return result; + }, + ); + + return { + productId: data.product.id, + productTitle: data.product.title, + variantsCount: variants.length, + variants, + pageInfo: data.product.variants.pageInfo, + }; + } + + // Default: full formatting const variants = edgesToNodes(data.product.variants).map( (variant: any) => { const mediaNodes = variant.media diff --git a/src/tools/getProducts.ts b/src/tools/getProducts.ts index 9a18157f..9f0946df 100644 --- a/src/tools/getProducts.ts +++ b/src/tools/getProducts.ts @@ -1,12 +1,28 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; import { handleToolError } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; + +/** Selectable fields for products */ +const productProjection = defineProjection({ + id: "id", + title: "title", + description: "description", + handle: "handle", + status: "status", + createdAt: "createdAt", + updatedAt: "updatedAt", + totalInventory: "totalInventory", + priceRange: "priceRangeV2 { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } }", + media: "media(first: 1) { edges { node { ... on MediaImage { id image { url altText } } } } }", + variants: "variants(first: 5) { edges { node { id title price inventoryQuantity sku } } }", +}); // Input schema for getProducts const GetProductsInputSchema = z.object({ searchTitle: z.string().optional().describe("Search by title (convenience filter, wraps in title:*...*). Use 'query' for advanced filtering."), - limit: z.number().default(10), + limit: z.number().min(1).max(250).default(10) + .describe("Number of products to return (default 10, max 250)"), after: z.string().optional().describe("Cursor for forward pagination"), before: z.string().optional().describe("Cursor for backward pagination"), sortKey: z.enum([ @@ -14,7 +30,9 @@ const GetProductsInputSchema = z.object({ "PUBLISHED_AT", "RELEVANCE", "TITLE", "UPDATED_AT", "VENDOR" ]).optional().describe("Sort key for products"), reverse: z.boolean().optional().describe("Reverse the sort order"), - query: z.string().optional().describe("Raw query string for advanced filtering (e.g. 'status:active vendor:Nike tag:sale')") + query: z.string().optional().describe("Raw query string for advanced filtering (e.g. 'status:active vendor:Nike tag:sale')"), + fields: productProjection.fieldsParam({ noun: "product" }), + countOnly: countOnlyParam(), }); type GetProductsInput = z.infer; @@ -24,7 +42,7 @@ let shopifyClient: GraphQLClient; const getProducts = { name: "get-products", - description: "Get all products or search by title", + description: "Get all products or search by title. Supports field selection via 'fields' and 'countOnly' to get just the count.", schema: GetProductsInputSchema, // Add initialize method to set up the GraphQL client @@ -34,7 +52,7 @@ const getProducts = { execute: async (input: GetProductsInput) => { try { - const { searchTitle, limit, after, before, sortKey, reverse, query: rawQuery } = input; + const { searchTitle, limit, after, before, sortKey, reverse, query: rawQuery, fields, countOnly } = input; // Build query string from convenience filters and raw query const queryParts: string[] = []; @@ -46,55 +64,17 @@ const getProducts = { } const queryFilter = queryParts.join(" ") || undefined; - const query = gql` - #graphql + // Count-only mode: return just the count + if (countOnly) { + return fetchCount(shopifyClient, "productsCount", queryFilter); + } + const query = ` query GetProducts($first: Int!, $query: String, $after: String, $before: String, $sortKey: ProductSortKeys, $reverse: Boolean) { products(first: $first, query: $query, after: $after, before: $before, sortKey: $sortKey, reverse: $reverse) { edges { node { - id - title - description - handle - status - createdAt - updatedAt - totalInventory - priceRangeV2 { - minVariantPrice { - amount - currencyCode - } - maxVariantPrice { - amount - currencyCode - } - } - media(first: 1) { - edges { - node { - ... on MediaImage { - id - image { - url - altText - } - } - } - } - } - variants(first: 5) { - edges { - node { - id - title - price - inventoryQuantity - sku - } - } - } + ${productProjection.selection(fields)} } } pageInfo { @@ -120,7 +100,26 @@ const getProducts = { products: any; }; - // Extract and format product data + // When custom fields are specified, flatten connection fields to match default format + if (fields) { + const products = data.products.edges.map((edge: any) => { + const product: any = productProjection.normalize(edge.node); + if (product.priceRangeV2) { + product.priceRange = { + minPrice: product.priceRangeV2.minVariantPrice, + maxPrice: product.priceRangeV2.maxVariantPrice, + }; + delete product.priceRangeV2; + } + return product; + }); + return { + products, + pageInfo: data.products.pageInfo + }; + } + + // Default: full formatting const products = data.products.edges.map((edge: any) => { const product = edge.node; diff --git a/src/tools/manageTags.ts b/src/tools/manageTags.ts index 8edf41bc..c3d4b673 100644 --- a/src/tools/manageTags.ts +++ b/src/tools/manageTags.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { checkUserErrors, handleToolError } from "../lib/toolUtils.js"; const ManageTagsInputSchema = z.object({ - id: z.string().describe("GID of the resource (order, product, customer, draft order, or article)"), + id: z.string().describe("GID of the resource, e.g. gid://shopify/Product/123 or gid://shopify/Order/123"), tags: z.array(z.string()).min(1).describe("Tags to add or remove"), action: z.enum(["add", "remove"]).describe("Whether to add or remove the tags"), }); diff --git a/src/tools/manageTagsBulk.ts b/src/tools/manageTagsBulk.ts new file mode 100644 index 00000000..537ec2b8 --- /dev/null +++ b/src/tools/manageTagsBulk.ts @@ -0,0 +1,100 @@ +import type { GraphQLClient } from "graphql-request"; +import { gql } from "graphql-request"; +import { z } from "zod"; +import { checkUserErrors, handleToolError } from "../lib/toolUtils.js"; + +const ManageTagsBulkInputSchema = z.object({ + ids: z + .array(z.string()) + .min(1) + .max(100) + .describe("Array of resource GIDs (orders, products, customers, etc.) — up to 100 resources in a single call. For larger sets, call this tool multiple times."), + tags: z.array(z.string()).min(1).describe("Tags to add or remove"), + action: z.enum(["add", "remove"]).describe("Whether to add or remove the tags"), +}); + +type ManageTagsBulkInput = z.infer; + +let shopifyClient: GraphQLClient; + +const ADD_TAGS_MUTATION = gql` + mutation tagsAdd($id: ID!, $tags: [String!]!) { + tagsAdd(id: $id, tags: $tags) { + node { id } + userErrors { field message } + } + } +`; + +const REMOVE_TAGS_MUTATION = gql` + mutation tagsRemove($id: ID!, $tags: [String!]!) { + tagsRemove(id: $id, tags: $tags) { + node { id } + userErrors { field message } + } + } +`; + +const manageTagsBulk = { + name: "manage-tags-bulk", + description: + "Bulk add or remove tags on up to 100 resources in a single call. " + + "IMPORTANT: Prefer this over calling manage-tags repeatedly — it runs all mutations in parallel and returns per-resource results.", + schema: ManageTagsBulkInputSchema, + + initialize(client: GraphQLClient) { + shopifyClient = client; + }, + + execute: async (input: ManageTagsBulkInput) => { + try { + const mutation = input.action === "add" ? ADD_TAGS_MUTATION : REMOVE_TAGS_MUTATION; + const mutationKey = input.action === "add" ? "tagsAdd" : "tagsRemove"; + + const BATCH_SIZE = 20; + const results: Array< + | { id: string; status: "fulfilled" } + | { id: string; status: "rejected"; error: string } + > = []; + + for (let i = 0; i < input.ids.length; i += BATCH_SIZE) { + const chunk = input.ids.slice(i, i + BATCH_SIZE); + + const settled = await Promise.allSettled( + chunk.map(async (id) => { + const data = (await shopifyClient.request(mutation, { + id, + tags: input.tags, + })) as Record }>; + + checkUserErrors(data[mutationKey].userErrors, `${input.action} tags on ${id}`); + return { id, status: "fulfilled" as const }; + }), + ); + + for (let j = 0; j < settled.length; j++) { + const result = settled[j]; + if (result.status === "fulfilled") { + results.push(result.value); + } else { + results.push({ + id: chunk[j], + status: "rejected" as const, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + } + + return { + action: input.action, + tags: input.tags, + results, + }; + } catch (error) { + handleToolError(`bulk ${input.action} tags`, error); + } + }, +}; + +export { manageTagsBulk }; diff --git a/src/tools/mergeCustomers.ts b/src/tools/mergeCustomers.ts index 888afbdb..7c7721fe 100644 --- a/src/tools/mergeCustomers.ts +++ b/src/tools/mergeCustomers.ts @@ -4,15 +4,15 @@ import { z } from "zod"; import { checkUserErrors, handleToolError } from "../lib/toolUtils.js"; const MergeCustomersInputSchema = z.object({ - customerOneId: z.string().describe("GID of the first customer"), - customerTwoId: z.string().describe("GID of the second customer"), + customerOneId: z.string().describe("GID of the first customer, e.g. gid://shopify/Customer/123"), + customerTwoId: z.string().describe("GID of the second customer, e.g. gid://shopify/Customer/456"), overrideFields: z .object({ - customerIdOfFirstNameToKeep: z.string().optional().describe("Customer GID whose first name to keep"), - customerIdOfLastNameToKeep: z.string().optional().describe("Customer GID whose last name to keep"), - customerIdOfEmailToKeep: z.string().optional().describe("Customer GID whose email to keep"), - customerIdOfPhoneNumberToKeep: z.string().optional().describe("Customer GID whose phone to keep"), - customerIdOfDefaultAddressToKeep: z.string().optional().describe("Customer GID whose default address to keep"), + customerIdOfFirstNameToKeep: z.string().optional().describe("Customer GID whose first name to keep, e.g. gid://shopify/Customer/123"), + customerIdOfLastNameToKeep: z.string().optional().describe("Customer GID whose last name to keep, e.g. gid://shopify/Customer/123"), + customerIdOfEmailToKeep: z.string().optional().describe("Customer GID whose email to keep, e.g. gid://shopify/Customer/123"), + customerIdOfPhoneNumberToKeep: z.string().optional().describe("Customer GID whose phone to keep, e.g. gid://shopify/Customer/123"), + customerIdOfDefaultAddressToKeep: z.string().optional().describe("Customer GID whose default address to keep, e.g. gid://shopify/Customer/123"), note: z.string().optional().describe("Note to keep on the merged customer"), tags: z.array(z.string()).optional().describe("Tags to keep on the merged customer"), }) diff --git a/src/tools/orderCancel.ts b/src/tools/orderCancel.ts index 377dc01d..0b01f21f 100644 --- a/src/tools/orderCancel.ts +++ b/src/tools/orderCancel.ts @@ -7,9 +7,9 @@ const OrderCancelInputSchema = z.object({ orderId: z.string().describe("The order GID, e.g. gid://shopify/Order/123"), reason: z.enum(["CUSTOMER", "DECLINED", "FRAUD", "INVENTORY", "OTHER", "STAFF"]).describe("Reason for cancellation"), restock: z.boolean().describe("Whether to restock inventory"), - notifyCustomer: z.boolean().default(false).describe("Whether to notify the customer"), + notifyCustomer: z.boolean().default(false).describe("Whether to send cancellation notification to customer. IMPORTANT: Always confirm with the user before enabling — sends a real email/SMS to the customer."), staffNote: z.string().optional().describe("Internal note (not visible to customer)"), - refund: z.boolean().optional().describe("Whether to refund to the original payment method"), + refund: z.boolean().optional().describe("Whether to refund the full amount to the original payment method. For partial refunds on specific line items, use refund-create instead."), }); type OrderCancelInput = z.infer; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4fc5192f..bdd05384 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -39,6 +39,7 @@ import { deleteMetafields } from "./deleteMetafields.js"; // Convenience / cross-resource tools import { manageTags } from "./manageTags.js"; +import { manageTagsBulk } from "./manageTagsBulk.js"; import { setInventoryQuantities } from "./setInventoryQuantities.js"; // Configuration & discovery tools @@ -94,8 +95,9 @@ export const tools: ShopifyTool[] = [ getMetafields, setMetafields, deleteMetafields, - // Convenience (2) + // Convenience (3) manageTags, + manageTagsBulk, setInventoryQuantities, // Configuration & discovery (5) getShopInfo, diff --git a/src/tools/setInventoryQuantities.ts b/src/tools/setInventoryQuantities.ts index 73beeee7..b7ebb92a 100644 --- a/src/tools/setInventoryQuantities.ts +++ b/src/tools/setInventoryQuantities.ts @@ -4,7 +4,13 @@ import { z } from "zod"; import { checkUserErrors, handleToolError } from "../lib/toolUtils.js"; const SetInventoryQuantitiesInputSchema = z.object({ - reason: z.string().describe("Reason for the quantity change (e.g. 'correction', 'cycle_count_available', 'received')"), + reason: z.enum([ + "correction", "cycle_count_available", "damaged", + "movement_created", "movement_updated", "movement_received", "movement_canceled", + "other", "promotion", "quality_control", "received", + "reservation_created", "reservation_deleted", "reservation_updated", + "restock", "safety_stock", "shrinkage" + ]).describe("Reason for the quantity change"), name: z.enum(["available", "on_hand"]).describe("Which quantity to set: 'available' or 'on_hand'"), quantities: z .array( diff --git a/src/tools/setMetafields.ts b/src/tools/setMetafields.ts index f9bd7873..b4a5e43d 100644 --- a/src/tools/setMetafields.ts +++ b/src/tools/setMetafields.ts @@ -7,7 +7,7 @@ const SetMetafieldsInputSchema = z.object({ metafields: z .array( z.object({ - ownerId: z.string().describe("GID of the resource (product, order, customer, variant, etc.)"), + ownerId: z.string().describe("GID of the resource, e.g. gid://shopify/Product/123 or gid://shopify/Customer/123"), namespace: z.string().optional().describe("Metafield namespace. If omitted, app-reserved namespace is used."), key: z.string().describe("Unique identifier within its namespace (2-64 chars)"), value: z.string().describe("The value to set (always stored as string)"), diff --git a/src/tools/updateCustomer.ts b/src/tools/updateCustomer.ts index 93c118b0..799f558c 100644 --- a/src/tools/updateCustomer.ts +++ b/src/tools/updateCustomer.ts @@ -5,7 +5,7 @@ import { checkUserErrors, handleToolError } from "../lib/toolUtils.js"; // Input schema for updating a customer const UpdateCustomerInputSchema = z.object({ - id: z.string().regex(/^\d+$/, "Customer ID must be numeric"), + id: z.string().regex(/^\d+$/, "Customer ID must be numeric").describe("Numeric customer ID (e.g. 7832529321). Do not pass a full GID."), firstName: z.string().optional(), lastName: z.string().optional(), email: z.string().email().optional(), @@ -23,14 +23,18 @@ const UpdateCustomerInputSchema = z.object({ metafields: z .array( z.object({ - id: z.string().optional(), - namespace: z.string().optional(), - key: z.string().optional(), - value: z.string(), - type: z.string().optional() + id: z.string().optional().describe("Metafield GID to update an existing metafield. Omit to create/upsert by namespace+key."), + namespace: z.string().optional().describe("Metafield namespace (required when creating without id)"), + key: z.string().optional().describe("Metafield key (required when creating without id)"), + value: z.string().describe("The value to set"), + type: z.string().optional().describe("Metafield type (e.g. 'single_line_text_field'). Required when creating a new metafield without a definition.") }) ) .optional() + .describe( + "Metafields to create or update inline. Pass 'id' to update existing, or 'namespace'+'key' to upsert. " + + "For standalone metafield operations, prefer the set-metafields tool instead." + ) }); type UpdateCustomerInput = z.infer; diff --git a/src/tools/updateOrder.ts b/src/tools/updateOrder.ts index 078e8ce6..fd0484b4 100644 --- a/src/tools/updateOrder.ts +++ b/src/tools/updateOrder.ts @@ -9,7 +9,7 @@ let shopifyClient: GraphQLClient; // Input schema for updateOrder // Based on https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderupdate const UpdateOrderInputSchema = z.object({ - id: z.string().min(1), + id: z.string().min(1).describe("Order GID (e.g. gid://shopify/Order/123). Use get-order-by-id to look up by order number first."), tags: z.array(z.string()).optional(), email: z.string().email().optional(), note: z.string().optional(), @@ -24,14 +24,18 @@ const UpdateOrderInputSchema = z.object({ metafields: z .array( z.object({ - id: z.string().optional(), - namespace: z.string().optional(), - key: z.string().optional(), - value: z.string(), - type: z.string().optional() + id: z.string().optional().describe("Metafield GID to update an existing metafield. Omit to create/upsert by namespace+key."), + namespace: z.string().optional().describe("Metafield namespace (required when creating without id)"), + key: z.string().optional().describe("Metafield key (required when creating without id)"), + value: z.string().describe("The value to set"), + type: z.string().optional().describe("Metafield type (e.g. 'single_line_text_field'). Required when creating a new metafield without a definition.") }) ) - .optional(), + .optional() + .describe( + "Metafields to create or update inline. Pass 'id' to update existing, or 'namespace'+'key' to upsert. " + + "For standalone metafield operations, prefer the set-metafields tool instead." + ), phone: z.string().optional(), poNumber: z.string().optional(), shippingAddress: z diff --git a/src/tools/updateProduct.ts b/src/tools/updateProduct.ts index 3be59a1f..18386771 100644 --- a/src/tools/updateProduct.ts +++ b/src/tools/updateProduct.ts @@ -23,14 +23,18 @@ const UpdateProductInputSchema = z.object({ metafields: z .array( z.object({ - id: z.string().optional(), - namespace: z.string().optional(), - key: z.string().optional(), - value: z.string(), - type: z.string().optional(), + id: z.string().optional().describe("Metafield GID to update an existing metafield. Omit to create/upsert by namespace+key."), + namespace: z.string().optional().describe("Metafield namespace (required when creating without id)"), + key: z.string().optional().describe("Metafield key (required when creating without id)"), + value: z.string().describe("The value to set"), + type: z.string().optional().describe("Metafield type (e.g. 'single_line_text_field'). Required when creating a new metafield without a definition."), }) ) - .optional(), + .optional() + .describe( + "Metafields to create or update inline. Pass 'id' to update existing, or 'namespace'+'key' to upsert. " + + "For standalone metafield operations, prefer the set-metafields tool instead." + ), collectionsToJoin: z.array(z.string()).optional().describe("Collection GIDs to add the product to"), collectionsToLeave: z.array(z.string()).optional().describe("Collection GIDs to remove the product from"), redirectNewHandle: z.boolean().optional().describe("If true, old handle redirects to new handle"),