@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/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@humanagencyp/erp-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ERP MCP server for the Human Agency Protocol — quote-to-order: items, customers, quotes, orders",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/humanagencyprotocol/hap-erp-mcp.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"bin": {
|
|
16
|
+
"erp-mcp": "dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
20
|
+
"dev": "tsx src/index.ts",
|
|
21
|
+
"test": "vitest run --passWithNoTests",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"release:patch": "npm version patch && git push --follow-tags",
|
|
25
|
+
"release:minor": "npm version minor && git push --follow-tags",
|
|
26
|
+
"release:major": "npm version major && git push --follow-tags"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
30
|
+
"better-sqlite3": "^13.0.0",
|
|
31
|
+
"uuid": "^10.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"pg": "^8.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"pg": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"@types/uuid": "^10.0.0",
|
|
45
|
+
"tsup": "^8.0.0",
|
|
46
|
+
"tsx": "^4.0.0",
|
|
47
|
+
"typescript": "^5.3.0",
|
|
48
|
+
"vitest": "^2.0.0"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://humanagencyprotocol.org"
|
|
51
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync, statSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export interface Db {
|
|
6
|
+
run(sql: string, params?: any[]): Promise<void>;
|
|
7
|
+
get<T>(sql: string, params?: any[]): Promise<T | undefined>;
|
|
8
|
+
all<T>(sql: string, params?: any[]): Promise<T[]>;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SCHEMA = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
sku TEXT NOT NULL UNIQUE,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
unit TEXT NOT NULL DEFAULT 'pcs',
|
|
18
|
+
list_price REAL NOT NULL,
|
|
19
|
+
currency TEXT NOT NULL DEFAULT 'EUR',
|
|
20
|
+
stock INTEGER NOT NULL DEFAULT 0
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
email TEXT,
|
|
27
|
+
country TEXT,
|
|
28
|
+
currency TEXT NOT NULL DEFAULT 'EUR',
|
|
29
|
+
credit_limit REAL NOT NULL DEFAULT 0,
|
|
30
|
+
open_balance REAL NOT NULL DEFAULT 0,
|
|
31
|
+
payment_terms TEXT
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS quotes (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
number TEXT NOT NULL UNIQUE,
|
|
37
|
+
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
38
|
+
status TEXT CHECK(status IN ('draft','sent','converted','cancelled')) NOT NULL DEFAULT 'draft',
|
|
39
|
+
currency TEXT NOT NULL,
|
|
40
|
+
discount_pct REAL NOT NULL DEFAULT 0,
|
|
41
|
+
net_total REAL NOT NULL DEFAULT 0,
|
|
42
|
+
valid_until TEXT,
|
|
43
|
+
notes TEXT,
|
|
44
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
45
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
46
|
+
sent_at TEXT,
|
|
47
|
+
receipt_id TEXT
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS quote_lines (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
|
53
|
+
item_id TEXT NOT NULL REFERENCES items(id),
|
|
54
|
+
qty INTEGER NOT NULL,
|
|
55
|
+
list_price REAL NOT NULL,
|
|
56
|
+
line_total REAL NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
number TEXT NOT NULL UNIQUE,
|
|
62
|
+
quote_id TEXT NOT NULL REFERENCES quotes(id),
|
|
63
|
+
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
64
|
+
status TEXT CHECK(status IN ('confirmed')) NOT NULL DEFAULT 'confirmed',
|
|
65
|
+
net_total REAL NOT NULL,
|
|
66
|
+
currency TEXT NOT NULL,
|
|
67
|
+
requested_delivery TEXT,
|
|
68
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
69
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
70
|
+
receipt_id TEXT
|
|
71
|
+
);
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
/** Tables that carry an authorizing receipt_id (Content Provenance §4.1). */
|
|
75
|
+
const RECEIPT_ID_TABLES = ["quotes", "orders"];
|
|
76
|
+
|
|
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;
|
|
102
|
+
|
|
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
|
+
}
|
|
109
|
+
|
|
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);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// SQLite adapter using better-sqlite3 (synchronous API wrapped in async)
|
|
119
|
+
async function createSqliteDb(dbPath: string): Promise<Db> {
|
|
120
|
+
const { default: Database } = await import("better-sqlite3");
|
|
121
|
+
|
|
122
|
+
const db = new Database(dbPath);
|
|
123
|
+
db.pragma("foreign_keys = ON");
|
|
124
|
+
db.exec(SCHEMA);
|
|
125
|
+
seedSqlite(db);
|
|
126
|
+
|
|
127
|
+
// Migration: add receipt_id to pre-existing tables (Content Provenance §4.1).
|
|
128
|
+
// ALTER ... ADD COLUMN throws if it already exists, so guard on table_info.
|
|
129
|
+
for (const table of RECEIPT_ID_TABLES) {
|
|
130
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
131
|
+
if (!cols.some((c) => c.name === "receipt_id")) {
|
|
132
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN receipt_id TEXT`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
async run(sql: string, params: any[] = []): Promise<void> {
|
|
138
|
+
db.prepare(sql).run(...params);
|
|
139
|
+
},
|
|
140
|
+
async get<T>(sql: string, params: any[] = []): Promise<T | undefined> {
|
|
141
|
+
return db.prepare(sql).get(...params) as T | undefined;
|
|
142
|
+
},
|
|
143
|
+
async all<T>(sql: string, params: any[] = []): Promise<T[]> {
|
|
144
|
+
return db.prepare(sql).all(...params) as T[];
|
|
145
|
+
},
|
|
146
|
+
async close(): Promise<void> {
|
|
147
|
+
db.close();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Postgres adapter using pg Pool
|
|
153
|
+
async function createPostgresDb(connectionString: string): Promise<Db> {
|
|
154
|
+
const { default: pg } = await import("pg");
|
|
155
|
+
const pool = new pg.Pool({ connectionString });
|
|
156
|
+
|
|
157
|
+
// Adapt SQLite-style ? placeholders to Postgres $1, $2, ... style
|
|
158
|
+
function adaptSql(sql: string): string {
|
|
159
|
+
let i = 0;
|
|
160
|
+
return sql.replace(/\?/g, () => `$${++i}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pgSchema = SCHEMA.replace(/datetime\('now'\)/g, "NOW()");
|
|
164
|
+
|
|
165
|
+
const client = await pool.connect();
|
|
166
|
+
try {
|
|
167
|
+
await client.query(pgSchema);
|
|
168
|
+
// Migration: add receipt_id to pre-existing tables (Content Provenance §4.1).
|
|
169
|
+
for (const table of RECEIPT_ID_TABLES) {
|
|
170
|
+
await client.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS receipt_id TEXT`);
|
|
171
|
+
}
|
|
172
|
+
const { rows } = await client.query("SELECT COUNT(*)::int as count FROM items");
|
|
173
|
+
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
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
client.release();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
async run(sql: string, params: any[] = []): Promise<void> {
|
|
193
|
+
await pool.query(adaptSql(sql), params);
|
|
194
|
+
},
|
|
195
|
+
async get<T>(sql: string, params: any[] = []): Promise<T | undefined> {
|
|
196
|
+
const result = await pool.query(adaptSql(sql), params);
|
|
197
|
+
return result.rows[0] as T | undefined;
|
|
198
|
+
},
|
|
199
|
+
async all<T>(sql: string, params: any[] = []): Promise<T[]> {
|
|
200
|
+
const result = await pool.query(adaptSql(sql), params);
|
|
201
|
+
return result.rows as T[];
|
|
202
|
+
},
|
|
203
|
+
async close(): Promise<void> {
|
|
204
|
+
await pool.end();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function maybeBackupSqlite(dbPath: string): void {
|
|
210
|
+
const backupPath = dbPath.replace(/\.db$/, ".backup.db");
|
|
211
|
+
if (!existsSync(dbPath)) return;
|
|
212
|
+
|
|
213
|
+
const shouldBackup =
|
|
214
|
+
!existsSync(backupPath) ||
|
|
215
|
+
Date.now() - statSync(backupPath).mtimeMs > 24 * 60 * 60 * 1000;
|
|
216
|
+
|
|
217
|
+
if (shouldBackup) {
|
|
218
|
+
try {
|
|
219
|
+
copyFileSync(dbPath, backupPath);
|
|
220
|
+
console.error(`[erp-mcp] backup written to ${backupPath}`);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`[erp-mcp] backup failed: ${err}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function createDb(): Promise<Db> {
|
|
228
|
+
const databaseUrl = process.env.DATABASE_URL ?? "";
|
|
229
|
+
|
|
230
|
+
if (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://")) {
|
|
231
|
+
console.error("[erp-mcp] using Postgres");
|
|
232
|
+
return createPostgresDb(databaseUrl);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// SQLite path — honor HAP_DATA_DIR so docker (with a mounted /app/data) and
|
|
236
|
+
// local dev (~/.hap) write to the same place the gateway uses. The gateway
|
|
237
|
+
// 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 });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const dbPath = databaseUrl || join(hapDir, "erp.db");
|
|
244
|
+
maybeBackupSqlite(dbPath);
|
|
245
|
+
|
|
246
|
+
console.error(`[erp-mcp] using SQLite at ${dbPath}`);
|
|
247
|
+
return createSqliteDb(dbPath);
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
|
|
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";
|
|
15
|
+
|
|
16
|
+
const RECEIPT_FIELD = {
|
|
17
|
+
type: "string" as const,
|
|
18
|
+
description: "Suveren authorizing receipt id. Injected by the gateway — agents do not set this.",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const LINE_SCHEMA = {
|
|
22
|
+
type: "object" as const,
|
|
23
|
+
properties: {
|
|
24
|
+
item_id: { type: "string", description: "Item ID" },
|
|
25
|
+
qty: { type: "number", description: "Quantity (positive integer)" },
|
|
26
|
+
},
|
|
27
|
+
required: ["item_id", "qty"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TOOL_DEFINITIONS = [
|
|
31
|
+
// --- Reads ---
|
|
32
|
+
{
|
|
33
|
+
name: "list_items",
|
|
34
|
+
description: "List/search catalog items (sku, name, unit, list price, stock)",
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
query: { type: "string", description: "Text search across sku and name" },
|
|
39
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" },
|
|
40
|
+
},
|
|
41
|
+
required: [],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "get_item",
|
|
46
|
+
description: "Get a single item, including stock and list price",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
id: { type: "string", description: "Item ID" },
|
|
51
|
+
},
|
|
52
|
+
required: ["id"],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "find_customers",
|
|
57
|
+
description: "Search customers by name or email",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
query: { type: "string", description: "Text search across name and email" },
|
|
62
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" },
|
|
63
|
+
},
|
|
64
|
+
required: [],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "get_customer",
|
|
69
|
+
description: "Get a single customer, including credit limit, open balance, and available credit",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
id: { type: "string", description: "Customer ID" },
|
|
74
|
+
},
|
|
75
|
+
required: ["id"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "list_quotes",
|
|
80
|
+
description: "List quotes, optionally filtered by status or customer",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
status: { type: "string", enum: ["draft", "sent", "converted", "cancelled"], description: "Filter by status" },
|
|
85
|
+
customer_id: { type: "string", description: "Filter by customer ID" },
|
|
86
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" },
|
|
87
|
+
},
|
|
88
|
+
required: [],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "get_quote",
|
|
93
|
+
description: "Get a single quote with its lines",
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
id: { type: "string", description: "Quote ID" },
|
|
98
|
+
},
|
|
99
|
+
required: ["id"],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "list_orders",
|
|
104
|
+
description: "List orders, optionally filtered by customer",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
customer_id: { type: "string", description: "Filter by customer ID" },
|
|
109
|
+
limit: { type: "number", description: "Maximum results to return (default: 50)" },
|
|
110
|
+
},
|
|
111
|
+
required: [],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "get_order",
|
|
116
|
+
description: "Get a single order with its lines",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
id: { type: "string", description: "Order ID" },
|
|
121
|
+
},
|
|
122
|
+
required: ["id"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// --- Changes ---
|
|
127
|
+
{
|
|
128
|
+
name: "create_quote",
|
|
129
|
+
description:
|
|
130
|
+
"Create a draft quote for a customer. The connector recomputes the net total from the lines and the discount " +
|
|
131
|
+
"and refuses the call if the declared value, discount, or currency does not match.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
customer_id: { type: "string", description: "Customer ID" },
|
|
136
|
+
lines: { type: "array", items: LINE_SCHEMA, description: "Quote lines" },
|
|
137
|
+
discount_pct: { type: "number", description: "Document discount against list price, in percent (0-100)" },
|
|
138
|
+
value: { type: "number", description: "Net document total — must equal sum(qty*list_price)*(1-discount_pct/100)" },
|
|
139
|
+
currency: { type: "string", description: "Document currency — must match the customer's currency" },
|
|
140
|
+
valid_until: { type: "string", description: "ISO 8601 date the quote is valid until (optional)" },
|
|
141
|
+
notes: { type: "string", description: "Free-form notes (optional)" },
|
|
142
|
+
receipt_id: RECEIPT_FIELD,
|
|
143
|
+
},
|
|
144
|
+
required: ["customer_id", "lines", "discount_pct", "value", "currency"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "update_quote",
|
|
149
|
+
description:
|
|
150
|
+
"Update a draft quote (lines, discount, valid_until, notes). Only draft quotes can be updated. The connector " +
|
|
151
|
+
"recomputes the net total and refuses the call if the declared value, discount, or currency does not match.",
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: "object",
|
|
154
|
+
properties: {
|
|
155
|
+
id: { type: "string", description: "Quote ID" },
|
|
156
|
+
lines: { type: "array", items: LINE_SCHEMA, description: "Replacement quote lines (optional — omit to keep existing lines)" },
|
|
157
|
+
discount_pct: { type: "number", description: "Document discount against list price, in percent (0-100)" },
|
|
158
|
+
value: { type: "number", description: "Net document total — must equal sum(qty*list_price)*(1-discount_pct/100)" },
|
|
159
|
+
currency: { type: "string", description: "Document currency — must match the quote's currency" },
|
|
160
|
+
valid_until: { type: "string", description: "ISO 8601 date the quote is valid until (optional)" },
|
|
161
|
+
notes: { type: "string", description: "Free-form notes (optional)" },
|
|
162
|
+
receipt_id: RECEIPT_FIELD,
|
|
163
|
+
},
|
|
164
|
+
required: ["id", "discount_pct", "value", "currency"],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "send_quote",
|
|
169
|
+
description:
|
|
170
|
+
"Send a draft quote to the customer (draft -> sent). Simulated: marks the quote sent and records sent_at. " +
|
|
171
|
+
"The declared value, discount, and currency must match the quote as stored.",
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
id: { type: "string", description: "Quote ID" },
|
|
176
|
+
value: { type: "number", description: "Net document total — must match the quote's stored net total" },
|
|
177
|
+
discount_pct: { type: "number", description: "Must match the quote's stored discount — cannot change at send time" },
|
|
178
|
+
currency: { type: "string", description: "Must match the quote's stored currency" },
|
|
179
|
+
receipt_id: RECEIPT_FIELD,
|
|
180
|
+
},
|
|
181
|
+
required: ["id", "value", "discount_pct", "currency"],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "convert_quote_to_order",
|
|
186
|
+
description:
|
|
187
|
+
"Convert a sent quote into a confirmed order (sent -> order). Reserves stock for every line and refuses if the " +
|
|
188
|
+
"customer's credit limit would be exceeded or any line lacks sufficient stock.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {
|
|
192
|
+
id: { type: "string", description: "Quote ID" },
|
|
193
|
+
value: { type: "number", description: "Net document total — must match the quote's stored net total" },
|
|
194
|
+
discount_pct: { type: "number", description: "Must match the quote's stored discount — cannot change at conversion time" },
|
|
195
|
+
currency: { type: "string", description: "Must match the quote's stored currency" },
|
|
196
|
+
requested_delivery: { type: "string", description: "Requested delivery date (ISO 8601, optional)" },
|
|
197
|
+
receipt_id: RECEIPT_FIELD,
|
|
198
|
+
},
|
|
199
|
+
required: ["id", "value", "discount_pct", "currency"],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
] as const;
|
|
203
|
+
|
|
204
|
+
type ToolName = (typeof TOOL_DEFINITIONS)[number]["name"];
|
|
205
|
+
|
|
206
|
+
async function main() {
|
|
207
|
+
const db = await createDb();
|
|
208
|
+
|
|
209
|
+
const server = new Server(
|
|
210
|
+
{ name: "erp", version: "0.1.0" },
|
|
211
|
+
{ capabilities: { tools: {} } }
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
215
|
+
return { tools: TOOL_DEFINITIONS };
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
219
|
+
const { name, arguments: args } = request.params;
|
|
220
|
+
const safeArgs = (args ?? {}) as Record<string, any>;
|
|
221
|
+
|
|
222
|
+
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
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
content: [
|
|
268
|
+
{
|
|
269
|
+
type: "text",
|
|
270
|
+
text: JSON.stringify(result, null, 2),
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
};
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
276
|
+
console.error(`[erp-mcp] tool error (${name}):`, message);
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: "text",
|
|
281
|
+
text: JSON.stringify({ error: message }, null, 2),
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
isError: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
process.on("SIGINT", async () => {
|
|
290
|
+
await db.close();
|
|
291
|
+
process.exit(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const transport = new StdioServerTransport();
|
|
295
|
+
await server.connect(transport);
|
|
296
|
+
console.error("[erp-mcp] server started");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
main().catch((err) => {
|
|
300
|
+
console.error("[erp-mcp] fatal:", err);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway enforces bounds (value_max, discount_max, ...) on the DECLARED
|
|
3
|
+
* `value`/`discount_pct`/`currency` fields of a tool call — it never inspects
|
|
4
|
+
* line items. That makes the declaration the security boundary: if the
|
|
5
|
+
* connector executed a document whose real total didn't match what was
|
|
6
|
+
* declared and bound-checked, the enforcement would be checking a number
|
|
7
|
+
* that has nothing to do with what actually happened. Every change tool in
|
|
8
|
+
* this connector must recompute the real total from the lines and refuse the
|
|
9
|
+
* call outright when the declaration doesn't match — never merely proceed
|
|
10
|
+
* with the "real" number instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Tolerance for float rounding drift, in currency units. */
|
|
14
|
+
export const VALUE_TOLERANCE = 0.01;
|
|
15
|
+
|
|
16
|
+
export function roundToCents(n: number): number {
|
|
17
|
+
return Math.round(n * 100) / 100;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PricedLine {
|
|
21
|
+
qty: number;
|
|
22
|
+
list_price: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Net document total = sum(qty × list_price) × (1 − discount_pct/100), rounded to cents. */
|
|
26
|
+
export function computeNetTotal(lines: PricedLine[], discountPct: number): number {
|
|
27
|
+
const gross = lines.reduce((sum, l) => sum + l.qty * l.list_price, 0);
|
|
28
|
+
return roundToCents(gross * (1 - discountPct / 100));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function valuesMatch(a: number, b: number): boolean {
|
|
32
|
+
return Math.abs(a - b) <= VALUE_TOLERANCE;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class RefusalError extends Error {}
|
|
36
|
+
|
|
37
|
+
/** Refuse naming the field, the declared value, and the computed/expected value. */
|
|
38
|
+
export function refuse(field: string, declared: unknown, expected: unknown, extra?: string): never {
|
|
39
|
+
const suffix = extra ? ` ${extra}` : "";
|
|
40
|
+
throw new RefusalError(
|
|
41
|
+
`Refused: ${field} mismatch — declared ${JSON.stringify(declared)}, expected ${JSON.stringify(expected)}.${suffix}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Db } from "../db.js";
|
|
2
|
+
import { roundToCents } from "../money.js";
|
|
3
|
+
|
|
4
|
+
export interface Customer {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
email: string | null;
|
|
8
|
+
country: string | null;
|
|
9
|
+
currency: string;
|
|
10
|
+
credit_limit: number;
|
|
11
|
+
open_balance: number;
|
|
12
|
+
payment_terms: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function withAvailableCredit(row: Customer) {
|
|
16
|
+
return { ...row, available_credit: roundToCents(row.credit_limit - row.open_balance) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function find_customers(db: Db, args: Record<string, any>) {
|
|
20
|
+
const { query, limit = 50 } = args;
|
|
21
|
+
|
|
22
|
+
const conditions: string[] = [];
|
|
23
|
+
const params: any[] = [];
|
|
24
|
+
|
|
25
|
+
if (query) {
|
|
26
|
+
conditions.push("(name LIKE ? OR email LIKE ?)");
|
|
27
|
+
const like = `%${query}%`;
|
|
28
|
+
params.push(like, like);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
32
|
+
params.push(limit);
|
|
33
|
+
|
|
34
|
+
const rows = await db.all<Customer>(`SELECT * FROM customers ${where} ORDER BY name LIMIT ?`, params);
|
|
35
|
+
return rows.map(withAvailableCredit);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function get_customer(db: Db, args: Record<string, any>) {
|
|
39
|
+
const { id } = args;
|
|
40
|
+
const row = await db.get<Customer>("SELECT * FROM customers WHERE id = ?", [id]);
|
|
41
|
+
if (!row) throw new Error(`Unknown customer id: ${id}`);
|
|
42
|
+
return withAvailableCredit(row);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Shared lookup used by the quote/order tools — throws the same "unknown id" shape as the read tool. */
|
|
46
|
+
export async function requireCustomer(db: Db, id: string): Promise<Customer> {
|
|
47
|
+
const row = await db.get<Customer>("SELECT * FROM customers WHERE id = ?", [id]);
|
|
48
|
+
if (!row) throw new Error(`Unknown customer id: ${id}`);
|
|
49
|
+
return row;
|
|
50
|
+
}
|