From 3873faafd6f5c8db62e1d225ee577dedda36fec3 Mon Sep 17 00:00:00 2001 From: Lucas Jahn Date: Sun, 15 Mar 2026 05:56:09 +0100 Subject: [PATCH 1/6] Add field selection and increase max page size for list tools List tools (orders, products, customers, collections) now accept an optional 'fields' parameter to select which fields the GraphQL query returns, reducing context pollution when only IDs or specific data is needed. Max page size raised from 10-100 to 250 (Shopify API limit). Affected tools: get-orders, get-products, get-customers, get-collections, get-customer-orders --- src/lib/toolUtils.ts | 16 +++++ src/tools/getCollections.ts | 65 +++++++++--------- src/tools/getCustomerOrders.ts | 110 ++++++++++++------------------ src/tools/getCustomers.ts | 93 +++++++++++++------------- src/tools/getOrders.ts | 119 ++++++++++++--------------------- src/tools/getProducts.ts | 95 +++++++++++++------------- 6 files changed, 223 insertions(+), 275 deletions(-) diff --git a/src/lib/toolUtils.ts b/src/lib/toolUtils.ts index b6efa4b3..edbd3fe5 100644 --- a/src/lib/toolUtils.ts +++ b/src/lib/toolUtils.ts @@ -77,6 +77,22 @@ export function edgesToNodes(connection: ShopifyConnection): T[] { return connection.edges.map((edge) => edge.node); } +/** + * Build a GraphQL field selection string from a field map and optional field list. + * Always includes 'id'. When fields is undefined, includes all available fields. + */ +export function buildFieldSelection( + fieldMap: Record, + fields?: string[], +): string { + const selected = fields ?? Object.keys(fieldMap); + const fieldSet = new Set(["id", ...selected]); + return [...fieldSet] + .map((f) => fieldMap[f]) + .filter(Boolean) + .join("\n "); +} + /** * Extract shopMoney from a Shopify MoneyBag (e.g. totalPriceSet.shopMoney). */ diff --git a/src/tools/getCollections.ts b/src/tools/getCollections.ts index e101a951..ccf77ca9 100644 --- a/src/tools/getCollections.ts +++ b/src/tools/getCollections.ts @@ -1,22 +1,46 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; -import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { edgesToNodes, handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for collections */ +const COLLECTION_FIELD_MAP: Record = { + 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 AVAILABLE_COLLECTION_FIELDS = Object.keys(COLLECTION_FIELD_MAP) as [string, ...string[]]; 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: z + .array(z.enum(AVAILABLE_COLLECTION_FIELDS)) + .optional() + .describe( + "Select which fields to return to reduce response size. " + + "When omitted, all fields are returned. Always includes 'id'. " + + `Available: ${AVAILABLE_COLLECTION_FIELDS.join(", ")}`, + ), }); type GetCollectionsInput = z.infer; @@ -25,7 +49,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,37 +58,14 @@ const getCollections = { execute: async (input: GetCollectionsInput) => { try { - const query = gql` + const fieldSelection = buildFieldSelection(COLLECTION_FIELD_MAP, input.fields); + + 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 - } + ${fieldSelection} } } pageInfo { diff --git a/src/tools/getCustomerOrders.ts b/src/tools/getCustomerOrders.ts index 72daba52..fd743bee 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 { handleToolError, edgesToNodes, buildFieldSelection, type ShopifyConnection } from "../lib/toolUtils.js"; import { formatOrderSummary } from "../lib/formatters.js"; +/** Map of selectable field names → GraphQL fragments for orders */ +const ORDER_FIELD_MAP: Record = { + 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", +}; + +const AVAILABLE_ORDER_FIELDS = Object.keys(ORDER_FIELD_MAP) as [string, ...string[]]; + // 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), + 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([ @@ -15,7 +34,15 @@ 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: z + .array(z.enum(AVAILABLE_ORDER_FIELDS)) + .optional() + .describe( + "Select which fields to return to reduce response size. " + + "When omitted, all fields are returned. Always includes 'id'. " + + `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, + ), }); type GetCustomerOrdersInput = z.infer; @@ -25,7 +52,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,73 +62,16 @@ const getCustomerOrders = { execute: async (input: GetCustomerOrdersInput) => { try { - const { customerId, limit, after, before, sortKey, reverse } = input; + const { customerId, limit, after, before, sortKey, reverse, fields } = input; + + const fieldSelection = buildFieldSelection(ORDER_FIELD_MAP, fields); - // Query to get orders for a specific customer - const query = gql` + 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 + ${fieldSelection} } } pageInfo { @@ -128,8 +98,10 @@ const getCustomerOrders = { orders: ShopifyConnection; }; - // Extract and format order data - const orders = edgesToNodes(data.orders).map(formatOrderSummary); + // When custom fields are specified, return raw nodes (formatter expects all fields) + const orders = fields + ? edgesToNodes(data.orders) + : edgesToNodes(data.orders).map(formatOrderSummary); return { orders, diff --git a/src/tools/getCustomers.ts b/src/tools/getCustomers.ts index 5087a5a0..05144f67 100644 --- a/src/tools/getCustomers.ts +++ b/src/tools/getCustomers.ts @@ -1,19 +1,45 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for customers */ +const CUSTOMER_FIELD_MAP: Record = { + 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", +}; + +const AVAILABLE_CUSTOMER_FIELDS = Object.keys(CUSTOMER_FIELD_MAP) as [string, ...string[]]; // 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: z + .array(z.enum(AVAILABLE_CUSTOMER_FIELDS)) + .optional() + .describe( + "Select which fields to return to reduce response size. " + + "When omitted, all fields are returned. Always includes 'id'. " + + `Available: ${AVAILABLE_CUSTOMER_FIELDS.join(", ")}`, + ), }); type GetCustomersInput = z.infer; @@ -23,7 +49,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' to reduce response size (e.g. fields: [\"id\", \"email\"] for minimal data).", schema: GetCustomersInputSchema, // Add initialize method to set up the GraphQL client @@ -33,52 +59,16 @@ const getCustomers = { execute: async (input: GetCustomersInput) => { try { - const { searchQuery, limit, after, before, sortKey, reverse } = input; + const { searchQuery, limit, after, before, sortKey, reverse, fields } = input; + + const fieldSelection = buildFieldSelection(CUSTOMER_FIELD_MAP, fields); - const query = gql` + 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 + ${fieldSelection} } } pageInfo { @@ -104,7 +94,16 @@ const getCustomers = { customers: any; }; - // Extract and format customer data + // When custom fields are specified, return raw nodes + if (fields) { + const customers = data.customers.edges.map((edge: any) => edge.node); + 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/getOrders.ts b/src/tools/getOrders.ts index 7c7a200a..a1b968e4 100644 --- a/src/tools/getOrders.ts +++ b/src/tools/getOrders.ts @@ -1,13 +1,33 @@ 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 { handleToolError, edgesToNodes, buildFieldSelection, type ShopifyConnection } from "../lib/toolUtils.js"; import { formatOrderSummary } from "../lib/formatters.js"; +/** Map of selectable field names → GraphQL fragments for orders */ +const ORDER_FIELD_MAP: Record = { + 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", +}; + +const AVAILABLE_ORDER_FIELDS = Object.keys(ORDER_FIELD_MAP) as [string, ...string[]]; + // 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 +36,15 @@ 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: z + .array(z.enum(AVAILABLE_ORDER_FIELDS)) + .optional() + .describe( + "Select which fields to return to reduce response size. " + + "When omitted, all fields are returned. Always includes 'id'. " + + `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, + ), }); type GetOrdersInput = z.infer; @@ -26,7 +54,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' to reduce response size (e.g. fields: [\"id\", \"name\"] for minimal data).", schema: GetOrdersInputSchema, // Add initialize method to set up the GraphQL client @@ -36,7 +64,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 } = input; // Build query filters const queryParts: string[] = []; @@ -48,79 +76,14 @@ const getOrders = { } const queryFilter = queryParts.join(" ") || undefined; - const query = gql` + const fieldSelection = buildFieldSelection(ORDER_FIELD_MAP, fields); + + 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 + ${fieldSelection} } } pageInfo { @@ -146,8 +109,10 @@ const getOrders = { orders: ShopifyConnection; }; - // Extract and format order data - const orders = edgesToNodes(data.orders).map(formatOrderSummary); + // When custom fields are specified, return raw nodes (formatter expects all fields) + const orders = fields + ? edgesToNodes(data.orders) + : edgesToNodes(data.orders).map(formatOrderSummary); return { orders, diff --git a/src/tools/getProducts.ts b/src/tools/getProducts.ts index 7210890a..fa8c5685 100644 --- a/src/tools/getProducts.ts +++ b/src/tools/getProducts.ts @@ -1,12 +1,29 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; -import { handleToolError } from "../lib/toolUtils.js"; +import { handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for products */ +const PRODUCT_FIELD_MAP: Record = { + 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 } } }", +}; + +const AVAILABLE_PRODUCT_FIELDS = Object.keys(PRODUCT_FIELD_MAP) as [string, ...string[]]; // 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 +31,15 @@ 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: z + .array(z.enum(AVAILABLE_PRODUCT_FIELDS)) + .optional() + .describe( + "Select which fields to return to reduce response size. " + + "When omitted, all fields are returned. Always includes 'id'. " + + `Available: ${AVAILABLE_PRODUCT_FIELDS.join(", ")}`, + ), }); type GetProductsInput = z.infer; @@ -24,7 +49,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' to reduce response size (e.g. fields: [\"id\", \"title\"] for minimal data).", schema: GetProductsInputSchema, // Add initialize method to set up the GraphQL client @@ -34,7 +59,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 } = input; // Build query string from convenience filters and raw query const queryParts: string[] = []; @@ -46,53 +71,14 @@ const getProducts = { } const queryFilter = queryParts.join(" ") || undefined; - const query = gql` + const fieldSelection = buildFieldSelection(PRODUCT_FIELD_MAP, fields); + + 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 - } - } - } + ${fieldSelection} } } pageInfo { @@ -118,7 +104,16 @@ const getProducts = { products: any; }; - // Extract and format product data + // When custom fields are specified, return raw nodes to avoid formatter errors + if (fields) { + const products = data.products.edges.map((edge: any) => edge.node); + return { + products, + pageInfo: data.products.pageInfo + }; + } + + // Default: full formatting const products = data.products.edges.map((edge: any) => { const product = edge.node; From 15a97c864706540e84771ed53fca3fc9b06aaf6b Mon Sep 17 00:00:00 2001 From: Lucas Jahn Date: Sun, 15 Mar 2026 06:11:25 +0100 Subject: [PATCH 2/6] Improve fields parameter descriptions to guide AI agents Make descriptions more assertive so AI agents actually use the fields parameter instead of fetching all data. Descriptions now tell agents to always specify fields, and to ask the user which fields are needed when unsure rather than defaulting to all fields. --- src/tools/getCollections.ts | 6 ++++-- src/tools/getCustomerOrders.ts | 6 ++++-- src/tools/getCustomers.ts | 6 ++++-- src/tools/getOrders.ts | 6 ++++-- src/tools/getProducts.ts | 6 ++++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/tools/getCollections.ts b/src/tools/getCollections.ts index ccf77ca9..e9d444ae 100644 --- a/src/tools/getCollections.ts +++ b/src/tools/getCollections.ts @@ -37,8 +37,10 @@ const GetCollectionsInputSchema = z.object({ .array(z.enum(AVAILABLE_COLLECTION_FIELDS)) .optional() .describe( - "Select which fields to return to reduce response size. " + - "When omitted, all fields are returned. Always includes 'id'. " + + "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. " + + "Example: [\"id\", \"title\"] returns only collection GID and title. " + `Available: ${AVAILABLE_COLLECTION_FIELDS.join(", ")}`, ), }); diff --git a/src/tools/getCustomerOrders.ts b/src/tools/getCustomerOrders.ts index fd743bee..9e883cbd 100644 --- a/src/tools/getCustomerOrders.ts +++ b/src/tools/getCustomerOrders.ts @@ -39,8 +39,10 @@ const GetCustomerOrdersInputSchema = z.object({ .array(z.enum(AVAILABLE_ORDER_FIELDS)) .optional() .describe( - "Select which fields to return to reduce response size. " + - "When omitted, all fields are returned. Always includes 'id'. " + + "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. " + + "Example: [\"id\", \"name\"] returns only order GID and order number. " + `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, ), }); diff --git a/src/tools/getCustomers.ts b/src/tools/getCustomers.ts index 05144f67..c9bc2f15 100644 --- a/src/tools/getCustomers.ts +++ b/src/tools/getCustomers.ts @@ -36,8 +36,10 @@ const GetCustomersInputSchema = z.object({ .array(z.enum(AVAILABLE_CUSTOMER_FIELDS)) .optional() .describe( - "Select which fields to return to reduce response size. " + - "When omitted, all fields are returned. Always includes 'id'. " + + "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. " + + "Example: [\"id\", \"email\"] returns only customer GID and email. " + `Available: ${AVAILABLE_CUSTOMER_FIELDS.join(", ")}`, ), }); diff --git a/src/tools/getOrders.ts b/src/tools/getOrders.ts index a1b968e4..4ad7d0db 100644 --- a/src/tools/getOrders.ts +++ b/src/tools/getOrders.ts @@ -41,8 +41,10 @@ const GetOrdersInputSchema = z.object({ .array(z.enum(AVAILABLE_ORDER_FIELDS)) .optional() .describe( - "Select which fields to return to reduce response size. " + - "When omitted, all fields are returned. Always includes 'id'. " + + "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. " + + "Example: [\"id\", \"name\"] returns only order GID and order number. " + `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, ), }); diff --git a/src/tools/getProducts.ts b/src/tools/getProducts.ts index fa8c5685..8ebb581a 100644 --- a/src/tools/getProducts.ts +++ b/src/tools/getProducts.ts @@ -36,8 +36,10 @@ const GetProductsInputSchema = z.object({ .array(z.enum(AVAILABLE_PRODUCT_FIELDS)) .optional() .describe( - "Select which fields to return to reduce response size. " + - "When omitted, all fields are returned. Always includes 'id'. " + + "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. " + + "Example: [\"id\", \"title\"] returns only product GID and title. " + `Available: ${AVAILABLE_PRODUCT_FIELDS.join(", ")}`, ), }); From d158c9ed9cf3db41dd31c2cf7ba02581a9805293 Mon Sep 17 00:00:00 2001 From: Lucas Jahn Date: Sun, 15 Mar 2026 07:14:35 +0100 Subject: [PATCH 3/6] Improve AI agent usability: field selection, countOnly, descriptions, and bulk tags Driven by iterative feedback from an AI consumer agent testing all 45 tools, this addresses the main friction points when an LLM operates the MCP toolset without human guidance. Field selection & countOnly: - Add `fields` param to all GET tools (products, orders, customers, collections, metafields, variants) so agents fetch only the data they need - Add `countOnly` param to list tools (get-products, get-orders, get-customers, get-customer-orders) to size result sets before paginating - Increase max page size from 50 to 250 on list endpoints Bulk tag management: - Add manage-tags-bulk tool for batch add/remove on up to 100 resources Description clarity & consistency: - Add format examples to all ID params (GID vs numeric, with examples) - Add cross-tool navigation hints (e.g. create-fulfillment references get-fulfillment-orders for obtaining required IDs) - Add metafield upsert semantics to all update tools - Add customer notification safety warnings requiring user confirmation - Change inventory-set-quantities reason from free text to validated enum Collection improvements: - Add field selection to get-collection-by-id README: - Update tool count from 31 to 45 - Document new sections: Collections, Configuration, Enhanced Order, Inventory & Pricing, shared field selection and countOnly capabilities --- README.md | 69 ++++++++- src/tools/createDraftOrder.ts | 2 +- src/tools/createFulfillment.ts | 6 +- src/tools/createRefund.ts | 4 +- src/tools/deleteCustomer.ts | 2 +- src/tools/deleteMetafields.ts | 2 +- src/tools/getCollectionById.ts | 107 +++++++++++++- src/tools/getCustomerById.ts | 111 +++++++-------- src/tools/getCustomerOrders.ts | 45 +++++- src/tools/getCustomers.ts | 44 +++++- src/tools/getMetafields.ts | 92 ++++++++---- src/tools/getOrderById.ts | 179 +++++++++--------------- src/tools/getOrders.ts | 45 +++++- src/tools/getProductById.ts | 138 ++++++++---------- src/tools/getProductVariantsDetailed.ts | 125 +++++++++-------- src/tools/getProducts.ts | 50 ++++++- src/tools/manageTags.ts | 2 +- src/tools/manageTagsBulk.ts | 100 +++++++++++++ src/tools/mergeCustomers.ts | 14 +- src/tools/orderCancel.ts | 4 +- src/tools/registry.ts | 4 +- src/tools/setInventoryQuantities.ts | 8 +- src/tools/setMetafields.ts | 2 +- src/tools/updateCustomer.ts | 16 ++- src/tools/updateOrder.ts | 18 ++- src/tools/updateProduct.ts | 16 ++- 26 files changed, 802 insertions(+), 403 deletions(-) create mode 100644 src/tools/manageTagsBulk.ts 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/tools/createDraftOrder.ts b/src/tools/createDraftOrder.ts index 94b18acb..ce194526 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 b968d451..eeb230be 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 2da40928..74942d0a 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 f1f8d360..b305ef55 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 b23a08cf..fcb03927 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 aeeffab9..a04c3b8a 100644 --- a/src/tools/getCollectionById.ts +++ b/src/tools/getCollectionById.ts @@ -1,7 +1,24 @@ import type { GraphQLClient } from "graphql-request"; import { gql } from "graphql-request"; import { z } from "zod"; -import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { edgesToNodes, handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for collection-by-id */ +const COLLECTION_BY_ID_FIELD_MAP: Record = { + 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 AVAILABLE_COLLECTION_BY_ID_FIELDS = Object.keys(COLLECTION_BY_ID_FIELD_MAP) as [string, ...string[]]; const GetCollectionByIdInputSchema = z.object({ collectionId: z @@ -19,6 +36,17 @@ const GetCollectionByIdInputSchema = z.object({ .describe( "Number of products to include (default 25, max 100, 0 to skip products)", ), + fields: z + .array(z.enum(AVAILABLE_COLLECTION_BY_ID_FIELDS)) + .optional() + .describe( + "IMPORTANT: Always specify this to minimize token usage and avoid flooding context with unnecessary data. " + + "Only the listed collection-level fields will be fetched. 'id' is always included. " + + "Products are controlled separately via 'productsFirst'. " + + "If you are unsure which fields are needed, ask the user before fetching all fields. " + + "Example: [\"id\", \"title\"] returns only GID and title. " + + `Available: ${AVAILABLE_COLLECTION_BY_ID_FIELDS.join(", ")}`, + ), }); type GetCollectionByIdInput = z.infer; @@ -27,7 +55,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 +68,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 = buildFieldSelection(COLLECTION_BY_ID_FIELD_MAP, 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` query GetCollectionById($id: ID!, $productsFirst: Int!) { collection(id: $id) { diff --git a/src/tools/getCustomerById.ts b/src/tools/getCustomerById.ts index d82fb82b..cbe3529f 100644 --- a/src/tools/getCustomerById.ts +++ b/src/tools/getCustomerById.ts @@ -1,11 +1,41 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for customer-by-id */ +const CUSTOMER_BY_ID_FIELD_MAP: Record = { + 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 } } }", +}; + +const AVAILABLE_CUSTOMER_BY_ID_FIELDS = Object.keys(CUSTOMER_BY_ID_FIELD_MAP) as [string, ...string[]]; // 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: z + .array(z.enum(AVAILABLE_CUSTOMER_BY_ID_FIELDS)) + .optional() + .describe( + "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. " + + "Example: [\"id\", \"email\"] returns only GID and email. " + + `Available: ${AVAILABLE_CUSTOMER_BY_ID_FIELDS.join(", ")}`, + ), }); type GetCustomerByIdInput = z.infer; @@ -15,7 +45,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,65 +55,17 @@ 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` + const fieldSelection = buildFieldSelection(CUSTOMER_BY_ID_FIELD_MAP, fields); + + 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 - } - } - } + ${fieldSelection} } } `; @@ -102,7 +84,20 @@ 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 = { ...customer }; + if (result.addressesV2) { + result.addresses = edgesToNodes(result.addressesV2); + delete result.addressesV2; + } + if (result.metafields) { + result.metafields = edgesToNodes(result.metafields); + } + 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 9e883cbd..d601482f 100644 --- a/src/tools/getCustomerOrders.ts +++ b/src/tools/getCustomerOrders.ts @@ -24,9 +24,17 @@ const AVAILABLE_ORDER_FIELDS = Object.keys(ORDER_FIELD_MAP) as [string, ...strin // Input schema for getting customer orders const GetCustomerOrdersInputSchema = z.object({ - customerId: z.string().regex(/^\d+$/, "Customer ID must be numeric"), + 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: z + .boolean() + .optional() + .describe( + "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.", + ), after: z.string().optional().describe("Cursor for forward pagination"), before: z.string().optional().describe("Cursor for backward pagination"), sortKey: z.enum([ @@ -64,7 +72,20 @@ const getCustomerOrders = { execute: async (input: GetCustomerOrdersInput) => { try { - const { customerId, limit, after, before, sortKey, reverse, fields } = input; + const { customerId, limit, after, before, sortKey, reverse, fields, countOnly } = input; + + // Count-only mode: return just the count + if (countOnly) { + const countQuery = ` + query GetCustomerOrdersCount($query: String) { + ordersCount(query: $query) { count } + } + `; + const countData = (await shopifyClient.request(countQuery, { query: `customer_id:${customerId}` })) as { + ordersCount: { count: number }; + }; + return { count: countData.ordersCount.count }; + } const fieldSelection = buildFieldSelection(ORDER_FIELD_MAP, fields); @@ -100,10 +121,22 @@ const getCustomerOrders = { orders: ShopifyConnection; }; - // When custom fields are specified, return raw nodes (formatter expects all fields) - const orders = fields - ? edgesToNodes(data.orders) - : edgesToNodes(data.orders).map(formatOrderSummary); + // 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 = { ...order }; + if (result.lineItems) { + result.lineItems = edgesToNodes(result.lineItems); + } + return result; + }); + return { + orders, + pageInfo: data.orders.pageInfo + }; + } + + const orders = edgesToNodes(data.orders).map(formatOrderSummary); return { orders, diff --git a/src/tools/getCustomers.ts b/src/tools/getCustomers.ts index c9bc2f15..cbd7e862 100644 --- a/src/tools/getCustomers.ts +++ b/src/tools/getCustomers.ts @@ -42,6 +42,14 @@ const GetCustomersInputSchema = z.object({ "Example: [\"id\", \"email\"] returns only customer GID and email. " + `Available: ${AVAILABLE_CUSTOMER_FIELDS.join(", ")}`, ), + countOnly: z + .boolean() + .optional() + .describe( + "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.", + ), }); type GetCustomersInput = z.infer; @@ -51,7 +59,7 @@ let shopifyClient: GraphQLClient; const getCustomers = { name: "get-customers", - description: "Get customers or search by name/email. Supports field selection via 'fields' to reduce response size (e.g. fields: [\"id\", \"email\"] for minimal data).", + 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 @@ -61,7 +69,20 @@ const getCustomers = { execute: async (input: GetCustomersInput) => { try { - const { searchQuery, limit, after, before, sortKey, reverse, fields } = input; + const { searchQuery, limit, after, before, sortKey, reverse, fields, countOnly } = input; + + // Count-only mode: return just the count + if (countOnly) { + const countQuery = ` + query GetCustomersCount($query: String) { + customersCount(query: $query) { count } + } + `; + const countData = (await shopifyClient.request(countQuery, { query: searchQuery })) as { + customersCount: { count: number }; + }; + return { count: countData.customersCount.count }; + } const fieldSelection = buildFieldSelection(CUSTOMER_FIELD_MAP, fields); @@ -96,9 +117,24 @@ const getCustomers = { customers: any; }; - // When custom fields are specified, return raw nodes + // When custom fields are specified, return nodes with connection sub-fields flattened if (fields) { - const customers = data.customers.edges.map((edge: any) => edge.node); + const customers = edgesToNodes(data.customers).map((customer: any) => { + const result: any = { ...customer }; + if (result.addressesV2) { + result.addresses = edgesToNodes(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 diff --git a/src/tools/getMetafields.ts b/src/tools/getMetafields.ts index b59839f8..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,37 +35,72 @@ const getMetafields = { execute: async (input: GetMetafieldsInput) => { try { - const query = gql` - 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 + // When keys are provided, use the keys parameter (namespace is ignored) + const useKeys = input.keys && input.keys.length > 0; + + 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 f73df66f..4b7637f1 100644 --- a/src/tools/getOrderById.ts +++ b/src/tools/getOrderById.ts @@ -1,12 +1,57 @@ import type { GraphQLClient } from "graphql-request"; import { gql } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; import { formatLineItems, formatOrderSummary } from "../lib/formatters.js"; +/** Map of selectable field names → GraphQL fragments for order-by-id */ +const ORDER_BY_ID_FIELD_MAP: Record = { + 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 } } }", +}; + +const AVAILABLE_ORDER_BY_ID_FIELDS = Object.keys(ORDER_BY_ID_FIELD_MAP) as [string, ...string[]]; + // 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: z + .array(z.enum(AVAILABLE_ORDER_BY_ID_FIELDS)) + .optional() + .describe( + "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. " + + "Example: [\"id\", \"tags\"] returns only GID and tags. " + + `Available: ${AVAILABLE_ORDER_BY_ID_FIELDS.join(", ")}`, + ), }); type GetOrderByIdInput = z.infer; @@ -16,7 +61,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 +71,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; @@ -65,116 +110,12 @@ const getOrderById = { resolvedId = trimmed; } - const query = gql` + const fieldSelection = buildFieldSelection(ORDER_BY_ID_FIELD_MAP, fields); + + 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 - } - } - } + ${fieldSelection} } } `; @@ -191,9 +132,21 @@ 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 = { ...order }; + if (result.lineItems) { + result.lineItems = edgesToNodes(result.lineItems); + } + if (result.metafields) { + result.metafields = edgesToNodes(result.metafields); + } + 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 4ad7d0db..5a19d02c 100644 --- a/src/tools/getOrders.ts +++ b/src/tools/getOrders.ts @@ -47,6 +47,14 @@ const GetOrdersInputSchema = z.object({ "Example: [\"id\", \"name\"] returns only order GID and order number. " + `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, ), + countOnly: z + .boolean() + .optional() + .describe( + "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.", + ), }); type GetOrdersInput = z.infer; @@ -56,7 +64,7 @@ let shopifyClient: GraphQLClient; const getOrders = { name: "get-orders", - description: "Get orders with optional filtering by status. Supports field selection via 'fields' to reduce response size (e.g. fields: [\"id\", \"name\"] for minimal data).", + 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 @@ -66,7 +74,7 @@ const getOrders = { execute: async (input: GetOrdersInput) => { try { - const { status, limit, after, before, sortKey, reverse, query: rawQuery, fields } = input; + const { status, limit, after, before, sortKey, reverse, query: rawQuery, fields, countOnly } = input; // Build query filters const queryParts: string[] = []; @@ -78,6 +86,19 @@ const getOrders = { } const queryFilter = queryParts.join(" ") || undefined; + // Count-only mode: return just the count + if (countOnly) { + const countQuery = ` + query GetOrdersCount($query: String) { + ordersCount(query: $query) { count } + } + `; + const countData = (await shopifyClient.request(countQuery, { query: queryFilter })) as { + ordersCount: { count: number }; + }; + return { count: countData.ordersCount.count }; + } + const fieldSelection = buildFieldSelection(ORDER_FIELD_MAP, fields); const query = ` @@ -111,10 +132,22 @@ const getOrders = { orders: ShopifyConnection; }; - // When custom fields are specified, return raw nodes (formatter expects all fields) - const orders = fields - ? edgesToNodes(data.orders) - : edgesToNodes(data.orders).map(formatOrderSummary); + // 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 = { ...order }; + if (result.lineItems) { + result.lineItems = edgesToNodes(result.lineItems); + } + return result; + }); + return { + orders, + pageInfo: data.orders.pageInfo + }; + } + + const orders = edgesToNodes(data.orders).map(formatOrderSummary); return { orders, diff --git a/src/tools/getProductById.ts b/src/tools/getProductById.ts index dfe8116b..815f1c00 100644 --- a/src/tools/getProductById.ts +++ b/src/tools/getProductById.ts @@ -1,11 +1,44 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; -import { handleToolError } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for product-by-id */ +const PRODUCT_BY_ID_FIELD_MAP: Record = { + 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", +}; + +const AVAILABLE_PRODUCT_BY_ID_FIELDS = Object.keys(PRODUCT_BY_ID_FIELD_MAP) as [string, ...string[]]; // 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: z + .array(z.enum(AVAILABLE_PRODUCT_BY_ID_FIELDS)) + .optional() + .describe( + "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. " + + "Example: [\"id\", \"title\"] returns only GID and title. " + + `Available: ${AVAILABLE_PRODUCT_BY_ID_FIELDS.join(", ")}`, + ), }); type GetProductByIdInput = z.infer; @@ -15,7 +48,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,84 +58,14 @@ const getProductById = { execute: async (input: GetProductByIdInput) => { try { - const { productId } = input; + const { productId, fields } = input; - const query = gql` + const fieldSelection = buildFieldSelection(PRODUCT_BY_ID_FIELD_MAP, fields); + + 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 - } - } + ${fieldSelection} } } `; @@ -119,9 +82,24 @@ 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 = { ...product }; + if (result.media) { + result.media = edgesToNodes(result.media); + } + if (result.variants) { + result.variants = edgesToNodes(result.variants); + } + if (result.collections) { + result.collections = edgesToNodes(result.collections); + } + 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 40d63e10..d935750e 100644 --- a/src/tools/getProductVariantsDetailed.ts +++ b/src/tools/getProductVariantsDetailed.ts @@ -1,7 +1,29 @@ import type { GraphQLClient } from "graphql-request"; -import { gql } from "graphql-request"; import { z } from "zod"; -import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { edgesToNodes, handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; + +/** Map of selectable field names → GraphQL fragments for variant nodes */ +const VARIANT_FIELD_MAP: Record = { + 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 AVAILABLE_VARIANT_FIELDS = Object.keys(VARIANT_FIELD_MAP) as [string, ...string[]]; const GetProductVariantsDetailedInputSchema = z.object({ productId: z @@ -17,6 +39,16 @@ const GetProductVariantsDetailedInputSchema = z.object({ .default(50) .optional() .describe("Number of variants to return (default 50, max 100)"), + fields: z + .array(z.enum(AVAILABLE_VARIANT_FIELDS)) + .optional() + .describe( + "IMPORTANT: Always specify this to minimize token usage and avoid flooding context with unnecessary data. " + + "Only the listed fields will be fetched for each variant. 'id' is always included. " + + "If you are unsure which fields are needed, ask the user before fetching all fields. " + + "Example: [\"id\", \"price\", \"sku\"] returns only variant GID, price, and SKU. " + + `Available: ${AVAILABLE_VARIANT_FIELDS.join(", ")}`, + ), }); type GetProductVariantsDetailedInput = z.infer< typeof GetProductVariantsDetailedInputSchema @@ -27,7 +59,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,7 +72,9 @@ const getProductVariantsDetailed = { ? input.productId : `gid://shopify/Product/${input.productId}`; - const query = gql` + const variantFieldSelection = buildFieldSelection(VARIANT_FIELD_MAP, input.fields); + + const query = ` query GetProductVariantsDetailed($id: ID!, $first: Int!) { product(id: $id) { id @@ -48,60 +82,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 - } - } - } + ${variantFieldSelection} } } pageInfo { @@ -122,6 +103,34 @@ 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 = { ...variant }; + if (result.media) { + const mediaNodes = edgesToNodes(result.media); + const firstImage = mediaNodes.find((m: any) => m.image) as any; + result.image = firstImage?.image ?? null; + delete result.media; + } + if (result.metafields) { + result.metafields = edgesToNodes(result.metafields); + } + 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 8ebb581a..619f0d79 100644 --- a/src/tools/getProducts.ts +++ b/src/tools/getProducts.ts @@ -1,6 +1,6 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; /** Map of selectable field names → GraphQL fragments for products */ const PRODUCT_FIELD_MAP: Record = { @@ -42,6 +42,14 @@ const GetProductsInputSchema = z.object({ "Example: [\"id\", \"title\"] returns only product GID and title. " + `Available: ${AVAILABLE_PRODUCT_FIELDS.join(", ")}`, ), + countOnly: z + .boolean() + .optional() + .describe( + "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.", + ), }); type GetProductsInput = z.infer; @@ -51,7 +59,7 @@ let shopifyClient: GraphQLClient; const getProducts = { name: "get-products", - description: "Get all products or search by title. Supports field selection via 'fields' to reduce response size (e.g. fields: [\"id\", \"title\"] for minimal data).", + 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 @@ -61,7 +69,7 @@ const getProducts = { execute: async (input: GetProductsInput) => { try { - const { searchTitle, limit, after, before, sortKey, reverse, query: rawQuery, fields } = 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[] = []; @@ -73,6 +81,19 @@ const getProducts = { } const queryFilter = queryParts.join(" ") || undefined; + // Count-only mode: return just the count + if (countOnly) { + const countQuery = ` + query GetProductsCount($query: String) { + productsCount(query: $query) { count } + } + `; + const countData = (await shopifyClient.request(countQuery, { query: queryFilter })) as { + productsCount: { count: number }; + }; + return { count: countData.productsCount.count }; + } + const fieldSelection = buildFieldSelection(PRODUCT_FIELD_MAP, fields); const query = ` @@ -106,9 +127,28 @@ const getProducts = { products: any; }; - // When custom fields are specified, return raw nodes to avoid formatter errors + // When custom fields are specified, flatten connection fields to match default format if (fields) { - const products = data.products.edges.map((edge: any) => edge.node); + const products = data.products.edges.map((edge: any) => { + const product: any = { ...edge.node }; + if (product.variants) { + product.variants = edgesToNodes(product.variants); + } + if (product.media) { + product.media = edgesToNodes(product.media); + } + if (product.priceRangeV2) { + product.priceRange = { + minPrice: product.priceRangeV2.minVariantPrice, + maxPrice: product.priceRangeV2.maxVariantPrice, + }; + delete product.priceRangeV2; + } + if (product.collections) { + product.collections = edgesToNodes(product.collections); + } + return product; + }); return { products, pageInfo: data.products.pageInfo diff --git a/src/tools/manageTags.ts b/src/tools/manageTags.ts index 17d1d6d7..8a0a6e29 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 cf008b6e..6d08c12d 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 43151556..ca63403d 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 6b4f4de1..e962678b 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 630700af..b58e4384 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 60d93f73..a328b1f1 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 ac2a91f9..b4f8e8f9 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 d03cebdc..ee8915fb 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"), From bf62b746ab3079a612d09e8d92ecddaa24e410fa Mon Sep 17 00:00:00 2001 From: Lucas Jahn Date: Mon, 22 Jun 2026 11:06:27 +0200 Subject: [PATCH 4/6] Extract field selection into a Projection module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepening refactor (architecture review candidates A + B). The field selection feature was three shallow pieces kept in sync by hand across 10 GET tools: a FIELD_MAP of fragments, an AVAILABLE_*_FIELDS enum, a ~450-char copy-pasted "always specify fields" describe string, and a per-tool formatter that re-discovered which fields are connections. defineProjection() in src/lib/projection.ts is now the single source of truth per tool. From one field→fragment map it derives: - selection(fields): the GraphQL selection set (id always included), joined with newlines — removes the hidden 16-space indent coupling in the old buildFieldSelection (GraphQL is whitespace-insensitive). - fieldsParam({noun, extra}): the zod `fields` param with the shared agent guidance baked in — the describe text now lives in exactly one place instead of being copy-pasted (with drift) across 10 files. - normalize(node): flattens any top-level connection-shaped value ({edges:[...]}) to an array of nodes. Shape-based, so field aliases (e.g. addressesV2 under key "addresses") are handled correctly. countOnlyParam() likewise centralizes the countOnly guidance. Pure refactor — output shapes unchanged. Per-tool renames (priceRangeV2 →priceRange, addressesV2→addresses), computed fields (imageUrl, variant image), and formatOrderSummary delegation are preserved; default (no-fields) paths are untouched. getMetafields keeps its own keys/ namespace model. buildFieldSelection removed. Net: tools shrink by ~200 lines; selection/normalize are now pure and unit-testable without the GraphQL client global. Verified: npm run build and npm run validate:graphql both pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/projection.ts | 99 +++++++++++++++++++++++++ src/lib/toolUtils.ts | 16 ---- src/tools/getCollectionById.ts | 28 +++---- src/tools/getCollections.ts | 26 ++----- src/tools/getCustomerById.ts | 33 +++------ src/tools/getCustomerOrders.ts | 40 +++------- src/tools/getCustomers.ts | 39 +++------- src/tools/getOrderById.ts | 34 ++------- src/tools/getOrders.ts | 40 +++------- src/tools/getProductById.ts | 37 ++------- src/tools/getProductVariantsDetailed.ts | 33 +++------ src/tools/getProducts.ts | 46 +++--------- 12 files changed, 187 insertions(+), 284 deletions(-) create mode 100644 src/lib/projection.ts diff --git a/src/lib/projection.ts b/src/lib/projection.ts new file mode 100644 index 00000000..88ccc65b --- /dev/null +++ b/src/lib/projection.ts @@ -0,0 +1,99 @@ +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[]]; + const description = + SHARED_FIELDS_GUIDANCE + + `Example: ["id", "title"] returns only ${noun} GID and title. ` + + (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); +} diff --git a/src/lib/toolUtils.ts b/src/lib/toolUtils.ts index edbd3fe5..b6efa4b3 100644 --- a/src/lib/toolUtils.ts +++ b/src/lib/toolUtils.ts @@ -77,22 +77,6 @@ export function edgesToNodes(connection: ShopifyConnection): T[] { return connection.edges.map((edge) => edge.node); } -/** - * Build a GraphQL field selection string from a field map and optional field list. - * Always includes 'id'. When fields is undefined, includes all available fields. - */ -export function buildFieldSelection( - fieldMap: Record, - fields?: string[], -): string { - const selected = fields ?? Object.keys(fieldMap); - const fieldSet = new Set(["id", ...selected]); - return [...fieldSet] - .map((f) => fieldMap[f]) - .filter(Boolean) - .join("\n "); -} - /** * Extract shopMoney from a Shopify MoneyBag (e.g. totalPriceSet.shopMoney). */ diff --git a/src/tools/getCollectionById.ts b/src/tools/getCollectionById.ts index 2ce4255d..f848a7d6 100644 --- a/src/tools/getCollectionById.ts +++ b/src/tools/getCollectionById.ts @@ -1,10 +1,11 @@ import type { GraphQLClient } from "graphql-request"; import { gql } from "graphql-request"; import { z } from "zod"; -import { edgesToNodes, handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; +import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for collection-by-id */ -const COLLECTION_BY_ID_FIELD_MAP: Record = { +/** Selectable fields for collection-by-id */ +const collectionByIdProjection = defineProjection({ id: "id", title: "title", handle: "handle", @@ -16,9 +17,7 @@ const COLLECTION_BY_ID_FIELD_MAP: Record = { ruleSet: "ruleSet { appliedDisjunctively rules { column relation condition } }", image: "image { url altText width height }", seo: "seo { title description }", -}; - -const AVAILABLE_COLLECTION_BY_ID_FIELDS = Object.keys(COLLECTION_BY_ID_FIELD_MAP) as [string, ...string[]]; +}); const GetCollectionByIdInputSchema = z.object({ collectionId: z @@ -36,17 +35,10 @@ const GetCollectionByIdInputSchema = z.object({ .describe( "Number of products to include (default 25, max 100, 0 to skip products)", ), - fields: z - .array(z.enum(AVAILABLE_COLLECTION_BY_ID_FIELDS)) - .optional() - .describe( - "IMPORTANT: Always specify this to minimize token usage and avoid flooding context with unnecessary data. " + - "Only the listed collection-level fields will be fetched. 'id' is always included. " + - "Products are controlled separately via 'productsFirst'. " + - "If you are unsure which fields are needed, ask the user before fetching all fields. " + - "Example: [\"id\", \"title\"] returns only GID and title. " + - `Available: ${AVAILABLE_COLLECTION_BY_ID_FIELDS.join(", ")}`, - ), + fields: collectionByIdProjection.fieldsParam({ + noun: "collection", + extra: "Products are controlled separately via 'productsFirst'.", + }), }); type GetCollectionByIdInput = z.infer; @@ -72,7 +64,7 @@ const getCollectionById = { // When fields is set, use field selection for collection-level fields if (fields) { - const fieldSelection = buildFieldSelection(COLLECTION_BY_ID_FIELD_MAP, fields); + const fieldSelection = collectionByIdProjection.selection(fields); // Append products block separately if productsFirst > 0 const productsBlock = productsFirst > 0 ? ` diff --git a/src/tools/getCollections.ts b/src/tools/getCollections.ts index e9d444ae..6393a7e8 100644 --- a/src/tools/getCollections.ts +++ b/src/tools/getCollections.ts @@ -1,9 +1,10 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { edgesToNodes, handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; +import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for collections */ -const COLLECTION_FIELD_MAP: Record = { +/** Selectable fields for collections */ +const collectionProjection = defineProjection({ id: "id", title: "title", handle: "handle", @@ -15,9 +16,7 @@ const COLLECTION_FIELD_MAP: Record = { ruleSet: "ruleSet { appliedDisjunctively rules { column relation condition } }", image: "image { url altText }", seo: "seo { title description }", -}; - -const AVAILABLE_COLLECTION_FIELDS = Object.keys(COLLECTION_FIELD_MAP) as [string, ...string[]]; +}); const GetCollectionsInputSchema = z.object({ first: z @@ -33,16 +32,7 @@ const GetCollectionsInputSchema = z.object({ .describe( "Search query to filter collections (e.g. 'title:Summer' or 'collection_type:smart')", ), - fields: z - .array(z.enum(AVAILABLE_COLLECTION_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"title\"] returns only collection GID and title. " + - `Available: ${AVAILABLE_COLLECTION_FIELDS.join(", ")}`, - ), + fields: collectionProjection.fieldsParam({ noun: "collection" }), }); type GetCollectionsInput = z.infer; @@ -60,14 +50,12 @@ const getCollections = { execute: async (input: GetCollectionsInput) => { try { - const fieldSelection = buildFieldSelection(COLLECTION_FIELD_MAP, input.fields); - const query = ` query GetCollections($first: Int!, $query: String) { collections(first: $first, query: $query) { edges { node { - ${fieldSelection} + ${collectionProjection.selection(input.fields)} } } pageInfo { diff --git a/src/tools/getCustomerById.ts b/src/tools/getCustomerById.ts index cbe3529f..b789a49b 100644 --- a/src/tools/getCustomerById.ts +++ b/src/tools/getCustomerById.ts @@ -1,9 +1,10 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for customer-by-id */ -const CUSTOMER_BY_ID_FIELD_MAP: Record = { +/** Selectable fields for customer-by-id */ +const customerByIdProjection = defineProjection({ id: "id", firstName: "firstName", lastName: "lastName", @@ -19,23 +20,12 @@ const CUSTOMER_BY_ID_FIELD_MAP: Record = { amountSpent: "amountSpent { amount currencyCode }", numberOfOrders: "numberOfOrders", metafields: "metafields(first: 10) { edges { node { id namespace key value } } }", -}; - -const AVAILABLE_CUSTOMER_BY_ID_FIELDS = Object.keys(CUSTOMER_BY_ID_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getting a customer by ID const GetCustomerByIdInputSchema = z.object({ id: z.string().regex(/^\d+$/, "Customer ID must be numeric").describe("Numeric customer ID (e.g. 7832529321). Do not pass a full GID."), - fields: z - .array(z.enum(AVAILABLE_CUSTOMER_BY_ID_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"email\"] returns only GID and email. " + - `Available: ${AVAILABLE_CUSTOMER_BY_ID_FIELDS.join(", ")}`, - ), + fields: customerByIdProjection.fieldsParam({ noun: "customer" }), }); type GetCustomerByIdInput = z.infer; @@ -60,12 +50,10 @@ const getCustomerById = { // Convert numeric ID to GID format const customerGid = `gid://shopify/Customer/${id}`; - const fieldSelection = buildFieldSelection(CUSTOMER_BY_ID_FIELD_MAP, fields); - const query = ` query GetCustomerById($id: ID!) { customer(id: $id) { - ${fieldSelection} + ${customerByIdProjection.selection(fields)} } } `; @@ -86,14 +74,11 @@ const getCustomerById = { // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) if (fields) { - const result: any = { ...customer }; + const result: any = customerByIdProjection.normalize(customer); if (result.addressesV2) { - result.addresses = edgesToNodes(result.addressesV2); + result.addresses = result.addressesV2; delete result.addressesV2; } - if (result.metafields) { - result.metafields = edgesToNodes(result.metafields); - } return { customer: result }; } diff --git a/src/tools/getCustomerOrders.ts b/src/tools/getCustomerOrders.ts index d601482f..b658804e 100644 --- a/src/tools/getCustomerOrders.ts +++ b/src/tools/getCustomerOrders.ts @@ -1,10 +1,11 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection, type ShopifyConnection } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, type ShopifyConnection } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam } from "../lib/projection.js"; import { formatOrderSummary } from "../lib/formatters.js"; -/** Map of selectable field names → GraphQL fragments for orders */ -const ORDER_FIELD_MAP: Record = { +/** Selectable fields for orders */ +const orderProjection = defineProjection({ id: "id", name: "name", createdAt: "createdAt", @@ -18,23 +19,14 @@ const ORDER_FIELD_MAP: Record = { lineItems: "lineItems(first: 5) { edges { node { id title quantity originalTotalSet { shopMoney { amount currencyCode } } variant { id title sku } } } }", tags: "tags", note: "note", -}; - -const AVAILABLE_ORDER_FIELDS = Object.keys(ORDER_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getting customer orders const GetCustomerOrdersInputSchema = z.object({ 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: z - .boolean() - .optional() - .describe( - "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.", - ), + countOnly: countOnlyParam(), after: z.string().optional().describe("Cursor for forward pagination"), before: z.string().optional().describe("Cursor for backward pagination"), sortKey: z.enum([ @@ -43,16 +35,7 @@ const GetCustomerOrdersInputSchema = z.object({ "ID", "RELEVANCE" ]).optional().describe("Sort key for orders"), reverse: z.boolean().optional().describe("Reverse the sort order"), - fields: z - .array(z.enum(AVAILABLE_ORDER_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"name\"] returns only order GID and order number. " + - `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, - ), + fields: orderProjection.fieldsParam({ noun: "order" }), }); type GetCustomerOrdersInput = z.infer; @@ -87,14 +70,12 @@ const getCustomerOrders = { return { count: countData.ordersCount.count }; } - const fieldSelection = buildFieldSelection(ORDER_FIELD_MAP, fields); - 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 { - ${fieldSelection} + ${orderProjection.selection(fields)} } } pageInfo { @@ -124,10 +105,7 @@ const getCustomerOrders = { // 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 = { ...order }; - if (result.lineItems) { - result.lineItems = edgesToNodes(result.lineItems); - } + const result: any = orderProjection.normalize(order); return result; }); return { diff --git a/src/tools/getCustomers.ts b/src/tools/getCustomers.ts index cbd7e862..cadd1607 100644 --- a/src/tools/getCustomers.ts +++ b/src/tools/getCustomers.ts @@ -1,9 +1,10 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for customers */ -const CUSTOMER_FIELD_MAP: Record = { +/** Selectable fields for customers */ +const customerProjection = defineProjection({ id: "id", firstName: "firstName", lastName: "lastName", @@ -16,9 +17,7 @@ const CUSTOMER_FIELD_MAP: Record = { addresses: "addressesV2(first: 10) { edges { node { address1 address2 city provinceCode zip country phone } } }", amountSpent: "amountSpent { amount currencyCode }", numberOfOrders: "numberOfOrders", -}; - -const AVAILABLE_CUSTOMER_FIELDS = Object.keys(CUSTOMER_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getCustomers const GetCustomersInputSchema = z.object({ @@ -32,24 +31,8 @@ const GetCustomersInputSchema = z.object({ "ORDERS_COUNT", "RELEVANCE", "TOTAL_SPENT", "UPDATED_AT" ]).optional().describe("Sort key for customers"), reverse: z.boolean().optional().describe("Reverse the sort order"), - fields: z - .array(z.enum(AVAILABLE_CUSTOMER_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"email\"] returns only customer GID and email. " + - `Available: ${AVAILABLE_CUSTOMER_FIELDS.join(", ")}`, - ), - countOnly: z - .boolean() - .optional() - .describe( - "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.", - ), + fields: customerProjection.fieldsParam({ noun: "customer" }), + countOnly: countOnlyParam(), }); type GetCustomersInput = z.infer; @@ -84,14 +67,12 @@ const getCustomers = { return { count: countData.customersCount.count }; } - const fieldSelection = buildFieldSelection(CUSTOMER_FIELD_MAP, fields); - 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 { - ${fieldSelection} + ${customerProjection.selection(fields)} } } pageInfo { @@ -120,9 +101,9 @@ const getCustomers = { // 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 = { ...customer }; + const result: any = customerProjection.normalize(customer); if (result.addressesV2) { - result.addresses = edgesToNodes(result.addressesV2); + result.addresses = result.addressesV2; delete result.addressesV2; } if (result.defaultEmailAddress) { diff --git a/src/tools/getOrderById.ts b/src/tools/getOrderById.ts index 4b7637f1..5efb3294 100644 --- a/src/tools/getOrderById.ts +++ b/src/tools/getOrderById.ts @@ -1,11 +1,12 @@ import type { GraphQLClient } from "graphql-request"; import { gql } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; import { formatLineItems, formatOrderSummary } from "../lib/formatters.js"; -/** Map of selectable field names → GraphQL fragments for order-by-id */ -const ORDER_BY_ID_FIELD_MAP: Record = { +/** Selectable fields for order-by-id */ +const orderByIdProjection = defineProjection({ id: "id", name: "name", createdAt: "createdAt", @@ -30,9 +31,7 @@ const ORDER_BY_ID_FIELD_MAP: Record = { poNumber: "poNumber", discountCodes: "discountCodes", metafields: "metafields(first: 20) { edges { node { id namespace key value type } } }", -}; - -const AVAILABLE_ORDER_BY_ID_FIELDS = Object.keys(ORDER_BY_ID_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getOrderById const GetOrderByIdInputSchema = z.object({ @@ -42,16 +41,7 @@ const GetOrderByIdInputSchema = z.object({ .describe( "Accepts order numbers (e.g. 77713), numeric IDs, or full GIDs (gid://shopify/Order/...)", ), - fields: z - .array(z.enum(AVAILABLE_ORDER_BY_ID_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"tags\"] returns only GID and tags. " + - `Available: ${AVAILABLE_ORDER_BY_ID_FIELDS.join(", ")}`, - ), + fields: orderByIdProjection.fieldsParam({ noun: "order" }), }); type GetOrderByIdInput = z.infer; @@ -110,12 +100,10 @@ const getOrderById = { resolvedId = trimmed; } - const fieldSelection = buildFieldSelection(ORDER_BY_ID_FIELD_MAP, fields); - const query = ` query GetOrderById($id: ID!) { order(id: $id) { - ${fieldSelection} + ${orderByIdProjection.selection(fields)} } } `; @@ -136,13 +124,7 @@ const getOrderById = { // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) if (fields) { - const result: any = { ...order }; - if (result.lineItems) { - result.lineItems = edgesToNodes(result.lineItems); - } - if (result.metafields) { - result.metafields = edgesToNodes(result.metafields); - } + const result: any = orderByIdProjection.normalize(order); return { order: result }; } diff --git a/src/tools/getOrders.ts b/src/tools/getOrders.ts index 5a19d02c..2bcc16ed 100644 --- a/src/tools/getOrders.ts +++ b/src/tools/getOrders.ts @@ -1,10 +1,11 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection, type ShopifyConnection } from "../lib/toolUtils.js"; +import { handleToolError, edgesToNodes, type ShopifyConnection } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam } from "../lib/projection.js"; import { formatOrderSummary } from "../lib/formatters.js"; -/** Map of selectable field names → GraphQL fragments for orders */ -const ORDER_FIELD_MAP: Record = { +/** Selectable fields for orders */ +const orderProjection = defineProjection({ id: "id", name: "name", createdAt: "createdAt", @@ -19,9 +20,7 @@ const ORDER_FIELD_MAP: Record = { lineItems: "lineItems(first: 10) { edges { node { id title quantity originalTotalSet { shopMoney { amount currencyCode } } variant { id title sku } } } }", tags: "tags", note: "note", -}; - -const AVAILABLE_ORDER_FIELDS = Object.keys(ORDER_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getOrders const GetOrdersInputSchema = z.object({ @@ -37,24 +36,8 @@ const GetOrdersInputSchema = z.object({ ]).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')"), - fields: z - .array(z.enum(AVAILABLE_ORDER_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"name\"] returns only order GID and order number. " + - `Available: ${AVAILABLE_ORDER_FIELDS.join(", ")}`, - ), - countOnly: z - .boolean() - .optional() - .describe( - "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.", - ), + fields: orderProjection.fieldsParam({ noun: "order" }), + countOnly: countOnlyParam(), }); type GetOrdersInput = z.infer; @@ -99,14 +82,12 @@ const getOrders = { return { count: countData.ordersCount.count }; } - const fieldSelection = buildFieldSelection(ORDER_FIELD_MAP, fields); - 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 { - ${fieldSelection} + ${orderProjection.selection(fields)} } } pageInfo { @@ -135,10 +116,7 @@ const getOrders = { // 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 = { ...order }; - if (result.lineItems) { - result.lineItems = edgesToNodes(result.lineItems); - } + const result: any = orderProjection.normalize(order); return result; }); return { diff --git a/src/tools/getProductById.ts b/src/tools/getProductById.ts index 815f1c00..e98aed87 100644 --- a/src/tools/getProductById.ts +++ b/src/tools/getProductById.ts @@ -1,9 +1,10 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; +import { handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for product-by-id */ -const PRODUCT_BY_ID_FIELD_MAP: Record = { +/** Selectable fields for product-by-id */ +const productByIdProjection = defineProjection({ id: "id", title: "title", description: "description", @@ -22,23 +23,12 @@ const PRODUCT_BY_ID_FIELD_MAP: Record = { tags: "tags", vendor: "vendor", productType: "productType", -}; - -const AVAILABLE_PRODUCT_BY_ID_FIELDS = Object.keys(PRODUCT_BY_ID_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getProductById const GetProductByIdInputSchema = z.object({ productId: z.string().min(1).describe("The product ID (e.g. gid://shopify/Product/123 or just 123)"), - fields: z - .array(z.enum(AVAILABLE_PRODUCT_BY_ID_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"title\"] returns only GID and title. " + - `Available: ${AVAILABLE_PRODUCT_BY_ID_FIELDS.join(", ")}`, - ), + fields: productByIdProjection.fieldsParam({ noun: "product" }), }); type GetProductByIdInput = z.infer; @@ -60,12 +50,10 @@ const getProductById = { try { const { productId, fields } = input; - const fieldSelection = buildFieldSelection(PRODUCT_BY_ID_FIELD_MAP, fields); - const query = ` query GetProductById($id: ID!) { product(id: $id) { - ${fieldSelection} + ${productByIdProjection.selection(fields)} } } `; @@ -86,16 +74,7 @@ const getProductById = { // When custom fields are specified, return raw nodes (run edgesToNodes on connection fields) if (fields) { - const result: any = { ...product }; - if (result.media) { - result.media = edgesToNodes(result.media); - } - if (result.variants) { - result.variants = edgesToNodes(result.variants); - } - if (result.collections) { - result.collections = edgesToNodes(result.collections); - } + const result: any = productByIdProjection.normalize(product); return { product: result }; } diff --git a/src/tools/getProductVariantsDetailed.ts b/src/tools/getProductVariantsDetailed.ts index d935750e..bd8551f0 100644 --- a/src/tools/getProductVariantsDetailed.ts +++ b/src/tools/getProductVariantsDetailed.ts @@ -1,9 +1,10 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { edgesToNodes, handleToolError, buildFieldSelection } from "../lib/toolUtils.js"; +import { edgesToNodes, handleToolError } from "../lib/toolUtils.js"; +import { defineProjection } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for variant nodes */ -const VARIANT_FIELD_MAP: Record = { +/** Selectable fields for variant nodes */ +const variantProjection = defineProjection({ id: "id", title: "title", displayName: "displayName", @@ -21,9 +22,7 @@ const VARIANT_FIELD_MAP: Record = { 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 AVAILABLE_VARIANT_FIELDS = Object.keys(VARIANT_FIELD_MAP) as [string, ...string[]]; +}); const GetProductVariantsDetailedInputSchema = z.object({ productId: z @@ -39,16 +38,7 @@ const GetProductVariantsDetailedInputSchema = z.object({ .default(50) .optional() .describe("Number of variants to return (default 50, max 100)"), - fields: z - .array(z.enum(AVAILABLE_VARIANT_FIELDS)) - .optional() - .describe( - "IMPORTANT: Always specify this to minimize token usage and avoid flooding context with unnecessary data. " + - "Only the listed fields will be fetched for each variant. 'id' is always included. " + - "If you are unsure which fields are needed, ask the user before fetching all fields. " + - "Example: [\"id\", \"price\", \"sku\"] returns only variant GID, price, and SKU. " + - `Available: ${AVAILABLE_VARIANT_FIELDS.join(", ")}`, - ), + fields: variantProjection.fieldsParam({ noun: "variant" }), }); type GetProductVariantsDetailedInput = z.infer< typeof GetProductVariantsDetailedInputSchema @@ -72,8 +62,6 @@ const getProductVariantsDetailed = { ? input.productId : `gid://shopify/Product/${input.productId}`; - const variantFieldSelection = buildFieldSelection(VARIANT_FIELD_MAP, input.fields); - const query = ` query GetProductVariantsDetailed($id: ID!, $first: Int!) { product(id: $id) { @@ -82,7 +70,7 @@ const getProductVariantsDetailed = { variants(first: $first) { edges { node { - ${variantFieldSelection} + ${variantProjection.selection(input.fields)} } } pageInfo { @@ -107,16 +95,13 @@ const getProductVariantsDetailed = { if (input.fields) { const variants = edgesToNodes(data.product.variants).map( (variant: any) => { - const result: any = { ...variant }; + const result: any = variantProjection.normalize(variant); if (result.media) { - const mediaNodes = edgesToNodes(result.media); + const mediaNodes = result.media; const firstImage = mediaNodes.find((m: any) => m.image) as any; result.image = firstImage?.image ?? null; delete result.media; } - if (result.metafields) { - result.metafields = edgesToNodes(result.metafields); - } return result; }, ); diff --git a/src/tools/getProducts.ts b/src/tools/getProducts.ts index 619f0d79..e1dd905c 100644 --- a/src/tools/getProducts.ts +++ b/src/tools/getProducts.ts @@ -1,9 +1,10 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; -import { handleToolError, edgesToNodes, buildFieldSelection } from "../lib/toolUtils.js"; +import { handleToolError } from "../lib/toolUtils.js"; +import { defineProjection, countOnlyParam } from "../lib/projection.js"; -/** Map of selectable field names → GraphQL fragments for products */ -const PRODUCT_FIELD_MAP: Record = { +/** Selectable fields for products */ +const productProjection = defineProjection({ id: "id", title: "title", description: "description", @@ -15,9 +16,7 @@ const PRODUCT_FIELD_MAP: Record = { 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 } } }", -}; - -const AVAILABLE_PRODUCT_FIELDS = Object.keys(PRODUCT_FIELD_MAP) as [string, ...string[]]; +}); // Input schema for getProducts const GetProductsInputSchema = z.object({ @@ -32,24 +31,8 @@ const GetProductsInputSchema = z.object({ ]).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')"), - fields: z - .array(z.enum(AVAILABLE_PRODUCT_FIELDS)) - .optional() - .describe( - "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. " + - "Example: [\"id\", \"title\"] returns only product GID and title. " + - `Available: ${AVAILABLE_PRODUCT_FIELDS.join(", ")}`, - ), - countOnly: z - .boolean() - .optional() - .describe( - "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.", - ), + fields: productProjection.fieldsParam({ noun: "product" }), + countOnly: countOnlyParam(), }); type GetProductsInput = z.infer; @@ -94,14 +77,12 @@ const getProducts = { return { count: countData.productsCount.count }; } - const fieldSelection = buildFieldSelection(PRODUCT_FIELD_MAP, fields); - 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 { - ${fieldSelection} + ${productProjection.selection(fields)} } } pageInfo { @@ -130,13 +111,7 @@ const getProducts = { // 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 = { ...edge.node }; - if (product.variants) { - product.variants = edgesToNodes(product.variants); - } - if (product.media) { - product.media = edgesToNodes(product.media); - } + const product: any = productProjection.normalize(edge.node); if (product.priceRangeV2) { product.priceRange = { minPrice: product.priceRangeV2.minVariantPrice, @@ -144,9 +119,6 @@ const getProducts = { }; delete product.priceRangeV2; } - if (product.collections) { - product.collections = edgesToNodes(product.collections); - } return product; }); return { From 22e03ef32e85e7e1dc5ed80f2d6cba3912df30b4 Mon Sep 17 00:00:00 2001 From: Lucas Jahn Date: Tue, 23 Jun 2026 05:56:43 +0200 Subject: [PATCH 5/6] Extract countOnly query into fetchCount helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fallow` flagged the count-only block as duplicated across the four list tools (get-products, get-orders, get-customers, get-customer-orders) — each inlined a near-identical *Count query, request, and unwrap that differed only by the count field name and the query filter. Add fetchCount(client, countField, query?) to projection.ts (alongside countOnlyParam, its schema counterpart) and call it from all four tools. Pure refactor — behavior unchanged. Verified: npm run build and npm run validate:graphql both pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/projection.ts | 22 ++++++++++++++++++++++ src/tools/getCustomerOrders.ts | 12 ++---------- src/tools/getCustomers.ts | 12 ++---------- src/tools/getOrders.ts | 12 ++---------- src/tools/getProducts.ts | 12 ++---------- 5 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/lib/projection.ts b/src/lib/projection.ts index 88ccc65b..0cff6dc9 100644 --- a/src/lib/projection.ts +++ b/src/lib/projection.ts @@ -1,3 +1,4 @@ +import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; import { edgesToNodes } from "./toolUtils.js"; @@ -97,3 +98,24 @@ export function defineProjection(spec: Record): Projection { 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/getCustomerOrders.ts b/src/tools/getCustomerOrders.ts index b658804e..695930b1 100644 --- a/src/tools/getCustomerOrders.ts +++ b/src/tools/getCustomerOrders.ts @@ -1,7 +1,7 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes, type ShopifyConnection } from "../lib/toolUtils.js"; -import { defineProjection, countOnlyParam } from "../lib/projection.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; import { formatOrderSummary } from "../lib/formatters.js"; /** Selectable fields for orders */ @@ -59,15 +59,7 @@ const getCustomerOrders = { // Count-only mode: return just the count if (countOnly) { - const countQuery = ` - query GetCustomerOrdersCount($query: String) { - ordersCount(query: $query) { count } - } - `; - const countData = (await shopifyClient.request(countQuery, { query: `customer_id:${customerId}` })) as { - ordersCount: { count: number }; - }; - return { count: countData.ordersCount.count }; + return fetchCount(shopifyClient, "ordersCount", `customer_id:${customerId}`); } const query = ` diff --git a/src/tools/getCustomers.ts b/src/tools/getCustomers.ts index cadd1607..e350f20b 100644 --- a/src/tools/getCustomers.ts +++ b/src/tools/getCustomers.ts @@ -1,7 +1,7 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes } from "../lib/toolUtils.js"; -import { defineProjection, countOnlyParam } from "../lib/projection.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; /** Selectable fields for customers */ const customerProjection = defineProjection({ @@ -56,15 +56,7 @@ const getCustomers = { // Count-only mode: return just the count if (countOnly) { - const countQuery = ` - query GetCustomersCount($query: String) { - customersCount(query: $query) { count } - } - `; - const countData = (await shopifyClient.request(countQuery, { query: searchQuery })) as { - customersCount: { count: number }; - }; - return { count: countData.customersCount.count }; + return fetchCount(shopifyClient, "customersCount", searchQuery); } const query = ` diff --git a/src/tools/getOrders.ts b/src/tools/getOrders.ts index 2bcc16ed..07e9af74 100644 --- a/src/tools/getOrders.ts +++ b/src/tools/getOrders.ts @@ -1,7 +1,7 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; import { handleToolError, edgesToNodes, type ShopifyConnection } from "../lib/toolUtils.js"; -import { defineProjection, countOnlyParam } from "../lib/projection.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; import { formatOrderSummary } from "../lib/formatters.js"; /** Selectable fields for orders */ @@ -71,15 +71,7 @@ const getOrders = { // Count-only mode: return just the count if (countOnly) { - const countQuery = ` - query GetOrdersCount($query: String) { - ordersCount(query: $query) { count } - } - `; - const countData = (await shopifyClient.request(countQuery, { query: queryFilter })) as { - ordersCount: { count: number }; - }; - return { count: countData.ordersCount.count }; + return fetchCount(shopifyClient, "ordersCount", queryFilter); } const query = ` diff --git a/src/tools/getProducts.ts b/src/tools/getProducts.ts index e1dd905c..9f0946df 100644 --- a/src/tools/getProducts.ts +++ b/src/tools/getProducts.ts @@ -1,7 +1,7 @@ import type { GraphQLClient } from "graphql-request"; import { z } from "zod"; import { handleToolError } from "../lib/toolUtils.js"; -import { defineProjection, countOnlyParam } from "../lib/projection.js"; +import { defineProjection, countOnlyParam, fetchCount } from "../lib/projection.js"; /** Selectable fields for products */ const productProjection = defineProjection({ @@ -66,15 +66,7 @@ const getProducts = { // Count-only mode: return just the count if (countOnly) { - const countQuery = ` - query GetProductsCount($query: String) { - productsCount(query: $query) { count } - } - `; - const countData = (await shopifyClient.request(countQuery, { query: queryFilter })) as { - productsCount: { count: number }; - }; - return { count: countData.productsCount.count }; + return fetchCount(shopifyClient, "productsCount", queryFilter); } const query = ` From dc54e20b245b3c2af06c17e158c59e177a8658d5 Mon Sep 17 00:00:00 2001 From: Lucas Jahn Date: Tue, 23 Jun 2026 06:41:58 +0200 Subject: [PATCH 6/6] Fix fieldsParam example to use a field the resource actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared `fields` guidance hardcoded the example ["id", "title"], but orders use `name` and customers have no `title` field — an agent copying the example into get-orders/get-customers would hit a zod enum rejection. Derive the example field from the projection's own fields (first non-id key), so the example is always a valid selection for that tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/projection.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/projection.ts b/src/lib/projection.ts index 0cff6dc9..4cda9c2f 100644 --- a/src/lib/projection.ts +++ b/src/lib/projection.ts @@ -70,9 +70,12 @@ export function defineProjection(spec: Record): Projection { 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", "title"] returns only ${noun} GID and title. ` + + `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);