From fdeb2be7f42a3ab39e27c62626783632294edcd7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 4 Nov 2026 21:36:18 +0100 Subject: [PATCH 1/3] Expose completed MCP result files as text links --- .changeset/mcp-result-links.md | 8 + packages/mcp-server/src/server.ts | 73 ++++- .../test/unit/assembly-results.test.ts | 260 ++++++++++++++++++ 3 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-result-links.md create mode 100644 packages/mcp-server/test/unit/assembly-results.test.ts diff --git a/.changeset/mcp-result-links.md b/.changeset/mcp-result-links.md new file mode 100644 index 00000000..09c3e58c --- /dev/null +++ b/.changeset/mcp-result-links.md @@ -0,0 +1,8 @@ +--- +'@transloadit/mcp-server': patch +--- + +Show completed Assembly result files as readable Step names, file names and download URLs in tool +text, so clients without a result widget can link to the outputs. Prefer signed download URLs when +available. Keep metadata for the first 20 files in text and report the remaining count, while +keeping all results in structured output and the result widget. diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 41c54630..dc123b31 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -405,25 +405,82 @@ type ToolResponseExtras = { /** Result `_meta`, delivered to widgets and hosts but hidden from the model. */ meta?: Record isError?: boolean + content?: TextContent[] } const buildToolResponse = ( payload: Record, extras: ToolResponseExtras = {}, ): CallToolResult => { - const content: TextContent = { - type: 'text', - text: JSON.stringify(payload), - } + const content: TextContent[] = extras.content ?? [{ type: 'text', text: JSON.stringify(payload) }] return { - content: [content], + content, structuredContent: payload, ...(extras.isError ? { isError: true } : {}), ...(extras.meta ? { _meta: extras.meta } : {}), } } +const maxTextResultFiles = 20 + +/** Gives clients without a result widget a bounded, readable list of completed result files. */ +const buildAssemblyToolResponse = ( + payload: Record & { assembly: AssemblyStatus }, + extras: ToolResponseExtras = {}, +): CallToolResult => { + const { assembly } = payload + // Only successful completion publishes a final file list; partial results keep their JSON. + if (assembly.ok !== 'ASSEMBLY_COMPLETED' || assembly.error) { + return buildToolResponse(payload, extras) + } + + const lines: string[] = [] + const textResults: NonNullable = {} + let totalFiles = 0 + for (const [step, files] of Object.entries(assembly.results ?? {})) { + const remainingSlots = maxTextResultFiles - totalFiles + totalFiles += files.length + if (remainingSlots <= 0) continue + const textFiles = files.slice(0, remainingSlots) + // Metadata-only outputs, such as image descriptions, share the same cap as linked files. + textResults[step] = textFiles + for (const file of textFiles) { + const url = [file.signed_ssl_url, file.signed_url, file.ssl_url, file.url].find( + isNonEmptyString, + ) + if (!url) continue + + // Names can contain newlines; keep each file's label and URL together on one line. + const stepName = step.replaceAll('\r', ' ').replaceAll('\n', ' ') + const fileName = (file.name || 'Unnamed file').replaceAll('\r', ' ').replaceAll('\n', ' ') + lines.push(`- Step ${stepName} — ${fileName}: ${url}`) + } + } + + const remaining = Math.max(totalFiles - maxTextResultFiles, 0) + if (lines.length === 0 && remaining === 0) return buildToolResponse(payload, extras) + if (remaining > 0) { + lines.push(`… ${remaining} more result ${remaining === 1 ? 'file' : 'files'} not listed.`) + } + + return buildToolResponse(payload, { + ...extras, + content: [ + { + type: 'text', + // The requested 20-file text limit applies to this JSON fallback too: repeating all + // results would defeat the cap. The next text block reports omissions; structuredContent + // and the widget retain every result, including metadata and files beyond the cap. + text: JSON.stringify( + remaining > 0 ? { ...payload, assembly: { ...assembly, results: textResults } } : payload, + ), + }, + { type: 'text', text: ['Result files:', ...lines].join('\n') }, + ], + }) +} + /** * The SDK client validates `structuredContent` against a tool's output schema even on `isError` * results, so tools whose schema cannot describe an error (Template lists, the strict profile) @@ -1851,7 +1908,7 @@ export const createTransloaditMcpServer = ( ? [] : followRunningAssemblySteps - return buildToolResponse( + return buildAssemblyToolResponse( { status: 'ok', assembly, @@ -1988,7 +2045,7 @@ export const createTransloaditMcpServer = ( throw error } - return buildToolResponse({ + return buildAssemblyToolResponse({ status: 'ok', assembly, }) @@ -2058,7 +2115,7 @@ export const createTransloaditMcpServer = ( const waited_ms = Date.now() - start const running = isAssemblyRunning(assembly) - return buildToolResponse( + return buildAssemblyToolResponse( { status: 'ok', assembly, diff --git a/packages/mcp-server/test/unit/assembly-results.test.ts b/packages/mcp-server/test/unit/assembly-results.test.ts new file mode 100644 index 00000000..55fd9ad7 --- /dev/null +++ b/packages/mcp-server/test/unit/assembly-results.test.ts @@ -0,0 +1,260 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import type { AssemblyStatus } from '@transloadit/node' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { Transloadit } from '@transloadit/node' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createTransloaditMcpServer } from '../../src/server.ts' +import { widgetContextMetaKey } from '../../src/ui/assembly-result-widget.ts' + +const assemblyId = '0123456789abcdef0123456789abcdef' +const assemblyUrl = `https://api2.transloadit.com/assemblies/${assemblyId}` +const instructions = { steps: { thumbs: { robot: '/video/thumbs', count: 8 } } } +const tools = [ + { name: 'transloadit_create_assembly', arguments: { instructions, wait_for_completion: true } }, + { + name: 'transloadit_video_covers', + arguments: { files: [{ kind: 'url', field: 'file', url: 'https://example.com/video.mp4' }] }, + }, + { name: 'transloadit_get_assembly_status', arguments: { assembly_url: assemblyUrl } }, + { name: 'transloadit_wait_for_assembly', arguments: { assembly_url: assemblyUrl } }, +] +const cover = (index: number) => ({ + name: `cover-${index}.jpg`, + mime: 'image/jpeg', + ssl_url: `https://results.example.com/cover-${index}.jpg`, +}) +const completed: AssemblyStatus = { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: assemblyId, + assembly_ssl_url: assemblyUrl, + results: { + thumbs: Array.from({ length: 8 }, (_, index) => cover(index + 1)), + transcript: [ + { + name: 'transcript.txt', + mime: 'text/plain', + ssl_url: 'https://results.example.com/transcript.txt', + }, + ], + }, +} + +describe('Assembly result links for clients without a widget', () => { + let client: Client | undefined + let server: ReturnType | undefined + + const call = async ( + tool: (typeof tools)[number], + assembly: AssemblyStatus, + ): Promise => { + vi.spyOn(Transloadit.prototype, 'createAssembly').mockImplementation(() => + Object.assign(Promise.resolve(assembly), { assemblyId }), + ) + vi.spyOn(Transloadit.prototype, 'getAssembly').mockResolvedValue(assembly) + vi.spyOn(Transloadit.prototype, 'awaitAssemblyCompletion').mockResolvedValue(assembly) + const template = { + id: 'builtin/video-covers@0.0.1', + name: 'Video covers', + require_signature_auth: 0, + fields_schema: { type: 'object', properties: {} }, + content: { steps: { ':original': { robot: '/upload/handle' } } }, + } + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ + items: [template], + count: 1, + }) + vi.spyOn(Transloadit.prototype, 'getTemplate').mockResolvedValue({ + ...template, + ok: 'TEMPLATE_FOUND', + message: 'Found', + }) + server = createTransloaditMcpServer({ authKey: 'key', authSecret: 'secret' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'text-only-client', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return CallToolResultSchema.parse(await client.callTool(tool)) + } + + afterEach(async () => { + await client?.close() + await server?.close() + vi.restoreAllMocks() + }) + + it.each(tools)('lists completed result files as readable text in $name', async (tool) => { + const result = await call(tool, completed) + + expect(result.isError).toBeUndefined() + expect(result.structuredContent?.assembly).toEqual(completed) + expect(result.content).toContainEqual({ + type: 'text', + text: JSON.stringify(result.structuredContent), + }) + expect(result.content).toContainEqual({ + type: 'text', + text: [ + 'Result files:', + ...Array.from( + { length: 8 }, + (_, index) => + `- Step thumbs — cover-${index + 1}.jpg: https://results.example.com/cover-${index + 1}.jpg`, + ), + '- Step transcript — transcript.txt: https://results.example.com/transcript.txt', + ].join('\n'), + }) + expect(result._meta).toEqual( + tool.name === 'transloadit_get_assembly_status' + ? undefined + : { [widgetContextMetaKey]: { authenticated: true } }, + ) + }) + + it.each(tools)('keeps running Assembly text unchanged in $name', async (tool) => { + const running: AssemblyStatus = { ...completed, ok: 'ASSEMBLY_EXECUTING', results: {} } + const result = await call(tool, running) + + expect(result.structuredContent?.assembly).toEqual(running) + expect(result.content).toEqual([ + { type: 'text', text: JSON.stringify(result.structuredContent) }, + ]) + }) + + it.each( + tools, + )('caps text links across Steps while keeping every structured result in $name', async (tool) => { + const assembly: AssemblyStatus = { + ...completed, + results: { + first: Array.from({ length: 12 }, (_, index) => cover(index + 1)), + second: Array.from({ length: 13 }, (_, index) => cover(index + 13)), + }, + } + const result = await call(tool, assembly) + const content = JSON.stringify(result.content) + const summary = result.content.filter((item) => item.type === 'text')[1]?.text ?? '' + + expect(result.structuredContent?.assembly).toEqual(assembly) + expect(summary.match(/https:\/\/results\.example\.com\/cover-\d+\.jpg/g)).toHaveLength(20) + expect(content).toContain('Step first — cover-1.jpg: https://results.example.com/cover-1.jpg') + expect(content).toContain( + 'Step second — cover-20.jpg: https://results.example.com/cover-20.jpg', + ) + expect(content).not.toContain('https://results.example.com/cover-21.jpg') + expect(content).toContain('… 5 more result files not listed.') + expect(result.content).toContainEqual({ + type: 'text', + text: JSON.stringify({ + ...result.structuredContent, + assembly: { + ...assembly, + results: { first: assembly.results?.first, second: assembly.results?.second.slice(0, 8) }, + }, + }), + }) + }) + + it('does not add a file list when a completed Assembly has no results', async () => { + const result = await call(tools[2], { ...completed, results: {} }) + + expect(result.content).toEqual([ + { type: 'text', text: JSON.stringify(result.structuredContent) }, + ]) + }) + + it.each([20, 21])('handles the text limit boundary with %i result files', async (count) => { + const result = await call(tools[2], { + ...completed, + results: { thumbs: Array.from({ length: count }, (_, index) => cover(index + 1)) }, + }) + const content = result.content.filter((item) => item.type === 'text')[1]?.text ?? '' + + expect(content.match(/https:\/\/results\.example\.com\/cover-\d+\.jpg/g)).toHaveLength(20) + expect(content.includes('… 1 more result file not listed.')).toBe(count === 21) + }) + + it('preserves result metadata for text-only clients', async () => { + const result = await call(tools[2], { + ...completed, + results: { + described: [ + { ...cover(1), meta: { descriptions: ['A dog running through a field.'] } }, + { name: 'metadata-only.jpg', meta: { descriptions: ['A cat resting on a sofa.'] } }, + ], + }, + }) + + expect(result.content).toContainEqual({ + type: 'text', + text: JSON.stringify(result.structuredContent), + }) + expect(JSON.stringify(result.content)).toContain('A dog running through a field.') + expect(JSON.stringify(result.content)).toContain('A cat resting on a sofa.') + }) + + it('links to a signed SSL URL when a private result supplies one', async () => { + const signedUrl = 'https://results.example.com/private.jpg?X-Amz-Signature=test' + const result = await call(tools[2], { + ...completed, + results: { private: [{ ...cover(1), signed_ssl_url: signedUrl }] }, + }) + + expect(result.content).toContainEqual({ + type: 'text', + text: `Result files:\n- Step private — cover-1.jpg: ${signedUrl}`, + }) + }) + + it('links to url when a result has no SSL URL', async () => { + const result = await call(tools[2], { + ...completed, + results: { + thumbs: [cover(1), { name: 'legacy.jpg', url: 'https://results.example.com/legacy.jpg' }], + }, + }) + + expect(result.content).toContainEqual({ + type: 'text', + text: [ + 'Result files:', + '- Step thumbs — cover-1.jpg: https://results.example.com/cover-1.jpg', + '- Step thumbs — legacy.jpg: https://results.example.com/legacy.jpg', + ].join('\n'), + }) + }) + + it('skips files without download URLs and supplies a label for an unnamed file', async () => { + const result = await call(tools[2], { + ...completed, + results: { + empty: [], + thumbs: [ + { name: 'missing.jpg' }, + { name: 'null.jpg', ssl_url: null }, + { name: 'empty.jpg', ssl_url: '' }, + { ssl_url: cover(1).ssl_url }, + ], + }, + }) + + expect(result.content).toContainEqual({ + type: 'text', + text: 'Result files:\n- Step thumbs — Unnamed file: https://results.example.com/cover-1.jpg', + }) + }) + + it('keeps file links on one line when names contain newlines', async () => { + const result = await call(tools[2], { + ...completed, + results: { 'video\r\ncovers': [{ ...cover(1), name: 'cover\n1.jpg' }] }, + }) + + expect(result.content).toContainEqual({ + type: 'text', + text: 'Result files:\n- Step video covers — cover 1.jpg: https://results.example.com/cover-1.jpg', + }) + }) +}) From 280a85e36df1e747427e5ff1fc3a4ec88dcc6278 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 4 Nov 2026 22:18:42 +0100 Subject: [PATCH 2/3] Use a comma instead of an em dash in result file lines Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mcp-server/src/server.ts | 2 +- .../test/unit/assembly-results.test.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index dc123b31..f2d3a1f9 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -454,7 +454,7 @@ const buildAssemblyToolResponse = ( // Names can contain newlines; keep each file's label and URL together on one line. const stepName = step.replaceAll('\r', ' ').replaceAll('\n', ' ') const fileName = (file.name || 'Unnamed file').replaceAll('\r', ' ').replaceAll('\n', ' ') - lines.push(`- Step ${stepName} — ${fileName}: ${url}`) + lines.push(`- Step ${stepName}, ${fileName}: ${url}`) } } diff --git a/packages/mcp-server/test/unit/assembly-results.test.ts b/packages/mcp-server/test/unit/assembly-results.test.ts index 55fd9ad7..577c9f2a 100644 --- a/packages/mcp-server/test/unit/assembly-results.test.ts +++ b/packages/mcp-server/test/unit/assembly-results.test.ts @@ -101,9 +101,9 @@ describe('Assembly result links for clients without a widget', () => { ...Array.from( { length: 8 }, (_, index) => - `- Step thumbs — cover-${index + 1}.jpg: https://results.example.com/cover-${index + 1}.jpg`, + `- Step thumbs, cover-${index + 1}.jpg: https://results.example.com/cover-${index + 1}.jpg`, ), - '- Step transcript — transcript.txt: https://results.example.com/transcript.txt', + '- Step transcript, transcript.txt: https://results.example.com/transcript.txt', ].join('\n'), }) expect(result._meta).toEqual( @@ -139,9 +139,9 @@ describe('Assembly result links for clients without a widget', () => { expect(result.structuredContent?.assembly).toEqual(assembly) expect(summary.match(/https:\/\/results\.example\.com\/cover-\d+\.jpg/g)).toHaveLength(20) - expect(content).toContain('Step first — cover-1.jpg: https://results.example.com/cover-1.jpg') + expect(content).toContain('Step first, cover-1.jpg: https://results.example.com/cover-1.jpg') expect(content).toContain( - 'Step second — cover-20.jpg: https://results.example.com/cover-20.jpg', + 'Step second, cover-20.jpg: https://results.example.com/cover-20.jpg', ) expect(content).not.toContain('https://results.example.com/cover-21.jpg') expect(content).toContain('… 5 more result files not listed.') @@ -204,7 +204,7 @@ describe('Assembly result links for clients without a widget', () => { expect(result.content).toContainEqual({ type: 'text', - text: `Result files:\n- Step private — cover-1.jpg: ${signedUrl}`, + text: `Result files:\n- Step private, cover-1.jpg: ${signedUrl}`, }) }) @@ -220,8 +220,8 @@ describe('Assembly result links for clients without a widget', () => { type: 'text', text: [ 'Result files:', - '- Step thumbs — cover-1.jpg: https://results.example.com/cover-1.jpg', - '- Step thumbs — legacy.jpg: https://results.example.com/legacy.jpg', + '- Step thumbs, cover-1.jpg: https://results.example.com/cover-1.jpg', + '- Step thumbs, legacy.jpg: https://results.example.com/legacy.jpg', ].join('\n'), }) }) @@ -242,7 +242,7 @@ describe('Assembly result links for clients without a widget', () => { expect(result.content).toContainEqual({ type: 'text', - text: 'Result files:\n- Step thumbs — Unnamed file: https://results.example.com/cover-1.jpg', + text: 'Result files:\n- Step thumbs, Unnamed file: https://results.example.com/cover-1.jpg', }) }) @@ -254,7 +254,7 @@ describe('Assembly result links for clients without a widget', () => { expect(result.content).toContainEqual({ type: 'text', - text: 'Result files:\n- Step video covers — cover 1.jpg: https://results.example.com/cover-1.jpg', + text: 'Result files:\n- Step video covers, cover 1.jpg: https://results.example.com/cover-1.jpg', }) }) }) From 1f53315b8db1dc52473983a147b1ae850559e9ca Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 4 Nov 2026 22:23:04 +0100 Subject: [PATCH 3/3] Format the result link test Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mcp-server/test/unit/assembly-results.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/mcp-server/test/unit/assembly-results.test.ts b/packages/mcp-server/test/unit/assembly-results.test.ts index 577c9f2a..b6536c71 100644 --- a/packages/mcp-server/test/unit/assembly-results.test.ts +++ b/packages/mcp-server/test/unit/assembly-results.test.ts @@ -140,9 +140,7 @@ describe('Assembly result links for clients without a widget', () => { expect(result.structuredContent?.assembly).toEqual(assembly) expect(summary.match(/https:\/\/results\.example\.com\/cover-\d+\.jpg/g)).toHaveLength(20) expect(content).toContain('Step first, cover-1.jpg: https://results.example.com/cover-1.jpg') - expect(content).toContain( - 'Step second, cover-20.jpg: https://results.example.com/cover-20.jpg', - ) + expect(content).toContain('Step second, cover-20.jpg: https://results.example.com/cover-20.jpg') expect(content).not.toContain('https://results.example.com/cover-21.jpg') expect(content).toContain('… 5 more result files not listed.') expect(result.content).toContainEqual({