Skip to content
Merged
9 changes: 9 additions & 0 deletions .changeset/assembly-input-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@transloadit/node': minor
'transloadit': minor
'@transloadit/types': minor
'@transloadit/zod': minor
'@transloadit/mcp-server': patch
---

Contract: add the `input_url` Assembly parameter (transloadit/api2#10292), which imports one or more URLs as the input files of a Builtin Template selected with `template_id`, and share the Builtins that accept it with the MCP Builtin manifest.
5 changes: 5 additions & 0 deletions .changeset/builtin-input-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@transloadit/mcp-server': minor
---

Builtin shortcuts send URL inputs to Transloadit as `input_url` with `template_id` (transloadit/api2#10292) when every file and attachment of the call is a URL and the Builtin accepts it. Transloadit fetches the files itself, so the Assembly is created at once, the MCP server downloads nothing and large files no longer run into the wait budget. Mixed inputs, named inputs, `upload_behavior: 'none'` and other Builtins keep the download-and-upload path; this needs an API2 with `input_url` support.
12 changes: 12 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,18 @@ For URL inputs, behavior depends on the template/instructions:
- If the template does not take input files, URL inputs are ignored and a warning is returned.
- If `allow_steps_override=false` and only `/http/import` would work, URL inputs are rejected.

The Builtin shortcuts for `caption-video`, `convert-to-pdf`, `encode-video`, `extract-text`,
`prepare-image`, `remove-background`, `transcribe` and `video-covers` send URLs to API2 as
[`input_url`](https://github.com/transloadit/api2/pull/10292) next to `template_id` when every file
and attachment of the call is a URL (up to 10, one for `caption-video`). Transloadit then fetches
the files itself, so the Assembly exists at once and nothing is downloaded by the MCP server or held
up by a large file. API2 names the files after their URLs (or redirects and `Content-Disposition`),
so inputs given a file name, such as host attachments, keep the download path above, as do calls
that mix URLs with base64 files or sandbox uploads, calls with `upload_behavior: 'none'`, and URLs
that API2 would refuse (whitespace, `|`, `${…}`, over 4096 characters). If API2 refuses
`input_url` for the request, its error is returned like any other. The eligible Builtins are listed
in the [alphalib Builtin manifest](../node/src/alphalib/types/builtinTemplates.ts).

## Local vs hosted file access

- MCP accepts base64 and URL inputs in both self-hosted and hosted deployments. Local filesystem
Expand Down
39 changes: 38 additions & 1 deletion packages/mcp-server/src/builtin-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Transloadit } from '@transloadit/node'
import type { InputFile, Transloadit } from '@transloadit/node'
import type { BuiltinTemplateCatalogItem } from '@transloadit/zod/v4/builtinTemplates'
import type { ValidateFunction } from 'ajv'

import {
builtinTemplateCatalogItemSchema,
mcpBuiltinInputUrlMaxCounts,
mcpBuiltinTemplateManifest,
} from '@transloadit/zod/v4/builtinTemplates'
import { Ajv } from 'ajv'
Expand Down Expand Up @@ -167,6 +168,42 @@ export const validateBuiltinFields = (
return merged
}

// API2's literal-URL rules for `input_url`; a URL it would refuse keeps today's download path.
const isLiteralHttpUrl = (value: string): boolean => {
if ([...value].length > 4096 || /\s/u.test(value) || value.includes('${') || value.includes('|'))
return false
if (!URL.canParse(value)) return false
const { protocol } = new URL(value)
return protocol === 'http:' || protocol === 'https:'
}

/**
* The URLs a shortcut call hands to API2 as `input_url`, so API2 imports them itself and nothing
* is downloaded here. Only for eligible Builtins and calls whose every input is such a URL;
* anything else (local, base64 or sandbox inputs, too many URLs, given file names)
* returns undefined and keeps the download-and-upload path.
*/
export const selectBuiltinInputUrls = (
templateId: string,
inputs: readonly InputFile[],
expectedUploads = 0,
): string[] | undefined => {
const maxUrls = mcpBuiltinInputUrlMaxCounts[templateId]
if (maxUrls === undefined || inputs.length === 0 || inputs.length > maxUrls) return undefined
// expected_uploads counts every input; more than the URLs means sandbox uploads follow.
if (expectedUploads > inputs.length) return undefined
const urls: string[] = []
for (const input of inputs) {
if (input.kind !== 'url' || !isLiteralHttpUrl(input.url)) return undefined
// API2 names an imported file after its URL, its redirects or Content-Disposition. A given
// name (of a host attachment, say) may carry the extension processing depends on, and only an
// upload keeps it.
if (input.filename !== undefined) return undefined
urls.push(input.url)
}
return urls
}

/** Read approved Builtins with a dedicated, retry-disabled client; callers never share catalogs. */
export const loadBuiltinShortcuts = async (
client: Transloadit,
Expand Down
23 changes: 22 additions & 1 deletion packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
BuiltinFieldValidationError,
builtinShortcuts,
loadBuiltinShortcuts,
selectBuiltinInputUrls,
validateBuiltinFields,
} from './builtin-shortcuts.ts'
import { buildBearerChallenge, extractBearerToken } from './http-helpers.ts'
Expand Down Expand Up @@ -1920,8 +1921,28 @@ export const createTransloaditMcpServer = (
}
return buildShortcutError('mcp_invalid_builtin_fields', error.message, { path: 'fields' })
}
// upload_behavior none asks for reserved upload URLs, which only the upload path hands out.
const inputUrls =
args.upload_behavior === 'none'
? undefined
: selectBuiltinInputUrls(
template.id,
[...(args.files ?? []), ...toAttachmentInputs(args.attachments ?? [])],
args.expected_uploads,
)
// API2 imports eligible URLs itself (api2#10292), so the Assembly exists at once and nothing
// is downloaded here. A refusal is API2's to explain, so it surfaces like any other error.
const result = await createAssembly(
{ ...args, fields, instructions: { template_id: template.id } },
inputUrls
? {
...args,
files: undefined,
attachments: undefined,
expected_uploads: undefined,
fields,
instructions: { template_id: template.id, input_url: inputUrls },
}
: { ...args, fields, instructions: { template_id: template.id } },
extra,
true,
startedAt,
Expand Down
44 changes: 44 additions & 0 deletions packages/mcp-server/test/e2e/url-import.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Client } from '@modelcontextprotocol/sdk/client'

import { Transloadit } from '@transloadit/node'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

import { createMcpClient, isRecord, parseToolPayload } from './mcp-client.ts'

const shouldRun = process.env.TRANSLOADIT_KEY != null && process.env.TRANSLOADIT_SECRET != null
const maybeDescribe = shouldRun ? describe : describe.skip

const prepareImage = 'builtin/prepare-image@0.0.1'
const demoImage = 'https://demos.transloadit.com/66/01604e7d0248109df8c7cc0f8daef8/snowflake.jpg'

maybeDescribe('mcp-server URL inputs (stdio)', { timeout: 60000 }, () => {
Expand Down Expand Up @@ -105,4 +107,46 @@ maybeDescribe('mcp-server URL inputs (stdio)', { timeout: 60000 }, () => {
expect(Array.isArray(resized)).toBe(true)
expect(resized).toHaveLength(2)
})

// Until api2#10292 runs on every API2 host, an older host ignores input_url and completes the
// Builtin without input files. Asking API2 directly first tells that apart from an MCP bug. Only
// that exact old-host outcome skips; any error fails. The release waits for the rollout, so once
// it is done, drop the skip and assert the direct import instead.
it('lets API2 import a URL for a Builtin shortcut', async (context) => {
const api = new Transloadit({
authKey: process.env.TRANSLOADIT_KEY ?? '',
authSecret: process.env.TRANSLOADIT_SECRET ?? '',
endpoint: process.env.TRANSLOADIT_ENDPOINT,
})
const direct = await api.createAssembly({
params: { template_id: prepareImage, input_url: [demoImage], fields: { w: 64, h: 64 } },
waitForCompletion: true,
})
expect(direct.error).toBeUndefined()
expect(direct.ok).toBe('ASSEMBLY_COMPLETED')
context.skip(
direct.num_input_files === 0,
`API2 build ${direct.build_id ?? 'unknown'} ignored input_url (api2#10292 not on this host yet). Re-run after the rollout.`,
)
expect(direct.num_input_files).toBe(1)

const result = await client.callTool({
name: 'transloadit_prepare_image',
arguments: {
fields: { w: 64, h: 64 },
files: [{ kind: 'url', field: 'remote', url: demoImage }],
wait_for_completion: true,
},
})

const payload = parseToolPayload(result)
expect(payload.status).toBe('ok')
// Nothing went through the MCP server: API2's imported job supplied the input file.
expect(payload.upload).toMatchObject({ total_files: 0 })
const assembly = isRecord(payload.assembly) ? payload.assembly : {}
expect(assembly.ok).toBe('ASSEMBLY_COMPLETED')
expect(assembly.num_input_files).toBe(1)
expect(assembly.uploads).toEqual([])
expect(Object.values(isRecord(assembly.results) ? assembly.results : {}).flat()).not.toEqual([])
})
})
Loading
Loading