@anton.andrusenko/shopify-mcp-admin 1.1.2 → 2.0.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 CHANGED
@@ -40,9 +40,25 @@ npx @anton.andrusenko/shopify-mcp-admin
40
40
  npm install -g @anton.andrusenko/shopify-mcp-admin
41
41
  ```
42
42
 
43
- ### Configuration
43
+ ### Setup Wizard (Recommended)
44
44
 
45
- Set your environment variables:
45
+ The easiest way to get started is with the interactive setup wizard:
46
+
47
+ ```bash
48
+ npx @anton.andrusenko/shopify-mcp-admin init
49
+ ```
50
+
51
+ The wizard will:
52
+ 1. **Prompt for your store URL** and validate the format
53
+ 2. **Choose authentication method** — Legacy token or OAuth 2.0
54
+ 3. **Test the connection** — Verifies credentials before saving
55
+ 4. **Select your AI client** — Claude Desktop, Cursor, Windsurf, VS Code, LibreChat, or OpenAI
56
+ 5. **Configure tool loading** — All tools, role preset, or lazy loading
57
+ 6. **Generate and save config** — Creates the appropriate config file for your client
58
+
59
+ ### Manual Configuration
60
+
61
+ Alternatively, set environment variables directly:
46
62
 
47
63
  ```bash
48
64
  export SHOPIFY_STORE_URL=your-store.myshopify.com
@@ -99,10 +115,13 @@ Tokens are automatically refreshed every 24 hours.
99
115
 
100
116
  | Variable | Required | Default | Description |
101
117
  |----------|----------|---------|-------------|
102
- | `SHOPIFY_STORE_URL` | Yes | | Your Shopify store domain (e.g., `your-store.myshopify.com`) |
118
+ | `SERVER_MODE` | No | `local` | Server mode: `local` (single-tenant) or `remote` (multi-tenant) |
119
+ | `SHOPIFY_STORE_URL` | ✅ (local mode) | — | Your Shopify store domain (e.g., `your-store.myshopify.com`) |
103
120
  | `SHOPIFY_ACCESS_TOKEN` | ⚡ See below | — | Admin API access token from your Custom App |
104
121
  | `SHOPIFY_CLIENT_ID` | ⚡ See below | — | Client ID from Dev Dashboard app |
105
122
  | `SHOPIFY_CLIENT_SECRET` | ⚡ See below | — | Client Secret from Dev Dashboard app |
123
+ | `DATABASE_URL` | ✅ (remote mode) | — | PostgreSQL connection URL for multi-tenant mode |
124
+ | `ENCRYPTION_KEY` | ✅ (remote mode) | — | 64-char hex key for credential encryption |
106
125
  | `SHOPIFY_API_VERSION` | No | `2025-10` | Shopify API version |
107
126
  | `TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` |
108
127
  | `PORT` | No | `3000` | HTTP server port (when `TRANSPORT=http`) |
@@ -111,7 +130,28 @@ Tokens are automatically refreshed every 24 hours.
111
130
  | `SHOPIFY_MCP_LAZY_LOADING` | No | `false` | Enable modular lazy loading (set to `true` for on-demand module loading) |
112
131
  | `SHOPIFY_MCP_ROLE` | No | — | Role preset for automatic module loading (see [Role Presets](#-role-presets)) |
113
132
 
114
- **⚡ Authentication:** Provide EITHER `SHOPIFY_ACCESS_TOKEN` (legacy) OR both `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET` (Dev Dashboard).
133
+ ### Server Modes
134
+
135
+ shopify-mcp-admin supports two operating modes:
136
+
137
+ | Mode | Description | Use Case |
138
+ |------|-------------|----------|
139
+ | **local** (default) | Single-tenant with env credentials | Claude Desktop, local AI agents, development |
140
+ | **remote** | Multi-tenant with database storage | SaaS platform, hosted service |
141
+
142
+ **Local Mode** (default):
143
+ - Credentials via `SHOPIFY_STORE_URL` + `SHOPIFY_ACCESS_TOKEN` (or OAuth)
144
+ - All 79 tools operate on a single store
145
+ - No database required
146
+ - Works with STDIO and HTTP transports
147
+
148
+ **Remote Mode**:
149
+ - Multi-tenant with per-tenant credentials stored in PostgreSQL
150
+ - Requires `DATABASE_URL` and `ENCRYPTION_KEY`
151
+ - HTTP transport only (STDIO not supported for multi-tenant)
152
+ - Web dashboard available at `/app`
153
+
154
+ **⚡ Authentication (local mode):** Provide EITHER `SHOPIFY_ACCESS_TOKEN` (legacy) OR both `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET` (Dev Dashboard).
115
155
 
116
156
  ### Required Shopify Scopes
117
157
 
@@ -1187,6 +1227,94 @@ Enable these settings for the `main` branch in **Settings → Branches**:
1187
1227
 
1188
1228
  ---
1189
1229
 
1230
+ ## 🚀 Deployment
1231
+
1232
+ shopify-mcp-admin supports multiple deployment options for different environments:
1233
+
1234
+ ### Railway (Recommended for Quick Start)
1235
+
1236
+ Deploy to [Railway](https://railway.app) with the included `railway.json` configuration:
1237
+
1238
+ 1. Connect your GitHub repository to Railway
1239
+ 2. Add environment variables (DATABASE_URL, ENCRYPTION_KEY, etc.)
1240
+ 3. Deploy — Railway auto-detects the Dockerfile
1241
+
1242
+ ### Kubernetes
1243
+
1244
+ For self-hosted or cloud Kubernetes clusters, see the [Kubernetes Deployment Guide](docs/kubernetes.md).
1245
+
1246
+ Quick start:
1247
+ ```bash
1248
+ # Build and push image
1249
+ docker build -t yourregistry/shopify-mcp-admin:v1.0.0 .
1250
+ docker push yourregistry/shopify-mcp-admin:v1.0.0
1251
+
1252
+ # Configure and deploy
1253
+ cd k8s
1254
+ kustomize edit set image shopify-mcp-admin=yourregistry/shopify-mcp-admin:v1.0.0
1255
+ kubectl apply -k .
1256
+ ```
1257
+
1258
+ ### Docker (Local/Development)
1259
+
1260
+ ```bash
1261
+ # Build image
1262
+ docker build -t shopify-mcp-admin .
1263
+
1264
+ # Run container
1265
+ docker run -p 3000:3000 \
1266
+ -e DATABASE_URL="postgresql://..." \
1267
+ -e ENCRYPTION_KEY="..." \
1268
+ shopify-mcp-admin
1269
+ ```
1270
+
1271
+ ### Image Size
1272
+
1273
+ The production Docker image is optimized for minimal size:
1274
+ - Multi-stage build with Alpine Linux
1275
+ - Production dependencies only (`npm prune --production`)
1276
+ - Target size: <150MB
1277
+
1278
+ ---
1279
+
1280
+ ## 🎨 Tenant Dashboard (Remote Mode)
1281
+
1282
+ When running in remote mode (`SERVER_MODE=remote`), shopify-mcp-admin includes a web-based dashboard for tenant management. The dashboard provides a modern, responsive UI for:
1283
+
1284
+ - **Shop Management** — View and connect Shopify stores
1285
+ - **API Key Management** — Generate, copy, and revoke API keys
1286
+ - **Usage Statistics** — Monitor API calls and activity
1287
+ - **Account Settings** — Update profile and change password
1288
+
1289
+ ### Accessing the Dashboard
1290
+
1291
+ 1. Start the server in remote mode:
1292
+ ```bash
1293
+ SERVER_MODE=remote npm start
1294
+ ```
1295
+
1296
+ 2. Navigate to `http://localhost:3000/app` in your browser
1297
+
1298
+ 3. Log in with your tenant credentials
1299
+
1300
+ ### Dashboard Features
1301
+
1302
+ - ✨ **Modern UI** — Built with React, Vite, and Tailwind CSS
1303
+ - 📱 **Responsive Design** — Works on desktop, tablet, and mobile
1304
+ - ♿ **Accessible** — WCAG AA compliant with keyboard navigation
1305
+ - 🔐 **Secure** — Session-based authentication with HttpOnly cookies
1306
+ - 🎯 **Optimized** — <150KB gzipped bundle size
1307
+
1308
+ ### Tech Stack
1309
+
1310
+ - **Frontend:** React 18 + Vite + TypeScript
1311
+ - **Styling:** Tailwind CSS + shadcn/ui components
1312
+ - **State:** React Query for server state management
1313
+ - **Forms:** React Hook Form + Zod validation
1314
+ - **Auth:** Session cookies with automatic expiry handling
1315
+
1316
+ ---
1317
+
1190
1318
  ## 🤝 Contributing
1191
1319
 
1192
1320
  Contributions are welcome! Here's how to get started:
@@ -1251,3 +1379,55 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE
1251
1379
  <strong>Made with ❤️ for the AI-powered commerce era</strong>
1252
1380
  </p>
1253
1381
 
1382
+ ---
1383
+
1384
+ ## 🚀 Deployment
1385
+
1386
+ ### Production Deployment
1387
+
1388
+ For production deployment on Railway with Cloudflare, Sentry, and uptime monitoring, see the comprehensive deployment guide:
1389
+
1390
+ 📖 **[Production Deployment Guide](./docs/deployment.md)**
1391
+
1392
+ The guide includes:
1393
+ - **Railway Setup**: Step-by-step instructions for deploying to Railway
1394
+ - **Cloudflare DNS**: DNS configuration and SSL/TLS setup
1395
+ - **Database Setup**: Prisma migrations and backup strategy
1396
+ - **Staging Environment**: Separate staging deployment configuration
1397
+ - **Monitoring**: Sentry error tracking and uptime monitoring setup
1398
+
1399
+ ### Environment Variables
1400
+
1401
+ For production environment variables, see:
1402
+
1403
+ 📄 **[`.env.production.example`](./.env.production.example)**
1404
+
1405
+ This template includes all required and optional environment variables with descriptions, example values, and generation instructions.
1406
+
1407
+ ### Quick Start: Local vs Production
1408
+
1409
+ | Mode | Use Case | Transport | Database | Credentials |
1410
+ |------|----------|-----------|----------|-------------|
1411
+ | **Local** | Development, Claude Desktop | STDIO or HTTP | None | Environment variables |
1412
+ | **Remote** | Production, SaaS platform | HTTP only | PostgreSQL | Database (encrypted) |
1413
+
1414
+ **Local Mode** (default):
1415
+ ```bash
1416
+ export SHOPIFY_STORE_URL=your-store.myshopify.com
1417
+ export SHOPIFY_ACCESS_TOKEN=shpat_xxxxx
1418
+ npx @anton.andrusenko/shopify-mcp-admin
1419
+ ```
1420
+
1421
+ **Remote Mode** (production):
1422
+ ```bash
1423
+ export SERVER_MODE=remote
1424
+ export DATABASE_URL=postgresql://...
1425
+ export ENCRYPTION_KEY=<64-char-hex>
1426
+ export TRANSPORT=http
1427
+ # See .env.production.example for all variables
1428
+ ```
1429
+
1430
+ ---
1431
+
1432
+ </p>
1433
+
@@ -0,0 +1,124 @@
1
+ import {
2
+ log
3
+ } from "./chunk-GKGHMPEC.js";
4
+
5
+ // src/middleware/mcp-auth.ts
6
+ var MCP_AUTH_ERROR_CODES = {
7
+ AUTH_REQUIRED: -32001,
8
+ // Missing authentication
9
+ AUTH_INVALID: -32002,
10
+ // Invalid API key format or key
11
+ AUTH_REVOKED: -32003,
12
+ // API key has been revoked
13
+ AUTH_FORBIDDEN: -32004
14
+ // Tenant suspended or no access
15
+ };
16
+ function createJsonRpcError(code, message, hint) {
17
+ const error = {
18
+ jsonrpc: "2.0",
19
+ error: {
20
+ code,
21
+ message
22
+ },
23
+ id: null
24
+ };
25
+ if (hint) {
26
+ error.error.data = { hint };
27
+ }
28
+ return error;
29
+ }
30
+ function parseBearerToken(authHeader) {
31
+ if (!authHeader) {
32
+ return null;
33
+ }
34
+ const parts = authHeader.split(" ");
35
+ if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
36
+ return null;
37
+ }
38
+ const token = parts[1];
39
+ if (!token || token.trim() === "") {
40
+ return null;
41
+ }
42
+ return token;
43
+ }
44
+ async function validateMcpApiKey(authHeader, apiKeyService, prisma) {
45
+ const token = parseBearerToken(authHeader);
46
+ if (!token) {
47
+ return {
48
+ valid: false,
49
+ error: "Authentication required",
50
+ httpStatus: 401
51
+ };
52
+ }
53
+ const validationResult = await apiKeyService.validate(token);
54
+ if (!validationResult.valid) {
55
+ const isRevoked = validationResult.error?.includes("revoked");
56
+ return {
57
+ valid: false,
58
+ error: validationResult.error || "Invalid API key",
59
+ httpStatus: isRevoked ? 403 : 401
60
+ };
61
+ }
62
+ const tenant = validationResult.tenant;
63
+ const tenantShops = await prisma.tenantShop.findMany({
64
+ where: {
65
+ tenantId: tenant.tenantId,
66
+ uninstalledAt: null
67
+ // Only active shops
68
+ },
69
+ select: {
70
+ shopDomain: true,
71
+ scopes: true
72
+ }
73
+ });
74
+ const allowedShops = tenantShops.map((shop) => shop.shopDomain);
75
+ const mcpTenantContext = {
76
+ tenantId: tenant.tenantId,
77
+ email: tenant.email,
78
+ defaultShop: validationResult.shop,
79
+ allowedShops
80
+ };
81
+ return {
82
+ valid: true,
83
+ tenant: mcpTenantContext
84
+ };
85
+ }
86
+ function createMcpAuthMiddleware(options) {
87
+ const { apiKeyService, prisma, isRemote } = options;
88
+ return async (req, res, next) => {
89
+ if (!isRemote) {
90
+ log.debug("[mcp-auth] Local mode: skipping API key validation");
91
+ next();
92
+ return;
93
+ }
94
+ const authResult = await validateMcpApiKey(req.headers.authorization, apiKeyService, prisma);
95
+ if (!authResult.valid) {
96
+ log.debug(`[mcp-auth] Auth failed: ${authResult.error}`);
97
+ let errorCode;
98
+ let hint;
99
+ if (authResult.httpStatus === 401) {
100
+ errorCode = authResult.error === "Authentication required" ? MCP_AUTH_ERROR_CODES.AUTH_REQUIRED : MCP_AUTH_ERROR_CODES.AUTH_INVALID;
101
+ hint = "Include Authorization: Bearer sk_live_xxx header";
102
+ } else {
103
+ errorCode = authResult.error?.includes("revoked") ? MCP_AUTH_ERROR_CODES.AUTH_REVOKED : MCP_AUTH_ERROR_CODES.AUTH_FORBIDDEN;
104
+ hint = "API key has been revoked or tenant is inactive";
105
+ }
106
+ res.status(authResult.httpStatus || 401).json(createJsonRpcError(errorCode, authResult.error || "Authentication failed", hint));
107
+ return;
108
+ }
109
+ req.mcpTenantContext = authResult.tenant;
110
+ log.debug(
111
+ `[mcp-auth] Auth successful for tenant: ${authResult.tenant?.tenantId?.substring(0, 8)}...`
112
+ );
113
+ next();
114
+ };
115
+ }
116
+ var MCP_AUTH_ERRORS = MCP_AUTH_ERROR_CODES;
117
+
118
+ export {
119
+ createJsonRpcError,
120
+ parseBearerToken,
121
+ validateMcpApiKey,
122
+ createMcpAuthMiddleware,
123
+ MCP_AUTH_ERRORS
124
+ };
@@ -0,0 +1,208 @@
1
+ import {
2
+ log
3
+ } from "./chunk-GKGHMPEC.js";
4
+
5
+ // src/db/client.ts
6
+ import { PrismaClient } from "@prisma/client";
7
+ import { PrismaClient as PrismaClient2 } from "@prisma/client";
8
+ var prismaInstance = null;
9
+ function getPrismaClient() {
10
+ if (!prismaInstance) {
11
+ log.debug("Creating new Prisma client instance");
12
+ prismaInstance = new PrismaClient({
13
+ log: process.env.DEBUG === "true" ? ["query", "info", "warn", "error"] : ["error"]
14
+ });
15
+ }
16
+ return prismaInstance;
17
+ }
18
+ async function disconnectPrisma() {
19
+ if (prismaInstance) {
20
+ log.debug("Disconnecting Prisma client");
21
+ await prismaInstance.$disconnect();
22
+ prismaInstance = null;
23
+ }
24
+ }
25
+ var prisma = getPrismaClient();
26
+
27
+ // src/session/store.ts
28
+ var SessionStore = class {
29
+ cleanupInterval = null;
30
+ prisma = getPrismaClient();
31
+ SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
32
+ // 24 hours
33
+ /**
34
+ * Retrieve session data by session ID
35
+ * @param sessionId - Unique session identifier
36
+ * @returns SessionData if valid and not expired, null otherwise
37
+ */
38
+ async get(sessionId) {
39
+ try {
40
+ const session = await this.prisma.tenantSession.findUnique({
41
+ where: { id: sessionId },
42
+ select: {
43
+ tenantId: true,
44
+ expiresAt: true
45
+ }
46
+ });
47
+ if (!session) {
48
+ return null;
49
+ }
50
+ const expiresAt = session.expiresAt.getTime();
51
+ if (expiresAt < Date.now()) {
52
+ await this.prisma.tenantSession.delete({
53
+ where: { id: sessionId }
54
+ }).catch(() => {
55
+ });
56
+ return null;
57
+ }
58
+ return {
59
+ tenantId: session.tenantId,
60
+ expiresAt
61
+ };
62
+ } catch (error) {
63
+ log.error("Failed to retrieve session", error instanceof Error ? error : void 0);
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Store session data with automatic 24-hour expiration
69
+ * @param sessionId - Unique session identifier
70
+ * @param data - Session data (tenantId)
71
+ */
72
+ async set(sessionId, data) {
73
+ const expiresAt = new Date(Date.now() + this.SESSION_TTL_MS);
74
+ try {
75
+ await this.prisma.tenantSession.upsert({
76
+ where: { id: sessionId },
77
+ update: {
78
+ expiresAt
79
+ },
80
+ create: {
81
+ id: sessionId,
82
+ tenantId: data.tenantId,
83
+ expiresAt
84
+ }
85
+ });
86
+ } catch (error) {
87
+ log.error("Failed to store session", error instanceof Error ? error : void 0);
88
+ throw error;
89
+ }
90
+ }
91
+ /**
92
+ * Delete a specific session
93
+ * @param sessionId - Session identifier to delete
94
+ */
95
+ async delete(sessionId) {
96
+ try {
97
+ await this.prisma.tenantSession.delete({
98
+ where: { id: sessionId }
99
+ });
100
+ } catch (error) {
101
+ if (error && typeof error === "object" && "code" in error && error.code !== "P2025") {
102
+ log.error("Failed to delete session", error instanceof Error ? error : void 0);
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Delete all sessions for a specific tenant
108
+ * Used when a tenant changes their password for security
109
+ * @param tenantId - Tenant identifier
110
+ */
111
+ async deleteByTenantId(tenantId) {
112
+ try {
113
+ await this.prisma.tenantSession.deleteMany({
114
+ where: { tenantId }
115
+ });
116
+ log.debug(`Deleted all sessions for tenant ${tenantId}`);
117
+ } catch (error) {
118
+ log.error("Failed to delete tenant sessions", error instanceof Error ? error : void 0);
119
+ throw error;
120
+ }
121
+ }
122
+ /**
123
+ * Remove all expired sessions from the store
124
+ */
125
+ async cleanup() {
126
+ try {
127
+ const result = await this.prisma.tenantSession.deleteMany({
128
+ where: {
129
+ expiresAt: {
130
+ lt: /* @__PURE__ */ new Date()
131
+ }
132
+ }
133
+ });
134
+ if (result.count > 0) {
135
+ log.debug(`Cleaned up ${result.count} expired sessions`);
136
+ }
137
+ } catch (error) {
138
+ log.error("Session cleanup failed", error instanceof Error ? error : void 0);
139
+ }
140
+ }
141
+ /**
142
+ * Start automatic cleanup task (runs every 10 minutes)
143
+ * @returns Timer handle for cleanup task
144
+ */
145
+ startCleanup() {
146
+ if (this.cleanupInterval) {
147
+ return this.cleanupInterval;
148
+ }
149
+ this.cleanupInterval = setInterval(
150
+ () => {
151
+ this.cleanup().catch((error) => {
152
+ log.error("Background session cleanup error", error instanceof Error ? error : void 0);
153
+ });
154
+ },
155
+ 10 * 60 * 1e3
156
+ );
157
+ this.cleanupInterval.unref();
158
+ log.info("Session cleanup background task started (runs every 10 minutes)");
159
+ return this.cleanupInterval;
160
+ }
161
+ /**
162
+ * Stop the automatic cleanup task
163
+ */
164
+ stopCleanup() {
165
+ if (this.cleanupInterval) {
166
+ clearInterval(this.cleanupInterval);
167
+ this.cleanupInterval = null;
168
+ log.info("Session cleanup background task stopped");
169
+ }
170
+ }
171
+ /**
172
+ * Get the number of active sessions (for testing/monitoring)
173
+ */
174
+ async size() {
175
+ try {
176
+ return await this.prisma.tenantSession.count({
177
+ where: {
178
+ expiresAt: {
179
+ gte: /* @__PURE__ */ new Date()
180
+ }
181
+ }
182
+ });
183
+ } catch (error) {
184
+ log.error("Failed to count sessions", error instanceof Error ? error : void 0);
185
+ return 0;
186
+ }
187
+ }
188
+ /**
189
+ * Clear all sessions (for testing)
190
+ */
191
+ async clear() {
192
+ try {
193
+ await this.prisma.tenantSession.deleteMany();
194
+ } catch (error) {
195
+ log.error("Failed to clear sessions", error instanceof Error ? error : void 0);
196
+ throw error;
197
+ }
198
+ }
199
+ };
200
+ var sessionStore = new SessionStore();
201
+
202
+ export {
203
+ getPrismaClient,
204
+ disconnectPrisma,
205
+ prisma,
206
+ SessionStore,
207
+ sessionStore
208
+ };