Skip to content

Commit 638ca8e

Browse files
committed
resolve template URL inputs via template merge
1 parent ab771ed commit 638ca8e

5 files changed

Lines changed: 322 additions & 64 deletions

File tree

packages/mcp-server/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ the request body stays small and no extra MCP/LLM token budget is consumed.
9595
and avoids large inline payloads (the transfer happens out-of-band).
9696
- If instructions already contain an `/http/import` step, the MCP server sets/overrides its `url`.
9797
- If multiple URLs and a single `/http/import` step exists, it supplies a `url` array.
98-
- Templates (builtin or custom) should expose an `imported` step that the template uses as input.
99-
When you pass `template_id` (or `builtin_template`) and URL inputs, the MCP server injects an
100-
`/http/import` step named `imported` and fills its `url` value(s).
98+
- When `template_id` (or `builtin_template`) is used, the MCP server fetches the template and
99+
chooses the safest URL path:
100+
- If the merged template contains a `/http/import` step, it overrides that step’s `url`.
101+
- If the template expects uploads (`:original` / `/upload/handle`), it downloads and uploads via
102+
tus.
103+
- If the template doesn’t take inputs (for example `/html/convert` with a `url`), URL inputs are
104+
ignored and a warning is returned.
105+
- If `allow_steps_override=false` and the template only supports `/http/import`, URL inputs are
106+
rejected (no safe override path).
101107
102108
## Local vs hosted file access
103109

packages/mcp-server/src/server.ts

Lines changed: 140 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
22
import type { CallToolResult, TextContent } from '@modelcontextprotocol/sdk/types.js'
33
import 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'
511
import { z } from 'zod'
612
import packageJson from '../package.json' with { type: 'json' }
713
import { extractBearerToken } from './http-helpers.ts'
@@ -339,8 +345,71 @@ const isErrnoException = (value: unknown): value is NodeJS.ErrnoException =>
339345
const 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

345414
const getAssemblyIdFromUrl = (assemblyUrl: string): string => {
346415
const match = assemblyUrl.match(/\/assemblies\/([^/?#]+)/)
@@ -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+
457537
const 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',

packages/mcp-server/test/e2e/builtin-templates-import.test.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.

packages/mcp-server/test/e2e/builtin-templates.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { Client } from '@modelcontextprotocol/sdk/client'
22
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
33
import { createMcpClient, isRecord, parseToolPayload } from './mcp-client.ts'
44

5-
describe('mcp-server builtin templates', { timeout: 20000 }, () => {
5+
const shouldRun = process.env.TRANSLOADIT_KEY != null && process.env.TRANSLOADIT_SECRET != null
6+
const maybeDescribe = shouldRun ? describe : describe.skip
7+
8+
maybeDescribe('mcp-server builtin templates', { timeout: 20000 }, () => {
69
let client: Client
710

811
beforeAll(async () => {

0 commit comments

Comments
 (0)