From 1530e1f0434514592bb89b3e2de6811bede62b3a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 21:45:35 +0200 Subject: [PATCH 01/27] Add task note for MCP OAuth discovery and plugin surface Co-Authored-By: Claude Fable 5.1 --- .../prompts/2026-09-30-mcp-oauth-discovery.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/prompts/2026-09-30-mcp-oauth-discovery.md diff --git a/docs/prompts/2026-09-30-mcp-oauth-discovery.md b/docs/prompts/2026-09-30-mcp-oauth-discovery.md new file mode 100644 index 00000000..1cebd3db --- /dev/null +++ b/docs/prompts/2026-09-30-mcp-oauth-discovery.md @@ -0,0 +1,80 @@ +# MCP server: OAuth discovery, tool annotations, file params and result widget + +Part of the ChatGPT plugin plan in Content: `repodocs/prompts/2026-09-30-chatgpt-plugin-plan.md` +(branch `chatgpt-plugin-plan`). This note is the node-sdk half. Sibling branches: api2 +`agent/mcp-oauth-authcode` (authorization-code grant, DCR, CIMD, protected-resource metadata) and +Content `mcp-oauth-consent` (consent page at `/c/oauth/authorize`, QA scenario). + +## Objective + +The hosted `https://api2.transloadit.com/mcp` endpoint must let MCP clients discover API2 as its +authorization server and must satisfy the ChatGPT plugin and Anthropic connector directory review +requirements. The same package also gains the ChatGPT-specific pieces from Phase 1 of the plan: +OpenAI file params on `transloadit_create_assembly` and an MCP Apps result widget. + +## Existing pieces + +- `packages/mcp-server/src/http.ts`, `http-request-handler.ts`, `http-helpers.ts`: Streamable HTTP + transport, static `TRANSLOADIT_MCP_TOKEN` check for self-hosted deployments, CORS. +- `packages/mcp-server/src/server.ts`: tool registrations (`registerTool`), per-request bearer + extraction (`extractBearerToken`) forwarded to API2. +- `packages/mcp-server/src/server-card.ts`: `/.well-known/mcp/server-card.json` content used by + API2. +- `packages/node/src/cli/deviceLogin.ts`: `transloadit auth login` (device flow, already shipped). +- `@modelcontextprotocol/sdk` ≥ 1.29 ships `server/auth` helpers (`requireBearerAuth`, + protected-resource metadata router). Prefer them over hand-rolled headers where they fit the + existing transport code. + +## Checklist + +### Discovery and auth (Phase 0) + +- [ ] Hosted mode: unauthenticated `/mcp` requests return `401` with + `WWW-Authenticate: Bearer resource_metadata="https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp"`. + Keep the friendly JSON on bare `GET` without `Accept: text/event-stream` for directory + health probes. Self-hosted mode keeps `TRANSLOADIT_MCP_TOKEN` behavior. +- [ ] Per-tool `securitySchemes`: `noauth` for `transloadit_list_robots`, + `transloadit_get_robot_help`, `transloadit_lint_assembly_instructions`; `oauth2` with the + scopes each tool needs for `create_assembly`, `get_assembly_status`, `wait_for_assembly`, + `list_templates`. Mirror them in `_meta["securitySchemes"]` for clients that only read + `_meta`. +- [ ] Tool results that fail on auth carry `_meta["mcp/www_authenticate"]` with `error` and + `error_description` so ChatGPT shows the account-linking UI. +- [ ] Origin validation on the hosted endpoint (allow `chatgpt.com`, `claude.ai`, `claude.com`, + Transloadit origins and loopback; reject others). +- [ ] Every tool gets a `title` and accurate `readOnlyHint`, `destructiveHint`, `openWorldHint` + (`true` for URL imports). +- [ ] Optional profile tool marked `_meta["openai/profile"]: true` returning a stable opaque + workspace id, so multi-account works in ChatGPT. +- [ ] Server card advertises the OAuth-by-URL path; README puts "connect by URL" first and moves + minted bearer tokens to the CI/headless section; drop the device-login TODO. + +### ChatGPT plugin surface (Phase 1) + +- [ ] `_meta["openai/fileParams"]: ["files"]` on `transloadit_create_assembly` with the required + file object schema (`download_url`, `file_id` required; `mime_type`, `file_name` optional, + nothing else required). Map each entry onto the existing URL-import path. +- [ ] MCP Apps result widget (`_meta.ui.resourceUri`, `ui://transloadit/assembly-result`): per-Step + preview (image, video, audio, document thumbnail), before/after for image Steps, download + links, "Save as Template" when authenticated. Set `_meta.ui.csp.connectDomains` and + `resourceDomains` to Transloadit result origins and `_meta.ui.domain` to a dedicated origin. +- [ ] `_meta["openai/toolInvocation/invoking"]` / `invoked` status strings (≤ 64 chars). +- [ ] `plugin.json` with `extensions.com.openai` (presentation, registered MCP server, hooks) and + `.codex-plugin/plugin.json` fallback; bundle the `transloadit/skills` catalog within OpenAI's + limits (5 skills, 100 files each, 256 KiB `SKILL.md`). +- [ ] Tests: unit tests for the 401/metadata behavior, security schemes, file-param mapping and + widget resource; e2e against devdock once the api2 branch serves the metadata. + +## Getting a local build into devdock + +API2's container bind-mounts the api2 worktree at `/srv/current` and runs the `mcp-server` service +from the worktree's `node_modules/@transloadit/mcp-server`. To test this branch: + +```bash +cd ~/code/node-sdk && corepack yarn build +rm -rf ~/code/api2-clone-1/node_modules/@transloadit/mcp-server/dist +cp -r packages/mcp-server/dist ~/code/api2-clone-1/node_modules/@transloadit/mcp-server/dist +cd ~/code/api2-clone-1 && core/bin/devdock.ts --app api2 restart +``` + +Bump the dependency in API2 once the package is published from this branch. From e3f4d6b629dfede7b842b840ccf551bc56633b60 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 21:47:19 +0200 Subject: [PATCH 02/27] Link the sibling MCP OAuth pull requests Co-Authored-By: Claude Fable 5.1 --- docs/prompts/2026-09-30-mcp-oauth-discovery.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/prompts/2026-09-30-mcp-oauth-discovery.md b/docs/prompts/2026-09-30-mcp-oauth-discovery.md index 1cebd3db..6bbba7bc 100644 --- a/docs/prompts/2026-09-30-mcp-oauth-discovery.md +++ b/docs/prompts/2026-09-30-mcp-oauth-discovery.md @@ -3,9 +3,11 @@ Part of the ChatGPT plugin plan in Content: `repodocs/prompts/2026-09-30-chatgpt-plugin-plan.md` (branch `chatgpt-plugin-plan`). This note is the node-sdk half. Sibling branches: api2 `agent/mcp-oauth-authcode` (authorization-code grant, DCR, CIMD, protected-resource metadata) and -Content `mcp-oauth-consent` (consent page at `/c/oauth/authorize`, QA scenario). +Content `chatgpt-plugin-plan` (consent page at `/c/oauth/authorize`, QA scenario). -## Objective +Pull requests: api2 [#9320](https://github.com/transloadit/api2/pull/9320), Content +[#6207](https://github.com/transloadit/content/pull/6207), node-sdk +[#529](https://github.com/transloadit/node-sdk/pull/529). The hosted `https://api2.transloadit.com/mcp` endpoint must let MCP clients discover API2 as its authorization server and must satisfy the ChatGPT plugin and Anthropic connector directory review From 958e8c99cdef35f413587a921a7e23ffd4f1cce0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 22:19:28 +0200 Subject: [PATCH 03/27] Add OAuth discovery, tool annotations, file params and result widget to the MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted mode (`TRANSLOADIT_MCP_RESOURCE_METADATA_URL`) answers unauthenticated MCP requests with `401` and `WWW-Authenticate: Bearer resource_metadata="…"`, keeps the bare-GET health probe, and limits browser Origins to ChatGPT, Claude, Transloadit and loopback unless `allowedOrigins` is set. Self-hosted `TRANSLOADIT_MCP_TOKEN` behavior is unchanged. Every tool now carries a title, annotations and per-tool `securitySchemes` (top-level via a `tools/list` override, mirrored in `_meta`). Auth failures return `isError` results with `_meta["mcp/www_authenticate"]`. New `transloadit_get_profile` tool for multi-account hosts, `attachments` on `transloadit_create_assembly` for `_meta["openai/fileParams"]`, and the MCP Apps widget `ui://transloadit/assembly-result` linked from the Assembly tools. Co-Authored-By: Claude Fable 5.1 --- packages/mcp-server/src/cli.ts | 16 +- packages/mcp-server/src/express.ts | 23 +- packages/mcp-server/src/http-helpers.ts | 128 +++++- .../mcp-server/src/http-request-handler.ts | 22 +- packages/mcp-server/src/http.ts | 22 +- packages/mcp-server/src/server-card.ts | 87 +++- packages/mcp-server/src/server.ts | 432 ++++++++++++++---- packages/mcp-server/src/tool-list.ts | 64 +++ packages/mcp-server/src/tool-metadata.ts | 151 ++++++ .../src/ui/assembly-result-widget.ts | 356 +++++++++++++++ .../mcp-server/test/e2e/server-card.test.ts | 4 +- .../mcp-server/test/unit/auth-errors.test.ts | 195 ++++++++ .../mcp-server/test/unit/file-inputs.test.ts | 86 ++++ .../mcp-server/test/unit/hosted-auth.test.ts | 215 +++++++++ .../mcp-server/test/unit/tool-surface.test.ts | 213 +++++++++ 15 files changed, 1877 insertions(+), 137 deletions(-) create mode 100644 packages/mcp-server/src/tool-list.ts create mode 100644 packages/mcp-server/src/tool-metadata.ts create mode 100644 packages/mcp-server/src/ui/assembly-result-widget.ts create mode 100644 packages/mcp-server/test/unit/auth-errors.test.ts create mode 100644 packages/mcp-server/test/unit/hosted-auth.test.ts create mode 100644 packages/mcp-server/test/unit/tool-surface.test.ts diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 2352ac0e..0a6e2e11 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -19,6 +19,8 @@ Environment: TRANSLOADIT_KEY TRANSLOADIT_SECRET TRANSLOADIT_MCP_TOKEN + TRANSLOADIT_MCP_RESOURCE_METADATA_URL + TRANSLOADIT_MCP_CONSOLE_URL TRANSLOADIT_ENDPOINT TRANSLOADIT_MCP_METRICS_PATH TRANSLOADIT_MCP_METRICS_USER @@ -128,10 +130,18 @@ const main = async (): Promise => { const mcpToken = (fileConfig.mcpToken ?? process.env.TRANSLOADIT_MCP_TOKEN) as | string | undefined + const resourceMetadataUrl = (fileConfig.resourceMetadataUrl ?? + process.env.TRANSLOADIT_MCP_RESOURCE_METADATA_URL) as string | undefined + const consoleUrl = (fileConfig.consoleUrl ?? process.env.TRANSLOADIT_MCP_CONSOLE_URL) as + | string + | undefined const clientSuffix = process.env.TRANSLOADIT_CLIENT_SUFFIX as string | undefined - if (!isLocalHost(host) && !mcpToken) { - throw new Error('TRANSLOADIT_MCP_TOKEN is required when binding to non-localhost host.') + // Hosted mode delegates token checks to API2, so it may bind publicly without a static token. + if (!isLocalHost(host) && !mcpToken && !resourceMetadataUrl) { + throw new Error( + 'TRANSLOADIT_MCP_TOKEN or TRANSLOADIT_MCP_RESOURCE_METADATA_URL is required when binding to a non-localhost host.', + ) } const handler = await createTransloaditMcpHttpHandler({ @@ -140,6 +150,8 @@ const main = async (): Promise => { endpoint, clientSuffix, mcpToken, + resourceMetadataUrl, + consoleUrl, allowedOrigins: fileConfig.allowedOrigins as string[] | undefined, allowedHosts: fileConfig.allowedHosts as string[] | undefined, enableDnsRebindingProtection: fileConfig.enableDnsRebindingProtection as boolean | undefined, diff --git a/packages/mcp-server/src/express.ts b/packages/mcp-server/src/express.ts index b53d90af..c9eb5efd 100644 --- a/packages/mcp-server/src/express.ts +++ b/packages/mcp-server/src/express.ts @@ -3,7 +3,12 @@ import type { TransloaditMcpHttpOptions } from './http.ts' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import express from 'express' -import { isBasicAuthorized } from './http-helpers.ts' +import { + applyCorsHeaders, + isBasicAuthorized, + rejectMissingBearerToken, + resolveAllowedOrigins, +} from './http-helpers.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' import { createTransloaditMcpServer } from './server.ts' import { buildServerCard, serverCardPath } from './server-card.ts' @@ -18,9 +23,15 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress const metricsPath = options.metricsPath === false ? undefined : (options.metricsPath ?? '/metrics') const metricsAuth = options.metricsAuth + // Only hosted mode adds an Origin policy here; embedders keep owning CORS otherwise. + const hostedOrigins = options.resourceMetadataUrl ? resolveAllowedOrigins(options) : undefined const serverCardJson = JSON.stringify( - buildServerCard(routePath, { authKey: options.authKey, authSecret: options.authSecret }), + buildServerCard(routePath, { + authKey: options.authKey, + authSecret: options.authSecret, + resourceMetadataUrl: options.resourceMetadataUrl, + }), ) const sendServerCard = (res: express.Response, includeBody: boolean) => { @@ -59,6 +70,10 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress }) router.all(routePath, async (req: express.Request, res: express.Response) => { + if (hostedOrigins && !applyCorsHeaders(req, res, hostedOrigins)) { + return + } + if (req.method !== 'POST') { res.status(405).json({ jsonrpc: '2.0', @@ -68,6 +83,10 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress return } + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { + return + } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, allowedOrigins: options.allowedOrigins, diff --git a/packages/mcp-server/src/http-helpers.ts b/packages/mcp-server/src/http-helpers.ts index 6c3f62dc..9ca4814c 100644 --- a/packages/mcp-server/src/http-helpers.ts +++ b/packages/mcp-server/src/http-helpers.ts @@ -65,6 +65,69 @@ export const isBasicAuthorized = ( ) } +/** + * Browser origins the hosted endpoint accepts when no explicit `allowedOrigins` are configured: + * the ChatGPT and Claude web apps, Transloadit sites, devdock and loopback. Requests without an + * `Origin` header (CLIs, servers) are never subject to this list. + */ +export const hostedAllowedOrigins = [ + 'https://chatgpt.com', + 'https://chat.openai.com', + 'https://claude.ai', + 'https://claude.com', + 'https://transloadit.com', + 'https://*.transloadit.com', + 'https://transloadit.dev:*', + 'https://*.transloadit.dev:*', + 'http://localhost:*', + 'https://localhost:*', + 'http://127.0.0.1:*', + 'https://127.0.0.1:*', + 'http://[::1]:*', + 'https://[::1]:*', +] + +const originPatternRegex = + /^(?https?):\/\/(?\*\.)?(?\[[^\]]+\]|[^:/]+)(?::(?\*|\d+))?$/ + +/** + * Matches an `Origin` header against an allowlist entry. Entries are exact origins or patterns + * with a `*.` subdomain wildcard and/or a `:*` any-port suffix. + */ +export const matchesOriginPattern = (origin: string, pattern: string): boolean => { + if (origin === pattern) return true + const match = originPatternRegex.exec(pattern) + if (!match?.groups) return false + let url: URL + try { + url = new URL(origin) + } catch { + return false + } + const { protocol, host, hostname, port } = match.groups + if (url.protocol !== `${protocol}:`) return false + const originHost = url.hostname.replaceAll(/^\[|\]$/g, '') + const patternHost = (hostname ?? '').replaceAll(/^\[|\]$/g, '') + const hostMatches = host + ? originHost.endsWith(`.${patternHost}`) && originHost.length > patternHost.length + 1 + : originHost === patternHost + if (!hostMatches) return false + if (port === '*') return true + return url.port === (port ?? '') +} + +export const isOriginAllowed = (origin: string, allowedOrigins: string[]): boolean => + allowedOrigins.some((pattern) => matchesOriginPattern(origin, pattern)) + +/** Explicit `allowedOrigins` win; hosted mode falls back to the ChatGPT/Claude/Transloadit list. */ +export const resolveAllowedOrigins = (options: { + allowedOrigins?: string[] + resourceMetadataUrl?: string +}): string[] | undefined => { + if (options.allowedOrigins && options.allowedOrigins.length > 0) return options.allowedOrigins + return options.resourceMetadataUrl ? hostedAllowedOrigins : undefined +} + export const applyCorsHeaders = ( req: IncomingMessage, res: ServerResponse, @@ -76,7 +139,7 @@ export const applyCorsHeaders = ( } if (allowedOrigins && allowedOrigins.length > 0) { - if (!allowedOrigins.includes(origin)) { + if (!isOriginAllowed(origin, allowedOrigins)) { res.statusCode = 403 res.end('Forbidden') return false @@ -92,7 +155,68 @@ export const applyCorsHeaders = ( 'Access-Control-Allow-Headers', 'Authorization,Content-Type,Mcp-Session-Id,Last-Event-ID', ) - res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id,WWW-Authenticate') return true } + +/** + * `WWW-Authenticate` value (RFC 6750) that points OAuth clients at the protected-resource + * metadata and, for rejected requests, names the error so hosts show their linking UI. + */ +export const buildBearerChallenge = (options: { + resourceMetadataUrl?: string + error?: { code: string; description: string } +}): string => { + const parts: string[] = [] + if (options.resourceMetadataUrl) { + parts.push(`resource_metadata="${options.resourceMetadataUrl}"`) + } + if (options.error) { + parts.push( + `error="${options.error.code}"`, + `error_description="${options.error.description.replaceAll('"', "'")}"`, + ) + } + return parts.length > 0 ? `Bearer ${parts.join(', ')}` : 'Bearer' +} + +/** + * Self-hosted policy: the request must carry the static `TRANSLOADIT_MCP_TOKEN`. Returns `true` + * when the 401 was already sent. + */ +export const rejectMissingMcpToken = ( + req: IncomingMessage, + res: ServerResponse, + mcpToken: string | undefined, +): boolean => { + if (!mcpToken || isAuthorized(req, mcpToken)) return false + res.statusCode = 401 + res.setHeader('WWW-Authenticate', 'Bearer') + res.end('Unauthorized') + return true +} + +/** + * Hosted policy: a bearer token only has to be present, because API2 verifies it on every + * forwarded call. Without one, the 401 points OAuth clients at the protected-resource metadata. + * Returns `true` when the 401 was already sent. + */ +export const rejectMissingBearerToken = ( + req: IncomingMessage, + res: ServerResponse, + resourceMetadataUrl: string | undefined, +): boolean => { + if (!resourceMetadataUrl || extractBearerToken(req.headers.authorization)) return false + res.statusCode = 401 + res.setHeader('WWW-Authenticate', buildBearerChallenge({ resourceMetadataUrl })) + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + error: 'unauthorized', + error_description: + 'This endpoint requires an OAuth bearer token. Discover the authorization server through the resource_metadata URL in the WWW-Authenticate header.', + }), + ) + return true +} diff --git a/packages/mcp-server/src/http-request-handler.ts b/packages/mcp-server/src/http-request-handler.ts index 793d28f6..eda66f51 100644 --- a/packages/mcp-server/src/http-request-handler.ts +++ b/packages/mcp-server/src/http-request-handler.ts @@ -3,7 +3,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import type { SevLogger } from '@transloadit/sev-logger' -import { applyCorsHeaders, isAuthorized, normalizePath, parsePathname } from './http-helpers.ts' +import { + applyCorsHeaders, + normalizePath, + parsePathname, + rejectMissingBearerToken, + rejectMissingMcpToken, + resolveAllowedOrigins, +} from './http-helpers.ts' import { buildRedactor, getLogger } from './logger.ts' type PathPolicy = { @@ -14,6 +21,7 @@ type PathPolicy = { type RequestHandlerOptions = { allowedOrigins?: string[] mcpToken?: string + resourceMetadataUrl?: string path: PathPolicy logger?: SevLogger redactSecrets?: Array @@ -27,6 +35,7 @@ export const createMcpRequestHandler = ( const allowRoot = options.path.allowRoot ?? false const logger = options.logger ?? getLogger().nest('http') const redact = buildRedactor(options.redactSecrets ?? []) + const allowedOrigins = resolveAllowedOrigins(options) return async (req: IncomingMessage, res: ServerResponse) => { const pathname = normalizePath(parsePathname(req.url, expectedPath)) @@ -36,7 +45,7 @@ export const createMcpRequestHandler = ( return } - if (!applyCorsHeaders(req, res, options.allowedOrigins)) { + if (!applyCorsHeaders(req, res, allowedOrigins)) { return } @@ -46,10 +55,7 @@ export const createMcpRequestHandler = ( return } - if (options.mcpToken && !isAuthorized(req, options.mcpToken)) { - res.statusCode = 401 - res.setHeader('WWW-Authenticate', 'Bearer') - res.end('Unauthorized') + if (rejectMissingMcpToken(req, res, options.mcpToken)) { return } @@ -71,6 +77,10 @@ export const createMcpRequestHandler = ( return } + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { + return + } + try { const parsedBody = (req as { body?: unknown }).body await transport.handleRequest(req, res, parsedBody) diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index e3f551bd..9ceb26cc 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -8,10 +8,12 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { applyCorsHeaders, - isAuthorized, isBasicAuthorized, normalizePath, parsePathname, + rejectMissingBearerToken, + rejectMissingMcpToken, + resolveAllowedOrigins, } from './http-helpers.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' import { createTransloaditMcpServer } from './server.ts' @@ -71,9 +73,14 @@ export function createTransloaditMcpHttpHandler( const metricsPath = options.metricsPath === false ? undefined : normalizePath(options.metricsPath ?? '/metrics') const metricsAuth = options.metricsAuth + const allowedOrigins = resolveAllowedOrigins(options) const serverCardJson = JSON.stringify( - buildServerCard(expectedPath, { authKey: options.authKey, authSecret: options.authSecret }), + buildServerCard(expectedPath, { + authKey: options.authKey, + authSecret: options.authSecret, + resourceMetadataUrl: options.resourceMetadataUrl, + }), ) const handler = (async (req, res) => { @@ -131,7 +138,7 @@ export function createTransloaditMcpHttpHandler( return } - if (!applyCorsHeaders(req, res, options.allowedOrigins)) { + if (!applyCorsHeaders(req, res, allowedOrigins)) { return } @@ -141,10 +148,7 @@ export function createTransloaditMcpHttpHandler( return } - if (options.mcpToken && !isAuthorized(req, options.mcpToken)) { - res.statusCode = 401 - res.setHeader('WWW-Authenticate', 'Bearer') - res.end('Unauthorized') + if (rejectMissingMcpToken(req, res, options.mcpToken)) { return } @@ -165,6 +169,10 @@ export function createTransloaditMcpHttpHandler( return } + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { + return + } + if (req.method !== 'POST') { res.statusCode = 405 res.setHeader('Content-Type', 'application/json') diff --git a/packages/mcp-server/src/server-card.ts b/packages/mcp-server/src/server-card.ts index 56b846d1..fc3ce221 100644 --- a/packages/mcp-server/src/server-card.ts +++ b/packages/mcp-server/src/server-card.ts @@ -1,16 +1,33 @@ +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' + +import type { ToolName, ToolSecurityScheme } from './tool-metadata.ts' + import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js' import packageJson from '../package.json' with { type: 'json' } +import { toolMetadata } from './tool-metadata.ts' export const serverCardPath = '/.well-known/mcp/server-card.json' type JsonSchemaObject = Record -type ServerCardToolDefinition = { - name: string +type ServerCardToolInput = { + name: ToolName + inputSchema: JsonSchemaObject +} + +type ServerCardToolDefinition = ServerCardToolInput & { title: string description: string - inputSchema: JsonSchemaObject + annotations: ToolAnnotations + securitySchemes: ToolSecurityScheme[] +} + +type ServerCardAuthentication = { + required: boolean + schemes: string[] + /** RFC 9728 protected-resource metadata that names the OAuth authorization server. */ + resourceMetadataUrl?: string } type ServerCard = { @@ -22,17 +39,14 @@ type ServerCard = { documentationUrl: string iconUrl: string transport: { type: string; endpoint: string } - authentication?: { required: boolean; schemes: string[] } - capabilities: { tools: { listChanged: boolean } } + authentication?: ServerCardAuthentication + capabilities: { tools: { listChanged: boolean }; resources: { listChanged: boolean } } tools: ['dynamic'] | ServerCardToolDefinition[] } -const tools: ServerCardToolDefinition[] = [ +const toolInputs: ServerCardToolInput[] = [ { name: 'transloadit_lint_assembly_instructions', - title: 'Lint Assembly Instructions', - description: - 'Lint Assembly Instructions without creating an Assembly. Returns structured issues.', inputSchema: { type: 'object', additionalProperties: false, @@ -46,9 +60,6 @@ const tools: ServerCardToolDefinition[] = [ }, { name: 'transloadit_create_assembly', - title: 'Create or resume an Assembly', - description: - 'Create or resume an Assembly, optionally uploading files and waiting for completion.', inputSchema: { type: 'object', additionalProperties: false, @@ -58,6 +69,20 @@ const tools: ServerCardToolDefinition[] = [ type: 'array', items: { type: 'object' }, }, + attachments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['download_url', 'file_id'], + properties: { + download_url: { type: 'string' }, + file_id: { type: 'string' }, + mime_type: { type: 'string' }, + file_name: { type: 'string' }, + }, + }, + }, fields: { type: 'object' }, wait_for_completion: { type: 'boolean' }, wait_timeout_ms: { type: 'number' }, @@ -71,8 +96,6 @@ const tools: ServerCardToolDefinition[] = [ }, { name: 'transloadit_get_assembly_status', - title: 'Get Assembly Status', - description: 'Fetch the latest Assembly status by URL or ID.', inputSchema: { type: 'object', additionalProperties: false, @@ -84,8 +107,6 @@ const tools: ServerCardToolDefinition[] = [ }, { name: 'transloadit_wait_for_assembly', - title: 'Wait For Assembly Completion', - description: 'Polls until the Assembly completes or timeout is reached.', inputSchema: { type: 'object', additionalProperties: false, @@ -99,8 +120,6 @@ const tools: ServerCardToolDefinition[] = [ }, { name: 'transloadit_list_robots', - title: 'List Robots', - description: 'Returns a filtered list of robots with short summaries.', inputSchema: { type: 'object', additionalProperties: false, @@ -114,8 +133,6 @@ const tools: ServerCardToolDefinition[] = [ }, { name: 'transloadit_get_robot_help', - title: 'Get Robot Help', - description: 'Returns a robot summary and parameter details.', inputSchema: { type: 'object', additionalProperties: false, @@ -127,9 +144,6 @@ const tools: ServerCardToolDefinition[] = [ }, { name: 'transloadit_list_templates', - title: 'List Templates', - description: - 'List Assembly Templates (owned and/or builtin). Tip: pass include_builtin: "exclusively-latest" to list builtins only.', inputSchema: { type: 'object', additionalProperties: false, @@ -147,13 +161,34 @@ const tools: ServerCardToolDefinition[] = [ }, }, }, + { + name: 'transloadit_get_profile', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: {}, + }, + }, ] +const tools: ServerCardToolDefinition[] = toolInputs.map((tool) => { + const metadata = toolMetadata[tool.name] + return { + ...tool, + title: metadata.title, + description: metadata.description, + annotations: metadata.annotations, + securitySchemes: metadata.securitySchemes, + } +}) + export const buildServerCard = ( endpoint: string, - options: { authKey?: string; authSecret?: string } = {}, + options: { authKey?: string; authSecret?: string; resourceMetadataUrl?: string } = {}, ): ServerCard => { const hasCredentials = Boolean(options.authKey && options.authSecret) + // Hosted deployments hand out tokens through OAuth; self-hosted ones accept a static bearer. + const schemes = options.resourceMetadataUrl ? ['oauth2', 'bearer'] : ['bearer'] return { $schema: 'https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json', @@ -174,10 +209,12 @@ export const buildServerCard = ( }, authentication: { required: !hasCredentials, - schemes: ['bearer'], + schemes, + ...(options.resourceMetadataUrl ? { resourceMetadataUrl: options.resourceMetadataUrl } : {}), }, capabilities: { tools: { listChanged: false }, + resources: { listChanged: false }, }, tools, } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index f2d168e6..6cb3f970 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,9 +1,17 @@ +import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js' import type { CallToolResult, TextContent } from '@modelcontextprotocol/sdk/types.js' import type { AssemblyInstructionsInput, + AssemblyStatus, CreateAssemblyParams, + InputFile, LintAssemblyInstructionsResult, } from '@transloadit/node' +import type { ZodObject } from 'zod' + +import type { ListedTool } from './tool-list.ts' +import type { ToolName } from './tool-metadata.ts' +import type { WidgetContext } from './ui/assembly-result-widget.ts' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { @@ -19,12 +27,22 @@ import { import { z } from 'zod' import packageJson from '../package.json' with { type: 'json' } -import { extractBearerToken } from './http-helpers.ts' +import { buildBearerChallenge, extractBearerToken } from './http-helpers.ts' +import { installToolListHandler } from './tool-list.ts' +import { buildToolMeta, toolMetadata } from './tool-metadata.ts' +import { registerAssemblyResultWidget, widgetContextMetaKey } from './ui/assembly-result-widget.ts' export type TransloaditMcpServerOptions = { authKey?: string authSecret?: string mcpToken?: string + /** + * Protected-resource metadata URL of the hosted deployment. When set, auth failures carry an + * RFC 6750 challenge that points OAuth clients at API2 (`TRANSLOADIT_MCP_RESOURCE_METADATA_URL`). + */ + resourceMetadataUrl?: string + /** Console origin used for widget deep links; defaults to the public website. */ + consoleUrl?: string endpoint?: string serverName?: string serverVersion?: string @@ -32,6 +50,8 @@ export type TransloaditMcpServerOptions = { clientSuffix?: string } +const defaultConsoleUrl = 'https://transloadit.com' + type LintIssueOutput = { path: string message: string @@ -149,9 +169,23 @@ const inputFileSchema = z.discriminatedUnion('kind', [ }), ]) +// Exactly the file object ChatGPT hydrates for `_meta["openai/fileParams"]`: the two ids are +// required, the descriptive fields optional, and nothing else is allowed. +const hostFileSchema = z + .object({ + download_url: z.string(), + file_id: z.string(), + mime_type: z.string().optional(), + file_name: z.string().optional(), + }) + .strict() + +type HostFile = z.infer + const createAssemblyInputSchema = z.object({ instructions: z.unknown().optional(), files: z.array(inputFileSchema).optional(), + attachments: z.array(hostFileSchema).optional(), fields: z.record(z.string(), z.unknown()).optional(), wait_for_completion: z.boolean().optional(), wait_timeout_ms: z.number().int().positive().optional(), @@ -245,6 +279,19 @@ const lintAssemblyOutputSchema = z.object({ normalized_instructions: z.unknown().optional(), }) +const getProfileInputSchema = z.object({}).strict() + +// Shape ChatGPT expects from a profile tool: an opaque stable id plus optional display fields. +const getProfileOutputSchema = z + .object({ + id: z.string().min(1).regex(/\S/), + name: z.string().optional(), + nickname: z.string().optional(), + }) + .strict() + +type WorkspaceProfile = z.infer + const toLintIssues = (issues: LintAssemblyInstructionsResult['issues']): LintIssueOutput[] => issues.map((issue) => ({ path: issue.stepName ? `steps.${issue.stepName}` : 'instructions', @@ -261,7 +308,16 @@ const safeJsonParse = (value: string): unknown => { } } -const buildToolResponse = (payload: Record): CallToolResult => { +type ToolResponseExtras = { + /** Result `_meta`, delivered to widgets and hosts but hidden from the model. */ + meta?: Record + isError?: boolean +} + +const buildToolResponse = ( + payload: Record, + extras: ToolResponseExtras = {}, +): CallToolResult => { const content: TextContent = { type: 'text', text: JSON.stringify(payload), @@ -270,9 +326,54 @@ const buildToolResponse = (payload: Record): CallToolResult => return { content: [content], structuredContent: payload, + ...(extras.isError ? { isError: true } : {}), + ...(extras.meta ? { _meta: extras.meta } : {}), } } +type AuthErrorInput = { + code: 'mcp_missing_auth' | 'mcp_auth_rejected' | 'mcp_insufficient_scope' + oauthError: 'invalid_token' | 'insufficient_scope' + message: string + hint?: string +} + +/** + * Tool-level auth failure. `_meta["mcp/www_authenticate"]` mirrors the HTTP challenge so ChatGPT + * and Claude open their account-linking UI; the text content keeps the reason readable. + */ +const buildAuthError = ( + options: TransloaditMcpServerOptions, + input: AuthErrorInput, +): CallToolResult => + buildToolResponse( + { + status: 'error', + errors: [{ code: input.code, message: input.message, hint: input.hint }], + }, + { + isError: true, + meta: { + 'mcp/www_authenticate': [ + buildBearerChallenge({ + resourceMetadataUrl: options.resourceMetadataUrl, + error: { code: input.oauthError, description: input.message }, + }), + ], + }, + }, + ) + +const buildMissingAuthError = (options: TransloaditMcpServerOptions): CallToolResult => + buildAuthError(options, { + code: 'mcp_missing_auth', + oauthError: 'insufficient_scope', + message: 'Sign in to Transloadit to use this tool.', + hint: options.resourceMetadataUrl + ? 'Connect your Transloadit account through OAuth, then retry.' + : 'Set TRANSLOADIT_KEY/TRANSLOADIT_SECRET or send an Authorization: Bearer token.', + }) + const buildToolError = ( code: string, message: string, @@ -344,12 +445,7 @@ const createLiveClient = ( } if (!options.authKey || !options.authSecret) { - return { - error: buildToolError( - 'mcp_missing_auth', - 'Missing TRANSLOADIT_KEY/TRANSLOADIT_SECRET or Authorization: Bearer token for live API calls.', - ), - } + return { error: buildMissingAuthError(options) } } return { @@ -397,6 +493,63 @@ const getHttpStatusCode = (error: unknown): number | undefined => { const isErrnoException = (value: unknown): value is NodeJS.ErrnoException => isRecord(value) && typeof value.code === 'string' +/** + * Maps API2 rejections of the forwarded credentials to a tool auth error. Other failures are + * left to the caller so they keep surfacing as ordinary tool errors. + */ +const toAuthRejection = ( + options: TransloaditMcpServerOptions, + error: unknown, +): CallToolResult | undefined => { + const status = getHttpStatusCode(error) + if (status === 401) { + return buildAuthError(options, { + code: 'mcp_auth_rejected', + oauthError: 'invalid_token', + message: 'Transloadit rejected the credentials; the token may have expired.', + hint: 'Reconnect your Transloadit account and retry.', + }) + } + const scopeRejected = + status === 403 && error instanceof ApiError && /SCOPE|BEARER_TOKEN/.test(error.code ?? '') + if (scopeRejected) { + return buildAuthError(options, { + code: 'mcp_insufficient_scope', + oauthError: 'insufficient_scope', + message: 'The connected credentials lack the scope this tool needs.', + hint: 'Reconnect your Transloadit account and grant the requested access.', + }) + } + return undefined +} + +const trimTrailingSlash = (value: string): string => value.replace(/\/$/, '') + +/** Widget-only context: whether the caller is signed in and where the Console deep links go. */ +const buildWidgetContext = ( + options: TransloaditMcpServerOptions, + assembly: AssemblyStatus, +): WidgetContext => { + const consoleUrl = trimTrailingSlash(options.consoleUrl || defaultConsoleUrl) + const slug = isNonEmptyString(assembly.account_slug) ? assembly.account_slug : undefined + const assemblyId = isNonEmptyString(assembly.assembly_id) ? assembly.assembly_id : undefined + const templateId = isNonEmptyString(assembly.template_id) ? assembly.template_id : undefined + if (!slug) return { authenticated: true } + + const workspaceUrl = `${consoleUrl}/c/${encodeURIComponent(slug)}` + const newTemplateUrl = new URL(`${workspaceUrl}/templates/new`) + if (templateId && !isBuiltinTemplateId(templateId)) { + newTemplateUrl.searchParams.set('duplicateFrom', templateId) + } + return { + authenticated: true, + assembly_console_url: assemblyId + ? `${workspaceUrl}/assemblies/${encodeURIComponent(assemblyId)}` + : undefined, + new_template_url: newTemplateUrl.toString(), + } +} + const isHttpImportStep = (value: unknown): value is Record => isRecord(value) && value.robot === '/http/import' @@ -571,6 +724,7 @@ const apiTemplateSchema = z description: z.string().optional(), builtin_version: z.string().optional(), content: z.unknown().optional(), + account_id: z.string().nullable().optional(), }) .passthrough() @@ -614,6 +768,47 @@ const loadTemplateSteps = async ( return extractTemplateSteps(full.content) } +/** + * API2 has no userinfo endpoint yet, so the Workspace behind the credentials is read from the + * caller's own Assemblies (id, name and slug) or, failing that, from an owned Template (id only). + */ +const resolveWorkspaceProfile = async ( + client: Transloadit, +): Promise => { + const assemblies = await client.listAssemblies({ pagesize: 1 }) + const latest = assemblies.items[0] + if (latest?.id) { + const status = await client.getAssembly(latest.id) + const id = isNonEmptyString(status.account_id) ? status.account_id : latest.account_id + if (isNonEmptyString(id)) { + return { + id, + name: isNonEmptyString(status.account_name) ? status.account_name : undefined, + nickname: isNonEmptyString(status.account_slug) ? status.account_slug : undefined, + } + } + } + + const templates = listTemplatesResponseSchema.safeParse( + await client.listTemplates({ pagesize: 1 }), + ) + const template = templates.success ? templates.data.items?.[0] : undefined + if (template && isNonEmptyString(template.account_id)) { + return { id: template.account_id } + } + return undefined +} + +/** Host-attached files become URL inputs; `file_id` is opaque, so the field name is positional. */ +const toAttachmentInputs = (attachments: HostFile[]): InputFile[] => + attachments.map((attachment, index) => ({ + kind: 'url', + field: `attachment_${index + 1}`, + url: attachment.download_url, + filename: attachment.file_name, + contentType: attachment.mime_type, + })) + const looksLikeAssemblyParams = (input: Record): boolean => { return ( 'steps' in input || @@ -656,16 +851,40 @@ export const createTransloaditMcpServer = ( version: options.serverVersion ?? packageJson.version, }) + const listedTools: ListedTool[] = [] + const register = ( + name: ToolName, + inputSchema: Input, + outputSchema: Output, + callback: ToolCallback, + ): void => { + const metadata = toolMetadata[name] + const registered = server.registerTool( + name, + { + title: metadata.title, + description: metadata.description, + inputSchema, + outputSchema, + annotations: metadata.annotations, + _meta: buildToolMeta(metadata), + }, + callback, + ) + listedTools.push({ + name, + inputSchema, + outputSchema, + securitySchemes: metadata.securitySchemes, + registered, + }) + } + // Builtin templates supersede the old golden template tool; no legacy alias by design. - server.registerTool( + register( 'transloadit_lint_assembly_instructions', - { - title: 'Lint Assembly Instructions', - description: - 'Lint Assembly Instructions without creating an Assembly. Returns structured issues.', - inputSchema: lintAssemblyInputSchema, - outputSchema: lintAssemblyOutputSchema, - }, + lintAssemblyInputSchema, + lintAssemblyOutputSchema, async ({ instructions, strict, return_fixed }) => { const client = createLintClient(options) const assemblyInstructions = @@ -689,19 +908,15 @@ export const createTransloaditMcpServer = ( }, ) - server.registerTool( + register( 'transloadit_create_assembly', - { - title: 'Create or resume an Assembly', - description: - 'Create or resume an Assembly, optionally uploading files and waiting for completion.', - inputSchema: createAssemblyInputSchema, - outputSchema: createAssemblyOutputSchema, - }, + createAssemblyInputSchema, + createAssemblyOutputSchema, async ( { instructions, files, + attachments, fields, wait_for_completion, wait_timeout_ms, @@ -724,7 +939,7 @@ export const createTransloaditMcpServer = ( let templatePathHint: string | undefined try { - const fileInputs = files ?? [] + const fileInputs: InputFile[] = [...(files ?? []), ...toAttachmentInputs(attachments ?? [])] const urlInputs = fileInputs.filter((file) => file.kind === 'url') const hasUrlInputs = urlInputs.length > 0 let inputFilesForPrep = fileInputs @@ -940,32 +1155,42 @@ export const createTransloaditMcpServer = ( ? [] : ['transloadit_wait_for_assembly', 'transloadit_get_assembly_status'] - return buildToolResponse({ - status: 'ok', - assembly, - upload: uploadSummary, - next_steps: nextSteps, - warnings: warnings.length > 0 ? warnings : undefined, - }) + return buildToolResponse( + { + status: 'ok', + assembly, + upload: uploadSummary, + next_steps: nextSteps, + warnings: warnings.length > 0 ? warnings : undefined, + }, + { meta: { [widgetContextMetaKey]: buildWidgetContext(options, assembly) } }, + ) + } catch (error) { + const rejection = toAuthRejection(options, error) + if (rejection) return rejection + throw error } finally { await Promise.all(tempCleanups.map((cleanup) => cleanup())) } }, ) - server.registerTool( + register( 'transloadit_get_assembly_status', - { - title: 'Get Assembly status', - description: 'Fetch the latest Assembly status by URL or ID.', - inputSchema: getAssemblyStatusInputSchema, - outputSchema: getAssemblyStatusOutputSchema, - }, + getAssemblyStatusInputSchema, + getAssemblyStatusOutputSchema, async ({ assembly_url, assembly_id }, extra) => { const access = resolveAssemblyAccess(options, extra, { assembly_url, assembly_id }) if ('error' in access) return access.error - const assembly = await access.client.getAssembly(access.assemblyId) + let assembly: AssemblyStatus + try { + assembly = await access.client.getAssembly(access.assemblyId) + } catch (error) { + const rejection = toAuthRejection(options, error) + if (rejection) return rejection + throw error + } return buildToolResponse({ status: 'ok', @@ -974,42 +1199,44 @@ export const createTransloaditMcpServer = ( }, ) - server.registerTool( + register( 'transloadit_wait_for_assembly', - { - title: 'Wait for Assembly completion', - description: 'Polls until the Assembly completes or timeout is reached.', - inputSchema: waitForAssemblyInputSchema, - outputSchema: waitForAssemblyOutputSchema, - }, + waitForAssemblyInputSchema, + waitForAssemblyOutputSchema, async ({ assembly_url, assembly_id, timeout_ms, poll_interval_ms }, extra) => { const access = resolveAssemblyAccess(options, extra, { assembly_url, assembly_id }) if ('error' in access) return access.error const start = Date.now() - const assembly = await access.client.awaitAssemblyCompletion(access.assemblyId, { - timeout: timeout_ms, - interval: poll_interval_ms, - assemblyUrl: access.assemblyUrl, - }) + let assembly: AssemblyStatus + try { + assembly = await access.client.awaitAssemblyCompletion(access.assemblyId, { + timeout: timeout_ms, + interval: poll_interval_ms, + assemblyUrl: access.assemblyUrl, + }) + } catch (error) { + const rejection = toAuthRejection(options, error) + if (rejection) return rejection + throw error + } const waited_ms = Date.now() - start - return buildToolResponse({ - status: 'ok', - assembly, - waited_ms, - }) + return buildToolResponse( + { + status: 'ok', + assembly, + waited_ms, + }, + { meta: { [widgetContextMetaKey]: buildWidgetContext(options, assembly) } }, + ) }, ) - server.registerTool( + register( 'transloadit_list_robots', - { - title: 'List Transloadit robots', - description: 'Returns a filtered list of robots with short summaries.', - inputSchema: listRobotsInputSchema, - outputSchema: listRobotsOutputSchema, - }, + listRobotsInputSchema, + listRobotsOutputSchema, ({ category, search, limit, cursor }) => { const result = listRobots({ category, search, limit, cursor }) @@ -1021,14 +1248,10 @@ export const createTransloaditMcpServer = ( }, ) - server.registerTool( + register( 'transloadit_get_robot_help', - { - title: 'Get robot parameter help', - description: 'Returns a robot summary and parameter details.', - inputSchema: getRobotHelpInputSchema, - outputSchema: getRobotHelpOutputSchema, - }, + getRobotHelpInputSchema, + getRobotHelpOutputSchema, ({ robot_name, robot_names }) => { const splitComma = (value: string): string[] => value @@ -1094,30 +1317,13 @@ export const createTransloaditMcpServer = ( }, ) - server.registerTool( + register( 'transloadit_list_templates', - { - title: 'List templates', - description: - 'List Assembly Templates (owned and/or builtin). Tip: pass include_builtin: "exclusively-latest" to list builtins only.', - inputSchema: listTemplatesInputSchema, - outputSchema: listTemplatesOutputSchema, - }, + listTemplatesInputSchema, + listTemplatesOutputSchema, async ({ page, page_size, sort, order, keywords, include_builtin, include_content }, extra) => { const liveClient = createLiveClient(options, extra) - if ('error' in liveClient) { - return buildToolResponse({ - status: 'error', - templates: [], - errors: [ - { - code: 'mcp_missing_auth', - message: - 'Missing TRANSLOADIT_KEY/TRANSLOADIT_SECRET or Authorization: Bearer token for live API calls.', - }, - ], - }) - } + if ('error' in liveClient) return liveClient.error try { const response = await liveClient.client.listTemplates({ @@ -1153,6 +1359,8 @@ export const createTransloaditMcpServer = ( total: parsed.data.count ?? items.length, }) } catch (error) { + const rejection = toAuthRejection(options, error) + if (rejection) return rejection const message = error instanceof Error ? error.message : 'Failed to list templates.' return buildToolResponse({ status: 'error', @@ -1168,5 +1376,45 @@ export const createTransloaditMcpServer = ( }, ) + register( + 'transloadit_get_profile', + getProfileInputSchema, + getProfileOutputSchema, + async (_args, extra) => { + const liveClient = createLiveClient(options, extra) + if ('error' in liveClient) return liveClient.error + + let profile: WorkspaceProfile | undefined + try { + profile = await resolveWorkspaceProfile(liveClient.client) + } catch (error) { + const rejection = toAuthRejection(options, error) + if (rejection) return rejection + throw error + } + + if (!profile) { + // The strict profile output schema has no error shape, so this must be an error result. + return buildToolResponse( + { + status: 'error', + errors: [ + { + code: 'mcp_profile_unavailable', + message: 'Could not determine the Workspace behind these credentials yet.', + hint: 'Create an Assembly or a Template first, then call this tool again.', + }, + ], + }, + { isError: true }, + ) + } + return buildToolResponse(profile) + }, + ) + + registerAssemblyResultWidget(server) + installToolListHandler(server, listedTools) + return server } diff --git a/packages/mcp-server/src/tool-list.ts b/packages/mcp-server/src/tool-list.ts new file mode 100644 index 00000000..67538c31 --- /dev/null +++ b/packages/mcp-server/src/tool-list.ts @@ -0,0 +1,64 @@ +import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import type { ZodObject } from 'zod' + +import type { ToolSecurityScheme } from './tool-metadata.ts' + +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' + +export type ListedTool = { + name: string + inputSchema: ZodObject + outputSchema: ZodObject + securitySchemes: ToolSecurityScheme[] + registered: RegisteredTool +} + +type ListedToolDefinition = Tool & { securitySchemes: ToolSecurityScheme[] } + +// Same options the SDK passes to Zod for v4 schemas, so the advertised schemas stay identical. +const jsonSchemaTarget = 'draft-7' + +const toObjectJsonSchema = (schema: ZodObject, io: 'input' | 'output'): Tool['inputSchema'] => { + const jsonSchema = z.toJSONSchema(schema, { target: jsonSchemaTarget, io }) + // Zod types the payload loosely (any schema kind, boolean sub-schemas); a ZodObject always + // serializes to an object schema with object properties, so this only narrows the type. + if (jsonSchema.type !== 'object') { + throw new Error(`Expected an object JSON schema, received ${String(jsonSchema.type)}`) + } + const properties: Record = {} + for (const [key, value] of Object.entries(jsonSchema.properties ?? {})) { + if (typeof value === 'object') properties[key] = value + } + return { ...jsonSchema, type: 'object', properties } +} + +const toToolDefinition = ({ + name, + inputSchema, + outputSchema, + securitySchemes, + registered, +}: ListedTool): ListedToolDefinition => ({ + name, + title: registered.title, + description: registered.description, + inputSchema: toObjectJsonSchema(inputSchema, 'input'), + outputSchema: toObjectJsonSchema(outputSchema, 'output'), + annotations: registered.annotations, + execution: registered.execution, + _meta: registered._meta, + securitySchemes, +}) + +/** + * Replaces the SDK's `tools/list` handler so every tool also carries the top-level + * `securitySchemes` field, which `registerTool()` has no way to emit. The definitions are built + * the same way the SDK builds them; only the extra field differs. + */ +export const installToolListHandler = (server: McpServer, tools: ListedTool[]): void => { + server.server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: tools.filter((tool) => tool.registered.enabled).map(toToolDefinition), + })) +} diff --git a/packages/mcp-server/src/tool-metadata.ts b/packages/mcp-server/src/tool-metadata.ts new file mode 100644 index 00000000..ef59a497 --- /dev/null +++ b/packages/mcp-server/src/tool-metadata.ts @@ -0,0 +1,151 @@ +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' + +import { assemblyResultWidgetUri } from './ui/assembly-result-widget.ts' + +/** Auth Key scopes the hosted tools need; mirrors API2's `safeMcpScopes` allowlist. */ +export const mcpScopes = { + assembliesRead: 'assemblies:read', + assembliesWrite: 'assemblies:write', + templatesRead: 'templates:read', +} as const + +/** Per-tool security scheme as read by ChatGPT and Claude (MCP draft `securitySchemes`). */ +export type ToolSecurityScheme = { type: 'noauth' } | { type: 'oauth2'; scopes: string[] } + +export const toolNames = [ + 'transloadit_lint_assembly_instructions', + 'transloadit_create_assembly', + 'transloadit_get_assembly_status', + 'transloadit_wait_for_assembly', + 'transloadit_list_robots', + 'transloadit_get_robot_help', + 'transloadit_list_templates', + 'transloadit_get_profile', +] as const + +export type ToolName = (typeof toolNames)[number] + +export type ToolMetadata = { + title: string + description: string + annotations: ToolAnnotations + securitySchemes: ToolSecurityScheme[] + /** Host-specific `_meta` keys (OpenAI, MCP Apps). `securitySchemes` is mirrored in automatically. */ + meta?: Record +} + +const noAuth: ToolSecurityScheme = { type: 'noauth' } + +const oauth2 = (...scopes: string[]): ToolSecurityScheme => ({ type: 'oauth2', scopes }) + +const readOnly: ToolAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} + +const widgetMeta = { + ui: { resourceUri: assemblyResultWidgetUri }, + 'openai/outputTemplate': assemblyResultWidgetUri, +} + +/** Names of the hosted tools whose results render in the Assembly result widget. */ +export const widgetToolNames: readonly ToolName[] = [ + 'transloadit_create_assembly', + 'transloadit_wait_for_assembly', +] + +/** + * Titles, descriptions, annotations and security schemes for every tool, shared by the live + * server and the static server card so the two never drift. + */ +export const toolMetadata: Record = { + transloadit_lint_assembly_instructions: { + title: 'Lint Assembly Instructions', + description: + 'Lint Assembly Instructions without creating an Assembly. Returns structured issues.', + annotations: readOnly, + securitySchemes: [noAuth], + }, + transloadit_create_assembly: { + title: 'Create or resume an Assembly', + description: + 'Create or resume an Assembly, optionally uploading files and waiting for completion. Files attached in the chat arrive under attachments; public URLs and small base64 payloads go under files.', + annotations: { + readOnlyHint: false, + // Creating an Assembly is billable but never deletes or overwrites customer data. + destructiveHint: false, + idempotentHint: false, + // URL inputs and /http/import Steps fetch caller-supplied locations. + openWorldHint: true, + }, + securitySchemes: [oauth2(mcpScopes.assembliesWrite)], + meta: { + ...widgetMeta, + 'openai/fileParams': ['attachments'], + 'openai/toolInvocation/invoking': 'Processing files with Transloadit…', + 'openai/toolInvocation/invoked': 'Transloadit processed the files', + }, + }, + transloadit_get_assembly_status: { + title: 'Get Assembly status', + description: 'Fetch the latest Assembly status by URL or ID.', + annotations: readOnly, + securitySchemes: [oauth2(mcpScopes.assembliesRead)], + meta: { + 'openai/toolInvocation/invoking': 'Checking the Assembly status…', + 'openai/toolInvocation/invoked': 'Fetched the Assembly status', + }, + }, + transloadit_wait_for_assembly: { + title: 'Wait for Assembly completion', + description: 'Polls until the Assembly completes or timeout is reached.', + annotations: readOnly, + securitySchemes: [oauth2(mcpScopes.assembliesRead)], + meta: { + ...widgetMeta, + 'openai/toolInvocation/invoking': 'Waiting for the Assembly to finish…', + 'openai/toolInvocation/invoked': 'The Assembly finished', + }, + }, + transloadit_list_robots: { + title: 'List Transloadit Robots', + description: 'Returns a filtered list of Robots with short summaries.', + annotations: readOnly, + securitySchemes: [noAuth], + }, + transloadit_get_robot_help: { + title: 'Get Robot parameter help', + description: 'Returns a Robot summary and parameter details.', + annotations: readOnly, + securitySchemes: [noAuth], + }, + transloadit_list_templates: { + title: 'List Templates', + description: + 'List Assembly Templates (owned and/or builtin). Tip: pass include_builtin: "exclusively-latest" to list builtins only.', + annotations: readOnly, + securitySchemes: [oauth2(mcpScopes.templatesRead)], + meta: { + 'openai/toolInvocation/invoking': 'Listing Templates…', + 'openai/toolInvocation/invoked': 'Listed Templates', + }, + }, + transloadit_get_profile: { + title: 'Get connected Workspace', + description: + 'Returns a stable identifier and name for the Transloadit Workspace behind the current credentials, so hosts can tell connected accounts apart.', + annotations: readOnly, + securitySchemes: [oauth2()], + meta: { + 'openai/profile': true, + }, + }, +} + +/** `_meta` for `tools/list`: mirrors `securitySchemes` for hosts that only read `_meta`. */ +export const buildToolMeta = (metadata: ToolMetadata): Record => ({ + securitySchemes: metadata.securitySchemes, + ...metadata.meta, +}) diff --git a/packages/mcp-server/src/ui/assembly-result-widget.ts b/packages/mcp-server/src/ui/assembly-result-widget.ts new file mode 100644 index 00000000..9d4df7b2 --- /dev/null +++ b/packages/mcp-server/src/ui/assembly-result-widget.ts @@ -0,0 +1,356 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +import packageJson from '../../package.json' with { type: 'json' } + +/** MCP Apps resource that renders `transloadit_create_assembly` and `transloadit_wait_for_assembly` results. */ +export const assemblyResultWidgetUri = 'ui://transloadit/assembly-result' + +/** Mime type the MCP Apps spec requires for UI resources. */ +export const assemblyResultWidgetMimeType = 'text/html;profile=mcp-app' + +/** Origins that serve Assembly result files (temporary result buckets, demos, Console). */ +export const assemblyResultOrigins = ['https://*.transloadit.com', 'https://*.transloadit.net'] + +/** Result `_meta` key that carries widget-only context (never read by the model). */ +export const widgetContextMetaKey = 'transloadit/widget' + +export type WidgetContext = { + authenticated: boolean + assembly_console_url?: string + new_template_url?: string +} + +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.' + +/** Resource `_meta` in both the MCP Apps form and the legacy ChatGPT aliases. */ +export const assemblyResultWidgetMeta = { + ui: { + csp: { + connectDomains: assemblyResultOrigins, + resourceDomains: assemblyResultOrigins, + }, + prefersBorder: true, + }, + 'openai/widgetDescription': widgetDescription, + 'openai/widgetCSP': { + connect_domains: assemblyResultOrigins, + resource_domains: assemblyResultOrigins, + }, + 'openai/widgetPrefersBorder': true, +} + +/** + * The widget document. Everything is inline (no external scripts) so it runs under the + * restrictive default MCP Apps sandbox CSP; the host only needs to allow result origins. + */ +export const assemblyResultWidgetHtml = ` + + + + +Transloadit Assembly result + + + +

Waiting for the Assembly result…

+ + + +` + +/** Registers the widget so hosts can `resources/read` it through `_meta.ui.resourceUri`. */ +export const registerAssemblyResultWidget = (server: McpServer): void => { + server.registerResource( + 'assembly-result', + assemblyResultWidgetUri, + { + title: 'Assembly result', + description: widgetDescription, + mimeType: assemblyResultWidgetMimeType, + _meta: assemblyResultWidgetMeta, + }, + () => ({ + contents: [ + { + uri: assemblyResultWidgetUri, + mimeType: assemblyResultWidgetMimeType, + text: assemblyResultWidgetHtml, + _meta: assemblyResultWidgetMeta, + }, + ], + }), + ) +} diff --git a/packages/mcp-server/test/e2e/server-card.test.ts b/packages/mcp-server/test/e2e/server-card.test.ts index 5294260e..22daa575 100644 --- a/packages/mcp-server/test/e2e/server-card.test.ts +++ b/packages/mcp-server/test/e2e/server-card.test.ts @@ -32,12 +32,14 @@ describe('server card', () => { }) expect(Array.isArray(body.tools)).toBe(true) - expect(body.tools.length).toBe(7) + expect(body.tools.length).toBe(8) for (const tool of body.tools as Array>) { expect(typeof tool.name).toBe('string') expect(typeof tool.title).toBe('string') expect(typeof tool.description).toBe('string') expect(typeof tool.inputSchema).toBe('object') + expect(tool.annotations).toMatchObject({ destructiveHint: false }) + expect(Array.isArray(tool.securitySchemes)).toBe(true) } } finally { await close() diff --git a/packages/mcp-server/test/unit/auth-errors.test.ts b/packages/mcp-server/test/unit/auth-errors.test.ts new file mode 100644 index 00000000..5a928ca3 --- /dev/null +++ b/packages/mcp-server/test/unit/auth-errors.test.ts @@ -0,0 +1,195 @@ +import type { AddressInfo } from 'node:net' + +import type { TransloaditMcpHttpOptions } from '../../src/http.ts' + +import { createServer } from 'node:http' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { Transloadit } from '@transloadit/node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' + +const resourceMetadataUrl = 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const wwwAuthenticate = (result: { _meta?: Record }): string => { + const values = result._meta?.['mcp/www_authenticate'] + if (!Array.isArray(values) || typeof values[0] !== 'string') { + throw new Error('Expected _meta["mcp/www_authenticate"] to be a list of header values') + } + return values[0] +} + +const apiRejection = (statusCode: number): Error => + Object.assign(new Error('Rejected'), { response: { statusCode } }) + +describe('tool auth errors', () => { + const serverOptions: TransloaditMcpHttpOptions = { metricsPath: false } + const handler = createTransloaditMcpHttpHandler(serverOptions) + const httpServer = createServer((req, res) => { + void handler(req, res) + }) + let url: URL + let client: Client + + const connect = async (headers: Record = {}): Promise => { + client = new Client({ name: 'auth-errors', version: '1.0.0' }) + await client.connect(new StreamableHTTPClientTransport(url, { requestInit: { headers } })) + } + + beforeEach(async () => { + delete serverOptions.authKey + delete serverOptions.authSecret + delete serverOptions.resourceMetadataUrl + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + url = new URL(`http://127.0.0.1:${port}/mcp`) + }) + + afterEach(async () => { + await client?.close() + await handler.close() + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ) + vi.restoreAllMocks() + }) + + it.each([ + 'transloadit_create_assembly', + 'transloadit_get_assembly_status', + 'transloadit_wait_for_assembly', + 'transloadit_list_templates', + 'transloadit_get_profile', + ])('%s asks the host to link an account when no credentials exist', async (name) => { + await connect() + const result = await client.callTool({ + name, + arguments: + name.endsWith('assembly_status') || name.endsWith('for_assembly') + ? { assembly_id: '0123456789abcdef0123456789abcdef' } + : {}, + }) + expect(result.isError).toBe(true) + expect(result.structuredContent).toMatchObject({ + status: 'error', + errors: [{ code: 'mcp_missing_auth' }], + }) + expect(wwwAuthenticate(result)).toMatch( + /^Bearer error="insufficient_scope", error_description="[^"]+"$/, + ) + }) + + it('includes the resource metadata URL in the challenge when hosted', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + vi.spyOn(Transloadit.prototype, 'listTemplates').mockRejectedValue(apiRejection(401)) + await connect({ Authorization: 'Bearer expired-token' }) + + const result = await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + expect(result.isError).toBe(true) + expect(result.structuredContent).toMatchObject({ + status: 'error', + errors: [{ code: 'mcp_auth_rejected' }], + }) + expect(wwwAuthenticate(result)).toBe( + `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token", error_description="Transloadit rejected the credentials; the token may have expired."`, + ) + }) + + it('reports rejected tokens on Assembly tools without leaking the API response', async () => { + vi.spyOn(Transloadit.prototype, 'getAssembly').mockRejectedValue(apiRejection(401)) + await connect({ Authorization: 'Bearer expired-token' }) + + const result = await client.callTool({ + name: 'transloadit_get_assembly_status', + arguments: { assembly_id: '0123456789abcdef0123456789abcdef' }, + }) + expect(result.isError).toBe(true) + expect(wwwAuthenticate(result)).toContain('error="invalid_token"') + const content = Array.isArray(result.content) ? result.content[0] : undefined + expect(isRecord(content) ? content.text : undefined).not.toContain('Rejected') + }) + + it('leaves other API failures as ordinary tool errors', async () => { + vi.spyOn(Transloadit.prototype, 'getAssembly').mockRejectedValue(apiRejection(500)) + await connect({ Authorization: 'Bearer token' }) + + const result = await client.callTool({ + name: 'transloadit_get_assembly_status', + arguments: { assembly_id: '0123456789abcdef0123456789abcdef' }, + }) + expect(result.isError).toBe(true) + expect(result._meta?.['mcp/www_authenticate']).toBeUndefined() + }) +}) + +describe('profile tool', () => { + const handler = createTransloaditMcpHttpHandler({ metricsPath: false }) + const httpServer = createServer((req, res) => { + void handler(req, res) + }) + let client: Client + + beforeEach(async () => { + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + client = new Client({ name: 'profile', version: '1.0.0' }) + await client.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), { + requestInit: { headers: { Authorization: 'Bearer token' } }, + }), + ) + }) + + afterEach(async () => { + await client?.close() + await handler.close() + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ) + vi.restoreAllMocks() + }) + + it('returns the Workspace behind the latest Assembly', async () => { + vi.spyOn(Transloadit.prototype, 'listAssemblies').mockResolvedValue({ + items: [{ id: 'abcdef', account_id: 'ws_1' }], + count: 1, + }) + vi.spyOn(Transloadit.prototype, 'getAssembly').mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + account_id: 'ws_1', + account_name: 'Acme Media', + account_slug: 'acme', + }) + + const result = await client.callTool({ name: 'transloadit_get_profile', arguments: {} }) + expect(result.isError).toBeFalsy() + expect(result.structuredContent).toEqual({ id: 'ws_1', name: 'Acme Media', nickname: 'acme' }) + }) + + it('falls back to an owned Template when no Assembly exists', async () => { + vi.spyOn(Transloadit.prototype, 'listAssemblies').mockResolvedValue({ items: [], count: 0 }) + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ + items: [{ id: 'tpl_1', name: 'resize', content: {}, account_id: 'ws_2' }], + count: 1, + }) + + const result = await client.callTool({ name: 'transloadit_get_profile', arguments: {} }) + expect(result.structuredContent).toEqual({ id: 'ws_2' }) + }) + + it('explains when the Workspace cannot be resolved yet', async () => { + vi.spyOn(Transloadit.prototype, 'listAssemblies').mockResolvedValue({ items: [], count: 0 }) + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ items: [], count: 0 }) + + const result = await client.callTool({ name: 'transloadit_get_profile', arguments: {} }) + expect(result.structuredContent).toMatchObject({ + status: 'error', + errors: [{ code: 'mcp_profile_unavailable' }], + }) + }) +}) diff --git a/packages/mcp-server/test/unit/file-inputs.test.ts b/packages/mcp-server/test/unit/file-inputs.test.ts index 4aefd686..dbd6f91a 100644 --- a/packages/mcp-server/test/unit/file-inputs.test.ts +++ b/packages/mcp-server/test/unit/file-inputs.test.ts @@ -602,4 +602,90 @@ describe('MCP file inputs', () => { }), ) }) + + it('maps host-attached files onto the URL import path', async () => { + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { source: { robot: '/http/import' } } }, + attachments: [ + { + download_url: 'https://example.com/attached.jpg', + file_id: 'file_123', + mime_type: 'image/jpeg', + file_name: 'attached.jpg', + }, + ], + }, + }) + expect(result.structuredContent).toMatchObject({ status: 'ok' }) + expect(Transloadit.prototype.createAssembly).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + steps: { source: { robot: '/http/import', url: 'https://example.com/attached.jpg' } }, + }), + }), + ) + }) + + it('downloads host-attached files for upload templates alongside legacy inputs', async () => { + const download = nock('https://example.com').get('/attached.txt').reply(200, fixtureContent) + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { ':original': { robot: '/upload/handle' } } }, + files: [{ kind: 'base64', field: 'inline', base64: 'aGk=', filename: 'inline.txt' }], + attachments: [{ download_url: 'https://example.com/attached.txt', file_id: 'file_1' }], + wait_for_completion: true, + }, + }) + expect(result.structuredContent).toMatchObject({ + status: 'ok', + upload: { status: 'complete', total_files: 2 }, + }) + expect(download.isDone()).toBe(true) + expect(Transloadit.prototype.createAssembly).toHaveBeenCalledWith( + expect.objectContaining({ + files: { inline: expect.any(String), attachment_1: expect.any(String) }, + }), + ) + }) + + it('rejects host file objects with unknown properties before any API call', async () => { + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { source: { robot: '/http/import' } } }, + attachments: [ + { download_url: 'https://example.com/a.jpg', file_id: 'file_1', kind: 'url' }, + ], + }, + }) + expect(result.isError).toBe(true) + expect(Transloadit.prototype.createAssembly).not.toHaveBeenCalled() + }) + + it('hands the widget Console deep links for the Assembly and a new Template', async () => { + vi.mocked(Transloadit.prototype.createAssembly).mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + assembly_id: assemblyId, + account_slug: 'acme', + template_id: 'tpl_1', + }) + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { resized: { robot: '/image/resize', width: 1 } } }, + wait_for_completion: true, + }, + }) + expect(result.structuredContent).toMatchObject({ status: 'ok' }) + expect(result._meta).toEqual({ + 'transloadit/widget': { + authenticated: true, + assembly_console_url: `https://transloadit.com/c/acme/assemblies/${assemblyId}`, + new_template_url: 'https://transloadit.com/c/acme/templates/new?duplicateFrom=tpl_1', + }, + }) + }) }) diff --git a/packages/mcp-server/test/unit/hosted-auth.test.ts b/packages/mcp-server/test/unit/hosted-auth.test.ts new file mode 100644 index 00000000..53cd392e --- /dev/null +++ b/packages/mcp-server/test/unit/hosted-auth.test.ts @@ -0,0 +1,215 @@ +import type { AddressInfo } from 'node:net' + +import { createServer } from 'node:http' + +import { afterEach, describe, expect, it } from 'vitest' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' +import { matchesOriginPattern } from '../../src/http-helpers.ts' + +type RunningServer = { url: URL; close: () => Promise } + +const resourceMetadataUrl = 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp' + +const initializeBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'hosted-auth-test', version: '0.0.0' }, + }, +}) + +const start = async ( + options: Parameters[0] = {}, +): Promise => { + const handler = createTransloaditMcpHttpHandler({ metricsPath: false, ...options }) + const server = createServer((req, res) => { + void handler(req, res) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + return { + url: new URL(`http://127.0.0.1:${port}/mcp`), + close: async () => { + await handler.close() + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + }, + } +} + +const post = (url: URL, headers: Record = {}): Promise => + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers, + }, + body: initializeBody, + }) + +describe('hosted MCP endpoint auth', () => { + let running: RunningServer | undefined + + afterEach(async () => { + await running?.close() + running = undefined + }) + + it('challenges unauthenticated requests with the protected-resource metadata URL', async () => { + running = await start({ resourceMetadataUrl }) + + const response = await post(running.url) + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ) + expect(response.headers.get('content-type')).toContain('application/json') + await expect(response.json()).resolves.toMatchObject({ + error: 'unauthorized', + error_description: expect.stringContaining('OAuth'), + }) + }) + + it('challenges SSE GETs without a bearer token but keeps the bare GET health probe', async () => { + running = await start({ resourceMetadataUrl }) + + const stream = await fetch(running.url, { headers: { Accept: 'text/event-stream' } }) + expect(stream.status).toBe(401) + expect(stream.headers.get('www-authenticate')).toContain('resource_metadata=') + + const probe = await fetch(running.url) + expect(probe.status).toBe(200) + await expect(probe.json()).resolves.toMatchObject({ status: 'ok' }) + }) + + it('forwards requests that carry any bearer token to the MCP transport', async () => { + running = await start({ resourceMetadataUrl }) + + const response = await post(running.url, { Authorization: 'Bearer oauth-access-token' }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('protocolVersion') + }) + + it('keeps the self-hosted static token behavior', async () => { + running = await start({ mcpToken: 'static-secret' }) + + const rejected = await post(running.url, { Authorization: 'Bearer wrong' }) + expect(rejected.status).toBe(401) + expect(rejected.headers.get('www-authenticate')).toBe('Bearer') + + const accepted = await post(running.url, { Authorization: 'Bearer static-secret' }) + expect(accepted.status).toBe(200) + }) + + it('does not challenge when neither mode is configured', async () => { + running = await start() + + const response = await post(running.url) + expect(response.status).toBe(200) + }) + + it('advertises OAuth in the server card when hosted', async () => { + running = await start({ resourceMetadataUrl }) + + const card = await fetch(new URL('/.well-known/mcp/server-card.json', running.url)) + expect(card.status).toBe(200) + await expect(card.json()).resolves.toMatchObject({ + authentication: { required: true, schemes: ['oauth2', 'bearer'], resourceMetadataUrl }, + }) + }) +}) + +describe('hosted MCP endpoint origins', () => { + let running: RunningServer | undefined + + afterEach(async () => { + await running?.close() + running = undefined + }) + + it.each([ + 'https://chatgpt.com', + 'https://chat.openai.com', + 'https://claude.ai', + 'https://claude.com', + 'https://transloadit.com', + 'https://mcp.transloadit.com', + 'https://transloadit.dev:3001', + 'http://localhost:6274', + 'http://127.0.0.1:5173', + ])('allows %s in hosted mode', async (origin) => { + running = await start({ resourceMetadataUrl }) + + const response = await post(running.url, { Origin: origin, Authorization: 'Bearer token' }) + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe(origin) + expect(response.headers.get('access-control-expose-headers')).toContain('WWW-Authenticate') + }) + + it.each([ + 'https://evil.example', + 'https://transloadit.com.evil.example', + 'null', + ])('rejects %s in hosted mode', async (origin) => { + running = await start({ resourceMetadataUrl }) + + const response = await post(running.url, { Origin: origin, Authorization: 'Bearer token' }) + expect(response.status).toBe(403) + }) + + it('passes requests without an Origin header in hosted mode', async () => { + running = await start({ resourceMetadataUrl }) + + const response = await post(running.url, { Authorization: 'Bearer token' }) + expect(response.status).toBe(200) + }) + + it('lets explicit allowedOrigins replace the hosted defaults', async () => { + running = await start({ resourceMetadataUrl, allowedOrigins: ['https://allowed.example'] }) + + const allowed = await post(running.url, { + Origin: 'https://allowed.example', + Authorization: 'Bearer token', + }) + expect(allowed.status).toBe(200) + + const chatgpt = await post(running.url, { + Origin: 'https://chatgpt.com', + Authorization: 'Bearer token', + }) + expect(chatgpt.status).toBe(403) + }) + + it('keeps the open CORS policy outside hosted mode', async () => { + running = await start() + + const response = await post(running.url, { Origin: 'https://anything.example' }) + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + }) +}) + +describe('matchesOriginPattern', () => { + it.each([ + ['https://chatgpt.com', 'https://chatgpt.com', true], + ['https://chatgpt.com', 'https://chat.openai.com', false], + ['https://api2.transloadit.com', 'https://*.transloadit.com', true], + ['https://transloadit.com', 'https://*.transloadit.com', false], + ['https://transloadit.com.evil.example', 'https://*.transloadit.com', false], + ['https://transloadit.dev:3001', 'https://transloadit.dev:*', true], + ['https://transloadit.dev', 'https://transloadit.dev:*', true], + ['http://transloadit.dev:3001', 'https://transloadit.dev:*', false], + ['http://localhost:6274', 'http://localhost:*', true], + ['http://[::1]:6274', 'http://[::1]:*', true], + ['https://chatgpt.com:8443', 'https://chatgpt.com', false], + ['not a url', 'https://chatgpt.com', false], + ])('%s against %s is %s', (origin, pattern, expected) => { + expect(matchesOriginPattern(origin, pattern)).toBe(expected) + }) +}) diff --git a/packages/mcp-server/test/unit/tool-surface.test.ts b/packages/mcp-server/test/unit/tool-surface.test.ts new file mode 100644 index 00000000..bfd90002 --- /dev/null +++ b/packages/mcp-server/test/unit/tool-surface.test.ts @@ -0,0 +1,213 @@ +import type { AddressInfo } from 'node:net' + +import { createServer } from 'node:http' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' +import { toolNames } from '../../src/tool-metadata.ts' +import { + assemblyResultOrigins, + assemblyResultWidgetMimeType, + assemblyResultWidgetUri, +} from '../../src/ui/assembly-result-widget.ts' + +type JsonRecord = Record + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const resourceMetadataUrl = 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp' + +// The SDK client strips unknown Tool fields such as `securitySchemes`, so tools/list is read raw. +const parseJsonRpcResult = async (response: Response): Promise => { + const text = await response.text() + const payload = response.headers.get('content-type')?.includes('text/event-stream') + ? text + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice('data: '.length)) + .at(-1) + : text + if (!payload) throw new Error(`Empty JSON-RPC response: ${text}`) + const parsed: unknown = JSON.parse(payload) + if (!isRecord(parsed) || !isRecord(parsed.result)) { + throw new Error(`Unexpected JSON-RPC response: ${payload}`) + } + return parsed.result +} + +describe('tool surface', () => { + const handler = createTransloaditMcpHttpHandler({ metricsPath: false, resourceMetadataUrl }) + const httpServer = createServer((req, res) => { + void handler(req, res) + }) + let url: URL + + const call = async (method: string, params: JsonRecord = {}): Promise => { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + expect(response.status).toBe(200) + return parseJsonRpcResult(response) + } + + const listTools = async (): Promise => { + const result = await call('tools/list') + expect(Array.isArray(result.tools)).toBe(true) + return (result.tools as unknown[]).filter(isRecord) + } + + const findTool = (tools: JsonRecord[], name: string): JsonRecord => { + const tool = tools.find((entry) => entry.name === name) + if (!tool) throw new Error(`Tool ${name} is not listed`) + return tool + } + + beforeAll(async () => { + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + url = new URL(`http://127.0.0.1:${port}/mcp`) + }) + + afterAll(async () => { + await handler.close() + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ) + }) + + it('lists every tool with a title, annotations and security schemes in both places', async () => { + const tools = await listTools() + expect(tools.map((tool) => tool.name).sort()).toEqual([...toolNames].sort()) + + for (const tool of tools) { + expect(typeof tool.title, String(tool.name)).toBe('string') + expect(tool.annotations).toMatchObject({ + readOnlyHint: expect.any(Boolean), + destructiveHint: false, + openWorldHint: expect.any(Boolean), + }) + expect(Array.isArray(tool.securitySchemes), String(tool.name)).toBe(true) + expect(isRecord(tool._meta) ? tool._meta.securitySchemes : undefined).toEqual( + tool.securitySchemes, + ) + } + }) + + it.each([ + ['transloadit_list_robots', [{ type: 'noauth' }]], + ['transloadit_get_robot_help', [{ type: 'noauth' }]], + ['transloadit_lint_assembly_instructions', [{ type: 'noauth' }]], + ['transloadit_create_assembly', [{ type: 'oauth2', scopes: ['assemblies:write'] }]], + ['transloadit_get_assembly_status', [{ type: 'oauth2', scopes: ['assemblies:read'] }]], + ['transloadit_wait_for_assembly', [{ type: 'oauth2', scopes: ['assemblies:read'] }]], + ['transloadit_list_templates', [{ type: 'oauth2', scopes: ['templates:read'] }]], + ['transloadit_get_profile', [{ type: 'oauth2', scopes: [] }]], + ])('%s declares %j', async (name, securitySchemes) => { + const tool = findTool(await listTools(), name) + expect(tool.securitySchemes).toEqual(securitySchemes) + }) + + it('marks only the Assembly creation as open-world and nothing as destructive', async () => { + const tools = await listTools() + const openWorld = tools.filter( + (tool) => isRecord(tool.annotations) && tool.annotations.openWorldHint === true, + ) + expect(openWorld.map((tool) => tool.name)).toEqual(['transloadit_create_assembly']) + const readOnly = tools.filter( + (tool) => isRecord(tool.annotations) && tool.annotations.readOnlyHint === true, + ) + expect(readOnly).toHaveLength(tools.length - 1) + }) + + it('declares ChatGPT file params with exactly the OpenAI file object schema', async () => { + const tool = findTool(await listTools(), 'transloadit_create_assembly') + expect(isRecord(tool._meta) ? tool._meta['openai/fileParams'] : undefined).toEqual([ + 'attachments', + ]) + + const inputSchema = isRecord(tool.inputSchema) ? tool.inputSchema : {} + const properties = isRecord(inputSchema.properties) ? inputSchema.properties : {} + const attachments = isRecord(properties.attachments) ? properties.attachments : {} + expect(attachments.type).toBe('array') + expect(attachments.items).toEqual({ + type: 'object', + properties: { + download_url: { type: 'string' }, + file_id: { type: 'string' }, + mime_type: { type: 'string' }, + file_name: { type: 'string' }, + }, + required: ['download_url', 'file_id'], + additionalProperties: false, + }) + expect(JSON.stringify(properties.files)).toContain('base64') + }) + + it('links the Assembly tools to the result widget with short status strings', async () => { + const tools = await listTools() + for (const name of ['transloadit_create_assembly', 'transloadit_wait_for_assembly']) { + const meta = findTool(tools, name)._meta + expect(isRecord(meta) ? meta.ui : undefined).toEqual({ resourceUri: assemblyResultWidgetUri }) + expect(isRecord(meta) ? meta['openai/outputTemplate'] : undefined).toBe( + assemblyResultWidgetUri, + ) + } + for (const tool of tools) { + if (!isRecord(tool._meta)) continue + for (const key of ['openai/toolInvocation/invoking', 'openai/toolInvocation/invoked']) { + const value = tool._meta[key] + if (value === undefined) continue + expect(typeof value).toBe('string') + expect(String(value).length).toBeLessThanOrEqual(64) + } + } + }) + + it('marks the profile tool for multi-account hosts', async () => { + const tool = findTool(await listTools(), 'transloadit_get_profile') + expect(isRecord(tool._meta) ? tool._meta['openai/profile'] : undefined).toBe(true) + expect(tool.annotations).toMatchObject({ readOnlyHint: true, openWorldHint: false }) + expect(tool.inputSchema).toMatchObject({ type: 'object', additionalProperties: false }) + expect(tool.outputSchema).toMatchObject({ required: ['id'] }) + }) + + it('lists and serves the Assembly result widget with a CSP for result origins', async () => { + const listed = await call('resources/list') + const resources = (listed.resources as unknown[]).filter(isRecord) + const widget = resources.find((resource) => resource.uri === assemblyResultWidgetUri) + expect(widget).toMatchObject({ + mimeType: assemblyResultWidgetMimeType, + _meta: { + ui: { + csp: { connectDomains: assemblyResultOrigins, resourceDomains: assemblyResultOrigins }, + }, + 'openai/widgetDescription': expect.any(String), + 'openai/widgetCSP': { + connect_domains: assemblyResultOrigins, + resource_domains: assemblyResultOrigins, + }, + }, + }) + + const read = await call('resources/read', { uri: assemblyResultWidgetUri }) + const contents = (read.contents as unknown[]).filter(isRecord) + expect(contents).toHaveLength(1) + const html = String(contents[0]?.text) + expect(contents[0]).toMatchObject({ + uri: assemblyResultWidgetUri, + mimeType: assemblyResultWidgetMimeType, + }) + expect(html).toContain('') + expect(html).toContain('ui/notifications/tool-result') + expect(html).toContain('Save as Template') + expect(html).not.toMatch(/]+src=/) + }) +}) From d85cb647f88d8ee5d2bdbd6cab3f141567f9a050 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 22:19:28 +0200 Subject: [PATCH 04/27] Document connect-by-URL, add the plugin manifests and changeset README leads with the hosted endpoint and OAuth clients, moves minted bearer tokens to the CI section and drops the roadmap TODO. `plugin.json`, `mcp.json` and `.codex-plugin/plugin.json` describe the ChatGPT and Codex plugin and reference the skills catalog by URL. The task note checklist and devdock paths are updated. Co-Authored-By: Claude Fable 5.1 --- .changeset/mcp-oauth-discovery.md | 22 ++ .../prompts/2026-09-30-mcp-oauth-discovery.md | 35 +-- packages/mcp-server/.codex-plugin/plugin.json | 48 ++++ packages/mcp-server/README.md | 254 ++++++++++++------ packages/mcp-server/mcp.json | 9 + packages/mcp-server/plugin.json | 53 ++++ packages/mcp-server/server.json | 2 +- 7 files changed, 329 insertions(+), 94 deletions(-) create mode 100644 .changeset/mcp-oauth-discovery.md create mode 100644 packages/mcp-server/.codex-plugin/plugin.json create mode 100644 packages/mcp-server/mcp.json create mode 100644 packages/mcp-server/plugin.json diff --git a/.changeset/mcp-oauth-discovery.md b/.changeset/mcp-oauth-discovery.md new file mode 100644 index 00000000..25dda9c3 --- /dev/null +++ b/.changeset/mcp-oauth-discovery.md @@ -0,0 +1,22 @@ +--- +"@transloadit/mcp-server": minor +--- + +Let MCP clients connect to the hosted server by URL and satisfy the ChatGPT plugin and Claude +connector requirements. + +- Hosted mode (`TRANSLOADIT_MCP_RESOURCE_METADATA_URL`): unauthenticated requests get a `401` with + `WWW-Authenticate: Bearer resource_metadata="…"`, browser Origins are limited to ChatGPT, Claude, + Transloadit and loopback (overridable with `allowedOrigins`), and the server card advertises + OAuth. Self-hosted `TRANSLOADIT_MCP_TOKEN` behavior is unchanged. +- Every tool carries a title, `readOnlyHint`/`destructiveHint`/`openWorldHint`/`idempotentHint` + annotations and per-tool `securitySchemes` (`noauth` for Robots and linting, `oauth2` with scopes + elsewhere), mirrored in `_meta.securitySchemes`. Auth failures return `isError` results with + `_meta["mcp/www_authenticate"]` so hosts show their account-linking UI. +- New `transloadit_get_profile` tool (`_meta["openai/profile"]`) returns the Workspace behind the + credentials for multi-account hosts. +- `transloadit_create_assembly` accepts ChatGPT-attached files through `attachments` + (`_meta["openai/fileParams"]`), mapped onto the existing URL-input path. +- MCP Apps result widget `ui://transloadit/assembly-result` with previews, download links and a + Save as Template shortcut, linked from the Assembly tools with `_meta.ui.resourceUri`. +- `plugin.json`, `mcp.json` and `.codex-plugin/plugin.json` describe the ChatGPT and Codex plugin. diff --git a/docs/prompts/2026-09-30-mcp-oauth-discovery.md b/docs/prompts/2026-09-30-mcp-oauth-discovery.md index 6bbba7bc..be8b17a6 100644 --- a/docs/prompts/2026-09-30-mcp-oauth-discovery.md +++ b/docs/prompts/2026-09-30-mcp-oauth-discovery.md @@ -31,52 +31,53 @@ OpenAI file params on `transloadit_create_assembly` and an MCP Apps result widge ### Discovery and auth (Phase 0) -- [ ] Hosted mode: unauthenticated `/mcp` requests return `401` with +- [x] Hosted mode: unauthenticated `/mcp` requests return `401` with `WWW-Authenticate: Bearer resource_metadata="https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp"`. Keep the friendly JSON on bare `GET` without `Accept: text/event-stream` for directory health probes. Self-hosted mode keeps `TRANSLOADIT_MCP_TOKEN` behavior. -- [ ] Per-tool `securitySchemes`: `noauth` for `transloadit_list_robots`, +- [x] Per-tool `securitySchemes`: `noauth` for `transloadit_list_robots`, `transloadit_get_robot_help`, `transloadit_lint_assembly_instructions`; `oauth2` with the scopes each tool needs for `create_assembly`, `get_assembly_status`, `wait_for_assembly`, `list_templates`. Mirror them in `_meta["securitySchemes"]` for clients that only read `_meta`. -- [ ] Tool results that fail on auth carry `_meta["mcp/www_authenticate"]` with `error` and +- [x] Tool results that fail on auth carry `_meta["mcp/www_authenticate"]` with `error` and `error_description` so ChatGPT shows the account-linking UI. -- [ ] Origin validation on the hosted endpoint (allow `chatgpt.com`, `claude.ai`, `claude.com`, +- [x] Origin validation on the hosted endpoint (allow `chatgpt.com`, `claude.ai`, `claude.com`, Transloadit origins and loopback; reject others). -- [ ] Every tool gets a `title` and accurate `readOnlyHint`, `destructiveHint`, `openWorldHint` +- [x] Every tool gets a `title` and accurate `readOnlyHint`, `destructiveHint`, `openWorldHint` (`true` for URL imports). -- [ ] Optional profile tool marked `_meta["openai/profile"]: true` returning a stable opaque +- [x] Optional profile tool marked `_meta["openai/profile"]: true` returning a stable opaque workspace id, so multi-account works in ChatGPT. -- [ ] Server card advertises the OAuth-by-URL path; README puts "connect by URL" first and moves +- [x] Server card advertises the OAuth-by-URL path; README puts "connect by URL" first and moves minted bearer tokens to the CI/headless section; drop the device-login TODO. ### ChatGPT plugin surface (Phase 1) -- [ ] `_meta["openai/fileParams"]: ["files"]` on `transloadit_create_assembly` with the required +- [x] `_meta["openai/fileParams"]: ["files"]` on `transloadit_create_assembly` with the required file object schema (`download_url`, `file_id` required; `mime_type`, `file_name` optional, nothing else required). Map each entry onto the existing URL-import path. -- [ ] MCP Apps result widget (`_meta.ui.resourceUri`, `ui://transloadit/assembly-result`): per-Step +- [x] MCP Apps result widget (`_meta.ui.resourceUri`, `ui://transloadit/assembly-result`): per-Step preview (image, video, audio, document thumbnail), before/after for image Steps, download links, "Save as Template" when authenticated. Set `_meta.ui.csp.connectDomains` and `resourceDomains` to Transloadit result origins and `_meta.ui.domain` to a dedicated origin. -- [ ] `_meta["openai/toolInvocation/invoking"]` / `invoked` status strings (≤ 64 chars). -- [ ] `plugin.json` with `extensions.com.openai` (presentation, registered MCP server, hooks) and +- [x] `_meta["openai/toolInvocation/invoking"]` / `invoked` status strings (≤ 64 chars). +- [x] `plugin.json` with `extensions.com.openai` (presentation, registered MCP server, hooks) and `.codex-plugin/plugin.json` fallback; bundle the `transloadit/skills` catalog within OpenAI's limits (5 skills, 100 files each, 256 KiB `SKILL.md`). -- [ ] Tests: unit tests for the 401/metadata behavior, security schemes, file-param mapping and - widget resource; e2e against devdock once the api2 branch serves the metadata. +- [x] Tests: unit tests for the 401/metadata behavior, security schemes, file-param mapping and + widget resource. +- [ ] E2e against devdock once the api2 branch serves the metadata. ## Getting a local build into devdock API2's container bind-mounts the api2 worktree at `/srv/current` and runs the `mcp-server` service -from the worktree's `node_modules/@transloadit/mcp-server`. To test this branch: +from the worktree's `api2/node_modules/@transloadit/mcp-server`. To test this branch: ```bash cd ~/code/node-sdk && corepack yarn build -rm -rf ~/code/api2-clone-1/node_modules/@transloadit/mcp-server/dist -cp -r packages/mcp-server/dist ~/code/api2-clone-1/node_modules/@transloadit/mcp-server/dist -cd ~/code/api2-clone-1 && core/bin/devdock.ts --app api2 restart +rm -rf ~/code/api2-clone-1/api2/node_modules/@transloadit/mcp-server/dist +cp -r packages/mcp-server/dist ~/code/api2-clone-1/api2/node_modules/@transloadit/mcp-server/dist +cd ~/code/api2-clone-1 && core/bin/devdock.ts --app api2 restart -s mcp-server ``` Bump the dependency in API2 once the package is published from this branch. diff --git a/packages/mcp-server/.codex-plugin/plugin.json b/packages/mcp-server/.codex-plugin/plugin.json new file mode 100644 index 00000000..a10565dd --- /dev/null +++ b/packages/mcp-server/.codex-plugin/plugin.json @@ -0,0 +1,48 @@ +{ + "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.", + "author": { + "name": "Transloadit", + "email": "support@transloadit.com", + "url": "https://transloadit.com" + }, + "homepage": "https://transloadit.com/docs/sdks/mcp-server/", + "repository": "https://github.com/transloadit/node-sdk", + "license": "MIT", + "keywords": [ + "media", + "video", + "image", + "audio", + "document-processing", + "file-processing", + "transcoding", + "uploads" + ], + "mcpServers": { + "transloadit": { + "type": "http", + "url": "https://api2.transloadit.com/mcp" + } + }, + "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. Browsing Robots and linting Assembly Instructions works without an account; processing connects to your Transloadit Workspace through OAuth. Agent Skills for these workflows are published at https://transloadit.com/.well-known/skills/index.json (source: https://github.com/transloadit/skills).", + "developerName": "Transloadit", + "category": "Productivity", + "capabilities": ["Read", "Write"], + "websiteURL": "https://transloadit.com", + "privacyPolicyURL": "https://transloadit.com/legal/privacy/", + "termsOfServiceURL": "https://transloadit.com/legal/terms/", + "defaultPrompt": [ + "Turn this video into HLS with 720p and 1080p renditions", + "Transcribe this recording and give me an SRT subtitle file" + ], + "brandColor": "#1B61A7", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": [] + } +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 83b12691..f81b3e82 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -2,18 +2,122 @@ Transloadit MCP Server (Streamable HTTP + stdio), built on top of `@transloadit/node`. -## Install +## Connect by URL (recommended) + +The hosted server lives at: + +```text +https://api2.transloadit.com/mcp +``` + +Add it to your MCP client by URL. The client discovers API2 as the OAuth authorization server, +opens a browser consent page in the Transloadit Console, and keeps a short-lived token plus refresh +token for you. No API keys leave your Workspace. + +Browsing Robots (`transloadit_list_robots`, `transloadit_get_robot_help`) and linting Assembly +Instructions work before you sign in; creating Assemblies and listing Templates ask you to connect +your Workspace first. + +### Claude Code ```bash -npm install @transloadit/mcp-server +claude mcp add --transport http transloadit https://api2.transloadit.com/mcp +claude mcp login transloadit +``` + +For non-interactive runs (for example `claude -p`), explicitly allow MCP tools: + +```bash +claude -p "List templates" \ + --allowedTools mcp__transloadit__* \ + --output-format json +``` + +### Claude.ai and Claude Desktop + +Settings → Connectors → Add custom connector → enter `https://api2.transloadit.com/mcp`. Claude +registers itself with API2 and opens the consent page. + +### ChatGPT + +Settings → Apps & Connectors → Advanced → Developer mode → Create, then enter the URL above with +authentication set to OAuth. Files you attach in the chat are handed to +`transloadit_create_assembly` as `attachments`; results render in the Assembly result widget. + +The plugin manifest for the ChatGPT and Codex catalog (`plugin.json`, `mcp.json` and the +`.codex-plugin/plugin.json` fallback) lives in this package directory. It points at the hosted +server and at the Agent Skills catalog at `https://transloadit.com/.well-known/skills/index.json` +instead of bundling skill files. + +### Codex + +```bash +codex mcp add transloadit --url https://api2.transloadit.com/mcp +codex mcp login transloadit +``` + +Or in `~/.codex/config.toml`: + +```toml +[mcp_servers.transloadit] +url = "https://api2.transloadit.com/mcp" ``` -## Quick start (self-hosted, recommended) +### Cursor + +`~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "transloadit": { + "url": "https://api2.transloadit.com/mcp" + } + } +} +``` + +### MCP Inspector + +```bash +npx @modelcontextprotocol/inspector --cli https://api2.transloadit.com/mcp +``` -For most teams, self-hosted MCP is the simplest happy path: run the server where your agent runs, -set `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET`, and the server handles API auth automatically. +## CI and headless agents -### Stdio (recommended) +Where no browser is available, mint a bearer token from an Auth Key and pass it as +`Authorization: Bearer `: + +```bash +npx -y @transloadit/node auth token --aud mcp +``` + +Generate this token in a trusted environment (backend, CI, or local shell), then hand it to the +agent runtime. You can mint it via: + +- CLI: `npx -y @transloadit/node auth token --aud mcp` +- API: `POST https://api2.transloadit.com/token` (HTTP Basic Auth with key/secret) +- Node SDK: instantiate `Transloadit` with `authKey` + `authSecret`, then call + `client.mintBearerToken({ aud: 'mcp' })` + +Interactive CLI sessions can also run `npx -y @transloadit/node auth login` (device flow) and reuse +the stored credentials. + +Bearer tokens satisfy signature auth on API2 requests; signature checks apply to key/secret +requests. + +## Self-hosted + +Run the server where your agent runs, set `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET`, and the server +handles API auth automatically. + +### Install + +```bash +npm install @transloadit/mcp-server +``` + +### Stdio ```bash TRANSLOADIT_KEY=MY_AUTH_KEY TRANSLOADIT_SECRET=MY_SECRET_KEY npx -y @transloadit/mcp-server stdio @@ -26,7 +130,8 @@ TRANSLOADIT_KEY=MY_AUTH_KEY TRANSLOADIT_SECRET=MY_SECRET_KEY \ npx -y @transloadit/mcp-server http --host 127.0.0.1 --port 5723 ``` -When binding HTTP mode to non-localhost hosts, `TRANSLOADIT_MCP_TOKEN` is required. +When binding HTTP mode to non-localhost hosts, `TRANSLOADIT_MCP_TOKEN` (or the hosted-mode +`TRANSLOADIT_MCP_RESOURCE_METADATA_URL`) is required. ### Docker @@ -65,36 +170,11 @@ export TRANSLOADIT_MCP_TOKEN="$(openssl rand -hex 32)" npx -y @transloadit/mcp-server http --host 0.0.0.0 --port 5723 ``` -## Hosted endpoint - -If you cannot run `npx` where the agent runs, use the hosted endpoint: - -```text -https://api2.transloadit.com/mcp -``` - -Use `Authorization: Bearer `. Mint a token with: - -```bash -npx -y @transloadit/node auth token --aud mcp -``` - -Generate this token in a trusted environment (backend, CI, or local shell), then hand it to the -agent runtime. You can mint it via: - -- CLI: `npx -y @transloadit/node auth token --aud mcp` -- API: `POST https://api2.transloadit.com/token` (HTTP Basic Auth with key/secret) -- Node SDK: instantiate `Transloadit` with `authKey` + `authSecret`, then call - `client.mintBearerToken({ aud: 'mcp' })` - -Bearer tokens satisfy signature auth on API2 requests; signature checks apply to key/secret -requests. - -## Agent client setup +### Self-hosted client setup -Most users add the server to their MCP client and let the client start it automatically via stdio. +Most self-hosted users add the server to their MCP client and let the client start it via stdio. -### Claude Code +#### Claude Code ```bash claude mcp add --transport stdio transloadit \ @@ -103,15 +183,7 @@ claude mcp add --transport stdio transloadit \ -- npx -y @transloadit/mcp-server stdio ``` -For non-interactive runs (for example `claude -p`), explicitly allow MCP tools: - -```bash -claude -p "List templates" \ - --allowedTools mcp__transloadit__* \ - --output-format json -``` - -### Codex CLI +#### Codex CLI ```bash codex mcp add transloadit \ @@ -129,7 +201,7 @@ args = ["-y", "@transloadit/mcp-server", "stdio"] enabled_tools = ["transloadit_list_templates"] ``` -### Gemini CLI +#### Gemini CLI ```bash gemini mcp add --scope user transloadit npx -y @transloadit/mcp-server stdio \ @@ -155,7 +227,7 @@ Allowlist tools in `~/.gemini/settings.json`: } ``` -### Cursor +#### Cursor `~/.cursor/mcp.json`: @@ -174,7 +246,7 @@ Allowlist tools in `~/.gemini/settings.json`: } ``` -### OpenCode +#### OpenCode `~/.config/opencode/opencode.json`: @@ -193,27 +265,21 @@ Allowlist tools in `~/.gemini/settings.json`: } ``` -## Run the server manually - -HTTP: - -```bash -npx -y @transloadit/mcp-server http --host 127.0.0.1 --port 5723 -``` - -Stdio: - -```bash -npx -y @transloadit/mcp-server stdio -``` - ## Auth model ### Hosted (`https://api2.transloadit.com/mcp`) -- Mint token via `POST https://api2.transloadit.com/token`. -- Send `Authorization: Bearer `. -- Bearer auth satisfies signature auth; signature checks apply to key/secret requests. +- Requests without a bearer token get `401` with + `WWW-Authenticate: Bearer resource_metadata="https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp"`. + MCP clients follow that document to API2's authorization server (authorization code + PKCE, + Dynamic Client Registration or Client ID Metadata Documents). +- Bearer tokens (OAuth or minted with `--aud mcp`) are forwarded to API2, which verifies them on + every call. A rejected or expired token yields a tool result with `isError` and + `_meta["mcp/www_authenticate"]`, so ChatGPT and Claude prompt you to reconnect. +- Each tool declares `securitySchemes`: `noauth` for Robot docs and linting, `oauth2` with the + scopes it needs (`assemblies:write`, `assemblies:read`, `templates:read`) otherwise. +- Browser requests must come from ChatGPT, Claude, Transloadit or loopback origins; requests without + an `Origin` header (CLIs, servers) are not restricted. Set `allowedOrigins` to change the list. ### Self-hosted @@ -230,6 +296,10 @@ npx -y @transloadit/mcp-server stdio - `TRANSLOADIT_KEY` - `TRANSLOADIT_SECRET` - `TRANSLOADIT_MCP_TOKEN` +- `TRANSLOADIT_MCP_RESOURCE_METADATA_URL` (hosted mode: protected-resource metadata URL to + advertise in `401` challenges) +- `TRANSLOADIT_MCP_CONSOLE_URL` (optional, default `https://transloadit.com`; Console origin for + widget deep links) - `TRANSLOADIT_ENDPOINT` (optional, default `https://api2.transloadit.com`) - `TRANSLOADIT_MCP_METRICS_PATH` (optional, default `/metrics`) - `TRANSLOADIT_MCP_METRICS_USER` (optional) @@ -241,21 +311,41 @@ npx -y @transloadit/mcp-server stdio - `npx -y @transloadit/mcp-server http --endpoint https://api2.transloadit.com` - `npx -y @transloadit/mcp-server http --config path/to/config.json` +The JSON config accepts the same keys as `createTransloaditMcpHttpHandler()`, including +`allowedOrigins`, `resourceMetadataUrl` and `consoleUrl`. + ## Tool surface -- `transloadit_lint_assembly_instructions` -- `transloadit_create_assembly` -- `transloadit_get_assembly_status` -- `transloadit_wait_for_assembly` -- `transloadit_list_robots` -- `transloadit_get_robot_help` -- `transloadit_list_templates` +| Tool | Auth | Notes | +| --------------------------------------- | --------------------------- | ---------------------------------------- | +| `transloadit_lint_assembly_instructions` | none | read-only | +| `transloadit_list_robots` | none | read-only | +| `transloadit_get_robot_help` | none | read-only | +| `transloadit_create_assembly` | `oauth2` `assemblies:write` | open-world (URL imports), result widget | +| `transloadit_get_assembly_status` | `oauth2` `assemblies:read` | read-only | +| `transloadit_wait_for_assembly` | `oauth2` `assemblies:read` | read-only, result widget | +| `transloadit_list_templates` | `oauth2` `templates:read` | read-only | +| `transloadit_get_profile` | `oauth2` | read-only, `_meta["openai/profile"]` | + +Every tool carries `title`, `readOnlyHint`, `destructiveHint` (always `false`), `idempotentHint` +and `openWorldHint` annotations. `transloadit_list_templates` supports: - `include_builtin`: `all`, `latest`, `exclusively-all`, `exclusively-latest` - `include_content`: include parsed `steps` in each template item +`transloadit_get_profile` returns `{ id, name?, nickname? }` for the Workspace behind the current +credentials, derived from the Workspace's own Assemblies or Templates. + +### Result widget + +`transloadit_create_assembly` and `transloadit_wait_for_assembly` link the MCP Apps resource +`ui://transloadit/assembly-result` (`_meta.ui.resourceUri`, also `_meta["openai/outputTemplate"]`). +Hosts that support MCP Apps render each Step's results with image, video and audio previews, +download links, an "Open in Console" link and a "Save as Template" shortcut. The widget only needs +`https://*.transloadit.com` and `https://*.transloadit.net` in its CSP. + ## Input files ```ts @@ -276,6 +366,21 @@ export type InputFile = } ``` +Hosts that attach chat files (ChatGPT) pass them under `attachments` instead, as declared by +`_meta["openai/fileParams"]`: + +```ts +type Attachment = { + download_url: string + file_id: string + mime_type?: string + file_name?: string +} +``` + +Each attachment becomes a URL input (`attachment_1`, `attachment_2`, …) and follows the URL rules +below. + ## Limits These limits apply to inline JSON/base64 payloads. For larger files, use a public URL or upload from @@ -329,7 +434,8 @@ but this does not enable private-network URL file downloads. - Disable via `metricsPath: false`. - Optional metrics basic auth via `TRANSLOADIT_MCP_METRICS_USER` + `TRANSLOADIT_MCP_METRICS_PASSWORD` or `metricsAuth`. -- Public discovery endpoint at `/.well-known/mcp/server-card.json`. +- Public discovery endpoint at `/.well-known/mcp/server-card.json`, listing every tool with its + annotations and security schemes and, in hosted mode, the OAuth resource metadata URL. ## MCP vs skills/CLI @@ -388,7 +494,3 @@ corepack yarn --cwd packages/mcp-server test:e2e - Add or update tests with behavior changes. - Keep README and website docs aligned for user-facing behavior. - Open a PR in `transloadit/node-sdk`. - -### Roadmap - -- Next.js Claude Web flow to mint and hand off bearer tokens for MCP. diff --git a/packages/mcp-server/mcp.json b/packages/mcp-server/mcp.json new file mode 100644 index 00000000..2c121eac --- /dev/null +++ b/packages/mcp-server/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "transloadit": { + "type": "streamable-http", + "url": "https://api2.transloadit.com/mcp" + } + } +} diff --git a/packages/mcp-server/plugin.json b/packages/mcp-server/plugin.json new file mode 100644 index 00000000..fb7d81cd --- /dev/null +++ b/packages/mcp-server/plugin.json @@ -0,0 +1,53 @@ +{ + "$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.", + "author": { + "name": "Transloadit", + "email": "support@transloadit.com", + "url": "https://transloadit.com" + }, + "homepage": "https://transloadit.com/docs/sdks/mcp-server/", + "repository": "https://github.com/transloadit/node-sdk", + "license": "MIT", + "keywords": [ + "media", + "video", + "image", + "audio", + "document-processing", + "file-processing", + "transcoding", + "uploads" + ], + "extensions": { + "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. Browsing Robots and linting Assembly Instructions works without an account; processing connects to your Transloadit Workspace through OAuth. Agent Skills for these workflows are published at https://transloadit.com/.well-known/skills/index.json (source: https://github.com/transloadit/skills).", + "developerName": "Transloadit", + "category": "Productivity", + "capabilities": ["Read", "Write"], + "websiteURL": "https://transloadit.com", + "privacyPolicyURL": "https://transloadit.com/legal/privacy/", + "termsOfServiceURL": "https://transloadit.com/legal/terms/", + "defaultPrompt": [ + "Turn this video into HLS with 720p and 1080p renditions", + "Transcribe this recording and give me an SRT subtitle file" + ], + "brandColor": "#1B61A7", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": [] + } + }, + "com.transloadit": { + "skillsCatalog": "https://transloadit.com/.well-known/skills/index.json", + "skillsRepository": "https://github.com/transloadit/skills", + "mcpServer": "https://api2.transloadit.com/mcp", + "authentication": "oauth" + } + } +} diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json index affac95c..c5889114 100644 --- a/packages/mcp-server/server.json +++ b/packages/mcp-server/server.json @@ -49,7 +49,7 @@ "headers": [ { "name": "Authorization", - "description": "Bearer token obtained via the authenticate tool, or set TRANSLOADIT_KEY and TRANSLOADIT_SECRET env vars with the self-hosted package instead", + "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 } From da8fc66dc737da609beebb7d42a8f298deb704df Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 22:20:58 +0200 Subject: [PATCH 05/27] Record the devdock verification of the hosted MCP build Co-Authored-By: Claude Fable 5.1 --- docs/prompts/2026-09-30-mcp-oauth-discovery.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/prompts/2026-09-30-mcp-oauth-discovery.md b/docs/prompts/2026-09-30-mcp-oauth-discovery.md index be8b17a6..e0b376e9 100644 --- a/docs/prompts/2026-09-30-mcp-oauth-discovery.md +++ b/docs/prompts/2026-09-30-mcp-oauth-discovery.md @@ -80,4 +80,13 @@ cp -r packages/mcp-server/dist ~/code/api2-clone-1/api2/node_modules/@transloadi cd ~/code/api2-clone-1 && core/bin/devdock.ts --app api2 restart -s mcp-server ``` +Verified on devdock (2026-09-30) with this branch's `dist` and the api2 branch's service environment: +`POST /mcp` without a token returns `401` with +`WWW-Authenticate: Bearer resource_metadata="https://api2-devdock.transloadit.dev/.well-known/oauth-protected-resource/mcp"`, +the bare `GET` health probe stays `200`, `tools/list` shows `securitySchemes`, `openai/fileParams` +and the widget link, and `resources/read` serves `ui://transloadit/assembly-result`. The server card +at `/.well-known/mcp/server-card.json` is rendered by the API2 process from its own import of the +package, so it only picks up the new tools and OAuth schemes after API2 restarts with the bumped +dependency and passes `resourceMetadataUrl` to `buildServerCard()`. + Bump the dependency in API2 once the package is published from this branch. From ed0cc57c0b905360d7321f882590751bba5ade66 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 23:19:58 +0200 Subject: [PATCH 06/27] Identify the hosted MCP service to API2 with an upstream secret header API2 only accepts relayed `aud=mcp` bearer tokens on ordinary endpoints when the request comes from the Transloadit-hosted MCP service. `TRANSLOADIT_MCP_UPSTREAM_SECRET` (option and JSON config key `upstreamSecret`) is sent as `Transloadit-Mcp-Upstream` next to a forwarded bearer token, never in key/secret mode, and is redacted from logs. `@transloadit/node` gains an `extraHeaders` client option to carry the fixed header. Co-Authored-By: Claude Fable 5.1 --- .changeset/mcp-upstream-header.md | 7 + packages/mcp-server/README.md | 4 + packages/mcp-server/src/cli.ts | 5 + packages/mcp-server/src/logger.ts | 8 +- packages/mcp-server/src/server.ts | 12 ++ .../test/unit/upstream-secret.test.ts | 125 ++++++++++++++++++ packages/node/src/Transloadit.ts | 9 ++ .../test/unit/test-transloadit-client.test.ts | 23 ++++ 8 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-upstream-header.md create mode 100644 packages/mcp-server/test/unit/upstream-secret.test.ts diff --git a/.changeset/mcp-upstream-header.md b/.changeset/mcp-upstream-header.md new file mode 100644 index 00000000..1e604a28 --- /dev/null +++ b/.changeset/mcp-upstream-header.md @@ -0,0 +1,7 @@ +--- +"@transloadit/node": patch +"transloadit": patch +--- + +Add an `extraHeaders` client option so trusted relays such as the hosted MCP service can send a +fixed header (`Transloadit-Mcp-Upstream`) with every API request next to a forwarded bearer token. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f81b3e82..9ed22d0b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -280,6 +280,8 @@ Allowlist tools in `~/.gemini/settings.json`: scopes it needs (`assemblies:write`, `assemblies:read`, `templates:read`) otherwise. - Browser requests must come from ChatGPT, Claude, Transloadit or loopback origins; requests without an `Origin` header (CLIs, servers) are not restricted. Set `allowedOrigins` to change the list. +- `TRANSLOADIT_MCP_UPSTREAM_SECRET` is set by Transloadit's own deployment so API2 can tell that a + relayed `aud=mcp` token arrives from the hosted service; it is not needed for self-hosting. ### Self-hosted @@ -298,6 +300,8 @@ Allowlist tools in `~/.gemini/settings.json`: - `TRANSLOADIT_MCP_TOKEN` - `TRANSLOADIT_MCP_RESOURCE_METADATA_URL` (hosted mode: protected-resource metadata URL to advertise in `401` challenges) +- `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_CONSOLE_URL` (optional, default `https://transloadit.com`; Console origin for widget deep links) - `TRANSLOADIT_ENDPOINT` (optional, default `https://api2.transloadit.com`) diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 0a6e2e11..381d8ad9 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -20,6 +20,7 @@ Environment: TRANSLOADIT_SECRET TRANSLOADIT_MCP_TOKEN TRANSLOADIT_MCP_RESOURCE_METADATA_URL + TRANSLOADIT_MCP_UPSTREAM_SECRET TRANSLOADIT_MCP_CONSOLE_URL TRANSLOADIT_ENDPOINT TRANSLOADIT_MCP_METRICS_PATH @@ -132,6 +133,8 @@ const main = async (): Promise => { | undefined const resourceMetadataUrl = (fileConfig.resourceMetadataUrl ?? process.env.TRANSLOADIT_MCP_RESOURCE_METADATA_URL) as string | undefined + const upstreamSecret = (fileConfig.upstreamSecret ?? + process.env.TRANSLOADIT_MCP_UPSTREAM_SECRET) as string | undefined const consoleUrl = (fileConfig.consoleUrl ?? process.env.TRANSLOADIT_MCP_CONSOLE_URL) as | string | undefined @@ -151,6 +154,7 @@ const main = async (): Promise => { clientSuffix, mcpToken, resourceMetadataUrl, + upstreamSecret, consoleUrl, allowedOrigins: fileConfig.allowedOrigins as string[] | undefined, allowedHosts: fileConfig.allowedHosts as string[] | undefined, @@ -198,6 +202,7 @@ main().catch((err) => { process.env.TRANSLOADIT_KEY, process.env.TRANSLOADIT_SECRET, process.env.TRANSLOADIT_MCP_TOKEN, + process.env.TRANSLOADIT_MCP_UPSTREAM_SECRET, ]) logger.err('MCP server failed: %s', redact(err)) process.exit(1) diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts index 0f37f86a..264ca5fc 100644 --- a/packages/mcp-server/src/logger.ts +++ b/packages/mcp-server/src/logger.ts @@ -3,7 +3,13 @@ import { SevLogger } from '@transloadit/sev-logger' const baseLogger = new SevLogger({ breadcrumbs: ['mcp-server'] }) const redactString = (value: string, secrets: string[]): string => { - let output = value.replace(/Bearer\s+[^\s]+/gi, 'Bearer [redacted]') + let output = value + .replace(/Bearer\s+[^\s]+/gi, 'Bearer [redacted]') + // The hosted upstream secret travels as a header; scrub it even when it was not listed. + .replace( + /Transloadit-Mcp-Upstream(["']?\s*[:=]\s*["']?)[^\s"',}]+/gi, + 'Transloadit-Mcp-Upstream$1[redacted]', + ) for (const secret of secrets) { if (!secret) continue output = output.split(secret).join('[redacted]') diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 6cb3f970..541bae4f 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -41,6 +41,12 @@ export type TransloaditMcpServerOptions = { * RFC 6750 challenge that points OAuth clients at API2 (`TRANSLOADIT_MCP_RESOURCE_METADATA_URL`). */ resourceMetadataUrl?: string + /** + * Shared secret that identifies the Transloadit-hosted MCP service to API2, which only accepts + * relayed `aud=mcp` bearer tokens from that service (`TRANSLOADIT_MCP_UPSTREAM_SECRET`). It is + * sent as `Transloadit-Mcp-Upstream` next to a forwarded bearer token and never with key/secret. + */ + upstreamSecret?: string /** Console origin used for widget deep links; defaults to the public website. */ consoleUrl?: string endpoint?: string @@ -52,6 +58,9 @@ export type TransloaditMcpServerOptions = { const defaultConsoleUrl = 'https://transloadit.com' +/** Header that carries `upstreamSecret` on API2 calls made with a forwarded bearer token. */ +export const upstreamSecretHeader = 'Transloadit-Mcp-Upstream' + type LintIssueOutput = { path: string message: string @@ -440,6 +449,9 @@ const createLiveClient = ( endpoint: options.endpoint, clientName: getClientName(options), followRedirects: false, + extraHeaders: options.upstreamSecret + ? { [upstreamSecretHeader]: options.upstreamSecret } + : undefined, }), } } diff --git a/packages/mcp-server/test/unit/upstream-secret.test.ts b/packages/mcp-server/test/unit/upstream-secret.test.ts new file mode 100644 index 00000000..f36977ee --- /dev/null +++ b/packages/mcp-server/test/unit/upstream-secret.test.ts @@ -0,0 +1,125 @@ +import type { AddressInfo } from 'node:net' + +import type { TransloaditMcpHttpOptions } from '../../src/http.ts' + +import { createServer } from 'node:http' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' +import { redactForLog } from '../../src/logger.ts' +import { upstreamSecretHeader } from '../../src/server.ts' + +const upstreamSecret = 'upstream-s3cr3t' +const endpoint = 'https://api2.transloadit.com' + +describe('upstream secret header', () => { + const serverOptions: TransloaditMcpHttpOptions = { metricsPath: false } + const handler = createTransloaditMcpHttpHandler(serverOptions) + const httpServer = createServer((req, res) => { + void handler(req, res) + }) + let url: URL + let client: Client + + const connect = async (headers: Record = {}): Promise => { + client = new Client({ name: 'upstream-secret', version: '1.0.0' }) + await client.connect(new StreamableHTTPClientTransport(url, { requestInit: { headers } })) + } + + const listTemplates = async (): Promise => { + const result = await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + return result.structuredContent + } + + beforeEach(async () => { + delete serverOptions.authKey + delete serverOptions.authSecret + delete serverOptions.upstreamSecret + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + url = new URL(`http://127.0.0.1:${port}/mcp`) + }) + + afterEach(async () => { + await client?.close() + await handler.close() + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ) + nock.cleanAll() + }) + + it('sends the header with a forwarded bearer token in hosted mode', async () => { + serverOptions.upstreamSecret = upstreamSecret + const api = nock(endpoint, { + reqheaders: { + authorization: 'Bearer forwarded-oauth-token', + [upstreamSecretHeader.toLowerCase()]: upstreamSecret, + }, + }) + .get('/templates') + .query(true) + .reply(200, { items: [], count: 0 }) + await connect({ Authorization: 'Bearer forwarded-oauth-token' }) + + await expect(listTemplates()).resolves.toMatchObject({ status: 'ok', templates: [] }) + expect(api.isDone()).toBe(true) + }) + + it('omits the header when no secret is configured', async () => { + const api = nock(endpoint, { badheaders: [upstreamSecretHeader.toLowerCase()] }) + .get('/templates') + .query(true) + .reply(200, { items: [], count: 0 }) + await connect({ Authorization: 'Bearer forwarded-oauth-token' }) + + await expect(listTemplates()).resolves.toMatchObject({ status: 'ok' }) + expect(api.isDone()).toBe(true) + }) + + it('omits the header for self-hosted key/secret calls even when configured', async () => { + serverOptions.upstreamSecret = upstreamSecret + serverOptions.authKey = 'key' + serverOptions.authSecret = 'secret' + const api = nock(endpoint, { badheaders: [upstreamSecretHeader.toLowerCase()] }) + .get('/templates') + .query(true) + .reply(200, { items: [], count: 0 }) + await connect() + + await expect(listTemplates()).resolves.toMatchObject({ status: 'ok' }) + expect(api.isDone()).toBe(true) + }) + + it('keeps the secret out of tool errors and the server card', async () => { + serverOptions.upstreamSecret = upstreamSecret + nock(endpoint).get('/templates').query(true).reply(500, { error: 'SERVER_ERROR' }) + await connect({ Authorization: 'Bearer forwarded-oauth-token' }) + + const result = await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + expect(JSON.stringify(result)).not.toContain(upstreamSecret) + + const card = await fetch(new URL('/.well-known/mcp/server-card.json', url)) + expect(await card.text()).not.toContain(upstreamSecret) + }) +}) + +describe('redactForLog', () => { + it('scrubs the upstream secret header even when the value was not listed', () => { + const line = `request failed headers={"Transloadit-Mcp-Upstream":"${upstreamSecret}","Authorization":"Bearer abc"}` + const redacted = redactForLog(line, []) + expect(redacted).not.toContain(upstreamSecret) + expect(redacted).toContain('Transloadit-Mcp-Upstream') + expect(redacted).toContain('Bearer [redacted]') + }) + + it('scrubs listed secrets anywhere in the message', () => { + expect(redactForLog(`boom ${upstreamSecret} boom`, [upstreamSecret])).toBe( + 'boom [redacted] boom', + ) + }) +}) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index c1516cc0..328f280c 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -459,6 +459,11 @@ type BaseOptions = { followRedirects?: boolean validateResponses?: boolean clientName?: string + /** + * Fixed headers sent with every API request, for trusted relays such as the Transloadit-hosted + * MCP service that must identify itself to API2 next to a forwarded bearer token. + */ + extraHeaders?: Record } export type Options = BaseOptions & (AuthKeySecret | AuthToken) @@ -482,6 +487,8 @@ export class Transloadit { private _clientName: string + #extraHeaders: Record + private _lastUsedAssemblyUrl = '' private _validateResponses = false @@ -514,6 +521,7 @@ export class Transloadit { this._maxRetries = opts.maxRetries != null ? opts.maxRetries : 5 this._defaultTimeout = opts.timeout != null ? opts.timeout : 60000 this._clientName = opts.clientName?.trim() || `node-sdk:${version}` + this.#extraHeaders = { ...opts.extraHeaders } // Passed on to got https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md this._gotRetry = opts.gotRetry != null ? opts.gotRetry : { limit: 0 } @@ -1665,6 +1673,7 @@ export class Transloadit { 'Transloadit-Client': this._clientName, 'User-Agent': undefined, // Remove got's user-agent ...(this._authToken ? { Authorization: `Bearer ${this._authToken}` } : {}), + ...this.#extraHeaders, ...headers, }, responseType: 'json', diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index b38aac3a..0fb1a85c 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -409,6 +409,29 @@ describe('Transloadit', () => { expect.objectContaining({ headers: { 'Transloadit-Client': 'mcp-server:1.2.3' } }), ) }) + + it('should send extraHeaders next to the bearer token on every request', async () => { + const client = new Transloadit({ + authToken: 'forwarded-token', + extraHeaders: { 'Transloadit-Mcp-Upstream': 'shared-secret' }, + }) + + const get = mockGot('get') + + const url = '/some-url' + // @ts-expect-error This tests private internals + await client._remoteJson({ url, method: 'get', isTrustedUrl: true }) + + expect(get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer forwarded-token', + 'Transloadit-Mcp-Upstream': 'shared-secret', + }), + }), + ) + }) }) describe('getSignedSmartCDNUrl', () => { From 824cc35a111bf4f56560e2059175c3851fd75e8c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 30 Sep 2026 23:35:01 +0200 Subject: [PATCH 07/27] Document the node dist copy and paired dependency bump for devdock Co-Authored-By: Claude Fable 5.1 --- docs/prompts/2026-09-30-mcp-oauth-discovery.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/prompts/2026-09-30-mcp-oauth-discovery.md b/docs/prompts/2026-09-30-mcp-oauth-discovery.md index e0b376e9..f9b95bc1 100644 --- a/docs/prompts/2026-09-30-mcp-oauth-discovery.md +++ b/docs/prompts/2026-09-30-mcp-oauth-discovery.md @@ -77,9 +77,18 @@ from the worktree's `api2/node_modules/@transloadit/mcp-server`. To test this br cd ~/code/node-sdk && corepack yarn build rm -rf ~/code/api2-clone-1/api2/node_modules/@transloadit/mcp-server/dist cp -r packages/mcp-server/dist ~/code/api2-clone-1/api2/node_modules/@transloadit/mcp-server/dist +# The MCP server relies on the `extraHeaders` client option that only exists in this branch's +# @transloadit/node; the published 4.14.0 in API2's node_modules silently drops the +# `Transloadit-Mcp-Upstream` header, so copy that dist too. +rm -rf ~/code/api2-clone-1/api2/node_modules/@transloadit/node/dist +cp -r packages/node/dist ~/code/api2-clone-1/api2/node_modules/@transloadit/node/dist cd ~/code/api2-clone-1 && core/bin/devdock.ts --app api2 restart -s mcp-server ``` +The service also needs `TRANSLOADIT_MCP_RESOURCE_METADATA_URL`, `TRANSLOADIT_MCP_UPSTREAM_SECRET` +(matching API2's expected value) and `TRANSLOADIT_ENDPOINT` in its environment; api2's service +config on `agent/mcp-oauth-authcode` sets them. + Verified on devdock (2026-09-30) with this branch's `dist` and the api2 branch's service environment: `POST /mcp` without a token returns `401` with `WWW-Authenticate: Bearer resource_metadata="https://api2-devdock.transloadit.dev/.well-known/oauth-protected-resource/mcp"`, @@ -89,4 +98,6 @@ at `/.well-known/mcp/server-card.json` is rendered by the API2 process from its package, so it only picks up the new tools and OAuth schemes after API2 restarts with the bumped dependency and passes `resourceMetadataUrl` to `buildServerCard()`. -Bump the dependency in API2 once the package is published from this branch. +Once published from this branch, API2 must bump `@transloadit/node` (patch, brings `extraHeaders`) +and `@transloadit/mcp-server` (minor) together; bumping only the MCP server leaves the upstream +header unsent. From b6cde6c69d9442115c0d0495708d92077d43d64b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 1 Oct 2026 22:31:30 +0200 Subject: [PATCH 08/27] Fix Codex discovery, MCP Apps handshake, CORS, result CSP and key signing QA (session 2fe9f3b7) found five MCP server gaps: - Hosted mode answered a bare `GET /mcp` with the friendly 200, so Codex never saw the OAuth challenge. Every unauthenticated hosted request now gets the 401 challenge; the body keeps `name`/`status`/`docs`. Self-hosted and unauthenticated deployments keep the 200. - The widget sent `clientInfo` in `ui/initialize`; MCP Apps 2026-01-26 hosts require `appInfo` and rejected the handshake. The widget now matches the ext-apps wire format, answers `ping` and `ui/resource-teardown`, shows tool-input progress, cancellations and failed tool calls, and stops waiting when initialization is rejected. Link clicks no longer call `preventDefault()` after an await. - CORS preflights now allow `Mcp-Protocol-Version` (and expose `WWW-Authenticate`), so browser hosts such as the ext-apps basic host can connect. - The widget CSP adds `https://*.r2.dev` result buckets; `TRANSLOADIT_MCP_RESULT_DOMAINS` / `resultDomains` override it. - `TRANSLOADIT_SIGNATURE_ALGORITHM` / `signatureAlgorithm` (sha1, sha256, sha384) is passed to the SDK, so Console keys pinned to sha256 work; `INVALID_SIGNATURE` maps to an actionable hint. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/mcp-oauth-discovery.md | 7 + packages/mcp-server/README.md | 21 +- packages/mcp-server/package.json | 1 + packages/mcp-server/src/cli.ts | 36 ++++ packages/mcp-server/src/express.ts | 25 ++- packages/mcp-server/src/http-helpers.ts | 45 ++++- .../mcp-server/src/http-request-handler.ts | 19 +- packages/mcp-server/src/http.ts | 18 +- packages/mcp-server/src/server.ts | 46 ++++- .../src/ui/assembly-result-widget.ts | 147 ++++++++++---- .../mcp-server/test/unit/cli-config.test.ts | 57 ++++++ .../mcp-server/test/unit/hosted-auth.test.ts | 67 ++++++- .../test/unit/signature-algorithm.test.ts | 81 ++++++++ .../mcp-server/test/unit/tool-surface.test.ts | 38 +++- .../test/unit/widget-handshake.test.ts | 186 ++++++++++++++++++ .../test/unit/test-transloadit-client.test.ts | 18 ++ yarn.lock | 1 + 17 files changed, 710 insertions(+), 103 deletions(-) create mode 100644 packages/mcp-server/test/unit/cli-config.test.ts create mode 100644 packages/mcp-server/test/unit/signature-algorithm.test.ts create mode 100644 packages/mcp-server/test/unit/widget-handshake.test.ts diff --git a/.changeset/mcp-oauth-discovery.md b/.changeset/mcp-oauth-discovery.md index 25dda9c3..bbef200a 100644 --- a/.changeset/mcp-oauth-discovery.md +++ b/.changeset/mcp-oauth-discovery.md @@ -20,3 +20,10 @@ connector requirements. - MCP Apps result widget `ui://transloadit/assembly-result` with previews, download links and a Save as Template shortcut, linked from the Assembly tools with `_meta.ui.resourceUri`. - `plugin.json`, `mcp.json` and `.codex-plugin/plugin.json` describe the ChatGPT and Codex plugin. +- Hosted mode also challenges bare `GET /mcp` probes (Codex discovers OAuth from them); CORS now + allows `Mcp-Protocol-Version` so browser hosts can connect. +- Self-hosted servers sign with `TRANSLOADIT_SIGNATURE_ALGORITHM` (`sha1`, `sha256` or `sha384`), + so Console keys that require `sha256` work; mismatches return an actionable + `mcp_invalid_signature` hint. +- The widget speaks the MCP Apps `2026-01-26` handshake (`appInfo`), shows failed tool calls, and + allows `https://*.r2.dev` result URLs; `TRANSLOADIT_MCP_RESULT_DOMAINS` overrides its CSP. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 9ed22d0b..541d4785 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -123,6 +123,11 @@ npm install @transloadit/mcp-server TRANSLOADIT_KEY=MY_AUTH_KEY TRANSLOADIT_SECRET=MY_SECRET_KEY npx -y @transloadit/mcp-server stdio ``` +Auth Keys sign with one HMAC algorithm. Keys created in the Console with "Allow signing Smart CDN +URLs" (the default) require `sha256`, so add `TRANSLOADIT_SIGNATURE_ALGORITHM=sha256`, as the +Console's snippet for the key shows. Other keys use the default `sha384`. A mismatch fails with +`mcp_invalid_signature`, and its hint names the algorithm the key requires. + ### HTTP ```bash @@ -273,6 +278,10 @@ Allowlist tools in `~/.gemini/settings.json`: `WWW-Authenticate: Bearer resource_metadata="https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp"`. MCP clients follow that document to API2's authorization server (authorization code + PKCE, Dynamic Client Registration or Client ID Metadata Documents). +- This includes a bare `GET /mcp`, because some clients (Codex) discover the authorization server + from that probe. Directory health checks therefore see `401` instead of `200`; the JSON body + still carries `name`, `status` and `docs`. Self-hosted and unauthenticated deployments keep + answering the bare `GET` with `200`. - Bearer tokens (OAuth or minted with `--aud mcp`) are forwarded to API2, which verifies them on every call. A rejected or expired token yields a tool result with `isError` and `_meta["mcp/www_authenticate"]`, so ChatGPT and Claude prompt you to reconnect. @@ -297,11 +306,15 @@ Allowlist tools in `~/.gemini/settings.json`: - `TRANSLOADIT_KEY` - `TRANSLOADIT_SECRET` +- `TRANSLOADIT_SIGNATURE_ALGORITHM` (optional, `sha1`, `sha256` or `sha384`, default `sha384`; + must match the Auth Key) - `TRANSLOADIT_MCP_TOKEN` - `TRANSLOADIT_MCP_RESOURCE_METADATA_URL` (hosted mode: protected-resource metadata URL to advertise in `401` challenges) - `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`) - `TRANSLOADIT_MCP_CONSOLE_URL` (optional, default `https://transloadit.com`; Console origin for widget deep links) - `TRANSLOADIT_ENDPOINT` (optional, default `https://api2.transloadit.com`) @@ -316,7 +329,7 @@ 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` and `consoleUrl`. +`allowedOrigins`, `resourceMetadataUrl`, `signatureAlgorithm`, `resultDomains` and `consoleUrl`. ## Tool surface @@ -347,8 +360,10 @@ credentials, derived from the Workspace's own Assemblies or Templates. `transloadit_create_assembly` and `transloadit_wait_for_assembly` link the MCP Apps resource `ui://transloadit/assembly-result` (`_meta.ui.resourceUri`, also `_meta["openai/outputTemplate"]`). Hosts that support MCP Apps render each Step's results with image, video and audio previews, -download links, an "Open in Console" link and a "Save as Template" shortcut. The widget only needs -`https://*.transloadit.com` and `https://*.transloadit.net` in its CSP. +download links, an "Open in Console" link and a "Save as Template" shortcut. It speaks the MCP Apps +`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`. ## Input files diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index c664623f..6cb4b3f9 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -71,6 +71,7 @@ "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.8.0", + "happy-dom": "^20.9.0", "nock": "^14.0.15" }, "mcpName": "io.github.transloadit/mcp-server" diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 381d8ad9..4cc81854 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import type { McpSignatureAlgorithm } from './server.ts' + import { readFile } from 'node:fs/promises' import { createServer } from 'node:http' @@ -7,6 +9,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { createTransloaditMcpHttpHandler, createTransloaditMcpServer } from './index.ts' import { buildRedactor, getLogger } from './logger.ts' +import { signatureAlgorithmSchema } from './server.ts' const printHelp = (): void => { process.stdout.write(`transloadit-mcp @@ -18,9 +21,11 @@ Usage: Environment: TRANSLOADIT_KEY TRANSLOADIT_SECRET + TRANSLOADIT_SIGNATURE_ALGORITHM (sha1, sha256 or sha384; must match the Auth Key) TRANSLOADIT_MCP_TOKEN TRANSLOADIT_MCP_RESOURCE_METADATA_URL TRANSLOADIT_MCP_UPSTREAM_SECRET + TRANSLOADIT_MCP_RESULT_DOMAINS (comma-separated origins for result previews) TRANSLOADIT_MCP_CONSOLE_URL TRANSLOADIT_ENDPOINT TRANSLOADIT_MCP_METRICS_PATH @@ -84,6 +89,26 @@ const parseArgs = (args: string[]): { command: string; config: CliConfig } => { return { command, config } } +/** Reads the key/secret signature algorithm; an unknown value would only fail later per call. */ +const parseSignatureAlgorithm = (value: unknown): McpSignatureAlgorithm | undefined => { + if (value === undefined || value === '') return undefined + const parsed = signatureAlgorithmSchema.safeParse(value) + if (!parsed.success) { + throw new Error('TRANSLOADIT_SIGNATURE_ALGORITHM must be one of sha1, sha256 or sha384.') + } + return parsed.data +} + +/** Accepts a JSON array (config file) or a comma-separated string (environment). */ +const parseResultDomains = (value: unknown): string[] | undefined => { + const entries = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [] + const domains = entries + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean) + return domains.length > 0 ? domains : undefined +} + const isLocalHost = (host: string | undefined): boolean => host === '127.0.0.1' || host === 'localhost' || host === '::1' @@ -138,6 +163,12 @@ const main = async (): Promise => { const consoleUrl = (fileConfig.consoleUrl ?? process.env.TRANSLOADIT_MCP_CONSOLE_URL) as | string | undefined + const signatureAlgorithm = parseSignatureAlgorithm( + fileConfig.signatureAlgorithm ?? process.env.TRANSLOADIT_SIGNATURE_ALGORITHM, + ) + const resultDomains = parseResultDomains( + fileConfig.resultDomains ?? process.env.TRANSLOADIT_MCP_RESULT_DOMAINS, + ) 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. @@ -155,6 +186,8 @@ const main = async (): Promise => { mcpToken, resourceMetadataUrl, upstreamSecret, + signatureAlgorithm, + resultDomains, consoleUrl, allowedOrigins: fileConfig.allowedOrigins as string[] | undefined, allowedHosts: fileConfig.allowedHosts as string[] | undefined, @@ -189,7 +222,10 @@ const main = async (): Promise => { const server = createTransloaditMcpServer({ authKey: process.env.TRANSLOADIT_KEY, authSecret: process.env.TRANSLOADIT_SECRET, + signatureAlgorithm: parseSignatureAlgorithm(process.env.TRANSLOADIT_SIGNATURE_ALGORITHM), + resultDomains: parseResultDomains(process.env.TRANSLOADIT_MCP_RESULT_DOMAINS), endpoint: process.env.TRANSLOADIT_ENDPOINT, + consoleUrl: process.env.TRANSLOADIT_MCP_CONSOLE_URL, clientSuffix: process.env.TRANSLOADIT_CLIENT_SUFFIX, }) const transport = new StdioServerTransport() diff --git a/packages/mcp-server/src/express.ts b/packages/mcp-server/src/express.ts index c9eb5efd..0f4d9946 100644 --- a/packages/mcp-server/src/express.ts +++ b/packages/mcp-server/src/express.ts @@ -5,6 +5,7 @@ import express from 'express' import { applyCorsHeaders, + corsAllowHeaders, isBasicAuthorized, rejectMissingBearerToken, resolveAllowedOrigins, @@ -37,10 +38,7 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress const sendServerCard = (res: express.Response, includeBody: boolean) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization,Content-Type,Mcp-Session-Id,Last-Event-ID', - ) + res.setHeader('Access-Control-Allow-Headers', corsAllowHeaders) res.setHeader('Content-Type', 'application/json; charset=utf-8') res.setHeader('Cache-Control', 'public, max-age=3600') res.setHeader('X-Content-Type-Options', 'nosniff') @@ -54,10 +52,7 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress router.options(serverCardPath, (_req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization,Content-Type,Mcp-Session-Id,Last-Event-ID', - ) + res.setHeader('Access-Control-Allow-Headers', corsAllowHeaders) res.status(204).end() }) @@ -70,8 +65,14 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress }) router.all(routePath, async (req: express.Request, res: express.Response) => { - if (hostedOrigins && !applyCorsHeaders(req, res, hostedOrigins)) { - return + if (hostedOrigins) { + if (!applyCorsHeaders(req, res, hostedOrigins)) return + if (req.method === 'OPTIONS') { + res.status(204).end() + return + } + // Any unauthenticated method gets the OAuth challenge, so GET-probing clients find API2. + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) return } if (req.method !== 'POST') { @@ -83,10 +84,6 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress return } - if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { - return - } - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, allowedOrigins: options.allowedOrigins, diff --git a/packages/mcp-server/src/http-helpers.ts b/packages/mcp-server/src/http-helpers.ts index 9ca4814c..f51ae675 100644 --- a/packages/mcp-server/src/http-helpers.ts +++ b/packages/mcp-server/src/http-helpers.ts @@ -151,12 +151,40 @@ export const applyCorsHeaders = ( } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS') - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization,Content-Type,Mcp-Session-Id,Last-Event-ID', - ) - res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id,WWW-Authenticate') + res.setHeader('Access-Control-Allow-Headers', corsAllowHeaders) + res.setHeader('Access-Control-Expose-Headers', corsExposeHeaders) + + return true +} + +/** + * Request headers browser MCP clients send (Streamable HTTP adds `Mcp-Protocol-Version` after + * initialization); preflights that omit one block the client entirely. + */ +export const corsAllowHeaders = + 'Authorization,Content-Type,Mcp-Protocol-Version,Mcp-Session-Id,Last-Event-ID' +/** Response headers browser clients must read: the session id and the OAuth challenge. */ +export const corsExposeHeaders = 'Mcp-Session-Id,WWW-Authenticate' + +/** Human-readable status served on bare GETs and kept in the hosted 401 body. */ +export const serverInfo = { + name: 'Transloadit MCP Server', + status: 'ok', + docs: 'https://transloadit.com/docs/sdks/mcp-server/', +} + +/** + * Bare GETs without the SSE Accept header are not valid MCP requests (Streamable HTTP requires + * `Accept: text/event-stream` for GET). Answer with a friendly status so directory health probes + * (Glama, uptime monitors) see a 200 instead of the SDK's opaque 406. Returns `true` when sent. + */ +export const sendServerInfoForBareGet = (req: IncomingMessage, res: ServerResponse): boolean => { + const accept = req.headers.accept ?? '' + if (req.method !== 'GET' || accept.includes('text/event-stream')) return false + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(serverInfo)) return true } @@ -199,8 +227,10 @@ export const rejectMissingMcpToken = ( /** * Hosted policy: a bearer token only has to be present, because API2 verifies it on every - * forwarded call. Without one, the 401 points OAuth clients at the protected-resource metadata. - * Returns `true` when the 401 was already sent. + * forwarded call. Without one, any request (bare GET probes included, since clients such as Codex + * discover the authorization server from an unauthenticated GET) gets a 401 that points OAuth + * clients at the protected-resource metadata. The body keeps the friendly server status for + * humans. Returns `true` when the 401 was already sent. */ export const rejectMissingBearerToken = ( req: IncomingMessage, @@ -213,6 +243,7 @@ export const rejectMissingBearerToken = ( res.setHeader('Content-Type', 'application/json') res.end( JSON.stringify({ + ...serverInfo, error: 'unauthorized', error_description: 'This endpoint requires an OAuth bearer token. Discover the authorization server through the resource_metadata URL in the WWW-Authenticate header.', diff --git a/packages/mcp-server/src/http-request-handler.ts b/packages/mcp-server/src/http-request-handler.ts index eda66f51..ff2fb4aa 100644 --- a/packages/mcp-server/src/http-request-handler.ts +++ b/packages/mcp-server/src/http-request-handler.ts @@ -10,6 +10,7 @@ import { rejectMissingBearerToken, rejectMissingMcpToken, resolveAllowedOrigins, + sendServerInfoForBareGet, } from './http-helpers.ts' import { buildRedactor, getLogger } from './logger.ts' @@ -59,25 +60,11 @@ export const createMcpRequestHandler = ( return } - // Bare GETs without the SSE Accept header are not valid MCP requests (the - // Streamable HTTP spec requires Accept: text/event-stream for GET). Return - // a friendly JSON status so directory health-probes (Glama, uptime monitors) - // see a 200 instead of the SDK's opaque 406. - const accept = req.headers.accept ?? '' - if (req.method === 'GET' && !accept.includes('text/event-stream')) { - res.statusCode = 200 - res.setHeader('Content-Type', 'application/json') - res.end( - JSON.stringify({ - name: 'Transloadit MCP Server', - status: 'ok', - docs: 'https://transloadit.com/docs/sdks/mcp-server/', - }), - ) + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { return } - if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { + if (sendServerInfoForBareGet(req, res)) { return } diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 9ceb26cc..e2bfb301 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -14,6 +14,7 @@ import { rejectMissingBearerToken, rejectMissingMcpToken, resolveAllowedOrigins, + sendServerInfoForBareGet, } from './http-helpers.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' import { createTransloaditMcpServer } from './server.ts' @@ -152,24 +153,11 @@ export function createTransloaditMcpHttpHandler( return } - // Bare GETs without the SSE Accept header are not valid MCP requests (the - // Streamable HTTP spec requires Accept: text/event-stream for GET). Return - // a friendly JSON status so directory health-probes see a 200 instead of 406. - const accept = req.headers.accept ?? '' - if (req.method === 'GET' && !accept.includes('text/event-stream')) { - res.statusCode = 200 - res.setHeader('Content-Type', 'application/json') - res.end( - JSON.stringify({ - name: 'Transloadit MCP Server', - status: 'ok', - docs: 'https://transloadit.com/docs/sdks/mcp-server/', - }), - ) + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { return } - if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) { + if (sendServerInfoForBareGet(req, res)) { return } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 541bae4f..49af0975 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -47,6 +47,14 @@ export type TransloaditMcpServerOptions = { * sent as `Transloadit-Mcp-Upstream` next to a forwarded bearer token and never with key/secret. */ upstreamSecret?: string + /** + * HMAC algorithm for key/secret signatures (`TRANSLOADIT_SIGNATURE_ALGORITHM`). Must match the + * Auth Key's `signature_algo`; Console keys that may sign Smart CDN URLs require `sha256`. + * 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`). */ + resultDomains?: string[] /** Console origin used for widget deep links; defaults to the public website. */ consoleUrl?: string endpoint?: string @@ -58,6 +66,11 @@ export type TransloaditMcpServerOptions = { const defaultConsoleUrl = 'https://transloadit.com' +/** Signature algorithms API2 accepts for Auth Key signatures. */ +export const signatureAlgorithmSchema = z.enum(['sha1', 'sha256', 'sha384']) + +export type McpSignatureAlgorithm = z.infer + /** Header that carries `upstreamSecret` on API2 calls made with a forwarded bearer token. */ export const upstreamSecretHeader = 'Transloadit-Mcp-Upstream' @@ -448,6 +461,7 @@ const createLiveClient = ( authSecret: options.authSecret, endpoint: options.endpoint, clientName: getClientName(options), + signatureAlgorithm: options.signatureAlgorithm, followRedirects: false, extraHeaders: options.upstreamSecret ? { [upstreamSecretHeader]: options.upstreamSecret } @@ -466,6 +480,7 @@ const createLiveClient = ( authSecret: options.authSecret, endpoint: options.endpoint, clientName: getClientName(options), + signatureAlgorithm: options.signatureAlgorithm, followRedirects: false, }), } @@ -532,9 +547,38 @@ const toAuthRejection = ( hint: 'Reconnect your Transloadit account and grant the requested access.', }) } + if (error instanceof ApiError && error.code === 'INVALID_SIGNATURE') { + return buildSignatureError(error) + } return undefined } +/** + * Key/secret signatures fail when the configured algorithm differs from the Auth Key's + * `signature_algo`; API2 names the required one, so the hint can say exactly what to set. + */ +const buildSignatureError = (error: ApiError): CallToolResult => { + const required = signatureAlgorithmSchema.safeParse( + /requires (sha\d+)/.exec(error.rawMessage ?? '')?.[1], + ) + // An error result, so tools with stricter output schemas (list_templates) can still return it. + return buildToolResponse( + { + status: 'error', + errors: [ + { + code: 'mcp_invalid_signature', + message: 'Transloadit rejected the request signature for this Auth Key.', + hint: required.success + ? `This Auth Key signs with ${required.data}: set TRANSLOADIT_SIGNATURE_ALGORITHM=${required.data} (or the signatureAlgorithm option) and restart the MCP server.` + : 'Check that TRANSLOADIT_SECRET and TRANSLOADIT_SIGNATURE_ALGORITHM match the Auth Key.', + }, + ], + }, + { isError: true }, + ) +} + const trimTrailingSlash = (value: string): string => value.replace(/\/$/, '') /** Widget-only context: whether the caller is signed in and where the Console deep links go. */ @@ -1425,7 +1469,7 @@ export const createTransloaditMcpServer = ( }, ) - registerAssemblyResultWidget(server) + registerAssemblyResultWidget(server, { resultDomains: options.resultDomains }) installToolListHandler(server, listedTools) 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 9d4df7b2..d4ac7250 100644 --- a/packages/mcp-server/src/ui/assembly-result-widget.ts +++ b/packages/mcp-server/src/ui/assembly-result-widget.ts @@ -8,8 +8,21 @@ export const assemblyResultWidgetUri = 'ui://transloadit/assembly-result' /** Mime type the MCP Apps spec requires for UI resources. */ export const assemblyResultWidgetMimeType = 'text/html;profile=mcp-app' -/** Origins that serve Assembly result files (temporary result buckets, demos, Console). */ -export const assemblyResultOrigins = ['https://*.transloadit.com', 'https://*.transloadit.net'] +/** + * 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`. + */ +export const defaultResultDomains = [ + 'https://*.transloadit.com', + 'https://*.transloadit.net', + 'https://*.r2.dev', +] + +/** MCP Apps protocol revision the widget speaks (ext-apps `LATEST_PROTOCOL_VERSION`). */ +export const widgetProtocolVersion = '2026-01-26' + +/** `appInfo` the widget announces in `ui/initialize`. */ +export const widgetAppInfo = { name: 'transloadit-assembly-result', version: packageJson.version } /** Result `_meta` key that carries widget-only context (never read by the model). */ export const widgetContextMetaKey = 'transloadit/widget' @@ -24,21 +37,23 @@ 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.' /** Resource `_meta` in both the MCP Apps form and the legacy ChatGPT aliases. */ -export const assemblyResultWidgetMeta = { +export const buildAssemblyResultWidgetMeta = ( + resultDomains: string[] = defaultResultDomains, +): Record => ({ ui: { csp: { - connectDomains: assemblyResultOrigins, - resourceDomains: assemblyResultOrigins, + connectDomains: resultDomains, + resourceDomains: resultDomains, }, prefersBorder: true, }, 'openai/widgetDescription': widgetDescription, 'openai/widgetCSP': { - connect_domains: assemblyResultOrigins, - resource_domains: assemblyResultOrigins, + connect_domains: resultDomains, + resource_domains: resultDomains, }, 'openai/widgetPrefersBorder': true, -} +}) /** * The widget document. Everything is inline (no external scripts) so it runs under the @@ -96,9 +111,11 @@ export const assemblyResultWidgetHtml = `