Skip to main content
22 toolsOAuth
shopify_example.py
Products, orders, customers and the shop itself — the read-and-triage core of the Admin API. Fulfillment, inventory adjustment, discounts and the Storefront API are not modelled, and neither are bulk operations: Shopify’s answer to a large export is an asynchronous job you poll and then download, and that is orchestration. Shopify is the second GraphQL pack. Read Linear first for what the two have in common; this page is about what Shopify breaks that Linear does not.

Authenticating

Get credentials: create an app in Shopify’s Dev Dashboard, install it on your store, and copy its client ID and secret — Set up Shopify has every screen. The pack trades them for an Admin API token and trades them again when it runs out, so SHOPIFY_SHOP, SHOPIFY_CLIENT_ID and SHOPIFY_CLIENT_SECRET are all it needs. This pack takes an OAuth token, sent bare in X-Shopify-Access-Token rather than in Authorization. Its configure() takes the installation as well as the credential, so the shapes it accepts are written out below. configure() takes the store and one credential, because a Shopify installation is a store as well as a token. shop is the subdomain — "my-store" for my-store.myshopify.com — and the full domain or a complete URL is accepted too, since all three appear in Shopify’s own documentation and admin UI. The credential is one of three: configure() is optional when $SHOPIFY_SHOP is set beside either $SHOPIFY_CLIENT_ID and $SHOPIFY_CLIENT_SECRET or $SHOPIFY_ACCESS_TOKEN. When both are set the pair wins, because it can always produce a valid token and a raw one left in the environment usually cannot. That is what keeps the MCP server working past its first day. The pair is Shopify’s client credentials grant: the pack posts it to the store’s own /admin/oauth/access_token, sends the token it gets back in X-Shopify-Access-Token, and asks again shortly before the token expires. It is the same OAuth2Client every other OAuth pack uses, pointed at a token endpoint on the store’s host. Built by hand, which is what the client below takes as credentials:
shopify_credentials.py
Shopify grants this only when the app and the store belong to the same organisation, so it is a credential for your own stores. An app installed on merchants’ stores goes through Shopify’s authorization code flow and holds a token per store, which is your application’s to keep.

Getting the client ID and secret

Since January 2026 a new app is made in Shopify’s Dev Dashboard rather than in the store admin, and its Admin API token is minted on request rather than shown once on a settings page. Set up Shopify has these steps with a screenshot for each screen:
  1. In the Dev Dashboard, Create app.
  2. On the app’s version, choose the Admin API access scopes for what the tools you hand out touch — products, orders, customers, inventory, locations, draft orders, fulfillments; read for the readers, write for the rest — and Release it. A call that needs one you left out names it in the error: “Required access: read_products access scope”.
  3. Install the app on your store. The app and the store must belong to the same organisation, or Shopify refuses the client credentials grant.
  4. Copy the Client ID and Client secret from the app’s Settings:
The secret is the credential that mints every token, so it is kept like one. A store with a custom app made in the admin before 2026 keeps that app’s shpat_ token, which still works and does not expire: set it as $SHOPIFY_ACCESS_TOKEN instead of the pair.

The client

oauth_tool_factory is the whole client: a thin wrapper over httpx that attaches your token and these endpoint constants to each request. ShopifyAPI does not enter your dependency tree.
credentials is the provider you built in Authenticating. A pack takes it through configure(); a client you build takes the same object as credential_provider, and has no configure() of its own.

Paging through a list

A cursor belongs to the tool that returns it, so it is declared on that tool’s builder call:
shopify_pagination.py
Pagination is declared on products_list, orders_list, customers_list and locations_list. The other 18 take no cursor.

Tools

Each is a Tool, called with ainvoke as in the snippet above. The name links to its parameters, its response and what it costs. The API version sits in the path, and pinning it is not optional: Shopify ships a new version quarterly and supports each for twelve months, so an unpinned integration is one that changes under you. shopify.API_VERSION is the version these documents were written against.

A host that is not a constant

Every other pack here has a fixed base URL. A Shopify store lives at https://{shop}.myshopify.com/, so the host is only known once someone installs the app. base_url therefore takes a callable, resolved on every request:
shopify_resolve_base_url.py
The host must never be a schema field. A Path() parameter for the subdomain would let the model choose which server the request goes to, which is the one thing a boundary cannot permit. An unresolved base URL raises CredentialError before anything leaves the process, the same way an unconfigured credential does. Zendesk, Atlassian and self-hosted GitLab have the same shape. See the wire contract.

Failure hides in userErrors

As with Linear, GraphQL’s errors array only carries problems with the document. A mutation Shopify ran and then refused — a duplicate handle, a customer with no contact details — returns HTTP 200, no errors, and a populated userErrors:
shopify_user_errors.json
errors_field takes several paths, so one declaration covers both places:
shopify_envelope.py
A query has no userErrors, and a successful mutation has an empty one, so neither matches. The error message names the resolved path, so it says which write was declined without the envelope knowing anything about Shopify.

Money comes back as a MoneyBag

Shopify returns every price as {"shopMoney": {"amount": "10.00", "currencyCode": "USD"}}, because a store can present one currency and settle in another. Two levels of nesting per price, on every line item of every order, is a lot of envelope for a number. Every tool here runs an unwrap handler that strips GraphQL’s data and operation name and flattens the shop-currency side to "10.00 USD". The walk covers the whole payload, since prices appear on the order, on each line item, and on a customer’s lifetime spend.

Gotchas

product_get, order_get and customer_get take a global id — gid://shopify/Product/123456 — not the bare number from the admin URL. The list tools return ids in that form, which is the intended way to get one.
Shopify’s query argument takes its own syntax rather than a filter object, so it cannot be typed the way Linear’s IssueFilter can. The supported qualifiers are documented on each field instead: financial_status:paid fulfillment_status:unshipped for orders waiting to ship, status:active vendor:Acme for products, orders_count:>5 for customers.
Shopify serves an app holding read_orders only the last 60 days of a store’s orders. Everything older needs read_all_orders, which Shopify grants by review rather than on request.The truncation is silent — no error, no marker on the page, just a shorter history than the store has — so orders_list says so in the description the model reads, and a created_at: window reaching further back is called out on the query field. An empty result is not evidence of an empty history.
Shopify prices each query by the fields it selects, so no constant quota_cost would be truthful — the cost of a call depends on the page size it is invoked with. The selection sets in charter.packs.shopify.queries are deliberately narrow for the same reason, and keeping first small is worth doing.
Shopify creates products unpublished. product_create returns successfully and the product is invisible to shoppers until it is published to a sales channel, which is a separate operation and is not modelled here.
customer_create with neither is refused — and refused through userErrors, at HTTP 200, which is exactly the case the envelope exists for.
product_update overwrites rather than merges, the same as Linear’s label_ids and GitHub’s labels. Read, union, send.