Skip to content

Commit 9ceba2d

Browse files
committed
validate required template fields
1 parent 638ca8e commit 9ceba2d

3 files changed

Lines changed: 121 additions & 20 deletions

File tree

packages/mcp-server/src/server.ts

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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'
44
import {
5+
extractFieldNamesFromTemplate,
56
getRobotHelp,
67
listRobots,
78
mergeTemplateContent,
@@ -411,6 +412,21 @@ const mergeImportOverrides = (
411412
return nextOverrides
412413
}
413414

415+
const collectFieldNames = (templateContent: string): string[] => {
416+
const names = extractFieldNamesFromTemplate(templateContent).map((field) => field.fieldName)
417+
return Array.from(new Set(names))
418+
}
419+
420+
const mergeFieldValues = (
421+
templateFields: Record<string, unknown> | undefined,
422+
argFields: Record<string, unknown> | undefined,
423+
): Record<string, unknown> => {
424+
return {
425+
...(templateFields ?? {}),
426+
...(argFields ?? {}),
427+
}
428+
}
429+
414430
const getAssemblyIdFromUrl = (assemblyUrl: string): string => {
415431
const match = assemblyUrl.match(/\/assemblies\/([^/?#]+)/)
416432
if (!match) {
@@ -637,7 +653,9 @@ export const createTransloaditMcpServer = (
637653
let inputFilesForPrep = fileInputs
638654
let params = parseInstructions(instructions) ?? ({} as CreateAssemblyParams)
639655
let allowStepsOverride = true
656+
let mergedInstructions: CreateAssemblyParams | undefined
640657
let mergedSteps: Record<string, unknown> | undefined
658+
let mergedFields: Record<string, unknown> | undefined
641659

642660
if (builtin_template) {
643661
const templateId = buildBuiltinTemplateId(builtin_template.slug, builtin_template.version)
@@ -651,29 +669,35 @@ export const createTransloaditMcpServer = (
651669
params.template_id = templateId
652670
}
653671

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
672+
let analysis = analyzeSteps(isRecord(params.steps) ? params.steps : {})
673+
674+
if (params.template_id) {
675+
templatePathHint = templatePathHint ?? 'instructions.template_id'
676+
const template = await client.getTemplate(params.template_id)
677+
allowStepsOverride = template.content.allow_steps_override !== false
678+
try {
679+
const merged = mergeTemplateContent(template.content, params)
680+
mergedInstructions = merged as CreateAssemblyParams
681+
mergedSteps = isRecord(merged.steps) ? (merged.steps as Record<string, unknown>) : {}
682+
mergedFields = isRecord(merged.fields) ? (merged.fields as Record<string, unknown>) : {}
683+
analysis = analyzeSteps(mergedSteps)
684+
} catch (error) {
685+
if (error instanceof Error && error.message === 'TEMPLATE_DENIES_STEPS_OVERRIDE') {
686+
return buildToolError(
687+
'mcp_template_override_denied',
688+
'Template forbids step overrides; remove steps overrides or choose a different template.',
689+
{ path: templatePathHint },
690+
)
674691
}
692+
throw error
675693
}
694+
} else {
695+
mergedInstructions = params
696+
mergedSteps = isRecord(params.steps) ? (params.steps as Record<string, unknown>) : {}
697+
mergedFields = isRecord(params.fields) ? (params.fields as Record<string, unknown>) : {}
698+
}
676699

700+
if (hasUrlInputs) {
677701
if (!analysis.hasHttpImport && !analysis.requiresUpload) {
678702
inputFilesForPrep = fileInputs.filter((file) => file.kind !== 'url')
679703
warnings.push({
@@ -706,6 +730,31 @@ export const createTransloaditMcpServer = (
706730
}
707731
}
708732
}
733+
734+
if (mergedInstructions) {
735+
const fieldTemplateContent = JSON.stringify(mergedInstructions)
736+
const requiredFields = collectFieldNames(fieldTemplateContent)
737+
const providedFields = mergeFieldValues(
738+
mergedFields,
739+
isRecord(fields) ? (fields as Record<string, unknown>) : undefined,
740+
)
741+
const missingFields = requiredFields.filter((fieldName) => !(fieldName in providedFields))
742+
const effectiveMissing =
743+
hasUrlInputs && analysis.hasHttpImport
744+
? missingFields.filter((fieldName) => fieldName !== 'input')
745+
: missingFields
746+
747+
if (effectiveMissing.length > 0) {
748+
return buildToolError(
749+
'mcp_missing_fields',
750+
`Missing required fields: ${effectiveMissing.join(', ')}`,
751+
{
752+
path: 'fields',
753+
hint: 'Provide these field names under the fields argument.',
754+
},
755+
)
756+
}
757+
}
709758
const prep = await prepareInputFiles({
710759
inputFiles: inputFilesForPrep,
711760
params,

packages/mcp-server/test/e2e/template-inputs.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,24 @@ maybeDescribe('mcp-server template URL handling', { timeout: 60000 }, () => {
7474
},
7575
})
7676

77+
const fieldTemplate = await api.createTemplate({
78+
name: `mcp-fields-${suffix}`,
79+
template: {
80+
steps: {
81+
convert: {
82+
robot: '/html/convert',
83+
url: '${fields.prompt}',
84+
result: true,
85+
},
86+
},
87+
},
88+
})
89+
7790
templates.push(
7891
{ id: importTemplate.id, name: importTemplate.name },
7992
{ id: uploadTemplate.id, name: uploadTemplate.name },
8093
{ id: noInputTemplate.id, name: noInputTemplate.name },
94+
{ id: fieldTemplate.id, name: fieldTemplate.name },
8195
)
8296
})
8397

@@ -166,4 +180,41 @@ maybeDescribe('mcp-server template URL handling', { timeout: 60000 }, () => {
166180
const warnings = Array.isArray(payload.warnings) ? payload.warnings : []
167181
expect(warnings.some((warning) => warning.code === 'mcp_url_inputs_ignored')).toBe(true)
168182
})
183+
184+
it('errors when required fields are missing', async () => {
185+
const result = await client.callTool({
186+
name: 'transloadit_create_assembly',
187+
arguments: {
188+
instructions: {
189+
template_id: templates[3]?.id,
190+
},
191+
fields: {
192+
wrong: 'https://example.com',
193+
},
194+
},
195+
})
196+
197+
const payload = parseToolPayload(result)
198+
expect(payload.status).toBe('error')
199+
const errors = Array.isArray(payload.errors) ? payload.errors : []
200+
expect(errors.some((error) => error.code === 'mcp_missing_fields')).toBe(true)
201+
})
202+
203+
it('accepts required fields referenced by the template', async () => {
204+
const result = await client.callTool({
205+
name: 'transloadit_create_assembly',
206+
arguments: {
207+
instructions: {
208+
template_id: templates[3]?.id,
209+
},
210+
fields: {
211+
prompt: 'https://example.com',
212+
},
213+
wait_for_completion: false,
214+
},
215+
})
216+
217+
const payload = parseToolPayload(result)
218+
expect(payload.status).toBe('ok')
219+
})
169220
})

packages/node/src/Transloadit.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export {
6969
export type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts'
7070
export * from './apiTypes.ts'
7171
export { InconsistentResponseError, ApiError }
72+
export { extractFieldNamesFromTemplate } from './alphalib/stepParsing.ts'
7273
// Builtin templates replace the legacy golden template helpers.
7374
export { mergeTemplateContent } from './alphalib/templateMerge.ts'
7475
export type {

0 commit comments

Comments
 (0)