plm-upload-mcp 1.0.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +35 -4
  2. package/index.mjs +296 -5
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # plm-upload-mcp
2
2
 
3
- An MCP server that bulk-uploads local files (SolidWorks STEP/PDF exports) into the
4
- **LionsBot PLM**, matched to a component by SKU. It runs on **your own machine**, so
5
- it reads your exported files straight off disk, and it signs in as **you** with your
6
- PLM/hub login — every uploaded file is tagged with who uploaded it.
3
+ An MCP server for the **LionsBot PLM**. It **reads** the PLM — SKU folders, full
4
+ component metadata, extracted drawing/BOM metadata, and product & staging BOMs — and
5
+ **bulk-uploads** local files (SolidWorks STEP/PDF exports) into it, matched to a
6
+ component by SKU. It runs on **your own machine**, so it reads your exported files
7
+ straight off disk, and it signs in as **you** with your PLM/hub login — every read is
8
+ row-level-security scoped to you, and every uploaded file is tagged with who uploaded it.
7
9
 
8
10
  Pair it with your SolidWorks MCP: that MCP knows each part's SKU and exports the files;
9
11
  this MCP takes `{path, sku}` pairs and pushes them into the PLM.
@@ -57,6 +59,35 @@ Lists the files currently in a component's Drive folder.
57
59
  - `sku`: component SKU
58
60
  - `bucket`: `main` (default) or `staging`
59
61
 
62
+ ## Read-only tools
63
+
64
+ All read tools query the PLM as **you** (RLS-scoped) — you only ever see what you're
65
+ allowed to see. `bucket` / `classification` values are `main` (confirmed), `staging`
66
+ (unconfirmed), or `all` (default).
67
+
68
+ ### `list_sku_folders`
69
+ Every component SKU with its Google Drive folder — the SKU→folder map (SKU, name,
70
+ category, type, revision, status, staging flag, folder id/URL). Filters: `bucket`,
71
+ `category`, `with_folder_only`, `limit`, `offset`.
72
+
73
+ ### `get_component_metadata`
74
+ Full component metadata (every column, incl. cost/supply, drawing-extracted fields,
75
+ CAD provenance, compliance, and the `extract_meta` block). Pass a single `sku` for one
76
+ part, or omit it to page through all. Filters: `search`, `bucket`, `category`,
77
+ `status`, `limit`, `offset`.
78
+
79
+ ### `list_bom_extract_metadata`
80
+ The metadata machine-extracted from each part's drawing/BOM PDF (`extract_meta`): the
81
+ Lionsbot-custom flag, SKU/drawing match check, per-field extracted values, and source
82
+ file + who/when. Only extracted parts are returned. Filters: `sku`, `bucket`, `limit`,
83
+ `offset`.
84
+
85
+ ### `list_boms`
86
+ BOMs across all products, each product carrying its BOM(s) + line items and a derived
87
+ `staging` flag (a product is *staging* while its working BOM still contains any staging
88
+ component). Working BOMs only by default. Filters: `product_sku`, `classification`,
89
+ `include_snapshots`, `include_rows`, `max_rows_per_bom`.
90
+
60
91
  ## Config overrides (rarely needed)
61
92
 
62
93
  Baked to production defaults; override via env only if the PLM backend moves:
package/index.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * plm-upload-mcp — bulk-upload local files (SolidWorks STEP/PDF exports) into
4
- * the LionsBot PLM, matched to a component by SKU.
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
5
  *
6
6
  * Runs locally on each engineer's machine (stdio), so it reads their exported
7
7
  * files straight off disk. It authenticates as the engineer with their own PLM
8
8
  * login (GoTrue password grant) and every uploaded file is tagged with who
9
- * uploaded it, server-side, by the plm-edge `upload` action.
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.
10
12
  *
11
13
  * Config (env):
12
14
  * PLM_EMAIL, PLM_PASSWORD — the engineer's PLM/hub login (required)
@@ -43,6 +45,13 @@ const MIME = {
43
45
  }
44
46
  const mimeFor = (name) => MIME[extname(name).toLowerCase()] || 'application/octet-stream'
45
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
+
46
55
  // --- Auth: cache one access token, re-grant when it's near expiry. -----------
47
56
  let cached = { token: null, exp: 0 }
48
57
  async function getToken() {
@@ -73,6 +82,27 @@ async function edgeJson(body) {
73
82
  return data
74
83
  }
75
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
+
76
106
  /** store bytes → storagePath, then upload → mirror into the SKU's Drive folder. */
77
107
  async function uploadOne({ path, sku, skuName, category, bucket }) {
78
108
  const bytes = await readFile(path) // throws ENOENT with the path — surfaced per-file below
@@ -80,7 +110,8 @@ async function uploadOne({ path, sku, skuName, category, bucket }) {
80
110
  const mimeType = mimeFor(filename)
81
111
  const token = await getToken()
82
112
 
83
- const params = new URLSearchParams({ action: 'store', sku, filename, mimeType })
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 })
84
115
  const sResp = await fetch(`${EDGE}/drive-library?${params}`, {
85
116
  method: 'POST',
86
117
  headers: { 'content-type': mimeType, authorization: `Bearer ${token}` },
@@ -117,7 +148,7 @@ async function uploadOne({ path, sku, skuName, category, bucket }) {
117
148
  }
118
149
 
119
150
  // --- Server ------------------------------------------------------------------
120
- const server = new McpServer({ name: 'plm-upload', version: '1.0.0' })
151
+ const server = new McpServer({ name: 'plm-upload', version: '1.1.0' })
121
152
 
122
153
  server.registerTool(
123
154
  'upload_files',
@@ -201,5 +232,265 @@ server.registerTool(
201
232
  },
202
233
  )
203
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
+
204
495
  const transport = new StdioServerTransport()
205
496
  await server.connect(transport)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plm-upload-mcp",
3
- "version": "1.0.0",
4
- "description": "MCP server to bulk-upload SolidWorks exports (STEP/PDF) into the LionsBot PLM by SKU.",
3
+ "version": "1.1.0",
4
+ "description": "MCP server to read the LionsBot PLM (SKU folders, component metadata, extracted drawing metadata, product & staging BOMs) and bulk-upload SolidWorks exports (STEP/PDF) into it by SKU.",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": ["mcp", "modelcontextprotocol", "lionsbot", "plm", "solidworks"],
7
7
  "type": "module",