@transloadit/mcp-server 0.7.3 → 0.8.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/dist/server.js CHANGED
@@ -3,8 +3,9 @@ import { ApiError, extractFieldNamesFromTemplate, getRobotHelp, isKnownRobot, li
3
3
  import { builtinTemplateFieldsSchema } from '@transloadit/zod/v4/builtinTemplates';
4
4
  import { z } from 'zod';
5
5
  import packageJson from '../package.json' with { type: 'json' };
6
- import { applyBuiltinCatalog, BuiltinFieldValidationError, builtinShortcuts, loadBuiltinShortcuts, validateBuiltinFields, } from "./builtin-shortcuts.js";
6
+ import { applyBuiltinCatalog, BuiltinFieldValidationError, builtinShortcuts, loadBuiltinShortcuts, selectBuiltinInputUrls, validateBuiltinFields, } from "./builtin-shortcuts.js";
7
7
  import { buildBearerChallenge, extractBearerToken } from "./http-helpers.js";
8
+ import { buildRedactor, getDiagnosticsLogger } from "./logger.js";
8
9
  import { assertServerOptions } from "./options.js";
9
10
  import { mirrorSecuritySchemes } from "./tool-list.js";
10
11
  import { buildToolMeta, resolveSecuritySchemes, resolveToolAuthMode, toolMetadata, } from "./tool-metadata.js";
@@ -19,6 +20,16 @@ const maxBase64Bytes = 512_000;
19
20
  /** URL inputs beyond these limits are not downloaded to this server's disk (per call). */
20
21
  const defaultMaxUrlDownloadBytes = 1024 * 1024 * 1024;
21
22
  const defaultUrlDownloadTimeoutMs = 10 * 60 * 1000;
23
+ /**
24
+ * Hosts abandon tool calls that run too long (ChatGPT after about a minute) and may retry them,
25
+ * creating a second Assembly. Calls that create or follow an Assembly therefore return its
26
+ * current state once this budget is spent; transloadit_wait_for_assembly follows it from there.
27
+ */
28
+ const defaultMaxWaitMs = 45_000;
29
+ /** Lowest request timeout handed to the SDK, so a nearly spent budget cannot fail a request. */
30
+ const minimumSdkTimeoutMs = 10_000;
31
+ /** Longest a status read after an early return may take. */
32
+ const statusReadTimeoutMs = 5_000;
22
33
  const lintIssueSchema = z.object({
23
34
  path: z.string(),
24
35
  message: z.string(),
@@ -126,6 +137,7 @@ const createAssemblyInputSchema = z.object({
126
137
  const createAssemblyOutputSchema = z.object({
127
138
  status: z.enum(['ok', 'error']),
128
139
  assembly: z.unknown().optional(),
140
+ running: z.boolean().optional(),
129
141
  upload: z
130
142
  .object({
131
143
  status: z.enum(['none', 'uploading', 'complete']),
@@ -158,6 +170,7 @@ const waitForAssemblyInputSchema = z.object({
158
170
  const waitForAssemblyOutputSchema = z.object({
159
171
  status: z.enum(['ok', 'error']),
160
172
  assembly: z.unknown().optional(),
173
+ running: z.boolean().optional(),
161
174
  waited_ms: z.number().int().nonnegative().optional(),
162
175
  errors: z.array(toolMessageSchema).optional(),
163
176
  warnings: z.array(toolMessageSchema).optional(),
@@ -289,6 +302,65 @@ const buildShortcutError = (...args) => ({
289
302
  ...buildToolError(...args),
290
303
  isError: true,
291
304
  });
305
+ /** Maps an input preparation failure to a tool error; API rejections are left to the caller. */
306
+ const buildInputError = (error) => {
307
+ // API rejections (the token check) are not input mistakes; the outer handler maps them.
308
+ if (error instanceof ApiError)
309
+ throw error;
310
+ const message = error instanceof Error ? error.message : 'Invalid file input.';
311
+ if (message.startsWith('Duplicate file field')) {
312
+ return buildToolError('mcp_duplicate_field', message, { path: 'files' });
313
+ }
314
+ if (message.startsWith('Base64 payload exceeds')) {
315
+ return buildToolError('mcp_base64_too_large', message, {
316
+ hint: 'Use a public URL import or upload from your own machine instead.',
317
+ });
318
+ }
319
+ return buildToolError('mcp_invalid_args', message);
320
+ };
321
+ const deadlineReached = Symbol('deadlineReached');
322
+ /** Stops input preparation from starting another download once the wait budget is spent. */
323
+ class PreparationBudgetSpentError extends Error {
324
+ name = 'PreparationBudgetSpentError';
325
+ }
326
+ /** Settles like `promise`, or with `deadlineReached` when it is still pending after `ms`. */
327
+ const settleWithin = async (promise, ms) => {
328
+ let timer;
329
+ const deadline = new Promise((resolve) => {
330
+ timer = setTimeout(resolve, ms, deadlineReached);
331
+ });
332
+ try {
333
+ return await Promise.race([promise, deadline]);
334
+ }
335
+ finally {
336
+ clearTimeout(timer);
337
+ }
338
+ };
339
+ /** The SDK does not export PollingTimeoutError, so it is recognized by name. */
340
+ const isPollingTimeout = (error) => error instanceof Error && error.name === 'PollingTimeoutError';
341
+ /** The same states the SDK's awaitAssemblyCompletion keeps polling on. */
342
+ const runningAssemblyStates = new Set([
343
+ 'ASSEMBLY_UPLOADING',
344
+ 'ASSEMBLY_EXECUTING',
345
+ 'ASSEMBLY_REPLAYING',
346
+ ]);
347
+ const isAssemblyRunning = (assembly) => !assembly.error && typeof assembly.ok === 'string' && runningAssemblyStates.has(assembly.ok);
348
+ /** Tells the model that the call returned early and how to follow the Assembly instead of retrying. */
349
+ const buildStillRunningNotice = (assembly, fallbackUrl) => {
350
+ const assemblyUrl = isNonEmptyString(assembly.assembly_ssl_url)
351
+ ? assembly.assembly_ssl_url
352
+ : isNonEmptyString(assembly.assembly_url)
353
+ ? assembly.assembly_url
354
+ : fallbackUrl;
355
+ const follow = 'Follow it with transloadit_wait_for_assembly or transloadit_get_assembly_status';
356
+ return {
357
+ code: 'mcp_assembly_still_running',
358
+ message: 'Returned before the Assembly finished, within the time this call may wait. The Assembly keeps running.',
359
+ hint: assemblyUrl
360
+ ? `${follow} using assembly_url ${assemblyUrl}; do not create it again.`
361
+ : `${follow}; do not create it again.`,
362
+ };
363
+ };
292
364
  const getClientName = (options) => {
293
365
  const base = options.clientName ?? `mcp-server:${packageJson.version}`;
294
366
  const suffix = options.clientSuffix;
@@ -436,22 +508,53 @@ const toAuthRejection = (options, error, credentials, scopes) => {
436
508
  }
437
509
  return undefined;
438
510
  };
511
+ /** Matches resolveAssemblyReference: the SDK's default when the endpoint is empty. */
512
+ const defaultAssemblyUrl = (options, assemblyId) => `${options.endpoint || 'https://api2.transloadit.com'}/assemblies/${assemblyId}`;
439
513
  /** The Assembly exists, but its status could not be read with the caller's credentials. */
440
- const buildCreatedAssemblyUnavailable = (options, assemblyId) => {
441
- // Matches resolveAssemblyReference: the SDK's default when the endpoint is empty.
442
- const assemblyUrl = `${options.endpoint || 'https://api2.transloadit.com'}/assemblies/${assemblyId}`;
514
+ const buildCreatedAssemblyUnavailable = (options, assemblyId, describe = (assemblyUrl) => ({
515
+ code: 'mcp_assembly_status_unavailable',
516
+ message: 'The Assembly was created, but Transloadit rejected the credentials while reading its status.',
517
+ hint: `Reconnect if needed, then call transloadit_get_assembly_status with assembly_url ${assemblyUrl} instead of creating it again.`,
518
+ })) => {
519
+ const assemblyUrl = defaultAssemblyUrl(options, assemblyId);
443
520
  return buildToolResponse({
444
521
  status: 'error',
445
522
  assembly: { assembly_id: assemblyId, assembly_ssl_url: assemblyUrl },
446
- errors: [
447
- {
448
- code: 'mcp_assembly_status_unavailable',
449
- message: 'The Assembly was created, but Transloadit rejected the credentials while reading its status.',
450
- hint: `Reconnect if needed, then call transloadit_get_assembly_status with assembly_url ${assemblyUrl} instead of creating it again.`,
451
- },
452
- ],
523
+ errors: [describe(assemblyUrl)],
453
524
  }, { isError: true });
454
525
  };
526
+ /** The creation request is still in flight after the budget; its ID is chosen by this server. */
527
+ const buildCreationPending = (options, assemblyId, serverFiles,
528
+ /** Set when some of them are uploaded from the caller's sandbox. */
529
+ expectedUploads) => buildCreatedAssemblyUnavailable(options, assemblyId, (assemblyUrl) => {
530
+ // An Assembly that API2 accepted only after the request was stopped never receives this
531
+ // call's files, so it is resumed rather than created again: another Assembly could repeat
532
+ // Steps that already ran, such as imports. API2 could in theory still be handling a request
533
+ // it received before the abort, so the hint asks for a pause first. Strict idempotency would
534
+ // need an API2 creation key, which does not exist; this path only runs after a request
535
+ // already took longer than the budget.
536
+ const resumeArguments = [
537
+ `assembly_url ${assemblyUrl}`,
538
+ ...(serverFiles > 0 ? ['the same files'] : []),
539
+ ...(expectedUploads ? [`expected_uploads ${expectedUploads}`] : []),
540
+ ];
541
+ const resume = serverFiles > 0 || expectedUploads
542
+ ? `. If it is still waiting for uploads (ASSEMBLY_UPLOADING), its files were never sent: resume it by calling transloadit_create_assembly with ${resumeArguments.join(' and ')}${expectedUploads ? ', which hands out new upload_instructions' : ''}`
543
+ : '';
544
+ return {
545
+ code: 'mcp_assembly_creation_pending',
546
+ message: 'Transloadit did not confirm the Assembly in time, so this server stopped sending the creation request. Transloadit may still have received it.',
547
+ hint: `Wait about 30 seconds, then call transloadit_get_assembly_status with assembly_url ${assemblyUrl}. Create it again only if several checks over a few minutes report that the Assembly does not exist${resume}.`,
548
+ };
549
+ });
550
+ /** Reserved upload URLs that an early return could not include yet. */
551
+ const buildUploadUrlsPending = (assemblyUrl) => ({
552
+ code: 'mcp_upload_urls_pending',
553
+ message: 'The upload URLs were not reserved before this call returned.',
554
+ // The reservations this call started keep running; asking at once could reserve the files
555
+ // a second time instead of returning the URLs those reservations produce.
556
+ hint: `Wait about 30 seconds, then call transloadit_create_assembly with assembly_url ${assemblyUrl ?? 'from this result'}, the same files and upload_behavior "none" to get them.`,
557
+ });
455
558
  /** A failed call caused by server-side credentials; an error result so strict outputs still pass. */
456
559
  const buildCredentialError = (error) => buildToolResponse({ status: 'error', errors: [error] }, { isError: true });
457
560
  /**
@@ -573,6 +676,25 @@ const mergeFieldValues = (templateFields, argFields) => {
573
676
  };
574
677
  const assemblyIdSchema = z.string().regex(/^[a-f\d]{32}$/i);
575
678
  const assemblyUrlSchema = z.url();
679
+ /**
680
+ * Reads an Assembly's current status once, without outlasting the host's patience. The SDK's
681
+ * rate-limit backoff does not observe the abort signal, so the read is also raced against the
682
+ * clock; without a status in time, it returns undefined.
683
+ */
684
+ const readAssemblyStatus = async (client, assemblyId) => {
685
+ const status = await settleWithin(
686
+ // Aborts after the race below gave up, so the abort never becomes the call's error.
687
+ client.getAssembly(assemblyId, { signal: AbortSignal.timeout(statusReadTimeoutMs + 1_000) }), statusReadTimeoutMs);
688
+ return status === deadlineReached ? undefined : status;
689
+ };
690
+ /** Answers a call that returned early when not even the Assembly's status arrived in time. */
691
+ const buildStatusPendingResponse = (reference, fields = {}, warnings = []) => buildToolResponse({
692
+ status: 'ok',
693
+ assembly: { assembly_id: reference.assemblyId, assembly_ssl_url: reference.assemblyUrl },
694
+ running: true,
695
+ ...fields,
696
+ warnings: [buildStillRunningNotice({}, reference.assemblyUrl), ...warnings],
697
+ });
576
698
  /** Production (`.com`) and development or tunnel (`.dev`) Transloadit hosts. */
577
699
  const isTransloaditHost = (hostname) => ['transloadit.com', 'transloadit.dev'].some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
578
700
  const resolveAssemblyReference = (options, args) => {
@@ -768,6 +890,8 @@ export const listOwnTools = (server) => {
768
890
  };
769
891
  export const createTransloaditMcpServer = (options = {}) => {
770
892
  assertServerOptions(options);
893
+ const logger = (options.logger ?? getDiagnosticsLogger()).nest('assemblies');
894
+ const redactError = buildRedactor([options.authSecret, options.upstreamSecret, options.mcpToken]);
771
895
  const server = new McpServer({
772
896
  name: options.serverName ?? 'Transloadit MCP',
773
897
  version: options.serverVersion ?? packageJson.version,
@@ -807,7 +931,9 @@ export const createTransloaditMcpServer = (options = {}) => {
807
931
  }
808
932
  return buildToolResponse(payload);
809
933
  });
810
- const createAssembly = 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, fieldContractValidated = false) => {
934
+ const createAssembly = 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, fieldContractValidated = false,
935
+ // Shortcuts start the clock before loading their catalog, which counts toward the budget.
936
+ startedAt = performance.now()) => {
811
937
  const liveClient = createLiveClient(options, extra);
812
938
  if ('error' in liveClient)
813
939
  return liveClient.error;
@@ -816,8 +942,19 @@ export const createTransloaditMcpServer = (options = {}) => {
816
942
  if (reference && 'error' in reference)
817
943
  return reference.error;
818
944
  const tempCleanups = [];
945
+ // Uploads or polling that continue after the call returned early.
946
+ let backgroundWork;
819
947
  const warnings = [];
820
948
  let templatePathHint;
949
+ const maxWaitMs = options.maxWaitMs ?? defaultMaxWaitMs;
950
+ // Preparation (Template lookup, file inputs) runs before any Assembly exists, so failing
951
+ // once it used up the budget is safe to retry, unlike a host timeout after creation.
952
+ const preparationRemainingMs = () => Math.max(startedAt + maxWaitMs - performance.now(), 0);
953
+ const preparationTimeout = (reason) => buildToolError('mcp_preparation_timeout', reference
954
+ ? 'Preparing the files took too long, so none were added to the Assembly.'
955
+ : 'Preparing the Assembly took too long, so none was created.', {
956
+ hint: reason ? `Retrying this call is safe. ${reason}` : 'Retrying this call is safe.',
957
+ });
821
958
  try {
822
959
  const fileInputs = [...(files ?? []), ...toAttachmentInputs(attachments ?? [])];
823
960
  const urlInputs = fileInputs.filter((file) => file.kind === 'url');
@@ -833,7 +970,10 @@ export const createTransloaditMcpServer = (options = {}) => {
833
970
  templatePathHint = templatePathHint ?? 'instructions.template_id';
834
971
  let template;
835
972
  try {
836
- template = await client.getTemplate(params.template_id);
973
+ const lookup = await settleWithin(client.getTemplate(params.template_id), preparationRemainingMs());
974
+ if (lookup === deadlineReached)
975
+ return preparationTimeout();
976
+ template = lookup;
837
977
  }
838
978
  catch (error) {
839
979
  const isTemplateNotFound = (error instanceof ApiError && error.code === 'TEMPLATE_NOT_FOUND') ||
@@ -924,7 +1064,7 @@ export const createTransloaditMcpServer = (options = {}) => {
924
1064
  tokenCheck ??= client.listTemplates({ pagesize: 1 });
925
1065
  await tokenCheck;
926
1066
  };
927
- const prep = await prepareInputFiles({
1067
+ const preparation = prepareInputFiles({
928
1068
  inputFiles: inputFilesForPrep,
929
1069
  params,
930
1070
  fields,
@@ -933,23 +1073,37 @@ export const createTransloaditMcpServer = (options = {}) => {
933
1073
  allowPrivateUrls: false,
934
1074
  maxBase64Bytes,
935
1075
  maxUrlDownloadBytes: options.maxUrlDownloadBytes ?? defaultMaxUrlDownloadBytes,
936
- urlDownloadTimeoutMs: options.urlDownloadTimeoutMs ?? defaultUrlDownloadTimeoutMs,
937
- beforeUrlDownload: credentials === 'bearer' ? verifyTokenBeforeDownload : undefined,
938
- }).catch((error) => {
939
- // API rejections (the token check) are not input mistakes; the outer handler maps them.
940
- if (error instanceof ApiError)
941
- throw error;
942
- const message = error instanceof Error ? error.message : 'Invalid file input.';
943
- if (message.startsWith('Duplicate file field')) {
944
- return buildToolError('mcp_duplicate_field', message, { path: 'files' });
945
- }
946
- if (message.startsWith('Base64 payload exceeds')) {
947
- return buildToolError('mcp_base64_too_large', message, {
948
- hint: 'Use a public URL import or upload from your own machine instead.',
949
- });
950
- }
951
- return buildToolError('mcp_invalid_args', message);
1076
+ // prepareInputFiles() takes no abort signal, so its download timeout is what stops a
1077
+ // download once the budget is spent; a retry would otherwise run next to it. The extra
1078
+ // second lets the budget check below answer first, with mcp_preparation_timeout. Each
1079
+ // download gets this timeout from its own start, but beforeUrlDownload starts none
1080
+ // after the budget, so an abandoned download ends at most one timeout after the budget
1081
+ // (twice the budget at worst). A tighter bound needs an abort signal in the SDK, which
1082
+ // the URL import work in API2 makes unnecessary.
1083
+ urlDownloadTimeoutMs: Math.min(options.urlDownloadTimeoutMs ?? defaultUrlDownloadTimeoutMs, preparationRemainingMs() + 1_000),
1084
+ // Runs before each download, so an abandoned preparation starts no further one.
1085
+ beforeUrlDownload: async () => {
1086
+ if (credentials === 'bearer')
1087
+ await verifyTokenBeforeDownload();
1088
+ if (preparationRemainingMs() === 0)
1089
+ throw new PreparationBudgetSpentError();
1090
+ },
952
1091
  });
1092
+ const prep = await settleWithin(preparation, preparationRemainingMs()).catch((error) =>
1093
+ // The budget check can reject just before the deadline timer fires.
1094
+ error instanceof PreparationBudgetSpentError ? deadlineReached : buildInputError(error));
1095
+ if (prep === deadlineReached) {
1096
+ // The download stops at its timeout and prepareInputFiles() removes its files; one that
1097
+ // still finishes first returns files that nothing uploads.
1098
+ void preparation
1099
+ .then((late) => Promise.all(late.cleanup.map((cleanupFile) => cleanupFile())), () => undefined)
1100
+ .catch((error) => {
1101
+ logger.warn('Removing prepared files failed: %s', redactError(error));
1102
+ });
1103
+ return preparationTimeout(inputFilesForPrep.some((file) => file.kind === 'url')
1104
+ ? 'A retry downloads the URL inputs again from the start. To have Transloadit fetch large files itself, use transloadit_create_assembly with Steps that contain an /http/import Step.'
1105
+ : undefined);
1106
+ }
953
1107
  if ('content' in prep) {
954
1108
  return prep;
955
1109
  }
@@ -962,68 +1116,175 @@ export const createTransloaditMcpServer = (options = {}) => {
962
1116
  status: totalFiles > 0 ? 'complete' : 'none',
963
1117
  total_files: totalFiles,
964
1118
  };
965
- const timeout = wait_timeout_ms;
966
1119
  // Files the caller uploads itself (from a sandbox) can only start once this call returns,
967
- // so waiting here would just run into the timeout.
968
- const outOfBandUploads = reference ? 0 : Math.max((expected_uploads ?? 0) - totalFiles, 0);
1120
+ // so waiting here would just run into the timeout. A resumed Assembly hands them out
1121
+ // again, e.g. after a creation request that was stopped. Whether its uploads already
1122
+ // arrived shows only in its status, so a resume with expected_uploads does not wait
1123
+ // either; next_steps then point to transloadit_wait_for_assembly.
1124
+ const outOfBandUploads = Math.max((expected_uploads ?? 0) - totalFiles, 0);
969
1125
  const waitForCompletion = (wait_for_completion ?? false) && outOfBandUploads === 0;
970
- if (wait_for_completion && outOfBandUploads > 0) {
971
- warnings.push({
972
- code: 'mcp_wait_skipped_for_uploads',
973
- message: 'Did not wait for completion because the Assembly waits for the expected uploads. Run upload_instructions, then call transloadit_wait_for_assembly.',
974
- });
975
- }
976
1126
  const uploadBehavior = upload_behavior ?? (waitForCompletion ? 'await' : 'background');
977
1127
  const uploadConcurrency = upload_concurrency;
978
1128
  const chunkSize = upload_chunk_size;
1129
+ const deadline = startedAt +
1130
+ (waitForCompletion ? Math.min(wait_timeout_ms ?? maxWaitMs, maxWaitMs) : maxWaitMs);
1131
+ const remainingMs = () => Math.max(deadline - performance.now(), 0);
1132
+ // The SDK also applies this timeout to the creation request. It outlasts the deadline and
1133
+ // the creation grace period after it, so a request timeout never fails a creation that
1134
+ // API2 may have accepted; the deadline below bounds the call itself. Polling after an
1135
+ // early return is not stopped separately: it ends with this timeout, a few seconds later,
1136
+ // and the signal that could stop it would also stop uploads the Assembly still needs.
1137
+ const timeout = Math.max(remainingMs() + statusReadTimeoutMs + 1_000, minimumSdkTimeoutMs);
1138
+ // Every status the SDK reports, so a call that returns early still has the latest state.
1139
+ let latest;
1140
+ const trackProgress = (status) => {
1141
+ latest = status;
1142
+ };
979
1143
  let assembly;
980
1144
  // Set by the SDK only after API2 accepted the creation request, so a rejected request
981
1145
  // (including an error body with HTTP 200) is never mistaken for an existing Assembly.
982
1146
  let createdAssemblyId;
1147
+ let resolveCreated = () => undefined;
1148
+ const created = new Promise((resolve) => {
1149
+ resolveCreated = resolve;
1150
+ });
1151
+ let work;
1152
+ // Chosen by the SDK before the creation request is sent.
1153
+ let creationAssemblyId;
1154
+ // Stops a creation request (and its rate-limit retries) that outlasted the call.
1155
+ const creationAbort = new AbortController();
1156
+ if (reference) {
1157
+ work = client.resumeAssemblyUploads({
1158
+ assemblyUrl: reference.assemblyUrl,
1159
+ files: filesMap,
1160
+ uploads: uploadsMap,
1161
+ waitForCompletion,
1162
+ timeout,
1163
+ uploadConcurrency,
1164
+ chunkSize,
1165
+ uploadBehavior,
1166
+ onAssemblyProgress: trackProgress,
1167
+ });
1168
+ }
1169
+ else {
1170
+ const creation = client.createAssembly({
1171
+ params,
1172
+ files: filesMap,
1173
+ uploads: uploadsMap,
1174
+ waitForCompletion,
1175
+ timeout,
1176
+ uploadConcurrency,
1177
+ chunkSize,
1178
+ uploadBehavior,
1179
+ expectedUploads: expected_uploads,
1180
+ onAssemblyCreated: (status) => {
1181
+ createdAssemblyId = status.assembly_id ?? creation.assemblyId;
1182
+ latest = status;
1183
+ resolveCreated(status);
1184
+ },
1185
+ onAssemblyProgress: trackProgress,
1186
+ signal: creationAbort.signal,
1187
+ });
1188
+ creationAssemblyId = creation.assemblyId;
1189
+ work = creation;
1190
+ }
1191
+ let workSettled = true;
1192
+ let returnedEarly = false;
983
1193
  try {
984
- if (reference) {
985
- assembly = await client.resumeAssemblyUploads({
986
- assemblyUrl: reference.assemblyUrl,
987
- files: filesMap,
988
- uploads: uploadsMap,
989
- waitForCompletion,
990
- timeout,
991
- uploadConcurrency,
992
- chunkSize,
993
- uploadBehavior,
994
- });
1194
+ const outcome = await settleWithin(work, remainingMs());
1195
+ if (outcome !== deadlineReached) {
1196
+ assembly = outcome;
995
1197
  }
996
1198
  else {
997
- const creation = client.createAssembly({
998
- params,
999
- files: filesMap,
1000
- uploads: uploadsMap,
1001
- waitForCompletion,
1002
- timeout,
1003
- uploadConcurrency,
1004
- chunkSize,
1005
- uploadBehavior,
1006
- expectedUploads: expected_uploads,
1007
- onAssemblyCreated: (created) => {
1008
- createdAssemblyId = created.assembly_id ?? creation.assemblyId;
1009
- },
1199
+ // Hosts abandon calls that outlast their deadline and retry them, which creates a
1200
+ // second Assembly. Return what is known instead; the Assembly keeps running.
1201
+ workSettled = false;
1202
+ returnedEarly = true;
1203
+ backgroundWork = work.then(() => undefined, (error) => {
1204
+ if (isPollingTimeout(error) || creationAbort.signal.aborted)
1205
+ return;
1206
+ // A creation failure within the grace period below also reaches the caller; the
1207
+ // extra log line is accepted to keep this handler attached before that wait.
1208
+ // Hosted instances keep no per-Assembly state to report this later; follow-up
1209
+ // status calls show the Assembly itself.
1210
+ logger.warn('Assembly work after an early return failed: %s', redactError(error));
1010
1211
  });
1011
- assembly = await creation;
1212
+ if (latest) {
1213
+ assembly = latest;
1214
+ }
1215
+ else if (reference) {
1216
+ // The uploads keep running, so even a rejected read must not invite a replay.
1217
+ const status = await readAssemblyStatus(client, reference.assemblyId).catch(() => undefined);
1218
+ if (!status) {
1219
+ // Nothing sends reserved files or sandbox files by itself; say how to get the
1220
+ // URLs or commands for them.
1221
+ const recovery = [
1222
+ ...(uploadBehavior === 'none' && totalFiles > 0
1223
+ ? [buildUploadUrlsPending(reference.assemblyUrl)]
1224
+ : []),
1225
+ ...(outOfBandUploads > 0
1226
+ ? [
1227
+ {
1228
+ code: 'mcp_upload_instructions_pending',
1229
+ message: 'The upload instructions were not ready before this call returned.',
1230
+ // Uploads this call started still reserve their fields; with the same
1231
+ // files, the next call reuses them instead of handing out commands.
1232
+ hint: `Wait about 30 seconds, then call transloadit_create_assembly with assembly_url ${reference.assemblyUrl}${totalFiles > 0 ? ', the same files' : ''} and expected_uploads ${expected_uploads} to get them.`,
1233
+ },
1234
+ ]
1235
+ : []),
1236
+ ];
1237
+ return buildStatusPendingResponse(reference, {
1238
+ upload: {
1239
+ ...uploadSummary,
1240
+ status: uploadBehavior === 'none' || totalFiles === 0 ? 'none' : 'uploading',
1241
+ resumed: true,
1242
+ },
1243
+ next_steps: [
1244
+ 'transloadit_wait_for_assembly',
1245
+ 'transloadit_get_assembly_status',
1246
+ ],
1247
+ }, recovery);
1248
+ }
1249
+ assembly = status;
1250
+ }
1251
+ else {
1252
+ // The creation request itself is slow (or retried); give it as long as a status read.
1253
+ const known = await settleWithin(Promise.race([created, work]), statusReadTimeoutMs);
1254
+ if (known === deadlineReached) {
1255
+ // Without the request, nothing can create this Assembly later, so a status check
1256
+ // that finds no Assembly makes creating it again safe. The SDK keeps the read
1257
+ // streams it opened for the files until they are collected, as on any failed
1258
+ // creation; closing them needs an SDK change.
1259
+ creationAbort.abort();
1260
+ return creationAssemblyId
1261
+ ? buildCreationPending(options, creationAssemblyId, totalFiles, outOfBandUploads > 0 ? expected_uploads : undefined)
1262
+ : buildToolError('mcp_assembly_creation_pending', 'Creating the Assembly is taking too long.');
1263
+ }
1264
+ assembly = known;
1265
+ }
1012
1266
  }
1013
1267
  }
1014
1268
  catch (error) {
1015
- // Once API2 accepted the creation request, an auth failure (a token expiring while
1016
- // uploads or polling run) must not reach the client as a challenge: it would refresh and
1017
- // replay the call, creating and billing a second Assembly.
1018
- if (createdAssemblyId && toAuthRejection(options, error, credentials, [])) {
1019
- return buildCreatedAssemblyUnavailable(options, createdAssemblyId);
1269
+ if (isPollingTimeout(error) && latest) {
1270
+ // The SDK's own polling ran out of time; the Assembly is still known and running.
1271
+ assembly = latest;
1272
+ returnedEarly = true;
1020
1273
  }
1021
- if (isErrnoException(error) && error.code === 'ENOENT') {
1022
- return buildToolError('mcp_file_not_found', 'A prepared upload is no longer available.', {
1023
- hint: 'Retry with base64 or a public URL, or upload via `npx -y @transloadit/node upload`.',
1024
- });
1274
+ else {
1275
+ // Once API2 accepted the creation request, an auth failure (a token expiring while
1276
+ // uploads or polling run) must not reach the client as a challenge: it would refresh
1277
+ // and replay the call, creating and billing a second Assembly.
1278
+ if (createdAssemblyId && toAuthRejection(options, error, credentials, [])) {
1279
+ return buildCreatedAssemblyUnavailable(options, createdAssemblyId);
1280
+ }
1281
+ if (isErrnoException(error) && error.code === 'ENOENT') {
1282
+ return buildToolError('mcp_file_not_found', 'A prepared upload is no longer available.', {
1283
+ hint: 'Retry with base64 or a public URL, or upload via `npx -y @transloadit/node upload`.',
1284
+ });
1285
+ }
1286
+ throw error;
1025
1287
  }
1026
- throw error;
1027
1288
  }
1028
1289
  if (assembly_url) {
1029
1290
  uploadSummary.resumed = true;
@@ -1034,29 +1295,63 @@ export const createTransloaditMcpServer = (options = {}) => {
1034
1295
  else if (uploadBehavior === 'none') {
1035
1296
  uploadSummary.status = 'none';
1036
1297
  }
1037
- else if (uploadBehavior === 'background') {
1298
+ else if (uploadBehavior === 'background' ||
1299
+ (!workSettled && assembly.ok === 'ASSEMBLY_UPLOADING')) {
1038
1300
  uploadSummary.status = 'uploading';
1039
1301
  }
1040
1302
  if (isRecord(assembly.upload_urls)) {
1041
1303
  uploadSummary.upload_urls = assembly.upload_urls;
1042
1304
  }
1043
- const uploadInstructions = outOfBandUploads > 0
1044
- ? buildUploadInstructions(assembly, outOfBandUploads, new Set([...Object.keys(filesMap), ...Object.keys(uploadsMap)]))
1305
+ const ownFields = new Set([...Object.keys(filesMap), ...Object.keys(uploadsMap)]);
1306
+ // A resumed Assembly may have received some uploads already, and takes more only while
1307
+ // it waits for them; expected_uploads counts all of its uploads, as at creation.
1308
+ // Finished uploads are listed under uploads, running ones under tus_uploads.
1309
+ const receivedFields = new Set([
1310
+ ...(assembly.uploads ?? []).map((upload) => upload.field),
1311
+ ...(assembly.tus_uploads ?? []).map((upload) => upload.fieldname),
1312
+ ].filter((field) => isNonEmptyString(field) && !ownFields.has(field)));
1313
+ const pendingUploads = !reference
1314
+ ? outOfBandUploads
1315
+ : assembly.ok === 'ASSEMBLY_UPLOADING'
1316
+ ? Math.max(outOfBandUploads - receivedFields.size, 0)
1317
+ : 0;
1318
+ const uploadInstructions = pendingUploads > 0
1319
+ ? buildUploadInstructions(assembly, pendingUploads, new Set([...ownFields, ...receivedFields]))
1045
1320
  : undefined;
1046
- if (outOfBandUploads > 0 && !uploadInstructions) {
1321
+ if (wait_for_completion && uploadInstructions) {
1322
+ warnings.push({
1323
+ code: 'mcp_wait_skipped_for_uploads',
1324
+ message: 'Did not wait for completion because the Assembly waits for the expected uploads. Run upload_instructions, then call transloadit_wait_for_assembly.',
1325
+ });
1326
+ }
1327
+ if (pendingUploads > 0 && !uploadInstructions) {
1047
1328
  warnings.push({
1048
1329
  code: 'mcp_upload_instructions_unavailable',
1049
1330
  message: 'The Assembly status has no tus_url or assembly_ssl_url to upload to.',
1050
1331
  });
1051
1332
  }
1333
+ const running = isAssemblyRunning(assembly);
1334
+ if (returnedEarly && running) {
1335
+ warnings.push(buildStillRunningNotice(assembly, reference?.assemblyUrl));
1336
+ }
1337
+ if (returnedEarly &&
1338
+ uploadBehavior === 'none' &&
1339
+ totalFiles > 0 &&
1340
+ !uploadSummary.upload_urls) {
1341
+ // Nothing uploads these files later, so the caller needs their reserved upload URLs.
1342
+ const assemblyUrl = reference?.assemblyUrl ??
1343
+ (isNonEmptyString(assembly.assembly_ssl_url) ? assembly.assembly_ssl_url : undefined);
1344
+ warnings.push(buildUploadUrlsPending(assemblyUrl));
1345
+ }
1052
1346
  const nextSteps = uploadInstructions
1053
1347
  ? ['transloadit_wait_for_assembly']
1054
- : waitForCompletion
1348
+ : waitForCompletion && !running
1055
1349
  ? []
1056
1350
  : ['transloadit_wait_for_assembly', 'transloadit_get_assembly_status'];
1057
1351
  return buildToolResponse({
1058
1352
  status: 'ok',
1059
1353
  assembly,
1354
+ running,
1060
1355
  upload: uploadSummary,
1061
1356
  upload_instructions: uploadInstructions,
1062
1357
  next_steps: nextSteps,
@@ -1070,7 +1365,19 @@ export const createTransloaditMcpServer = (options = {}) => {
1070
1365
  throw error;
1071
1366
  }
1072
1367
  finally {
1073
- await Promise.all(tempCleanups.map((cleanup) => cleanup()));
1368
+ const cleanup = async () => {
1369
+ await Promise.all(tempCleanups.map((cleanupFile) => cleanupFile()));
1370
+ };
1371
+ // Uploads that continue after an early return still read the prepared files. This
1372
+ // promise never rejects.
1373
+ if (backgroundWork) {
1374
+ void backgroundWork.then(cleanup).catch((error) => {
1375
+ logger.warn('Removing prepared files failed: %s', redactError(error));
1376
+ });
1377
+ }
1378
+ else {
1379
+ await cleanup();
1380
+ }
1074
1381
  }
1075
1382
  });
1076
1383
  // Keep the generic execution path responsible for auth, uploads, waiting and widget results.
@@ -1080,6 +1387,7 @@ export const createTransloaditMcpServer = (options = {}) => {
1080
1387
  .strict();
1081
1388
  for (const { id, name } of builtinShortcuts) {
1082
1389
  register(name, shortcutInputSchema, createAssemblyOutputSchema, async (args, extra) => {
1390
+ const startedAt = performance.now();
1083
1391
  const live = createLiveClient(options, extra, 0);
1084
1392
  if ('error' in live)
1085
1393
  return live.error;
@@ -1107,7 +1415,22 @@ export const createTransloaditMcpServer = (options = {}) => {
1107
1415
  }
1108
1416
  return buildShortcutError('mcp_invalid_builtin_fields', error.message, { path: 'fields' });
1109
1417
  }
1110
- const result = await createAssembly({ ...args, fields, instructions: { template_id: template.id } }, extra, true);
1418
+ // upload_behavior none asks for reserved upload URLs, which only the upload path hands out.
1419
+ const inputUrls = args.upload_behavior === 'none'
1420
+ ? undefined
1421
+ : selectBuiltinInputUrls(template.id, [...(args.files ?? []), ...toAttachmentInputs(args.attachments ?? [])], args.expected_uploads);
1422
+ // API2 imports eligible URLs itself (api2#10292), so the Assembly exists at once and nothing
1423
+ // is downloaded here. A refusal is API2's to explain, so it surfaces like any other error.
1424
+ const result = await createAssembly(inputUrls
1425
+ ? {
1426
+ ...args,
1427
+ files: undefined,
1428
+ attachments: undefined,
1429
+ expected_uploads: undefined,
1430
+ fields,
1431
+ instructions: { template_id: template.id, input_url: inputUrls },
1432
+ }
1433
+ : { ...args, fields, instructions: { template_id: template.id } }, extra, true, startedAt);
1111
1434
  // Keep the generic tool's historical error shape while making new shortcut failures
1112
1435
  // recognizable to MCP clients that rely on isError instead of structuredContent.status.
1113
1436
  return result.structuredContent?.status === 'error' ? { ...result, isError: true } : result;
@@ -1137,25 +1460,59 @@ export const createTransloaditMcpServer = (options = {}) => {
1137
1460
  if ('error' in access)
1138
1461
  return access.error;
1139
1462
  const start = Date.now();
1463
+ const maxWaitMs = options.maxWaitMs ?? defaultMaxWaitMs;
1464
+ const budgetMs = Math.min(timeout_ms ?? maxWaitMs, maxWaitMs);
1465
+ // Polling only reads status, so it can be stopped as soon as the budget is spent.
1466
+ const polling = new AbortController();
1467
+ let latest;
1140
1468
  let assembly;
1469
+ let returnedEarly = false;
1141
1470
  try {
1142
- assembly = await access.client.awaitAssemblyCompletion(access.assemblyId, {
1143
- timeout: timeout_ms,
1471
+ const outcome = await settleWithin(access.client.awaitAssemblyCompletion(access.assemblyId, {
1472
+ timeout: budgetMs,
1144
1473
  interval: poll_interval_ms,
1145
1474
  assemblyUrl: access.assemblyUrl,
1146
- });
1475
+ signal: polling.signal,
1476
+ onAssemblyProgress: (status) => {
1477
+ latest = status;
1478
+ },
1479
+ }), budgetMs);
1480
+ if (outcome === deadlineReached) {
1481
+ polling.abort();
1482
+ returnedEarly = true;
1483
+ const status = latest ?? (await readAssemblyStatus(access.client, access.assemblyId));
1484
+ if (!status) {
1485
+ return buildStatusPendingResponse({
1486
+ assemblyId: access.assemblyId,
1487
+ assemblyUrl: access.assemblyUrl ?? defaultAssemblyUrl(options, access.assemblyId),
1488
+ }, { waited_ms: Date.now() - start });
1489
+ }
1490
+ assembly = status;
1491
+ }
1492
+ else {
1493
+ assembly = outcome;
1494
+ }
1147
1495
  }
1148
1496
  catch (error) {
1149
- const rejection = toAuthRejection(options, error, access.credentials, toolMetadata.transloadit_wait_for_assembly.scopes);
1150
- if (rejection)
1151
- return rejection;
1152
- throw error;
1497
+ if (!(isPollingTimeout(error) && latest)) {
1498
+ const rejection = toAuthRejection(options, error, access.credentials, toolMetadata.transloadit_wait_for_assembly.scopes);
1499
+ if (rejection)
1500
+ return rejection;
1501
+ throw error;
1502
+ }
1503
+ assembly = latest;
1504
+ returnedEarly = true;
1153
1505
  }
1154
1506
  const waited_ms = Date.now() - start;
1507
+ const running = isAssemblyRunning(assembly);
1155
1508
  return buildToolResponse({
1156
1509
  status: 'ok',
1157
1510
  assembly,
1511
+ running,
1158
1512
  waited_ms,
1513
+ warnings: returnedEarly && running
1514
+ ? [buildStillRunningNotice(assembly, access.assemblyUrl)]
1515
+ : undefined,
1159
1516
  }, { meta: { [widgetContextMetaKey]: buildWidgetContext(options, assembly) } });
1160
1517
  });
1161
1518
  register('transloadit_list_robots', listRobotsInputSchema, listRobotsOutputSchema, ({ category, search, limit, cursor }) => {