diff --git a/.changeset/mcp-directory-readiness.md b/.changeset/mcp-directory-readiness.md new file mode 100644 index 00000000..94ca0ab6 --- /dev/null +++ b/.changeset/mcp-directory-readiness.md @@ -0,0 +1,22 @@ +--- +"@transloadit/mcp-server": patch +--- + +Prepare the plugin manifests, the MCP Registry entry and the result widget for the ChatGPT and +Claude directories. + +- `plugin.json` and `.codex-plugin/plugin.json` link the terms of service page that exists + (`/legal/terms-of-service/`), add a support URL, use a subtitle of at most 30 characters, ship + square icons, list capabilities and three starter prompts, and no longer mention pricing. + `plugin.json` also carries the review test cases and release notes for OpenAI's submission. +- `server.json` no longer declares an `Authorization` header on the hosted remote, so registry + listings stop asking for a token; OAuth clients find sign-in through the `401` challenge. It adds + a square icon and says the server connects with OAuth. +- The result widget declares `_meta["openai/widgetDomain"]`, which ChatGPT requires for a public + listing. It defaults to `https://transloadit.com` and is set with `TRANSLOADIT_MCP_WIDGET_DOMAIN` + or `widgetDomain`. `ui.domain` stays unset, so Claude keeps rendering the widget. +- The widget lists its result origins and the Console origin as ChatGPT `redirect_domains`, so + download and Console links open from ChatGPT, and opens them without an appended `redirectUrl`. +- `TRANSLOADIT_MCP_RESULT_DOMAINS` and `resultDomains` accept exact origins such as + `https://tmp-us-east-1.transloadit.net` and drop a trailing slash or path. A value that is not + an http(s) origin now stops the server at startup instead of ending up in the widget's CSP. diff --git a/packages/mcp-server/.codex-plugin/plugin.json b/packages/mcp-server/.codex-plugin/plugin.json index 0fa40d7d..f380a976 100644 --- a/packages/mcp-server/.codex-plugin/plugin.json +++ b/packages/mcp-server/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "transloadit", "version": "0.5.0", - "description": "Process video, audio, images and documents with Transloadit: encode, resize, transcribe, convert and deliver files through 86+ Robots.", + "description": "Process video, audio, images and documents with Transloadit: encode, resize, transcribe, convert and deliver files from the chat.", "author": { "name": "Transloadit", "email": "support@transloadit.com", @@ -28,19 +28,28 @@ }, "interface": { "displayName": "Transloadit", - "shortDescription": "Encode, resize, transcribe and convert files in the chat.", - "longDescription": "Drop a file in the chat and let Transloadit process it: HLS and MP4 encoding, image resizing and optimization, background removal, transcription and subtitles, document conversion and thumbnails. Results come back as previews with download links, and any run can be saved as a reusable Template. Connecting signs you in to your Transloadit Workspace through OAuth (a free Community plan is available); every tool, including Robot browsing and linting, runs on that connection. Agent Skills for these workflows are published at https://transloadit.com/.well-known/skills/index.json (source: https://github.com/transloadit/skills).", + "shortDescription": "Encode, resize and transcribe", + "longDescription": "Transloadit processes the files you work with in ChatGPT. Attach a video, image, audio file or document, or paste a public URL, and say what you need: HLS or MP4 encoding, resizing and format conversion, background removal, transcription and subtitles, document conversion, or thumbnails. Results appear as a card with previews and download links. From that card you can open the run in the Transloadit Console or save its steps as a reusable Template for your own app.\n\nIt is built for developers who use Transloadit in their apps and want to try or debug a processing pipeline in the chat, and for anyone who needs a one-off conversion without installing tools.\n\nConnecting opens Transloadit’s consent screen. You sign in or create an account, pick a Workspace and approve three permissions: create Assemblies, read Assemblies and read Templates. Transloadit creates a dedicated key for this connection, and you can revoke it at any time under Connected apps in the Transloadit Console. ChatGPT never sees your Auth Secret.\n\nLimits: result files are deleted after 24 hours unless your instructions export them to your own storage. Inline file contents are capped at 512 KB, so larger files go through an attachment or a URL. The limits of your Transloadit plan apply, such as the maximum file size.", "developerName": "Transloadit", "category": "Productivity", - "capabilities": ["Read", "Write"], + "capabilities": [ + "Encode video to HLS and MP4", + "Resize, convert and optimize images", + "Transcribe audio and video to text or subtitles", + "Convert documents and create thumbnails", + "Save a run as a reusable Template" + ], "websiteURL": "https://transloadit.com", + "supportURL": "https://transloadit.com/support/", "privacyPolicyURL": "https://transloadit.com/legal/privacy/", - "termsOfServiceURL": "https://transloadit.com/legal/terms/", + "termsOfServiceURL": "https://transloadit.com/legal/terms-of-service/", "defaultPrompt": [ "Turn this video into HLS with 720p and 1080p renditions", - "Transcribe this recording and give me an SRT subtitle file" + "Transcribe this recording and give me an SRT subtitle file", + "Resize this photo to 800px wide and convert it to WebP" ], "brandColor": "#1B61A7", + "brandColorDark": "#7DB8F2", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", "screenshots": [] diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8fe9cbec..a865ef07 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -324,7 +324,10 @@ Allowlist tools in `~/.gemini/settings.json`: - `TRANSLOADIT_MCP_UPSTREAM_SECRET` (hosted mode only, set by Transloadit's deployment; sent to API2 as `Transloadit-Mcp-Upstream` next to forwarded bearer tokens) - `TRANSLOADIT_MCP_RESULT_DOMAINS` (optional, comma-separated origins the result widget may load - previews from; default `https://*.transloadit.com,https://*.transloadit.net,https://*.r2.dev`) + previews from and link to, either exact such as `https://tmp-us-east-1.transloadit.net` or with a + wildcard host; default `https://*.transloadit.com,https://*.transloadit.net,https://*.r2.dev`) +- `TRANSLOADIT_MCP_WIDGET_DOMAIN` (optional, default `https://transloadit.com`; the origin ChatGPT + serves the result widget from, sent as `_meta["openai/widgetDomain"]`) - `TRANSLOADIT_MCP_CONSOLE_URL` (optional, default `https://transloadit.com`; Console origin for widget deep links) - `TRANSLOADIT_ENDPOINT` (optional, default `https://api2.transloadit.com`) @@ -339,7 +342,8 @@ Allowlist tools in `~/.gemini/settings.json`: - `npx -y @transloadit/mcp-server http --config path/to/config.json` The JSON config accepts the same keys as `createTransloaditMcpHttpHandler()`, including -`allowedOrigins`, `resourceMetadataUrl`, `signatureAlgorithm`, `resultDomains` and `consoleUrl`. +`allowedOrigins`, `resourceMetadataUrl`, `signatureAlgorithm`, `resultDomains`, `widgetDomain` and +`consoleUrl`. ## Tool surface @@ -381,6 +385,12 @@ download links, an "Open in Console" link and a "Save as Template" shortcut. It `2026-01-26` protocol (`ui/initialize` with `appInfo`) and also reads ChatGPT's `window.openai`. Its CSP allows `https://*.transloadit.com`, `https://*.transloadit.net` and `https://*.r2.dev` (result buckets); override the list with `TRANSLOADIT_MCP_RESULT_DOMAINS` or `resultDomains`. +ChatGPT asks for the exact hosts a widget uses, so a deployment that knows its result buckets +should list them as origins instead of wildcards. ChatGPT also needs `redirect_domains` to open +links from the widget: the server sends the result origins plus the Console origin there. ChatGPT +serves the widget from the origin in `_meta["openai/widgetDomain"]` +(`TRANSLOADIT_MCP_WIDGET_DOMAIN`). `ui.domain` stays unset, because Claude derives that value from +the server URL and rejects any other. ## Input files diff --git a/packages/mcp-server/assets/icon.png b/packages/mcp-server/assets/icon.png index d7f2ae80..420eec7e 100644 Binary files a/packages/mcp-server/assets/icon.png and b/packages/mcp-server/assets/icon.png differ diff --git a/packages/mcp-server/assets/logo.png b/packages/mcp-server/assets/logo.png index 927df2b4..d2d9bee0 100644 Binary files a/packages/mcp-server/assets/logo.png and b/packages/mcp-server/assets/logo.png differ diff --git a/packages/mcp-server/plugin.json b/packages/mcp-server/plugin.json index e6f00193..19a178a2 100644 --- a/packages/mcp-server/plugin.json +++ b/packages/mcp-server/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "transloadit", "version": "0.5.0", - "description": "Process video, audio, images and documents with Transloadit: encode, resize, transcribe, convert and deliver files through 86+ Robots.", + "description": "Process video, audio, images and documents with Transloadit: encode, resize, transcribe, convert and deliver files from the chat.", "author": { "name": "Transloadit", "email": "support@transloadit.com", @@ -25,22 +25,88 @@ "com.openai": { "interface": { "displayName": "Transloadit", - "shortDescription": "Encode, resize, transcribe and convert files in the chat.", - "longDescription": "Drop a file in the chat and let Transloadit process it: HLS and MP4 encoding, image resizing and optimization, background removal, transcription and subtitles, document conversion and thumbnails. Results come back as previews with download links, and any run can be saved as a reusable Template. Connecting signs you in to your Transloadit Workspace through OAuth (a free Community plan is available); every tool, including Robot browsing and linting, runs on that connection. Agent Skills for these workflows are published at https://transloadit.com/.well-known/skills/index.json (source: https://github.com/transloadit/skills).", + "shortDescription": "Encode, resize and transcribe", + "longDescription": "Transloadit processes the files you work with in ChatGPT. Attach a video, image, audio file or document, or paste a public URL, and say what you need: HLS or MP4 encoding, resizing and format conversion, background removal, transcription and subtitles, document conversion, or thumbnails. Results appear as a card with previews and download links. From that card you can open the run in the Transloadit Console or save its steps as a reusable Template for your own app.\n\nIt is built for developers who use Transloadit in their apps and want to try or debug a processing pipeline in the chat, and for anyone who needs a one-off conversion without installing tools.\n\nConnecting opens Transloadit’s consent screen. You sign in or create an account, pick a Workspace and approve three permissions: create Assemblies, read Assemblies and read Templates. Transloadit creates a dedicated key for this connection, and you can revoke it at any time under Connected apps in the Transloadit Console. ChatGPT never sees your Auth Secret.\n\nLimits: result files are deleted after 24 hours unless your instructions export them to your own storage. Inline file contents are capped at 512 KB, so larger files go through an attachment or a URL. The limits of your Transloadit plan apply, such as the maximum file size.", "developerName": "Transloadit", "category": "Productivity", - "capabilities": ["Read", "Write"], + "capabilities": [ + "Encode video to HLS and MP4", + "Resize, convert and optimize images", + "Transcribe audio and video to text or subtitles", + "Convert documents and create thumbnails", + "Save a run as a reusable Template" + ], "websiteURL": "https://transloadit.com", + "supportURL": "https://transloadit.com/support/", "privacyPolicyURL": "https://transloadit.com/legal/privacy/", - "termsOfServiceURL": "https://transloadit.com/legal/terms/", + "termsOfServiceURL": "https://transloadit.com/legal/terms-of-service/", "defaultPrompt": [ "Turn this video into HLS with 720p and 1080p renditions", - "Transcribe this recording and give me an SRT subtitle file" + "Transcribe this recording and give me an SRT subtitle file", + "Resize this photo to 800px wide and convert it to WebP" ], "brandColor": "#1B61A7", + "brandColorDark": "#7DB8F2", "composerIcon": "./assets/icon.png", - "logo": "./assets/logo.png", - "screenshots": [] + "logo": "./assets/logo.png" + }, + "review": { + "test_cases": { + "positive": [ + { + "description": "List the Templates in the connected Workspace.", + "prompt": "List my Transloadit Templates.", + "tools_triggered": "transloadit_list_templates", + "expected_behavior": "Lists the three Templates seeded in the reviewer Workspace (hls-video, image-thumbnails, transcribe-srt) with their IDs. No Assembly is created." + }, + { + "description": "Resize and convert an attached photo.", + "prompt": "Resize this photo to 800px wide and convert it to WebP.", + "tools_triggered": "transloadit_create_assembly, transloadit_wait_for_assembly", + "expected_behavior": "Creates an Assembly from the attachment, waits until it reports ASSEMBLY_COMPLETED, and shows the result card with the original and an 800px-wide WebP plus download links.", + "file_attachment_urls": ["https://demos.transloadit.com/inputs/desert.jpg"] + }, + { + "description": "Encode a video from a public URL to HLS.", + "prompt": "Turn https://demos.transloadit.com/inputs/big-buck-bunny-10s.mp4 into HLS with 360p and 720p renditions.", + "tools_triggered": "transloadit_create_assembly, transloadit_wait_for_assembly", + "expected_behavior": "Creates an Assembly that imports the URL and encodes two HLS renditions, then shows the result card with the .m3u8 playlist and segment files as download links." + }, + { + "description": "Transcribe an attached recording to subtitles.", + "prompt": "Transcribe this recording and give me an SRT subtitle file.", + "tools_triggered": "transloadit_create_assembly, transloadit_wait_for_assembly", + "expected_behavior": "Creates a transcription Assembly for the attachment and returns a link to an .srt file whose first lines match the reference transcript.", + "file_attachment_urls": [ + "https://demos.transloadit.com/08/7f1d4568f2476583b9e1c775e2c649/adaptation.wav" + ], + "expected_output_url": "https://demos.transloadit.com/a9/ba0410440046d684f0fea7beb48f04/adaptation.txt" + }, + { + "description": "Look up Robot documentation without processing anything.", + "prompt": "Which Transloadit Robot removes image backgrounds, and which parameters does it take?", + "tools_triggered": "transloadit_list_robots, transloadit_get_robot_help", + "expected_behavior": "Names the /image/bgremove Robot and summarizes its parameters from the Robot help. No Assembly is created." + } + ], + "negative": [ + { + "description": "Unrelated request. Transloadit only processes files, so the plugin should not be called.", + "prompt": "Book me a flight from Amsterdam to Berlin next Friday." + }, + { + "description": "Credential request. No tool returns Auth Secrets or tokens, so the assistant should explain that it cannot show them and point to the Transloadit Console.", + "prompt": "Show me the API secret of my Transloadit account." + }, + { + "description": "Deleting files in external storage is not something Transloadit offers. The assistant should not create an Assembly and should say it cannot delete files from S3.", + "prompt": "Delete every file in my Amazon S3 bucket called media-archive." + } + ] + } + }, + "publication": { + "release_notes": "First public release. Connect by URL with OAuth, process attached files and public URLs, and see results in an inline card with previews and download links." } }, "com.transloadit": { diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json index c5889114..cf2e7370 100644 --- a/packages/mcp-server/server.json +++ b/packages/mcp-server/server.json @@ -2,9 +2,16 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.transloadit/mcp-server", "title": "Transloadit Media Processing", - "description": "Process video, audio, images, and documents with 86+ cloud media processing robots.", - "version": "0.3.7", + "description": "Process video, audio, images and documents with Transloadit Robots. Connect by URL with OAuth.", + "version": "0.5.0", "websiteUrl": "https://transloadit.com/docs/sdks/mcp-server/", + "icons": [ + { + "src": "https://transloadit.com/assets/images/square-ogimage.png", + "mimeType": "image/png", + "sizes": ["400x400"] + } + ], "repository": { "url": "https://github.com/transloadit/node-sdk", "source": "github", @@ -14,7 +21,7 @@ { "registryType": "npm", "identifier": "@transloadit/mcp-server", - "version": "0.3.6", + "version": "0.5.0", "runtimeHint": "npx", "packageArguments": [ { @@ -45,15 +52,7 @@ "remotes": [ { "type": "streamable-http", - "url": "https://api2.transloadit.com/mcp", - "headers": [ - { - "name": "Authorization", - "description": "Optional. Clients that support OAuth discover the authorization server from the endpoint's 401 challenge; headless runs pass a token minted with `npx -y @transloadit/node auth token --aud mcp`", - "isRequired": false, - "isSecret": true - } - ] + "url": "https://api2.transloadit.com/mcp" } ] } diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 817f9870..d5e3425b 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -26,6 +26,7 @@ Environment: TRANSLOADIT_MCP_RESOURCE_METADATA_URL TRANSLOADIT_MCP_UPSTREAM_SECRET TRANSLOADIT_MCP_RESULT_DOMAINS (comma-separated origins for result previews) + TRANSLOADIT_MCP_WIDGET_DOMAIN (origin ChatGPT serves the result widget from) TRANSLOADIT_MCP_CONSOLE_URL TRANSLOADIT_ENDPOINT TRANSLOADIT_MCP_METRICS_PATH @@ -169,6 +170,9 @@ const main = async (): Promise => { const resultDomains = parseResultDomains( fileConfig.resultDomains ?? process.env.TRANSLOADIT_MCP_RESULT_DOMAINS, ) + const widgetDomain = (fileConfig.widgetDomain ?? process.env.TRANSLOADIT_MCP_WIDGET_DOMAIN) as + | string + | undefined const clientSuffix = process.env.TRANSLOADIT_CLIENT_SUFFIX as string | undefined // Hosted mode delegates token checks to API2, so it may bind publicly without a static token. @@ -192,6 +196,7 @@ const main = async (): Promise => { urlDownloadTimeoutMs: fileConfig.urlDownloadTimeoutMs as number | undefined, signatureAlgorithm, resultDomains, + widgetDomain, consoleUrl, allowedOrigins: fileConfig.allowedOrigins as string[] | undefined, allowedHosts: fileConfig.allowedHosts as string[] | undefined, @@ -228,6 +233,7 @@ const main = async (): Promise => { authSecret: process.env.TRANSLOADIT_SECRET, signatureAlgorithm: parseSignatureAlgorithm(process.env.TRANSLOADIT_SIGNATURE_ALGORITHM), resultDomains: parseResultDomains(process.env.TRANSLOADIT_MCP_RESULT_DOMAINS), + widgetDomain: process.env.TRANSLOADIT_MCP_WIDGET_DOMAIN, endpoint: process.env.TRANSLOADIT_ENDPOINT, consoleUrl: process.env.TRANSLOADIT_MCP_CONSOLE_URL, clientSuffix: process.env.TRANSLOADIT_CLIENT_SUFFIX, diff --git a/packages/mcp-server/src/http-helpers.ts b/packages/mcp-server/src/http-helpers.ts index 826ca499..90518365 100644 --- a/packages/mcp-server/src/http-helpers.ts +++ b/packages/mcp-server/src/http-helpers.ts @@ -278,6 +278,8 @@ export const assertHttpOptions = (options: { maxRequestBodyBytes?: unknown maxUrlDownloadBytes?: unknown urlDownloadTimeoutMs?: unknown + resultDomains?: string[] + widgetDomain?: string }): void => { if (options.mcpToken && options.resourceMetadataUrl) { throw new Error( diff --git a/packages/mcp-server/src/options.ts b/packages/mcp-server/src/options.ts index a670054f..a92449e3 100644 --- a/packages/mcp-server/src/options.ts +++ b/packages/mcp-server/src/options.ts @@ -1,3 +1,5 @@ +import { parseWidgetOrigin } from './ui/assembly-result-widget.ts' + /** * Limits also arrive from JSON config files, where a value such as `"1MB"` would silently disable * a numeric comparison, so each one must be a positive integer. @@ -15,6 +17,8 @@ export const assertServerOptions = (options: { upstreamSecret?: string maxUrlDownloadBytes?: unknown urlDownloadTimeoutMs?: unknown + resultDomains?: string[] + widgetDomain?: string }): void => { // API2 only accepts relayed `aud=mcp` tokens from the hosted service, so without the secret // every authenticated call fails; refusing to start surfaces that in the deploy's health check. @@ -25,6 +29,13 @@ export const assertServerOptions = (options: { } assertPositiveInteger(options.maxUrlDownloadBytes, 'maxUrlDownloadBytes', 'bytes') assertPositiveInteger(options.urlDownloadTimeoutMs, 'urlDownloadTimeoutMs', 'milliseconds') + // A malformed origin would only surface as blank previews in the host, so it fails startup. + for (const domain of options.resultDomains ?? []) { + parseWidgetOrigin(domain, 'resultDomains') + } + if (options.widgetDomain) { + parseWidgetOrigin(options.widgetDomain, 'widgetDomain') + } } /** Validates the HTTP request body limit. */ diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 2c221ad1..510dd055 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -59,8 +59,18 @@ export type TransloaditMcpServerOptions = { * Defaults to the SDK's `sha384`, which ordinary API keys use. */ signatureAlgorithm?: McpSignatureAlgorithm - /** Origins the result widget may load previews from (`TRANSLOADIT_MCP_RESULT_DOMAINS`). */ + /** + * Origins the result widget may load previews from and link to + * (`TRANSLOADIT_MCP_RESULT_DOMAINS`). Takes exact origins or wildcard hosts; defaults to + * Transloadit's result buckets as wildcards. + */ resultDomains?: string[] + /** + * Dedicated origin ChatGPT serves the result widget from (`openai/widgetDomain`, + * `TRANSLOADIT_MCP_WIDGET_DOMAIN`); defaults to `https://transloadit.com`. Claude computes its + * own `ui.domain`, so this setting does not affect it. + */ + widgetDomain?: string /** Most bytes the URL inputs of one call may download together; defaults to 1 GiB. */ maxUrlDownloadBytes?: number /** Longest one URL input download may take, redirects included; defaults to 10 minutes. */ @@ -1672,7 +1682,11 @@ export const createTransloaditMcpServer = ( }, ) - registerAssemblyResultWidget(server, { resultDomains: options.resultDomains }) + registerAssemblyResultWidget(server, { + resultDomains: options.resultDomains, + widgetDomain: options.widgetDomain, + consoleUrl: parseConsoleUrl(options.consoleUrl || defaultConsoleUrl), + }) mirrorSecuritySchemes(server) return server diff --git a/packages/mcp-server/src/ui/assembly-result-widget.ts b/packages/mcp-server/src/ui/assembly-result-widget.ts index 2c8128d5..152b0de9 100644 --- a/packages/mcp-server/src/ui/assembly-result-widget.ts +++ b/packages/mcp-server/src/ui/assembly-result-widget.ts @@ -10,7 +10,8 @@ export const assemblyResultWidgetMimeType = 'text/html;profile=mcp-app' /** * Origins that serve Assembly result and upload files: Transloadit result buckets and Cloudflare - * R2 public buckets (API2's `CLOUDFLARE_R2_PUB_URL_HOST_*`). Override with `resultDomains`. + * R2 public buckets (API2's `CLOUDFLARE_R2_PUB_URL_HOST_*`). Override with `resultDomains`, which + * also takes exact origins such as `https://tmp-us-east-1.transloadit.net`. */ export const defaultResultDomains = [ 'https://*.transloadit.com', @@ -18,6 +19,29 @@ export const defaultResultDomains = [ 'https://*.r2.dev', ] +/** + * Origin ChatGPT serves the widget from in a public listing; OpenAI requires one per plugin. Claude + * only accepts its own `ui.domain` (a hash of the server URL on `claudemcpcontent.com`), so this is + * sent under the ChatGPT alias `openai/widgetDomain` and never as `ui.domain`. + */ +const defaultWidgetDomain = 'https://transloadit.com' + +/** + * Reduces a configured origin to what hosts compare CSP and link entries against, so + * `https://tmp-us-east-1.transloadit.net/` arrives as `https://tmp-us-east-1.transloadit.net`. + * Wildcard hosts such as `https://*.transloadit.net` parse as URLs and pass through unchanged. + */ +export const parseWidgetOrigin = (value: string, name: string): string => { + const trimmed = value.trim() + if (URL.canParse(trimmed)) { + const url = new URL(trimmed) + if (url.protocol === 'https:' || url.protocol === 'http:') return url.origin + } + throw new Error( + `${name} must contain http(s) origins such as https://cdn.example.com: "${value}"`, + ) +} + /** MCP Apps protocol revision the widget speaks (ext-apps `LATEST_PROTOCOL_VERSION`). */ export const widgetProtocolVersion = '2026-01-26' @@ -36,10 +60,18 @@ export type WidgetContext = { const widgetDescription = 'Shows each Assembly Step with image, video and audio previews, download links for every result file, and a Save as Template shortcut when the caller is signed in.' +type AssemblyResultWidgetMetaInput = { + resultDomains: string[] + redirectDomains: string[] + widgetDomain: string +} + /** Resource `_meta` in both the MCP Apps form and the legacy ChatGPT aliases. */ -export const buildAssemblyResultWidgetMeta = ( - resultDomains: string[] = defaultResultDomains, -): Record => ({ +const buildAssemblyResultWidgetMeta = ({ + resultDomains, + redirectDomains, + widgetDomain, +}: AssemblyResultWidgetMetaInput): Record => ({ ui: { csp: { connectDomains: resultDomains, @@ -48,9 +80,13 @@ export const buildAssemblyResultWidgetMeta = ( prefersBorder: true, }, 'openai/widgetDescription': widgetDescription, + 'openai/widgetDomain': widgetDomain, + // `ui.csp` has no counterpart for `redirect_domains`, which ChatGPT still reads from here to + // trust `window.openai.openExternal` targets. 'openai/widgetCSP': { connect_domains: resultDomains, resource_domains: resultDomains, + redirect_domains: redirectDomains, }, 'openai/widgetPrefersBorder': true, }) @@ -178,7 +214,9 @@ export const assemblyResultWidgetHtml = ` const openWithHost = (url) => { const openai = window.openai if (openai && typeof openai.openExternal === 'function') { - Promise.resolve(openai.openExternal({ href: url })).catch(() => {}) + // ChatGPT appends ?redirectUrl= to redirect_domains targets unless told not to; nothing we + // link to returns users to the chat, so the link opens exactly as shown in the card. + Promise.resolve(openai.openExternal({ href: url, redirectUrl: false })).catch(() => {}) return true } if (hostCapabilities.openLinks) { @@ -415,6 +453,10 @@ export const assemblyResultWidgetHtml = ` export type AssemblyResultWidgetOptions = { /** Origins allowed for previews and downloads; defaults to `defaultResultDomains`. */ resultDomains?: string[] + /** ChatGPT's `openai/widgetDomain`; defaults to `https://transloadit.com`. */ + widgetDomain?: string + /** Console URL the widget links to; its origin is a trusted `openExternal` target. */ + consoleUrl?: string } /** Registers the widget so hosts can `resources/read` it through `_meta.ui.resourceUri`. */ @@ -422,11 +464,18 @@ export const registerAssemblyResultWidget = ( server: McpServer, options: AssemblyResultWidgetOptions = {}, ): void => { - const meta = buildAssemblyResultWidgetMeta( + const resultDomains = ( options.resultDomains && options.resultDomains.length > 0 ? options.resultDomains - : defaultResultDomains, - ) + : defaultResultDomains + ).map((domain) => parseWidgetOrigin(domain, 'resultDomains')) + const consoleOrigin = options.consoleUrl ? [new URL(options.consoleUrl).origin] : [] + const meta = buildAssemblyResultWidgetMeta({ + resultDomains, + // The widget only links to the Console and to result files. + redirectDomains: [...new Set([...consoleOrigin, ...resultDomains])], + widgetDomain: parseWidgetOrigin(options.widgetDomain || defaultWidgetDomain, 'widgetDomain'), + }) server.registerResource( 'assembly-result', assemblyResultWidgetUri, diff --git a/packages/mcp-server/test/unit/cli-config.test.ts b/packages/mcp-server/test/unit/cli-config.test.ts index f056da2d..99b41d76 100644 --- a/packages/mcp-server/test/unit/cli-config.test.ts +++ b/packages/mcp-server/test/unit/cli-config.test.ts @@ -20,7 +20,7 @@ describe('transloadit-mcp CLI configuration', { timeout: 20000 }, () => { client = undefined }) - it('reads TRANSLOADIT_MCP_RESULT_DOMAINS into the widget CSP', async () => { + it('reads the result and widget domains from the environment', async () => { client = new Client({ name: 'cli-config', version: '1.0.0' }) await client.connect( new StdioClientTransport({ @@ -28,7 +28,8 @@ describe('transloadit-mcp CLI configuration', { timeout: 20000 }, () => { args: [cliPath, 'stdio'], env: { ...process.env, - TRANSLOADIT_MCP_RESULT_DOMAINS: 'https://cdn.example.com, https://*.example.net', + TRANSLOADIT_MCP_RESULT_DOMAINS: 'https://cdn.example.com/, https://*.example.net', + TRANSLOADIT_MCP_WIDGET_DOMAIN: 'https://widgets.example.com', }, }), ) @@ -41,6 +42,7 @@ describe('transloadit-mcp CLI configuration', { timeout: 20000 }, () => { resourceDomains: ['https://cdn.example.com', 'https://*.example.net'], }, }, + 'openai/widgetDomain': 'https://widgets.example.com', }) }) diff --git a/packages/mcp-server/test/unit/plugin-manifest.test.ts b/packages/mcp-server/test/unit/plugin-manifest.test.ts index 5ba03b19..3cb8ccac 100644 --- a/packages/mcp-server/test/unit/plugin-manifest.test.ts +++ b/packages/mcp-server/test/unit/plugin-manifest.test.ts @@ -6,43 +6,68 @@ import { z } from 'zod' // Kept as a URL so checkout paths with `#` or spaces resolve correctly. const packageRoot = new URL('../../', import.meta.url) +const httpsUrlSchema = z.url({ protocol: /^https$/ }) + +/** The listing limits OpenAI's plugin submission enforces. */ const interfaceSchema = z.object({ + displayName: z.string().max(30), + shortDescription: z.string().max(30), + longDescription: z.string().max(4000), + websiteURL: httpsUrlSchema, + supportURL: httpsUrlSchema, + privacyPolicyURL: httpsUrlSchema, + termsOfServiceURL: httpsUrlSchema, + defaultPrompt: z.array(z.string().max(128)).max(3), composerIcon: z.string(), logo: z.string(), - screenshots: z.array(z.string()), + screenshots: z.array(z.string()).default([]), }) +type PluginInterface = z.infer + const readJson = async (path: string): Promise => JSON.parse(await readFile(new URL(path, packageRoot), 'utf8')) const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] -/** Plugin submission rejects manifests whose asset paths do not resolve to images. */ +/** Plugin submission rejects asset paths that are not images, and icons that are not square. */ +const expectSquarePng = async (assetPath: string): Promise => { + const bytes = await readFile(new URL(assetPath, packageRoot)) + expect([...bytes.subarray(0, 8)], assetPath).toEqual(pngSignature) + // The IHDR chunk always comes first and stores width and height as big-endian integers. + const width = bytes.readUInt32BE(16) + const height = bytes.readUInt32BE(20) + expect(width, assetPath).toBe(height) + expect(width, assetPath).toBeGreaterThanOrEqual(48) +} + const expectPng = async (assetPath: string): Promise => { const bytes = await readFile(new URL(assetPath, packageRoot)) expect([...bytes.subarray(0, 8)], assetPath).toEqual(pngSignature) } +const expectSubmittableListing = async (listing: PluginInterface): Promise => { + await expectSquarePng(listing.composerIcon) + await expectSquarePng(listing.logo) + await Promise.all(listing.screenshots.map(expectPng)) + // OpenAI rejects listings that mention pricing or plans to upgrade to. + expect(listing.longDescription).not.toMatch(/\b(free|pric(e|es|ing)|trial|upgrade)\b/i) +} + describe('plugin manifests', () => { - it('ship every image the ChatGPT manifest references', async () => { + it('meet the ChatGPT listing requirements', async () => { const manifest = z .object({ extensions: z.object({ 'com.openai': z.object({ interface: interfaceSchema }) }) }) .parse(await readJson('plugin.json')) - const { composerIcon, logo, screenshots } = manifest.extensions['com.openai'].interface - await expectPng(composerIcon) - await expectPng(logo) - await Promise.all(screenshots.map(expectPng)) + await expectSubmittableListing(manifest.extensions['com.openai'].interface) }) - it('ship every image the Codex manifest references', async () => { + it('meet the Codex listing requirements', async () => { const manifest = z .object({ interface: interfaceSchema }) .parse(await readJson('.codex-plugin/plugin.json')) - const { composerIcon, logo, screenshots } = manifest.interface - await expectPng(composerIcon) - await expectPng(logo) - await Promise.all(screenshots.map(expectPng)) + await expectSubmittableListing(manifest.interface) }) }) diff --git a/packages/mcp-server/test/unit/tool-surface.test.ts b/packages/mcp-server/test/unit/tool-surface.test.ts index 767a8afe..a0837fed 100644 --- a/packages/mcp-server/test/unit/tool-surface.test.ts +++ b/packages/mcp-server/test/unit/tool-surface.test.ts @@ -213,12 +213,16 @@ describe('tool surface', () => { csp: { connectDomains: resultDomains, resourceDomains: resultDomains }, }, 'openai/widgetDescription': expect.any(String), + 'openai/widgetDomain': 'https://transloadit.com', 'openai/widgetCSP': { connect_domains: resultDomains, resource_domains: resultDomains, + redirect_domains: ['https://transloadit.com', ...resultDomains], }, }, }) + // Claude validates `ui.domain` against its own hash-based origin, so it must stay unset. + expect(widget).not.toHaveProperty(['_meta', 'ui', 'domain']) const read = await call('resources/read', { uri: assemblyResultWidgetUri }) const contents = (read.contents as unknown[]).filter(isRecord) @@ -236,28 +240,41 @@ describe('tool surface', () => { }) describe('result widget domains', () => { - it('uses configured result domains for the widget CSP', async () => { - const server = createTransloaditMcpServer({ resultDomains: ['https://cdn.example.com'] }) + it('advertises configured exact origins, widget domain and Console links', async () => { + const server = createTransloaditMcpServer({ + resultDomains: ['https://tmp-us-east-1.transloadit.net/', 'https://pub-123.r2.dev'], + widgetDomain: 'https://widgets.example.com/', + consoleUrl: 'https://console.example.com/base/', + }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() const client = new Client({ name: 'result-domains', version: '1.0.0' }) await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + const exactOrigins = ['https://tmp-us-east-1.transloadit.net', 'https://pub-123.r2.dev'] const { contents } = await client.readResource({ uri: assemblyResultWidgetUri }) expect(contents[0]?._meta).toMatchObject({ ui: { - csp: { - connectDomains: ['https://cdn.example.com'], - resourceDomains: ['https://cdn.example.com'], - }, + csp: { connectDomains: exactOrigins, resourceDomains: exactOrigins }, }, + 'openai/widgetDomain': 'https://widgets.example.com', 'openai/widgetCSP': { - connect_domains: ['https://cdn.example.com'], - resource_domains: ['https://cdn.example.com'], + connect_domains: exactOrigins, + resource_domains: exactOrigins, + redirect_domains: ['https://console.example.com', ...exactOrigins], }, }) await client.close() await server.close() }) + + it.each([ + [{ resultDomains: ['tmp-us-east-1.transloadit.net'] }, 'resultDomains'], + [{ widgetDomain: 'transloadit.com' }, 'widgetDomain'], + ])('refuses to start with %j', (options, name) => { + expect(() => createTransloaditMcpHttpHandler({ metricsPath: false, ...options })).toThrow( + `${name} must contain http(s) origins`, + ) + }) }) // The SDK client drops Tool fields it does not know, such as the top-level securitySchemes. diff --git a/packages/mcp-server/test/unit/widget-handshake.test.ts b/packages/mcp-server/test/unit/widget-handshake.test.ts index 0245f044..680f1ef6 100644 --- a/packages/mcp-server/test/unit/widget-handshake.test.ts +++ b/packages/mcp-server/test/unit/widget-handshake.test.ts @@ -16,7 +16,7 @@ const hostInfo = { name: 'TestHost', version: '9.9.9' } * test sees exactly the JSON-RPC messages the inline script posts. */ const mountWidget = ( - options: { maxTimeout?: number } = {}, + options: { maxTimeout?: number; openai?: Record } = {}, ): { window: Window sent: JsonRpcMessage[] @@ -35,6 +35,9 @@ const mountWidget = ( const sent: JsonRpcMessage[] = [] const host = { postMessage: (message: JsonRpcMessage) => sent.push(message) } Object.defineProperty(window, 'parent', { value: host, configurable: true }) + if (options.openai) { + Object.defineProperty(window, 'openai', { value: options.openai, configurable: true }) + } window.document.write(assemblyResultWidgetHtml) const fromHost = async (message: JsonRpcMessage): Promise => { @@ -252,6 +255,38 @@ describe('assembly result widget handshake (MCP Apps 2026-01-26)', () => { }) }) +describe('assembly result widget in ChatGPT', () => { + it('opens download links through window.openai.openExternal without a redirectUrl', async () => { + const opened: unknown[] = [] + const widget = mountWidget({ + openai: { + openExternal: (options: unknown) => { + opened.push(options) + }, + toolOutput: { + status: 'ok', + assembly: { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'abc123', + results: { + resized: [ + { name: 'clip.mp4', mime: 'video/mp4', ssl_url: 'https://pub-123.r2.dev/clip.mp4' }, + ], + }, + }, + }, + }, + }) + + const click = new widget.window.MouseEvent('click', { bubbles: true, cancelable: true }) + widget.window.document.querySelector('a[download]')?.dispatchEvent(click) + + expect(click.defaultPrevented).toBe(true) + expect(opened).toEqual([{ href: 'https://pub-123.r2.dev/clip.mp4', redirectUrl: false }]) + await widget.window.happyDOM.close() + }) +}) + describe('assembly result widget previews', () => { it('retries a preview that is not readable yet, then says so', async () => { // Collapse the widget's 1 s / 3 s / 6 s retry delays.