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.
- package/README.md +35 -4
- package/index.mjs +274 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# plm-upload-mcp
|
|
2
2
|
|
|
3
|
-
An MCP server
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
4
|
-
*
|
|
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.
|
|
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
|
|
4
|
-
"description": "MCP server to bulk-upload SolidWorks exports (STEP/PDF) into
|
|
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",
|