@humanagencyp/erp-mcp 0.1.0 → 0.2.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 +50 -2
- package/dist/index.js +352 -96
- package/examples/company.example.json +13 -0
- package/examples/scenario.example.json +8 -0
- package/package.json +1 -1
- package/src/cli.ts +99 -0
- package/src/company.ts +115 -0
- package/src/db.ts +104 -59
- package/src/dispatch.ts +66 -0
- package/src/index.ts +12 -49
- package/src/mode.ts +26 -0
- package/test/simulation.test.ts +215 -0
package/README.md
CHANGED
|
@@ -123,8 +123,56 @@ the declared value, and the computed/expected value.
|
|
|
123
123
|
**Postgres** — set `DATABASE_URL` to a connection string. For teams where
|
|
124
124
|
multiple gateways need shared access.
|
|
125
125
|
|
|
126
|
-
Schema is created automatically on first start
|
|
127
|
-
|
|
126
|
+
Schema is created automatically on first start. An empty database is seeded
|
|
127
|
+
from the company file (`ERP_COMPANY_FILE`, see below) or, without one, from a
|
|
128
|
+
deterministic demo dataset.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Simulation mode
|
|
133
|
+
|
|
134
|
+
The connector has a switch, `ERP_MODE`:
|
|
135
|
+
|
|
136
|
+
| Mode | What answers | Status |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `simulation` (default) | the built-in simulated ERP (the database above) | available |
|
|
139
|
+
| `live` | the company's real ERP | no adapter in 0.x — **every call is refused** with a clear message, nothing is read or changed |
|
|
140
|
+
|
|
141
|
+
The point: a three-week test runs on exactly this connector, its tools and its
|
|
142
|
+
HAP profile. Only the system behind it is simulated, so the tickets issued during
|
|
143
|
+
the test are the same tickets that will be issued live. Going live = switch the
|
|
144
|
+
mode and connect the real system; mandates and agent setup stay as they are.
|
|
145
|
+
|
|
146
|
+
**Company file.** `ERP_COMPANY_FILE=/path/to/company.json` seeds an empty
|
|
147
|
+
database with the company's items, prices, stock and customers (invented but
|
|
148
|
+
realistic test data). The file is validated strictly and refused whole on the
|
|
149
|
+
first problem. Example: [`examples/company.example.json`](examples/company.example.json).
|
|
150
|
+
|
|
151
|
+
**Changes.** Every change the connector performs is recorded as its own entry —
|
|
152
|
+
time, tool, document, status, amount, and the `receipt_id` the gateway injected.
|
|
153
|
+
A document only keeps its latest `receipt_id` (sending a quote replaces the one
|
|
154
|
+
from creating it), so this record, not the document, lines up ticket and effect
|
|
155
|
+
one to one.
|
|
156
|
+
|
|
157
|
+
**Refusals after the gateway.** When the connector refuses a change call the
|
|
158
|
+
gateway already let through (false declared value, credit limit, stock, wrong
|
|
159
|
+
state), it records the refusal with the `receipt_id` the gateway injected. A
|
|
160
|
+
ticket then exists for an action that never happened, and this record is the
|
|
161
|
+
only place that says so.
|
|
162
|
+
|
|
163
|
+
**Local commands** (not MCP tools — the agent can neither read nor change them).
|
|
164
|
+
Point them at the same database the gateway uses — for a gateway install that is
|
|
165
|
+
`HAP_DATA_DIR=~/.suveren`:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
HAP_DATA_DIR=~/.suveren erp-mcp scenario next examples/scenario.example.json # hand over the next request, record the time
|
|
169
|
+
HAP_DATA_DIR=~/.suveren erp-mcp scenario status examples/scenario.example.json
|
|
170
|
+
HAP_DATA_DIR=~/.suveren erp-mcp export > record.json # triggers, changes, refusals, quotes + lines, orders, mode
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The export lines up **trigger → ticket → effect**: each request's hand-over time,
|
|
174
|
+
each change with the `receipt_id` of the ticket that authorised it, and each
|
|
175
|
+
refusal with the `receipt_id` of the ticket whose action did not happen.
|
|
128
176
|
|
|
129
177
|
---
|
|
130
178
|
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,81 @@ import {
|
|
|
12
12
|
import { existsSync, mkdirSync, copyFileSync, statSync } from "fs";
|
|
13
13
|
import { homedir } from "os";
|
|
14
14
|
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
// src/company.ts
|
|
17
|
+
import { readFileSync } from "fs";
|
|
18
|
+
function fail(path, msg) {
|
|
19
|
+
throw new Error(`Company file ${path}: ${msg}`);
|
|
20
|
+
}
|
|
21
|
+
function str(v) {
|
|
22
|
+
return typeof v === "string" && v.trim() !== "";
|
|
23
|
+
}
|
|
24
|
+
function num(v) {
|
|
25
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
26
|
+
}
|
|
27
|
+
function parseCompany(raw, path = "(inline)") {
|
|
28
|
+
if (!raw || typeof raw !== "object") fail(path, "must be a JSON object");
|
|
29
|
+
const c = raw;
|
|
30
|
+
if (!str(c.name)) fail(path, "`name` is required");
|
|
31
|
+
if (!str(c.currency) || !/^[A-Z]{3}$/.test(c.currency)) fail(path, "`currency` must be a 3-letter ISO code, e.g. EUR");
|
|
32
|
+
if (!Array.isArray(c.items) || c.items.length === 0) fail(path, "`items` must be a non-empty list");
|
|
33
|
+
if (!Array.isArray(c.customers) || c.customers.length === 0) fail(path, "`customers` must be a non-empty list");
|
|
34
|
+
const skus = /* @__PURE__ */ new Set();
|
|
35
|
+
const items = c.items.map((it, i) => {
|
|
36
|
+
const o = it ?? {};
|
|
37
|
+
const at = `items[${i}]`;
|
|
38
|
+
if (!str(o.sku)) fail(path, `${at}.sku is required`);
|
|
39
|
+
if (skus.has(o.sku)) fail(path, `${at}.sku ${JSON.stringify(o.sku)} appears twice`);
|
|
40
|
+
skus.add(o.sku);
|
|
41
|
+
if (!str(o.name)) fail(path, `${at}.name is required`);
|
|
42
|
+
if (!num(o.list_price) || o.list_price < 0) fail(path, `${at}.list_price must be a number >= 0`);
|
|
43
|
+
if (!num(o.stock) || !Number.isInteger(o.stock) || o.stock < 0) fail(path, `${at}.stock must be a whole number >= 0`);
|
|
44
|
+
return {
|
|
45
|
+
id: str(o.id) ? o.id : `item-${i + 1}`,
|
|
46
|
+
sku: o.sku,
|
|
47
|
+
name: o.name,
|
|
48
|
+
unit: str(o.unit) ? o.unit : "pcs",
|
|
49
|
+
list_price: o.list_price,
|
|
50
|
+
stock: o.stock
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
const customers = c.customers.map((cu, i) => {
|
|
54
|
+
const o = cu ?? {};
|
|
55
|
+
const at = `customers[${i}]`;
|
|
56
|
+
if (!str(o.name)) fail(path, `${at}.name is required`);
|
|
57
|
+
if (!num(o.credit_limit) || o.credit_limit < 0) fail(path, `${at}.credit_limit must be a number >= 0`);
|
|
58
|
+
const open = o.open_balance ?? 0;
|
|
59
|
+
if (!num(open) || open < 0) fail(path, `${at}.open_balance must be a number >= 0`);
|
|
60
|
+
return {
|
|
61
|
+
id: str(o.id) ? o.id : `cust-${i + 1}`,
|
|
62
|
+
name: o.name,
|
|
63
|
+
email: str(o.email) ? o.email : null,
|
|
64
|
+
country: str(o.country) ? o.country : null,
|
|
65
|
+
credit_limit: o.credit_limit,
|
|
66
|
+
open_balance: open,
|
|
67
|
+
payment_terms: str(o.payment_terms) ? o.payment_terms : null
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
for (const [label, list] of [["item", items], ["customer", customers]]) {
|
|
71
|
+
const ids = /* @__PURE__ */ new Set();
|
|
72
|
+
for (const { id } of list) {
|
|
73
|
+
if (ids.has(id)) fail(path, `${label} id ${JSON.stringify(id)} appears twice`);
|
|
74
|
+
ids.add(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { name: c.name, currency: c.currency, items, customers };
|
|
78
|
+
}
|
|
79
|
+
function loadCompanyFile(path) {
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
fail(path, `cannot be read as JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
85
|
+
}
|
|
86
|
+
return parseCompany(raw, path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/db.ts
|
|
15
90
|
var SCHEMA = `
|
|
16
91
|
CREATE TABLE IF NOT EXISTS items (
|
|
17
92
|
id TEXT PRIMARY KEY,
|
|
@@ -72,49 +147,106 @@ CREATE TABLE IF NOT EXISTS orders (
|
|
|
72
147
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
73
148
|
receipt_id TEXT
|
|
74
149
|
);
|
|
150
|
+
|
|
151
|
+
-- Calls the connector refused AFTER the gateway let them through. When the gateway
|
|
152
|
+
-- injected a receipt_id, a ticket exists for an action that never happened; this
|
|
153
|
+
-- table is the only place that says so (the ticket alone reads like a done action).
|
|
154
|
+
CREATE TABLE IF NOT EXISTS refusals (
|
|
155
|
+
id TEXT PRIMARY KEY,
|
|
156
|
+
at TEXT DEFAULT (datetime('now')),
|
|
157
|
+
tool TEXT NOT NULL,
|
|
158
|
+
receipt_id TEXT,
|
|
159
|
+
message TEXT NOT NULL
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
-- Every change the ERP performed, one row per call \u2014 the effect each ticket produced.
|
|
163
|
+
-- A document's own receipt_id column holds only its latest ticket (send overwrites
|
|
164
|
+
-- create), so this table, not the document, is what lines up ticket \u2194 effect 1:1.
|
|
165
|
+
CREATE TABLE IF NOT EXISTS changes (
|
|
166
|
+
id TEXT PRIMARY KEY,
|
|
167
|
+
at TEXT DEFAULT (datetime('now')),
|
|
168
|
+
tool TEXT NOT NULL,
|
|
169
|
+
receipt_id TEXT,
|
|
170
|
+
document_id TEXT,
|
|
171
|
+
document_number TEXT,
|
|
172
|
+
status TEXT,
|
|
173
|
+
net_total REAL
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
-- Scenario requests handed to the agent, with the moment they were handed over.
|
|
177
|
+
CREATE TABLE IF NOT EXISTS triggers (
|
|
178
|
+
scenario_id TEXT PRIMARY KEY,
|
|
179
|
+
triggered_at TEXT DEFAULT (datetime('now')),
|
|
180
|
+
request TEXT NOT NULL,
|
|
181
|
+
expected TEXT
|
|
182
|
+
);
|
|
75
183
|
`;
|
|
76
184
|
var RECEIPT_ID_TABLES = ["quotes", "orders"];
|
|
77
|
-
var
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
[
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
[
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
185
|
+
var DEMO_COMPANY = {
|
|
186
|
+
name: "Demo Industrial",
|
|
187
|
+
currency: "EUR",
|
|
188
|
+
items: [
|
|
189
|
+
{ id: "item-1", sku: "WIDGET-100", name: "Widget 100", unit: "pcs", list_price: 25, stock: 500 },
|
|
190
|
+
{ id: "item-2", sku: "WIDGET-200", name: "Widget 200 Pro", unit: "pcs", list_price: 45, stock: 300 },
|
|
191
|
+
{ id: "item-3", sku: "GEAR-A1", name: "Precision Gear A1", unit: "pcs", list_price: 120, stock: 80 },
|
|
192
|
+
{ id: "item-4", sku: "GEAR-B2", name: "Precision Gear B2", unit: "pcs", list_price: 150, stock: 60 },
|
|
193
|
+
{ id: "item-5", sku: "PANEL-S", name: "Control Panel S", unit: "pcs", list_price: 89.5, stock: 150 },
|
|
194
|
+
{ id: "item-6", sku: "PANEL-L", name: "Control Panel L", unit: "pcs", list_price: 149.5, stock: 90 },
|
|
195
|
+
{ id: "item-7", sku: "CABLE-5M", name: "Cable 5m", unit: "pcs", list_price: 12, stock: 1e3 },
|
|
196
|
+
{ id: "item-8", sku: "SERVICE-KIT", name: "Maintenance Kit", unit: "set", list_price: 340, stock: 20 }
|
|
197
|
+
],
|
|
198
|
+
customers: [
|
|
199
|
+
{ id: "cust-1", name: "Nordkap Manufacturing GmbH", email: "ap@nordkap.example", country: "DE", credit_limit: 5e4, open_balance: 12e3, payment_terms: "NET30" },
|
|
200
|
+
{ id: "cust-2", name: "Alpine Components AG", email: "finance@alpinecomp.example", country: "AT", credit_limit: 2e4, open_balance: 4e3, payment_terms: "NET30" },
|
|
201
|
+
{ id: "cust-3", name: "Baltic Retail Group", email: "accounts@balticretail.example", country: "LV", credit_limit: 8e3, open_balance: 7500, payment_terms: "NET14" },
|
|
202
|
+
{ id: "cust-4", name: "Meridian Industrial Ltd", email: "payables@meridianind.example", country: "IE", credit_limit: 1e5, open_balance: 0, payment_terms: "NET60" },
|
|
203
|
+
{ id: "cust-5", name: "Solstice Equipment Co", email: "finance@solsticeeq.example", country: "NL", credit_limit: 5e3, open_balance: 4800, payment_terms: "NET7" }
|
|
204
|
+
]
|
|
205
|
+
};
|
|
206
|
+
var INSERT_ITEM = `INSERT INTO items (id, sku, name, unit, list_price, currency, stock) VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
207
|
+
var INSERT_CUSTOMER = `INSERT INTO customers (id, name, email, country, currency, credit_limit, open_balance, payment_terms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
208
|
+
function companyRows(company) {
|
|
209
|
+
return {
|
|
210
|
+
items: company.items.map((i) => [i.id, i.sku, i.name, i.unit, i.list_price, company.currency, i.stock]),
|
|
211
|
+
customers: company.customers.map((c) => [
|
|
212
|
+
c.id,
|
|
213
|
+
c.name,
|
|
214
|
+
c.email,
|
|
215
|
+
c.country,
|
|
216
|
+
company.currency,
|
|
217
|
+
c.credit_limit,
|
|
218
|
+
c.open_balance,
|
|
219
|
+
c.payment_terms
|
|
220
|
+
])
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function resolveCompany(env = process.env) {
|
|
224
|
+
const path = env.ERP_COMPANY_FILE?.trim();
|
|
225
|
+
return path ? loadCompanyFile(path) : DEMO_COMPANY;
|
|
226
|
+
}
|
|
227
|
+
function seedSqlite(db, company) {
|
|
97
228
|
const { count } = db.prepare("SELECT COUNT(*) as count FROM items").get();
|
|
98
|
-
if (count > 0)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
229
|
+
if (count > 0) {
|
|
230
|
+
if (process.env.ERP_COMPANY_FILE) {
|
|
231
|
+
console.error("[erp-mcp] database already holds data \u2014 ERP_COMPANY_FILE not loaded (start from an empty database to load it)");
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
110
234
|
}
|
|
235
|
+
const rows = companyRows(company);
|
|
236
|
+
const insertItem = db.prepare(INSERT_ITEM);
|
|
237
|
+
const insertCustomer = db.prepare(INSERT_CUSTOMER);
|
|
238
|
+
db.transaction(() => {
|
|
239
|
+
for (const r of rows.items) insertItem.run(...r);
|
|
240
|
+
for (const r of rows.customers) insertCustomer.run(...r);
|
|
241
|
+
})();
|
|
242
|
+
console.error(`[erp-mcp] seeded "${company.name}": ${company.items.length} items, ${company.customers.length} customers`);
|
|
111
243
|
}
|
|
112
|
-
async function createSqliteDb(dbPath) {
|
|
244
|
+
async function createSqliteDb(dbPath, company) {
|
|
113
245
|
const { default: Database } = await import("better-sqlite3");
|
|
114
246
|
const db = new Database(dbPath);
|
|
115
247
|
db.pragma("foreign_keys = ON");
|
|
116
248
|
db.exec(SCHEMA);
|
|
117
|
-
seedSqlite(db);
|
|
249
|
+
seedSqlite(db, company);
|
|
118
250
|
for (const table of RECEIPT_ID_TABLES) {
|
|
119
251
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
120
252
|
if (!cols.some((c) => c.name === "receipt_id")) {
|
|
@@ -136,7 +268,7 @@ async function createSqliteDb(dbPath) {
|
|
|
136
268
|
}
|
|
137
269
|
};
|
|
138
270
|
}
|
|
139
|
-
async function createPostgresDb(connectionString) {
|
|
271
|
+
async function createPostgresDb(connectionString, company) {
|
|
140
272
|
const { default: pg } = await import("pg");
|
|
141
273
|
const pool = new pg.Pool({ connectionString });
|
|
142
274
|
function adaptSql(sql) {
|
|
@@ -152,18 +284,9 @@ async function createPostgresDb(connectionString) {
|
|
|
152
284
|
}
|
|
153
285
|
const { rows } = await client.query("SELECT COUNT(*)::int as count FROM items");
|
|
154
286
|
if (rows[0].count === 0) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
}
|
|
287
|
+
const seed = companyRows(company);
|
|
288
|
+
for (const r of seed.items) await client.query(adaptSql(INSERT_ITEM), r);
|
|
289
|
+
for (const r of seed.customers) await client.query(adaptSql(INSERT_CUSTOMER), r);
|
|
167
290
|
}
|
|
168
291
|
} finally {
|
|
169
292
|
client.release();
|
|
@@ -198,22 +321,36 @@ function maybeBackupSqlite(dbPath) {
|
|
|
198
321
|
}
|
|
199
322
|
}
|
|
200
323
|
}
|
|
201
|
-
async function createDb() {
|
|
324
|
+
async function createDb(company = resolveCompany()) {
|
|
202
325
|
const databaseUrl = process.env.DATABASE_URL ?? "";
|
|
203
326
|
if (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://")) {
|
|
204
327
|
console.error("[erp-mcp] using Postgres");
|
|
205
|
-
return createPostgresDb(databaseUrl);
|
|
328
|
+
return createPostgresDb(databaseUrl, company);
|
|
206
329
|
}
|
|
207
|
-
|
|
208
|
-
if (!
|
|
209
|
-
|
|
330
|
+
let dbPath = databaseUrl;
|
|
331
|
+
if (!dbPath) {
|
|
332
|
+
const hapDir = process.env.HAP_DATA_DIR ?? join(homedir(), ".hap");
|
|
333
|
+
if (!existsSync(hapDir)) mkdirSync(hapDir, { recursive: true });
|
|
334
|
+
dbPath = join(hapDir, "erp.db");
|
|
210
335
|
}
|
|
211
|
-
const dbPath = databaseUrl || join(hapDir, "erp.db");
|
|
212
336
|
maybeBackupSqlite(dbPath);
|
|
213
337
|
console.error(`[erp-mcp] using SQLite at ${dbPath}`);
|
|
214
|
-
return createSqliteDb(dbPath);
|
|
338
|
+
return createSqliteDb(dbPath, company);
|
|
215
339
|
}
|
|
216
340
|
|
|
341
|
+
// src/dispatch.ts
|
|
342
|
+
import { randomUUID } from "crypto";
|
|
343
|
+
|
|
344
|
+
// src/mode.ts
|
|
345
|
+
var MODES = ["simulation", "live"];
|
|
346
|
+
function getMode(env = process.env) {
|
|
347
|
+
const raw = (env.ERP_MODE ?? "").trim().toLowerCase();
|
|
348
|
+
if (raw === "") return "simulation";
|
|
349
|
+
if (MODES.includes(raw)) return raw;
|
|
350
|
+
throw new Error(`ERP_MODE must be one of ${MODES.join(", ")} \u2014 got ${JSON.stringify(env.ERP_MODE)}.`);
|
|
351
|
+
}
|
|
352
|
+
var LIVE_NOT_AVAILABLE = "Refused: this ERP connector is in live mode, but no live ERP adapter is installed (0.x ships the simulation only). Nothing was read or changed. Set ERP_MODE=simulation, or install the adapter for your ERP.";
|
|
353
|
+
|
|
217
354
|
// src/tools/items.ts
|
|
218
355
|
async function list_items(db, args) {
|
|
219
356
|
const { query, limit = 50 } = args;
|
|
@@ -520,6 +657,160 @@ async function get_order(db, args) {
|
|
|
520
657
|
return orderWithLines(db, id);
|
|
521
658
|
}
|
|
522
659
|
|
|
660
|
+
// src/dispatch.ts
|
|
661
|
+
var CHANGE_TOOLS = /* @__PURE__ */ new Set(["create_quote", "update_quote", "send_quote", "convert_quote_to_order"]);
|
|
662
|
+
async function runTool(db, name, args) {
|
|
663
|
+
switch (name) {
|
|
664
|
+
case "list_items":
|
|
665
|
+
return list_items(db, args);
|
|
666
|
+
case "get_item":
|
|
667
|
+
return get_item(db, args);
|
|
668
|
+
case "find_customers":
|
|
669
|
+
return find_customers(db, args);
|
|
670
|
+
case "get_customer":
|
|
671
|
+
return get_customer(db, args);
|
|
672
|
+
case "list_quotes":
|
|
673
|
+
return list_quotes(db, args);
|
|
674
|
+
case "get_quote":
|
|
675
|
+
return get_quote(db, args);
|
|
676
|
+
case "list_orders":
|
|
677
|
+
return list_orders(db, args);
|
|
678
|
+
case "get_order":
|
|
679
|
+
return get_order(db, args);
|
|
680
|
+
case "create_quote":
|
|
681
|
+
return create_quote(db, args);
|
|
682
|
+
case "update_quote":
|
|
683
|
+
return update_quote(db, args);
|
|
684
|
+
case "send_quote":
|
|
685
|
+
return send_quote(db, args);
|
|
686
|
+
case "convert_quote_to_order":
|
|
687
|
+
return convert_quote_to_order(db, args);
|
|
688
|
+
default:
|
|
689
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function callTool(db, mode, name, args) {
|
|
693
|
+
if (mode === "live") throw new Error(LIVE_NOT_AVAILABLE);
|
|
694
|
+
try {
|
|
695
|
+
const result = await runTool(db, name, args);
|
|
696
|
+
if (CHANGE_TOOLS.has(name)) {
|
|
697
|
+
const doc = result ?? {};
|
|
698
|
+
await db.run(
|
|
699
|
+
`INSERT INTO changes (id, at, tool, receipt_id, document_id, document_number, status, net_total) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
700
|
+
[
|
|
701
|
+
randomUUID(),
|
|
702
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
703
|
+
name,
|
|
704
|
+
typeof args.receipt_id === "string" ? args.receipt_id : null,
|
|
705
|
+
doc.id ?? null,
|
|
706
|
+
doc.number ?? null,
|
|
707
|
+
doc.status ?? null,
|
|
708
|
+
typeof doc.net_total === "number" ? doc.net_total : null
|
|
709
|
+
]
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
return result;
|
|
713
|
+
} catch (err) {
|
|
714
|
+
if (CHANGE_TOOLS.has(name)) {
|
|
715
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
716
|
+
const receiptId = typeof args.receipt_id === "string" ? args.receipt_id : null;
|
|
717
|
+
await db.run(`INSERT INTO refusals (id, at, tool, receipt_id, message) VALUES (?, ?, ?, ?, ?)`, [
|
|
718
|
+
randomUUID(),
|
|
719
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
720
|
+
name,
|
|
721
|
+
receiptId,
|
|
722
|
+
message
|
|
723
|
+
]);
|
|
724
|
+
}
|
|
725
|
+
throw err;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/cli.ts
|
|
730
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
731
|
+
function loadScenario(path) {
|
|
732
|
+
let raw;
|
|
733
|
+
try {
|
|
734
|
+
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
735
|
+
} catch (err) {
|
|
736
|
+
throw new Error(`Scenario file ${path} cannot be read as JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
737
|
+
}
|
|
738
|
+
const list = raw?.requests;
|
|
739
|
+
if (!Array.isArray(list) || list.length === 0) throw new Error(`Scenario file ${path}: \`requests\` must be a non-empty list`);
|
|
740
|
+
const ids = /* @__PURE__ */ new Set();
|
|
741
|
+
return list.map((r, i) => {
|
|
742
|
+
if (typeof r?.id !== "string" || !r.id.trim()) throw new Error(`Scenario file ${path}: requests[${i}].id is required`);
|
|
743
|
+
if (ids.has(r.id)) throw new Error(`Scenario file ${path}: request id ${JSON.stringify(r.id)} appears twice`);
|
|
744
|
+
ids.add(r.id);
|
|
745
|
+
if (typeof r.request !== "string" || !r.request.trim()) throw new Error(`Scenario file ${path}: requests[${i}].request is required`);
|
|
746
|
+
return { id: r.id, request: r.request, expected: r.expected };
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
async function nextRequest(db, scenario) {
|
|
750
|
+
const done = new Set((await db.all(`SELECT scenario_id FROM triggers`)).map((r) => r.scenario_id));
|
|
751
|
+
const next = scenario.find((r) => !done.has(r.id));
|
|
752
|
+
if (!next) return null;
|
|
753
|
+
await db.run(`INSERT INTO triggers (scenario_id, request, expected) VALUES (?, ?, ?)`, [
|
|
754
|
+
next.id,
|
|
755
|
+
next.request,
|
|
756
|
+
next.expected === void 0 ? null : JSON.stringify(next.expected)
|
|
757
|
+
]);
|
|
758
|
+
const row = await db.get(`SELECT triggered_at FROM triggers WHERE scenario_id = ?`, [next.id]);
|
|
759
|
+
return { ...next, triggered_at: row.triggered_at };
|
|
760
|
+
}
|
|
761
|
+
async function exportRecord(db, mode = getMode()) {
|
|
762
|
+
const quotes = await db.all(`SELECT * FROM quotes ORDER BY created_at, number`);
|
|
763
|
+
const lines = await db.all(`SELECT * FROM quote_lines`);
|
|
764
|
+
const byQuote = /* @__PURE__ */ new Map();
|
|
765
|
+
for (const l of lines) byQuote.set(l.quote_id, [...byQuote.get(l.quote_id) ?? [], l]);
|
|
766
|
+
return {
|
|
767
|
+
mode,
|
|
768
|
+
exported_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
769
|
+
triggers: (await db.all(`SELECT * FROM triggers ORDER BY triggered_at`)).map((t) => ({
|
|
770
|
+
...t,
|
|
771
|
+
expected: t.expected ? JSON.parse(t.expected) : null
|
|
772
|
+
})),
|
|
773
|
+
quotes: quotes.map((q) => ({ ...q, lines: byQuote.get(q.id) ?? [] })),
|
|
774
|
+
orders: await db.all(`SELECT * FROM orders ORDER BY created_at, number`),
|
|
775
|
+
changes: await db.all(`SELECT * FROM changes ORDER BY at`),
|
|
776
|
+
refusals: await db.all(`SELECT * FROM refusals ORDER BY at`)
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
var USAGE = `usage: erp-mcp export | erp-mcp scenario next <file> | erp-mcp scenario status <file>`;
|
|
780
|
+
async function runCli(argv) {
|
|
781
|
+
const [cmd, sub, file] = argv;
|
|
782
|
+
try {
|
|
783
|
+
if (cmd === "export") {
|
|
784
|
+
const db = await createDb();
|
|
785
|
+
process.stdout.write(JSON.stringify(await exportRecord(db), null, 2) + "\n");
|
|
786
|
+
await db.close();
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
if (cmd === "scenario" && (sub === "next" || sub === "status") && file) {
|
|
790
|
+
const scenario = loadScenario(file);
|
|
791
|
+
const db = await createDb();
|
|
792
|
+
if (sub === "next") {
|
|
793
|
+
const next = await nextRequest(db, scenario);
|
|
794
|
+
process.stdout.write(next ? `[${next.id}] handed over ${next.triggered_at} UTC
|
|
795
|
+
${next.request}
|
|
796
|
+
` : "All requests handed over.\n");
|
|
797
|
+
} else {
|
|
798
|
+
const done = new Map((await db.all(`SELECT scenario_id, triggered_at FROM triggers`)).map((r) => [r.scenario_id, r.triggered_at]));
|
|
799
|
+
for (const r of scenario) process.stdout.write(`${r.id} ${done.get(r.id) ?? "pending"} ${r.request}
|
|
800
|
+
`);
|
|
801
|
+
}
|
|
802
|
+
await db.close();
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
process.stderr.write(USAGE + "\n");
|
|
806
|
+
return 2;
|
|
807
|
+
} catch (err) {
|
|
808
|
+
process.stderr.write(`[erp-mcp] ${err instanceof Error ? err.message : String(err)}
|
|
809
|
+
`);
|
|
810
|
+
return 1;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
523
814
|
// src/index.ts
|
|
524
815
|
var RECEIPT_FIELD = {
|
|
525
816
|
type: "string",
|
|
@@ -698,9 +989,14 @@ var TOOL_DEFINITIONS = [
|
|
|
698
989
|
}
|
|
699
990
|
];
|
|
700
991
|
async function main() {
|
|
992
|
+
if (process.argv.length > 2) {
|
|
993
|
+
process.exit(await runCli(process.argv.slice(2)));
|
|
994
|
+
}
|
|
995
|
+
const mode = getMode();
|
|
701
996
|
const db = await createDb();
|
|
997
|
+
console.error(`[erp-mcp] mode: ${mode}`);
|
|
702
998
|
const server = new Server(
|
|
703
|
-
{ name: "erp", version: "0.
|
|
999
|
+
{ name: "erp", version: "0.2.0" },
|
|
704
1000
|
{ capabilities: { tools: {} } }
|
|
705
1001
|
);
|
|
706
1002
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -710,47 +1006,7 @@ async function main() {
|
|
|
710
1006
|
const { name, arguments: args } = request.params;
|
|
711
1007
|
const safeArgs = args ?? {};
|
|
712
1008
|
try {
|
|
713
|
-
|
|
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
|
-
}
|
|
1009
|
+
const result = await callTool(db, mode, name, safeArgs);
|
|
754
1010
|
return {
|
|
755
1011
|
content: [
|
|
756
1012
|
{
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Bergmann Ersatzteile GmbH",
|
|
3
|
+
"currency": "EUR",
|
|
4
|
+
"items": [
|
|
5
|
+
{ "sku": "SP-100", "name": "Hydraulic seal kit", "unit": "set", "list_price": 84.5, "stock": 40 },
|
|
6
|
+
{ "sku": "SP-200", "name": "Bearing set 6204", "unit": "set", "list_price": 120.0, "stock": 10 },
|
|
7
|
+
{ "sku": "SP-310", "name": "Drive belt XPZ 1250", "unit": "pcs", "list_price": 18.9, "stock": 250 }
|
|
8
|
+
],
|
|
9
|
+
"customers": [
|
|
10
|
+
{ "name": "Huber Maschinenbau GmbH", "email": "einkauf@huber.example", "country": "AT", "credit_limit": 15000, "open_balance": 2000, "payment_terms": "NET30" },
|
|
11
|
+
{ "name": "Steiner Anlagentechnik KG", "email": "office@steiner.example", "country": "DE", "credit_limit": 4000, "open_balance": 3500, "payment_terms": "NET14" }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"requests": [
|
|
3
|
+
{ "id": "r1", "request": "Huber Maschinenbau asks for 10 × Hydraulic seal kit (SP-100), delivery next week.",
|
|
4
|
+
"expected": { "customer_id": "cust-1", "lines": [{ "item_id": "item-1", "qty": 10 }], "value": 845 } },
|
|
5
|
+
{ "id": "r2", "request": "Steiner Anlagentechnik asks for 5 × Bearing set 6204 (SP-200).",
|
|
6
|
+
"expected": { "customer_id": "cust-2", "lines": [{ "item_id": "item-2", "qty": 5 }], "value": 600, "note": "an order would exceed the credit limit (3,500 open + 600 > 4,000)" } }
|
|
7
|
+
]
|
|
8
|
+
}
|