@transloadit/mcp-server 0.4.2 → 0.5.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 (58) hide show
  1. package/README.md +246 -80
  2. package/dist/cli.js +48 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/express.d.ts.map +1 -1
  5. package/dist/express.js +25 -12
  6. package/dist/express.js.map +1 -1
  7. package/dist/http-helpers.d.ts +88 -0
  8. package/dist/http-helpers.d.ts.map +1 -1
  9. package/dist/http-helpers.js +193 -3
  10. package/dist/http-helpers.js.map +1 -1
  11. package/dist/http-request-handler.d.ts +1 -0
  12. package/dist/http-request-handler.d.ts.map +1 -1
  13. package/dist/http-request-handler.js +9 -19
  14. package/dist/http-request-handler.js.map +1 -1
  15. package/dist/http.d.ts +2 -0
  16. package/dist/http.d.ts.map +1 -1
  17. package/dist/http.js +25 -49
  18. package/dist/http.js.map +1 -1
  19. package/dist/json.d.ts +5 -0
  20. package/dist/json.d.ts.map +1 -0
  21. package/dist/json.js +14 -0
  22. package/dist/json.js.map +1 -0
  23. package/dist/logger.d.ts.map +1 -1
  24. package/dist/logger.js +4 -1
  25. package/dist/logger.js.map +1 -1
  26. package/dist/options.d.ts +10 -0
  27. package/dist/options.d.ts.map +1 -0
  28. package/dist/options.js +26 -0
  29. package/dist/options.js.map +1 -0
  30. package/dist/request-transport.d.ts +21 -0
  31. package/dist/request-transport.d.ts.map +1 -0
  32. package/dist/request-transport.js +129 -0
  33. package/dist/request-transport.js.map +1 -0
  34. package/dist/server-card.d.ts +20 -7
  35. package/dist/server-card.d.ts.map +1 -1
  36. package/dist/server-card.js +41 -16
  37. package/dist/server-card.js.map +1 -1
  38. package/dist/server.d.ts +35 -0
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/server.js +419 -81
  41. package/dist/server.js.map +1 -1
  42. package/dist/tool-list.d.ts +9 -0
  43. package/dist/tool-list.d.ts.map +1 -0
  44. package/dist/tool-list.js +44 -0
  45. package/dist/tool-list.js.map +1 -0
  46. package/dist/tool-metadata.d.ts +53 -0
  47. package/dist/tool-metadata.d.ts.map +1 -0
  48. package/dist/tool-metadata.js +147 -0
  49. package/dist/tool-metadata.js.map +1 -0
  50. package/dist/ui/assembly-result-widget.d.ts +38 -0
  51. package/dist/ui/assembly-result-widget.d.ts.map +1 -0
  52. package/dist/ui/assembly-result-widget.js +415 -0
  53. package/dist/ui/assembly-result-widget.js.map +1 -0
  54. package/dist/upload-instructions.d.ts +29 -0
  55. package/dist/upload-instructions.d.ts.map +1 -0
  56. package/dist/upload-instructions.js +60 -0
  57. package/dist/upload-instructions.js.map +1 -0
  58. package/package.json +3 -2
@@ -0,0 +1,415 @@
1
+ import packageJson from '../../package.json' with { type: 'json' };
2
+ /** MCP Apps resource that renders `transloadit_create_assembly` and `transloadit_wait_for_assembly` results. */
3
+ export const assemblyResultWidgetUri = 'ui://transloadit/assembly-result';
4
+ /** Mime type the MCP Apps spec requires for UI resources. */
5
+ export const assemblyResultWidgetMimeType = 'text/html;profile=mcp-app';
6
+ /**
7
+ * Origins that serve Assembly result and upload files: Transloadit result buckets and Cloudflare
8
+ * R2 public buckets (API2's `CLOUDFLARE_R2_PUB_URL_HOST_*`). Override with `resultDomains`.
9
+ */
10
+ export const defaultResultDomains = [
11
+ 'https://*.transloadit.com',
12
+ 'https://*.transloadit.net',
13
+ 'https://*.r2.dev',
14
+ ];
15
+ /** MCP Apps protocol revision the widget speaks (ext-apps `LATEST_PROTOCOL_VERSION`). */
16
+ export const widgetProtocolVersion = '2026-01-26';
17
+ /** `appInfo` the widget announces in `ui/initialize`. */
18
+ export const widgetAppInfo = { name: 'transloadit-assembly-result', version: packageJson.version };
19
+ /** Result `_meta` key that carries widget-only context (never read by the model). */
20
+ export const widgetContextMetaKey = 'transloadit/widget';
21
+ const widgetDescription = 'Shows each Assembly Step with image, video and audio previews, download links for every result file, and a Save as Template shortcut when the caller is signed in.';
22
+ /** Resource `_meta` in both the MCP Apps form and the legacy ChatGPT aliases. */
23
+ export const buildAssemblyResultWidgetMeta = (resultDomains = defaultResultDomains) => ({
24
+ ui: {
25
+ csp: {
26
+ connectDomains: resultDomains,
27
+ resourceDomains: resultDomains,
28
+ },
29
+ prefersBorder: true,
30
+ },
31
+ 'openai/widgetDescription': widgetDescription,
32
+ 'openai/widgetCSP': {
33
+ connect_domains: resultDomains,
34
+ resource_domains: resultDomains,
35
+ },
36
+ 'openai/widgetPrefersBorder': true,
37
+ });
38
+ /**
39
+ * The widget document. Everything is inline (no external scripts) so it runs under the
40
+ * restrictive default MCP Apps sandbox CSP; the host only needs to allow result origins.
41
+ */
42
+ export const assemblyResultWidgetHtml = `<!doctype html>
43
+ <html lang="en">
44
+ <head>
45
+ <meta charset="utf-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1">
47
+ <title>Transloadit Assembly result</title>
48
+ <style>
49
+ :root {
50
+ color-scheme: light dark;
51
+ --fg: #1a1d21; --muted: #5f6672; --bg: #ffffff; --card: #f5f7fa; --border: #e1e5eb; --accent: #1b61a7; --ok: #1f8a4c; --err: #c0392b;
52
+ }
53
+ :root[data-theme="dark"] {
54
+ --fg: #f1f3f5; --muted: #a2a8b3; --bg: #121417; --card: #1b1e23; --border: #2c313a; --accent: #7db8f2; --ok: #5ccf8a; --err: #f28b7d;
55
+ }
56
+ @media (prefers-color-scheme: dark) {
57
+ :root:not([data-theme="light"]) {
58
+ --fg: #f1f3f5; --muted: #a2a8b3; --bg: #121417; --card: #1b1e23; --border: #2c313a; --accent: #7db8f2; --ok: #5ccf8a; --err: #f28b7d;
59
+ }
60
+ }
61
+ * { box-sizing: border-box; }
62
+ body { margin: 0; padding: 12px; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); }
63
+ a { color: var(--accent); }
64
+ header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; }
65
+ header h1 { font-size: 15px; margin: 0; }
66
+ .badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); }
67
+ .badge.ok { color: var(--ok); border-color: var(--ok); }
68
+ .badge.err { color: var(--err); border-color: var(--err); }
69
+ .actions { margin-left: auto; display: flex; gap: 8px; }
70
+ .button { display: inline-block; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); color: #fff; text-decoration: none; font-size: 13px; cursor: pointer; }
71
+ .button.secondary { background: transparent; color: var(--accent); }
72
+ section.step { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; background: var(--card); }
73
+ section.step h2 { font-size: 13px; margin: 0 0 8px; font-weight: 600; }
74
+ section.step h2 code { font-weight: 600; }
75
+ .files { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
76
+ figure { margin: 0; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); overflow: hidden; }
77
+ figure .preview { display: flex; align-items: center; justify-content: center; min-height: 72px; max-height: 220px; background: #0000000d; }
78
+ figure img, figure video { display: block; max-width: 100%; max-height: 220px; }
79
+ figure audio { width: 100%; }
80
+ figure .file { padding: 12px; font-size: 24px; color: var(--muted); }
81
+ figcaption { padding: 6px 8px; font-size: 12px; }
82
+ figcaption .name { display: block; font-weight: 600; word-break: break-all; }
83
+ .muted { color: var(--muted); }
84
+ .error { color: var(--err); }
85
+ details { margin-top: 10px; font-size: 12px; }
86
+ pre { white-space: pre-wrap; word-break: break-word; font-size: 11px; background: var(--card); padding: 8px; border-radius: 6px; }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <main id="app"><p class="muted">Waiting for the Assembly result…</p></main>
91
+ <script>
92
+ (() => {
93
+ const app = document.getElementById('app')
94
+ const appInfo = ${JSON.stringify(widgetAppInfo)}
95
+ const protocolVersion = ${JSON.stringify(widgetProtocolVersion)}
96
+ const contextKey = ${JSON.stringify(widgetContextMetaKey)}
97
+ const hostCapabilities = { openLinks: false }
98
+ let hasResult = false
99
+
100
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
101
+ const text = (value) => (typeof value === 'string' ? value : '')
102
+
103
+ const safeUrl = (value) => {
104
+ if (typeof value !== 'string') return null
105
+ try {
106
+ const url = new URL(value)
107
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ const el = (tag, props, children) => {
114
+ const node = document.createElement(tag)
115
+ for (const [key, value] of Object.entries(props || {})) {
116
+ if (value === undefined || value === null) continue
117
+ if (key === 'className') node.className = value
118
+ else if (key === 'text') node.textContent = value
119
+ else node.setAttribute(key, value)
120
+ }
121
+ for (const child of children || []) {
122
+ if (child) node.append(child)
123
+ }
124
+ return node
125
+ }
126
+
127
+ const showStatus = (message, className) => {
128
+ app.replaceChildren(el('p', { className: className || 'muted', role: 'status', text: message }))
129
+ }
130
+
131
+ // MCP Apps transport: JSON-RPC 2.0 over postMessage with the host (or its sandbox proxy).
132
+ const post = (message) => {
133
+ if (window.parent === window) return
134
+ window.parent.postMessage(message, '*')
135
+ }
136
+
137
+ const pending = new Map()
138
+ let nextId = 1
139
+ const request = (method, params) =>
140
+ new Promise((resolve, reject) => {
141
+ const id = nextId++
142
+ pending.set(id, { resolve, reject })
143
+ post({ jsonrpc: '2.0', id, method, params })
144
+ })
145
+ const notify = (method, params) =>
146
+ post(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
147
+ const respond = (id, result) => post({ jsonrpc: '2.0', id, result })
148
+ const respondError = (id, code, message) => post({ jsonrpc: '2.0', id, error: { code, message } })
149
+
150
+ const applyHostContext = (context) => {
151
+ if (!isRecord(context)) return
152
+ if (context.theme === 'dark' || context.theme === 'light') {
153
+ document.documentElement.dataset.theme = context.theme
154
+ }
155
+ }
156
+
157
+ // Decided synchronously: preventDefault() after an await would be too late to stop navigation.
158
+ const openWithHost = (url) => {
159
+ const openai = window.openai
160
+ if (openai && typeof openai.openExternal === 'function') {
161
+ Promise.resolve(openai.openExternal({ href: url })).catch(() => {})
162
+ return true
163
+ }
164
+ if (hostCapabilities.openLinks) {
165
+ request('ui/open-link', { url }).catch(() => {})
166
+ return true
167
+ }
168
+ return false
169
+ }
170
+
171
+ // Sandboxed frames often block popups and downloads, so every outgoing link goes through the
172
+ // host when it offers ui/open-link (or ChatGPT's openExternal).
173
+ const hostLink = (props, url) => {
174
+ const anchor = el('a', { href: url, target: '_blank', rel: 'noopener noreferrer', ...props })
175
+ anchor.addEventListener('click', (event) => {
176
+ if (openWithHost(url)) event.preventDefault()
177
+ })
178
+ return anchor
179
+ }
180
+
181
+ const linkButton = (label, url, secondary) =>
182
+ hostLink({ className: secondary ? 'button secondary' : 'button', text: label }, url)
183
+
184
+ // Result files can land on the public bucket a moment after the Assembly reports completion,
185
+ // and a failed <img>/<video> never retries by itself.
186
+ const previewRetryDelays = [1000, 3000, 6000]
187
+ const retryPreview = (media, url) => {
188
+ let attempt = 0
189
+ media.addEventListener('error', () => {
190
+ if (attempt >= previewRetryDelays.length) {
191
+ media.replaceWith(el('span', { className: 'muted', role: 'status', text: 'Preview not available yet' }))
192
+ return
193
+ }
194
+ setTimeout(() => {
195
+ media.removeAttribute('src')
196
+ media.setAttribute('src', url)
197
+ if (typeof media.load === 'function') media.load()
198
+ }, previewRetryDelays[attempt])
199
+ attempt += 1
200
+ })
201
+ return media
202
+ }
203
+
204
+ const preview = (file) => {
205
+ const url = safeUrl(file.ssl_url) || safeUrl(file.url)
206
+ const mime = text(file.mime)
207
+ const name = text(file.name) || text(file.basename) || 'file'
208
+ const box = el('div', { className: 'preview' })
209
+ if (url && mime.startsWith('image/')) {
210
+ box.append(retryPreview(el('img', { src: url, alt: name, loading: 'lazy' }), url))
211
+ } else if (url && mime.startsWith('video/')) {
212
+ box.append(retryPreview(el('video', { src: url, controls: '', preload: 'metadata', playsinline: '' }), url))
213
+ } else if (url && mime.startsWith('audio/')) {
214
+ box.append(retryPreview(el('audio', { src: url, controls: '', preload: 'metadata' }), url))
215
+ } else {
216
+ box.append(el('span', { className: 'file', 'aria-hidden': 'true', text: '📄' }))
217
+ }
218
+ return box
219
+ }
220
+
221
+ const fileCard = (file) => {
222
+ const url = safeUrl(file.ssl_url) || safeUrl(file.url)
223
+ const name = text(file.name) || text(file.basename) || 'file'
224
+ const details = []
225
+ if (typeof file.width === 'number' && typeof file.height === 'number') {
226
+ details.push(file.width + '×' + file.height)
227
+ }
228
+ if (text(file.size_human)) details.push(text(file.size_human))
229
+ if (text(file.mime)) details.push(text(file.mime))
230
+ const caption = el('figcaption', {}, [
231
+ el('span', { className: 'name', text: name }),
232
+ el('span', { className: 'muted', text: details.join(' · ') }),
233
+ url ? el('div', {}, [hostLink({ download: '', text: 'Download ' + name }, url)]) : null,
234
+ ])
235
+ return el('figure', {}, [preview(isRecord(file) ? file : {}), caption])
236
+ }
237
+
238
+ // Each tool error as "message hint", so recovery advice is shown next to what failed.
239
+ const payloadErrors = (payload) =>
240
+ (Array.isArray(payload.errors) ? payload.errors : [])
241
+ .map((error) => (isRecord(error) ? [text(error.message), text(error.hint)].filter(Boolean).join(' ') : ''))
242
+ .filter(Boolean)
243
+
244
+ const errorText = (result, payload) => {
245
+ const fromPayload = payloadErrors(payload)
246
+ if (fromPayload.length > 0) return fromPayload.join(' ')
247
+ const content = Array.isArray(result.content) ? result.content : []
248
+ return content
249
+ .map((block) => (isRecord(block) && block.type === 'text' ? text(block.text) : ''))
250
+ .filter(Boolean)
251
+ .join(' ')
252
+ }
253
+
254
+ const render = (result) => {
255
+ if (!isRecord(result)) return
256
+ hasResult = true
257
+ app.replaceChildren()
258
+ const payload = isRecord(result.structuredContent) ? result.structuredContent : {}
259
+ const meta = isRecord(result._meta) && isRecord(result._meta[contextKey]) ? result._meta[contextKey] : {}
260
+ const assembly = isRecord(payload.assembly) ? payload.assembly : null
261
+
262
+ if (!assembly) {
263
+ app.append(el('p', { className: 'error', role: 'alert', text: errorText(result, payload) || 'No Assembly result was returned.' }))
264
+ return
265
+ }
266
+
267
+ const ok = text(assembly.ok)
268
+ const failed = Boolean(assembly.error)
269
+ const status = failed ? text(assembly.error) : ok || 'ASSEMBLY_EXECUTING'
270
+ const header = el('header', {}, [
271
+ el('h1', { text: 'Assembly ' + (text(assembly.assembly_id) || '') }),
272
+ el('span', { className: 'badge ' + (failed ? 'err' : ok === 'ASSEMBLY_COMPLETED' ? 'ok' : ''), text: status }),
273
+ ])
274
+ const actions = el('div', { className: 'actions' })
275
+ const consoleUrl = safeUrl(meta.assembly_console_url)
276
+ if (consoleUrl) actions.append(linkButton('Open in Console', consoleUrl, true))
277
+ const templateUrl = safeUrl(meta.new_template_url)
278
+ if (meta.authenticated === true && templateUrl) actions.append(linkButton('Save as Template', templateUrl, false))
279
+ if (actions.childElementCount > 0) header.append(actions)
280
+ app.append(header)
281
+
282
+ if (failed && text(assembly.message)) {
283
+ app.append(el('p', { className: 'error', role: 'alert', text: text(assembly.message) }))
284
+ }
285
+ // A tool error can come with a partial Assembly (created, but its status unreadable).
286
+ const toolErrors = payloadErrors(payload)
287
+ for (const message of toolErrors) {
288
+ app.append(el('p', { className: 'error', role: 'alert', text: message }))
289
+ }
290
+
291
+ const results = isRecord(assembly.results) ? assembly.results : {}
292
+ const uploads = Array.isArray(assembly.uploads) ? assembly.uploads : []
293
+ const steps = Object.entries(results).filter(([, files]) => Array.isArray(files) && files.length > 0)
294
+ if (uploads.length > 0) steps.unshift([':original', uploads])
295
+
296
+ if (steps.length === 0 && toolErrors.length === 0) {
297
+ app.append(el('p', { className: 'muted', text: 'No result files yet.' }))
298
+ }
299
+ for (const [stepName, files] of steps) {
300
+ const section = el('section', { className: 'step' }, [
301
+ el('h2', {}, [el('code', { text: stepName }), el('span', { className: 'muted', text: ' · ' + files.length + (files.length === 1 ? ' file' : ' files') })]),
302
+ el('div', { className: 'files' }, files.filter(isRecord).map(fileCard)),
303
+ ])
304
+ app.append(section)
305
+ }
306
+
307
+ const warnings = Array.isArray(payload.warnings) ? payload.warnings : []
308
+ for (const warning of warnings) {
309
+ if (isRecord(warning) && text(warning.message)) {
310
+ app.append(el('p', { className: 'muted', text: warning.message }))
311
+ }
312
+ }
313
+ }
314
+
315
+ const handleHostRequest = (message) => {
316
+ if (message.method === 'ping' || message.method === 'ui/resource-teardown') {
317
+ respond(message.id, {})
318
+ return
319
+ }
320
+ respondError(message.id, -32601, 'Method not found: ' + text(message.method))
321
+ }
322
+
323
+ const handleHostNotification = (message) => {
324
+ if (message.method === 'ui/notifications/tool-input') {
325
+ if (!hasResult) showStatus('Running the Assembly…')
326
+ } else if (message.method === 'ui/notifications/tool-result') {
327
+ render(message.params)
328
+ } else if (message.method === 'ui/notifications/tool-cancelled') {
329
+ if (!hasResult) showStatus('The tool call was cancelled.')
330
+ } else if (message.method === 'ui/notifications/host-context-changed') {
331
+ applyHostContext(message.params)
332
+ }
333
+ }
334
+
335
+ window.addEventListener('message', (event) => {
336
+ if (event.source !== window.parent) return
337
+ const message = event.data
338
+ if (!isRecord(message) || message.jsonrpc !== '2.0') return
339
+ if (typeof message.method === 'string') {
340
+ if ('id' in message) handleHostRequest(message)
341
+ else handleHostNotification(message)
342
+ return
343
+ }
344
+ const entry = pending.get(message.id)
345
+ if (!entry) return
346
+ pending.delete(message.id)
347
+ if (message.error) entry.reject(message.error)
348
+ else entry.resolve(message.result)
349
+ })
350
+
351
+ // ChatGPT also exposes the result through window.openai; render from there when present.
352
+ const renderFromOpenAi = () => {
353
+ const openai = window.openai
354
+ if (!openai) return
355
+ applyHostContext({ theme: openai.theme })
356
+ if (isRecord(openai.toolOutput)) {
357
+ render({ structuredContent: openai.toolOutput, _meta: openai.toolResponseMetadata })
358
+ }
359
+ }
360
+ window.addEventListener('openai:set_globals', renderFromOpenAi)
361
+ renderFromOpenAi()
362
+
363
+ if (window.parent !== window) {
364
+ request('ui/initialize', {
365
+ appCapabilities: {},
366
+ appInfo,
367
+ protocolVersion,
368
+ })
369
+ .then((result) => {
370
+ if (isRecord(result) && isRecord(result.hostCapabilities)) {
371
+ hostCapabilities.openLinks = Boolean(result.hostCapabilities.openLinks)
372
+ }
373
+ if (isRecord(result)) applyHostContext(result.hostContext)
374
+ notify('ui/notifications/initialized')
375
+ })
376
+ .catch(() => {
377
+ if (!hasResult) showStatus('This host could not start the Assembly result view.', 'error')
378
+ })
379
+
380
+ if (typeof ResizeObserver === 'function') {
381
+ new ResizeObserver(() => {
382
+ notify('ui/notifications/size-changed', {
383
+ width: document.documentElement.scrollWidth,
384
+ height: document.documentElement.scrollHeight,
385
+ })
386
+ }).observe(document.body)
387
+ }
388
+ }
389
+ })()
390
+ </script>
391
+ </body>
392
+ </html>
393
+ `;
394
+ /** Registers the widget so hosts can `resources/read` it through `_meta.ui.resourceUri`. */
395
+ export const registerAssemblyResultWidget = (server, options = {}) => {
396
+ const meta = buildAssemblyResultWidgetMeta(options.resultDomains && options.resultDomains.length > 0
397
+ ? options.resultDomains
398
+ : defaultResultDomains);
399
+ server.registerResource('assembly-result', assemblyResultWidgetUri, {
400
+ title: 'Assembly result',
401
+ description: widgetDescription,
402
+ mimeType: assemblyResultWidgetMimeType,
403
+ _meta: meta,
404
+ }, () => ({
405
+ contents: [
406
+ {
407
+ uri: assemblyResultWidgetUri,
408
+ mimeType: assemblyResultWidgetMimeType,
409
+ text: assemblyResultWidgetHtml,
410
+ _meta: meta,
411
+ },
412
+ ],
413
+ }));
414
+ };
415
+ //# sourceMappingURL=assembly-result-widget.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assembly-result-widget.js","sourceRoot":"","sources":["../../src/ui/assembly-result-widget.ts"],"names":[],"mappings":"AAEA,OAAO,WAAW,MAAM,oBAAoB,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAA;AAElE,gHAAgH;AAChH,MAAM,CAAC,MAAM,uBAAuB,GAAG,kCAAkC,CAAA;AAEzE,6DAA6D;AAC7D,MAAM,CAAC,MAAM,4BAA4B,GAAG,2BAA2B,CAAA;AAEvE;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,2BAA2B;IAC3B,2BAA2B;IAC3B,kBAAkB;CACnB,CAAA;AAED,yFAAyF;AACzF,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAAY,CAAA;AAEjD,yDAAyD;AACzD,MAAM,CAAC,MAAM,aAAa,GAAG,EAAE,IAAI,EAAE,6BAA6B,EAAE,OAAO,EAAE,WAAW,CAAC,OAAO,EAAE,CAAA;AAElG,qFAAqF;AACrF,MAAM,CAAC,MAAM,oBAAoB,GAAG,oBAAoB,CAAA;AAQxD,MAAM,iBAAiB,GACrB,oKAAoK,CAAA;AAEtK,iFAAiF;AACjF,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAC3C,gBAA0B,oBAAoB,EACrB,EAAE,CAAC,CAAC;IAC7B,EAAE,EAAE;QACF,GAAG,EAAE;YACH,cAAc,EAAE,aAAa;YAC7B,eAAe,EAAE,aAAa;SAC/B;QACD,aAAa,EAAE,IAAI;KACpB;IACD,0BAA0B,EAAE,iBAAiB;IAC7C,kBAAkB,EAAE;QAClB,eAAe,EAAE,aAAa;QAC9B,gBAAgB,EAAE,aAAa;KAChC;IACD,4BAA4B,EAAE,IAAI;CACnC,CAAC,CAAA;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oBAoDpB,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC;4BACrB,IAAI,CAAC,SAAS,CAAC,qBAAqB,CAAC;uBAC1C,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyS1D,CAAA;AAOD,4FAA4F;AAC5F,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAC1C,MAAiB,EACjB,UAAuC,EAAE,EACnC,EAAE;IACR,MAAM,IAAI,GAAG,6BAA6B,CACxC,OAAO,CAAC,aAAa,IAAI,OAAO,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC;QACvD,CAAC,CAAC,OAAO,CAAC,aAAa;QACvB,CAAC,CAAC,oBAAoB,CACzB,CAAA;IACD,MAAM,CAAC,gBAAgB,CACrB,iBAAiB,EACjB,uBAAuB,EACvB;QACE,KAAK,EAAE,iBAAiB;QACxB,WAAW,EAAE,iBAAiB;QAC9B,QAAQ,EAAE,4BAA4B;QACtC,KAAK,EAAE,IAAI;KACZ,EACD,GAAG,EAAE,CAAC,CAAC;QACL,QAAQ,EAAE;YACR;gBACE,GAAG,EAAE,uBAAuB;gBAC5B,QAAQ,EAAE,4BAA4B;gBACtC,IAAI,EAAE,wBAAwB;gBAC9B,KAAK,EAAE,IAAI;aACZ;SACF;KACF,CAAC,CACH,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ /** One out-of-band tus upload the caller runs where the file lives, such as an agent sandbox. */
3
+ export declare const uploadInstructionSchema: z.ZodObject<{
4
+ fieldname: z.ZodString;
5
+ tus_endpoint: z.ZodString;
6
+ metadata: z.ZodObject<{
7
+ assembly_url: z.ZodString;
8
+ fieldname: z.ZodString;
9
+ }, z.core.$strip>;
10
+ curl: z.ZodString;
11
+ }, z.core.$strip>;
12
+ export type UploadInstruction = z.infer<typeof uploadInstructionSchema>;
13
+ /**
14
+ * Builds a single `curl` command per expected upload, using tus creation-with-upload (one POST
15
+ * with the file as body), which Transloadit's tusd advertises; it prints `201` on success. The
16
+ * shell derives `Upload-Length` and the base64 filename from the file, so any local name works.
17
+ * `-T` streams the file from disk (`--data-binary @file` would read it into memory first), and
18
+ * `--request-target` keeps curl from appending the local filename to the endpoint path.
19
+ *
20
+ * The Assembly URL in the metadata is the only capability these commands carry: whoever holds it
21
+ * can add files to this Assembly while it waits for uploads. Credentials (Auth Keys, secrets,
22
+ * bearer tokens) must never be added here, because the commands are shown to the model and run
23
+ * in its sandbox.
24
+ */
25
+ export declare const buildUploadInstructions: (assembly: {
26
+ tus_url?: unknown;
27
+ assembly_ssl_url?: unknown;
28
+ }, count: number, takenFieldnames: ReadonlySet<string>) => UploadInstruction[] | undefined;
29
+ //# sourceMappingURL=upload-instructions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-instructions.d.ts","sourceRoot":"","sources":["../src/upload-instructions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,iGAAiG;AACjG,eAAO,MAAM,uBAAuB;;;;;;;;iBAKlC,CAAA;AAEF,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAA;AAgBvE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,GAClC,UAAU;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAAE,EAC3D,OAAO,MAAM,EACb,iBAAiB,WAAW,CAAC,MAAM,CAAC,KACnC,iBAAiB,EAAE,GAAG,SAyBxB,CAAA"}
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ /** One out-of-band tus upload the caller runs where the file lives, such as an agent sandbox. */
3
+ export const uploadInstructionSchema = z.object({
4
+ fieldname: z.string(),
5
+ tus_endpoint: z.string(),
6
+ metadata: z.object({ assembly_url: z.string(), fieldname: z.string() }),
7
+ curl: z.string(),
8
+ });
9
+ const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
10
+ const toBase64 = (value) => Buffer.from(value, 'utf8').toString('base64');
11
+ /** Field names `file_1`, `file_2`, … that files sent with the same call do not use yet. */
12
+ const pickFieldnames = (count, taken) => {
13
+ const fieldnames = [];
14
+ for (let index = 1; fieldnames.length < count; index += 1) {
15
+ const fieldname = `file_${index}`;
16
+ if (!taken.has(fieldname))
17
+ fieldnames.push(fieldname);
18
+ }
19
+ return fieldnames;
20
+ };
21
+ /**
22
+ * Builds a single `curl` command per expected upload, using tus creation-with-upload (one POST
23
+ * with the file as body), which Transloadit's tusd advertises; it prints `201` on success. The
24
+ * shell derives `Upload-Length` and the base64 filename from the file, so any local name works.
25
+ * `-T` streams the file from disk (`--data-binary @file` would read it into memory first), and
26
+ * `--request-target` keeps curl from appending the local filename to the endpoint path.
27
+ *
28
+ * The Assembly URL in the metadata is the only capability these commands carry: whoever holds it
29
+ * can add files to this Assembly while it waits for uploads. Credentials (Auth Keys, secrets,
30
+ * bearer tokens) must never be added here, because the commands are shown to the model and run
31
+ * in its sandbox.
32
+ */
33
+ export const buildUploadInstructions = (assembly, count, takenFieldnames) => {
34
+ const tusEndpoint = assembly.tus_url;
35
+ const assemblyUrl = assembly.assembly_ssl_url;
36
+ if (typeof tusEndpoint !== 'string' || typeof assemblyUrl !== 'string')
37
+ return undefined;
38
+ if (!URL.canParse(tusEndpoint))
39
+ return undefined;
40
+ const { pathname, search } = new URL(tusEndpoint);
41
+ return pickFieldnames(count, takenFieldnames).map((fieldname) => {
42
+ const metadata = `assembly_url ${toBase64(assemblyUrl)},fieldname ${toBase64(fieldname)}`;
43
+ const curl = [
44
+ `FILE='/path/to/the/file'`,
45
+ `curl -sS -o /dev/null -w '%{http_code}\\n' -X POST -T "$FILE" \\`,
46
+ ` --request-target ${shellQuote(`${pathname}${search}`)} ${shellQuote(tusEndpoint)} \\`,
47
+ ` -H 'Tus-Resumable: 1.0.0' \\`,
48
+ ` -H "Upload-Length: $(wc -c < "$FILE" | tr -d ' ')" \\`,
49
+ ` -H "Upload-Metadata: ${metadata},filename $(basename "$FILE" | tr -d '\\n' | base64 | tr -d '\\n')" \\`,
50
+ ` -H 'Content-Type: application/offset+octet-stream'`,
51
+ ].join('\n');
52
+ return {
53
+ fieldname,
54
+ tus_endpoint: tusEndpoint,
55
+ metadata: { assembly_url: assemblyUrl, fieldname },
56
+ curl,
57
+ };
58
+ });
59
+ };
60
+ //# sourceMappingURL=upload-instructions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload-instructions.js","sourceRoot":"","sources":["../src/upload-instructions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,iGAAiG;AACjG,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9C,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE;IACxB,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;IACvE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;CACjB,CAAC,CAAA;AAIF,MAAM,UAAU,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAA;AAEnF,MAAM,QAAQ,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAA;AAEzF,2FAA2F;AAC3F,MAAM,cAAc,GAAG,CAAC,KAAa,EAAE,KAA0B,EAAY,EAAE;IAC7E,MAAM,UAAU,GAAa,EAAE,CAAA;IAC/B,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,UAAU,CAAC,MAAM,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC1D,MAAM,SAAS,GAAG,QAAQ,KAAK,EAAE,CAAA;QACjC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IACvD,CAAC;IACD,OAAO,UAAU,CAAA;AACnB,CAAC,CAAA;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CACrC,QAA2D,EAC3D,KAAa,EACb,eAAoC,EACH,EAAE;IACnC,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAA;IACpC,MAAM,WAAW,GAAG,QAAQ,CAAC,gBAAgB,CAAA;IAC7C,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,OAAO,WAAW,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAA;IACxF,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC;QAAE,OAAO,SAAS,CAAA;IAChD,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAA;IAEjD,OAAO,cAAc,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;QAC9D,MAAM,QAAQ,GAAG,gBAAgB,QAAQ,CAAC,WAAW,CAAC,cAAc,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAA;QACzF,MAAM,IAAI,GAAG;YACX,0BAA0B;YAC1B,kEAAkE;YAClE,sBAAsB,UAAU,CAAC,GAAG,QAAQ,GAAG,MAAM,EAAE,CAAC,IAAI,UAAU,CAAC,WAAW,CAAC,KAAK;YACxF,gCAAgC;YAChC,yDAAyD;YACzD,0BAA0B,QAAQ,wEAAwE;YAC1G,sDAAsD;SACvD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACZ,OAAO;YACL,SAAS;YACT,YAAY,EAAE,WAAW;YACzB,QAAQ,EAAE,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE;YAClD,IAAI;SACL,CAAA;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transloadit/mcp-server",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Transloadit MCP server",
5
5
  "keywords": [
6
6
  "mcp",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@modelcontextprotocol/sdk": "^1.29.0",
65
- "@transloadit/node": "^5.1.0",
65
+ "@transloadit/node": "^5.2.0",
66
66
  "@transloadit/sev-logger": "^0.1.9",
67
67
  "express": "^5.2.1",
68
68
  "prom-client": "^15.1.3",
@@ -71,6 +71,7 @@
71
71
  "devDependencies": {
72
72
  "@types/express": "^5.0.6",
73
73
  "@types/node": "^25.8.0",
74
+ "happy-dom": "^20.9.0",
74
75
  "nock": "^14.0.15"
75
76
  },
76
77
  "mcpName": "io.github.transloadit/mcp-server"