Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -164,15 +169,17 @@ shopify-mcp --clientId=<ID> --clientSecret=<SECRET> --domain=<YOUR_SHOP>.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)

Expand Down Expand Up @@ -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):
Expand Down
124 changes: 124 additions & 0 deletions src/lib/projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { GraphQLClient } from "graphql-request";
import { z } from "zod";
import { edgesToNodes } from "./toolUtils.js";

// ── Field projection ──────────────────────────────────────────────────
//
// A Projection is the single source of truth for a tool's selectable fields.
// Each field maps to its GraphQL fragment. From that one definition a
// Projection derives:
// - the GraphQL selection set (selection)
// - the zod `fields` parameter, including the shared agent guidance (fieldsParam)
// - response normalisation that flattens connection-shaped values (normalize)
//
// This keeps the fragment, the enum, and the docs for a field in one place
// instead of spread across a field map, an enum, and a describe string in every
// tool. `normalize` flattens any top-level Shopify connection ({ edges: [...] })
// on a node to an array of nodes — detected by shape, so field aliases (e.g. a
// fragment using addressesV2 under the key "addresses") are handled correctly.

const SHARED_FIELDS_GUIDANCE =
"IMPORTANT: Always specify this to minimize token usage and avoid flooding context with unnecessary data. " +
"Only the listed fields will be fetched from the API and returned. 'id' is always included. " +
"If you are unsure which fields are needed, ask the user before fetching all fields. ";

const COUNT_ONLY_GUIDANCE =
"IMPORTANT: Use this to check result set size before fetching data. " +
"Returns only { count: N } without any resource data, saving significant context. " +
"Recommended before paginating large result sets.";

export interface Projection {
/** All selectable field names, in declaration order ('id' is always implicitly selected). */
readonly fieldNames: string[];

/**
* GraphQL selection set for the requested fields (or all when omitted).
* Always includes 'id'. Joined with newlines — GraphQL is whitespace-insensitive,
* so callers can interpolate this at any indentation.
*/
selection(fields?: string[]): string;

/**
* Zod parameter for the `fields` argument, carrying the shared agent guidance.
* @param opts.noun word used in the example sentence (e.g. "product", "collection").
* @param opts.extra extra guidance appended before the "Available:" list.
*/
fieldsParam(opts?: { noun?: string; extra?: string }): z.ZodOptional<
z.ZodArray<z.ZodEnum<[string, ...string[]]>>
>;

/** Flatten any top-level connection-shaped value ({ edges: [...] }) on a node to an array of nodes. */
normalize<T extends Record<string, unknown>>(node: T): T;
}

/** Build a Projection from a map of field name → GraphQL fragment. */
export function defineProjection(spec: Record<string, string>): Projection {
const names = Object.keys(spec);

return {
fieldNames: names,

selection(fields?: string[]): string {
const selected = fields ?? names;
const ordered = [...new Set(["id", ...selected])];
return ordered
.map((n) => spec[n])
.filter(Boolean)
.join("\n");
},

fieldsParam(opts: { noun?: string; extra?: string } = {}) {
const noun = opts.noun ?? "resource";
const enumNames = names as [string, ...string[]];
// Build the example from this projection's own fields so it is always a
// valid selection (not every resource has a "title" field).
const exampleField = names.find((n) => n !== "id") ?? "id";
const description =
SHARED_FIELDS_GUIDANCE +
`Example: ["id", "${exampleField}"] returns only ${noun} GID and ${exampleField}. ` +
(opts.extra ? `${opts.extra} ` : "") +
`Available: ${names.join(", ")}`;
return z.array(z.enum(enumNames)).optional().describe(description);
},

normalize<T extends Record<string, unknown>>(node: T): T {
const result: Record<string, unknown> = { ...node };
for (const [key, value] of Object.entries(result)) {
if (
value &&
typeof value === "object" &&
Array.isArray((value as { edges?: unknown[] }).edges)
) {
result[key] = edgesToNodes(value as never);
}
}
return result as T;
},
};
}

/** Shared zod parameter for the `countOnly` argument. */
export function countOnlyParam() {
return z.boolean().optional().describe(COUNT_ONLY_GUIDANCE);
}

/**
* Run a Shopify *Count query (e.g. "productsCount", "ordersCount") and return { count }.
* Backs the `countOnly` mode of the list tools.
*/
export async function fetchCount(
client: GraphQLClient,
countField: string,
query?: string,
): Promise<{ count: number }> {
const countQuery = `
query Count($query: String) {
${countField}(query: $query) { count }
}
`;
const data = (await client.request(countQuery, { query })) as Record<
string,
{ count: number }
>;
return { count: data[countField].count };
}
2 changes: 1 addition & 1 deletion src/tools/createDraftOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
6 changes: 3 additions & 3 deletions src/tools/createFulfillment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<typeof CreateFulfillmentInputSchema>;
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/createRefund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)"),
});

Expand Down
2 changes: 1 addition & 1 deletion src/tools/deleteCustomer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof DeleteCustomerInputSchema>;
Expand Down
2 changes: 1 addition & 1 deletion src/tools/deleteMetafields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
})
Expand Down
Loading
Loading