plm-upload-mcp 1.0.1 → 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 +274 -4
  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)
@@ -80,6 +82,27 @@ async function edgeJson(body) {
80
82
  return data
81
83
  }
82
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
+
83
106
  /** store bytes → storagePath, then upload → mirror into the SKU's Drive folder. */
84
107
  async function uploadOne({ path, sku, skuName, category, bucket }) {
85
108
  const bytes = await readFile(path) // throws ENOENT with the path — surfaced per-file below
@@ -125,7 +148,7 @@ async function uploadOne({ path, sku, skuName, category, bucket }) {
125
148
  }
126
149
 
127
150
  // --- Server ------------------------------------------------------------------
128
- const server = new McpServer({ name: 'plm-upload', version: '1.0.0' })
151
+ const server = new McpServer({ name: 'plm-upload', version: '1.1.0' })
129
152
 
130
153
  server.registerTool(
131
154
  'upload_files',
@@ -209,6 +232,249 @@ server.registerTool(
209
232
  },
210
233
  )
211
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
+
212
478
  // `node index.mjs --selftest` — verify the storage-key sanitizer without a server.
213
479
  if (process.argv.includes('--selftest')) {
214
480
  const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } }
@@ -218,6 +484,10 @@ if (process.argv.includes('--selftest')) {
218
484
  === 'BRG-0245-X0 Bushing_260908.pdf', 'safe name unchanged')
219
485
  const SAFE = /^[\w !\-.*'()&$@=;:+,?/]*$/
220
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')
221
491
  console.log('selftest ok')
222
492
  process.exit(0)
223
493
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plm-upload-mcp",
3
- "version": "1.0.1",
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",