@theethosteam/shopify-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/bin/shop.mjs +3 -0
- package/bin/shopify-mcp.mjs +3 -0
- package/cli.ts +106 -0
- package/core/client.ts +155 -0
- package/core/creds.ts +54 -0
- package/core/env.ts +53 -0
- package/core/register.ts +244 -0
- package/core/shopify.ts +312 -0
- package/mcp.ts +15 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @theethosteam/shopify-mcp
|
|
2
|
+
|
|
3
|
+
Multi-store read + write MCP server (and CLI) for the **Shopify Admin GraphQL
|
|
4
|
+
API**. Built for agency use: one server, every client store, and every response
|
|
5
|
+
names the store it ran against so data can never be misattributed.
|
|
6
|
+
|
|
7
|
+
- **19 typed tools** — shop identity, products (CRUD), orders, customers,
|
|
8
|
+
collections, locations, themes, webhooks — plus a **raw GraphQL passthrough**
|
|
9
|
+
for everything else.
|
|
10
|
+
- **Tiered writes** — routine writes (create/update product, create webhook)
|
|
11
|
+
execute; **deletes are dry-run unless `confirm:true`**; raw-passthrough
|
|
12
|
+
**mutations are refused unless `confirm:true`**.
|
|
13
|
+
- **Multi-store** — one env entry per store; `shopify_use_store` switches the
|
|
14
|
+
session default (request-scoped on the remote host, so users never clobber
|
|
15
|
+
each other).
|
|
16
|
+
- Runs three ways, like every Ethos connector: **remote connector**
|
|
17
|
+
(`https://mcp.theethosteam.com/api/shopify/mcp`), **stdio via npx**, and
|
|
18
|
+
**CLI** (`shop`).
|
|
19
|
+
|
|
20
|
+
## Auth: per-store custom-app Admin tokens
|
|
21
|
+
|
|
22
|
+
No OAuth dance. Each store gets a [custom app](https://help.shopify.com/en/manual/apps/app-types/custom-apps):
|
|
23
|
+
store admin → **Settings → Apps and sales channels → Develop apps → Create app**
|
|
24
|
+
→ enable the Admin API scopes you need (start with `read_products,
|
|
25
|
+
write_products, read_orders, read_customers, read_themes, read_locations,
|
|
26
|
+
write_webhooks`) → **Install app** → copy the Admin API access token
|
|
27
|
+
(`shpat_…`). The token is shown **once**.
|
|
28
|
+
|
|
29
|
+
## Env
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# One entry per store — alias is the suffix, lowercased ("bia" here):
|
|
33
|
+
SHOPIFY_STORE_BIA="born-in-apparel.myshopify.com|shpat_xxx"
|
|
34
|
+
SHOPIFY_STORE_A2="a2-phone-repair.myshopify.com|shpat_yyy"
|
|
35
|
+
|
|
36
|
+
# Single-store shorthand (registers as alias "default"):
|
|
37
|
+
SHOPIFY_STORE_DOMAIN=born-in-apparel.myshopify.com
|
|
38
|
+
SHOPIFY_ADMIN_TOKEN=shpat_xxx
|
|
39
|
+
|
|
40
|
+
# Optional:
|
|
41
|
+
SHOPIFY_ACTIVE_STORE=bia # default store when several are configured
|
|
42
|
+
SHOPIFY_API_VERSION=2026-07 # defaults to the pinned stable
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
With multiple stores and no active store set, tools **refuse rather than
|
|
46
|
+
guess** — pass `store:"bia"` or call `shopify_use_store` first.
|
|
47
|
+
|
|
48
|
+
## Run
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# MCP over stdio
|
|
52
|
+
npx -y @theethosteam/shopify-mcp
|
|
53
|
+
|
|
54
|
+
# CLI
|
|
55
|
+
npx -y -p @theethosteam/shopify-mcp shop whoami
|
|
56
|
+
npx -y -p @theethosteam/shopify-mcp shop products -q "status:active" -s bia
|
|
57
|
+
npx -y -p @theethosteam/shopify-mcp shop orders -q "financial_status:paid"
|
|
58
|
+
npx -y -p @theethosteam/shopify-mcp shop graphql '{ shop { name } }'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Claude Code:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude mcp add shopify -e SHOPIFY_STORE_DOMAIN=<shop>.myshopify.com -e SHOPIFY_ADMIN_TOKEN=shpat_... -- npx -y @theethosteam/shopify-mcp
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Tools
|
|
68
|
+
|
|
69
|
+
| Tool | Tier |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `shopify_whoami` · `shopify_list_stores` · `shopify_use_store` | awareness |
|
|
72
|
+
| `shopify_list_products` · `shopify_get_product` | read |
|
|
73
|
+
| `shopify_create_product` · `shopify_update_product` | routine write |
|
|
74
|
+
| `shopify_delete_product` | **confirm-gated** |
|
|
75
|
+
| `shopify_list_orders` · `shopify_get_order` | read |
|
|
76
|
+
| `shopify_list_customers` · `shopify_get_customer` | read |
|
|
77
|
+
| `shopify_list_collections` · `shopify_list_locations` · `shopify_list_themes` | read |
|
|
78
|
+
| `shopify_list_webhooks` · `shopify_create_webhook` | read / routine |
|
|
79
|
+
| `shopify_delete_webhook` | **confirm-gated** |
|
|
80
|
+
| `shopify_graphql` | read free / **mutations confirm-gated** |
|
|
81
|
+
|
|
82
|
+
List queries accept [Shopify search syntax](https://shopify.dev/docs/api/usage/search-syntax)
|
|
83
|
+
(`status:active`, `financial_status:paid`, `created_at:>2026-08-01`, …) and
|
|
84
|
+
cursor pagination (`first` ≤ 50, `after` from the previous page's
|
|
85
|
+
`pageInfo.endCursor`).
|
|
86
|
+
|
|
87
|
+
## Notes
|
|
88
|
+
|
|
89
|
+
- **API version** is pinned per release (`LATEST_STABLE` in `core/client.ts`);
|
|
90
|
+
Shopify versions the Admin API quarterly and supports each for 12 months —
|
|
91
|
+
bump the pin with a normal version bump.
|
|
92
|
+
- Throttled calls (`THROTTLED`) retry once automatically.
|
|
93
|
+
- Mutation soft failures (`userErrors`) surface as tool errors with field paths.
|
|
94
|
+
- The remote host's **user-mode (OAuth) logins carry no Shopify creds yet** —
|
|
95
|
+
Shopify tools on `mcp.theethosteam.com` work in bearer/admin mode (deployment
|
|
96
|
+
env). Per-user store grants would follow the `ghl_pits` pattern in
|
|
97
|
+
`connector_users` when needed.
|
package/bin/shop.mjs
ADDED
package/cli.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { loadEnv } from "./core/env.js";
|
|
3
|
+
import * as shopify from "./core/shopify.js";
|
|
4
|
+
|
|
5
|
+
loadEnv();
|
|
6
|
+
|
|
7
|
+
const out = (data: unknown) => console.log(JSON.stringify(data, null, 2));
|
|
8
|
+
const run = (fn: () => Promise<unknown> | unknown) =>
|
|
9
|
+
Promise.resolve()
|
|
10
|
+
.then(fn)
|
|
11
|
+
.then(out)
|
|
12
|
+
.catch((e: any) => {
|
|
13
|
+
console.error("ERROR:", e?.message ?? String(e));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name("shop")
|
|
20
|
+
.description("Shopify Admin CLI — multi-store reads + guarded writes over per-store custom-app tokens.")
|
|
21
|
+
.version("0.1.0");
|
|
22
|
+
|
|
23
|
+
const st = (cmd: Command) => cmd.option("-s, --store <aliasOrDomain>", "store alias or *.myshopify.com domain");
|
|
24
|
+
|
|
25
|
+
// ---- Awareness -------------------------------------------------------------
|
|
26
|
+
st(program.command("whoami")).description("Active store + shop identity + API version").action((o: any) => run(() => shopify.whoami(o.store)));
|
|
27
|
+
program.command("stores").description("All configured stores").action(() => run(() => shopify.stores()));
|
|
28
|
+
|
|
29
|
+
// ---- Reads -----------------------------------------------------------------
|
|
30
|
+
st(program.command("products"))
|
|
31
|
+
.description("List/search products")
|
|
32
|
+
.option("-q, --query <s>", "Shopify search syntax, e.g. 'status:active'")
|
|
33
|
+
.option("--first <n>")
|
|
34
|
+
.option("--after <cursor>")
|
|
35
|
+
.action((o: any) => run(() => shopify.listProducts({ query: o.query, first: o.first ? Number(o.first) : undefined, after: o.after, store: o.store })));
|
|
36
|
+
st(program.command("product <idOrHandle>"))
|
|
37
|
+
.description("One product in full (numeric id, gid, or handle)")
|
|
38
|
+
.action((idOrHandle: string, o: any) =>
|
|
39
|
+
run(() =>
|
|
40
|
+
/^\d+$|^gid:/.test(idOrHandle)
|
|
41
|
+
? shopify.getProduct({ id: idOrHandle, store: o.store })
|
|
42
|
+
: shopify.getProduct({ handle: idOrHandle, store: o.store }),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
st(program.command("orders"))
|
|
46
|
+
.description("List orders, newest first")
|
|
47
|
+
.option("-q, --query <s>", "e.g. 'financial_status:paid'")
|
|
48
|
+
.option("--first <n>")
|
|
49
|
+
.option("--after <cursor>")
|
|
50
|
+
.action((o: any) => run(() => shopify.listOrders({ query: o.query, first: o.first ? Number(o.first) : undefined, after: o.after, store: o.store })));
|
|
51
|
+
st(program.command("order <id>")).description("One order in full").action((id: string, o: any) => run(() => shopify.getOrder(id, o.store)));
|
|
52
|
+
st(program.command("customers"))
|
|
53
|
+
.description("List/search customers")
|
|
54
|
+
.option("-q, --query <s>")
|
|
55
|
+
.option("--first <n>")
|
|
56
|
+
.option("--after <cursor>")
|
|
57
|
+
.action((o: any) => run(() => shopify.listCustomers({ query: o.query, first: o.first ? Number(o.first) : undefined, after: o.after, store: o.store })));
|
|
58
|
+
st(program.command("customer <id>")).description("One customer in full").action((id: string, o: any) => run(() => shopify.getCustomer(id, o.store)));
|
|
59
|
+
st(program.command("collections")).description("List collections").action((o: any) => run(() => shopify.listCollections({ store: o.store })));
|
|
60
|
+
st(program.command("locations")).description("Inventory locations").action((o: any) => run(() => shopify.listLocations(o.store)));
|
|
61
|
+
st(program.command("themes")).description("Online-store themes").action((o: any) => run(() => shopify.listThemes(o.store)));
|
|
62
|
+
st(program.command("webhooks")).description("Webhook subscriptions").action((o: any) => run(() => shopify.listWebhooks(o.store)));
|
|
63
|
+
|
|
64
|
+
// ---- Writes (destructive ones need --confirm) ------------------------------
|
|
65
|
+
st(program.command("create-product <title>"))
|
|
66
|
+
.description("Create a product (add --status DRAFT while iterating)")
|
|
67
|
+
.option("--status <s>", "ACTIVE|DRAFT|ARCHIVED")
|
|
68
|
+
.option("--vendor <s>")
|
|
69
|
+
.option("--type <s>", "productType")
|
|
70
|
+
.option("--tags <csv>")
|
|
71
|
+
.action((title: string, o: any) =>
|
|
72
|
+
run(() =>
|
|
73
|
+
shopify.createProduct(
|
|
74
|
+
{
|
|
75
|
+
title,
|
|
76
|
+
status: o.status,
|
|
77
|
+
vendor: o.vendor,
|
|
78
|
+
productType: o.type,
|
|
79
|
+
tags: o.tags ? String(o.tags).split(",").map((t: string) => t.trim()) : undefined,
|
|
80
|
+
},
|
|
81
|
+
o.store,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
st(program.command("delete-product <id>"))
|
|
86
|
+
.description("Delete a product (dry-run without --confirm)")
|
|
87
|
+
.option("--confirm")
|
|
88
|
+
.action((id: string, o: any) =>
|
|
89
|
+
run(() => (o.confirm ? shopify.deleteProduct(id, o.store) : shopify.getProduct({ id, store: o.store }).then((p) => ({ dryRun: true, wouldDelete: p })))),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// ---- Raw -------------------------------------------------------------------
|
|
93
|
+
st(program.command("graphql <query>"))
|
|
94
|
+
.description("Raw Admin GraphQL (mutations need --confirm)")
|
|
95
|
+
.option("-v, --variables <json>")
|
|
96
|
+
.option("--confirm")
|
|
97
|
+
.action((query: string, o: any) =>
|
|
98
|
+
run(() => {
|
|
99
|
+
if (shopify.isMutation(query) && !o.confirm) {
|
|
100
|
+
return { refused: true, note: "Mutation — re-run with --confirm." };
|
|
101
|
+
}
|
|
102
|
+
return shopify.raw(query, o.variables ? JSON.parse(o.variables) : undefined, o.store);
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
program.parseAsync(process.argv);
|
package/core/client.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { credKeys, getCred, setCred } from "./creds.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Store resolution + Admin GraphQL transport.
|
|
5
|
+
*
|
|
6
|
+
* Credential model (mirrors ghl-mcp's per-location PITs):
|
|
7
|
+
* SHOPIFY_STORE_<ALIAS> = "<shop>.myshopify.com|shpat_..." one entry per store
|
|
8
|
+
* SHOPIFY_STORE_DOMAIN + SHOPIFY_ADMIN_TOKEN single-store shorthand
|
|
9
|
+
* (registered under alias "default")
|
|
10
|
+
* SHOPIFY_ACTIVE_STORE = alias or domain session default (use_store writes it)
|
|
11
|
+
* SHOPIFY_API_VERSION defaults to LATEST_STABLE
|
|
12
|
+
*
|
|
13
|
+
* Tokens are Admin API access tokens from a per-store custom app
|
|
14
|
+
* (store admin → Settings → Apps and sales channels → Develop apps).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const LATEST_STABLE = "2026-07";
|
|
18
|
+
|
|
19
|
+
export class ShopifyError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
readonly status?: number,
|
|
23
|
+
readonly detail?: unknown,
|
|
24
|
+
) {
|
|
25
|
+
super(message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Store {
|
|
30
|
+
alias: string;
|
|
31
|
+
domain: string;
|
|
32
|
+
token: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const normDomain = (d: string): string => {
|
|
36
|
+
let s = d.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
37
|
+
if (s && !s.includes(".")) s = `${s}.myshopify.com`;
|
|
38
|
+
return s;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Every configured store, from the request context else env. */
|
|
42
|
+
export function listStores(): Store[] {
|
|
43
|
+
const out: Store[] = [];
|
|
44
|
+
const domain = getCred("SHOPIFY_STORE_DOMAIN");
|
|
45
|
+
const token = getCred("SHOPIFY_ADMIN_TOKEN");
|
|
46
|
+
if (domain && token) out.push({ alias: "default", domain: normDomain(domain), token });
|
|
47
|
+
for (const key of credKeys("SHOPIFY_STORE_")) {
|
|
48
|
+
if (key === "SHOPIFY_STORE_DOMAIN") continue;
|
|
49
|
+
const raw = getCred(key);
|
|
50
|
+
if (!raw) continue;
|
|
51
|
+
const sep = raw.indexOf("|");
|
|
52
|
+
if (sep < 0) continue; // malformed entry — skip rather than guess
|
|
53
|
+
const alias = key.slice("SHOPIFY_STORE_".length).toLowerCase();
|
|
54
|
+
out.push({ alias, domain: normDomain(raw.slice(0, sep)), token: raw.slice(sep + 1).trim() });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve a store by alias or domain; undefined falls back to the active/only store. */
|
|
60
|
+
export function resolveStore(aliasOrDomain?: string): Store {
|
|
61
|
+
const stores = listStores();
|
|
62
|
+
if (stores.length === 0) {
|
|
63
|
+
throw new ShopifyError(
|
|
64
|
+
"No Shopify store configured. Set SHOPIFY_STORE_DOMAIN + SHOPIFY_ADMIN_TOKEN, or SHOPIFY_STORE_<ALIAS>=\"<shop>.myshopify.com|shpat_...\".",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const want = aliasOrDomain ?? getCred("SHOPIFY_ACTIVE_STORE");
|
|
68
|
+
if (!want) {
|
|
69
|
+
if (stores.length === 1) return stores[0];
|
|
70
|
+
throw new ShopifyError(
|
|
71
|
+
`Multiple stores configured (${stores.map((s) => s.alias).join(", ")}) and no active store. Pass store:<alias> or call use_store.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const needle = want.trim().toLowerCase();
|
|
75
|
+
const hit =
|
|
76
|
+
stores.find((s) => s.alias === needle) ??
|
|
77
|
+
stores.find((s) => s.domain === normDomain(needle));
|
|
78
|
+
if (!hit) {
|
|
79
|
+
throw new ShopifyError(
|
|
80
|
+
`Unknown store "${want}". Configured: ${stores.map((s) => `${s.alias} (${s.domain})`).join(", ")}.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return hit;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Set the session's active store (request-scoped on the remote host). */
|
|
87
|
+
export function useStore(aliasOrDomain: string): Store {
|
|
88
|
+
const store = resolveStore(aliasOrDomain);
|
|
89
|
+
setCred("SHOPIFY_ACTIVE_STORE", store.alias);
|
|
90
|
+
return store;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const apiVersion = (): string => getCred("SHOPIFY_API_VERSION") || LATEST_STABLE;
|
|
94
|
+
|
|
95
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One Admin GraphQL call. Throws ShopifyError on HTTP or GraphQL errors;
|
|
99
|
+
* a THROTTLED response is retried once after a short pause.
|
|
100
|
+
*/
|
|
101
|
+
export async function gql<T = any>(
|
|
102
|
+
query: string,
|
|
103
|
+
variables: Record<string, unknown> = {},
|
|
104
|
+
store?: Store,
|
|
105
|
+
attempt = 0,
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
const s = store ?? resolveStore();
|
|
108
|
+
const res = await fetch(`https://${s.domain}/admin/api/${apiVersion()}/graphql.json`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"X-Shopify-Access-Token": s.token,
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify({ query, variables }),
|
|
115
|
+
});
|
|
116
|
+
if (res.status === 401 || res.status === 403) {
|
|
117
|
+
throw new ShopifyError(
|
|
118
|
+
`Shopify rejected the token for ${s.domain} (HTTP ${res.status}). Re-check the custom app's Admin API token and scopes.`,
|
|
119
|
+
res.status,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
throw new ShopifyError(`Shopify HTTP ${res.status} from ${s.domain}: ${await res.text()}`, res.status);
|
|
124
|
+
}
|
|
125
|
+
const body = (await res.json()) as { data?: T; errors?: any[] };
|
|
126
|
+
if (body.errors?.length) {
|
|
127
|
+
const throttled = body.errors.some((e) => e?.extensions?.code === "THROTTLED");
|
|
128
|
+
if (throttled && attempt < 1) {
|
|
129
|
+
await sleep(1500);
|
|
130
|
+
return gql(query, variables, s, attempt + 1);
|
|
131
|
+
}
|
|
132
|
+
throw new ShopifyError(
|
|
133
|
+
`Shopify GraphQL error from ${s.domain}: ${body.errors.map((e) => e.message).join("; ")}`,
|
|
134
|
+
undefined,
|
|
135
|
+
body.errors,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return body.data as T;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Accept a bare numeric id or a full gid for the given resource kind. */
|
|
142
|
+
export const gid = (kind: string, id: string): string =>
|
|
143
|
+
/^\d+$/.test(id) ? `gid://shopify/${kind}/${id}` : id;
|
|
144
|
+
|
|
145
|
+
/** Throw if the payload carries non-empty userErrors (mutation soft failures). */
|
|
146
|
+
export function assertNoUserErrors(payload: any, op: string): void {
|
|
147
|
+
const errs = payload?.userErrors;
|
|
148
|
+
if (Array.isArray(errs) && errs.length) {
|
|
149
|
+
throw new ShopifyError(
|
|
150
|
+
`${op} failed: ${errs.map((e: any) => `${(e.field ?? []).join(".") || "?"}: ${e.message}`).join("; ")}`,
|
|
151
|
+
undefined,
|
|
152
|
+
errs,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/core/creds.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-request credential context for multi-user remote hosts.
|
|
5
|
+
*
|
|
6
|
+
* A remote MCP host (apps/connectors-mcp) serves many users from one process,
|
|
7
|
+
* so user credentials cannot live in process.env. The host runs each request
|
|
8
|
+
* inside credStore.run(<that user's creds>, …); credential reads in this
|
|
9
|
+
* package go through getCred()/credKeys(), which resolve from the request
|
|
10
|
+
* store when one is active and fall back to process.env otherwise (stdio CLI,
|
|
11
|
+
* single-user deployments — behavior unchanged).
|
|
12
|
+
*
|
|
13
|
+
* The store is deliberately AUTHORITATIVE while active: a key absent from the
|
|
14
|
+
* request store is absent, full stop — never silently satisfied by the host's
|
|
15
|
+
* own env credentials (that would let one user act as another).
|
|
16
|
+
*
|
|
17
|
+
* globalThis holds the single AsyncLocalStorage instance so every connector
|
|
18
|
+
* package in the process shares one context, even when bundlers duplicate
|
|
19
|
+
* this module. This file is intentionally identical across the packages.
|
|
20
|
+
*/
|
|
21
|
+
type CredMap = Record<string, string | undefined>;
|
|
22
|
+
|
|
23
|
+
const g = globalThis as typeof globalThis & {
|
|
24
|
+
__ethosCredStore?: AsyncLocalStorage<CredMap>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const credStore: AsyncLocalStorage<CredMap> = (g.__ethosCredStore ??=
|
|
28
|
+
new AsyncLocalStorage<CredMap>());
|
|
29
|
+
|
|
30
|
+
/** True while running inside a per-request credential context. */
|
|
31
|
+
export const hasCredContext = (): boolean => credStore.getStore() !== undefined;
|
|
32
|
+
|
|
33
|
+
/** The named credential from the active request context, else process.env. */
|
|
34
|
+
export function getCred(name: string): string | undefined {
|
|
35
|
+
const store = credStore.getStore();
|
|
36
|
+
return store ? store[name] : process.env[name];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** All credential names with the prefix, from the active context else env. */
|
|
40
|
+
export function credKeys(prefix: string): string[] {
|
|
41
|
+
const store = credStore.getStore();
|
|
42
|
+
return Object.keys(store ?? process.env).filter((k) => k.startsWith(prefix));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write a value into the active request context when one exists, else into
|
|
47
|
+
* process.env. Keeps per-session mutations (e.g. ghl `use_location`) scoped to
|
|
48
|
+
* the request instead of leaking process-wide in a multi-user host.
|
|
49
|
+
*/
|
|
50
|
+
export function setCred(name: string, value: string): void {
|
|
51
|
+
const store = credStore.getStore();
|
|
52
|
+
if (store) store[name] = value;
|
|
53
|
+
else process.env[name] = value;
|
|
54
|
+
}
|
package/core/env.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Absolute path to the package root (one level up from core/). */
|
|
6
|
+
export function pkgRoot(): string {
|
|
7
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Path to the package's .env file (where a rotated refresh token is stored). */
|
|
11
|
+
export function envPath(): string {
|
|
12
|
+
return join(pkgRoot(), ".env");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort .env loader. Reads the package .env and the cwd .env and sets
|
|
17
|
+
* any keys that aren't already in process.env (so env vars passed by the MCP
|
|
18
|
+
* client / `claude mcp add -e` always win). Silent if no file exists.
|
|
19
|
+
*/
|
|
20
|
+
export function loadEnv(): void {
|
|
21
|
+
for (const path of [envPath(), join(process.cwd(), ".env")]) {
|
|
22
|
+
let txt: string;
|
|
23
|
+
try {
|
|
24
|
+
txt = readFileSync(path, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (const line of txt.split("\n")) {
|
|
29
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
30
|
+
if (!m) continue;
|
|
31
|
+
let val = m[2];
|
|
32
|
+
if (
|
|
33
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
34
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
35
|
+
) {
|
|
36
|
+
val = val.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
if (process.env[m[1]] === undefined) process.env[m[1]] = val;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Upsert a single KEY=value into the package .env (used by `auth:exchange`). */
|
|
44
|
+
export function upsertEnv(key: string, value: string): void {
|
|
45
|
+
const path = envPath();
|
|
46
|
+
let lines: string[] = [];
|
|
47
|
+
if (existsSync(path)) lines = readFileSync(path, "utf8").split("\n");
|
|
48
|
+
const idx = lines.findIndex((l) => l.match(new RegExp(`^\\s*${key}\\s*=`)));
|
|
49
|
+
const entry = `${key}=${value}`;
|
|
50
|
+
if (idx >= 0) lines[idx] = entry;
|
|
51
|
+
else lines.push(entry);
|
|
52
|
+
writeFileSync(path, lines.filter((l, i) => l !== "" || i < lines.length - 1).join("\n") + "\n");
|
|
53
|
+
}
|
package/core/register.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import * as shopify from "./shopify.js";
|
|
4
|
+
|
|
5
|
+
const json = (data: unknown) => ({
|
|
6
|
+
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Register every Shopify tool on a given McpServer.
|
|
11
|
+
*
|
|
12
|
+
* Shared by BOTH transports so they expose the identical toolset:
|
|
13
|
+
* - the stdio entrypoint (mcp.ts), and
|
|
14
|
+
* - the remote HTTP handler (apps/connectors-mcp, via mcp-handler).
|
|
15
|
+
*
|
|
16
|
+
* Pure: it only registers tools. Env loading + transport wiring live in the caller.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Some MCP clients (claude.ai custom-connector harness among them) deliver
|
|
20
|
+
* every argument as a string when they lack property types to coerce against.
|
|
21
|
+
* Same leniency shim as ghl-mcp: retry a failing string value JSON.parsed or
|
|
22
|
+
* array-wrapped; correctly-typed clients see zero behavior change.
|
|
23
|
+
*/
|
|
24
|
+
function lenientField(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
25
|
+
return z.preprocess((v) => {
|
|
26
|
+
if (typeof v !== "string" || schema.safeParse(v).success) return v;
|
|
27
|
+
const candidates: unknown[] = [];
|
|
28
|
+
const t = v.trim();
|
|
29
|
+
if (/^[\[{"]|^-?\d|^(true|false|null)$/.test(t)) {
|
|
30
|
+
try {
|
|
31
|
+
candidates.push(JSON.parse(t));
|
|
32
|
+
} catch {
|
|
33
|
+
/* not JSON — fall through */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
candidates.push([v]);
|
|
37
|
+
for (const c of candidates) if (schema.safeParse(c).success) return c;
|
|
38
|
+
return v;
|
|
39
|
+
}, schema);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lenientShape(shape: Record<string, z.ZodTypeAny>): Record<string, z.ZodTypeAny> {
|
|
43
|
+
const out: Record<string, z.ZodTypeAny> = {};
|
|
44
|
+
for (const [k, s] of Object.entries(shape)) out[k] = lenientField(s);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const storeArg = {
|
|
49
|
+
store: z.string().optional().describe("Store alias or *.myshopify.com domain (defaults to the active store)"),
|
|
50
|
+
};
|
|
51
|
+
const paging = {
|
|
52
|
+
first: z.number().optional().describe("Page size, max 50 (default 20)"),
|
|
53
|
+
after: z.string().optional().describe("Cursor from the previous page's pageInfo.endCursor"),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function registerTools(server: McpServer) {
|
|
57
|
+
/** Wrap a handler so thrown errors become an isError tool result. */
|
|
58
|
+
function tool(
|
|
59
|
+
name: string,
|
|
60
|
+
description: string,
|
|
61
|
+
shape: Record<string, z.ZodTypeAny>,
|
|
62
|
+
run: (a: any) => Promise<unknown> | unknown,
|
|
63
|
+
) {
|
|
64
|
+
server.tool(name, description, lenientShape(shape), async (a: any) => {
|
|
65
|
+
try {
|
|
66
|
+
return json(await run(a));
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text" as const, text: `ERROR: ${e?.message ?? String(e)}` }],
|
|
70
|
+
isError: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- Awareness ----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
tool(
|
|
79
|
+
"shopify_whoami",
|
|
80
|
+
"Active store + shop identity (name, domain, plan, currency, timezone) + API version. Run this first — it also proves the token works.",
|
|
81
|
+
storeArg,
|
|
82
|
+
(a) => shopify.whoami(a.store),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
tool("shopify_list_stores", "All configured stores (alias + domain) and the API version.", {}, () =>
|
|
86
|
+
shopify.stores(),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
tool(
|
|
90
|
+
"shopify_use_store",
|
|
91
|
+
"Set the session's active store; subsequent tools default to it.",
|
|
92
|
+
{ store: z.string().describe("Store alias or *.myshopify.com domain") },
|
|
93
|
+
(a) => shopify.switchStore(a.store),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// ---- Products -----------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
tool(
|
|
99
|
+
"shopify_list_products",
|
|
100
|
+
"List/search products. `query` uses Shopify search syntax (e.g. 'status:active', 'title:*hoodie*', 'tag:sale').",
|
|
101
|
+
{ query: z.string().optional(), ...paging, ...storeArg },
|
|
102
|
+
(a) => shopify.listProducts(a),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
tool(
|
|
106
|
+
"shopify_get_product",
|
|
107
|
+
"One product in full — variants, options, images, description — by id (numeric or gid) or handle.",
|
|
108
|
+
{ id: z.string().optional(), handle: z.string().optional(), ...storeArg },
|
|
109
|
+
(a) => shopify.getProduct(a),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const productFields = {
|
|
113
|
+
descriptionHtml: z.string().optional(),
|
|
114
|
+
vendor: z.string().optional(),
|
|
115
|
+
productType: z.string().optional(),
|
|
116
|
+
tags: z.array(z.string()).optional(),
|
|
117
|
+
status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]).optional(),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
tool(
|
|
121
|
+
"shopify_create_product",
|
|
122
|
+
"Create a product. Shopify defaults new products to ACTIVE — pass status:'DRAFT' while iterating.",
|
|
123
|
+
{ title: z.string(), ...productFields, ...storeArg },
|
|
124
|
+
(a) => {
|
|
125
|
+
const { store, ...input } = a;
|
|
126
|
+
return shopify.createProduct(input, store);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
tool(
|
|
131
|
+
"shopify_update_product",
|
|
132
|
+
"Update product fields (only the fields you pass change).",
|
|
133
|
+
{ id: z.string(), title: z.string().optional(), ...productFields, ...storeArg },
|
|
134
|
+
(a) => {
|
|
135
|
+
const { id, store, ...input } = a;
|
|
136
|
+
return shopify.updateProduct(id, input, store);
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
tool(
|
|
141
|
+
"shopify_delete_product",
|
|
142
|
+
"DELETE a product permanently. Dry-run unless confirm:true.",
|
|
143
|
+
{ id: z.string(), confirm: z.boolean().optional(), ...storeArg },
|
|
144
|
+
async (a) => {
|
|
145
|
+
if (!a.confirm) {
|
|
146
|
+
const current = await shopify.getProduct({ id: a.id, store: a.store });
|
|
147
|
+
return { dryRun: true, wouldDelete: current, note: "Pass confirm:true to delete." };
|
|
148
|
+
}
|
|
149
|
+
return shopify.deleteProduct(a.id, a.store);
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ---- Orders -------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
tool(
|
|
156
|
+
"shopify_list_orders",
|
|
157
|
+
"List orders, newest first. `query` uses Shopify search syntax (e.g. 'financial_status:paid', 'created_at:>2026-08-01', 'fulfillment_status:unfulfilled').",
|
|
158
|
+
{ query: z.string().optional(), ...paging, ...storeArg },
|
|
159
|
+
(a) => shopify.listOrders(a),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
tool(
|
|
163
|
+
"shopify_get_order",
|
|
164
|
+
"One order in full — line items, customer, addresses, fulfillments, totals.",
|
|
165
|
+
{ id: z.string(), ...storeArg },
|
|
166
|
+
(a) => shopify.getOrder(a.id, a.store),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// ---- Customers ----------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
tool(
|
|
172
|
+
"shopify_list_customers",
|
|
173
|
+
"List/search customers (query e.g. 'email:jane@…', 'orders_count:>5').",
|
|
174
|
+
{ query: z.string().optional(), ...paging, ...storeArg },
|
|
175
|
+
(a) => shopify.listCustomers(a),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
tool(
|
|
179
|
+
"shopify_get_customer",
|
|
180
|
+
"One customer in full, incl. their 10 latest orders.",
|
|
181
|
+
{ id: z.string(), ...storeArg },
|
|
182
|
+
(a) => shopify.getCustomer(a.id, a.store),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// ---- Catalog / storefront ----------------------------------------------
|
|
186
|
+
|
|
187
|
+
tool("shopify_list_collections", "List collections with product counts.", { ...paging, ...storeArg }, (a) =>
|
|
188
|
+
shopify.listCollections(a),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
tool("shopify_list_locations", "Inventory locations.", storeArg, (a) => shopify.listLocations(a.store));
|
|
192
|
+
|
|
193
|
+
tool(
|
|
194
|
+
"shopify_list_themes",
|
|
195
|
+
"Online-store themes (the MAIN role is the live theme).",
|
|
196
|
+
storeArg,
|
|
197
|
+
(a) => shopify.listThemes(a.store),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ---- Webhooks -----------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
tool("shopify_list_webhooks", "Webhook subscriptions with endpoints.", storeArg, (a) =>
|
|
203
|
+
shopify.listWebhooks(a.store),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
tool(
|
|
207
|
+
"shopify_create_webhook",
|
|
208
|
+
"Subscribe an HTTPS callback to a topic (e.g. ORDERS_CREATE, PRODUCTS_UPDATE).",
|
|
209
|
+
{ topic: z.string(), callbackUrl: z.string(), ...storeArg },
|
|
210
|
+
(a) => shopify.createWebhook(a.topic, a.callbackUrl, a.store),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
tool(
|
|
214
|
+
"shopify_delete_webhook",
|
|
215
|
+
"Delete a webhook subscription. Dry-run unless confirm:true.",
|
|
216
|
+
{ id: z.string(), confirm: z.boolean().optional(), ...storeArg },
|
|
217
|
+
async (a) => {
|
|
218
|
+
if (!a.confirm) return { dryRun: true, wouldDeleteWebhook: a.id, note: "Pass confirm:true to delete." };
|
|
219
|
+
return shopify.deleteWebhook(a.id, a.store);
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// ---- Raw passthrough ----------------------------------------------------
|
|
224
|
+
|
|
225
|
+
tool(
|
|
226
|
+
"shopify_graphql",
|
|
227
|
+
"Raw Admin GraphQL passthrough — the escape hatch for anything without a typed tool. Queries run as-is; MUTATIONS are refused unless confirm:true.",
|
|
228
|
+
{
|
|
229
|
+
query: z.string(),
|
|
230
|
+
variables: z.record(z.unknown()).optional(),
|
|
231
|
+
confirm: z.boolean().optional(),
|
|
232
|
+
...storeArg,
|
|
233
|
+
},
|
|
234
|
+
(a) => {
|
|
235
|
+
if (shopify.isMutation(a.query) && !a.confirm) {
|
|
236
|
+
return {
|
|
237
|
+
refused: true,
|
|
238
|
+
note: "This is a mutation. Re-run with confirm:true to execute it.",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return shopify.raw(a.query, a.variables as Record<string, unknown> | undefined, a.store);
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
}
|
package/core/shopify.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiVersion,
|
|
3
|
+
assertNoUserErrors,
|
|
4
|
+
gid,
|
|
5
|
+
gql,
|
|
6
|
+
listStores,
|
|
7
|
+
resolveStore,
|
|
8
|
+
useStore,
|
|
9
|
+
} from "./client.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Typed operations over the Admin GraphQL API, shared by the MCP tools
|
|
13
|
+
* (core/register.ts) and the CLI (cli.ts). Every response names the store it
|
|
14
|
+
* ran against, so multi-store sessions can never misattribute data.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const branded = <T>(store: { alias: string; domain: string }, data: T) => ({
|
|
18
|
+
store: `${store.alias} (${store.domain})`,
|
|
19
|
+
...(data as object),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const pageArgs = (first?: number, after?: string) => ({
|
|
23
|
+
first: Math.min(Math.max(first ?? 20, 1), 50),
|
|
24
|
+
after: after || null,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ---- Awareness -------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export function stores() {
|
|
30
|
+
return {
|
|
31
|
+
apiVersion: apiVersion(),
|
|
32
|
+
stores: listStores().map((s) => ({ alias: s.alias, domain: s.domain })),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function switchStore(aliasOrDomain: string) {
|
|
37
|
+
const s = useStore(aliasOrDomain);
|
|
38
|
+
return { active: s.alias, domain: s.domain };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function whoami(store?: string) {
|
|
42
|
+
const s = resolveStore(store);
|
|
43
|
+
const data = await gql(
|
|
44
|
+
`{ shop { id name myshopifyDomain primaryDomain { host } plan { publicDisplayName shopifyPlus } currencyCode ianaTimezone email } }`,
|
|
45
|
+
{},
|
|
46
|
+
s,
|
|
47
|
+
);
|
|
48
|
+
return branded(s, { apiVersion: apiVersion(), shop: data.shop });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Products --------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const PRODUCT_CARD = `id title handle status vendor productType tags totalInventory updatedAt
|
|
54
|
+
priceRangeV2 { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } }`;
|
|
55
|
+
|
|
56
|
+
export async function listProducts(o: { query?: string; first?: number; after?: string; store?: string }) {
|
|
57
|
+
const s = resolveStore(o.store);
|
|
58
|
+
const data = await gql(
|
|
59
|
+
`query ($first: Int!, $after: String, $query: String) {
|
|
60
|
+
products(first: $first, after: $after, query: $query) {
|
|
61
|
+
nodes { ${PRODUCT_CARD} }
|
|
62
|
+
pageInfo { hasNextPage endCursor }
|
|
63
|
+
}
|
|
64
|
+
}`,
|
|
65
|
+
{ ...pageArgs(o.first, o.after), query: o.query || null },
|
|
66
|
+
s,
|
|
67
|
+
);
|
|
68
|
+
return branded(s, data.products);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function getProduct(o: { id?: string; handle?: string; store?: string }) {
|
|
72
|
+
const s = resolveStore(o.store);
|
|
73
|
+
const selection = `${PRODUCT_CARD} descriptionHtml
|
|
74
|
+
options { name values }
|
|
75
|
+
variants(first: 50) { nodes { id title sku price compareAtPrice inventoryQuantity selectedOptions { name value } } }
|
|
76
|
+
media(first: 20) { nodes { ... on MediaImage { id image { url altText } } } }`;
|
|
77
|
+
if (o.handle && !o.id) {
|
|
78
|
+
const data = await gql(
|
|
79
|
+
`query ($handle: String!) { productByIdentifier(identifier: { handle: $handle }) { ${selection} } }`,
|
|
80
|
+
{ handle: o.handle },
|
|
81
|
+
s,
|
|
82
|
+
);
|
|
83
|
+
return branded(s, { product: data.productByIdentifier });
|
|
84
|
+
}
|
|
85
|
+
if (!o.id) throw new Error("Pass id or handle.");
|
|
86
|
+
const data = await gql(
|
|
87
|
+
`query ($id: ID!) { product(id: $id) { ${selection} } }`,
|
|
88
|
+
{ id: gid("Product", o.id) },
|
|
89
|
+
s,
|
|
90
|
+
);
|
|
91
|
+
return branded(s, { product: data.product });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ProductInput {
|
|
95
|
+
title?: string;
|
|
96
|
+
descriptionHtml?: string;
|
|
97
|
+
vendor?: string;
|
|
98
|
+
productType?: string;
|
|
99
|
+
tags?: string[];
|
|
100
|
+
status?: "ACTIVE" | "DRAFT" | "ARCHIVED";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function createProduct(input: ProductInput & { title: string }, store?: string) {
|
|
104
|
+
const s = resolveStore(store);
|
|
105
|
+
const data = await gql(
|
|
106
|
+
`mutation ($product: ProductCreateInput!) {
|
|
107
|
+
productCreate(product: $product) {
|
|
108
|
+
product { ${PRODUCT_CARD} }
|
|
109
|
+
userErrors { field message }
|
|
110
|
+
}
|
|
111
|
+
}`,
|
|
112
|
+
{ product: input },
|
|
113
|
+
s,
|
|
114
|
+
);
|
|
115
|
+
assertNoUserErrors(data.productCreate, "productCreate");
|
|
116
|
+
return branded(s, { product: data.productCreate.product });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function updateProduct(id: string, input: ProductInput, store?: string) {
|
|
120
|
+
const s = resolveStore(store);
|
|
121
|
+
const data = await gql(
|
|
122
|
+
`mutation ($product: ProductUpdateInput!) {
|
|
123
|
+
productUpdate(product: $product) {
|
|
124
|
+
product { ${PRODUCT_CARD} }
|
|
125
|
+
userErrors { field message }
|
|
126
|
+
}
|
|
127
|
+
}`,
|
|
128
|
+
{ product: { id: gid("Product", id), ...input } },
|
|
129
|
+
s,
|
|
130
|
+
);
|
|
131
|
+
assertNoUserErrors(data.productUpdate, "productUpdate");
|
|
132
|
+
return branded(s, { product: data.productUpdate.product });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function deleteProduct(id: string, store?: string) {
|
|
136
|
+
const s = resolveStore(store);
|
|
137
|
+
const data = await gql(
|
|
138
|
+
`mutation ($input: ProductDeleteInput!) {
|
|
139
|
+
productDelete(input: $input) { deletedProductId userErrors { field message } }
|
|
140
|
+
}`,
|
|
141
|
+
{ input: { id: gid("Product", id) } },
|
|
142
|
+
s,
|
|
143
|
+
);
|
|
144
|
+
assertNoUserErrors(data.productDelete, "productDelete");
|
|
145
|
+
return branded(s, { deletedProductId: data.productDelete.deletedProductId });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- Orders ----------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export async function listOrders(o: { query?: string; first?: number; after?: string; store?: string }) {
|
|
151
|
+
const s = resolveStore(o.store);
|
|
152
|
+
const data = await gql(
|
|
153
|
+
`query ($first: Int!, $after: String, $query: String) {
|
|
154
|
+
orders(first: $first, after: $after, query: $query, sortKey: CREATED_AT, reverse: true) {
|
|
155
|
+
nodes { id name createdAt displayFinancialStatus displayFulfillmentStatus
|
|
156
|
+
totalPriceSet { shopMoney { amount currencyCode } }
|
|
157
|
+
customer { id displayName defaultEmailAddress { emailAddress } } }
|
|
158
|
+
pageInfo { hasNextPage endCursor }
|
|
159
|
+
}
|
|
160
|
+
}`,
|
|
161
|
+
{ ...pageArgs(o.first, o.after), query: o.query || null },
|
|
162
|
+
s,
|
|
163
|
+
);
|
|
164
|
+
return branded(s, data.orders);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function getOrder(id: string, store?: string) {
|
|
168
|
+
const s = resolveStore(store);
|
|
169
|
+
const data = await gql(
|
|
170
|
+
`query ($id: ID!) {
|
|
171
|
+
order(id: $id) {
|
|
172
|
+
id name createdAt processedAt displayFinancialStatus displayFulfillmentStatus note tags
|
|
173
|
+
totalPriceSet { shopMoney { amount currencyCode } }
|
|
174
|
+
subtotalPriceSet { shopMoney { amount currencyCode } }
|
|
175
|
+
totalShippingPriceSet { shopMoney { amount currencyCode } }
|
|
176
|
+
customer { id displayName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } }
|
|
177
|
+
shippingAddress { name address1 address2 city provinceCode zip countryCodeV2 }
|
|
178
|
+
lineItems(first: 50) { nodes { title quantity sku variantTitle
|
|
179
|
+
originalUnitPriceSet { shopMoney { amount currencyCode } } } }
|
|
180
|
+
fulfillments { status trackingInfo { company number url } }
|
|
181
|
+
}
|
|
182
|
+
}`,
|
|
183
|
+
{ id: gid("Order", id) },
|
|
184
|
+
s,
|
|
185
|
+
);
|
|
186
|
+
return branded(s, { order: data.order });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- Customers -------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export async function listCustomers(o: { query?: string; first?: number; after?: string; store?: string }) {
|
|
192
|
+
const s = resolveStore(o.store);
|
|
193
|
+
const data = await gql(
|
|
194
|
+
`query ($first: Int!, $after: String, $query: String) {
|
|
195
|
+
customers(first: $first, after: $after, query: $query) {
|
|
196
|
+
nodes { id displayName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } createdAt numberOfOrders
|
|
197
|
+
amountSpent { amount currencyCode } }
|
|
198
|
+
pageInfo { hasNextPage endCursor }
|
|
199
|
+
}
|
|
200
|
+
}`,
|
|
201
|
+
{ ...pageArgs(o.first, o.after), query: o.query || null },
|
|
202
|
+
s,
|
|
203
|
+
);
|
|
204
|
+
return branded(s, data.customers);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function getCustomer(id: string, store?: string) {
|
|
208
|
+
const s = resolveStore(store);
|
|
209
|
+
const data = await gql(
|
|
210
|
+
`query ($id: ID!) {
|
|
211
|
+
customer(id: $id) {
|
|
212
|
+
id displayName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } note tags createdAt numberOfOrders
|
|
213
|
+
amountSpent { amount currencyCode }
|
|
214
|
+
defaultAddress { address1 address2 city provinceCode zip countryCodeV2 }
|
|
215
|
+
orders(first: 10, sortKey: CREATED_AT, reverse: true) { nodes { id name createdAt
|
|
216
|
+
totalPriceSet { shopMoney { amount currencyCode } } } }
|
|
217
|
+
}
|
|
218
|
+
}`,
|
|
219
|
+
{ id: gid("Customer", id) },
|
|
220
|
+
s,
|
|
221
|
+
);
|
|
222
|
+
return branded(s, { customer: data.customer });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- Catalog structure / storefront ---------------------------------------
|
|
226
|
+
|
|
227
|
+
export async function listCollections(o: { first?: number; after?: string; store?: string }) {
|
|
228
|
+
const s = resolveStore(o.store);
|
|
229
|
+
const data = await gql(
|
|
230
|
+
`query ($first: Int!, $after: String) {
|
|
231
|
+
collections(first: $first, after: $after) {
|
|
232
|
+
nodes { id title handle updatedAt productsCount { count } }
|
|
233
|
+
pageInfo { hasNextPage endCursor }
|
|
234
|
+
}
|
|
235
|
+
}`,
|
|
236
|
+
pageArgs(o.first, o.after),
|
|
237
|
+
s,
|
|
238
|
+
);
|
|
239
|
+
return branded(s, data.collections);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function listLocations(store?: string) {
|
|
243
|
+
const s = resolveStore(store);
|
|
244
|
+
const data = await gql(
|
|
245
|
+
`{ locations(first: 20) { nodes { id name isActive address { city provinceCode countryCode } } } }`,
|
|
246
|
+
{},
|
|
247
|
+
s,
|
|
248
|
+
);
|
|
249
|
+
return branded(s, data.locations);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function listThemes(store?: string) {
|
|
253
|
+
const s = resolveStore(store);
|
|
254
|
+
const data = await gql(
|
|
255
|
+
`{ themes(first: 20) { nodes { id name role updatedAt } } }`,
|
|
256
|
+
{},
|
|
257
|
+
s,
|
|
258
|
+
);
|
|
259
|
+
return branded(s, data.themes);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- Webhooks --------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export async function listWebhooks(store?: string) {
|
|
265
|
+
const s = resolveStore(store);
|
|
266
|
+
const data = await gql(
|
|
267
|
+
`{ webhookSubscriptions(first: 50) { nodes { id topic createdAt uri } } }`,
|
|
268
|
+
{},
|
|
269
|
+
s,
|
|
270
|
+
);
|
|
271
|
+
return branded(s, data.webhookSubscriptions);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function createWebhook(topic: string, callbackUrl: string, store?: string) {
|
|
275
|
+
const s = resolveStore(store);
|
|
276
|
+
const data = await gql(
|
|
277
|
+
`mutation ($topic: WebhookSubscriptionTopic!, $sub: WebhookSubscriptionInput!) {
|
|
278
|
+
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $sub) {
|
|
279
|
+
webhookSubscription { id topic }
|
|
280
|
+
userErrors { field message }
|
|
281
|
+
}
|
|
282
|
+
}`,
|
|
283
|
+
{ topic, sub: { callbackUrl, format: "JSON" } },
|
|
284
|
+
s,
|
|
285
|
+
);
|
|
286
|
+
assertNoUserErrors(data.webhookSubscriptionCreate, "webhookSubscriptionCreate");
|
|
287
|
+
return branded(s, { webhook: data.webhookSubscriptionCreate.webhookSubscription });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function deleteWebhook(id: string, store?: string) {
|
|
291
|
+
const s = resolveStore(store);
|
|
292
|
+
const data = await gql(
|
|
293
|
+
`mutation ($id: ID!) {
|
|
294
|
+
webhookSubscriptionDelete(id: $id) { deletedWebhookSubscriptionId userErrors { field message } }
|
|
295
|
+
}`,
|
|
296
|
+
{ id: gid("WebhookSubscription", id) },
|
|
297
|
+
s,
|
|
298
|
+
);
|
|
299
|
+
assertNoUserErrors(data.webhookSubscriptionDelete, "webhookSubscriptionDelete");
|
|
300
|
+
return branded(s, { deletedWebhookSubscriptionId: data.webhookSubscriptionDelete.deletedWebhookSubscriptionId });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- Raw passthrough -------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
export const isMutation = (query: string): boolean =>
|
|
306
|
+
/^\s*mutation\b/i.test(query.trim().replace(/^#[^\n]*\n\s*/g, ""));
|
|
307
|
+
|
|
308
|
+
export async function raw(query: string, variables?: Record<string, unknown>, store?: string) {
|
|
309
|
+
const s = resolveStore(store);
|
|
310
|
+
const data = await gql(query, variables ?? {}, s);
|
|
311
|
+
return branded(s, { data });
|
|
312
|
+
}
|
package/mcp.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { loadEnv } from "./core/env.js";
|
|
4
|
+
import { registerTools } from "./core/register.js";
|
|
5
|
+
|
|
6
|
+
loadEnv();
|
|
7
|
+
|
|
8
|
+
const server = new McpServer({ name: "shopify-mcp", version: "0.1.0" });
|
|
9
|
+
registerTools(server);
|
|
10
|
+
|
|
11
|
+
const transport = new StdioServerTransport();
|
|
12
|
+
await server.connect(transport);
|
|
13
|
+
console.error(
|
|
14
|
+
"[shopify-mcp] ready v0.1.0 — multi-store Admin GraphQL, store-branded responses, confirm-gated deletes/mutations, raw passthrough.",
|
|
15
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theethosteam/shopify-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-store read + write MCP server (and CLI) for the Shopify Admin GraphQL API. Per-store custom-app tokens (SHOPIFY_STORE_<ALIAS>), store-aware responses, tiered writes (routine executes, destructive confirm-gated), raw GraphQL passthrough. Runs via npx, locally or in cloud.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./register": "./core/register.ts"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"shopify-mcp": "bin/shopify-mcp.mjs",
|
|
11
|
+
"shop": "bin/shop.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"core",
|
|
16
|
+
"mcp.ts",
|
|
17
|
+
"cli.ts",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"mcp": "node bin/shopify-mcp.mjs",
|
|
25
|
+
"shop": "node bin/shop.mjs"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"shopify",
|
|
31
|
+
"admin-api",
|
|
32
|
+
"graphql",
|
|
33
|
+
"ecommerce"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/The-Ethos-Team/connectors.git",
|
|
42
|
+
"directory": "packages/shopify-mcp"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "1.25.2",
|
|
46
|
+
"commander": "^15.0.0",
|
|
47
|
+
"tsx": "^4.22.4",
|
|
48
|
+
"zod": "^3.23.8"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.10.0"
|
|
52
|
+
}
|
|
53
|
+
}
|