@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
package/dist/server.js CHANGED
@@ -2,8 +2,21 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { ApiError, extractFieldNamesFromTemplate, getRobotHelp, isKnownRobot, listRobots, mergeTemplateContent, prepareInputFiles, Transloadit, } from '@transloadit/node';
3
3
  import { z } from 'zod';
4
4
  import packageJson from '../package.json' with { type: 'json' };
5
- import { extractBearerToken } from "./http-helpers.js";
5
+ import { buildBearerChallenge, extractBearerToken } from "./http-helpers.js";
6
+ import { assertServerOptions } from "./options.js";
7
+ import { mirrorSecuritySchemes } from "./tool-list.js";
8
+ import { buildToolMeta, resolveSecuritySchemes, resolveToolAuthMode, toolMetadata, } from "./tool-metadata.js";
9
+ import { registerAssemblyResultWidget, widgetContextMetaKey } from "./ui/assembly-result-widget.js";
10
+ import { buildUploadInstructions, uploadInstructionSchema } from "./upload-instructions.js";
11
+ const defaultConsoleUrl = 'https://transloadit.com';
12
+ /** Signature algorithms API2 accepts for Auth Key signatures. */
13
+ export const signatureAlgorithmSchema = z.enum(['sha1', 'sha256', 'sha384']);
14
+ /** Header that carries `upstreamSecret` on API2 calls made with a forwarded bearer token. */
15
+ export const upstreamSecretHeader = 'Transloadit-Mcp-Upstream';
6
16
  const maxBase64Bytes = 512_000;
17
+ /** URL inputs beyond these limits are not downloaded to this server's disk (per call). */
18
+ const defaultMaxUrlDownloadBytes = 1024 * 1024 * 1024;
19
+ const defaultUrlDownloadTimeoutMs = 10 * 60 * 1000;
7
20
  const lintIssueSchema = z.object({
8
21
  path: z.string(),
9
22
  message: z.string(),
@@ -82,16 +95,28 @@ const inputFileSchema = z.discriminatedUnion('kind', [
82
95
  contentType: z.string().optional(),
83
96
  }),
84
97
  ]);
98
+ // Exactly the file object ChatGPT hydrates for `_meta["openai/fileParams"]`: the two ids are
99
+ // required, the descriptive fields optional, and nothing else is allowed.
100
+ const hostFileSchema = z
101
+ .object({
102
+ download_url: z.string(),
103
+ file_id: z.string(),
104
+ mime_type: z.string().optional(),
105
+ file_name: z.string().optional(),
106
+ })
107
+ .strict();
85
108
  const createAssemblyInputSchema = z.object({
86
109
  instructions: z.unknown().optional(),
87
110
  files: z.array(inputFileSchema).optional(),
111
+ attachments: z.array(hostFileSchema).optional(),
88
112
  fields: z.record(z.string(), z.unknown()).optional(),
89
113
  wait_for_completion: z.boolean().optional(),
90
114
  wait_timeout_ms: z.number().int().positive().optional(),
91
115
  upload_concurrency: z.number().int().positive().optional(),
92
116
  upload_chunk_size: z.number().int().positive().optional(),
93
117
  upload_behavior: z.enum(['await', 'background', 'none']).optional(),
94
- expected_uploads: z.number().int().positive().optional(),
118
+ // Each expected upload becomes an instruction in the response; keep that response bounded.
119
+ expected_uploads: z.number().int().positive().max(100).optional(),
95
120
  assembly_url: z.string().optional(),
96
121
  });
97
122
  const createAssemblyOutputSchema = z.object({
@@ -105,6 +130,7 @@ const createAssemblyOutputSchema = z.object({
105
130
  upload_urls: z.record(z.string(), z.string()).optional(),
106
131
  })
107
132
  .optional(),
133
+ upload_instructions: z.array(uploadInstructionSchema).optional(),
108
134
  next_steps: z.array(z.string()).optional(),
109
135
  errors: z.array(toolMessageSchema).optional(),
110
136
  warnings: z.array(toolMessageSchema).optional(),
@@ -166,6 +192,15 @@ const lintAssemblyOutputSchema = z.object({
166
192
  linting_issues: z.array(lintIssueSchema),
167
193
  normalized_instructions: z.unknown().optional(),
168
194
  });
195
+ const getProfileInputSchema = z.object({}).strict();
196
+ // Shape ChatGPT expects from a profile tool: an opaque stable id plus optional display fields.
197
+ const getProfileOutputSchema = z
198
+ .object({
199
+ id: z.string().min(1).regex(/\S/),
200
+ name: z.string().optional(),
201
+ nickname: z.string().optional(),
202
+ })
203
+ .strict();
169
204
  const toLintIssues = (issues) => issues.map((issue) => ({
170
205
  path: issue.stepName ? `steps.${issue.stepName}` : 'instructions',
171
206
  message: issue.summary,
@@ -180,7 +215,7 @@ const safeJsonParse = (value) => {
180
215
  return value;
181
216
  }
182
217
  };
183
- const buildToolResponse = (payload) => {
218
+ const buildToolResponse = (payload, extras = {}) => {
184
219
  const content = {
185
220
  type: 'text',
186
221
  text: JSON.stringify(payload),
@@ -188,8 +223,52 @@ const buildToolResponse = (payload) => {
188
223
  return {
189
224
  content: [content],
190
225
  structuredContent: payload,
226
+ ...(extras.isError ? { isError: true } : {}),
227
+ ...(extras.meta ? { _meta: extras.meta } : {}),
191
228
  };
192
229
  };
230
+ /**
231
+ * The SDK client validates `structuredContent` against a tool's output schema even on `isError`
232
+ * results, so tools whose schema cannot describe an error (Template lists, the strict profile)
233
+ * return errors as text only. The text still carries the code and hint as JSON.
234
+ */
235
+ const withoutStructuredContent = (result) => {
236
+ const { structuredContent: _structuredContent, ...rest } = result;
237
+ return rest;
238
+ };
239
+ /**
240
+ * Tool-level auth failure. `_meta["mcp/www_authenticate"]` mirrors the HTTP challenge so ChatGPT
241
+ * and Claude open their account-linking UI; the text content keeps the reason readable.
242
+ */
243
+ const buildAuthError = (options, input) => buildToolResponse({
244
+ status: 'error',
245
+ errors: [{ code: input.code, message: input.message, hint: input.hint }],
246
+ }, {
247
+ isError: true,
248
+ meta: {
249
+ 'mcp/www_authenticate': [
250
+ buildBearerChallenge({
251
+ resourceMetadataUrl: options.resourceMetadataUrl,
252
+ error: { code: input.oauthError, description: input.message },
253
+ scopes: input.scopes,
254
+ }),
255
+ ],
256
+ },
257
+ });
258
+ const buildMissingAuthError = (options) =>
259
+ // Only a hosted server has an authorization server a host could link an account with.
260
+ options.resourceMetadataUrl
261
+ ? buildAuthError(options, {
262
+ code: 'mcp_missing_auth',
263
+ oauthError: 'insufficient_scope',
264
+ message: 'Sign in to Transloadit to use this tool.',
265
+ hint: 'Connect your Transloadit account through OAuth, then retry.',
266
+ })
267
+ : buildCredentialError({
268
+ code: 'mcp_missing_auth',
269
+ message: 'This server has no Transloadit credentials for this tool.',
270
+ hint: 'Set TRANSLOADIT_KEY/TRANSLOADIT_SECRET or send an Authorization: Bearer token.',
271
+ });
193
272
  const buildToolError = (code, message, options = {}) => buildToolResponse({
194
273
  status: 'error',
195
274
  errors: [
@@ -234,27 +313,32 @@ const createLiveClient = (options, extra) => {
234
313
  const authToken = token && token !== options.mcpToken ? token : undefined;
235
314
  if (authToken) {
236
315
  return {
316
+ credentials: 'bearer',
237
317
  client: new Transloadit({
238
318
  authToken,
239
319
  authKey: options.authKey,
240
320
  authSecret: options.authSecret,
241
321
  endpoint: options.endpoint,
242
322
  clientName: getClientName(options),
323
+ signatureAlgorithm: options.signatureAlgorithm,
243
324
  followRedirects: false,
325
+ extraHeaders: options.upstreamSecret
326
+ ? { [upstreamSecretHeader]: options.upstreamSecret }
327
+ : undefined,
244
328
  }),
245
329
  };
246
330
  }
247
331
  if (!options.authKey || !options.authSecret) {
248
- return {
249
- error: buildToolError('mcp_missing_auth', 'Missing TRANSLOADIT_KEY/TRANSLOADIT_SECRET or Authorization: Bearer token for live API calls.'),
250
- };
332
+ return { error: buildMissingAuthError(options) };
251
333
  }
252
334
  return {
335
+ credentials: 'auth-key',
253
336
  client: new Transloadit({
254
337
  authKey: options.authKey,
255
338
  authSecret: options.authSecret,
256
339
  endpoint: options.endpoint,
257
340
  clientName: getClientName(options),
341
+ signatureAlgorithm: options.signatureAlgorithm,
258
342
  followRedirects: false,
259
343
  }),
260
344
  };
@@ -284,6 +368,134 @@ const getHttpStatusCode = (error) => {
284
368
  return undefined;
285
369
  };
286
370
  const isErrnoException = (value) => isRecord(value) && typeof value.code === 'string';
371
+ /**
372
+ * Maps API2 rejections of the forwarded credentials to a tool auth error. Other failures are
373
+ * left to the caller so they keep surfacing as ordinary tool errors.
374
+ */
375
+ const toAuthRejection = (options, error, credentials, scopes) => {
376
+ const status = getHttpStatusCode(error);
377
+ // The token or key is valid but names a different Auth Key than the instructions'
378
+ // `auth.key`; reconnecting cannot fix that, so it is reported like any other bad argument.
379
+ if (error instanceof ApiError && error.code === 'BEARER_TOKEN_AUTH_KEY_MISMATCH') {
380
+ return buildCredentialError({
381
+ code: 'mcp_auth_key_mismatch',
382
+ message: 'The instructions name a different Auth Key than the connected credentials.',
383
+ hint: 'Remove auth.key from the instructions; the connected credentials supply it.',
384
+ });
385
+ }
386
+ // INSUFFICIENT_AUTH_SCOPE is API2's only scope rejection.
387
+ const scopeRejected = status === 403 && error instanceof ApiError && error.code === 'INSUFFICIENT_AUTH_SCOPE';
388
+ // Only forwarded tokens can be renewed by the host's account linking; an Auth Key configured
389
+ // on the server needs an operator, so it gets no OAuth challenge.
390
+ if (credentials === 'auth-key') {
391
+ if (status === 401) {
392
+ return buildCredentialError({
393
+ code: 'mcp_credentials_rejected',
394
+ message: 'Transloadit rejected the Auth Key this server is configured with.',
395
+ hint: 'Check that TRANSLOADIT_KEY and TRANSLOADIT_SECRET belong to an active Auth Key.',
396
+ });
397
+ }
398
+ if (scopeRejected) {
399
+ return buildCredentialError({
400
+ code: 'mcp_insufficient_scope',
401
+ message: 'The configured Auth Key lacks the scope this tool needs.',
402
+ hint: 'Grant the Auth Key the scopes this tool declares, or configure another key.',
403
+ });
404
+ }
405
+ }
406
+ else if (status === 401) {
407
+ return buildAuthError(options, {
408
+ code: 'mcp_auth_rejected',
409
+ oauthError: 'invalid_token',
410
+ message: 'Transloadit rejected the credentials; the token may have expired.',
411
+ hint: 'Reconnect your Transloadit account and retry.',
412
+ });
413
+ }
414
+ else if (scopeRejected) {
415
+ return buildAuthError(options, {
416
+ code: 'mcp_insufficient_scope',
417
+ oauthError: 'insufficient_scope',
418
+ message: 'The connected credentials lack the scope this tool needs.',
419
+ hint: 'Reconnect your Transloadit account and grant the requested access.',
420
+ scopes,
421
+ });
422
+ }
423
+ if (error instanceof ApiError && error.code === 'INVALID_SIGNATURE') {
424
+ return buildSignatureError(error);
425
+ }
426
+ return undefined;
427
+ };
428
+ /** The Assembly exists, but its status could not be read with the caller's credentials. */
429
+ const buildCreatedAssemblyUnavailable = (options, assemblyId) => {
430
+ // Matches resolveAssemblyReference: the SDK's default when the endpoint is empty.
431
+ const assemblyUrl = `${options.endpoint || 'https://api2.transloadit.com'}/assemblies/${assemblyId}`;
432
+ return buildToolResponse({
433
+ status: 'error',
434
+ assembly: { assembly_id: assemblyId, assembly_ssl_url: assemblyUrl },
435
+ errors: [
436
+ {
437
+ code: 'mcp_assembly_status_unavailable',
438
+ message: 'The Assembly was created, but Transloadit rejected the credentials while reading its status.',
439
+ hint: `Reconnect if needed, then call transloadit_get_assembly_status with assembly_url ${assemblyUrl} instead of creating it again.`,
440
+ },
441
+ ],
442
+ }, { isError: true });
443
+ };
444
+ /** A failed call caused by server-side credentials; an error result so strict outputs still pass. */
445
+ const buildCredentialError = (error) => buildToolResponse({ status: 'error', errors: [error] }, { isError: true });
446
+ /**
447
+ * Key/secret signatures fail when the configured algorithm differs from the Auth Key's
448
+ * `signature_algo`; API2 names the required one, so the hint can say exactly what to set.
449
+ */
450
+ const buildSignatureError = (error) => {
451
+ const required = signatureAlgorithmSchema.safeParse(/requires (sha\d+)/.exec(error.rawMessage ?? '')?.[1]);
452
+ return buildCredentialError({
453
+ code: 'mcp_invalid_signature',
454
+ message: 'Transloadit rejected the request signature for this Auth Key.',
455
+ hint: required.success
456
+ ? `This Auth Key signs with ${required.data}: set TRANSLOADIT_SIGNATURE_ALGORITHM=${required.data} (or the signatureAlgorithm option) and restart the MCP server.`
457
+ : 'Check that TRANSLOADIT_SECRET and TRANSLOADIT_SIGNATURE_ALGORITHM match the Auth Key.',
458
+ });
459
+ };
460
+ /** The Console origin and base path without a trailing slash, or `undefined` when unusable. */
461
+ const parseConsoleUrl = (value) => {
462
+ if (!URL.canParse(value))
463
+ return undefined;
464
+ const url = new URL(value);
465
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
466
+ return undefined;
467
+ return `${url.origin}${url.pathname}`.replace(/\/$/, '');
468
+ };
469
+ /** Widget-only context: whether the caller is signed in and where the Console deep links go. */
470
+ const buildWidgetContext = (options, assembly) => {
471
+ // Links are a convenience: a malformed consoleUrl must not turn an Assembly that was already
472
+ // created into a failed call, which would invite a retry that processes the files twice.
473
+ const consoleUrl = parseConsoleUrl(options.consoleUrl || defaultConsoleUrl);
474
+ const slug = isNonEmptyString(assembly.account_slug) ? assembly.account_slug : undefined;
475
+ const assemblyId = isNonEmptyString(assembly.assembly_id) ? assembly.assembly_id : undefined;
476
+ const templateId = isNonEmptyString(assembly.template_id) ? assembly.template_id : undefined;
477
+ if (!slug || !consoleUrl)
478
+ return { authenticated: true };
479
+ const workspaceUrl = `${consoleUrl}/c/${encodeURIComponent(slug)}`;
480
+ const newTemplateUrl = new URL(`${workspaceUrl}/templates/new`);
481
+ // The Console seeds the editor from `fromAssembly` since transloadit/content#6207, which ships
482
+ // together with this release: it loads the Assembly's effective instructions, overrides
483
+ // included. `duplicateFrom` stays as the seed for Consoles that predate it. Hiding or relabeling
484
+ // the action was considered and rejected in review of transloadit/node-sdk#529.
485
+ if (assemblyId) {
486
+ newTemplateUrl.searchParams.set('fromAssembly', assemblyId);
487
+ }
488
+ if (templateId && !isBuiltinTemplateId(templateId)) {
489
+ newTemplateUrl.searchParams.set('duplicateFrom', templateId);
490
+ }
491
+ return {
492
+ authenticated: true,
493
+ assembly_console_url: assemblyId
494
+ ? `${workspaceUrl}/assemblies/${encodeURIComponent(assemblyId)}`
495
+ : undefined,
496
+ new_template_url: newTemplateUrl.toString(),
497
+ };
498
+ };
287
499
  const isHttpImportStep = (value) => isRecord(value) && value.robot === '/http/import';
288
500
  const listHttpImportStepNames = (steps) => Object.entries(steps)
289
501
  .filter(([, step]) => isHttpImportStep(step))
@@ -348,6 +560,8 @@ const mergeFieldValues = (templateFields, argFields) => {
348
560
  };
349
561
  const assemblyIdSchema = z.string().regex(/^[a-f\d]{32}$/i);
350
562
  const assemblyUrlSchema = z.url();
563
+ /** Production (`.com`) and development or tunnel (`.dev`) Transloadit hosts. */
564
+ const isTransloaditHost = (hostname) => ['transloadit.com', 'transloadit.dev'].some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
351
565
  const resolveAssemblyReference = (options, args) => {
352
566
  if (args.assembly_url === undefined && args.assembly_id === undefined) {
353
567
  return { error: buildToolError('mcp_missing_args', 'Provide assembly_url or assembly_id.') };
@@ -361,20 +575,29 @@ const resolveAssemblyReference = (options, args) => {
361
575
  // Match the SDK's default when an environment variable supplies an empty endpoint.
362
576
  const endpoint = options.endpoint || 'https://api2.transloadit.com';
363
577
  let assemblyId = args.assembly_id;
364
- if (args.assembly_url !== undefined) {
578
+ // The message promises "URL or ID", and models pass a bare ID in either field.
579
+ if (args.assembly_url !== undefined && assemblyIdSchema.safeParse(args.assembly_url).success) {
580
+ assemblyId = args.assembly_url;
581
+ }
582
+ else if (args.assembly_url !== undefined) {
365
583
  const parsed = assemblyUrlSchema.safeParse(args.assembly_url);
366
584
  if (!parsed.success)
367
585
  return invalidReference;
368
586
  const url = new URL(parsed.data);
369
587
  const apiEndpoint = new URL(endpoint);
370
588
  const usesConfiguredOrigin = url.origin === apiEndpoint.origin;
371
- const usesTransloaditOrigin = url.hostname.endsWith('.transloadit.com') && url.port === '';
589
+ // A hosted server may answer on a public origin (a tunnel or proxy) other than its API
590
+ // endpoint; Assembly URLs it hands out carry that origin.
591
+ const usesPublicOrigin = options.resourceMetadataUrl !== undefined &&
592
+ URL.canParse(options.resourceMetadataUrl) &&
593
+ url.origin === new URL(options.resourceMetadataUrl).origin;
594
+ const usesTransloaditOrigin = isTransloaditHost(url.hostname) && url.port === '';
372
595
  if ((url.protocol !== 'http:' && url.protocol !== 'https:') ||
373
596
  url.username ||
374
597
  url.password ||
375
598
  url.search ||
376
599
  url.hash ||
377
- (!usesConfiguredOrigin && !usesTransloaditOrigin)) {
600
+ (!usesConfiguredOrigin && !usesPublicOrigin && !usesTransloaditOrigin)) {
378
601
  return invalidReference;
379
602
  }
380
603
  const prefix = usesConfiguredOrigin ? apiEndpoint.pathname.replace(/\/$/, '') : '';
@@ -402,6 +625,7 @@ const resolveAssemblyAccess = (options, extra, args) => {
402
625
  return reference;
403
626
  return {
404
627
  client: liveClient.client,
628
+ credentials: liveClient.credentials,
405
629
  ...reference,
406
630
  };
407
631
  };
@@ -412,6 +636,7 @@ const apiTemplateSchema = z
412
636
  description: z.string().optional(),
413
637
  builtin_version: z.string().optional(),
414
638
  content: z.unknown().optional(),
639
+ account_id: z.string().nullable().optional(),
415
640
  })
416
641
  .passthrough();
417
642
  const listTemplatesResponseSchema = z
@@ -449,6 +674,42 @@ const loadTemplateSteps = async (client, template) => {
449
674
  const full = await client.getTemplate(templateId);
450
675
  return extractTemplateSteps(full.content);
451
676
  };
677
+ /**
678
+ * API2 has no userinfo endpoint yet, so the Workspace behind the credentials is read from the
679
+ * caller's own Assemblies (id, name and slug) or, failing that, from an owned Template (id only).
680
+ */
681
+ const resolveWorkspaceProfile = async (client) => {
682
+ const assemblies = await client.listAssemblies({ pagesize: 1 });
683
+ const latest = assemblies.items[0];
684
+ if (latest?.id) {
685
+ // The details only add the Workspace name; an expired or unreadable Assembly must not hide
686
+ // the id the list already returned, nor skip the Template fallback.
687
+ const status = await client.getAssembly(latest.id).catch(() => ({}));
688
+ const id = isNonEmptyString(status.account_id) ? status.account_id : latest.account_id;
689
+ if (isNonEmptyString(id)) {
690
+ return {
691
+ id,
692
+ name: isNonEmptyString(status.account_name) ? status.account_name : undefined,
693
+ nickname: isNonEmptyString(status.account_slug) ? status.account_slug : undefined,
694
+ };
695
+ }
696
+ }
697
+ // API2's default Template list fields omit account_id, so request it explicitly.
698
+ const templates = listTemplatesResponseSchema.safeParse(await client.listTemplates({ pagesize: 1, fields: ['id', 'account_id'] }));
699
+ const template = templates.success ? templates.data.items?.[0] : undefined;
700
+ if (template && isNonEmptyString(template.account_id)) {
701
+ return { id: template.account_id };
702
+ }
703
+ return undefined;
704
+ };
705
+ /** Host-attached files become URL inputs; `file_id` is opaque, so the field name is positional. */
706
+ const toAttachmentInputs = (attachments) => attachments.map((attachment, index) => ({
707
+ kind: 'url',
708
+ field: `attachment_${index + 1}`,
709
+ url: attachment.download_url,
710
+ filename: attachment.file_name,
711
+ contentType: attachment.mime_type,
712
+ }));
452
713
  const looksLikeAssemblyParams = (input) => {
453
714
  return ('steps' in input ||
454
715
  'template_id' in input ||
@@ -480,17 +741,25 @@ const toAssemblyInstructionsInput = (params) => {
480
741
  return rest;
481
742
  };
482
743
  export const createTransloaditMcpServer = (options = {}) => {
744
+ assertServerOptions(options);
483
745
  const server = new McpServer({
484
746
  name: options.serverName ?? 'Transloadit MCP',
485
747
  version: options.serverVersion ?? packageJson.version,
486
748
  });
749
+ const authMode = resolveToolAuthMode(options);
750
+ const register = (name, inputSchema, outputSchema, callback) => {
751
+ const metadata = toolMetadata[name];
752
+ server.registerTool(name, {
753
+ title: metadata.title,
754
+ description: metadata.description,
755
+ inputSchema,
756
+ outputSchema,
757
+ annotations: metadata.annotations,
758
+ _meta: buildToolMeta(metadata, resolveSecuritySchemes(metadata, authMode)),
759
+ }, callback);
760
+ };
487
761
  // Builtin templates supersede the old golden template tool; no legacy alias by design.
488
- server.registerTool('transloadit_lint_assembly_instructions', {
489
- title: 'Lint Assembly Instructions',
490
- description: 'Lint Assembly Instructions without creating an Assembly. Returns structured issues.',
491
- inputSchema: lintAssemblyInputSchema,
492
- outputSchema: lintAssemblyOutputSchema,
493
- }, async ({ instructions, strict, return_fixed }) => {
762
+ register('transloadit_lint_assembly_instructions', lintAssemblyInputSchema, lintAssemblyOutputSchema, async ({ instructions, strict, return_fixed }) => {
494
763
  const client = createLintClient(options);
495
764
  const assemblyInstructions = instructions;
496
765
  const result = await client.lintAssemblyInstructions({
@@ -507,16 +776,11 @@ export const createTransloaditMcpServer = (options = {}) => {
507
776
  }
508
777
  return buildToolResponse(payload);
509
778
  });
510
- server.registerTool('transloadit_create_assembly', {
511
- title: 'Create or resume an Assembly',
512
- description: 'Create or resume an Assembly, optionally uploading files and waiting for completion.',
513
- inputSchema: createAssemblyInputSchema,
514
- outputSchema: createAssemblyOutputSchema,
515
- }, async ({ instructions, files, fields, wait_for_completion, wait_timeout_ms, upload_concurrency, upload_chunk_size, upload_behavior, expected_uploads, assembly_url, }, extra) => {
779
+ register('transloadit_create_assembly', createAssemblyInputSchema, createAssemblyOutputSchema, async ({ instructions, files, attachments, fields, wait_for_completion, wait_timeout_ms, upload_concurrency, upload_chunk_size, upload_behavior, expected_uploads, assembly_url, }, extra) => {
516
780
  const liveClient = createLiveClient(options, extra);
517
781
  if ('error' in liveClient)
518
782
  return liveClient.error;
519
- const { client } = liveClient;
783
+ const { client, credentials } = liveClient;
520
784
  const reference = assembly_url === undefined ? undefined : resolveAssemblyReference(options, { assembly_url });
521
785
  if (reference && 'error' in reference)
522
786
  return reference.error;
@@ -524,7 +788,7 @@ export const createTransloaditMcpServer = (options = {}) => {
524
788
  const warnings = [];
525
789
  let templatePathHint;
526
790
  try {
527
- const fileInputs = files ?? [];
791
+ const fileInputs = [...(files ?? []), ...toAttachmentInputs(attachments ?? [])];
528
792
  const urlInputs = fileInputs.filter((file) => file.kind === 'url');
529
793
  const hasUrlInputs = urlInputs.length > 0;
530
794
  let inputFilesForPrep = fileInputs;
@@ -618,6 +882,14 @@ export const createTransloaditMcpServer = (options = {}) => {
618
882
  });
619
883
  }
620
884
  }
885
+ // The hosted gate only checks that a bearer is present. Before a URL input is downloaded
886
+ // to this server's disk (not for /http/import), API2 vouches for the token once
887
+ // (templates:read is declared for this tool).
888
+ let tokenCheck;
889
+ const verifyTokenBeforeDownload = async () => {
890
+ tokenCheck ??= client.listTemplates({ pagesize: 1 });
891
+ await tokenCheck;
892
+ };
621
893
  const prep = await prepareInputFiles({
622
894
  inputFiles: inputFilesForPrep,
623
895
  params,
@@ -626,7 +898,13 @@ export const createTransloaditMcpServer = (options = {}) => {
626
898
  urlStrategy: reference ? 'download' : 'import-if-present',
627
899
  allowPrivateUrls: false,
628
900
  maxBase64Bytes,
901
+ maxUrlDownloadBytes: options.maxUrlDownloadBytes ?? defaultMaxUrlDownloadBytes,
902
+ urlDownloadTimeoutMs: options.urlDownloadTimeoutMs ?? defaultUrlDownloadTimeoutMs,
903
+ beforeUrlDownload: credentials === 'bearer' ? verifyTokenBeforeDownload : undefined,
629
904
  }).catch((error) => {
905
+ // API rejections (the token check) are not input mistakes; the outer handler maps them.
906
+ if (error instanceof ApiError)
907
+ throw error;
630
908
  const message = error instanceof Error ? error.message : 'Invalid file input.';
631
909
  if (message.startsWith('Duplicate file field')) {
632
910
  return buildToolError('mcp_duplicate_field', message, { path: 'files' });
@@ -651,14 +929,26 @@ export const createTransloaditMcpServer = (options = {}) => {
651
929
  total_files: totalFiles,
652
930
  };
653
931
  const timeout = wait_timeout_ms;
654
- const waitForCompletion = wait_for_completion ?? false;
932
+ // Files the caller uploads itself (from a sandbox) can only start once this call returns,
933
+ // so waiting here would just run into the timeout.
934
+ const outOfBandUploads = reference ? 0 : Math.max((expected_uploads ?? 0) - totalFiles, 0);
935
+ const waitForCompletion = (wait_for_completion ?? false) && outOfBandUploads === 0;
936
+ if (wait_for_completion && outOfBandUploads > 0) {
937
+ warnings.push({
938
+ code: 'mcp_wait_skipped_for_uploads',
939
+ message: 'Did not wait for completion because the Assembly waits for the expected uploads. Run upload_instructions, then call transloadit_wait_for_assembly.',
940
+ });
941
+ }
655
942
  const uploadBehavior = upload_behavior ?? (waitForCompletion ? 'await' : 'background');
656
943
  const uploadConcurrency = upload_concurrency;
657
944
  const chunkSize = upload_chunk_size;
658
945
  let assembly;
946
+ // Set by the SDK only after API2 accepted the creation request, so a rejected request
947
+ // (including an error body with HTTP 200) is never mistaken for an existing Assembly.
948
+ let createdAssemblyId;
659
949
  try {
660
- assembly = reference
661
- ? await client.resumeAssemblyUploads({
950
+ if (reference) {
951
+ assembly = await client.resumeAssemblyUploads({
662
952
  assemblyUrl: reference.assemblyUrl,
663
953
  files: filesMap,
664
954
  uploads: uploadsMap,
@@ -667,8 +957,10 @@ export const createTransloaditMcpServer = (options = {}) => {
667
957
  uploadConcurrency,
668
958
  chunkSize,
669
959
  uploadBehavior,
670
- })
671
- : await client.createAssembly({
960
+ });
961
+ }
962
+ else {
963
+ const creation = client.createAssembly({
672
964
  params,
673
965
  files: filesMap,
674
966
  uploads: uploadsMap,
@@ -678,9 +970,20 @@ export const createTransloaditMcpServer = (options = {}) => {
678
970
  chunkSize,
679
971
  uploadBehavior,
680
972
  expectedUploads: expected_uploads,
973
+ onAssemblyCreated: (created) => {
974
+ createdAssemblyId = created.assembly_id ?? creation.assemblyId;
975
+ },
681
976
  });
977
+ assembly = await creation;
978
+ }
682
979
  }
683
980
  catch (error) {
981
+ // Once API2 accepted the creation request, an auth failure (a token expiring while
982
+ // uploads or polling run) must not reach the client as a challenge: it would refresh and
983
+ // replay the call, creating and billing a second Assembly.
984
+ if (createdAssemblyId && toAuthRejection(options, error, credentials, [])) {
985
+ return buildCreatedAssemblyUnavailable(options, createdAssemblyId);
986
+ }
684
987
  if (isErrnoException(error) && error.code === 'ENOENT') {
685
988
  return buildToolError('mcp_file_not_found', 'A prepared upload is no longer available.', {
686
989
  hint: 'Retry with base64 or a public URL, or upload via `npx -y @transloadit/node upload`.',
@@ -703,64 +1006,85 @@ export const createTransloaditMcpServer = (options = {}) => {
703
1006
  if (isRecord(assembly.upload_urls)) {
704
1007
  uploadSummary.upload_urls = assembly.upload_urls;
705
1008
  }
706
- const nextSteps = waitForCompletion
707
- ? []
708
- : ['transloadit_wait_for_assembly', 'transloadit_get_assembly_status'];
1009
+ const uploadInstructions = outOfBandUploads > 0
1010
+ ? buildUploadInstructions(assembly, outOfBandUploads, new Set([...Object.keys(filesMap), ...Object.keys(uploadsMap)]))
1011
+ : undefined;
1012
+ if (outOfBandUploads > 0 && !uploadInstructions) {
1013
+ warnings.push({
1014
+ code: 'mcp_upload_instructions_unavailable',
1015
+ message: 'The Assembly status has no tus_url or assembly_ssl_url to upload to.',
1016
+ });
1017
+ }
1018
+ const nextSteps = uploadInstructions
1019
+ ? ['transloadit_wait_for_assembly']
1020
+ : waitForCompletion
1021
+ ? []
1022
+ : ['transloadit_wait_for_assembly', 'transloadit_get_assembly_status'];
709
1023
  return buildToolResponse({
710
1024
  status: 'ok',
711
1025
  assembly,
712
1026
  upload: uploadSummary,
1027
+ upload_instructions: uploadInstructions,
713
1028
  next_steps: nextSteps,
714
1029
  warnings: warnings.length > 0 ? warnings : undefined,
715
- });
1030
+ }, { meta: { [widgetContextMetaKey]: buildWidgetContext(options, assembly) } });
1031
+ }
1032
+ catch (error) {
1033
+ const rejection = toAuthRejection(options, error, credentials, toolMetadata.transloadit_create_assembly.scopes);
1034
+ if (rejection)
1035
+ return rejection;
1036
+ throw error;
716
1037
  }
717
1038
  finally {
718
1039
  await Promise.all(tempCleanups.map((cleanup) => cleanup()));
719
1040
  }
720
1041
  });
721
- server.registerTool('transloadit_get_assembly_status', {
722
- title: 'Get Assembly status',
723
- description: 'Fetch the latest Assembly status by URL or ID.',
724
- inputSchema: getAssemblyStatusInputSchema,
725
- outputSchema: getAssemblyStatusOutputSchema,
726
- }, async ({ assembly_url, assembly_id }, extra) => {
1042
+ register('transloadit_get_assembly_status', getAssemblyStatusInputSchema, getAssemblyStatusOutputSchema, async ({ assembly_url, assembly_id }, extra) => {
727
1043
  const access = resolveAssemblyAccess(options, extra, { assembly_url, assembly_id });
728
1044
  if ('error' in access)
729
1045
  return access.error;
730
- const assembly = await access.client.getAssembly(access.assemblyId);
1046
+ let assembly;
1047
+ try {
1048
+ assembly = await access.client.getAssembly(access.assemblyId);
1049
+ }
1050
+ catch (error) {
1051
+ const rejection = toAuthRejection(options, error, access.credentials, toolMetadata.transloadit_get_assembly_status.scopes);
1052
+ if (rejection)
1053
+ return rejection;
1054
+ throw error;
1055
+ }
731
1056
  return buildToolResponse({
732
1057
  status: 'ok',
733
1058
  assembly,
734
1059
  });
735
1060
  });
736
- server.registerTool('transloadit_wait_for_assembly', {
737
- title: 'Wait for Assembly completion',
738
- description: 'Polls until the Assembly completes or timeout is reached.',
739
- inputSchema: waitForAssemblyInputSchema,
740
- outputSchema: waitForAssemblyOutputSchema,
741
- }, async ({ assembly_url, assembly_id, timeout_ms, poll_interval_ms }, extra) => {
1061
+ register('transloadit_wait_for_assembly', waitForAssemblyInputSchema, waitForAssemblyOutputSchema, async ({ assembly_url, assembly_id, timeout_ms, poll_interval_ms }, extra) => {
742
1062
  const access = resolveAssemblyAccess(options, extra, { assembly_url, assembly_id });
743
1063
  if ('error' in access)
744
1064
  return access.error;
745
1065
  const start = Date.now();
746
- const assembly = await access.client.awaitAssemblyCompletion(access.assemblyId, {
747
- timeout: timeout_ms,
748
- interval: poll_interval_ms,
749
- assemblyUrl: access.assemblyUrl,
750
- });
1066
+ let assembly;
1067
+ try {
1068
+ assembly = await access.client.awaitAssemblyCompletion(access.assemblyId, {
1069
+ timeout: timeout_ms,
1070
+ interval: poll_interval_ms,
1071
+ assemblyUrl: access.assemblyUrl,
1072
+ });
1073
+ }
1074
+ catch (error) {
1075
+ const rejection = toAuthRejection(options, error, access.credentials, toolMetadata.transloadit_wait_for_assembly.scopes);
1076
+ if (rejection)
1077
+ return rejection;
1078
+ throw error;
1079
+ }
751
1080
  const waited_ms = Date.now() - start;
752
1081
  return buildToolResponse({
753
1082
  status: 'ok',
754
1083
  assembly,
755
1084
  waited_ms,
756
- });
1085
+ }, { meta: { [widgetContextMetaKey]: buildWidgetContext(options, assembly) } });
757
1086
  });
758
- server.registerTool('transloadit_list_robots', {
759
- title: 'List Transloadit robots',
760
- description: 'Returns a filtered list of robots with short summaries.',
761
- inputSchema: listRobotsInputSchema,
762
- outputSchema: listRobotsOutputSchema,
763
- }, ({ category, search, limit, cursor }) => {
1087
+ register('transloadit_list_robots', listRobotsInputSchema, listRobotsOutputSchema, ({ category, search, limit, cursor }) => {
764
1088
  const result = listRobots({ category, search, limit, cursor });
765
1089
  return buildToolResponse({
766
1090
  status: 'ok',
@@ -768,12 +1092,7 @@ export const createTransloaditMcpServer = (options = {}) => {
768
1092
  next_cursor: result.nextCursor,
769
1093
  });
770
1094
  });
771
- server.registerTool('transloadit_get_robot_help', {
772
- title: 'Get robot parameter help',
773
- description: 'Returns a robot summary and parameter details.',
774
- inputSchema: getRobotHelpInputSchema,
775
- outputSchema: getRobotHelpOutputSchema,
776
- }, ({ robot_name, robot_names }) => {
1095
+ register('transloadit_get_robot_help', getRobotHelpInputSchema, getRobotHelpOutputSchema, ({ robot_name, robot_names }) => {
777
1096
  const splitComma = (value) => value
778
1097
  .split(',')
779
1098
  .map((part) => part.trim())
@@ -819,25 +1138,10 @@ export const createTransloaditMcpServer = (options = {}) => {
819
1138
  not_found: notFound.length > 0 ? notFound : undefined,
820
1139
  });
821
1140
  });
822
- server.registerTool('transloadit_list_templates', {
823
- title: 'List templates',
824
- description: 'List Assembly Templates (owned and/or builtin). Tip: pass include_builtin: "exclusively-latest" to list builtins only.',
825
- inputSchema: listTemplatesInputSchema,
826
- outputSchema: listTemplatesOutputSchema,
827
- }, async ({ page, page_size, sort, order, keywords, include_builtin, include_content }, extra) => {
1141
+ register('transloadit_list_templates', listTemplatesInputSchema, listTemplatesOutputSchema, async ({ page, page_size, sort, order, keywords, include_builtin, include_content }, extra) => {
828
1142
  const liveClient = createLiveClient(options, extra);
829
- if ('error' in liveClient) {
830
- return buildToolResponse({
831
- status: 'error',
832
- templates: [],
833
- errors: [
834
- {
835
- code: 'mcp_missing_auth',
836
- message: 'Missing TRANSLOADIT_KEY/TRANSLOADIT_SECRET or Authorization: Bearer token for live API calls.',
837
- },
838
- ],
839
- });
840
- }
1143
+ if ('error' in liveClient)
1144
+ return withoutStructuredContent(liveClient.error);
841
1145
  try {
842
1146
  const response = await liveClient.client.listTemplates({
843
1147
  page,
@@ -869,6 +1173,9 @@ export const createTransloaditMcpServer = (options = {}) => {
869
1173
  });
870
1174
  }
871
1175
  catch (error) {
1176
+ const rejection = toAuthRejection(options, error, liveClient.credentials, toolMetadata.transloadit_list_templates.scopes);
1177
+ if (rejection)
1178
+ return withoutStructuredContent(rejection);
872
1179
  const message = error instanceof Error ? error.message : 'Failed to list templates.';
873
1180
  return buildToolResponse({
874
1181
  status: 'error',
@@ -882,6 +1189,37 @@ export const createTransloaditMcpServer = (options = {}) => {
882
1189
  });
883
1190
  }
884
1191
  });
1192
+ register('transloadit_get_profile', getProfileInputSchema, getProfileOutputSchema, async (_args, extra) => {
1193
+ const liveClient = createLiveClient(options, extra);
1194
+ if ('error' in liveClient)
1195
+ return withoutStructuredContent(liveClient.error);
1196
+ let profile;
1197
+ try {
1198
+ profile = await resolveWorkspaceProfile(liveClient.client);
1199
+ }
1200
+ catch (error) {
1201
+ const rejection = toAuthRejection(options, error, liveClient.credentials, toolMetadata.transloadit_get_profile.scopes);
1202
+ if (rejection)
1203
+ return withoutStructuredContent(rejection);
1204
+ throw error;
1205
+ }
1206
+ if (!profile) {
1207
+ // The strict profile output schema has no error shape, so this must be an error result.
1208
+ return withoutStructuredContent(buildToolResponse({
1209
+ status: 'error',
1210
+ errors: [
1211
+ {
1212
+ code: 'mcp_profile_unavailable',
1213
+ message: 'Could not determine the Workspace behind these credentials yet.',
1214
+ hint: 'Create an Assembly or a Template first, then call this tool again.',
1215
+ },
1216
+ ],
1217
+ }, { isError: true }));
1218
+ }
1219
+ return buildToolResponse(profile);
1220
+ });
1221
+ registerAssemblyResultWidget(server, { resultDomains: options.resultDomains });
1222
+ mirrorSecuritySchemes(server);
885
1223
  return server;
886
1224
  };
887
1225
  //# sourceMappingURL=server.js.map