epicmerch-mcp 1.3.21 → 1.3.23

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.
@@ -120,13 +120,10 @@ There is no hard "skip" here: `complete` requires a non-empty catalog. If the me
120
120
 
121
121
  Remember the answer as `visualStyle`.
122
122
 
123
- **4b — Scaffold + style.** Run `/epicmerch-storefront`, passing along the chosen
124
- `visualStyle`. It runs the tiered flow: it lays the standard skeleton from the
125
- `store_scaffold_full` tool (instant no hand-writing), applies the matching Tailwind
126
- theme pass (or leaves it unstyled on "skip"), confirms the session-restore block, and
127
- ends on the `/epicmerch-verify` gate. You don't style files here — the leaf skill owns
128
- the theme pass. Recap what it reports (`✓ Storefront scaffolded + styled (<visualStyle>)`
129
- or `unstyled`).
123
+ **4b — Scaffold + style.** Run `/epicmerch-storefront`. After it finishes:
124
+ - If `visualStyle` skip, do a quick styling pass: 5-10 well-chosen Tailwind utility classes per component (typography, spacing, colour) matching `visualStyle`. Don't overdo it.
125
+ - Confirm `src/lib/epicmerch.js` contains the **session-restore block** (the `localStorage.getItem('customerInfo') setCustomerToken` snippet). Without it, OTP login appears to work but doesn't survive a page reload — the #1 silent storefront bug. The 1.3.4+ scaffold includes it by default; add it manually if missing.
126
+ - Recap `✓ Storefront scaffolded + styled (<visualStyle>)` (or `unstyled`).
130
127
 
131
128
  **Optional add-on (not gated):** once the storefront exists, you may offer a 1-click Buy Now button — *"Want a 1-click 'Buy Now' button? Razorpay's Magic Checkout lets customers buy without a cart screen."* → `/epicmerch-magic-checkout` (it verifies Razorpay first). This is optional and does NOT affect `complete`; the merchant can add it anytime by saying *"add a Buy Now button"*. Then re-loop.
132
129
 
@@ -11,6 +11,7 @@ import { developerTools, developerToolDefs } from '../tools/developer.js';
11
11
  import { scaffoldTools, scaffoldToolDefs } from '../tools/scaffold.js';
12
12
  import { merchantTools, merchantToolDefs } from '../tools/merchant.js';
13
13
  import { prompts } from '../prompts/index.js';
14
+ import { VERSION } from '../version.js';
14
15
 
15
16
  const __dir = dirname(fileURLToPath(import.meta.url));
16
17
  const readResource = (rel) => readFileSync(join(__dir, '../resources', rel), 'utf-8');
@@ -27,7 +28,7 @@ function buildZodSchema(schemaDef) {
27
28
  }
28
29
 
29
30
  export async function startMcpAdapter(session, client) {
30
- const server = new McpServer({ name: 'epicmerch', version: '1.0.0' });
31
+ const server = new McpServer({ name: 'epicmerch', version: VERSION });
31
32
 
32
33
  const allToolDefs = [...sessionToolDefs, ...developerToolDefs, ...scaffoldToolDefs, ...merchantToolDefs];
33
34
  const allHandlers = {
@@ -6,6 +6,7 @@ import { sessionTools, sessionToolDefs } from '../tools/session.js';
6
6
  import { developerTools, developerToolDefs } from '../tools/developer.js';
7
7
  import { scaffoldTools, scaffoldToolDefs } from '../tools/scaffold.js';
8
8
  import { merchantTools, merchantToolDefs } from '../tools/merchant.js';
9
+ import { VERSION } from '../version.js';
9
10
 
10
11
  const ALL_TOOL_DEFS = [...sessionToolDefs, ...developerToolDefs, ...scaffoldToolDefs, ...merchantToolDefs];
11
12
 
@@ -46,7 +47,7 @@ function buildOpenApiSpec(serverUrl) {
46
47
 
47
48
  return {
48
49
  openapi: '3.1.0',
49
- info: { title: 'EpicMerch MCP Tools', version: '1.0.0', description: 'EpicMerch tools for Claude and ChatGPT' },
50
+ info: { title: 'EpicMerch MCP Tools', version: VERSION, description: 'EpicMerch tools for Claude and ChatGPT' },
50
51
  servers: [{ url: serverUrl || 'https://mcp.epicmerch.in' }],
51
52
  paths,
52
53
  components: {
package/src/auth.js CHANGED
@@ -7,6 +7,13 @@ export class Session {
7
7
  #jwts = new Map();
8
8
  #authMode;
9
9
  #tokenStore;
10
+ // Active PLATFORM store (EpicMerch Store entity, multi-store merchants).
11
+ // Distinct from #stores/#active above, which are named CREDENTIAL PROFILES
12
+ // (login entries in ~/.epicmerch/token.json / EPICMERCH_STORES) — do not
13
+ // conflate the two. When set, the Client sends it as X-Store-Id on every
14
+ // request and the server's tenantMiddleware scopes data to that store.
15
+ // null = server default (API key's pinned store, else Store #1).
16
+ #activePlatformStoreId = null;
10
17
 
11
18
  constructor({ stores, defaultStore, apiUrl, authMode = 'apikey', tokenStore = null }) {
12
19
  if (!stores || Object.keys(stores).length === 0) {
@@ -27,6 +34,10 @@ export class Session {
27
34
  getApiKey() { return this.#stores[this.#active]; }
28
35
  getApiUrl() { return this.#apiUrl; }
29
36
 
37
+ // --- Platform store context (multi-store; see field comment above) ---
38
+ activePlatformStoreId() { return this.#activePlatformStoreId; }
39
+ setActivePlatformStoreId(id) { this.#activePlatformStoreId = id || null; }
40
+
30
41
  getAuthMode() {
31
42
  return this.#authMode;
32
43
  }
package/src/client.js CHANGED
@@ -40,6 +40,12 @@ export class Client {
40
40
  } else {
41
41
  headers['x-api-key'] = this.#session.getApiKey();
42
42
  }
43
+ // Active platform store (multi-store merchants, set via merchant_switch_store).
44
+ // Re-read from the session on every call — headers are built per-request via
45
+ // the `headers: () => ...` callbacks, so a switch takes effect immediately.
46
+ // #authHeaders() spreads this, so BOTH header paths carry it.
47
+ const platformStoreId = this.#session.activePlatformStoreId?.();
48
+ if (platformStoreId) headers['X-Store-Id'] = platformStoreId;
43
49
  return headers;
44
50
  }
45
51
 
@@ -1,159 +1,163 @@
1
- // src/lib/onboardingState.js
2
- //
3
- // The CONTROL PLANE of the /epicmerch onboarding harness.
4
- //
5
- // This module is the single source of truth for "what's done / what's next /
6
- // are we complete". It re-derives that from live server state on every call —
7
- // nothing is cached, nothing is asserted by the LLM. The thin skill loop in
8
- // epicmerch.md drives off `deriveOnboardingState`, and `merchant_diagnose`
9
- // reuses the same helpers (safeGet/unwrap/unwrapList/productStock) so the two
10
- // can never drift apart — the `countInStock` regression class is structurally
11
- // impossible once both read stock through the same `productStock`.
12
- //
13
- // THE GATE: `complete` is true only when EVERY step — including `verified` — is
14
- // done, and `verified.done` requires a fresh server-recorded `lastVerifiedAt`.
15
- // That timestamp is stamped ONLY by /epicmerch-verify (Deep mode) → merchant_mark_verified →
16
- // POST /api/users/verification-passed. The LLM cannot fake it; "done but never
17
- // verified" is unreachable.
18
-
19
- // Freshness window for the `verified` gate (hours). A store counts as verified
20
- // only if its last successful /epicmerch-verify (Deep mode) run was within this window. Kept
21
- // simple (time-based only) for now — re-verify-on-change is out of scope.
22
- export const VERIFIED_FRESHNESS_HOURS = 24;
23
-
24
- // Canonical step order. `nextStep` is the first not-done step in this order, and
25
- // `complete` is "no not-done step remains". `verified` is intentionally last so
26
- // the gate can't be satisfied before a live e2e run.
27
- export const STEP_ORDER = ['account', 'apiKey', 'payment', 'products', 'storefront', 'verified'];
28
-
29
- // Fetch a path, never throw. Returns { ok:true, data } or { ok:false, error }.
30
- // One failed read does not abort the others (callers Promise.all these).
31
- export const safeGet = async (client, path) => {
32
- try { return { ok: true, data: await client.authGet(path) }; }
33
- catch (e) { return { ok: false, error: e?.message || String(e) }; }
34
- };
35
-
36
- // Unwrap a single-object response, tolerating shape drift (e.g. profile may be
37
- // { user: {...} } or {...}). Returns `fallback` when the read failed.
38
- export const unwrap = (r, fallback = null) => (r?.ok ? (r.data ?? fallback) : fallback);
39
-
40
- // Unwrap a list response into a plain array, tolerating these shapes:
41
- // [...] (bare array)
42
- // { products: [...] } (products list endpoint)
43
- // { categories: [...] } (categories list endpoint)
44
- // { keys: [...] } (api-keys list endpoint)
45
- // Returns null when the read failed (so callers can distinguish "fetch error"
46
- // from "fetched, empty").
47
- export const unwrapList = (r) => {
48
- if (!r?.ok) return null;
49
- const d = r.data;
50
- if (Array.isArray(d)) return d;
51
- if (Array.isArray(d?.products)) return d.products;
52
- if (Array.isArray(d?.categories)) return d.categories;
53
- if (Array.isArray(d?.keys)) return d.keys;
54
- return [];
55
- };
56
-
57
- // Total available stock for a product. The Prisma API returns `stock` (NOT the
58
- // legacy Mongo field `countInStock` — reading that made EVERY product look
59
- // out-of-stock). For products with variants, true availability is the sum of
60
- // per-variant stock. Falls back to countInStock only for very old API shapes.
61
- export const productStock = (p) => {
62
- if (Array.isArray(p?.variants) && p.variants.length > 0) {
63
- return p.variants.reduce((sum, v) => sum + (Number(v?.stock) || 0), 0);
64
- }
65
- return Number(p?.stock ?? p?.countInStock ?? 0) || 0;
66
- };
67
-
68
- // True when `lastVerifiedAt` (ISO string or Date) is within the freshness window.
69
- export const isVerifiedFresh = (lastVerifiedAt, hours = VERIFIED_FRESHNESS_HOURS) => {
70
- if (!lastVerifiedAt) return false;
71
- const ts = new Date(lastVerifiedAt).getTime();
72
- if (Number.isNaN(ts)) return false;
73
- return (Date.now() - ts) < hours * 60 * 60 * 1000;
74
- };
75
-
76
- /**
77
- * Derive the deterministic onboarding state from live server reads + one client
78
- * flag. This is the ONLY place the harness decides done/next/complete.
79
- *
80
- * Server reads (4, in parallel):
81
- * /users/settings → account done (200) + lastVerifiedAt (verified)
82
- * /keys → apiKey done (length > 0)
83
- * /users/checkout-settings → payment done (processor + checkoutType)
84
- * /products → products done (total > 0)
85
- * Client flag:
86
- * storefrontPresent → storefront done (does src/lib/epicmerch.js exist?)
87
- *
88
- * @param {object} client - Client with authGet(path) → parsed JSON (throws on error).
89
- * @param {{ storefrontPresent?: boolean }} opts
90
- * @returns {Promise<{ steps: object, nextStep: string|null, complete: boolean }>}
91
- */
92
- export async function deriveOnboardingState(client, { storefrontPresent = false } = {}) {
93
- const [settingsR, keysR, checkoutR, productsR] = await Promise.all([
94
- safeGet(client, '/users/settings'),
95
- safeGet(client, '/keys'),
96
- safeGet(client, '/users/checkout-settings'),
97
- safeGet(client, '/products'),
98
- ]);
99
-
100
- const settings = unwrap(settingsR);
101
- const keys = unwrapList(keysR) || [];
102
- const checkout = unwrap(checkoutR);
103
- const products = unwrapList(productsR) || [];
104
-
105
- // --- account: the settings read succeeded (merchant is authenticated + provisioned).
106
- const accountDone = settingsR.ok;
107
-
108
- // --- apiKey: at least one storefront key issued.
109
- const apiKeyDone = keys.length > 0;
110
-
111
- // --- payment: a processor is configured AND a checkout type is selected.
112
- const razorpayConfigured = Boolean(checkout?.razorpayKeyId && checkout?.razorpayKeySecretIsSet);
113
- const stripeConfigured = Boolean(checkout?.stripeSecretKeyIsSet);
114
- const checkoutType = checkout?.checkoutType ?? null;
115
- const paymentDone = Boolean((razorpayConfigured || stripeConfigured) && checkoutType);
116
-
117
- // --- products: at least one product in the catalog (prefer total, fall back to page length).
118
- const productCount = Number(unwrap(productsR)?.total) || products.length;
119
- const inStockCount = products.filter((p) => productStock(p) > 0).length;
120
- const productsDone = productCount > 0;
121
-
122
- // --- storefront: client says the scaffold exists locally.
123
- const storefrontDone = Boolean(storefrontPresent);
124
-
125
- // --- verified: server stamped lastVerifiedAt within the freshness window.
126
- const lastVerifiedAt = settings?.lastVerifiedAt ?? null;
127
- const verifiedDone = isVerifiedFresh(lastVerifiedAt);
128
-
129
- const steps = {
130
- account: accountDone
131
- ? { done: true, detail: settings?.name ? `Store "${settings.name}" is provisioned.` : 'Merchant account is provisioned.' }
132
- : { done: false, action: `Authenticate first — run \`npx epicmerch-mcp login\` in a terminal. (settings read failed: ${settingsR.error || 'unknown'})` },
133
-
134
- apiKey: apiKeyDone
135
- ? { done: true, detail: `${keys.length} storefront API key(s) issued.` }
136
- : { done: false, action: 'Generate a storefront API key with merchant_generate_api_key.' },
137
-
138
- payment: paymentDone
139
- ? { done: true, detail: `Payment ready (checkoutType="${checkoutType}"${razorpayConfigured ? ', Razorpay configured' : ''}${stripeConfigured ? ', Stripe configured' : ''}).` }
140
- : { done: false, action: 'Configure a payment processor — run /epicmerch-payments, then set the checkout type.' },
141
-
142
- products: productsDone
143
- ? { done: true, detail: `${productCount} product(s) in catalog (${inStockCount} in stock).` }
144
- : { done: false, action: 'Add products — run /epicmerch-migrate (Shopify import) or merchant_create_product with the full shape (variants as { variant, stock }).' },
145
-
146
- storefront: storefrontDone
147
- ? { done: true, detail: 'Storefront scaffold present (src/lib/epicmerch.js found).' }
148
- : { done: false, action: 'Scaffold the storefront ask the merchant the vibe question (4a), then run /epicmerch-storefront (4b) and do a style pass.' },
149
-
150
- verified: verifiedDone
151
- ? { done: true, detail: `Verified live ${lastVerifiedAt} (within ${VERIFIED_FRESHNESS_HOURS}h).` }
152
- : { done: false, action: 'Run /epicmerch-verify (Deep mode) a full product→cart→order→cleanup pass against the live API. On success it stamps verification server-side (this is the un-fakeable gate; Smoke mode is the no-token fallback).' },
153
- };
154
-
155
- const nextStep = STEP_ORDER.find((k) => !steps[k].done) ?? null;
156
- const complete = nextStep === null; // true ONLY when ALL steps incl. verified are done
157
-
158
- return { steps, nextStep, complete };
159
- }
1
+ // src/lib/onboardingState.js
2
+ //
3
+ // The CONTROL PLANE of the /epicmerch onboarding harness.
4
+ //
5
+ // This module is the single source of truth for "what's done / what's next /
6
+ // are we complete". It re-derives that from live server state on every call —
7
+ // nothing is cached, nothing is asserted by the LLM. The thin skill loop in
8
+ // epicmerch.md drives off `deriveOnboardingState`, and `merchant_diagnose`
9
+ // reuses the same helpers (safeGet/unwrap/unwrapList/productStock) so the two
10
+ // can never drift apart — the `countInStock` regression class is structurally
11
+ // impossible once both read stock through the same `productStock`.
12
+ //
13
+ // THE GATE: `complete` is true only when EVERY step — including `verified` — is
14
+ // done, and `verified.done` requires a fresh server-recorded `lastVerifiedAt`.
15
+ // That timestamp is stamped ONLY by /epicmerch-verify (Deep mode) → merchant_mark_verified →
16
+ // POST /api/users/verification-passed. The LLM cannot fake it; "done but never
17
+ // verified" is unreachable.
18
+
19
+ // Freshness window for the `verified` gate (hours). A store counts as verified
20
+ // only if its last successful /epicmerch-verify (Deep mode) run was within this window. Kept
21
+ // simple (time-based only) for now — re-verify-on-change is out of scope.
22
+ export const VERIFIED_FRESHNESS_HOURS = 24;
23
+
24
+ // Canonical step order. `nextStep` is the first not-done step in this order, and
25
+ // `complete` is "no not-done step remains". `verified` is intentionally last so
26
+ // the gate can't be satisfied before a live e2e run.
27
+ export const STEP_ORDER = ['account', 'apiKey', 'payment', 'products', 'storefront', 'verified'];
28
+
29
+ // Fetch a path, never throw. Returns { ok:true, data } or { ok:false, error }.
30
+ // One failed read does not abort the others (callers Promise.all these).
31
+ export const safeGet = async (client, path) => {
32
+ try { return { ok: true, data: await client.authGet(path) }; }
33
+ catch (e) { return { ok: false, error: e?.message || String(e) }; }
34
+ };
35
+
36
+ // Unwrap a single-object response, tolerating shape drift (e.g. profile may be
37
+ // { user: {...} } or {...}). Returns `fallback` when the read failed.
38
+ export const unwrap = (r, fallback = null) => (r?.ok ? (r.data ?? fallback) : fallback);
39
+
40
+ // Unwrap a list response into a plain array, tolerating these shapes:
41
+ // [...] (bare array)
42
+ // { products: [...] } (products list endpoint)
43
+ // { categories: [...] } (categories list endpoint)
44
+ // { keys: [...] } (api-keys list endpoint)
45
+ // Returns null when the read failed (so callers can distinguish "fetch error"
46
+ // from "fetched, empty").
47
+ export const unwrapList = (r) => {
48
+ if (!r?.ok) return null;
49
+ const d = r.data;
50
+ if (Array.isArray(d)) return d;
51
+ if (Array.isArray(d?.products)) return d.products;
52
+ if (Array.isArray(d?.categories)) return d.categories;
53
+ if (Array.isArray(d?.keys)) return d.keys;
54
+ return [];
55
+ };
56
+
57
+ // Total available stock for a product. The Prisma API returns `stock` (NOT the
58
+ // legacy Mongo field `countInStock` — reading that made EVERY product look
59
+ // out-of-stock). For products with variants, true availability is the sum of
60
+ // per-variant stock. Falls back to countInStock only for very old API shapes.
61
+ export const productStock = (p) => {
62
+ if (Array.isArray(p?.variants) && p.variants.length > 0) {
63
+ return p.variants.reduce((sum, v) => sum + (Number(v?.stock) || 0), 0);
64
+ }
65
+ return Number(p?.stock ?? p?.countInStock ?? 0) || 0;
66
+ };
67
+
68
+ // True when `lastVerifiedAt` (ISO string or Date) is within the freshness window.
69
+ export const isVerifiedFresh = (lastVerifiedAt, hours = VERIFIED_FRESHNESS_HOURS) => {
70
+ if (!lastVerifiedAt) return false;
71
+ const ts = new Date(lastVerifiedAt).getTime();
72
+ if (Number.isNaN(ts)) return false;
73
+ return (Date.now() - ts) < hours * 60 * 60 * 1000;
74
+ };
75
+
76
+ /**
77
+ * Derive the deterministic onboarding state from live server reads + one client
78
+ * flag. This is the ONLY place the harness decides done/next/complete.
79
+ *
80
+ * Server reads (4, in parallel):
81
+ * /users/settings → account done (200) + lastVerifiedAt (verified)
82
+ * /keys → apiKey done (length > 0)
83
+ * /users/checkout-settings → payment done (processor + checkoutType)
84
+ * /products → products done (total > 0)
85
+ * Client flag:
86
+ * storefrontPresent → storefront done (does src/lib/epicmerch.js exist?)
87
+ *
88
+ * @param {object} client - Client with authGet(path) → parsed JSON (throws on error).
89
+ * @param {{ storefrontPresent?: boolean }} opts
90
+ * @returns {Promise<{ steps: object, nextStep: string|null, complete: boolean }>}
91
+ */
92
+ export async function deriveOnboardingState(client, { storefrontPresent = false } = {}) {
93
+ const [settingsR, keysR, checkoutR, productsR] = await Promise.all([
94
+ safeGet(client, '/users/settings'),
95
+ safeGet(client, '/keys'),
96
+ safeGet(client, '/users/checkout-settings'),
97
+ safeGet(client, '/products'),
98
+ ]);
99
+
100
+ const settings = unwrap(settingsR);
101
+ const keys = unwrapList(keysR) || [];
102
+ const checkout = unwrap(checkoutR);
103
+ const products = unwrapList(productsR) || [];
104
+
105
+ // --- account: the settings read succeeded (merchant is authenticated + provisioned).
106
+ const accountDone = settingsR.ok;
107
+
108
+ // --- apiKey: at least one storefront key issued.
109
+ const apiKeyDone = keys.length > 0;
110
+
111
+ // --- payment: a processor is configured AND a checkout type is selected.
112
+ const razorpayConfigured = Boolean(checkout?.razorpayKeyId && checkout?.razorpayKeySecretIsSet);
113
+ const stripeConfigured = Boolean(checkout?.stripeSecretKeyIsSet);
114
+ const checkoutType = checkout?.checkoutType ?? null;
115
+ const paymentDone = Boolean((razorpayConfigured || stripeConfigured) && checkoutType);
116
+
117
+ // --- products: at least one product in the catalog (prefer total, fall back to page length).
118
+ const productCount = Number(unwrap(productsR)?.total) || products.length;
119
+ const inStockCount = products.filter((p) => productStock(p) > 0).length;
120
+ const productsDone = productCount > 0;
121
+
122
+ // --- storefront: client says the scaffold exists locally.
123
+ const storefrontDone = Boolean(storefrontPresent);
124
+
125
+ // --- verified: server stamped lastVerifiedAt within the freshness window.
126
+ const lastVerifiedAt = settings?.lastVerifiedAt ?? null;
127
+ const verifiedDone = isVerifiedFresh(lastVerifiedAt);
128
+
129
+ const steps = {
130
+ account: accountDone
131
+ ? { done: true, detail: settings?.name ? `Store "${settings.name}" is provisioned.` : 'Merchant account is provisioned.' }
132
+ : { done: false, action: `Authenticate first — run \`npx epicmerch-mcp login\` in a terminal. (settings read failed: ${settingsR.error || 'unknown'})` },
133
+
134
+ apiKey: apiKeyDone
135
+ ? { done: true, detail: `${keys.length} storefront API key(s) issued.` }
136
+ : { done: false, action: 'Generate a storefront API key with merchant_generate_api_key.' },
137
+
138
+ // Per-shop: /users/checkout-settings is store-scoped (the client sends
139
+ // X-Store-Id from session.activePlatformStoreId), so this gate reflects the
140
+ // ACTIVE shop. settings.name (from the same store-scoped /users/settings read)
141
+ // names which shop, so the gate is unambiguous when a merchant has many shops.
142
+ payment: paymentDone
143
+ ? { done: true, detail: `Payment ready for "${settings?.name ?? 'this shop'}" (checkoutType="${checkoutType}"${razorpayConfigured ? ', Razorpay configured' : ''}${stripeConfigured ? ', Stripe configured' : ''}).` }
144
+ : { done: false, action: `Configure a payment processor for "${settings?.name ?? 'this shop'}" — run /epicmerch-payments, then set the checkout type.` },
145
+
146
+ products: productsDone
147
+ ? { done: true, detail: `${productCount} product(s) in catalog (${inStockCount} in stock).` }
148
+ : { done: false, action: 'Add productsrun /epicmerch-migrate (Shopify import) or merchant_create_product with the full shape (variants as { variant, stock }).' },
149
+
150
+ storefront: storefrontDone
151
+ ? { done: true, detail: 'Storefront scaffold present (src/lib/epicmerch.js found).' }
152
+ : { done: false, action: 'Scaffold the storefrontask the merchant the vibe question (4a), then run /epicmerch-storefront (4b) and do a style pass.' },
153
+
154
+ verified: verifiedDone
155
+ ? { done: true, detail: `Verified live ${lastVerifiedAt} (within ${VERIFIED_FRESHNESS_HOURS}h).` }
156
+ : { done: false, action: 'Run /epicmerch-verify (Deep mode) a full product→cart→order→cleanup pass against the live API. On success it stamps verification server-side (this is the un-fakeable gate; Smoke mode is the no-token fallback).' },
157
+ };
158
+
159
+ const nextStep = STEP_ORDER.find((k) => !steps[k].done) ?? null;
160
+ const complete = nextStep === null; // true ONLY when ALL steps incl. verified are done
161
+
162
+ return { steps, nextStep, complete };
163
+ }
@@ -311,6 +311,14 @@ export const TOOL_EXAMPLES = {
311
311
  'Push a product to SR catalog',
312
312
  ],
313
313
 
314
+ // --- Multi-store ---
315
+ merchant_switch_store: [
316
+ 'Switch to my other store',
317
+ 'Work on my Streetwear store',
318
+ 'Which stores do I have?',
319
+ 'List my stores',
320
+ ],
321
+
314
322
  // --- Shopify migration ---
315
323
  merchant_shopify_migrate: [
316
324
  'Migrate my Shopify store',
@@ -195,7 +195,13 @@ export function merchantTools(client, session) {
195
195
 
196
196
  // --- SHIPROCKET ---
197
197
  async merchant_list_sr_orders(body) { requireAuth(); return ok(await client.authPost('/sr-checkout/order-list', body)); },
198
- async merchant_initiate_refund({ orderId, amount }) { requireAuth(); return ok(await client.authPost('/sr-checkout/refund', { order_id: orderId, amount })); },
198
+ async merchant_initiate_refund({ orderId, amount, reason, restock } = {}) {
199
+ requireAuth();
200
+ const body = { amount };
201
+ if (reason !== undefined) body.reason = reason;
202
+ if (restock !== undefined) body.restock = restock;
203
+ return ok(await client.authPost(`/orders/${orderId}/refund`, body));
204
+ },
199
205
  async merchant_sync_product_to_sr({ productId }) { requireAuth(); return ok(await client.authPost('/sr-checkout/catalog/sync-product', { productId })); },
200
206
 
201
207
  // --- SHOPIFY MIGRATION ---
@@ -399,12 +405,13 @@ export function merchantTools(client, session) {
399
405
  // src/lib/onboardingState.js and SHARED with the onboarding state machine,
400
406
  // so the health report and the harness gate can never drift — same reads,
401
407
  // same stock math, one source of truth (kills the countInStock drift class).
402
- const [profileR, productsR, categoriesR, checkoutR, keysR] = await Promise.all([
408
+ const [profileR, productsR, categoriesR, checkoutR, keysR, storesR] = await Promise.all([
403
409
  safeGet(client, '/users/profile'),
404
410
  safeGet(client, '/products'),
405
411
  safeGet(client, '/categories'),
406
412
  safeGet(client, '/users/checkout-settings'),
407
413
  safeGet(client, '/keys'),
414
+ safeGet(client, '/stores'),
408
415
  ]);
409
416
 
410
417
  const profile = unwrap(profileR, null);
@@ -413,8 +420,18 @@ export function merchantTools(client, session) {
413
420
  const checkout = unwrap(checkoutR, null);
414
421
  const keys = unwrapList(keysR);
415
422
 
423
+ // MULTI-STORE: every section above is scoped server-side by X-Store-Id (the
424
+ // active PLATFORM store from session.activePlatformStoreId). Resolve that
425
+ // store's id/name so the diagnosis is unambiguous about WHICH shop it
426
+ // describes — the checkout/payment reads are per-shop now.
427
+ const storesData = unwrap(storesR, null);
428
+ const activeStoreId = session.activePlatformStoreId?.() || storesData?.activeStoreId || null;
429
+ const activeStore = (storesData?.stores || []).find((s) => s.id === activeStoreId) || null;
430
+
416
431
  // Build each section, returning { error } if its fetch failed.
417
432
  const store = profileR.ok ? {
433
+ activeStoreId,
434
+ activeStoreName: activeStore?.name ?? null,
418
435
  name: profile?.name ?? profile?.user?.name ?? null,
419
436
  currency: profile?.currency ?? profile?.user?.currency ?? null,
420
437
  hasDomain: Boolean(profile?.domain ?? profile?.user?.domain),
@@ -506,6 +523,35 @@ export function merchantTools(client, session) {
506
523
  requireAuth();
507
524
  return ok(await client.authPost('/users/verification-passed', {}));
508
525
  },
526
+
527
+ // --- MULTI-STORE: active-store context ---
528
+ // Sets the session's active PLATFORM store (EpicMerch Store entity — NOT a
529
+ // credential profile; see Session#activePlatformStoreId). Once set, the
530
+ // Client sends X-Store-Id on every request and the server's tenantMiddleware
531
+ // scopes all merchant tools to that store. No per-tool storeId params.
532
+ // GET /api/stores returns { stores: [{id,name,slug,status,createdAt}], activeStoreId, maxStores }.
533
+ async merchant_switch_store({ store } = {}) {
534
+ requireAuth();
535
+ const data = await client.authGet('/stores');
536
+ const list = Array.isArray(data) ? data : (data.stores || []);
537
+ if (!store) {
538
+ return ok({
539
+ activeStoreId: session.activePlatformStoreId() || data.activeStoreId || null,
540
+ stores: list,
541
+ });
542
+ }
543
+ const match = list.find(s => s.id === store
544
+ || (s.slug && s.slug.toLowerCase() === String(store).toLowerCase())
545
+ || (s.name && s.name.toLowerCase() === String(store).toLowerCase()));
546
+ if (!match) {
547
+ return ok({
548
+ error: `No store matching "${store}".`,
549
+ stores: list.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
550
+ });
551
+ }
552
+ session.setActivePlatformStoreId(match.id);
553
+ return ok({ switched: true, activeStore: { id: match.id, name: match.name, slug: match.slug } });
554
+ },
509
555
  };
510
556
  }
511
557
 
@@ -593,8 +639,18 @@ const _baseMerchantToolDefs = [
593
639
  { name: 'merchant_update_email_settings', description: 'Update email settings.', schema: {} },
594
640
  { name: 'merchant_get_logistics_settings', description: 'Get logistics/shipping settings.', schema: {} },
595
641
  { name: 'merchant_update_logistics_settings', description: 'Update logistics settings.', schema: {} },
596
- { name: 'merchant_get_checkout_settings', description: 'Get checkout settings.', schema: {} },
597
- { name: 'merchant_update_checkout_settings', description: 'Update checkout settings.', schema: {} },
642
+ { name: 'merchant_get_checkout_settings', description: 'Get checkout settings: checkoutType plus the merchant-defined per-store pricing (taxRate as a fraction, shippingFee, freeShippingThreshold, codEnabled). A null pricing field means the store falls back to the platform env/default. These values are the single source of truth the storefront display is seeded from, so they always match what the server charges.', schema: {} },
643
+ {
644
+ name: 'merchant_update_checkout_settings',
645
+ description: 'Update checkout settings. Set the merchant-defined per-store pricing here — it is the single source of truth used for BOTH the storefront display and the server-side charge. IMPORTANT: taxRate is a FRACTION (0.18 = 18% GST), NOT a percentage. Pass null (or "") for any pricing field to clear it and fall back to the platform env/default. Use merchant_set_checkout_type / merchant_configure_razorpay / merchant_configure_stripe for those specific fields; this tool can also set them but the dedicated tools validate inputs.',
646
+ schema: {
647
+ checkoutType: { type: 'string', description: "Checkout processor: 'epicmerch' | 'stripe' | 'shiprocket'" },
648
+ taxRate: { type: 'number', description: 'Tax applied to the cart subtotal, as a FRACTION (0.18 = 18%). null clears → env/default.' },
649
+ shippingFee: { type: 'number', description: 'Flat shipping fee in the store currency. null clears → env/default.' },
650
+ freeShippingThreshold: { type: 'number', description: 'Subtotal at or above which shipping is free. null clears → env/default.' },
651
+ codEnabled: { type: 'boolean', description: 'Whether Cash on Delivery is offered at checkout.' },
652
+ },
653
+ },
598
654
  { name: 'merchant_get_security_settings', description: 'Get security settings and allowed domains.', schema: {} },
599
655
  { name: 'merchant_add_allowed_domain', description: 'Add an allowed CORS domain.', schema: { domain: { type: 'string' } } },
600
656
  { name: 'merchant_remove_allowed_domain', description: 'Remove an allowed CORS domain.', schema: { domain: { type: 'string' } } },
@@ -612,7 +668,7 @@ const _baseMerchantToolDefs = [
612
668
  { name: 'merchant_track_shipment', description: 'Track a shipment by tracking ID.', schema: { trackingId: { type: 'string' } } },
613
669
  { name: 'merchant_get_consolidation_report', description: 'Get financial consolidation report.', schema: {} },
614
670
  { name: 'merchant_list_sr_orders', description: 'List Shiprocket orders.', schema: {} },
615
- { name: 'merchant_initiate_refund', description: 'Initiate a Shiprocket refund.', schema: { orderId: { type: 'string' }, amount: { type: 'number' } } },
671
+ { name: 'merchant_initiate_refund', description: 'Issue a real provider-backed refund (Stripe/Razorpay/Shiprocket) for an order. Full or partial; set restock:true on a FULL refund to return inventory.', schema: { orderId: { type: 'string' }, amount: { type: 'number' }, reason: { type: 'string' }, restock: { type: 'boolean' } } },
616
672
  { name: 'merchant_sync_product_to_sr', description: 'Sync a product to Shiprocket catalog.', schema: { productId: { type: 'string' } } },
617
673
  {
618
674
  name: 'merchant_quick_setup',
@@ -666,6 +722,11 @@ const _baseMerchantToolDefs = [
666
722
  description: 'Set which checkout flow the merchant\'s storefront uses. Must be one of: "epicmerch" (Razorpay + Shiprocket), "stripe" (Stripe + Shiprocket), or "shiprocket" (full SR Checkout).',
667
723
  schema: { type: { type: 'string' } },
668
724
  },
725
+ {
726
+ name: 'merchant_switch_store',
727
+ description: 'Switch the active store (multi-store merchants). All subsequent merchant tools operate on this store. Call with no args to list available stores.',
728
+ schema: { store: { type: 'string', description: 'Store id, slug, or name. Omit to list stores.' } },
729
+ },
669
730
  {
670
731
  name: 'merchant_shopify_migrate',
671
732
  description: 'Migrate a Shopify store to EpicMerch. stage="extract" fetches Shopify data and returns a preview diff + sessionId. stage="import" runs the actual import for an approved sessionId.',
package/src/version.js ADDED
@@ -0,0 +1,13 @@
1
+ // src/version.js
2
+ // Single source of the package version, read from package.json at runtime — so
3
+ // the MCP serverInfo handshake, the OpenAPI spec, etc. always match the published
4
+ // version instead of drifting from a hardcoded literal. Works in local dev, the
5
+ // npm tarball, and the staged .dxt (package.json sits at ../ from src/ in all three).
6
+ import { readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+
10
+ const __dir = dirname(fileURLToPath(import.meta.url));
11
+ export const VERSION = JSON.parse(
12
+ readFileSync(join(__dir, '..', 'package.json'), 'utf-8')
13
+ ).version;