11import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
22import type { CallToolResult , TextContent } from '@modelcontextprotocol/sdk/types.js'
33import type { CreateAssemblyParams , LintAssemblyInstructionsResult } from '@transloadit/node'
4- import { getRobotHelp , listRobots , prepareInputFiles , Transloadit } from '@transloadit/node'
4+ import {
5+ getRobotHelp ,
6+ listRobots ,
7+ mergeTemplateContent ,
8+ prepareInputFiles ,
9+ Transloadit ,
10+ } from '@transloadit/node'
511import { z } from 'zod'
612import packageJson from '../package.json' with { type : 'json' }
713import { extractBearerToken } from './http-helpers.ts'
@@ -339,8 +345,71 @@ const isErrnoException = (value: unknown): value is NodeJS.ErrnoException =>
339345const isHttpImportStep = ( value : unknown ) : value is Record < string , unknown > =>
340346 isRecord ( value ) && value . robot === '/http/import'
341347
342- const hasHttpImportStep = ( steps : Record < string , unknown > ) : boolean =>
343- Object . values ( steps ) . some ( ( step ) => isHttpImportStep ( step ) )
348+ const listHttpImportStepNames = ( steps : Record < string , unknown > ) : string [ ] =>
349+ Object . entries ( steps )
350+ . filter ( ( [ , step ] ) => isHttpImportStep ( step ) )
351+ . map ( ( [ name ] ) => name )
352+
353+ const usesOriginalReference = ( value : unknown ) : boolean => {
354+ if ( value === ':original' ) return true
355+ if ( Array . isArray ( value ) ) {
356+ return value . some ( ( item ) => usesOriginalReference ( item ) )
357+ }
358+ if ( isRecord ( value ) ) {
359+ return Object . values ( value ) . some ( ( item ) => usesOriginalReference ( item ) )
360+ }
361+ return false
362+ }
363+
364+ type StepAnalysis = {
365+ importStepNames : string [ ]
366+ hasHttpImport : boolean
367+ requiresUpload : boolean
368+ }
369+
370+ const analyzeSteps = ( steps : Record < string , unknown > ) : StepAnalysis => {
371+ let hasUploadHandle = false
372+ let hasOriginalStep = false
373+ let usesOriginal = false
374+ const importStepNames = listHttpImportStepNames ( steps )
375+
376+ for ( const [ name , step ] of Object . entries ( steps ) ) {
377+ if ( name === ':original' ) {
378+ hasOriginalStep = true
379+ }
380+ if ( ! isRecord ( step ) ) continue
381+ if ( step . robot === '/upload/handle' ) {
382+ hasUploadHandle = true
383+ }
384+ if ( ! usesOriginal && 'use' in step ) {
385+ usesOriginal = usesOriginalReference ( step . use )
386+ }
387+ }
388+
389+ return {
390+ importStepNames,
391+ hasHttpImport : importStepNames . length > 0 ,
392+ requiresUpload : hasUploadHandle || hasOriginalStep || usesOriginal ,
393+ }
394+ }
395+
396+ const mergeImportOverrides = (
397+ templateSteps : Record < string , unknown > ,
398+ existingOverrides : Record < string , unknown > | undefined ,
399+ importStepNames : string [ ] ,
400+ ) : Record < string , unknown > => {
401+ const nextOverrides = isRecord ( existingOverrides ) ? { ...existingOverrides } : { }
402+ for ( const stepName of importStepNames ) {
403+ const templateStep = isRecord ( templateSteps [ stepName ] ) ? templateSteps [ stepName ] : { }
404+ const overrideStep = isRecord ( nextOverrides [ stepName ] ) ? nextOverrides [ stepName ] : { }
405+ nextOverrides [ stepName ] = {
406+ ...templateStep ,
407+ ...overrideStep ,
408+ robot : '/http/import' ,
409+ }
410+ }
411+ return nextOverrides
412+ }
344413
345414const getAssemblyIdFromUrl = ( assemblyUrl : string ) : string => {
346415 const match = assemblyUrl . match ( / \/ a s s e m b l i e s \/ ( [ ^ / ? # ] + ) / )
@@ -454,14 +523,25 @@ const fetchBuiltinTemplates = async (client: Transloadit): Promise<BuiltinTempla
454523 . filter ( ( template ) : template is BuiltinTemplate => Boolean ( template ) )
455524}
456525
526+ const looksLikeAssemblyParams = ( input : Record < string , unknown > ) : boolean => {
527+ return (
528+ 'steps' in input ||
529+ 'template_id' in input ||
530+ 'auth' in input ||
531+ 'fields' in input ||
532+ 'notify_url' in input ||
533+ 'redirect_url' in input
534+ )
535+ }
536+
457537const parseInstructions = ( input : unknown ) : CreateAssemblyParams | undefined => {
458538 if ( input == null ) return undefined
459539 if ( typeof input === 'string' ) {
460540 const parsed = safeJsonParse ( input )
461541 return isRecord ( parsed ) ? ( parsed as CreateAssemblyParams ) : undefined
462542 }
463543 if ( isRecord ( input ) ) {
464- if ( 'steps' in input ) {
544+ if ( looksLikeAssemblyParams ( input ) ) {
465545 return input as CreateAssemblyParams
466546 }
467547 return { steps : input } as CreateAssemblyParams
@@ -552,8 +632,12 @@ export const createTransloaditMcpServer = (
552632
553633 try {
554634 const fileInputs = files ?? [ ]
555- const hasUrlInputs = fileInputs . some ( ( file ) => file . kind === 'url' )
635+ const urlInputs = fileInputs . filter ( ( file ) => file . kind === 'url' )
636+ const hasUrlInputs = urlInputs . length > 0
637+ let inputFilesForPrep = fileInputs
556638 let params = parseInstructions ( instructions ) ?? ( { } as CreateAssemblyParams )
639+ let allowStepsOverride = true
640+ let mergedSteps : Record < string , unknown > | undefined
557641
558642 if ( builtin_template ) {
559643 const templateId = buildBuiltinTemplateId ( builtin_template . slug , builtin_template . version )
@@ -567,27 +651,63 @@ export const createTransloaditMcpServer = (
567651 params . template_id = templateId
568652 }
569653
570- if ( hasUrlInputs && params . template_id ) {
571- const steps = isRecord ( params . steps ) ? { ...params . steps } : { }
572- if ( ! hasHttpImportStep ( steps ) ) {
573- const imported = isRecord ( steps . imported )
574- ? ( steps . imported as Record < string , unknown > )
575- : { }
576- steps . imported = {
577- ...imported ,
578- robot : '/http/import' ,
654+ if ( hasUrlInputs ) {
655+ let analysis = analyzeSteps ( isRecord ( params . steps ) ? params . steps : { } )
656+
657+ if ( params . template_id ) {
658+ templatePathHint = templatePathHint ?? 'instructions.template_id'
659+ const template = await client . getTemplate ( params . template_id )
660+ allowStepsOverride = template . content . allow_steps_override !== false
661+ try {
662+ const merged = mergeTemplateContent ( template . content , params )
663+ mergedSteps = isRecord ( merged . steps ) ? ( merged . steps as Record < string , unknown > ) : { }
664+ analysis = analyzeSteps ( mergedSteps )
665+ } catch ( error ) {
666+ if ( error instanceof Error && error . message === 'TEMPLATE_DENIES_STEPS_OVERRIDE' ) {
667+ return buildToolError (
668+ 'mcp_template_override_denied' ,
669+ 'Template forbids step overrides; remove steps overrides or choose a different template.' ,
670+ { path : templatePathHint } ,
671+ )
672+ }
673+ throw error
579674 }
580- params . steps = steps
675+ }
676+
677+ if ( ! analysis . hasHttpImport && ! analysis . requiresUpload ) {
678+ inputFilesForPrep = fileInputs . filter ( ( file ) => file . kind !== 'url' )
581679 warnings . push ( {
582- code : 'mcp_imported_step' ,
583- message :
584- 'URL inputs with template_id require a step named "imported" that the template uses as input. The MCP server injected /http/import into that step.' ,
585- path : templatePathHint ?? 'instructions.template_id' ,
680+ code : 'mcp_url_inputs_ignored' ,
681+ message : 'URL inputs were ignored because the template does not require input files.' ,
682+ path : templatePathHint ?? 'instructions' ,
586683 } )
684+ } else if ( analysis . hasHttpImport ) {
685+ if ( ! allowStepsOverride ) {
686+ if ( analysis . requiresUpload ) {
687+ warnings . push ( {
688+ code : 'mcp_template_import_override_denied' ,
689+ message :
690+ 'Template forbids step overrides; URL inputs will be downloaded and uploaded via tus instead of /http/import.' ,
691+ path : templatePathHint ?? 'instructions.template_id' ,
692+ } )
693+ } else {
694+ return buildToolError (
695+ 'mcp_template_override_denied' ,
696+ 'Template forbids step overrides; URL inputs cannot be mapped to /http/import.' ,
697+ { path : templatePathHint ?? 'instructions.template_id' } ,
698+ )
699+ }
700+ } else if ( mergedSteps ) {
701+ params . steps = mergeImportOverrides (
702+ mergedSteps ,
703+ isRecord ( params . steps ) ? params . steps : undefined ,
704+ analysis . importStepNames ,
705+ )
706+ }
587707 }
588708 }
589709 const prep = await prepareInputFiles ( {
590- inputFiles : fileInputs ,
710+ inputFiles : inputFilesForPrep ,
591711 params,
592712 fields,
593713 base64Strategy : 'tempfile' ,
0 commit comments