@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 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, and a deterministic demo
127
- dataset is seeded when the `items` table is empty.
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 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) {
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) 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);
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
- 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
- }
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
- const hapDir = process.env.HAP_DATA_DIR ?? join(homedir(), ".hap");
208
- if (!existsSync(hapDir)) {
209
- mkdirSync(hapDir, { recursive: true });
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.1.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
- 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
- }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/erp-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "ERP MCP server for the Human Agency Protocol — quote-to-order: items, customers, quotes, orders",
5
5
  "license": "MIT",
6
6
  "repository": {