plm-upload-mcp 1.0.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 +72 -0
- package/index.mjs +205 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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`).
|
package/index.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
// --- Auth: cache one access token, re-grant when it's near expiry. -----------
|
|
47
|
+
let cached = { token: null, exp: 0 }
|
|
48
|
+
async function getToken() {
|
|
49
|
+
if (cached.token && Date.now() < cached.exp - 60_000) return cached.token
|
|
50
|
+
if (!EMAIL || !PASSWORD) throw new Error('PLM_EMAIL and PLM_PASSWORD must be set in the MCP config env.')
|
|
51
|
+
const resp = await fetch(`${GATEWAY}/auth/v1/token?grant_type=password`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/json', apikey: ANON_KEY },
|
|
54
|
+
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
|
|
55
|
+
})
|
|
56
|
+
const data = await resp.json().catch(() => null)
|
|
57
|
+
if (!resp.ok || !data?.access_token) {
|
|
58
|
+
throw new Error(`PLM sign-in failed for ${EMAIL}: ${data?.error_description || data?.msg || resp.status}`)
|
|
59
|
+
}
|
|
60
|
+
cached = { token: data.access_token, exp: Date.now() + (data.expires_in ?? 3600) * 1000 }
|
|
61
|
+
return cached.token
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function edgeJson(body) {
|
|
65
|
+
const token = await getToken()
|
|
66
|
+
const resp = await fetch(`${EDGE}/drive-library`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
69
|
+
body: JSON.stringify(body),
|
|
70
|
+
})
|
|
71
|
+
const data = await resp.json().catch(() => null)
|
|
72
|
+
if (!resp.ok) throw new Error(data?.error || `edge ${body.action} failed: http ${resp.status}`)
|
|
73
|
+
return data
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** store bytes → storagePath, then upload → mirror into the SKU's Drive folder. */
|
|
77
|
+
async function uploadOne({ path, sku, skuName, category, bucket }) {
|
|
78
|
+
const bytes = await readFile(path) // throws ENOENT with the path — surfaced per-file below
|
|
79
|
+
const filename = basename(path)
|
|
80
|
+
const mimeType = mimeFor(filename)
|
|
81
|
+
const token = await getToken()
|
|
82
|
+
|
|
83
|
+
const params = new URLSearchParams({ action: 'store', sku, filename, mimeType })
|
|
84
|
+
const sResp = await fetch(`${EDGE}/drive-library?${params}`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'content-type': mimeType, authorization: `Bearer ${token}` },
|
|
87
|
+
body: bytes,
|
|
88
|
+
})
|
|
89
|
+
const stored = await sResp.json().catch(() => null)
|
|
90
|
+
if (!sResp.ok || !stored?.storagePath) {
|
|
91
|
+
throw new Error(stored?.error || `store failed: http ${sResp.status}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const synced = await edgeJson({
|
|
95
|
+
action: 'upload',
|
|
96
|
+
sku,
|
|
97
|
+
skuName: skuName ?? '',
|
|
98
|
+
storagePath: stored.storagePath,
|
|
99
|
+
filename,
|
|
100
|
+
mimeType,
|
|
101
|
+
bucket,
|
|
102
|
+
})
|
|
103
|
+
if (synced?.error) throw new Error(synced.error)
|
|
104
|
+
|
|
105
|
+
if (category && category !== 'Default' && synced?.driveFileId) {
|
|
106
|
+
await edgeJson({ action: 'set-category', fileId: synced.driveFileId, category }).catch(() => {})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
file: filename,
|
|
111
|
+
sku,
|
|
112
|
+
driveFileId: synced.driveFileId,
|
|
113
|
+
driveWebViewLink: synced.driveWebViewLink,
|
|
114
|
+
replaced: !!synced.replaced,
|
|
115
|
+
category: category && category !== 'Default' ? category : undefined,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Server ------------------------------------------------------------------
|
|
120
|
+
const server = new McpServer({ name: 'plm-upload', version: '1.0.0' })
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
'upload_files',
|
|
124
|
+
{
|
|
125
|
+
title: 'Upload files to PLM by SKU',
|
|
126
|
+
description:
|
|
127
|
+
"Bulk-upload local files (e.g. SolidWorks STEP/PDF exports) into the LionsBot PLM. " +
|
|
128
|
+
"Each file is attached to a component by its SKU: it lands in PLM storage and is mirrored " +
|
|
129
|
+
"into that SKU's Google Drive folder (folder auto-created if missing). Re-uploading a file " +
|
|
130
|
+
"with the same name replaces it in place rather than duplicating. You must supply the SKU " +
|
|
131
|
+
"for each file — the caller's own tooling (e.g. a SolidWorks MCP) already knows each part's SKU.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
files: z
|
|
134
|
+
.array(
|
|
135
|
+
z.object({
|
|
136
|
+
path: z.string().describe('Absolute local path to the file to upload.'),
|
|
137
|
+
sku: z.string().describe('Component SKU this file belongs to, e.g. MNT-1234-A0.'),
|
|
138
|
+
skuName: z
|
|
139
|
+
.string()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe("Component display name — used only if the SKU's Drive folder must be created."),
|
|
142
|
+
category: z
|
|
143
|
+
.enum(['Default', 'Archive', 'Compliance', 'Ops'])
|
|
144
|
+
.optional()
|
|
145
|
+
.describe('PLM file category tag. Defaults to Default.'),
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
.min(1)
|
|
149
|
+
.describe('The files to upload, each paired with its target SKU.'),
|
|
150
|
+
bucket: z
|
|
151
|
+
.enum(['main', 'staging'])
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("Which shared drive: 'main' (confirmed components, default) or 'staging' (unconfirmed)."),
|
|
154
|
+
},
|
|
155
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
156
|
+
},
|
|
157
|
+
async ({ files, bucket = 'main' }) => {
|
|
158
|
+
const results = []
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
try {
|
|
161
|
+
results.push({ ok: true, ...(await uploadOne({ ...f, bucket })) })
|
|
162
|
+
} catch (e) {
|
|
163
|
+
results.push({ ok: false, file: basename(f.path), sku: f.sku, error: e instanceof Error ? e.message : String(e) })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const ok = results.filter((r) => r.ok).length
|
|
167
|
+
const lines = results.map((r) =>
|
|
168
|
+
r.ok
|
|
169
|
+
? `✓ ${r.file} → ${r.sku}${r.replaced ? ' (replaced)' : ''}${r.category ? ` [${r.category}]` : ''}`
|
|
170
|
+
: `✗ ${r.file} → ${r.sku}: ${r.error}`,
|
|
171
|
+
)
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: 'text', text: `Uploaded ${ok}/${results.length} to ${bucket}.\n${lines.join('\n')}` }],
|
|
174
|
+
structuredContent: { uploaded: ok, total: results.length, bucket, results },
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
server.registerTool(
|
|
180
|
+
'list_component_files',
|
|
181
|
+
{
|
|
182
|
+
title: 'List a component\'s PLM files',
|
|
183
|
+
description: "Lists the files currently in a component's Google Drive folder in the PLM, by SKU.",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
sku: z.string().describe('Component SKU, e.g. MNT-1234-A0.'),
|
|
186
|
+
bucket: z.enum(['main', 'staging']).optional().describe("'main' (default) or 'staging'."),
|
|
187
|
+
},
|
|
188
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
189
|
+
},
|
|
190
|
+
async ({ sku, bucket = 'main' }) => {
|
|
191
|
+
const data = await edgeJson({ action: 'list', sku, bucket })
|
|
192
|
+
const files = Array.isArray(data?.files) ? data.files : []
|
|
193
|
+
if (!data?.connected) {
|
|
194
|
+
return { content: [{ type: 'text', text: `Drive not connected or no folder for ${sku}.` }], structuredContent: { connected: false, files: [] } }
|
|
195
|
+
}
|
|
196
|
+
const lines = files.map((x) => `• ${x.name}${x.appProperties?.plmCategory ? ` [${x.appProperties.plmCategory}]` : ''}`)
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: 'text', text: files.length ? `${sku} — ${files.length} file(s):\n${lines.join('\n')}` : `${sku} — folder is empty.` }],
|
|
199
|
+
structuredContent: { connected: true, folder: data.folder ?? null, files },
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const transport = new StdioServerTransport()
|
|
205
|
+
await server.connect(transport)
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plm-upload-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server to bulk-upload SolidWorks exports (STEP/PDF) into the LionsBot PLM by SKU.",
|
|
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
|
+
}
|