plm-upload-mcp 1.1.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +134 -103
  2. package/index.mjs +771 -496
  3. package/package.json +22 -22
package/index.mjs CHANGED
@@ -1,496 +1,771 @@
1
- #!/usr/bin/env node
2
- /**
3
- * plm-upload-mcp — read the LionsBot PLM and bulk-upload local files
4
- * (SolidWorks STEP/PDF exports) into it, matched to a component by SKU.
5
- *
6
- * Runs locally on each engineer's machine (stdio), so it reads their exported
7
- * files straight off disk. It authenticates as the engineer with their own PLM
8
- * login (GoTrue password grant) and every uploaded file is tagged with who
9
- * uploaded it, server-side, by the plm-edge `upload` action. The read-only
10
- * tools query PostgREST directly with the engineer's token, so RLS applies
11
- * exactly as it does in the PLM web app — they only ever see what they may see.
12
- *
13
- * Config (env):
14
- * PLM_EMAIL, PLM_PASSWORD — the engineer's PLM/hub login (required)
15
- * PLM_GATEWAY, PLM_ANON_KEY, PLM_EDGE_URL — override the baked prod defaults
16
- */
17
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
18
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
19
- import { z } from 'zod'
20
- import { readFile } from 'node:fs/promises'
21
- import { basename, extname } from 'node:path'
22
-
23
- // Prod defaults — same values the PLM web app ships with. Overridable via env.
24
- const GATEWAY = process.env.PLM_GATEWAY || 'https://gateway-production-c13d.up.railway.app'
25
- const ANON_KEY =
26
- process.env.PLM_ANON_KEY ||
27
- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg0NzE1NTExLCJleHAiOjIxMDAwNzU1MTF9.kqtdfumqx4EjRyJ15Ld0gSZBYdeE0VnpmlnJy68qQT0'
28
- const EDGE = process.env.PLM_EDGE_URL || 'https://plm-edge-production.up.railway.app'
29
- const EMAIL = process.env.PLM_EMAIL
30
- const PASSWORD = process.env.PLM_PASSWORD
31
-
32
- const MIME = {
33
- '.step': 'application/step',
34
- '.stp': 'application/step',
35
- '.pdf': 'application/pdf',
36
- '.sldprt': 'application/octet-stream',
37
- '.sldasm': 'application/octet-stream',
38
- '.dxf': 'application/dxf',
39
- '.dwg': 'application/acad',
40
- '.igs': 'model/iges',
41
- '.iges': 'model/iges',
42
- '.png': 'image/png',
43
- '.jpg': 'image/jpeg',
44
- '.jpeg': 'image/jpeg',
45
- }
46
- const mimeFor = (name) => MIME[extname(name).toLowerCase()] || 'application/octet-stream'
47
-
48
- // Supabase Storage rejects object keys with chars outside its safe set (notably
49
- // '[' ']', which the common "[SKU] Name" convention starts with) -> the store
50
- // call 400s with "storage upload failed: 400". The key is internal/UUID-prefixed
51
- // and never shown, so sanitize it to the safe set; the Drive display name (the
52
- // separate `upload` filename) keeps the original, brackets and all.
53
- const storageKeyName = (name) => name.replace(/[^\w !\-.*'()&$@=;:+,?/]/g, '_')
54
-
55
- // --- Auth: cache one access token, re-grant when it's near expiry. -----------
56
- let cached = { token: null, exp: 0 }
57
- async function getToken() {
58
- if (cached.token && Date.now() < cached.exp - 60_000) return cached.token
59
- if (!EMAIL || !PASSWORD) throw new Error('PLM_EMAIL and PLM_PASSWORD must be set in the MCP config env.')
60
- const resp = await fetch(`${GATEWAY}/auth/v1/token?grant_type=password`, {
61
- method: 'POST',
62
- headers: { 'content-type': 'application/json', apikey: ANON_KEY },
63
- body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
64
- })
65
- const data = await resp.json().catch(() => null)
66
- if (!resp.ok || !data?.access_token) {
67
- throw new Error(`PLM sign-in failed for ${EMAIL}: ${data?.error_description || data?.msg || resp.status}`)
68
- }
69
- cached = { token: data.access_token, exp: Date.now() + (data.expires_in ?? 3600) * 1000 }
70
- return cached.token
71
- }
72
-
73
- async function edgeJson(body) {
74
- const token = await getToken()
75
- const resp = await fetch(`${EDGE}/drive-library`, {
76
- method: 'POST',
77
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
78
- body: JSON.stringify(body),
79
- })
80
- const data = await resp.json().catch(() => null)
81
- if (!resp.ok) throw new Error(data?.error || `edge ${body.action} failed: http ${resp.status}`)
82
- return data
83
- }
84
-
85
- // --- Read path: PostgREST GET with the engineer's token (RLS-enforced). -------
86
- // Same data path the PLM web app uses (supabase-js -> /rest/v1). The anon key is
87
- // the gateway apikey; the bearer token is the signed-in engineer, so row-level
88
- // security decides what comes back — these tools can never read past the user.
89
- async function restGet(table, params = {}, { count = false } = {}) {
90
- const token = await getToken()
91
- const qs = new URLSearchParams(params).toString()
92
- const headers = { apikey: ANON_KEY, authorization: `Bearer ${token}` }
93
- if (count) headers['prefer'] = 'count=exact'
94
- const resp = await fetch(`${GATEWAY}/rest/v1/${table}?${qs}`, { headers })
95
- const data = await resp.json().catch(() => null)
96
- if (!resp.ok) throw new Error(data?.message || data?.error || `read ${table} failed: http ${resp.status}`)
97
- // content-range is "start-end/total" (or "*/total") when count=exact.
98
- const total = count ? Number((resp.headers.get('content-range') || '').split('/')[1]) || null : null
99
- return { rows: Array.isArray(data) ? data : [], total }
100
- }
101
-
102
- // bucket -> components.staging filter. 'main' = confirmed, 'staging' = unconfirmed.
103
- const stagingFilter = (bucket) =>
104
- bucket === 'staging' ? { staging: 'eq.true' } : bucket === 'main' ? { staging: 'eq.false' } : {}
105
-
106
- /** store bytes → storagePath, then upload → mirror into the SKU's Drive folder. */
107
- async function uploadOne({ path, sku, skuName, category, bucket }) {
108
- const bytes = await readFile(path) // throws ENOENT with the path — surfaced per-file below
109
- const filename = basename(path)
110
- const mimeType = mimeFor(filename)
111
- const token = await getToken()
112
-
113
- // Store under a storage-safe key; the Drive file keeps the original filename below.
114
- const params = new URLSearchParams({ action: 'store', sku, filename: storageKeyName(filename), mimeType })
115
- const sResp = await fetch(`${EDGE}/drive-library?${params}`, {
116
- method: 'POST',
117
- headers: { 'content-type': mimeType, authorization: `Bearer ${token}` },
118
- body: bytes,
119
- })
120
- const stored = await sResp.json().catch(() => null)
121
- if (!sResp.ok || !stored?.storagePath) {
122
- throw new Error(stored?.error || `store failed: http ${sResp.status}`)
123
- }
124
-
125
- const synced = await edgeJson({
126
- action: 'upload',
127
- sku,
128
- skuName: skuName ?? '',
129
- storagePath: stored.storagePath,
130
- filename,
131
- mimeType,
132
- bucket,
133
- })
134
- if (synced?.error) throw new Error(synced.error)
135
-
136
- if (category && category !== 'Default' && synced?.driveFileId) {
137
- await edgeJson({ action: 'set-category', fileId: synced.driveFileId, category }).catch(() => {})
138
- }
139
-
140
- return {
141
- file: filename,
142
- sku,
143
- driveFileId: synced.driveFileId,
144
- driveWebViewLink: synced.driveWebViewLink,
145
- replaced: !!synced.replaced,
146
- category: category && category !== 'Default' ? category : undefined,
147
- }
148
- }
149
-
150
- // --- Server ------------------------------------------------------------------
151
- const server = new McpServer({ name: 'plm-upload', version: '1.1.0' })
152
-
153
- server.registerTool(
154
- 'upload_files',
155
- {
156
- title: 'Upload files to PLM by SKU',
157
- description:
158
- "Bulk-upload local files (e.g. SolidWorks STEP/PDF exports) into the LionsBot PLM. " +
159
- "Each file is attached to a component by its SKU: it lands in PLM storage and is mirrored " +
160
- "into that SKU's Google Drive folder (folder auto-created if missing). Re-uploading a file " +
161
- "with the same name replaces it in place rather than duplicating. You must supply the SKU " +
162
- "for each file — the caller's own tooling (e.g. a SolidWorks MCP) already knows each part's SKU.",
163
- inputSchema: {
164
- files: z
165
- .array(
166
- z.object({
167
- path: z.string().describe('Absolute local path to the file to upload.'),
168
- sku: z.string().describe('Component SKU this file belongs to, e.g. MNT-1234-A0.'),
169
- skuName: z
170
- .string()
171
- .optional()
172
- .describe("Component display name — used only if the SKU's Drive folder must be created."),
173
- category: z
174
- .enum(['Default', 'Archive', 'Compliance', 'Ops'])
175
- .optional()
176
- .describe('PLM file category tag. Defaults to Default.'),
177
- }),
178
- )
179
- .min(1)
180
- .describe('The files to upload, each paired with its target SKU.'),
181
- bucket: z
182
- .enum(['main', 'staging'])
183
- .optional()
184
- .describe("Which shared drive: 'main' (confirmed components, default) or 'staging' (unconfirmed)."),
185
- },
186
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
187
- },
188
- async ({ files, bucket = 'main' }) => {
189
- const results = []
190
- for (const f of files) {
191
- try {
192
- results.push({ ok: true, ...(await uploadOne({ ...f, bucket })) })
193
- } catch (e) {
194
- results.push({ ok: false, file: basename(f.path), sku: f.sku, error: e instanceof Error ? e.message : String(e) })
195
- }
196
- }
197
- const ok = results.filter((r) => r.ok).length
198
- const lines = results.map((r) =>
199
- r.ok
200
- ? `✓ ${r.file} → ${r.sku}${r.replaced ? ' (replaced)' : ''}${r.category ? ` [${r.category}]` : ''}`
201
- : `✗ ${r.file} → ${r.sku}: ${r.error}`,
202
- )
203
- return {
204
- content: [{ type: 'text', text: `Uploaded ${ok}/${results.length} to ${bucket}.\n${lines.join('\n')}` }],
205
- structuredContent: { uploaded: ok, total: results.length, bucket, results },
206
- }
207
- },
208
- )
209
-
210
- server.registerTool(
211
- 'list_component_files',
212
- {
213
- title: 'List a component\'s PLM files',
214
- description: "Lists the files currently in a component's Google Drive folder in the PLM, by SKU.",
215
- inputSchema: {
216
- sku: z.string().describe('Component SKU, e.g. MNT-1234-A0.'),
217
- bucket: z.enum(['main', 'staging']).optional().describe("'main' (default) or 'staging'."),
218
- },
219
- annotations: { readOnlyHint: true, openWorldHint: true },
220
- },
221
- async ({ sku, bucket = 'main' }) => {
222
- const data = await edgeJson({ action: 'list', sku, bucket })
223
- const files = Array.isArray(data?.files) ? data.files : []
224
- if (!data?.connected) {
225
- return { content: [{ type: 'text', text: `Drive not connected or no folder for ${sku}.` }], structuredContent: { connected: false, files: [] } }
226
- }
227
- const lines = files.map((x) => `• ${x.name}${x.appProperties?.plmCategory ? ` [${x.appProperties.plmCategory}]` : ''}`)
228
- return {
229
- content: [{ type: 'text', text: files.length ? `${sku} — ${files.length} file(s):\n${lines.join('\n')}` : `${sku} — folder is empty.` }],
230
- structuredContent: { connected: true, folder: data.folder ?? null, files },
231
- }
232
- },
233
- )
234
-
235
- // === Read-only tools (RLS-enforced via the engineer's token) =================
236
-
237
- server.registerTool(
238
- 'list_sku_folders',
239
- {
240
- title: 'List all SKU folders',
241
- description:
242
- "List every component SKU in the PLM with its Google Drive folder — the SKU→folder map. " +
243
- "Returns SKU, name, category, type, revision, status, whether it is a staging (unconfirmed) " +
244
- "or main (confirmed) part, and the Drive folder id/URL. Read-only. Use it to discover which " +
245
- "SKUs exist and where each part's files live, or to find the folder for a SKU before uploading.",
246
- inputSchema: {
247
- bucket: z
248
- .enum(['main', 'staging', 'all'])
249
- .optional()
250
- .describe("Filter by drive: 'main' (confirmed), 'staging' (unconfirmed), or 'all' (default)."),
251
- category: z.string().optional().describe('Optional exact category code filter, e.g. MNT.'),
252
- with_folder_only: z
253
- .boolean()
254
- .optional()
255
- .describe('If true, only SKUs that already have a Drive folder. Default false.'),
256
- limit: z.number().int().min(1).max(2000).optional().describe('Max rows (default 1000).'),
257
- offset: z.number().int().min(0).optional().describe('Row offset for paging (default 0).'),
258
- },
259
- annotations: { readOnlyHint: true, openWorldHint: true },
260
- },
261
- async ({ bucket = 'all', category, with_folder_only = false, limit = 1000, offset = 0 }) => {
262
- const params = {
263
- select: 'sku,name,category,sku_type,revision,status,staging,drive_folder_id,drive_folder_url',
264
- order: 'sku.asc',
265
- limit: String(limit),
266
- offset: String(offset),
267
- ...stagingFilter(bucket),
268
- }
269
- if (category) params.category = `eq.${category}`
270
- if (with_folder_only) params.drive_folder_id = 'not.is.null'
271
- const { rows, total } = await restGet('components', params, { count: true })
272
- const withFolder = rows.filter((r) => r.drive_folder_id).length
273
- const lines = rows
274
- .slice(0, 50)
275
- .map((r) => `• ${r.sku} — ${r.name}${r.staging ? ' [staging]' : ''}${r.drive_folder_id ? '' : ' (no folder)'}`)
276
- const more = rows.length > 50 ? `\n… ${rows.length - 50} more (of ${total ?? rows.length}).` : ''
277
- return {
278
- content: [
279
- {
280
- type: 'text',
281
- text: `${rows.length} SKU(s) (${withFolder} with a Drive folder) of ${total ?? '?'} total in ${bucket}.\n${lines.join('\n')}${more}`,
282
- },
283
- ],
284
- structuredContent: { bucket, count: rows.length, total, folders: rows },
285
- }
286
- },
287
- )
288
-
289
- server.registerTool(
290
- 'get_component_metadata',
291
- {
292
- title: 'Get component metadata',
293
- description:
294
- "Read full component metadata from the PLM component database. Every column — identity " +
295
- "(sku, name, category, type, revision, status), cost/supply (unit_cost, moq, lead time, " +
296
- "preferred_supplier), drawing-extracted fields (material, colour, manufacturing_method, " +
297
- "release_status), CAD provenance, compliance (RoHS/REACH/CE), and Drive folder — plus the " +
298
- "extract_meta provenance block. Pass a single `sku` for one part, or omit it to page through " +
299
- "all parts. Read-only.",
300
- inputSchema: {
301
- sku: z.string().optional().describe('Exact SKU for a single component, e.g. MNT-1234-A0.'),
302
- search: z.string().optional().describe('Case-insensitive substring match on SKU or name.'),
303
- bucket: z.enum(['main', 'staging', 'all']).optional().describe("'main', 'staging', or 'all' (default)."),
304
- category: z.string().optional().describe('Optional exact category code filter, e.g. MNT.'),
305
- status: z.enum(['active', 'obsolete']).optional().describe('Optional lifecycle status filter.'),
306
- limit: z.number().int().min(1).max(500).optional().describe('Max rows when listing (default 100).'),
307
- offset: z.number().int().min(0).optional().describe('Row offset for paging (default 0).'),
308
- },
309
- annotations: { readOnlyHint: true, openWorldHint: true },
310
- },
311
- async ({ sku, search, bucket = 'all', category, status, limit = 100, offset = 0 }) => {
312
- const params = { select: '*', order: 'sku.asc', ...stagingFilter(bucket) }
313
- if (sku) {
314
- params.sku = `eq.${sku}`
315
- params.limit = '1'
316
- } else {
317
- params.limit = String(limit)
318
- params.offset = String(offset)
319
- if (search) params.or = `(sku.ilike.*${search}*,name.ilike.*${search}*)`
320
- if (category) params.category = `eq.${category}`
321
- if (status) params.status = `eq.${status}`
322
- }
323
- const { rows, total } = await restGet('components', params, { count: !sku })
324
- if (sku) {
325
- const c = rows[0]
326
- if (!c) return { content: [{ type: 'text', text: `No component with SKU ${sku}.` }], structuredContent: { component: null } }
327
- return {
328
- content: [{ type: 'text', text: `${c.sku} — ${c.name} (${c.category}, rev ${c.revision ?? '—'}, ${c.status}${c.staging ? ', staging' : ''}).` }],
329
- structuredContent: { component: c },
330
- }
331
- }
332
- const lines = rows.slice(0, 50).map((c) => `• ${c.sku} — ${c.name} (${c.status}${c.staging ? ', staging' : ''})`)
333
- const more = rows.length > 50 ? `\n… ${rows.length - 50} more shown truncated (of ${total ?? rows.length}).` : ''
334
- return {
335
- content: [{ type: 'text', text: `${rows.length} component(s) of ${total ?? '?'} total in ${bucket}.\n${lines.join('\n')}${more}` }],
336
- structuredContent: { bucket, count: rows.length, total, components: rows },
337
- }
338
- },
339
- )
340
-
341
- server.registerTool(
342
- 'list_bom_extract_metadata',
343
- {
344
- title: 'List extracted BOM/drawing metadata',
345
- description:
346
- "List the metadata machine-extracted from each part's drawing/BOM PDF (the PLM `extract` step) " +
347
- "across all components. For each SKU returns its extract_meta: the Lionsbot-custom-drawing flag, " +
348
- "the SKU/drawing match check, the per-field extracted values (release status, material, colour, " +
349
- "manufacturing method, CAD revision, drawn/designed by), and the source file + who/when it was " +
350
- "extracted. Only parts that have been extracted are returned. Read-only.",
351
- inputSchema: {
352
- sku: z.string().optional().describe('Restrict to a single SKU, e.g. MNT-1234-A0.'),
353
- bucket: z.enum(['main', 'staging', 'all']).optional().describe("'main', 'staging', or 'all' (default)."),
354
- limit: z.number().int().min(1).max(2000).optional().describe('Max rows (default 1000).'),
355
- offset: z.number().int().min(0).optional().describe('Row offset for paging (default 0).'),
356
- },
357
- annotations: { readOnlyHint: true, openWorldHint: true },
358
- },
359
- async ({ sku, bucket = 'all', limit = 1000, offset = 0 }) => {
360
- const params = {
361
- select: 'sku,name,category,staging,material,colour,manufacturing_method,release_status,cad_revision,cad_drawn_by,cad_designed_by,extract_meta',
362
- order: 'sku.asc',
363
- limit: String(limit),
364
- offset: String(offset),
365
- ...stagingFilter(bucket),
366
- }
367
- // extract_meta '{}' means never extracted — exclude it.
368
- params.extract_meta = 'neq.{}'
369
- if (sku) params.sku = `eq.${sku}`
370
- const { rows, total } = await restGet('components', params, { count: true })
371
- const lines = rows.slice(0, 50).map((r) => {
372
- const lb = r.extract_meta?.is_lionsbot === true ? ' [LB]' : ''
373
- const m = r.extract_meta?.sku_check?.match === true ? ' ✓match' : r.extract_meta?.sku_check ? ' ✗mismatch' : ''
374
- return `• ${r.sku} — ${r.name}${lb}${m}`
375
- })
376
- const more = rows.length > 50 ? `\n… ${rows.length - 50} more (of ${total ?? rows.length}).` : ''
377
- return {
378
- content: [{ type: 'text', text: `${rows.length} extracted part(s) of ${total ?? '?'} in ${bucket}.\n${lines.join('\n')}${more}` }],
379
- structuredContent: { bucket, count: rows.length, total, extracted: rows },
380
- }
381
- },
382
- )
383
-
384
- server.registerTool(
385
- 'list_boms',
386
- {
387
- title: 'List all BOMs (products + staging)',
388
- description:
389
- "List the bills of materials across all products. Each product carries its BOM(s) with the line " +
390
- "items — component SKU, name, quantity, unit, reference designator — and a derived `staging` flag: " +
391
- "a product is 'staging' while its working BOM still contains any staging (unconfirmed) component, " +
392
- "otherwise 'main'. By default returns working BOMs only (set include_snapshots to include saved " +
393
- "snapshots) with their rows. Filter to one product with product_sku. Read-only.",
394
- inputSchema: {
395
- product_sku: z.string().optional().describe('Restrict to one product by its SAL SKU, e.g. SAL-0R3-3001.'),
396
- classification: z
397
- .enum(['main', 'staging', 'all'])
398
- .optional()
399
- .describe("Only products in this derived class: 'main', 'staging', or 'all' (default)."),
400
- include_snapshots: z.boolean().optional().describe('Include saved snapshot BOMs, not just the working BOM. Default false.'),
401
- include_rows: z.boolean().optional().describe('Include BOM line items. Default true.'),
402
- max_rows_per_bom: z.number().int().min(1).max(5000).optional().describe('Cap rows fetched per BOM (default 2000).'),
403
- },
404
- annotations: { readOnlyHint: true, openWorldHint: true },
405
- },
406
- async ({ product_sku, classification = 'all', include_snapshots = false, include_rows = true, max_rows_per_bom = 2000 }) => {
407
- // 1) BOM headers + their product.
408
- const bomParams = {
409
- select: 'id,name,is_snapshot,snapshot_label,product_id,created_at,product:products(sku,name,status)',
410
- order: 'created_at.asc',
411
- }
412
- if (!include_snapshots) bomParams.is_snapshot = 'eq.false'
413
- const { rows: boms } = await restGet('boms', bomParams, {})
414
- let scoped = product_sku ? boms.filter((b) => b.product?.sku === product_sku) : boms
415
- if (!scoped.length) {
416
- return { content: [{ type: 'text', text: product_sku ? `No BOMs for product ${product_sku}.` : 'No BOMs found.' }], structuredContent: { products: [] } }
417
- }
418
-
419
- // 2) Rows for those BOMs, embedding each component's SKU + staging flag.
420
- const byBom = new Map(scoped.map((b) => [b.id, []]))
421
- if (include_rows) {
422
- const ids = scoped.map((b) => b.id)
423
- // Chunk the in-list so the URL stays a sane length.
424
- for (let i = 0; i < ids.length; i += 50) {
425
- const chunk = ids.slice(i, i + 50)
426
- const { rows } = await restGet('bom_rows', {
427
- select:
428
- 'bom_id,quantity,unit,reference_designator,sort_order,row_type,component:components(sku,name,category,revision,status,staging)',
429
- bom_id: `in.(${chunk.join(',')})`,
430
- order: 'bom_id.asc,sort_order.asc',
431
- limit: String(max_rows_per_bom * chunk.length),
432
- })
433
- for (const r of rows) if (byBom.has(r.bom_id)) byBom.get(r.bom_id).push(r)
434
- }
435
- }
436
-
437
- // 3) Group by product; derive staging from the working BOM's rows.
438
- const products = new Map()
439
- for (const b of scoped) {
440
- const key = b.product?.sku ?? b.product_id
441
- if (!products.has(key)) {
442
- products.set(key, { product: b.product ?? { sku: null, id: b.product_id }, staging: false, boms: [] })
443
- }
444
- const rows = byBom.get(b.id) ?? []
445
- const bomStaging = rows.some((r) => r.component?.staging === true)
446
- // A product's class is decided by its working (non-snapshot) BOM.
447
- if (!b.is_snapshot && bomStaging) products.get(key).staging = true
448
- products.get(key).boms.push({
449
- id: b.id,
450
- name: b.name,
451
- is_snapshot: b.is_snapshot,
452
- snapshot_label: b.snapshot_label,
453
- row_count: rows.length,
454
- rows: include_rows ? rows : undefined,
455
- })
456
- }
457
-
458
- let list = [...products.values()]
459
- if (classification !== 'all') list = list.filter((p) => (classification === 'staging' ? p.staging : !p.staging))
460
-
461
- const stagingCount = list.filter((p) => p.staging).length
462
- const lines = list
463
- .slice(0, 60)
464
- .map((p) => `• ${p.product.sku ?? p.product.id} — ${p.product.name ?? ''} [${p.staging ? 'staging' : 'main'}] · ${p.boms.reduce((n, b) => n + b.row_count, 0)} rows`)
465
- const more = list.length > 60 ? `\n… ${list.length - 60} more.` : ''
466
- return {
467
- content: [
468
- {
469
- type: 'text',
470
- text: `${list.length} product(s): ${stagingCount} staging, ${list.length - stagingCount} main.\n${lines.join('\n')}${more}`,
471
- },
472
- ],
473
- structuredContent: { classification, product_count: list.length, staging_products: stagingCount, products: list },
474
- }
475
- },
476
- )
477
-
478
- // `node index.mjs --selftest` — verify the storage-key sanitizer without a server.
479
- if (process.argv.includes('--selftest')) {
480
- const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } }
481
- assert(storageKeyName('[MNT-0930-X0] HANDLEBAR MOUNT LEFT_260908.STEP')
482
- === '_MNT-0930-X0_ HANDLEBAR MOUNT LEFT_260908.STEP', 'brackets -> _')
483
- assert(storageKeyName('BRG-0245-X0 Bushing_260908.pdf')
484
- === 'BRG-0245-X0 Bushing_260908.pdf', 'safe name unchanged')
485
- const SAFE = /^[\w !\-.*'()&$@=;:+,?/]*$/
486
- assert(SAFE.test(storageKeyName('a[b]c#d%e\\f"g<h>i.STEP')), 'all unsafe chars replaced')
487
- // stagingFilter mapping
488
- assert(JSON.stringify(stagingFilter('main')) === '{"staging":"eq.false"}', 'main -> staging=false')
489
- assert(JSON.stringify(stagingFilter('staging')) === '{"staging":"eq.true"}', 'staging -> staging=true')
490
- assert(JSON.stringify(stagingFilter('all')) === '{}', 'all -> no filter')
491
- console.log('selftest ok')
492
- process.exit(0)
493
- }
494
-
495
- const transport = new StdioServerTransport()
496
- await server.connect(transport)
1
+ #!/usr/bin/env node
2
+ /**
3
+ * plm-upload-mcp — read the LionsBot PLM, bulk-upload local files
4
+ * (SolidWorks STEP/PDF exports) into it matched to a component by SKU, make
5
+ * components' Drive folders and uprev staging prototypes.
6
+ *
7
+ * Runs locally on each engineer's machine (stdio), so it reads their exported
8
+ * files straight off disk. It authenticates as the engineer with their own PLM
9
+ * login (GoTrue password grant) and every uploaded file is tagged with who
10
+ * uploaded it, server-side, by the plm-edge `upload` action. The read-only
11
+ * tools query PostgREST directly with the engineer's token, so RLS applies
12
+ * exactly as it does in the PLM web app — they only ever see what they may see.
13
+ *
14
+ * Config (env):
15
+ * PLM_EMAIL, PLM_PASSWORD — the engineer's PLM/hub login (required)
16
+ * PLM_GATEWAY, PLM_ANON_KEY, PLM_EDGE_URL — override the baked prod defaults
17
+ */
18
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
20
+ import { z } from 'zod'
21
+ import { readFile, stat } from 'node:fs/promises'
22
+ import { basename, extname } from 'node:path'
23
+
24
+ // Prod defaults — same values the PLM web app ships with. Overridable via env.
25
+ const GATEWAY = process.env.PLM_GATEWAY || 'https://gateway-production-c13d.up.railway.app'
26
+ const ANON_KEY =
27
+ process.env.PLM_ANON_KEY ||
28
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg0NzE1NTExLCJleHAiOjIxMDAwNzU1MTF9.kqtdfumqx4EjRyJ15Ld0gSZBYdeE0VnpmlnJy68qQT0'
29
+ const EDGE = process.env.PLM_EDGE_URL || 'https://plm-edge-production.up.railway.app'
30
+ const EMAIL = process.env.PLM_EMAIL
31
+ const PASSWORD = process.env.PLM_PASSWORD
32
+
33
+ const MIME = {
34
+ '.step': 'application/step',
35
+ '.stp': 'application/step',
36
+ '.pdf': 'application/pdf',
37
+ '.sldprt': 'application/octet-stream',
38
+ '.sldasm': 'application/octet-stream',
39
+ '.dxf': 'application/dxf',
40
+ '.dwg': 'application/acad',
41
+ '.igs': 'model/iges',
42
+ '.iges': 'model/iges',
43
+ '.png': 'image/png',
44
+ '.jpg': 'image/jpeg',
45
+ '.jpeg': 'image/jpeg',
46
+ }
47
+ const mimeFor = (name) => MIME[extname(name).toLowerCase()] || 'application/octet-stream'
48
+
49
+ // Supabase Storage rejects object keys with chars outside its safe set (notably
50
+ // '[' ']', which the common "[SKU] Name" convention starts with) -> the store
51
+ // call 400s with "storage upload failed: 400". The key is internal/UUID-prefixed
52
+ // and never shown, so sanitize it to the safe set; the Drive display name (the
53
+ // separate `upload` filename) keeps the original, brackets and all.
54
+ const storageKeyName = (name) => name.replace(/[^\w !\-.*'()&$@=;:+,?/]/g, '_')
55
+
56
+ // --- Auth: cache one access token, re-grant when it's near expiry. -----------
57
+ let cached = { token: null, exp: 0 }
58
+ async function getToken() {
59
+ if (cached.token && Date.now() < cached.exp - 60_000) return cached.token
60
+ if (!EMAIL || !PASSWORD) throw new Error('PLM_EMAIL and PLM_PASSWORD must be set in the MCP config env.')
61
+ const resp = await fetch(`${GATEWAY}/auth/v1/token?grant_type=password`, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json', apikey: ANON_KEY },
64
+ body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
65
+ })
66
+ const data = await resp.json().catch(() => null)
67
+ if (!resp.ok || !data?.access_token) {
68
+ throw new Error(`PLM sign-in failed for ${EMAIL}: ${data?.error_description || data?.msg || resp.status}`)
69
+ }
70
+ cached = { token: data.access_token, exp: Date.now() + (data.expires_in ?? 3600) * 1000 }
71
+ return cached.token
72
+ }
73
+
74
+ async function edgeJson(body) {
75
+ const token = await getToken()
76
+ const resp = await fetch(`${EDGE}/drive-library`, {
77
+ method: 'POST',
78
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
79
+ body: JSON.stringify(body),
80
+ })
81
+ const data = await resp.json().catch(() => null)
82
+ if (!resp.ok) throw new Error(data?.error || `edge ${body.action} failed: http ${resp.status}`)
83
+ return data
84
+ }
85
+
86
+ // The engineer's PLM user id: PLM users.id is the auth uid, the token's `sub`.
87
+ const userId = async () => JSON.parse(Buffer.from((await getToken()).split('.')[1], 'base64url').toString()).sub
88
+
89
+ /** A part-history row, the same shape the PLM web app writes. Best effort:
90
+ * a failed history write never undoes or blocks the work it records. */
91
+ async function audit(entity_id, action, after_value) {
92
+ try {
93
+ const token = await getToken()
94
+ const resp = await fetch(`${GATEWAY}/rest/v1/audit_log`, {
95
+ method: 'POST',
96
+ headers: { apikey: ANON_KEY, authorization: `Bearer ${token}`, 'content-type': 'application/json', prefer: 'return=minimal' },
97
+ body: JSON.stringify({ entity_type: 'component', entity_id, action, actor_user_id: await userId(), actor_type: 'user', after_value }),
98
+ })
99
+ if (!resp.ok) console.error(`[audit] ${action}: http ${resp.status}`)
100
+ } catch (e) {
101
+ console.error(`[audit] ${action}:`, e instanceof Error ? e.message : e)
102
+ }
103
+ }
104
+
105
+ // --- Read path: PostgREST GET with the engineer's token (RLS-enforced). -------
106
+ // Same data path the PLM web app uses (supabase-js -> /rest/v1). The anon key is
107
+ // the gateway apikey; the bearer token is the signed-in engineer, so row-level
108
+ // security decides what comes back — these tools can never read past the user.
109
+ async function restGet(table, params = {}, { count = false } = {}) {
110
+ const token = await getToken()
111
+ const qs = new URLSearchParams(params).toString()
112
+ const headers = { apikey: ANON_KEY, authorization: `Bearer ${token}` }
113
+ if (count) headers['prefer'] = 'count=exact'
114
+ const resp = await fetch(`${GATEWAY}/rest/v1/${table}?${qs}`, { headers })
115
+ const data = await resp.json().catch(() => null)
116
+ if (!resp.ok) throw new Error(data?.message || data?.error || `read ${table} failed: http ${resp.status}`)
117
+ // content-range is "start-end/total" (or "*/total") when count=exact.
118
+ const total = count ? Number((resp.headers.get('content-range') || '').split('/')[1]) || null : null
119
+ return { rows: Array.isArray(data) ? data : [], total }
120
+ }
121
+
122
+ // bucket -> components.staging filter. 'main' = confirmed, 'staging' = unconfirmed.
123
+ const stagingFilter = (bucket) =>
124
+ bucket === 'staging' ? { staging: 'eq.true' } : bucket === 'main' ? { staging: 'eq.false' } : {}
125
+
126
+ /** The component with this SKU, or null. Writes only go to SKUs the PLM has:
127
+ * a stray `[SKU]` Drive folder would be read as a new part by the reconcile. */
128
+ async function findComponent(sku) {
129
+ const { rows } = await restGet('components', {
130
+ select: 'id,sku,name,staging,cad_revision,drive_folder_id,drive_folder_url',
131
+ sku: `eq.${sku.trim().toUpperCase()}`,
132
+ })
133
+ return rows[0] ?? null
134
+ }
135
+ const driveOf = (c) => (c.staging ? 'staging' : 'main')
136
+
137
+ // --- Injection Mold rules: a port of the PLM's src/lib/injectionMould.ts and
138
+ // planUprev (src/lib/promotion.ts). Change both together. ---------------------
139
+ const IM_METHOD = /\binjection\b|\bmou?ld(ed|ing)?\s+plastic/i
140
+ const isXRev = (sku) => /-X\d+$/.test(sku)
141
+ const familyOf = (sku) => sku.replace(/-(X\d+|[A-Z]\d|\d\d)$/, '')
142
+
143
+ /** Next prototype revision, same rule as the edge `uprev` (X0–X8 only). */
144
+ function nextRev(sku) {
145
+ const m = /^([A-Z]{3})-(\d{4})-X(\d)$/.exec(sku)
146
+ if (!m) return { error: `${sku}: only staging prototype revisions (X0–X8) can be uprevved` }
147
+ if (Number(m[3]) >= 9) return { error: 'X9 is the last prototype revision — release to A0 instead' }
148
+ return { newSku: `${m[1]}-${m[2]}-X${Number(m[3]) + 1}` }
149
+ }
150
+
151
+ /** The file name carrying `sku`: an older revision's SKU in it is swapped,
152
+ * else the SKU is put in front, so a STEP sent to a supplier names its part. */
153
+ function nameWithSku(name, sku) {
154
+ if (name.toUpperCase().includes(sku.toUpperCase())) return name
155
+ const older = new RegExp(`${familyOf(sku).replace(/-/g, '\\-')}-(X\\d+|[A-Z]\\d)`, 'i')
156
+ return older.test(name) ? name.replace(older, sku) : `[${sku}] ${name}`
157
+ }
158
+
159
+ /** Warnings for a drawing about to be uploaded as `newSku`, compared with the
160
+ * revision it replaces. They are prompts: the uploader may still go ahead. */
161
+ function drawingWarnings(read, newSku, prev) {
162
+ if (!read) return ['The PDF could not be read, so its SKU and REV were not checked.']
163
+ if (!read.is_lionsbot) return ['This is not a LionsBot drawing title block, so nothing can be checked or extracted.']
164
+ const out = []
165
+ const sameSku = !!read.sku && read.sku === prev.sku
166
+ const sameRev = !!read.rev && read.rev === prev.drawing_rev
167
+ if (sameSku && (sameRev || !read.rev)) {
168
+ out.push(`No difference found: the drawing still reads ${prev.sku}${read.rev ? ` REV ${read.rev}` : ''}, the same as the current revision.`)
169
+ } else if (!read.sku) {
170
+ out.push('No SKU was found on the drawing.')
171
+ } else if (familyOf(read.sku) !== familyOf(newSku)) {
172
+ out.push(`The drawing is for ${read.sku}, a different part.`)
173
+ } else if (read.sku !== newSku) {
174
+ out.push(`The drawing reads ${read.sku}; this revision will be ${newSku}.`)
175
+ }
176
+ if (!read.rev) out.push('No REV was found on the drawing.')
177
+ else if (sameRev && !sameSku) out.push(`Drawing REV ${read.rev} is unchanged from the current revision.`)
178
+ return out
179
+ }
180
+
181
+ /** What reading an uploaded drawing did; `listed` = it is now in Injection Mold Staging. */
182
+ function readOutcome(res, sku) {
183
+ if (res.error) return { ok: false, text: res.error, listed: false }
184
+ if (!res.written) {
185
+ const text = res.extracted?.is_lionsbot === false
186
+ ? 'Not a LionsBot drawing, nothing read'
187
+ : `Drawing says ${res.sku_check?.drawing_sku ?? 'no SKU'}, not ${sku}: nothing read`
188
+ return { ok: false, text, listed: false }
189
+ }
190
+ return {
191
+ ok: true,
192
+ text: `Drawing read${res.extracted?.rev ? ` · REV ${res.extracted.rev}` : ''}`,
193
+ listed: IM_METHOD.test(res.extracted?.manufacturing_method ?? '') && isXRev(sku),
194
+ }
195
+ }
196
+
197
+ /** store bytes → storagePath, then upload → mirror into the SKU's Drive folder. */
198
+ async function uploadOne({ path, sku, skuName, category, bucket, filename = basename(path) }) {
199
+ const bytes = await readFile(path) // throws ENOENT with the path — surfaced per-file below
200
+ const mimeType = mimeFor(filename)
201
+ const token = await getToken()
202
+
203
+ // Store under a storage-safe key; the Drive file keeps the original filename below.
204
+ const params = new URLSearchParams({ action: 'store', sku, filename: storageKeyName(filename), mimeType })
205
+ const sResp = await fetch(`${EDGE}/drive-library?${params}`, {
206
+ method: 'POST',
207
+ headers: { 'content-type': mimeType, authorization: `Bearer ${token}` },
208
+ body: bytes,
209
+ })
210
+ const stored = await sResp.json().catch(() => null)
211
+ if (!sResp.ok || !stored?.storagePath) {
212
+ throw new Error(stored?.error || `store failed: http ${sResp.status}`)
213
+ }
214
+
215
+ const synced = await edgeJson({
216
+ action: 'upload',
217
+ sku,
218
+ skuName: skuName ?? '',
219
+ storagePath: stored.storagePath,
220
+ filename,
221
+ mimeType,
222
+ bucket,
223
+ })
224
+ if (synced?.error) throw new Error(synced.error)
225
+
226
+ if (category && category !== 'Default' && synced?.driveFileId) {
227
+ await edgeJson({ action: 'set-category', fileId: synced.driveFileId, category }).catch(() => {})
228
+ }
229
+
230
+ return {
231
+ file: filename,
232
+ sku,
233
+ bucket,
234
+ driveFileId: synced.driveFileId,
235
+ driveWebViewLink: synced.driveWebViewLink,
236
+ replaced: !!synced.replaced,
237
+ category: category && category !== 'Default' ? category : undefined,
238
+ }
239
+ }
240
+
241
+ /** Reads an uploaded drawing PDF into the component (title-block fields), the
242
+ * same step the PLM runs after a Files-tab upload. The edge writes only a
243
+ * LionsBot drawing of this SKU. */
244
+ async function readDrawing(componentId, sku, fileId, filename) {
245
+ let res
246
+ try {
247
+ res = await edgeJson({ action: 'extract', componentId, fileId, filename })
248
+ } catch (e) {
249
+ res = { error: e instanceof Error ? e.message : String(e) }
250
+ }
251
+ if (res.written) await audit(componentId, 'component.drawing_extracted', { file: filename, extracted: res.extracted ?? null })
252
+ return readOutcome(res, sku)
253
+ }
254
+
255
+ /** The drawing's title block, read without storing or writing anything. */
256
+ async function previewDrawing(bytes, filename) {
257
+ try {
258
+ const token = await getToken()
259
+ const resp = await fetch(`${EDGE}/drive-library?${new URLSearchParams({ action: 'extract-preview', filename })}`, {
260
+ method: 'POST',
261
+ headers: { 'content-type': 'application/pdf', authorization: `Bearer ${token}` },
262
+ body: bytes,
263
+ })
264
+ const data = await resp.json().catch(() => null)
265
+ return resp.ok ? data?.extracted ?? null : null
266
+ } catch {
267
+ return null
268
+ }
269
+ }
270
+
271
+ const isPdf = (name) => /\.pdf$/i.test(name)
272
+ const readLine = (read) => (read ? ` · ${read.text}${read.listed ? ' · now in Injection Mold Staging' : ''}` : '')
273
+
274
+ // --- Server ------------------------------------------------------------------
275
+ const server = new McpServer({ name: 'plm-upload', version: '1.2.0' })
276
+
277
+ server.registerTool(
278
+ 'upload_files',
279
+ {
280
+ title: 'Upload files to PLM by SKU',
281
+ description:
282
+ "Bulk-upload local files (e.g. SolidWorks STEP/PDF exports) into the LionsBot PLM. " +
283
+ "Each file is attached to an existing component by its SKU: it lands in PLM storage and is " +
284
+ "mirrored into that SKU's Google Drive folder on the part's own drive (staging or main; folder " +
285
+ "auto-created if missing). A SKU that is not in the PLM is refused. Re-uploading a file with " +
286
+ "the same name replaces it in place rather than duplicating. A PDF is then read like a PLM " +
287
+ "upload: a LionsBot drawing of that SKU fills the part's title-block fields (material, colour, " +
288
+ "drawing REV, method), which lists an injection moulded X revision in Injection Mold Staging. " +
289
+ "You must supply the SKU for each file — the caller's own tooling (e.g. a SolidWorks MCP) " +
290
+ "already knows each part's SKU.",
291
+ inputSchema: {
292
+ files: z
293
+ .array(
294
+ z.object({
295
+ path: z.string().describe('Absolute local path to the file to upload.'),
296
+ sku: z.string().describe('Component SKU this file belongs to, e.g. MNT-1234-A0.'),
297
+ category: z
298
+ .enum(['Default', 'Archive', 'Compliance', 'Ops'])
299
+ .optional()
300
+ .describe('PLM file category tag. Defaults to Default.'),
301
+ }),
302
+ )
303
+ .min(1)
304
+ .describe('The files to upload, each paired with its target SKU.'),
305
+ },
306
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
307
+ },
308
+ async ({ files }) => {
309
+ const parts = new Map()
310
+ const results = []
311
+ for (const f of files) {
312
+ const sku = f.sku.trim().toUpperCase()
313
+ try {
314
+ if (!parts.has(sku)) parts.set(sku, await findComponent(sku))
315
+ const c = parts.get(sku)
316
+ if (!c) throw new Error('not in the PLM, nothing uploaded (create the component first)')
317
+ const up = await uploadOne({ path: f.path, sku: c.sku, skuName: c.name, category: f.category, bucket: driveOf(c) })
318
+ const read = isPdf(up.file) && up.driveFileId ? await readDrawing(c.id, c.sku, up.driveFileId, up.file) : undefined
319
+ results.push({ ok: true, ...up, read })
320
+ } catch (e) {
321
+ results.push({ ok: false, file: basename(f.path), sku, error: e instanceof Error ? e.message : String(e) })
322
+ }
323
+ }
324
+ const ok = results.filter((r) => r.ok).length
325
+ const lines = results.map((r) =>
326
+ r.ok
327
+ ? `✓ ${r.file} → ${r.sku} (${r.bucket})${r.replaced ? ' (replaced)' : ''}${r.category ? ` [${r.category}]` : ''}${readLine(r.read)}`
328
+ : `✗ ${r.file} → ${r.sku}: ${r.error}`,
329
+ )
330
+ return {
331
+ content: [{ type: 'text', text: `Uploaded ${ok}/${results.length}.\n${lines.join('\n')}` }],
332
+ structuredContent: { uploaded: ok, total: results.length, results },
333
+ }
334
+ },
335
+ )
336
+
337
+ server.registerTool(
338
+ 'list_component_files',
339
+ {
340
+ title: 'List a component\'s PLM files',
341
+ description: "Lists the files currently in a component's Google Drive folder in the PLM, by SKU.",
342
+ inputSchema: {
343
+ sku: z.string().describe('Component SKU, e.g. MNT-1234-A0.'),
344
+ bucket: z.enum(['main', 'staging']).optional().describe("'main' (default) or 'staging'."),
345
+ },
346
+ annotations: { readOnlyHint: true, openWorldHint: true },
347
+ },
348
+ async ({ sku, bucket = 'main' }) => {
349
+ const data = await edgeJson({ action: 'list', sku, bucket })
350
+ const files = Array.isArray(data?.files) ? data.files : []
351
+ if (!data?.connected) {
352
+ return { content: [{ type: 'text', text: `Drive not connected or no folder for ${sku}.` }], structuredContent: { connected: false, files: [] } }
353
+ }
354
+ const lines = files.map((x) => `• ${x.name}${x.appProperties?.plmCategory ? ` [${x.appProperties.plmCategory}]` : ''}`)
355
+ return {
356
+ content: [{ type: 'text', text: files.length ? `${sku} — ${files.length} file(s):\n${lines.join('\n')}` : `${sku} — folder is empty.` }],
357
+ structuredContent: { connected: true, folder: data.folder ?? null, files },
358
+ }
359
+ },
360
+ )
361
+
362
+ server.registerTool(
363
+ 'create_folder',
364
+ {
365
+ title: "Create components' Drive folders",
366
+ description:
367
+ "Finds or creates the `[SKU] Name` Google Drive folder of components that already exist in the " +
368
+ "PLM, on each part's own drive (staging or main), and links the folder to the component. SKUs " +
369
+ "not in the PLM are refused: a stray folder would later be read as a new part. Uploading a file " +
370
+ "also creates the folder, so this is only needed for an empty one.",
371
+ inputSchema: {
372
+ skus: z.array(z.string()).min(1).describe('Component SKUs, e.g. MNT-1234-X0.'),
373
+ },
374
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
375
+ },
376
+ async ({ skus }) => {
377
+ const results = []
378
+ for (const s of skus) {
379
+ const sku = s.trim().toUpperCase()
380
+ try {
381
+ const c = await findComponent(sku)
382
+ if (!c) throw new Error('not in the PLM, no folder made')
383
+ const bucket = driveOf(c)
384
+ const r = await edgeJson({ action: 'ensure', sku: c.sku, skuName: c.name, componentId: c.id, bucket })
385
+ if (!r?.connected) throw new Error('Drive is not connected')
386
+ if (r.error || !r.folderId) throw new Error(r.error || 'no folder returned')
387
+ results.push({ ok: true, sku: c.sku, bucket, created: !!r.created, folderId: r.folderId, folderUrl: r.folderUrl ?? null })
388
+ } catch (e) {
389
+ results.push({ ok: false, sku, error: e instanceof Error ? e.message : String(e) })
390
+ }
391
+ }
392
+ const lines = results.map((r) =>
393
+ r.ok ? `✓ ${r.sku} (${r.bucket}): ${r.created ? 'folder created' : 'folder already there'} ${r.folderUrl ?? ''}`.trim() : `✗ ${r.sku}: ${r.error}`,
394
+ )
395
+ return {
396
+ content: [{ type: 'text', text: lines.join('\n') }],
397
+ structuredContent: { results },
398
+ }
399
+ },
400
+ )
401
+
402
+ server.registerTool(
403
+ 'uprev_component',
404
+ {
405
+ title: 'Uprev a staging prototype',
406
+ description:
407
+ "Uprevs a staging prototype part to its next X revision (MNT-1234-X1 -> MNT-1234-X2), the same as " +
408
+ "Uprev in the PLM: a new component with the old one's details and its own staging Drive folder; " +
409
+ "the old revision stays as history. Optionally uploads the new revision's drawing PDF and STEP in " +
410
+ "the same step (renamed to carry the new SKU) and reads the drawing into the PLM, which lists an " +
411
+ "injection moulded part in Injection Mold Staging. The PDF is checked BEFORE anything is created: " +
412
+ "if its title block shows no SKU/REV change, no SKU/REV, or another part, NOTHING is uprevved and " +
413
+ "the warnings come back. Show them to the user and only call again with " +
414
+ "proceed_despite_warnings: true if they say to go ahead. Only staging X0-X8 parts can be uprevved; " +
415
+ "releasing to A0 is a promotion request in the PLM.",
416
+ inputSchema: {
417
+ sku: z.string().describe('Current SKU of the staging part, e.g. MNT-1234-X1.'),
418
+ drawing_pdf: z.string().optional().describe("Absolute local path to the new revision's drawing PDF."),
419
+ step: z.string().optional().describe("Absolute local path to the new revision's STEP model."),
420
+ proceed_despite_warnings: z
421
+ .boolean()
422
+ .optional()
423
+ .describe('Uprev even though the drawing check warned. Only after the user has seen the warnings and said yes.'),
424
+ },
425
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
426
+ },
427
+ async ({ sku, drawing_pdf, step, proceed_despite_warnings = false }) => {
428
+ const reply = (text, data, isError = false) => ({ content: [{ type: 'text', text }], structuredContent: data, isError })
429
+ const stop = (text) => reply(text, { uprevved: false, error: text }, true)
430
+
431
+ const c = await findComponent(sku)
432
+ if (!c) return stop(`${sku.trim().toUpperCase()} is not in the PLM.`)
433
+ if (!c.staging) return stop(`${c.sku} is not a staging part; only staging prototypes can be uprevved.`)
434
+ const plan = nextRev(c.sku)
435
+ if (plan.error) return stop(plan.error)
436
+
437
+ // Both files must be readable before anything is created.
438
+ let pdfBytes = null
439
+ try {
440
+ if (drawing_pdf) pdfBytes = await readFile(drawing_pdf)
441
+ if (step) await stat(step)
442
+ } catch (e) {
443
+ return stop(`Nothing uprevved: ${e instanceof Error ? e.message : e}`)
444
+ }
445
+
446
+ let drawing = null
447
+ let warnings = []
448
+ if (pdfBytes) {
449
+ drawing = await previewDrawing(pdfBytes, basename(drawing_pdf))
450
+ warnings = drawingWarnings(drawing, plan.newSku, { sku: c.sku, drawing_rev: c.cad_revision })
451
+ if (warnings.length && !proceed_despite_warnings) {
452
+ return reply(
453
+ `Not uprevved yet. Checking the drawing against ${plan.newSku} found:\n- ${warnings.join('\n- ')}\n` +
454
+ 'Show this to the user. Only if they say to go ahead, call again with proceed_despite_warnings: true.',
455
+ { uprevved: false, new_sku: plan.newSku, warnings, drawing },
456
+ )
457
+ }
458
+ }
459
+
460
+ let created
461
+ try {
462
+ created = await edgeJson({ action: 'uprev', componentId: c.id })
463
+ } catch (e) {
464
+ return stop(`Uprev failed: ${e instanceof Error ? e.message : e}`)
465
+ }
466
+ if (!created?.new_id) return stop(`Uprev failed: ${created?.error ?? 'no new component returned'}`)
467
+ await audit(created.new_id, 'component_uprevved', { old_sku: c.sku, new_sku: created.new_sku })
468
+
469
+ const lines = [`Uprevved ${c.sku} -> ${created.new_sku}, with its own staging Drive folder; ${c.sku} stays as history.`]
470
+ const files = []
471
+ let read
472
+ try {
473
+ for (const [path, kind] of [[drawing_pdf, 'DRAWING'], [step, 'STEP']]) {
474
+ if (!path) continue
475
+ const original = basename(path)
476
+ const name = nameWithSku(original, created.new_sku)
477
+ const up = await uploadOne({ path, sku: created.new_sku, skuName: c.name, bucket: 'staging', filename: name })
478
+ files.push(up)
479
+ await audit(created.new_id, 'component.file_uploaded', {
480
+ file: name,
481
+ original_name: name !== original ? original : undefined,
482
+ ...(kind === 'DRAWING' ? { drawing_sku: drawing?.sku ?? null, drawing_rev: drawing?.rev ?? null, warnings } : {}),
483
+ })
484
+ if (kind === 'DRAWING' && up.driveFileId) read = await readDrawing(created.new_id, created.new_sku, up.driveFileId, name)
485
+ lines.push(`✓ ${name}${kind === 'DRAWING' ? readLine(read) : ''}`)
486
+ }
487
+ } catch (e) {
488
+ lines.push(`✗ Upload failed: ${e instanceof Error ? e.message : e}. ${created.new_sku} was created; finish with upload_files.`)
489
+ return reply(lines.join('\n'), { uprevved: true, ...created, files, read, warnings }, true)
490
+ }
491
+ if (warnings.length) lines.push(`Uploaded despite the drawing check (kept in the part's history): ${warnings.join(' ')}`)
492
+ return reply(lines.join('\n'), { uprevved: true, ...created, files, read, warnings })
493
+ },
494
+ )
495
+
496
+ // === Read-only tools (RLS-enforced via the engineer's token) =================
497
+
498
+ server.registerTool(
499
+ 'list_sku_folders',
500
+ {
501
+ title: 'List all SKU folders',
502
+ description:
503
+ "List every component SKU in the PLM with its Google Drive folder — the SKU→folder map. " +
504
+ "Returns SKU, name, category, type, revision, status, whether it is a staging (unconfirmed) " +
505
+ "or main (confirmed) part, and the Drive folder id/URL. Read-only. Use it to discover which " +
506
+ "SKUs exist and where each part's files live, or to find the folder for a SKU before uploading.",
507
+ inputSchema: {
508
+ bucket: z
509
+ .enum(['main', 'staging', 'all'])
510
+ .optional()
511
+ .describe("Filter by drive: 'main' (confirmed), 'staging' (unconfirmed), or 'all' (default)."),
512
+ category: z.string().optional().describe('Optional exact category code filter, e.g. MNT.'),
513
+ with_folder_only: z
514
+ .boolean()
515
+ .optional()
516
+ .describe('If true, only SKUs that already have a Drive folder. Default false.'),
517
+ limit: z.number().int().min(1).max(2000).optional().describe('Max rows (default 1000).'),
518
+ offset: z.number().int().min(0).optional().describe('Row offset for paging (default 0).'),
519
+ },
520
+ annotations: { readOnlyHint: true, openWorldHint: true },
521
+ },
522
+ async ({ bucket = 'all', category, with_folder_only = false, limit = 1000, offset = 0 }) => {
523
+ const params = {
524
+ select: 'sku,name,category,sku_type,revision,status,staging,drive_folder_id,drive_folder_url',
525
+ order: 'sku.asc',
526
+ limit: String(limit),
527
+ offset: String(offset),
528
+ ...stagingFilter(bucket),
529
+ }
530
+ if (category) params.category = `eq.${category}`
531
+ if (with_folder_only) params.drive_folder_id = 'not.is.null'
532
+ const { rows, total } = await restGet('components', params, { count: true })
533
+ const withFolder = rows.filter((r) => r.drive_folder_id).length
534
+ const lines = rows
535
+ .slice(0, 50)
536
+ .map((r) => `• ${r.sku} — ${r.name}${r.staging ? ' [staging]' : ''}${r.drive_folder_id ? '' : ' (no folder)'}`)
537
+ const more = rows.length > 50 ? `\n… ${rows.length - 50} more (of ${total ?? rows.length}).` : ''
538
+ return {
539
+ content: [
540
+ {
541
+ type: 'text',
542
+ text: `${rows.length} SKU(s) (${withFolder} with a Drive folder) of ${total ?? '?'} total in ${bucket}.\n${lines.join('\n')}${more}`,
543
+ },
544
+ ],
545
+ structuredContent: { bucket, count: rows.length, total, folders: rows },
546
+ }
547
+ },
548
+ )
549
+
550
+ server.registerTool(
551
+ 'get_component_metadata',
552
+ {
553
+ title: 'Get component metadata',
554
+ description:
555
+ "Read full component metadata from the PLM component database. Every column — identity " +
556
+ "(sku, name, category, type, revision, status), cost/supply (unit_cost, moq, lead time, " +
557
+ "preferred_supplier), drawing-extracted fields (material, colour, manufacturing_method, " +
558
+ "release_status), CAD provenance, compliance (RoHS/REACH/CE), and Drive folder — plus the " +
559
+ "extract_meta provenance block. Pass a single `sku` for one part, or omit it to page through " +
560
+ "all parts. Read-only.",
561
+ inputSchema: {
562
+ sku: z.string().optional().describe('Exact SKU for a single component, e.g. MNT-1234-A0.'),
563
+ search: z.string().optional().describe('Case-insensitive substring match on SKU or name.'),
564
+ bucket: z.enum(['main', 'staging', 'all']).optional().describe("'main', 'staging', or 'all' (default)."),
565
+ category: z.string().optional().describe('Optional exact category code filter, e.g. MNT.'),
566
+ status: z.enum(['active', 'obsolete']).optional().describe('Optional lifecycle status filter.'),
567
+ limit: z.number().int().min(1).max(500).optional().describe('Max rows when listing (default 100).'),
568
+ offset: z.number().int().min(0).optional().describe('Row offset for paging (default 0).'),
569
+ },
570
+ annotations: { readOnlyHint: true, openWorldHint: true },
571
+ },
572
+ async ({ sku, search, bucket = 'all', category, status, limit = 100, offset = 0 }) => {
573
+ const params = { select: '*', order: 'sku.asc', ...stagingFilter(bucket) }
574
+ if (sku) {
575
+ params.sku = `eq.${sku}`
576
+ params.limit = '1'
577
+ } else {
578
+ params.limit = String(limit)
579
+ params.offset = String(offset)
580
+ if (search) params.or = `(sku.ilike.*${search}*,name.ilike.*${search}*)`
581
+ if (category) params.category = `eq.${category}`
582
+ if (status) params.status = `eq.${status}`
583
+ }
584
+ const { rows, total } = await restGet('components', params, { count: !sku })
585
+ if (sku) {
586
+ const c = rows[0]
587
+ if (!c) return { content: [{ type: 'text', text: `No component with SKU ${sku}.` }], structuredContent: { component: null } }
588
+ return {
589
+ content: [{ type: 'text', text: `${c.sku} — ${c.name} (${c.category}, rev ${c.revision ?? '—'}, ${c.status}${c.staging ? ', staging' : ''}).` }],
590
+ structuredContent: { component: c },
591
+ }
592
+ }
593
+ const lines = rows.slice(0, 50).map((c) => `• ${c.sku} — ${c.name} (${c.status}${c.staging ? ', staging' : ''})`)
594
+ const more = rows.length > 50 ? `\n… ${rows.length - 50} more shown truncated (of ${total ?? rows.length}).` : ''
595
+ return {
596
+ content: [{ type: 'text', text: `${rows.length} component(s) of ${total ?? '?'} total in ${bucket}.\n${lines.join('\n')}${more}` }],
597
+ structuredContent: { bucket, count: rows.length, total, components: rows },
598
+ }
599
+ },
600
+ )
601
+
602
+ server.registerTool(
603
+ 'list_bom_extract_metadata',
604
+ {
605
+ title: 'List extracted BOM/drawing metadata',
606
+ description:
607
+ "List the metadata machine-extracted from each part's drawing/BOM PDF (the PLM `extract` step) " +
608
+ "across all components. For each SKU returns its extract_meta: the Lionsbot-custom-drawing flag, " +
609
+ "the SKU/drawing match check, the per-field extracted values (release status, material, colour, " +
610
+ "manufacturing method, CAD revision, drawn/designed by), and the source file + who/when it was " +
611
+ "extracted. Only parts that have been extracted are returned. Read-only.",
612
+ inputSchema: {
613
+ sku: z.string().optional().describe('Restrict to a single SKU, e.g. MNT-1234-A0.'),
614
+ bucket: z.enum(['main', 'staging', 'all']).optional().describe("'main', 'staging', or 'all' (default)."),
615
+ limit: z.number().int().min(1).max(2000).optional().describe('Max rows (default 1000).'),
616
+ offset: z.number().int().min(0).optional().describe('Row offset for paging (default 0).'),
617
+ },
618
+ annotations: { readOnlyHint: true, openWorldHint: true },
619
+ },
620
+ async ({ sku, bucket = 'all', limit = 1000, offset = 0 }) => {
621
+ const params = {
622
+ select: 'sku,name,category,staging,material,colour,manufacturing_method,release_status,cad_revision,cad_drawn_by,cad_designed_by,extract_meta',
623
+ order: 'sku.asc',
624
+ limit: String(limit),
625
+ offset: String(offset),
626
+ ...stagingFilter(bucket),
627
+ }
628
+ // extract_meta '{}' means never extracted — exclude it.
629
+ params.extract_meta = 'neq.{}'
630
+ if (sku) params.sku = `eq.${sku}`
631
+ const { rows, total } = await restGet('components', params, { count: true })
632
+ const lines = rows.slice(0, 50).map((r) => {
633
+ const lb = r.extract_meta?.is_lionsbot === true ? ' [LB]' : ''
634
+ const m = r.extract_meta?.sku_check?.match === true ? ' ✓match' : r.extract_meta?.sku_check ? ' ✗mismatch' : ''
635
+ return `• ${r.sku} — ${r.name}${lb}${m}`
636
+ })
637
+ const more = rows.length > 50 ? `\n… ${rows.length - 50} more (of ${total ?? rows.length}).` : ''
638
+ return {
639
+ content: [{ type: 'text', text: `${rows.length} extracted part(s) of ${total ?? '?'} in ${bucket}.\n${lines.join('\n')}${more}` }],
640
+ structuredContent: { bucket, count: rows.length, total, extracted: rows },
641
+ }
642
+ },
643
+ )
644
+
645
+ server.registerTool(
646
+ 'list_boms',
647
+ {
648
+ title: 'List all BOMs (products + staging)',
649
+ description:
650
+ "List the bills of materials across all products. Each product carries its BOM(s) with the line " +
651
+ "items — component SKU, name, quantity, unit, reference designator — and a derived `staging` flag: " +
652
+ "a product is 'staging' while its working BOM still contains any staging (unconfirmed) component, " +
653
+ "otherwise 'main'. By default returns working BOMs only (set include_snapshots to include saved " +
654
+ "snapshots) with their rows. Filter to one product with product_sku. Read-only.",
655
+ inputSchema: {
656
+ product_sku: z.string().optional().describe('Restrict to one product by its SAL SKU, e.g. SAL-0R3-3001.'),
657
+ classification: z
658
+ .enum(['main', 'staging', 'all'])
659
+ .optional()
660
+ .describe("Only products in this derived class: 'main', 'staging', or 'all' (default)."),
661
+ include_snapshots: z.boolean().optional().describe('Include saved snapshot BOMs, not just the working BOM. Default false.'),
662
+ include_rows: z.boolean().optional().describe('Include BOM line items. Default true.'),
663
+ max_rows_per_bom: z.number().int().min(1).max(5000).optional().describe('Cap rows fetched per BOM (default 2000).'),
664
+ },
665
+ annotations: { readOnlyHint: true, openWorldHint: true },
666
+ },
667
+ async ({ product_sku, classification = 'all', include_snapshots = false, include_rows = true, max_rows_per_bom = 2000 }) => {
668
+ // 1) BOM headers + their product.
669
+ const bomParams = {
670
+ select: 'id,name,is_snapshot,snapshot_label,product_id,created_at,product:products(sku,name,status)',
671
+ order: 'created_at.asc',
672
+ }
673
+ if (!include_snapshots) bomParams.is_snapshot = 'eq.false'
674
+ const { rows: boms } = await restGet('boms', bomParams, {})
675
+ let scoped = product_sku ? boms.filter((b) => b.product?.sku === product_sku) : boms
676
+ if (!scoped.length) {
677
+ return { content: [{ type: 'text', text: product_sku ? `No BOMs for product ${product_sku}.` : 'No BOMs found.' }], structuredContent: { products: [] } }
678
+ }
679
+
680
+ // 2) Rows for those BOMs, embedding each component's SKU + staging flag.
681
+ const byBom = new Map(scoped.map((b) => [b.id, []]))
682
+ if (include_rows) {
683
+ const ids = scoped.map((b) => b.id)
684
+ // Chunk the in-list so the URL stays a sane length.
685
+ for (let i = 0; i < ids.length; i += 50) {
686
+ const chunk = ids.slice(i, i + 50)
687
+ const { rows } = await restGet('bom_rows', {
688
+ select:
689
+ 'bom_id,quantity,unit,reference_designator,sort_order,row_type,component:components(sku,name,category,revision,status,staging)',
690
+ bom_id: `in.(${chunk.join(',')})`,
691
+ order: 'bom_id.asc,sort_order.asc',
692
+ limit: String(max_rows_per_bom * chunk.length),
693
+ })
694
+ for (const r of rows) if (byBom.has(r.bom_id)) byBom.get(r.bom_id).push(r)
695
+ }
696
+ }
697
+
698
+ // 3) Group by product; derive staging from the working BOM's rows.
699
+ const products = new Map()
700
+ for (const b of scoped) {
701
+ const key = b.product?.sku ?? b.product_id
702
+ if (!products.has(key)) {
703
+ products.set(key, { product: b.product ?? { sku: null, id: b.product_id }, staging: false, boms: [] })
704
+ }
705
+ const rows = byBom.get(b.id) ?? []
706
+ const bomStaging = rows.some((r) => r.component?.staging === true)
707
+ // A product's class is decided by its working (non-snapshot) BOM.
708
+ if (!b.is_snapshot && bomStaging) products.get(key).staging = true
709
+ products.get(key).boms.push({
710
+ id: b.id,
711
+ name: b.name,
712
+ is_snapshot: b.is_snapshot,
713
+ snapshot_label: b.snapshot_label,
714
+ row_count: rows.length,
715
+ rows: include_rows ? rows : undefined,
716
+ })
717
+ }
718
+
719
+ let list = [...products.values()]
720
+ if (classification !== 'all') list = list.filter((p) => (classification === 'staging' ? p.staging : !p.staging))
721
+
722
+ const stagingCount = list.filter((p) => p.staging).length
723
+ const lines = list
724
+ .slice(0, 60)
725
+ .map((p) => `• ${p.product.sku ?? p.product.id} — ${p.product.name ?? ''} [${p.staging ? 'staging' : 'main'}] · ${p.boms.reduce((n, b) => n + b.row_count, 0)} rows`)
726
+ const more = list.length > 60 ? `\n… ${list.length - 60} more.` : ''
727
+ return {
728
+ content: [
729
+ {
730
+ type: 'text',
731
+ text: `${list.length} product(s): ${stagingCount} staging, ${list.length - stagingCount} main.\n${lines.join('\n')}${more}`,
732
+ },
733
+ ],
734
+ structuredContent: { classification, product_count: list.length, staging_products: stagingCount, products: list },
735
+ }
736
+ },
737
+ )
738
+
739
+ // `node index.mjs --selftest` — verify the storage-key sanitizer without a server.
740
+ if (process.argv.includes('--selftest')) {
741
+ const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } }
742
+ assert(storageKeyName('[MNT-0930-X0] HANDLEBAR MOUNT LEFT_260908.STEP')
743
+ === '_MNT-0930-X0_ HANDLEBAR MOUNT LEFT_260908.STEP', 'brackets -> _')
744
+ assert(storageKeyName('BRG-0245-X0 Bushing_260908.pdf')
745
+ === 'BRG-0245-X0 Bushing_260908.pdf', 'safe name unchanged')
746
+ const SAFE = /^[\w !\-.*'()&$@=;:+,?/]*$/
747
+ assert(SAFE.test(storageKeyName('a[b]c#d%e\\f"g<h>i.STEP')), 'all unsafe chars replaced')
748
+ // stagingFilter mapping
749
+ assert(JSON.stringify(stagingFilter('main')) === '{"staging":"eq.false"}', 'main -> staging=false')
750
+ assert(JSON.stringify(stagingFilter('staging')) === '{"staging":"eq.true"}', 'staging -> staging=true')
751
+ assert(JSON.stringify(stagingFilter('all')) === '{}', 'all -> no filter')
752
+ // ported rules (same cases as the PLM's injectionMould.test.ts)
753
+ assert(nameWithSku('Bracket.STEP', 'MNT-1234-X2') === '[MNT-1234-X2] Bracket.STEP', 'sku put in front')
754
+ assert(nameWithSku('MNT-1234-X1 Bracket.pdf', 'MNT-1234-X2') === 'MNT-1234-X2 Bracket.pdf', 'older rev swapped')
755
+ assert(nameWithSku('[MNT-1234-X2] Bracket.pdf', 'MNT-1234-X2') === '[MNT-1234-X2] Bracket.pdf', 'already named')
756
+ assert(nextRev('MNT-1234-X1').newSku === 'MNT-1234-X2', 'X1 -> X2')
757
+ assert(nextRev('MNT-1234-X9').error && nextRev('MNT-1234-A0').error, 'X9 and A0 refused')
758
+ const prev = { sku: 'MNT-1234-X1', drawing_rev: '01' }
759
+ assert(drawingWarnings({ is_lionsbot: true, sku: 'MNT-1234-X2', rev: '02' }, 'MNT-1234-X2', prev).length === 0, 'clean drawing')
760
+ assert(/No difference/.test(drawingWarnings({ is_lionsbot: true, sku: 'MNT-1234-X1', rev: '01' }, 'MNT-1234-X2', prev)[0]), 'same sku + rev')
761
+ assert(/different part/.test(drawingWarnings({ is_lionsbot: true, sku: 'BOX-0001-X0', rev: '02' }, 'MNT-1234-X2', prev)[0]), 'other part')
762
+ assert(/could not be read/.test(drawingWarnings(null, 'MNT-1234-X2', prev)[0]), 'unreadable')
763
+ assert(readOutcome({ written: true, extracted: { rev: '02', manufacturing_method: 'MOULD PLASTIC AND RUBBER' } }, 'MNT-1234-X2').listed, 'IM X rev listed')
764
+ assert(!readOutcome({ written: true, extracted: { manufacturing_method: 'MOULD PLASTIC AND RUBBER' } }, 'MNT-1234-A0').listed, 'released not listed')
765
+ assert(readOutcome({ written: false, extracted: { is_lionsbot: false } }, 'X').text === 'Not a LionsBot drawing, nothing read', 'not LB')
766
+ console.log('selftest ok')
767
+ process.exit(0)
768
+ }
769
+
770
+ const transport = new StdioServerTransport()
771
+ await server.connect(transport)