plm-upload-mcp 1.0.1 → 1.2.0

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