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.
- package/README.md +134 -103
- package/index.mjs +771 -496
- 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
|
|
4
|
-
* (SolidWorks STEP/PDF exports) into it
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'.
|
|
35
|
-
'.
|
|
36
|
-
'.
|
|
37
|
-
'.
|
|
38
|
-
'.
|
|
39
|
-
'.
|
|
40
|
-
'.
|
|
41
|
-
'.
|
|
42
|
-
'.
|
|
43
|
-
'.
|
|
44
|
-
'.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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)
|