@humanagencyp/erp-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +51 -0
- package/README.md +148 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +787 -0
- package/package.json +51 -0
- package/src/db.ts +248 -0
- package/src/index.ts +302 -0
- package/src/money.ts +43 -0
- package/src/tools/customers.ts +50 -0
- package/src/tools/items.ts +36 -0
- package/src/tools/orders.ts +123 -0
- package/src/tools/quotes.ts +227 -0
- package/test/erp.test.ts +518 -0
- package/tsconfig.json +15 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
CallToolRequestSchema
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
|
|
11
|
+
// src/db.ts
|
|
12
|
+
import { existsSync, mkdirSync, copyFileSync, statSync } from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
var SCHEMA = `
|
|
16
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
sku TEXT NOT NULL UNIQUE,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
unit TEXT NOT NULL DEFAULT 'pcs',
|
|
21
|
+
list_price REAL NOT NULL,
|
|
22
|
+
currency TEXT NOT NULL DEFAULT 'EUR',
|
|
23
|
+
stock INTEGER NOT NULL DEFAULT 0
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
name TEXT NOT NULL,
|
|
29
|
+
email TEXT,
|
|
30
|
+
country TEXT,
|
|
31
|
+
currency TEXT NOT NULL DEFAULT 'EUR',
|
|
32
|
+
credit_limit REAL NOT NULL DEFAULT 0,
|
|
33
|
+
open_balance REAL NOT NULL DEFAULT 0,
|
|
34
|
+
payment_terms TEXT
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS quotes (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
number TEXT NOT NULL UNIQUE,
|
|
40
|
+
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
41
|
+
status TEXT CHECK(status IN ('draft','sent','converted','cancelled')) NOT NULL DEFAULT 'draft',
|
|
42
|
+
currency TEXT NOT NULL,
|
|
43
|
+
discount_pct REAL NOT NULL DEFAULT 0,
|
|
44
|
+
net_total REAL NOT NULL DEFAULT 0,
|
|
45
|
+
valid_until TEXT,
|
|
46
|
+
notes TEXT,
|
|
47
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
48
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
49
|
+
sent_at TEXT,
|
|
50
|
+
receipt_id TEXT
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS quote_lines (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
|
56
|
+
item_id TEXT NOT NULL REFERENCES items(id),
|
|
57
|
+
qty INTEGER NOT NULL,
|
|
58
|
+
list_price REAL NOT NULL,
|
|
59
|
+
line_total REAL NOT NULL
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
number TEXT NOT NULL UNIQUE,
|
|
65
|
+
quote_id TEXT NOT NULL REFERENCES quotes(id),
|
|
66
|
+
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
67
|
+
status TEXT CHECK(status IN ('confirmed')) NOT NULL DEFAULT 'confirmed',
|
|
68
|
+
net_total REAL NOT NULL,
|
|
69
|
+
currency TEXT NOT NULL,
|
|
70
|
+
requested_delivery TEXT,
|
|
71
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
72
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
73
|
+
receipt_id TEXT
|
|
74
|
+
);
|
|
75
|
+
`;
|
|
76
|
+
var RECEIPT_ID_TABLES = ["quotes", "orders"];
|
|
77
|
+
var SEED_ITEMS = [
|
|
78
|
+
// id, sku, name, unit, list_price, stock
|
|
79
|
+
["item-1", "WIDGET-100", "Widget 100", "pcs", 25, 500],
|
|
80
|
+
["item-2", "WIDGET-200", "Widget 200 Pro", "pcs", 45, 300],
|
|
81
|
+
["item-3", "GEAR-A1", "Precision Gear A1", "pcs", 120, 80],
|
|
82
|
+
["item-4", "GEAR-B2", "Precision Gear B2", "pcs", 150, 60],
|
|
83
|
+
["item-5", "PANEL-S", "Control Panel S", "pcs", 89.5, 150],
|
|
84
|
+
["item-6", "PANEL-L", "Control Panel L", "pcs", 149.5, 90],
|
|
85
|
+
["item-7", "CABLE-5M", "Cable 5m", "pcs", 12, 1e3],
|
|
86
|
+
["item-8", "SERVICE-KIT", "Maintenance Kit", "set", 340, 20]
|
|
87
|
+
];
|
|
88
|
+
var SEED_CUSTOMERS = [
|
|
89
|
+
// id, name, email, country, credit_limit, open_balance, payment_terms
|
|
90
|
+
["cust-1", "Nordkap Manufacturing GmbH", "ap@nordkap.example", "DE", 5e4, 12e3, "NET30"],
|
|
91
|
+
["cust-2", "Alpine Components AG", "finance@alpinecomp.example", "AT", 2e4, 4e3, "NET30"],
|
|
92
|
+
["cust-3", "Baltic Retail Group", "accounts@balticretail.example", "LV", 8e3, 7500, "NET14"],
|
|
93
|
+
["cust-4", "Meridian Industrial Ltd", "payables@meridianind.example", "IE", 1e5, 0, "NET60"],
|
|
94
|
+
["cust-5", "Solstice Equipment Co", "finance@solsticeeq.example", "NL", 5e3, 4800, "NET7"]
|
|
95
|
+
];
|
|
96
|
+
function seedSqlite(db) {
|
|
97
|
+
const { count } = db.prepare("SELECT COUNT(*) as count FROM items").get();
|
|
98
|
+
if (count > 0) return;
|
|
99
|
+
const insertItem = db.prepare(
|
|
100
|
+
`INSERT INTO items (id, sku, name, unit, list_price, currency, stock) VALUES (?, ?, ?, ?, ?, 'EUR', ?)`
|
|
101
|
+
);
|
|
102
|
+
for (const [id, sku, name, unit, listPrice, stock] of SEED_ITEMS) {
|
|
103
|
+
insertItem.run(id, sku, name, unit, listPrice, stock);
|
|
104
|
+
}
|
|
105
|
+
const insertCustomer = db.prepare(
|
|
106
|
+
`INSERT INTO customers (id, name, email, country, currency, credit_limit, open_balance, payment_terms) VALUES (?, ?, ?, ?, 'EUR', ?, ?, ?)`
|
|
107
|
+
);
|
|
108
|
+
for (const [id, name, email, country, creditLimit, openBalance, terms] of SEED_CUSTOMERS) {
|
|
109
|
+
insertCustomer.run(id, name, email, country, creditLimit, openBalance, terms);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function createSqliteDb(dbPath) {
|
|
113
|
+
const { default: Database } = await import("better-sqlite3");
|
|
114
|
+
const db = new Database(dbPath);
|
|
115
|
+
db.pragma("foreign_keys = ON");
|
|
116
|
+
db.exec(SCHEMA);
|
|
117
|
+
seedSqlite(db);
|
|
118
|
+
for (const table of RECEIPT_ID_TABLES) {
|
|
119
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
120
|
+
if (!cols.some((c) => c.name === "receipt_id")) {
|
|
121
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN receipt_id TEXT`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
async run(sql, params = []) {
|
|
126
|
+
db.prepare(sql).run(...params);
|
|
127
|
+
},
|
|
128
|
+
async get(sql, params = []) {
|
|
129
|
+
return db.prepare(sql).get(...params);
|
|
130
|
+
},
|
|
131
|
+
async all(sql, params = []) {
|
|
132
|
+
return db.prepare(sql).all(...params);
|
|
133
|
+
},
|
|
134
|
+
async close() {
|
|
135
|
+
db.close();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function createPostgresDb(connectionString) {
|
|
140
|
+
const { default: pg } = await import("pg");
|
|
141
|
+
const pool = new pg.Pool({ connectionString });
|
|
142
|
+
function adaptSql(sql) {
|
|
143
|
+
let i = 0;
|
|
144
|
+
return sql.replace(/\?/g, () => `$${++i}`);
|
|
145
|
+
}
|
|
146
|
+
const pgSchema = SCHEMA.replace(/datetime\('now'\)/g, "NOW()");
|
|
147
|
+
const client = await pool.connect();
|
|
148
|
+
try {
|
|
149
|
+
await client.query(pgSchema);
|
|
150
|
+
for (const table of RECEIPT_ID_TABLES) {
|
|
151
|
+
await client.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS receipt_id TEXT`);
|
|
152
|
+
}
|
|
153
|
+
const { rows } = await client.query("SELECT COUNT(*)::int as count FROM items");
|
|
154
|
+
if (rows[0].count === 0) {
|
|
155
|
+
for (const [id, sku, name, unit, listPrice, stock] of SEED_ITEMS) {
|
|
156
|
+
await client.query(
|
|
157
|
+
`INSERT INTO items (id, sku, name, unit, list_price, currency, stock) VALUES ($1, $2, $3, $4, $5, 'EUR', $6)`,
|
|
158
|
+
[id, sku, name, unit, listPrice, stock]
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
for (const [id, name, email, country, creditLimit, openBalance, terms] of SEED_CUSTOMERS) {
|
|
162
|
+
await client.query(
|
|
163
|
+
`INSERT INTO customers (id, name, email, country, currency, credit_limit, open_balance, payment_terms) VALUES ($1, $2, $3, $4, 'EUR', $5, $6, $7)`,
|
|
164
|
+
[id, name, email, country, creditLimit, openBalance, terms]
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
client.release();
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
async run(sql, params = []) {
|
|
173
|
+
await pool.query(adaptSql(sql), params);
|
|
174
|
+
},
|
|
175
|
+
async get(sql, params = []) {
|
|
176
|
+
const result = await pool.query(adaptSql(sql), params);
|
|
177
|
+
return result.rows[0];
|
|
178
|
+
},
|
|
179
|
+
async all(sql, params = []) {
|
|
180
|
+
const result = await pool.query(adaptSql(sql), params);
|
|
181
|
+
return result.rows;
|
|
182
|
+
},
|
|
183
|
+
async close() {
|
|
184
|
+
await pool.end();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function maybeBackupSqlite(dbPath) {
|
|
189
|
+
const backupPath = dbPath.replace(/\.db$/, ".backup.db");
|
|
190
|
+
if (!existsSync(dbPath)) return;
|
|
191
|
+
const shouldBackup = !existsSync(backupPath) || Date.now() - statSync(backupPath).mtimeMs > 24 * 60 * 60 * 1e3;
|
|
192
|
+
if (shouldBackup) {
|
|
193
|
+
try {
|
|
194
|
+
copyFileSync(dbPath, backupPath);
|
|
195
|
+
console.error(`[erp-mcp] backup written to ${backupPath}`);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(`[erp-mcp] backup failed: ${err}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function createDb() {
|
|
202
|
+
const databaseUrl = process.env.DATABASE_URL ?? "";
|
|
203
|
+
if (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://")) {
|
|
204
|
+
console.error("[erp-mcp] using Postgres");
|
|
205
|
+
return createPostgresDb(databaseUrl);
|
|
206
|
+
}
|
|
207
|
+
const hapDir = process.env.HAP_DATA_DIR ?? join(homedir(), ".hap");
|
|
208
|
+
if (!existsSync(hapDir)) {
|
|
209
|
+
mkdirSync(hapDir, { recursive: true });
|
|
210
|
+
}
|
|
211
|
+
const dbPath = databaseUrl || join(hapDir, "erp.db");
|
|
212
|
+
maybeBackupSqlite(dbPath);
|
|
213
|
+
console.error(`[erp-mcp] using SQLite at ${dbPath}`);
|
|
214
|
+
return createSqliteDb(dbPath);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/tools/items.ts
|
|
218
|
+
async function list_items(db, args) {
|
|
219
|
+
const { query, limit = 50 } = args;
|
|
220
|
+
const conditions = [];
|
|
221
|
+
const params = [];
|
|
222
|
+
if (query) {
|
|
223
|
+
conditions.push("(sku LIKE ? OR name LIKE ?)");
|
|
224
|
+
const like = `%${query}%`;
|
|
225
|
+
params.push(like, like);
|
|
226
|
+
}
|
|
227
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
228
|
+
params.push(limit);
|
|
229
|
+
return db.all(`SELECT * FROM items ${where} ORDER BY sku LIMIT ?`, params);
|
|
230
|
+
}
|
|
231
|
+
async function get_item(db, args) {
|
|
232
|
+
const { id } = args;
|
|
233
|
+
const row = await db.get("SELECT * FROM items WHERE id = ?", [id]);
|
|
234
|
+
if (!row) throw new Error(`Unknown item id: ${id}`);
|
|
235
|
+
return row;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/money.ts
|
|
239
|
+
var VALUE_TOLERANCE = 0.01;
|
|
240
|
+
function roundToCents(n) {
|
|
241
|
+
return Math.round(n * 100) / 100;
|
|
242
|
+
}
|
|
243
|
+
function computeNetTotal(lines, discountPct) {
|
|
244
|
+
const gross = lines.reduce((sum, l) => sum + l.qty * l.list_price, 0);
|
|
245
|
+
return roundToCents(gross * (1 - discountPct / 100));
|
|
246
|
+
}
|
|
247
|
+
function valuesMatch(a, b) {
|
|
248
|
+
return Math.abs(a - b) <= VALUE_TOLERANCE;
|
|
249
|
+
}
|
|
250
|
+
var RefusalError = class extends Error {
|
|
251
|
+
};
|
|
252
|
+
function refuse(field, declared, expected, extra) {
|
|
253
|
+
const suffix = extra ? ` ${extra}` : "";
|
|
254
|
+
throw new RefusalError(
|
|
255
|
+
`Refused: ${field} mismatch \u2014 declared ${JSON.stringify(declared)}, expected ${JSON.stringify(expected)}.${suffix}`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/tools/customers.ts
|
|
260
|
+
function withAvailableCredit(row) {
|
|
261
|
+
return { ...row, available_credit: roundToCents(row.credit_limit - row.open_balance) };
|
|
262
|
+
}
|
|
263
|
+
async function find_customers(db, args) {
|
|
264
|
+
const { query, limit = 50 } = args;
|
|
265
|
+
const conditions = [];
|
|
266
|
+
const params = [];
|
|
267
|
+
if (query) {
|
|
268
|
+
conditions.push("(name LIKE ? OR email LIKE ?)");
|
|
269
|
+
const like = `%${query}%`;
|
|
270
|
+
params.push(like, like);
|
|
271
|
+
}
|
|
272
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
273
|
+
params.push(limit);
|
|
274
|
+
const rows = await db.all(`SELECT * FROM customers ${where} ORDER BY name LIMIT ?`, params);
|
|
275
|
+
return rows.map(withAvailableCredit);
|
|
276
|
+
}
|
|
277
|
+
async function get_customer(db, args) {
|
|
278
|
+
const { id } = args;
|
|
279
|
+
const row = await db.get("SELECT * FROM customers WHERE id = ?", [id]);
|
|
280
|
+
if (!row) throw new Error(`Unknown customer id: ${id}`);
|
|
281
|
+
return withAvailableCredit(row);
|
|
282
|
+
}
|
|
283
|
+
async function requireCustomer(db, id) {
|
|
284
|
+
const row = await db.get("SELECT * FROM customers WHERE id = ?", [id]);
|
|
285
|
+
if (!row) throw new Error(`Unknown customer id: ${id}`);
|
|
286
|
+
return row;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/tools/quotes.ts
|
|
290
|
+
import { v4 as uuidv4 } from "uuid";
|
|
291
|
+
async function priceLines(db, lines) {
|
|
292
|
+
if (!Array.isArray(lines) || lines.length === 0) {
|
|
293
|
+
throw new Error("lines must be a non-empty array of { item_id, qty }");
|
|
294
|
+
}
|
|
295
|
+
const priced = [];
|
|
296
|
+
for (const line of lines) {
|
|
297
|
+
const { item_id, qty } = line ?? {};
|
|
298
|
+
if (!item_id) {
|
|
299
|
+
throw new Error("Every line requires an item_id");
|
|
300
|
+
}
|
|
301
|
+
if (!Number.isInteger(qty) || qty <= 0) {
|
|
302
|
+
throw new Error(`Invalid qty for item ${item_id}: ${JSON.stringify(qty)} (must be a positive integer)`);
|
|
303
|
+
}
|
|
304
|
+
const item = await db.get("SELECT * FROM items WHERE id = ?", [item_id]);
|
|
305
|
+
if (!item) {
|
|
306
|
+
throw new Error(`Unknown item id: ${item_id}`);
|
|
307
|
+
}
|
|
308
|
+
priced.push({
|
|
309
|
+
item_id,
|
|
310
|
+
qty,
|
|
311
|
+
list_price: item.list_price,
|
|
312
|
+
line_total: Math.round(item.list_price * qty * 100) / 100
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return priced;
|
|
316
|
+
}
|
|
317
|
+
function validateDiscountPct(discount_pct) {
|
|
318
|
+
if (typeof discount_pct !== "number" || !Number.isFinite(discount_pct) || discount_pct < 0 || discount_pct > 100) {
|
|
319
|
+
refuse("discount_pct", discount_pct, "a number between 0 and 100");
|
|
320
|
+
}
|
|
321
|
+
return discount_pct;
|
|
322
|
+
}
|
|
323
|
+
async function nextNumber(db, table, prefix) {
|
|
324
|
+
const { count } = await db.get(`SELECT COUNT(*) as count FROM ${table}`) ?? { count: 0 };
|
|
325
|
+
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
|
|
326
|
+
}
|
|
327
|
+
async function requireQuote(db, id) {
|
|
328
|
+
const row = await db.get("SELECT * FROM quotes WHERE id = ?", [id]);
|
|
329
|
+
if (!row) throw new Error(`Unknown quote id: ${id}`);
|
|
330
|
+
return row;
|
|
331
|
+
}
|
|
332
|
+
async function loadQuoteLines(db, quoteId) {
|
|
333
|
+
return db.all("SELECT * FROM quote_lines WHERE quote_id = ? ORDER BY rowid", [quoteId]);
|
|
334
|
+
}
|
|
335
|
+
async function quoteWithLines(db, id) {
|
|
336
|
+
const quote = await requireQuote(db, id);
|
|
337
|
+
const lines = await loadQuoteLines(db, id);
|
|
338
|
+
return { ...quote, lines };
|
|
339
|
+
}
|
|
340
|
+
async function create_quote(db, args) {
|
|
341
|
+
const { customer_id, lines, discount_pct, value, currency, valid_until, notes, receipt_id } = args;
|
|
342
|
+
const customer = await requireCustomer(db, customer_id);
|
|
343
|
+
const priced = await priceLines(db, lines);
|
|
344
|
+
const discountPct = validateDiscountPct(discount_pct);
|
|
345
|
+
if (currency !== customer.currency) {
|
|
346
|
+
refuse("currency", currency, customer.currency, `Customer ${customer.name} (${customer.id}) is billed in ${customer.currency}.`);
|
|
347
|
+
}
|
|
348
|
+
const netTotal = computeNetTotal(priced, discountPct);
|
|
349
|
+
if (!valuesMatch(value, netTotal)) {
|
|
350
|
+
refuse("value", value, netTotal, "Recomputed from the quote lines and the declared discount.");
|
|
351
|
+
}
|
|
352
|
+
const id = uuidv4();
|
|
353
|
+
const number = await nextNumber(db, "quotes", "Q");
|
|
354
|
+
await db.run(
|
|
355
|
+
`INSERT INTO quotes (id, number, customer_id, status, currency, discount_pct, net_total, valid_until, notes, receipt_id)
|
|
356
|
+
VALUES (?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?)`,
|
|
357
|
+
[id, number, customer_id, currency, discountPct, netTotal, valid_until ?? null, notes ?? null, receipt_id ?? null]
|
|
358
|
+
);
|
|
359
|
+
for (const line of priced) {
|
|
360
|
+
await db.run(
|
|
361
|
+
`INSERT INTO quote_lines (id, quote_id, item_id, qty, list_price, line_total) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
362
|
+
[uuidv4(), id, line.item_id, line.qty, line.list_price, line.line_total]
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return quoteWithLines(db, id);
|
|
366
|
+
}
|
|
367
|
+
async function update_quote(db, args) {
|
|
368
|
+
const { id, lines, discount_pct, value, currency, valid_until, notes, receipt_id } = args;
|
|
369
|
+
const quote = await requireQuote(db, id);
|
|
370
|
+
if (quote.status !== "draft") {
|
|
371
|
+
throw new Error(`Quote ${quote.number} (${id}) is ${quote.status}; only draft quotes can be updated`);
|
|
372
|
+
}
|
|
373
|
+
const priced = lines !== void 0 ? await priceLines(db, lines) : await loadQuoteLines(db, id);
|
|
374
|
+
const discountPct = validateDiscountPct(discount_pct);
|
|
375
|
+
if (currency !== quote.currency) {
|
|
376
|
+
refuse("currency", currency, quote.currency, `Quote ${quote.number} was created in ${quote.currency}; currency cannot change on update.`);
|
|
377
|
+
}
|
|
378
|
+
const netTotal = computeNetTotal(priced, discountPct);
|
|
379
|
+
if (!valuesMatch(value, netTotal)) {
|
|
380
|
+
refuse("value", value, netTotal, "Recomputed from the quote lines and the declared discount.");
|
|
381
|
+
}
|
|
382
|
+
if (lines !== void 0) {
|
|
383
|
+
await db.run("DELETE FROM quote_lines WHERE quote_id = ?", [id]);
|
|
384
|
+
for (const line of priced) {
|
|
385
|
+
await db.run(
|
|
386
|
+
`INSERT INTO quote_lines (id, quote_id, item_id, qty, list_price, line_total) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
387
|
+
[uuidv4(), id, line.item_id, line.qty, line.list_price, line.line_total]
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
await db.run(
|
|
392
|
+
`UPDATE quotes SET discount_pct = ?, net_total = ?, valid_until = ?, notes = ?, receipt_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
393
|
+
[discountPct, netTotal, valid_until ?? quote.valid_until ?? null, notes ?? quote.notes ?? null, receipt_id ?? quote.receipt_id ?? null, id]
|
|
394
|
+
);
|
|
395
|
+
return quoteWithLines(db, id);
|
|
396
|
+
}
|
|
397
|
+
async function send_quote(db, args) {
|
|
398
|
+
const { id, value, discount_pct, currency, receipt_id } = args;
|
|
399
|
+
const quote = await requireQuote(db, id);
|
|
400
|
+
if (quote.status !== "draft") {
|
|
401
|
+
throw new Error(`Quote ${quote.number} (${id}) is ${quote.status}; only draft quotes can be sent`);
|
|
402
|
+
}
|
|
403
|
+
if (currency !== quote.currency) {
|
|
404
|
+
refuse("currency", currency, quote.currency, `Quote ${quote.number} is in ${quote.currency}.`);
|
|
405
|
+
}
|
|
406
|
+
if (discount_pct !== quote.discount_pct) {
|
|
407
|
+
refuse("discount_pct", discount_pct, quote.discount_pct, `Quote ${quote.number}'s discount cannot change at send time \u2014 edit the draft first.`);
|
|
408
|
+
}
|
|
409
|
+
const lines = await loadQuoteLines(db, id);
|
|
410
|
+
const netTotal = computeNetTotal(lines, quote.discount_pct);
|
|
411
|
+
if (!valuesMatch(value, netTotal)) {
|
|
412
|
+
refuse("value", value, netTotal, "Recomputed from the quote's lines and stored discount.");
|
|
413
|
+
}
|
|
414
|
+
await db.run(
|
|
415
|
+
`UPDATE quotes SET status = 'sent', sent_at = datetime('now'), receipt_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
416
|
+
[receipt_id ?? quote.receipt_id ?? null, id]
|
|
417
|
+
);
|
|
418
|
+
return quoteWithLines(db, id);
|
|
419
|
+
}
|
|
420
|
+
async function list_quotes(db, args) {
|
|
421
|
+
const { status, customer_id, limit = 50 } = args;
|
|
422
|
+
const conditions = [];
|
|
423
|
+
const params = [];
|
|
424
|
+
if (status) {
|
|
425
|
+
conditions.push("status = ?");
|
|
426
|
+
params.push(status);
|
|
427
|
+
}
|
|
428
|
+
if (customer_id) {
|
|
429
|
+
conditions.push("customer_id = ?");
|
|
430
|
+
params.push(customer_id);
|
|
431
|
+
}
|
|
432
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
433
|
+
params.push(limit);
|
|
434
|
+
return db.all(`SELECT * FROM quotes ${where} ORDER BY created_at DESC LIMIT ?`, params);
|
|
435
|
+
}
|
|
436
|
+
async function get_quote(db, args) {
|
|
437
|
+
const { id } = args;
|
|
438
|
+
return quoteWithLines(db, id);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/tools/orders.ts
|
|
442
|
+
import { v4 as uuidv42 } from "uuid";
|
|
443
|
+
async function nextOrderNumber(db) {
|
|
444
|
+
const { count } = await db.get("SELECT COUNT(*) as count FROM orders") ?? { count: 0 };
|
|
445
|
+
return `O-${String(count + 1).padStart(4, "0")}`;
|
|
446
|
+
}
|
|
447
|
+
async function orderWithLines(db, id) {
|
|
448
|
+
const order = await db.get("SELECT * FROM orders WHERE id = ?", [id]);
|
|
449
|
+
if (!order) throw new Error(`Unknown order id: ${id}`);
|
|
450
|
+
const lines = await loadQuoteLines(db, order.quote_id);
|
|
451
|
+
return { ...order, lines };
|
|
452
|
+
}
|
|
453
|
+
async function convert_quote_to_order(db, args) {
|
|
454
|
+
const { id, value, discount_pct, currency, requested_delivery, receipt_id } = args;
|
|
455
|
+
const quote = await requireQuote(db, id);
|
|
456
|
+
if (quote.status !== "sent") {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Quote ${quote.number} (${id}) is ${quote.status}; only sent quotes can be converted to an order` + (quote.status === "converted" ? " (it already was)" : "")
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (currency !== quote.currency) {
|
|
462
|
+
refuse("currency", currency, quote.currency, `Quote ${quote.number} is in ${quote.currency}.`);
|
|
463
|
+
}
|
|
464
|
+
if (discount_pct !== quote.discount_pct) {
|
|
465
|
+
refuse("discount_pct", discount_pct, quote.discount_pct, `Quote ${quote.number}'s discount cannot change at conversion time.`);
|
|
466
|
+
}
|
|
467
|
+
const lines = await loadQuoteLines(db, id);
|
|
468
|
+
const netTotal = computeNetTotal(lines, quote.discount_pct);
|
|
469
|
+
if (!valuesMatch(value, netTotal)) {
|
|
470
|
+
refuse("value", value, netTotal, "Recomputed from the quote's lines and stored discount.");
|
|
471
|
+
}
|
|
472
|
+
const customer = await requireCustomer(db, quote.customer_id);
|
|
473
|
+
const projectedBalance = roundToCents(customer.open_balance + netTotal);
|
|
474
|
+
if (projectedBalance > customer.credit_limit) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Refused: credit limit exceeded for ${customer.name} (${customer.id}) \u2014 open balance ${customer.open_balance} + order value ${netTotal} = ${projectedBalance}, credit limit is ${customer.credit_limit}.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const items = /* @__PURE__ */ new Map();
|
|
480
|
+
for (const line of lines) {
|
|
481
|
+
if (!items.has(line.item_id)) {
|
|
482
|
+
const item2 = await db.get("SELECT * FROM items WHERE id = ?", [line.item_id]);
|
|
483
|
+
if (!item2) throw new Error(`Unknown item id: ${line.item_id}`);
|
|
484
|
+
items.set(line.item_id, item2);
|
|
485
|
+
}
|
|
486
|
+
const item = items.get(line.item_id);
|
|
487
|
+
if (item.stock < line.qty) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`Refused: insufficient stock for ${item.sku} (${item.name}) \u2014 requested ${line.qty}, available ${item.stock}.`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const line of lines) {
|
|
494
|
+
await db.run("UPDATE items SET stock = stock - ? WHERE id = ?", [line.qty, line.item_id]);
|
|
495
|
+
}
|
|
496
|
+
const orderId = uuidv42();
|
|
497
|
+
const number = await nextOrderNumber(db);
|
|
498
|
+
await db.run(
|
|
499
|
+
`INSERT INTO orders (id, number, quote_id, customer_id, status, net_total, currency, requested_delivery, receipt_id)
|
|
500
|
+
VALUES (?, ?, ?, ?, 'confirmed', ?, ?, ?, ?)`,
|
|
501
|
+
[orderId, number, id, quote.customer_id, netTotal, currency, requested_delivery ?? null, receipt_id ?? null]
|
|
502
|
+
);
|
|
503
|
+
await db.run(`UPDATE quotes SET status = 'converted', updated_at = datetime('now') WHERE id = ?`, [id]);
|
|
504
|
+
return orderWithLines(db, orderId);
|
|
505
|
+
}
|
|
506
|
+
async function list_orders(db, args) {
|
|
507
|
+
const { customer_id, limit = 50 } = args;
|
|
508
|
+
const conditions = [];
|
|
509
|
+
const params = [];
|
|
510
|
+
if (customer_id) {
|
|
511
|
+
conditions.push("customer_id = ?");
|
|
512
|
+
params.push(customer_id);
|
|
513
|
+
}
|
|
514
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
515
|
+
params.push(limit);
|
|
516
|
+
return db.all(`SELECT * FROM orders ${where} ORDER BY created_at DESC LIMIT ?`, params);
|
|
517
|
+
}
|
|
518
|
+
async function get_order(db, args) {
|
|
519
|
+
const { id } = args;
|
|
520
|
+
return orderWithLines(db, id);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/index.ts
|
|
524
|
+
var RECEIPT_FIELD = {
|
|
525
|
+
type: "string",
|
|
526
|
+
description: "Suveren authorizing receipt id. Injected by the gateway \u2014 agents do not set this."
|
|
527
|
+
};
|
|
528
|
+
var LINE_SCHEMA = {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: {
|
|
531
|
+
item_id: { type: "string", description: "Item ID" },
|
|
532
|
+
qty: { type: "number", description: "Quantity (positive integer)" }
|
|
533
|
+
},
|
|
534
|
+
required: ["item_id", "qty"]
|
|
535
|
+
};
|
|
536
|
+
var TOOL_DEFINITIONS = [
|
|
537
|
+
// --- Reads ---
|
|
538
|
+
{
|
|
539
|
+
name: "list_items",
|
|
540
|
+
description: "List/search catalog items (sku, name, unit, list price, stock)",
|
|
541
|
+
inputSchema: {
|
|
542
|
+
type: "object",
|
|
543
|
+
properties: {
|
|
544
|
+
query: { type: "string", description: "Text search across sku and name" },
|
|
545
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" }
|
|
546
|
+
},
|
|
547
|
+
required: []
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "get_item",
|
|
552
|
+
description: "Get a single item, including stock and list price",
|
|
553
|
+
inputSchema: {
|
|
554
|
+
type: "object",
|
|
555
|
+
properties: {
|
|
556
|
+
id: { type: "string", description: "Item ID" }
|
|
557
|
+
},
|
|
558
|
+
required: ["id"]
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "find_customers",
|
|
563
|
+
description: "Search customers by name or email",
|
|
564
|
+
inputSchema: {
|
|
565
|
+
type: "object",
|
|
566
|
+
properties: {
|
|
567
|
+
query: { type: "string", description: "Text search across name and email" },
|
|
568
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" }
|
|
569
|
+
},
|
|
570
|
+
required: []
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: "get_customer",
|
|
575
|
+
description: "Get a single customer, including credit limit, open balance, and available credit",
|
|
576
|
+
inputSchema: {
|
|
577
|
+
type: "object",
|
|
578
|
+
properties: {
|
|
579
|
+
id: { type: "string", description: "Customer ID" }
|
|
580
|
+
},
|
|
581
|
+
required: ["id"]
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
name: "list_quotes",
|
|
586
|
+
description: "List quotes, optionally filtered by status or customer",
|
|
587
|
+
inputSchema: {
|
|
588
|
+
type: "object",
|
|
589
|
+
properties: {
|
|
590
|
+
status: { type: "string", enum: ["draft", "sent", "converted", "cancelled"], description: "Filter by status" },
|
|
591
|
+
customer_id: { type: "string", description: "Filter by customer ID" },
|
|
592
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" }
|
|
593
|
+
},
|
|
594
|
+
required: []
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "get_quote",
|
|
599
|
+
description: "Get a single quote with its lines",
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: "object",
|
|
602
|
+
properties: {
|
|
603
|
+
id: { type: "string", description: "Quote ID" }
|
|
604
|
+
},
|
|
605
|
+
required: ["id"]
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: "list_orders",
|
|
610
|
+
description: "List orders, optionally filtered by customer",
|
|
611
|
+
inputSchema: {
|
|
612
|
+
type: "object",
|
|
613
|
+
properties: {
|
|
614
|
+
customer_id: { type: "string", description: "Filter by customer ID" },
|
|
615
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" }
|
|
616
|
+
},
|
|
617
|
+
required: []
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: "get_order",
|
|
622
|
+
description: "Get a single order with its lines",
|
|
623
|
+
inputSchema: {
|
|
624
|
+
type: "object",
|
|
625
|
+
properties: {
|
|
626
|
+
id: { type: "string", description: "Order ID" }
|
|
627
|
+
},
|
|
628
|
+
required: ["id"]
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
// --- Changes ---
|
|
632
|
+
{
|
|
633
|
+
name: "create_quote",
|
|
634
|
+
description: "Create a draft quote for a customer. The connector recomputes the net total from the lines and the discount and refuses the call if the declared value, discount, or currency does not match.",
|
|
635
|
+
inputSchema: {
|
|
636
|
+
type: "object",
|
|
637
|
+
properties: {
|
|
638
|
+
customer_id: { type: "string", description: "Customer ID" },
|
|
639
|
+
lines: { type: "array", items: LINE_SCHEMA, description: "Quote lines" },
|
|
640
|
+
discount_pct: { type: "number", description: "Document discount against list price, in percent (0-100)" },
|
|
641
|
+
value: { type: "number", description: "Net document total \u2014 must equal sum(qty*list_price)*(1-discount_pct/100)" },
|
|
642
|
+
currency: { type: "string", description: "Document currency \u2014 must match the customer's currency" },
|
|
643
|
+
valid_until: { type: "string", description: "ISO 8601 date the quote is valid until (optional)" },
|
|
644
|
+
notes: { type: "string", description: "Free-form notes (optional)" },
|
|
645
|
+
receipt_id: RECEIPT_FIELD
|
|
646
|
+
},
|
|
647
|
+
required: ["customer_id", "lines", "discount_pct", "value", "currency"]
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "update_quote",
|
|
652
|
+
description: "Update a draft quote (lines, discount, valid_until, notes). Only draft quotes can be updated. The connector recomputes the net total and refuses the call if the declared value, discount, or currency does not match.",
|
|
653
|
+
inputSchema: {
|
|
654
|
+
type: "object",
|
|
655
|
+
properties: {
|
|
656
|
+
id: { type: "string", description: "Quote ID" },
|
|
657
|
+
lines: { type: "array", items: LINE_SCHEMA, description: "Replacement quote lines (optional \u2014 omit to keep existing lines)" },
|
|
658
|
+
discount_pct: { type: "number", description: "Document discount against list price, in percent (0-100)" },
|
|
659
|
+
value: { type: "number", description: "Net document total \u2014 must equal sum(qty*list_price)*(1-discount_pct/100)" },
|
|
660
|
+
currency: { type: "string", description: "Document currency \u2014 must match the quote's currency" },
|
|
661
|
+
valid_until: { type: "string", description: "ISO 8601 date the quote is valid until (optional)" },
|
|
662
|
+
notes: { type: "string", description: "Free-form notes (optional)" },
|
|
663
|
+
receipt_id: RECEIPT_FIELD
|
|
664
|
+
},
|
|
665
|
+
required: ["id", "discount_pct", "value", "currency"]
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: "send_quote",
|
|
670
|
+
description: "Send a draft quote to the customer (draft -> sent). Simulated: marks the quote sent and records sent_at. The declared value, discount, and currency must match the quote as stored.",
|
|
671
|
+
inputSchema: {
|
|
672
|
+
type: "object",
|
|
673
|
+
properties: {
|
|
674
|
+
id: { type: "string", description: "Quote ID" },
|
|
675
|
+
value: { type: "number", description: "Net document total \u2014 must match the quote's stored net total" },
|
|
676
|
+
discount_pct: { type: "number", description: "Must match the quote's stored discount \u2014 cannot change at send time" },
|
|
677
|
+
currency: { type: "string", description: "Must match the quote's stored currency" },
|
|
678
|
+
receipt_id: RECEIPT_FIELD
|
|
679
|
+
},
|
|
680
|
+
required: ["id", "value", "discount_pct", "currency"]
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: "convert_quote_to_order",
|
|
685
|
+
description: "Convert a sent quote into a confirmed order (sent -> order). Reserves stock for every line and refuses if the customer's credit limit would be exceeded or any line lacks sufficient stock.",
|
|
686
|
+
inputSchema: {
|
|
687
|
+
type: "object",
|
|
688
|
+
properties: {
|
|
689
|
+
id: { type: "string", description: "Quote ID" },
|
|
690
|
+
value: { type: "number", description: "Net document total \u2014 must match the quote's stored net total" },
|
|
691
|
+
discount_pct: { type: "number", description: "Must match the quote's stored discount \u2014 cannot change at conversion time" },
|
|
692
|
+
currency: { type: "string", description: "Must match the quote's stored currency" },
|
|
693
|
+
requested_delivery: { type: "string", description: "Requested delivery date (ISO 8601, optional)" },
|
|
694
|
+
receipt_id: RECEIPT_FIELD
|
|
695
|
+
},
|
|
696
|
+
required: ["id", "value", "discount_pct", "currency"]
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
];
|
|
700
|
+
async function main() {
|
|
701
|
+
const db = await createDb();
|
|
702
|
+
const server = new Server(
|
|
703
|
+
{ name: "erp", version: "0.1.0" },
|
|
704
|
+
{ capabilities: { tools: {} } }
|
|
705
|
+
);
|
|
706
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
707
|
+
return { tools: TOOL_DEFINITIONS };
|
|
708
|
+
});
|
|
709
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
710
|
+
const { name, arguments: args } = request.params;
|
|
711
|
+
const safeArgs = args ?? {};
|
|
712
|
+
try {
|
|
713
|
+
let result;
|
|
714
|
+
switch (name) {
|
|
715
|
+
case "list_items":
|
|
716
|
+
result = await list_items(db, safeArgs);
|
|
717
|
+
break;
|
|
718
|
+
case "get_item":
|
|
719
|
+
result = await get_item(db, safeArgs);
|
|
720
|
+
break;
|
|
721
|
+
case "find_customers":
|
|
722
|
+
result = await find_customers(db, safeArgs);
|
|
723
|
+
break;
|
|
724
|
+
case "get_customer":
|
|
725
|
+
result = await get_customer(db, safeArgs);
|
|
726
|
+
break;
|
|
727
|
+
case "list_quotes":
|
|
728
|
+
result = await list_quotes(db, safeArgs);
|
|
729
|
+
break;
|
|
730
|
+
case "get_quote":
|
|
731
|
+
result = await get_quote(db, safeArgs);
|
|
732
|
+
break;
|
|
733
|
+
case "list_orders":
|
|
734
|
+
result = await list_orders(db, safeArgs);
|
|
735
|
+
break;
|
|
736
|
+
case "get_order":
|
|
737
|
+
result = await get_order(db, safeArgs);
|
|
738
|
+
break;
|
|
739
|
+
case "create_quote":
|
|
740
|
+
result = await create_quote(db, safeArgs);
|
|
741
|
+
break;
|
|
742
|
+
case "update_quote":
|
|
743
|
+
result = await update_quote(db, safeArgs);
|
|
744
|
+
break;
|
|
745
|
+
case "send_quote":
|
|
746
|
+
result = await send_quote(db, safeArgs);
|
|
747
|
+
break;
|
|
748
|
+
case "convert_quote_to_order":
|
|
749
|
+
result = await convert_quote_to_order(db, safeArgs);
|
|
750
|
+
break;
|
|
751
|
+
default:
|
|
752
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
content: [
|
|
756
|
+
{
|
|
757
|
+
type: "text",
|
|
758
|
+
text: JSON.stringify(result, null, 2)
|
|
759
|
+
}
|
|
760
|
+
]
|
|
761
|
+
};
|
|
762
|
+
} catch (err) {
|
|
763
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
764
|
+
console.error(`[erp-mcp] tool error (${name}):`, message);
|
|
765
|
+
return {
|
|
766
|
+
content: [
|
|
767
|
+
{
|
|
768
|
+
type: "text",
|
|
769
|
+
text: JSON.stringify({ error: message }, null, 2)
|
|
770
|
+
}
|
|
771
|
+
],
|
|
772
|
+
isError: true
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
process.on("SIGINT", async () => {
|
|
777
|
+
await db.close();
|
|
778
|
+
process.exit(0);
|
|
779
|
+
});
|
|
780
|
+
const transport = new StdioServerTransport();
|
|
781
|
+
await server.connect(transport);
|
|
782
|
+
console.error("[erp-mcp] server started");
|
|
783
|
+
}
|
|
784
|
+
main().catch((err) => {
|
|
785
|
+
console.error("[erp-mcp] fatal:", err);
|
|
786
|
+
process.exit(1);
|
|
787
|
+
});
|