Skip to main content
Glama
aakarsh1t

Shopify Enterprise MCP Server

by aakarsh1t
README.md
# Shopify Enterprise MCP Server

Production-grade [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
**live Shopify Admin GraphQL data** to Microsoft Copilot Studio agents.

Every business question is answered by querying Shopify at request time. The server never returns
cached figures and instructs the connected agent never to answer from prior knowledge — inventory,
orders and sales change continuously, so a remembered number is a wrong number.

---

## Status

Complete. Twenty-one tools — seventeen read-only, four that change the store — implemented, unit
tested and verified against a live Shopify store.

| Area                                                    | State          |
| ------------------------------------------------------- | -------------- |
| Build, TypeScript strict mode, ESLint, Prettier         | ✅ Complete    |
| Configuration + fail-fast environment validation        | ✅ Complete    |
| Shopify authentication (client credentials + renewal)   | ✅ Complete    |
| Error hierarchy + RFC 9457 global handler               | ✅ Complete    |
| Structured logging + correlation context                | ✅ Complete    |
| Application Insights telemetry                          | ✅ Complete    |
| Shopify GraphQL client (retry, cost, throttle, paging)  | ✅ Complete    |
| MCP registry, server factory, Streamable HTTP transport | ✅ Complete    |
| Health / readiness / version endpoints                  | ✅ Complete    |
| All 21 MCP tools                                        | ✅ Complete    |
| Store-changing tools, gated and confirmed               | ✅ Complete    |
| Test suite                                              | ✅ 446 passing |

## Available MCP tools

**Catalogue**

| Tool                         | Answers                                                                                       |
| ---------------------------- | --------------------------------------------------------------------------------------------- |
| `search_products`            | "How many Nike shoes are in stock?", "Find products under 50", "What perfumes do we sell?"    |
| `get_product_details`        | Full record for one product: every variant, price, SKU, stock level, options and collections. |
| `recommend_related_products` | "What else would go with this?" — catalogue similarity, not purchase behaviour.               |
| `get_collections`            | "What collections do we have?", "Is that collection rule-based or curated?"                   |

**Orders, fulfilment and customers**

| Tool                            | Answers                                                                     |
| ------------------------------- | --------------------------------------------------------------------------- |
| `get_orders`                    | "How many orders this week?", "Show unfulfilled orders", "Any refunds?"     |
| `get_order_by_id`               | Everything about one order: line items, addresses, fulfilment, refunds.     |
| `get_fulfillments`              | "What has shipped?", "How long are we taking to despatch?", "No tracking?"  |
| `get_refunds`                   | "How much have we refunded?", "What came back, and was it restocked?"       |
| `search_customers`              | "Who are our customers?", "Find customers tagged VIP", "Customers in India" |
| `get_customer`                  | One customer's full profile, addresses and lifetime value.                  |
| `get_customer_purchase_history` | "What has this person bought?", "When did they last order?"                 |

**Inventory**

| Tool                     | Answers                                                           |
| ------------------------ | ----------------------------------------------------------------- |
| `get_inventory`          | "What's in stock?", "Stock level for SKU X", "Anything oversold?" |
| `get_low_stock_products` | "What's running low?", "What do we need to reorder?"              |

**Commercial**

| Tool                        | Answers                                                                    |
| --------------------------- | -------------------------------------------------------------------------- |
| `get_sales_summary`         | "How much did we sell last month?", "What's our average order value?"      |
| `get_best_selling_products` | "What are our top sellers?", "Which product made the most revenue?"        |
| `get_discounts`             | "What promotions are running?", "Is the free shipping offer still active?" |
| `get_abandoned_carts`       | "How many abandoned carts?", "What revenue are we losing at checkout?"     |

Every list tool filters and paginates **server-side in Shopify**. A price filter applied locally
could only narrow the page that happened to be fetched, so "products under 50" would silently come
to mean "products under 50 among the 20 that came back".

### Tools that change the store

| Tool                      | Effect                                                               |
| ------------------------- | -------------------------------------------------------------------- |
| `create_product`          | Adds a product. Created as **DRAFT** unless `ACTIVE` is asked for.   |
| `update_product`          | Changes title, description, vendor, type, tags or status.            |
| `update_inventory_levels` | Sets or adjusts stock on hand, which decides what customers can buy. |
| `create_refund`           | **Returns real money to a customer. Cannot be undone.**              |

Three independent controls stand in front of them:

1. **Registration gate.** `MCP_ENABLE_WRITE_TOOLS=false` withholds all four from `tools/list`
   entirely — an agent cannot call what was never published. It is a single App Service setting, so
   the capability can be withdrawn during an incident without a redeploy.
2. **Explicit confirmation.** Every one requires `confirm: true`. Called without it they change
   nothing and instead report exactly what _would_ change — for stock, after reading the live level,
   so the user approves real before-and-after figures rather than an intention.
3. **`userErrors` enforcement.** Shopify rejects a mutation with HTTP 200, a null payload and the
   reason in `userErrors`. Every mutation response is checked, so a write that did not happen is
   never reported as one that did.

`create_refund` adds two more: the amount is always computed by Shopify's own `suggestedRefund`
rather than by this server, and `MCP_MAX_REFUND_AMOUNT` can cap any single refund.

Every completed change writes an audit line carrying the correlation ID; refunds log at `warn`.

---

## Documentation

| Document                                                            | Covers                                                                      |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [architecture.md](docs/architecture.md)                             | Layering, request flow, and the reasoning behind each design decision       |
| [configuration.md](docs/configuration.md)                           | Every environment variable, production invariants, common misconfigurations |
| [deployment.md](docs/deployment.md)                                 | Full Azure App Service procedure, slots, scaling, rollback, checklist       |
| [copilot-studio.md](docs/copilot-studio.md)                         | Connector import, agent wiring, grounding instructions, troubleshooting     |
| [copilot-studio-connector.yaml](docs/copilot-studio-connector.yaml) | Power Platform custom connector definition                                  |
| [tools.md](docs/tools.md)                                           | Each tool with real request/response examples                               |
| [operations.md](docs/operations.md)                                 | Runbook: KQL queries, alerts, incident diagnosis, rotation                  |

---

## Architecture

```
┌──────────────────────┐   Streamable HTTP (JSON-RPC 2.0)   ┌────────────────────────────┐
│  Copilot Studio      │ ─────────────────────────────────► │  Azure App Service (Linux) │
│  custom connector    │        HTTPS + x-api-key           │  Fastify + MCP SDK         │
└──────────────────────┘                                    └─────────────┬──────────────┘
                                                                          │
                        ┌─────────────────────────────────────────────────┘
                        ▼
        ┌───────────────────────────┐   Correlation context (AsyncLocalStorage)
        │  MCP transport            │   flows through every layer below
        │  (stateless per request)  │
        └─────────────┬─────────────┘
                      ▼
        ┌───────────────────────────┐   Cross-cutting: validation, timing,
        │  Tool executor            │   envelope, error → result, telemetry
        └─────────────┬─────────────┘
                      ▼
        ┌───────────────────────────┐   Business logic only
        │  Service layer            │
        └─────────────┬─────────────┘
                      ▼
        ┌───────────────────────────┐   Retry · cost governor · pagination
        │  ShopifyGraphQLClient     │   error mapping · dependency telemetry
        └─────────────┬─────────────┘
                      ▼
             Shopify Admin GraphQL API
```

### Layer rules

- **Tools** contain no business logic. They validate input, call a service, and return a summary
  plus structured data.
- **Services** contain no MCP or HTTP concepts. They accept typed arguments and return domain
  objects.
- **The Shopify client** is the only egress point. Nothing else issues an outbound request.
- **Configuration** is read once, validated, frozen. No module touches `process.env` directly.

---

## Project structure

```
src/
  app/            Fastify composition root, plugins, routes, error handler
  config/         Environment schema, validated + frozen configuration
  mcp/
    registry/     Tool definitions and the process-wide tool catalogue
    server/       McpServer factory and the tool execution wrapper
    transport/    Streamable HTTP transport management
  tools/          MCP tool definitions (composition root)
  services/       Domain services (product, order, inventory, customer, …)
  shopify/
    graphql-client.ts   Retry, cost governance, error mapping, telemetry
    cost-governor.ts    Client-side model of Shopify's leaky bucket
    error-mapper.ts     HTTP + GraphQL failure classification
    pagination.ts       Bounded cursor pagination
    queries/            Reusable GraphQL documents
    types/              Connection, money and cost types
  middleware/
    auth/           API key authentication
    logging/        Pino logger and AsyncLocalStorage request context
    telemetry/      Application Insights behind a vendor-neutral port
  errors/         Error hierarchy, codes, normaliser
  schemas/        Shared Zod schemas
  utils/          Identifiers, timing, backoff
  types/          Shared type declarations
tests/
  unit/ integration/ mocks/ helpers/
docs/
```

---

## Getting started

### Prerequisites

- Node.js 22 LTS (the toolchain also runs on Node 24)
- npm 10+
- A Shopify app created in the Dev Dashboard, installed on your store

### Install and run

```bash
npm install
cp .env.example .env       # then fill in the Shopify values
npm run dev                # watch mode
```

```bash
npm run build && npm start # production build
```

### Verify

```bash
npm run verify             # typecheck + lint + tests
npm run test:coverage      # coverage report
```

```bash
curl http://localhost:8080/health
curl http://localhost:8080/ready
curl http://localhost:8080/version
```

MCP handshake:

```bash
curl -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2025-06-18","capabilities":{},
        "clientInfo":{"name":"curl","version":"1.0"}}}'
```

---

## Getting the Shopify credentials

**Shopify has retired legacy custom apps.** The store admin no longer offers "Create an app", and
there is no long-lived Admin API token to copy anywhere. Apps are now created in the
[Shopify Dev Dashboard](https://dev.shopify.com), and credentials are exchanged for a short-lived
access token through the OAuth **client credentials grant**.

1. Create an app in the Dev Dashboard and install it on your store.
2. Open **your app → Settings → Credentials** and copy the **Client ID** and **Secret** (`shpss_…`).
3. Set them as `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET`.

The server exchanges them for a token at startup and renews it automatically. Verify manually with:

```bash
curl -X POST https://<store>.myshopify.com/admin/oauth/access_token \
  -d grant_type=client_credentials \
  -d client_id=<CLIENT_ID> -d client_secret=<CLIENT_SECRET>
# -> {"access_token":"shpat_…","scope":"…","expires_in":86399}
```

> **`expires_in` is 24 hours.** This is why a pasted `SHOPIFY_ADMIN_ACCESS_TOKEN` is rejected in
> production: it works the day it is set and starts returning HTTP 401 the next.

> **The grant requires the app and the store to be in the same Shopify organization.** If they are
> not, Shopify answers `invalid_client` — which looks identical to a wrong secret.

Required read scopes: `read_products`, `read_inventory`, `read_locations`, `read_orders`,
`read_all_orders`, `read_customers`, `read_discounts`, `read_price_rules`, `read_analytics`,
`read_marketing_events`.

> Grant **read** scopes only. This server is read-only and advertises `readOnlyHint: true`, but the
> credential itself is not constrained by that — an app granted `write_products` holds the ability
> to delete a catalogue, on a connection whose other end is an LLM.

---

## Configuration

Every variable is validated at startup. A misconfigured deployment fails immediately with **every**
offending variable listed at once, rather than failing on the first one or booting half-configured.

See [.env.example](.env.example) for the full annotated contract. The essentials:

| Variable                        | Required                      | Default   | Purpose                                  |
| ------------------------------- | ----------------------------- | --------- | ---------------------------------------- |
| `SHOPIFY_STORE`                 | Yes                           | —         | Store handle or `*.myshopify.com` domain |
| `SHOPIFY_CLIENT_ID`             | Yes¹                          | —         | Dev Dashboard app client ID              |
| `SHOPIFY_CLIENT_SECRET`         | Yes¹                          | —         | Dev Dashboard app secret (`shpss_…`)     |
| `SHOPIFY_ADMIN_ACCESS_TOKEN`    | Local dev only¹               | —         | Pre-minted token; expires in 24h         |
| `SHOPIFY_API_VERSION`           | No                            | `2026-07` | Quarterly Admin API version              |
| `PORT`                          | No                            | `8080`    | Injected by App Service                  |
| `MCP_HTTP_PATH`                 | No                            | `/mcp`    | Must match the connector's declared path |
| `MCP_API_KEY`                   | **In production**             | —         | Shared secret for the connector          |
| `MCP_ALLOWED_HOSTS`             | **In production**             | —         | DNS-rebinding protection allowlist       |
| `APPINSIGHTS_CONNECTION_STRING` | **In production**             | —         | Application Insights                     |
| `LOG_PRETTY`                    | Must be `false` in production | `false`   | Human-readable logs (local only)         |

¹ Supply **either** the client credentials pair **or** a static token. Startup fails if neither is
present. When both are given, client credentials win — they are the only renewable option — and the
active mode is stated in the startup log and on `GET /version`.

### Production invariants

The server **refuses to start** in `NODE_ENV=production` when any of these hold:

- no `MCP_API_KEY` — an unauthenticated MCP endpoint exposes the store's entire order and customer
  dataset to anonymous callers;
- no `APPINSIGHTS_CONNECTION_STRING`;
- no `MCP_ALLOWED_HOSTS`;
- `LOG_PRETTY=true`;
- a static `SHOPIFY_ADMIN_ACCESS_TOKEN` instead of client credentials — it would expire within 24
  hours with no way to renew itself.

These are configuration errors, not warnings.

---

## Operational endpoints

| Endpoint   | Question answered                       | Behaviour                                                |
| ---------- | --------------------------------------- | -------------------------------------------------------- |
| `/health`  | Is the process alive?                   | Always 200 while running. **No I/O.**                    |
| `/ready`   | Can this instance serve a request?      | 200 / 503 based on a cached, 1-cost-point Shopify probe. |
| `/version` | Which build, store, API version, tools? | Build identity and the published tool catalogue.         |

> Configure the **App Service health check** against `/health`, not `/ready`. Pointing the platform
> probe at a dependency-aware endpoint makes App Service recycle healthy workers during a Shopify
> incident, turning a degraded service into an outage. Use `/ready` for deployment gates and load
> balancer decisions.

---

## Design decisions worth knowing

**Stateless MCP sessions by default.** A fresh `McpServer` is built per request, so any App Service
instance can serve any request and scale-out needs no ARR affinity. `MCP_SESSION_MODE=stateful` is
available but requires session affinity.

**Tokens are minted and renewed by the server.** Shopify's client credentials grant issues 24-hour
tokens. The provider caches one in memory, renews it 5 minutes before expiry, and collapses
concurrent callers onto a single in-flight mint so a burst of tool calls on a cold instance does not
trigger a token request each. If Shopify rejects a token early — after a credential rotation — the
client discards it and retries exactly once, outside the retry budget, so recovery also works for
callers that disable retries (such as the readiness probe).

**Client-side cost governance.** Shopify meters GraphQL by query cost against a leaky bucket
(1000 points refilling at 50/s on standard plans; the live test store reported 4000 at 200/s). The
client reads `extensions.cost` from every response, adopts whatever limits the store actually
reports, and waits for headroom _before_ dispatching — rather than absorbing a `THROTTLED` error and
retrying into an empty bucket.

**Search filters come from an allowlist.** Shopify silently ignores filter fields it does not
recognise: `price:<50` filters correctly, while `variants.price:<50` is dropped and returns
unfiltered products with HTTP 200 and no error at all. An agent would then relay those results as
though they satisfied the filter. Every field name the query builder emits is drawn from a fixed
list verified against the live API, and values are escaped and quoted.

**Stock figures are reported honestly.** Shopify returns `totalInventory: null` for untracked
products, which is not the same as zero in stock, so the domain model keeps a separate
`inventoryTracked` flag. Live data also showed a product reporting `totalInventory: 0` while its
variants stood at -31, -19 and -23; the summary states the oversell rather than repeating the zero.

**Throttling arrives as HTTP 200.** The Admin API reports throttling as a successful HTTP response
with `errors[].extensions.code === "THROTTLED"`. The client checks the GraphQL error array _and_ the
HTTP status, and classifies each failure mode for retryability independently.

**Tool failures are returned, not thrown.** A thrown error becomes a JSON-RPC protocol error, which
Copilot Studio surfaces as an opaque connector failure the agent cannot reason about. Tool failures
are returned as `isError: true` results carrying the reason, retryability, remediation and
correlation ID, so the agent can explain the problem to the user.

**Uniform response envelope.** Every tool returns `{ summary, resultCount, truncated, data }`.
`summary` gives the agent a grounded sentence it can relay verbatim; `truncated` lets it state
honestly that a figure is a bounded sample rather than a store-wide total.

**Correlation without plumbing.** Correlation IDs propagate through `AsyncLocalStorage`, so the
domain layer carries no context parameters, yet every log line, telemetry item and Shopify call is
attributable to the originating request.

---

## Copilot Studio compatibility

- **Streamable HTTP only.** Copilot Studio dropped SSE transport support in August 2025.
- **Custom connector** with `x-ms-agentic-protocol: mcp-streamable-1.0` on a POST operation.
- **HTTPS**, terminated by App Service.
- **Accept header shim.** The MCP specification requires clients to accept both `application/json`
  and `text/event-stream`. Power Platform connectors may forward only `application/json`, which the
  SDK rejects with HTTP 406 — surfacing in Copilot Studio as an unexplained connector failure. The
  MCP route widens the header (on `rawHeaders`, which is what the transport actually reads).
- **Tool metadata** is validated at registration: `snake_case` names and descriptions long enough to
  drive orchestrator routing. Both input and output schemas are published.

Connector setup, the OpenAPI schema and Azure deployment steps are documented in a later milestone.

---

## Testing

```bash
npm test              # all suites
npm run test:unit
npm run test:integration
npm run test:coverage
```

Integration tests drive the real Fastify instance via `app.inject()` — correlation hooks, security
plugins, error handler, route wiring and the MCP SDK transport all execute; only the network is
absent. Shopify is substituted at the transport seam, so retry sequencing, cost accounting and error
mapping are exercised for real.

---

## Scripts

| Script              | Purpose                         |
| ------------------- | ------------------------------- |
| `npm run dev`       | Watch mode via tsx              |
| `npm run build`     | Clean + compile to `dist/`      |
| `npm start`         | Run the compiled server         |
| `npm run verify`    | Typecheck + lint + test         |
| `npm run typecheck` | `tsc --noEmit`                  |
| `npm run lint`      | ESLint, zero warnings tolerated |
| `npm run format`    | Prettier                        |

---

## Troubleshooting

| Symptom                                              | Cause and fix                                                                                                    |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Startup fails listing environment variables          | Working as designed. Fix every listed variable; all problems are reported at once.                               |
| `/ready` reports `SHOPIFY_AUTHENTICATION_FAILED`     | Wrong client credentials, app and store in different Shopify organizations, or a missing read scope.             |
| `/ready` reports `SHOPIFY_API_ERROR` with a 404 hint | `SHOPIFY_STORE` or `SHOPIFY_API_VERSION` is wrong; an unsupported version returns 404.                           |
| HTTP 406 from `/mcp`                                 | The client sent a narrow `Accept` header. The shim handles the common case; check for a proxy rewriting headers. |
| HTTP 401 from `/mcp`                                 | `MCP_API_KEY` mismatch. The connector must send it as `x-api-key` or `Authorization: Bearer`.                    |
| Frequent `SHOPIFY_THROTTLED` warnings                | The store's cost budget is under pressure. Raise `SHOPIFY_MIN_COST_BUFFER` or lower page sizes.                  |
| Logs are unreadable JSON locally                     | Set `LOG_PRETTY=true`. It is rejected in production, where JSON is required.                                     |

Every error response and tool failure carries a **correlation ID**. Search Application Insights on
`customDimensions.correlationId` to retrieve the full request trace, including each Shopify call,
its cost and its outcome.