@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/src/cli.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Local operator commands. Deliberately NOT MCP tools: the agent under test must
3
+ * be able neither to read nor to change the record its work is measured by.
4
+ *
5
+ * erp-mcp export everything needed to line up trigger → ticket → effect (changes + refusals)
6
+ * erp-mcp scenario next <file> hand over the next request, record when
7
+ * erp-mcp scenario status <file> which requests were handed over, which are pending
8
+ */
9
+ import { readFileSync } from "fs";
10
+ import { createDb, type Db } from "./db.js";
11
+ import { getMode } from "./mode.js";
12
+
13
+ export interface ScenarioRequest {
14
+ id: string;
15
+ request: string;
16
+ /** What a correct outcome looks like, e.g. { customer_id, lines, value }. Free-form, carried into the export. */
17
+ expected?: unknown;
18
+ }
19
+
20
+ export function loadScenario(path: string): ScenarioRequest[] {
21
+ let raw: any;
22
+ try {
23
+ raw = JSON.parse(readFileSync(path, "utf8"));
24
+ } catch (err) {
25
+ throw new Error(`Scenario file ${path} cannot be read as JSON (${err instanceof Error ? err.message : String(err)})`);
26
+ }
27
+ const list = raw?.requests;
28
+ if (!Array.isArray(list) || list.length === 0) throw new Error(`Scenario file ${path}: \`requests\` must be a non-empty list`);
29
+ const ids = new Set<string>();
30
+ return list.map((r: any, i: number) => {
31
+ if (typeof r?.id !== "string" || !r.id.trim()) throw new Error(`Scenario file ${path}: requests[${i}].id is required`);
32
+ if (ids.has(r.id)) throw new Error(`Scenario file ${path}: request id ${JSON.stringify(r.id)} appears twice`);
33
+ ids.add(r.id);
34
+ if (typeof r.request !== "string" || !r.request.trim()) throw new Error(`Scenario file ${path}: requests[${i}].request is required`);
35
+ return { id: r.id, request: r.request, expected: r.expected };
36
+ });
37
+ }
38
+
39
+ /** Records the hand-over time of the first request not yet handed over. Null when all are done. */
40
+ export async function nextRequest(db: Db, scenario: ScenarioRequest[]): Promise<(ScenarioRequest & { triggered_at: string }) | null> {
41
+ const done = new Set((await db.all<{ scenario_id: string }>(`SELECT scenario_id FROM triggers`)).map((r) => r.scenario_id));
42
+ const next = scenario.find((r) => !done.has(r.id));
43
+ if (!next) return null;
44
+ await db.run(`INSERT INTO triggers (scenario_id, request, expected) VALUES (?, ?, ?)`, [
45
+ next.id, next.request, next.expected === undefined ? null : JSON.stringify(next.expected),
46
+ ]);
47
+ const row = await db.get<{ triggered_at: string }>(`SELECT triggered_at FROM triggers WHERE scenario_id = ?`, [next.id]);
48
+ return { ...next, triggered_at: row!.triggered_at };
49
+ }
50
+
51
+ export async function exportRecord(db: Db, mode = getMode()) {
52
+ const quotes = await db.all<any>(`SELECT * FROM quotes ORDER BY created_at, number`);
53
+ const lines = await db.all<any>(`SELECT * FROM quote_lines`);
54
+ const byQuote = new Map<string, any[]>();
55
+ for (const l of lines) byQuote.set(l.quote_id, [...(byQuote.get(l.quote_id) ?? []), l]);
56
+ return {
57
+ mode,
58
+ exported_at: new Date().toISOString(),
59
+ triggers: (await db.all<any>(`SELECT * FROM triggers ORDER BY triggered_at`)).map((t) => ({
60
+ ...t, expected: t.expected ? JSON.parse(t.expected) : null,
61
+ })),
62
+ quotes: quotes.map((q) => ({ ...q, lines: byQuote.get(q.id) ?? [] })),
63
+ orders: await db.all<any>(`SELECT * FROM orders ORDER BY created_at, number`),
64
+ changes: await db.all<any>(`SELECT * FROM changes ORDER BY at`),
65
+ refusals: await db.all<any>(`SELECT * FROM refusals ORDER BY at`),
66
+ };
67
+ }
68
+
69
+ const USAGE = `usage: erp-mcp export | erp-mcp scenario next <file> | erp-mcp scenario status <file>`;
70
+
71
+ export async function runCli(argv: string[]): Promise<number> {
72
+ const [cmd, sub, file] = argv;
73
+ try {
74
+ if (cmd === "export") {
75
+ const db = await createDb();
76
+ process.stdout.write(JSON.stringify(await exportRecord(db), null, 2) + "\n");
77
+ await db.close();
78
+ return 0;
79
+ }
80
+ if (cmd === "scenario" && (sub === "next" || sub === "status") && file) {
81
+ const scenario = loadScenario(file);
82
+ const db = await createDb();
83
+ if (sub === "next") {
84
+ const next = await nextRequest(db, scenario);
85
+ process.stdout.write(next ? `[${next.id}] handed over ${next.triggered_at} UTC\n${next.request}\n` : "All requests handed over.\n");
86
+ } else {
87
+ const done = new Map((await db.all<any>(`SELECT scenario_id, triggered_at FROM triggers`)).map((r) => [r.scenario_id, r.triggered_at]));
88
+ for (const r of scenario) process.stdout.write(`${r.id}\t${done.get(r.id) ?? "pending"}\t${r.request}\n`);
89
+ }
90
+ await db.close();
91
+ return 0;
92
+ }
93
+ process.stderr.write(USAGE + "\n");
94
+ return 2;
95
+ } catch (err) {
96
+ process.stderr.write(`[erp-mcp] ${err instanceof Error ? err.message : String(err)}\n`);
97
+ return 1;
98
+ }
99
+ }
package/src/company.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Company file: the test data the simulated ERP starts from — the company's
3
+ * items, prices, stock and customers, invented but realistic, prepared after the
4
+ * first conversation. Loaded once into an empty database (ERP_COMPANY_FILE).
5
+ *
6
+ * Validated strictly and refused whole on the first problem: a half-loaded or
7
+ * silently "fixed" company would make every later measurement of correctness
8
+ * compare against data nobody wrote.
9
+ */
10
+ import { readFileSync } from "fs";
11
+
12
+ export interface CompanyItem {
13
+ id: string;
14
+ sku: string;
15
+ name: string;
16
+ unit: string;
17
+ list_price: number;
18
+ stock: number;
19
+ }
20
+
21
+ export interface CompanyCustomer {
22
+ id: string;
23
+ name: string;
24
+ email: string | null;
25
+ country: string | null;
26
+ credit_limit: number;
27
+ open_balance: number;
28
+ payment_terms: string | null;
29
+ }
30
+
31
+ export interface Company {
32
+ name: string;
33
+ currency: string;
34
+ items: CompanyItem[];
35
+ customers: CompanyCustomer[];
36
+ }
37
+
38
+ function fail(path: string, msg: string): never {
39
+ throw new Error(`Company file ${path}: ${msg}`);
40
+ }
41
+
42
+ function str(v: unknown): v is string {
43
+ return typeof v === "string" && v.trim() !== "";
44
+ }
45
+
46
+ function num(v: unknown): v is number {
47
+ return typeof v === "number" && Number.isFinite(v);
48
+ }
49
+
50
+ export function parseCompany(raw: unknown, path = "(inline)"): Company {
51
+ if (!raw || typeof raw !== "object") fail(path, "must be a JSON object");
52
+ const c = raw as Record<string, unknown>;
53
+ if (!str(c.name)) fail(path, "`name` is required");
54
+ if (!str(c.currency) || !/^[A-Z]{3}$/.test(c.currency)) fail(path, "`currency` must be a 3-letter ISO code, e.g. EUR");
55
+ if (!Array.isArray(c.items) || c.items.length === 0) fail(path, "`items` must be a non-empty list");
56
+ if (!Array.isArray(c.customers) || c.customers.length === 0) fail(path, "`customers` must be a non-empty list");
57
+
58
+ const skus = new Set<string>();
59
+ const items = c.items.map((it, i): CompanyItem => {
60
+ const o = (it ?? {}) as Record<string, unknown>;
61
+ const at = `items[${i}]`;
62
+ if (!str(o.sku)) fail(path, `${at}.sku is required`);
63
+ if (skus.has(o.sku)) fail(path, `${at}.sku ${JSON.stringify(o.sku)} appears twice`);
64
+ skus.add(o.sku);
65
+ if (!str(o.name)) fail(path, `${at}.name is required`);
66
+ if (!num(o.list_price) || o.list_price < 0) fail(path, `${at}.list_price must be a number >= 0`);
67
+ if (!num(o.stock) || !Number.isInteger(o.stock) || o.stock < 0) fail(path, `${at}.stock must be a whole number >= 0`);
68
+ return {
69
+ id: str(o.id) ? o.id : `item-${i + 1}`,
70
+ sku: o.sku,
71
+ name: o.name,
72
+ unit: str(o.unit) ? o.unit : "pcs",
73
+ list_price: o.list_price,
74
+ stock: o.stock,
75
+ };
76
+ });
77
+
78
+ const customers = c.customers.map((cu, i): CompanyCustomer => {
79
+ const o = (cu ?? {}) as Record<string, unknown>;
80
+ const at = `customers[${i}]`;
81
+ if (!str(o.name)) fail(path, `${at}.name is required`);
82
+ if (!num(o.credit_limit) || o.credit_limit < 0) fail(path, `${at}.credit_limit must be a number >= 0`);
83
+ const open = o.open_balance ?? 0;
84
+ if (!num(open) || open < 0) fail(path, `${at}.open_balance must be a number >= 0`);
85
+ return {
86
+ id: str(o.id) ? o.id : `cust-${i + 1}`,
87
+ name: o.name,
88
+ email: str(o.email) ? o.email : null,
89
+ country: str(o.country) ? o.country : null,
90
+ credit_limit: o.credit_limit,
91
+ open_balance: open,
92
+ payment_terms: str(o.payment_terms) ? o.payment_terms : null,
93
+ };
94
+ });
95
+
96
+ for (const [label, list] of [["item", items], ["customer", customers]] as const) {
97
+ const ids = new Set<string>();
98
+ for (const { id } of list) {
99
+ if (ids.has(id)) fail(path, `${label} id ${JSON.stringify(id)} appears twice`);
100
+ ids.add(id);
101
+ }
102
+ }
103
+
104
+ return { name: c.name, currency: c.currency, items, customers };
105
+ }
106
+
107
+ export function loadCompanyFile(path: string): Company {
108
+ let raw: unknown;
109
+ try {
110
+ raw = JSON.parse(readFileSync(path, "utf8"));
111
+ } catch (err) {
112
+ fail(path, `cannot be read as JSON (${err instanceof Error ? err.message : String(err)})`);
113
+ }
114
+ return parseCompany(raw, path);
115
+ }
package/src/db.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, copyFileSync, statSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
+ import { loadCompanyFile, type Company } from "./company.js";
4
5
 
5
6
  export interface Db {
6
7
  run(sql: string, params?: any[]): Promise<void>;
@@ -69,60 +70,111 @@ CREATE TABLE IF NOT EXISTS orders (
69
70
  updated_at TEXT DEFAULT (datetime('now')),
70
71
  receipt_id TEXT
71
72
  );
73
+
74
+ -- Calls the connector refused AFTER the gateway let them through. When the gateway
75
+ -- injected a receipt_id, a ticket exists for an action that never happened; this
76
+ -- table is the only place that says so (the ticket alone reads like a done action).
77
+ CREATE TABLE IF NOT EXISTS refusals (
78
+ id TEXT PRIMARY KEY,
79
+ at TEXT DEFAULT (datetime('now')),
80
+ tool TEXT NOT NULL,
81
+ receipt_id TEXT,
82
+ message TEXT NOT NULL
83
+ );
84
+
85
+ -- Every change the ERP performed, one row per call — the effect each ticket produced.
86
+ -- A document's own receipt_id column holds only its latest ticket (send overwrites
87
+ -- create), so this table, not the document, is what lines up ticket ↔ effect 1:1.
88
+ CREATE TABLE IF NOT EXISTS changes (
89
+ id TEXT PRIMARY KEY,
90
+ at TEXT DEFAULT (datetime('now')),
91
+ tool TEXT NOT NULL,
92
+ receipt_id TEXT,
93
+ document_id TEXT,
94
+ document_number TEXT,
95
+ status TEXT,
96
+ net_total REAL
97
+ );
98
+
99
+ -- Scenario requests handed to the agent, with the moment they were handed over.
100
+ CREATE TABLE IF NOT EXISTS triggers (
101
+ scenario_id TEXT PRIMARY KEY,
102
+ triggered_at TEXT DEFAULT (datetime('now')),
103
+ request TEXT NOT NULL,
104
+ expected TEXT
105
+ );
72
106
  `;
73
107
 
74
108
  /** Tables that carry an authorizing receipt_id (Content Provenance §4.1). */
75
109
  const RECEIPT_ID_TABLES = ["quotes", "orders"];
76
110
 
77
- /** Deterministic demo dataset, seeded once when the items table is empty. */
78
- const SEED_ITEMS: Array<[string, string, string, string, number, number]> = [
79
- // id, sku, name, unit, list_price, stock
80
- ["item-1", "WIDGET-100", "Widget 100", "pcs", 25.0, 500],
81
- ["item-2", "WIDGET-200", "Widget 200 Pro", "pcs", 45.0, 300],
82
- ["item-3", "GEAR-A1", "Precision Gear A1", "pcs", 120.0, 80],
83
- ["item-4", "GEAR-B2", "Precision Gear B2", "pcs", 150.0, 60],
84
- ["item-5", "PANEL-S", "Control Panel S", "pcs", 89.5, 150],
85
- ["item-6", "PANEL-L", "Control Panel L", "pcs", 149.5, 90],
86
- ["item-7", "CABLE-5M", "Cable 5m", "pcs", 12.0, 1000],
87
- ["item-8", "SERVICE-KIT", "Maintenance Kit", "set", 340.0, 20],
88
- ];
89
-
90
- const SEED_CUSTOMERS: Array<[string, string, string, string, number, number, string]> = [
91
- // id, name, email, country, credit_limit, open_balance, payment_terms
92
- ["cust-1", "Nordkap Manufacturing GmbH", "ap@nordkap.example", "DE", 50000, 12000, "NET30"],
93
- ["cust-2", "Alpine Components AG", "finance@alpinecomp.example", "AT", 20000, 4000, "NET30"],
94
- ["cust-3", "Baltic Retail Group", "accounts@balticretail.example", "LV", 8000, 7500, "NET14"],
95
- ["cust-4", "Meridian Industrial Ltd", "payables@meridianind.example", "IE", 100000, 0, "NET60"],
96
- ["cust-5", "Solstice Equipment Co", "finance@solsticeeq.example", "NL", 5000, 4800, "NET7"],
97
- ];
98
-
99
- function seedSqlite(db: import("better-sqlite3").Database): void {
100
- const { count } = db.prepare("SELECT COUNT(*) as count FROM items").get() as { count: number };
101
- if (count > 0) return;
111
+ /** Demo company used when no ERP_COMPANY_FILE is given. Deterministic. */
112
+ export const DEMO_COMPANY: Company = {
113
+ name: "Demo Industrial",
114
+ currency: "EUR",
115
+ items: [
116
+ { id: "item-1", sku: "WIDGET-100", name: "Widget 100", unit: "pcs", list_price: 25.0, stock: 500 },
117
+ { id: "item-2", sku: "WIDGET-200", name: "Widget 200 Pro", unit: "pcs", list_price: 45.0, stock: 300 },
118
+ { id: "item-3", sku: "GEAR-A1", name: "Precision Gear A1", unit: "pcs", list_price: 120.0, stock: 80 },
119
+ { id: "item-4", sku: "GEAR-B2", name: "Precision Gear B2", unit: "pcs", list_price: 150.0, stock: 60 },
120
+ { id: "item-5", sku: "PANEL-S", name: "Control Panel S", unit: "pcs", list_price: 89.5, stock: 150 },
121
+ { id: "item-6", sku: "PANEL-L", name: "Control Panel L", unit: "pcs", list_price: 149.5, stock: 90 },
122
+ { id: "item-7", sku: "CABLE-5M", name: "Cable 5m", unit: "pcs", list_price: 12.0, stock: 1000 },
123
+ { id: "item-8", sku: "SERVICE-KIT", name: "Maintenance Kit", unit: "set", list_price: 340.0, stock: 20 },
124
+ ],
125
+ customers: [
126
+ { id: "cust-1", name: "Nordkap Manufacturing GmbH", email: "ap@nordkap.example", country: "DE", credit_limit: 50000, open_balance: 12000, payment_terms: "NET30" },
127
+ { id: "cust-2", name: "Alpine Components AG", email: "finance@alpinecomp.example", country: "AT", credit_limit: 20000, open_balance: 4000, payment_terms: "NET30" },
128
+ { id: "cust-3", name: "Baltic Retail Group", email: "accounts@balticretail.example", country: "LV", credit_limit: 8000, open_balance: 7500, payment_terms: "NET14" },
129
+ { id: "cust-4", name: "Meridian Industrial Ltd", email: "payables@meridianind.example", country: "IE", credit_limit: 100000, open_balance: 0, payment_terms: "NET60" },
130
+ { id: "cust-5", name: "Solstice Equipment Co", email: "finance@solsticeeq.example", country: "NL", credit_limit: 5000, open_balance: 4800, payment_terms: "NET7" },
131
+ ],
132
+ };
102
133
 
103
- const insertItem = db.prepare(
104
- `INSERT INTO items (id, sku, name, unit, list_price, currency, stock) VALUES (?, ?, ?, ?, ?, 'EUR', ?)`
105
- );
106
- for (const [id, sku, name, unit, listPrice, stock] of SEED_ITEMS) {
107
- insertItem.run(id, sku, name, unit, listPrice, stock);
108
- }
134
+ const INSERT_ITEM = `INSERT INTO items (id, sku, name, unit, list_price, currency, stock) VALUES (?, ?, ?, ?, ?, ?, ?)`;
135
+ const INSERT_CUSTOMER = `INSERT INTO customers (id, name, email, country, currency, credit_limit, open_balance, payment_terms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
109
136
 
110
- const insertCustomer = db.prepare(
111
- `INSERT INTO customers (id, name, email, country, currency, credit_limit, open_balance, payment_terms) VALUES (?, ?, ?, ?, 'EUR', ?, ?, ?)`
112
- );
113
- for (const [id, name, email, country, creditLimit, openBalance, terms] of SEED_CUSTOMERS) {
114
- insertCustomer.run(id, name, email, country, creditLimit, openBalance, terms);
137
+ function companyRows(company: Company): { items: any[][]; customers: any[][] } {
138
+ return {
139
+ items: company.items.map((i) => [i.id, i.sku, i.name, i.unit, i.list_price, company.currency, i.stock]),
140
+ customers: company.customers.map((c) => [
141
+ c.id, c.name, c.email, c.country, company.currency, c.credit_limit, c.open_balance, c.payment_terms,
142
+ ]),
143
+ };
144
+ }
145
+
146
+ /** The company to seed from: ERP_COMPANY_FILE if set (refused whole if invalid), else the demo. */
147
+ export function resolveCompany(env: NodeJS.ProcessEnv = process.env): Company {
148
+ const path = env.ERP_COMPANY_FILE?.trim();
149
+ return path ? loadCompanyFile(path) : DEMO_COMPANY;
150
+ }
151
+
152
+ function seedSqlite(db: import("better-sqlite3").Database, company: Company): void {
153
+ const { count } = db.prepare("SELECT COUNT(*) as count FROM items").get() as { count: number };
154
+ if (count > 0) {
155
+ if (process.env.ERP_COMPANY_FILE) {
156
+ console.error("[erp-mcp] database already holds data — ERP_COMPANY_FILE not loaded (start from an empty database to load it)");
157
+ }
158
+ return;
115
159
  }
160
+ const rows = companyRows(company);
161
+ const insertItem = db.prepare(INSERT_ITEM);
162
+ const insertCustomer = db.prepare(INSERT_CUSTOMER);
163
+ db.transaction(() => {
164
+ for (const r of rows.items) insertItem.run(...r);
165
+ for (const r of rows.customers) insertCustomer.run(...r);
166
+ })();
167
+ console.error(`[erp-mcp] seeded "${company.name}": ${company.items.length} items, ${company.customers.length} customers`);
116
168
  }
117
169
 
118
170
  // SQLite adapter using better-sqlite3 (synchronous API wrapped in async)
119
- async function createSqliteDb(dbPath: string): Promise<Db> {
171
+ async function createSqliteDb(dbPath: string, company: Company): Promise<Db> {
120
172
  const { default: Database } = await import("better-sqlite3");
121
173
 
122
174
  const db = new Database(dbPath);
123
175
  db.pragma("foreign_keys = ON");
124
176
  db.exec(SCHEMA);
125
- seedSqlite(db);
177
+ seedSqlite(db, company);
126
178
 
127
179
  // Migration: add receipt_id to pre-existing tables (Content Provenance §4.1).
128
180
  // ALTER ... ADD COLUMN throws if it already exists, so guard on table_info.
@@ -150,7 +202,7 @@ async function createSqliteDb(dbPath: string): Promise<Db> {
150
202
  }
151
203
 
152
204
  // Postgres adapter using pg Pool
153
- async function createPostgresDb(connectionString: string): Promise<Db> {
205
+ async function createPostgresDb(connectionString: string, company: Company): Promise<Db> {
154
206
  const { default: pg } = await import("pg");
155
207
  const pool = new pg.Pool({ connectionString });
156
208
 
@@ -171,18 +223,9 @@ async function createPostgresDb(connectionString: string): Promise<Db> {
171
223
  }
172
224
  const { rows } = await client.query("SELECT COUNT(*)::int as count FROM items");
173
225
  if (rows[0].count === 0) {
174
- for (const [id, sku, name, unit, listPrice, stock] of SEED_ITEMS) {
175
- await client.query(
176
- `INSERT INTO items (id, sku, name, unit, list_price, currency, stock) VALUES ($1, $2, $3, $4, $5, 'EUR', $6)`,
177
- [id, sku, name, unit, listPrice, stock]
178
- );
179
- }
180
- for (const [id, name, email, country, creditLimit, openBalance, terms] of SEED_CUSTOMERS) {
181
- await client.query(
182
- `INSERT INTO customers (id, name, email, country, currency, credit_limit, open_balance, payment_terms) VALUES ($1, $2, $3, $4, 'EUR', $5, $6, $7)`,
183
- [id, name, email, country, creditLimit, openBalance, terms]
184
- );
185
- }
226
+ const seed = companyRows(company);
227
+ for (const r of seed.items) await client.query(adaptSql(INSERT_ITEM), r);
228
+ for (const r of seed.customers) await client.query(adaptSql(INSERT_CUSTOMER), r);
186
229
  }
187
230
  } finally {
188
231
  client.release();
@@ -224,25 +267,27 @@ function maybeBackupSqlite(dbPath: string): void {
224
267
  }
225
268
  }
226
269
 
227
- export async function createDb(): Promise<Db> {
270
+ export async function createDb(company: Company = resolveCompany()): Promise<Db> {
228
271
  const databaseUrl = process.env.DATABASE_URL ?? "";
229
272
 
230
273
  if (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://")) {
231
274
  console.error("[erp-mcp] using Postgres");
232
- return createPostgresDb(databaseUrl);
275
+ return createPostgresDb(databaseUrl, company);
233
276
  }
234
277
 
235
278
  // SQLite path — honor HAP_DATA_DIR so docker (with a mounted /app/data) and
236
279
  // local dev (~/.hap) write to the same place the gateway uses. The gateway
237
280
  // injects HAP_DATA_DIR into the child env when spawning this MCP server.
238
- const hapDir = process.env.HAP_DATA_DIR ?? join(homedir(), ".hap");
239
- if (!existsSync(hapDir)) {
240
- mkdirSync(hapDir, { recursive: true });
281
+ // Only create the data directory when the default path is actually used — an
282
+ // explicit DATABASE_URL must not leave an empty ~/.hap behind.
283
+ let dbPath = databaseUrl;
284
+ if (!dbPath) {
285
+ const hapDir = process.env.HAP_DATA_DIR ?? join(homedir(), ".hap");
286
+ if (!existsSync(hapDir)) mkdirSync(hapDir, { recursive: true });
287
+ dbPath = join(hapDir, "erp.db");
241
288
  }
242
-
243
- const dbPath = databaseUrl || join(hapDir, "erp.db");
244
289
  maybeBackupSqlite(dbPath);
245
290
 
246
291
  console.error(`[erp-mcp] using SQLite at ${dbPath}`);
247
- return createSqliteDb(dbPath);
292
+ return createSqliteDb(dbPath, company);
248
293
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * One entry point for every tool call — used by the MCP server and by the tests,
3
+ * so the tests exercise exactly what the server runs.
4
+ */
5
+ import { randomUUID } from "crypto";
6
+ import type { Db } from "./db.js";
7
+ import { LIVE_NOT_AVAILABLE, type ErpMode } from "./mode.js";
8
+ import { list_items, get_item } from "./tools/items.js";
9
+ import { find_customers, get_customer } from "./tools/customers.js";
10
+ import { create_quote, update_quote, send_quote, list_quotes, get_quote } from "./tools/quotes.js";
11
+ import { convert_quote_to_order, list_orders, get_order } from "./tools/orders.js";
12
+
13
+ /** Tools that change the ERP — the ones the gateway issues a ticket for. */
14
+ export const CHANGE_TOOLS = new Set(["create_quote", "update_quote", "send_quote", "convert_quote_to_order"]);
15
+
16
+ async function runTool(db: Db, name: string, args: Record<string, any>): Promise<unknown> {
17
+ switch (name) {
18
+ case "list_items": return list_items(db, args);
19
+ case "get_item": return get_item(db, args);
20
+ case "find_customers": return find_customers(db, args);
21
+ case "get_customer": return get_customer(db, args);
22
+ case "list_quotes": return list_quotes(db, args);
23
+ case "get_quote": return get_quote(db, args);
24
+ case "list_orders": return list_orders(db, args);
25
+ case "get_order": return get_order(db, args);
26
+ case "create_quote": return create_quote(db, args);
27
+ case "update_quote": return update_quote(db, args);
28
+ case "send_quote": return send_quote(db, args);
29
+ case "convert_quote_to_order": return convert_quote_to_order(db, args);
30
+ default: throw new Error(`Unknown tool: ${name}`);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Run a tool in the given mode. Every successful change is recorded (`changes`)
36
+ * with the receipt_id the gateway injected; a refused change call is recorded with the
37
+ * receipt_id the gateway injected — that is the trace of a ticket whose action
38
+ * never happened. In live mode nothing runs and nothing is recorded locally:
39
+ * there is no local system to have refused anything.
40
+ */
41
+ export async function callTool(db: Db, mode: ErpMode, name: string, args: Record<string, any>): Promise<unknown> {
42
+ if (mode === "live") throw new Error(LIVE_NOT_AVAILABLE);
43
+ try {
44
+ const result = await runTool(db, name, args);
45
+ if (CHANGE_TOOLS.has(name)) {
46
+ const doc = (result ?? {}) as Record<string, unknown>;
47
+ await db.run(
48
+ `INSERT INTO changes (id, at, tool, receipt_id, document_id, document_number, status, net_total) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
49
+ [
50
+ randomUUID(), new Date().toISOString(), name, typeof args.receipt_id === "string" ? args.receipt_id : null,
51
+ doc.id ?? null, doc.number ?? null, doc.status ?? null, typeof doc.net_total === "number" ? doc.net_total : null,
52
+ ],
53
+ );
54
+ }
55
+ return result;
56
+ } catch (err) {
57
+ if (CHANGE_TOOLS.has(name)) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ const receiptId = typeof args.receipt_id === "string" ? args.receipt_id : null;
60
+ await db.run(`INSERT INTO refusals (id, at, tool, receipt_id, message) VALUES (?, ?, ?, ?, ?)`, [
61
+ randomUUID(), new Date().toISOString(), name, receiptId, message,
62
+ ]);
63
+ }
64
+ throw err;
65
+ }
66
+ }
package/src/index.ts CHANGED
@@ -8,10 +8,9 @@ import {
8
8
  } from "@modelcontextprotocol/sdk/types.js";
9
9
 
10
10
  import { createDb } from "./db.js";
11
- import { list_items, get_item } from "./tools/items.js";
12
- import { find_customers, get_customer } from "./tools/customers.js";
13
- import { create_quote, update_quote, send_quote, list_quotes, get_quote } from "./tools/quotes.js";
14
- import { convert_quote_to_order, list_orders, get_order } from "./tools/orders.js";
11
+ import { callTool } from "./dispatch.js";
12
+ import { getMode } from "./mode.js";
13
+ import { runCli } from "./cli.js";
15
14
 
16
15
  const RECEIPT_FIELD = {
17
16
  type: "string" as const,
@@ -201,13 +200,18 @@ const TOOL_DEFINITIONS = [
201
200
  },
202
201
  ] as const;
203
202
 
204
- type ToolName = (typeof TOOL_DEFINITIONS)[number]["name"];
205
-
206
203
  async function main() {
204
+ // `erp-mcp export` / `erp-mcp scenario …` are local operator commands, not MCP tools.
205
+ if (process.argv.length > 2) {
206
+ process.exit(await runCli(process.argv.slice(2)));
207
+ }
208
+
209
+ const mode = getMode();
207
210
  const db = await createDb();
211
+ console.error(`[erp-mcp] mode: ${mode}`);
208
212
 
209
213
  const server = new Server(
210
- { name: "erp", version: "0.1.0" },
214
+ { name: "erp", version: "0.2.0" },
211
215
  { capabilities: { tools: {} } }
212
216
  );
213
217
 
@@ -220,48 +224,7 @@ async function main() {
220
224
  const safeArgs = (args ?? {}) as Record<string, any>;
221
225
 
222
226
  try {
223
- let result: unknown;
224
-
225
- switch (name as ToolName) {
226
- case "list_items":
227
- result = await list_items(db, safeArgs);
228
- break;
229
- case "get_item":
230
- result = await get_item(db, safeArgs);
231
- break;
232
- case "find_customers":
233
- result = await find_customers(db, safeArgs);
234
- break;
235
- case "get_customer":
236
- result = await get_customer(db, safeArgs);
237
- break;
238
- case "list_quotes":
239
- result = await list_quotes(db, safeArgs);
240
- break;
241
- case "get_quote":
242
- result = await get_quote(db, safeArgs);
243
- break;
244
- case "list_orders":
245
- result = await list_orders(db, safeArgs);
246
- break;
247
- case "get_order":
248
- result = await get_order(db, safeArgs);
249
- break;
250
- case "create_quote":
251
- result = await create_quote(db, safeArgs);
252
- break;
253
- case "update_quote":
254
- result = await update_quote(db, safeArgs);
255
- break;
256
- case "send_quote":
257
- result = await send_quote(db, safeArgs);
258
- break;
259
- case "convert_quote_to_order":
260
- result = await convert_quote_to_order(db, safeArgs);
261
- break;
262
- default:
263
- throw new Error(`Unknown tool: ${name}`);
264
- }
227
+ const result = await callTool(db, mode, name, safeArgs);
265
228
 
266
229
  return {
267
230
  content: [
package/src/mode.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Connector mode: `simulation` answers from the built-in simulated ERP (SQLite);
3
+ * `live` would talk to a company's real ERP.
4
+ *
5
+ * The switch exists so a three-week test runs on exactly the connector, tools and
6
+ * profile that go live later — only the system behind the connector changes. No
7
+ * live adapter ships in 0.x, so `live` refuses every call loudly instead of
8
+ * silently falling back to the simulation: a test that believes it is live (or a
9
+ * go-live that is secretly still simulated) is the failure this must not hide.
10
+ */
11
+
12
+ export type ErpMode = "simulation" | "live";
13
+
14
+ export const MODES: readonly ErpMode[] = ["simulation", "live"];
15
+
16
+ /** Reads ERP_MODE (default `simulation`). An unknown value stops the server at start. */
17
+ export function getMode(env: NodeJS.ProcessEnv = process.env): ErpMode {
18
+ const raw = (env.ERP_MODE ?? "").trim().toLowerCase();
19
+ if (raw === "") return "simulation";
20
+ if ((MODES as readonly string[]).includes(raw)) return raw as ErpMode;
21
+ throw new Error(`ERP_MODE must be one of ${MODES.join(", ")} — got ${JSON.stringify(env.ERP_MODE)}.`);
22
+ }
23
+
24
+ export const LIVE_NOT_AVAILABLE =
25
+ "Refused: this ERP connector is in live mode, but no live ERP adapter is installed (0.x ships the simulation only). " +
26
+ "Nothing was read or changed. Set ERP_MODE=simulation, or install the adapter for your ERP.";