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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/epicmerch-storefront.md +575 -90
- package/skills/epicmerch.md +4 -7
- package/src/adapters/mcp.js +2 -1
- package/src/adapters/openapi.js +2 -1
- package/src/auth.js +11 -0
- package/src/client.js +6 -0
- package/src/lib/onboardingState.js +163 -159
- package/src/tools/_examples.js +8 -0
- package/src/tools/merchant.js +66 -5
- package/src/version.js +13 -0
package/skills/epicmerch.md
CHANGED
|
@@ -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
|
|
124
|
-
`visualStyle
|
|
125
|
-
`
|
|
126
|
-
|
|
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
|
|
package/src/adapters/mcp.js
CHANGED
|
@@ -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:
|
|
31
|
+
const server = new McpServer({ name: 'epicmerch', version: VERSION });
|
|
31
32
|
|
|
32
33
|
const allToolDefs = [...sessionToolDefs, ...developerToolDefs, ...scaffoldToolDefs, ...merchantToolDefs];
|
|
33
34
|
const allHandlers = {
|
package/src/adapters/openapi.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
? { done: true, detail:
|
|
144
|
-
: { done: false, action: '
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
? { done: true, detail:
|
|
148
|
-
: { done: false, action: '
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
? { done: true, detail:
|
|
152
|
-
: { done: false, action: '
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 products — run /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 storefront — ask 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
|
+
}
|
package/src/tools/_examples.js
CHANGED
|
@@ -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',
|
package/src/tools/merchant.js
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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: '
|
|
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;
|