qualmcp 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/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "qualmcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "qualmcp": "src/cli.js"
7
+ },
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "start": "node src/index.js",
13
+ "start:env": "node src/launch-env.js"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.0.0",
17
+ "dotenv": "^16.4.5",
18
+ "pg": "^8.11.3",
19
+ "zod": "^3.23.8",
20
+ "zod-to-json-schema": "^3.24.1"
21
+ }
22
+ }
package/src/cli.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+
4
+ const dbUrl = String(process.env.DATABASE_URL || '').trim();
5
+ if (!dbUrl) {
6
+ process.stderr.write('DATABASE_URL is required\n');
7
+ process.exit(1);
8
+ }
9
+
10
+ await import('./index.js');
package/src/db.js ADDED
@@ -0,0 +1,27 @@
1
+ import pg from 'pg';
2
+
3
+ const { Pool } = pg;
4
+
5
+ function getDatabaseUrl() {
6
+ const url = process.env.DATABASE_URL;
7
+ if (!url) {
8
+ throw new Error('DATABASE_URL is required');
9
+ }
10
+ return url;
11
+ }
12
+
13
+ function buildSslConfig() {
14
+ const mode = String(process.env.PGSSLMODE || '').toLowerCase();
15
+ if (!mode || mode === 'disable' || mode === 'false' || mode === '0') return false;
16
+ if (mode === 'require' || mode === 'true' || mode === '1') return { rejectUnauthorized: false };
17
+ return { rejectUnauthorized: false };
18
+ }
19
+
20
+ export const pool = new Pool({
21
+ connectionString: getDatabaseUrl(),
22
+ ssl: buildSslConfig()
23
+ });
24
+
25
+ export async function query(sql, params = []) {
26
+ return await pool.query(sql, params);
27
+ }
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ import 'dotenv/config';
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
+ import { zodToJsonSchema } from 'zod-to-json-schema';
7
+
8
+ import { tools } from './tools.js';
9
+
10
+ const server = new Server(
11
+ {
12
+ name: 'qualmcp',
13
+ version: '0.1.0'
14
+ },
15
+ {
16
+ capabilities: {
17
+ tools: {}
18
+ }
19
+ }
20
+ );
21
+
22
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
23
+ return {
24
+ tools: Object.values(tools).map((t) => ({
25
+ name: t.name,
26
+ description: t.description,
27
+ inputSchema: zodToJsonSchema(t.schema, { name: t.name })
28
+ }))
29
+ };
30
+ });
31
+
32
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
33
+ const name = req?.params?.name;
34
+ const args = req?.params?.arguments || {};
35
+
36
+ const entry = Object.values(tools).find((t) => t.name === name);
37
+ if (!entry) {
38
+ return {
39
+ content: [{ type: 'text', text: JSON.stringify({ success: false, error: `Unknown tool: ${name}` }) }]
40
+ };
41
+ }
42
+
43
+ try {
44
+ const parsed = entry.schema.parse(args);
45
+ const result = await entry.handler(parsed);
46
+ return {
47
+ content: [{ type: 'text', text: JSON.stringify({ success: true, result }, null, 2) }]
48
+ };
49
+ } catch (e) {
50
+ const msg = e?.message || String(e);
51
+ return {
52
+ content: [{ type: 'text', text: JSON.stringify({ success: false, error: msg }, null, 2) }]
53
+ };
54
+ }
55
+ });
56
+
57
+ const transport = new StdioServerTransport();
58
+ await server.connect(transport);
@@ -0,0 +1,14 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+
4
+ function main() {
5
+ const dbUrl = String(process.env.DATABASE_URL || '').trim();
6
+ if (!dbUrl) {
7
+ throw new Error('DATABASE_URL is required');
8
+ }
9
+
10
+ const entry = path.resolve('src/index.js');
11
+ import(entry);
12
+ }
13
+
14
+ main();
package/src/sql.js ADDED
@@ -0,0 +1,58 @@
1
+ export function buildOrderScopeWhere({ mode, statusIds, statusKeys, excludeSpam } = {}, startIndex = 1) {
2
+ const clauses = [];
3
+ const params = [];
4
+
5
+ const safeMode = mode || 'all';
6
+ let nextIndex = Number.isFinite(Number(startIndex)) ? Number(startIndex) : 1;
7
+
8
+ const pushParam = (value) => {
9
+ params.push(value);
10
+ const idx = nextIndex;
11
+ nextIndex += 1;
12
+ return idx;
13
+ };
14
+
15
+ if (safeMode === 'orderedFlag') {
16
+ clauses.push('os.is_ordered = TRUE');
17
+ }
18
+
19
+ if (safeMode === 'statusIds') {
20
+ const ids = Array.isArray(statusIds)
21
+ ? Array.from(new Set(statusIds.map((x) => Number(x)).filter((n) => Number.isFinite(n))))
22
+ : [];
23
+ if (ids.length > 0) {
24
+ const idx = pushParam(ids);
25
+ clauses.push(`o.status_id = ANY($${idx})`);
26
+ } else {
27
+ clauses.push('1=0');
28
+ }
29
+ }
30
+
31
+ if (safeMode === 'statusKeys') {
32
+ const keys = Array.isArray(statusKeys)
33
+ ? Array.from(new Set(statusKeys.map((x) => String(x || '').trim()).filter(Boolean)))
34
+ : [];
35
+ if (keys.length > 0) {
36
+ const idx = pushParam(keys);
37
+ clauses.push(`os.key = ANY($${idx})`);
38
+ } else {
39
+ clauses.push('1=0');
40
+ }
41
+ }
42
+
43
+ if (excludeSpam === true) {
44
+ clauses.push('COALESCE(os.is_spam, FALSE) = FALSE');
45
+ }
46
+
47
+ return {
48
+ whereSql: clauses.length > 0 ? clauses.join(' AND ') : '1=1',
49
+ params,
50
+ nextIndex
51
+ };
52
+ }
53
+
54
+ export function normalizeDateField(dateField) {
55
+ const allowed = new Set(['created_at', 'ordered_at', 'updated_at']);
56
+ const f = String(dateField || '').trim();
57
+ return allowed.has(f) ? f : 'created_at';
58
+ }
package/src/tools.js ADDED
@@ -0,0 +1,379 @@
1
+ import { z } from 'zod';
2
+ import { query } from './db.js';
3
+ import { buildOrderScopeWhere, normalizeDateField } from './sql.js';
4
+
5
+ const OrderScopeSchema = z.object({
6
+ mode: z.enum(['all', 'statusIds', 'statusKeys', 'orderedFlag']).default('all'),
7
+ statusIds: z.array(z.number()).optional(),
8
+ statusKeys: z.array(z.string()).optional(),
9
+ excludeSpam: z.boolean().optional()
10
+ }).optional();
11
+
12
+ const DateFieldSchema = z.enum(['created_at', 'ordered_at', 'updated_at']).optional();
13
+
14
+ function parseSkuTag(productSku) {
15
+ const raw = String(productSku || '').trim();
16
+ if (!raw) return { sku: null, tag: null, compound: null };
17
+ if (raw.includes('/')) {
18
+ const [sku, tag] = raw.split('/', 2);
19
+ const base = String(sku || '').trim();
20
+ const t = String(tag || '').trim();
21
+ return { sku: base || null, tag: t || null, compound: `${base}/${t}` };
22
+ }
23
+ return { sku: raw, tag: null, compound: raw };
24
+ }
25
+
26
+ function safeLimit(v, max = 200) {
27
+ const n = Number(v);
28
+ if (!Number.isFinite(n)) return 50;
29
+ return Math.min(max, Math.max(1, Math.floor(n)));
30
+ }
31
+
32
+ export const tools = {
33
+ analytics_salesRank: {
34
+ name: 'analytics.salesRank',
35
+ description: 'Ranking of products by sales quantity/revenue across orders with flexible filters and grouping. Always returns product names when possible.',
36
+ schema: z.object({
37
+ dateFrom: z.string().datetime().optional(),
38
+ dateTo: z.string().datetime().optional(),
39
+ dateField: DateFieldSchema,
40
+ managerId: z.number().optional(),
41
+ orderScope: OrderScopeSchema,
42
+ groupBy: z.enum(['sku', 'sku_tag']).default('sku_tag'),
43
+ metric: z.enum(['qty', 'revenueApprox', 'ordersCount']).default('qty'),
44
+ limit: z.number().optional(),
45
+ offset: z.number().optional()
46
+ }),
47
+ handler: async (input) => {
48
+ const {
49
+ dateFrom,
50
+ dateTo,
51
+ dateField,
52
+ managerId,
53
+ orderScope,
54
+ groupBy,
55
+ metric,
56
+ limit,
57
+ offset
58
+ } = input;
59
+
60
+ const field = normalizeDateField(dateField);
61
+ const scope = buildOrderScopeWhere(orderScope);
62
+
63
+ const params = [...scope.params];
64
+ const clauses = [scope.whereSql];
65
+
66
+ if (dateFrom) {
67
+ params.push(dateFrom);
68
+ clauses.push(`o.${field} >= $${params.length}`);
69
+ }
70
+ if (dateTo) {
71
+ params.push(dateTo);
72
+ clauses.push(`o.${field} <= $${params.length}`);
73
+ }
74
+ if (managerId != null) {
75
+ params.push(Number(managerId));
76
+ clauses.push(`o.manager_id = $${params.length}`);
77
+ }
78
+
79
+ const where = clauses.length > 0 ? clauses.join(' AND ') : '1=1';
80
+ const lim = safeLimit(limit, 500);
81
+ const off = Math.max(0, Math.floor(Number(offset || 0)));
82
+
83
+ const groupExpr = groupBy === 'sku'
84
+ ? `base_sku`
85
+ : `compound_sku`;
86
+
87
+ const orderExpr = metric === 'ordersCount'
88
+ ? 'orders_count'
89
+ : metric === 'revenueApprox'
90
+ ? 'revenue_approx'
91
+ : 'qty';
92
+
93
+ const sql = `
94
+ WITH expanded AS (
95
+ SELECT
96
+ o.id AS order_id,
97
+ o.manager_id,
98
+ o.${field} AS order_date,
99
+ x.sku AS raw_sku,
100
+ COALESCE(x.quantity, 1) AS qty,
101
+ CASE WHEN POSITION('/' IN x.sku) > 0 THEN SUBSTRING(x.sku FROM 1 FOR POSITION('/' IN x.sku) - 1) ELSE x.sku END AS base_sku,
102
+ CASE WHEN POSITION('/' IN x.sku) > 0 THEN SUBSTRING(x.sku FROM POSITION('/' IN x.sku) + 1) ELSE NULL END AS tag,
103
+ x.sku AS compound_sku
104
+ FROM orders o
105
+ LEFT JOIN order_statuses os ON os.id = o.status_id
106
+ JOIN LATERAL jsonb_to_recordset(o.products) AS x(sku text, quantity int) ON TRUE
107
+ WHERE ${where}
108
+ AND x.sku IS NOT NULL AND x.sku != ''
109
+ ),
110
+ agg AS (
111
+ SELECT
112
+ ${groupExpr} AS group_key,
113
+ base_sku,
114
+ tag,
115
+ SUM(qty)::bigint AS qty,
116
+ COUNT(DISTINCT order_id)::bigint AS orders_count
117
+ FROM expanded
118
+ GROUP BY ${groupExpr}, base_sku, tag
119
+ ),
120
+ product_pick AS (
121
+ SELECT
122
+ a.group_key,
123
+ a.base_sku,
124
+ a.tag,
125
+ a.qty,
126
+ a.orders_count,
127
+ p.id AS product_id,
128
+ p.name AS product_name,
129
+ p.price,
130
+ p.discount,
131
+ ROUND((COALESCE(p.price::numeric, 0) * (1 - COALESCE(p.discount::numeric, 0) / 100))::numeric, 2) AS final_price
132
+ FROM agg a
133
+ LEFT JOIN LATERAL (
134
+ SELECT id, name, price, discount
135
+ FROM products
136
+ WHERE sku = a.base_sku
137
+ AND ( (a.tag IS NOT NULL AND tag = a.tag) OR (a.tag IS NULL) )
138
+ ORDER BY
139
+ CASE WHEN a.tag IS NOT NULL AND tag = a.tag THEN 0 ELSE 1 END,
140
+ created_at ASC
141
+ LIMIT 1
142
+ ) p ON TRUE
143
+ )
144
+ SELECT
145
+ group_key,
146
+ base_sku AS sku,
147
+ tag,
148
+ product_name,
149
+ qty,
150
+ orders_count,
151
+ (qty * COALESCE(final_price, 0))::numeric AS revenue_approx
152
+ FROM product_pick
153
+ ORDER BY ${orderExpr} DESC NULLS LAST, qty DESC
154
+ LIMIT ${lim} OFFSET ${off}
155
+ `;
156
+
157
+ const res = await query(sql, params);
158
+ return {
159
+ rows: res.rows || [],
160
+ meta: {
161
+ metric,
162
+ groupBy,
163
+ dateField: field,
164
+ limit: lim,
165
+ offset: off
166
+ }
167
+ };
168
+ }
169
+ },
170
+
171
+ analytics_stockHealth: {
172
+ name: 'analytics.stockHealth',
173
+ description: 'Stock + sales velocity and days-cover metrics per product (optionally per store). Returns product names and signals; interpretation is left to the agent.',
174
+ schema: z.object({
175
+ storeId: z.number().optional(),
176
+ salesWindowDays: z.number().default(30),
177
+ dateTo: z.string().datetime().optional(),
178
+ dateField: DateFieldSchema,
179
+ orderScope: OrderScopeSchema,
180
+ includeZeroSales: z.boolean().default(true),
181
+ minSalesQty: z.number().default(0),
182
+ limit: z.number().optional(),
183
+ offset: z.number().optional()
184
+ }),
185
+ handler: async (input) => {
186
+ const {
187
+ storeId,
188
+ salesWindowDays,
189
+ dateTo,
190
+ dateField,
191
+ orderScope,
192
+ includeZeroSales,
193
+ minSalesQty,
194
+ limit,
195
+ offset
196
+ } = input;
197
+
198
+ const field = normalizeDateField(dateField);
199
+ const to = dateTo ? new Date(dateTo) : new Date();
200
+ const windowDays = Math.max(1, Math.min(3650, Math.floor(Number(salesWindowDays || 30))));
201
+ const from = new Date(to.getTime() - windowDays * 24 * 60 * 60 * 1000);
202
+
203
+ const hasStore = storeId != null;
204
+ const storeParams = hasStore ? [Number(storeId)] : [];
205
+ const scope = buildOrderScopeWhere(orderScope, hasStore ? 2 : 1);
206
+
207
+ const params = [...storeParams, ...scope.params];
208
+ const clauses = [scope.whereSql];
209
+
210
+ params.push(from.toISOString());
211
+ clauses.push(`o.${field} >= $${params.length}`);
212
+ params.push(to.toISOString());
213
+ clauses.push(`o.${field} <= $${params.length}`);
214
+
215
+ const where = clauses.length > 0 ? clauses.join(' AND ') : '1=1';
216
+ const lim = safeLimit(limit, 500);
217
+ const off = Math.max(0, Math.floor(Number(offset || 0)));
218
+
219
+ const sql = `
220
+ WITH stock AS (
221
+ SELECT
222
+ ${storeId != null ? 'si.store_id' : 'NULL::bigint'} AS store_id,
223
+ si.product_sku AS compound_sku,
224
+ SUM(COALESCE(si.quantity, 0))::bigint AS stock_qty
225
+ FROM store_inventory si
226
+ ${storeId != null ? 'WHERE si.store_id = $1' : ''}
227
+ GROUP BY ${storeId != null ? 'si.store_id, si.product_sku' : 'si.product_sku'}
228
+ ),
229
+ sales_expanded AS (
230
+ SELECT
231
+ x.sku AS compound_sku,
232
+ COALESCE(x.quantity, 1) AS qty
233
+ FROM orders o
234
+ LEFT JOIN order_statuses os ON os.id = o.status_id
235
+ JOIN LATERAL jsonb_to_recordset(o.products) AS x(sku text, quantity int) ON TRUE
236
+ WHERE ${where}
237
+ AND x.sku IS NOT NULL AND x.sku != ''
238
+ ),
239
+ sales AS (
240
+ SELECT
241
+ compound_sku,
242
+ SUM(qty)::bigint AS sales_qty_window
243
+ FROM sales_expanded
244
+ GROUP BY compound_sku
245
+ ),
246
+ merged AS (
247
+ SELECT
248
+ s.store_id,
249
+ s.compound_sku,
250
+ s.stock_qty,
251
+ COALESCE(sa.sales_qty_window, 0)::bigint AS sales_qty_window
252
+ FROM stock s
253
+ LEFT JOIN sales sa ON sa.compound_sku = s.compound_sku
254
+ ),
255
+ parsed AS (
256
+ SELECT
257
+ m.*,
258
+ CASE WHEN POSITION('/' IN m.compound_sku) > 0 THEN SUBSTRING(m.compound_sku FROM 1 FOR POSITION('/' IN m.compound_sku) - 1) ELSE m.compound_sku END AS base_sku,
259
+ CASE WHEN POSITION('/' IN m.compound_sku) > 0 THEN SUBSTRING(m.compound_sku FROM POSITION('/' IN m.compound_sku) + 1) ELSE NULL END AS tag
260
+ FROM merged m
261
+ ),
262
+ with_product AS (
263
+ SELECT
264
+ p2.store_id,
265
+ p2.compound_sku,
266
+ p2.base_sku AS sku,
267
+ p2.tag,
268
+ p2.stock_qty,
269
+ p2.sales_qty_window,
270
+ (p2.sales_qty_window::numeric / ${windowDays}::numeric) AS avg_daily_sales,
271
+ CASE WHEN (p2.sales_qty_window::numeric / ${windowDays}::numeric) > 0
272
+ THEN (p2.stock_qty::numeric / (p2.sales_qty_window::numeric / ${windowDays}::numeric))
273
+ ELSE NULL
274
+ END AS days_cover,
275
+ pr.name AS product_name,
276
+ pr.price,
277
+ pr.discount,
278
+ pr.category_id
279
+ FROM parsed p2
280
+ LEFT JOIN LATERAL (
281
+ SELECT name, price, discount, category_id
282
+ FROM products
283
+ WHERE sku = p2.base_sku
284
+ AND ( (p2.tag IS NOT NULL AND tag = p2.tag) OR (p2.tag IS NULL) )
285
+ ORDER BY
286
+ CASE WHEN p2.tag IS NOT NULL AND tag = p2.tag THEN 0 ELSE 1 END,
287
+ created_at ASC
288
+ LIMIT 1
289
+ ) pr ON TRUE
290
+ )
291
+ SELECT *
292
+ FROM with_product
293
+ WHERE (sales_qty_window >= $${params.length + 1})
294
+ ${includeZeroSales ? '' : 'AND sales_qty_window > 0'}
295
+ ORDER BY
296
+ CASE WHEN days_cover IS NULL THEN 1 ELSE 0 END,
297
+ days_cover ASC NULLS LAST,
298
+ stock_qty ASC
299
+ LIMIT ${lim} OFFSET ${off}
300
+ `;
301
+
302
+ const res = await query(sql, [...params, Number(minSalesQty || 0)]);
303
+
304
+ return {
305
+ window: {
306
+ from: from.toISOString(),
307
+ to: to.toISOString(),
308
+ days: windowDays,
309
+ dateField: field
310
+ },
311
+ rows: res.rows || []
312
+ };
313
+ }
314
+ },
315
+
316
+ analytics_salesTimeSeries: {
317
+ name: 'analytics.salesTimeSeries',
318
+ description: 'Time series aggregation of orders and items sold over time buckets (day/week/month) with flexible orderScope.',
319
+ schema: z.object({
320
+ dateFrom: z.string().datetime().optional(),
321
+ dateTo: z.string().datetime().optional(),
322
+ dateField: DateFieldSchema,
323
+ bucket: z.enum(['day', 'week', 'month']).default('day'),
324
+ managerId: z.number().optional(),
325
+ orderScope: OrderScopeSchema
326
+ }),
327
+ handler: async (input) => {
328
+ const { dateFrom, dateTo, dateField, bucket, managerId, orderScope } = input;
329
+
330
+ const field = normalizeDateField(dateField);
331
+ const scope = buildOrderScopeWhere(orderScope);
332
+ const params = [...scope.params];
333
+ const clauses = [scope.whereSql];
334
+
335
+ if (dateFrom) {
336
+ params.push(dateFrom);
337
+ clauses.push(`o.${field} >= $${params.length}`);
338
+ }
339
+ if (dateTo) {
340
+ params.push(dateTo);
341
+ clauses.push(`o.${field} <= $${params.length}`);
342
+ }
343
+ if (managerId != null) {
344
+ params.push(Number(managerId));
345
+ clauses.push(`o.manager_id = $${params.length}`);
346
+ }
347
+
348
+ const where = clauses.join(' AND ');
349
+ const trunc = bucket === 'week' ? 'week' : bucket === 'month' ? 'month' : 'day';
350
+
351
+ const sql = `
352
+ WITH expanded AS (
353
+ SELECT
354
+ o.id AS order_id,
355
+ date_trunc('${trunc}', o.${field}) AS period_start,
356
+ COALESCE(x.quantity, 1) AS qty
357
+ FROM orders o
358
+ LEFT JOIN order_statuses os ON os.id = o.status_id
359
+ JOIN LATERAL jsonb_to_recordset(o.products) AS x(sku text, quantity int) ON TRUE
360
+ WHERE ${where}
361
+ )
362
+ SELECT
363
+ period_start,
364
+ COUNT(DISTINCT order_id)::bigint AS orders_count,
365
+ SUM(qty)::bigint AS items_qty
366
+ FROM expanded
367
+ GROUP BY period_start
368
+ ORDER BY period_start ASC
369
+ `;
370
+
371
+ const res = await query(sql, params);
372
+ return {
373
+ bucket: trunc,
374
+ dateField: field,
375
+ rows: res.rows || []
376
+ };
377
+ }
378
+ }
379
+ };