diff --git a/.changeset/mcp-oauth-discovery.md b/.changeset/mcp-oauth-discovery.md new file mode 100644 index 00000000..34ff0958 --- /dev/null +++ b/.changeset/mcp-oauth-discovery.md @@ -0,0 +1,50 @@ +--- +"@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`) requires `TRANSLOADIT_MCP_UPSTREAM_SECRET` + and refuses to start without it, so a missing production secret fails the deploy's health check + instead of every authenticated tool call. +- 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 (only Assembly creation is destructive) and per-tool `securitySchemes`, also in + `_meta.securitySchemes`, that match the deployment: `oauth2` with each tool's full scopes when + hosted, `noauth` where the server holds an Auth Key or a tool needs no account. Auth failures return `isError` results with + `_meta["mcp/www_authenticate"]` so hosts show their account-linking UI. +- Behavior change for self-hosted servers without credentials: account tools now return an + `isError` result with `mcp_missing_auth` and a hint naming `TRANSLOADIT_KEY`/`TRANSLOADIT_SECRET` + (`transloadit_list_templates` no longer adds an empty `templates` list to that error). +- Behavior change for Express mounts: explicit `allowedOrigins` are now enforced by the router + (wildcards `*.` and `:*` supported) even without DNS rebinding protection, so other browser + Origins get HTTP 403 and allowed ones receive CORS headers. +- `transloadit_create_assembly` with `expected_uploads` returns `upload_instructions`: per file the + tus endpoint, metadata and a credential-free `curl` command (tus creation-with-upload), so agents + can upload files that exist only in their sandbox, then call `transloadit_wait_for_assembly`. + `expected_uploads` now accepts at most 100; larger values are rejected before an Assembly is + created. +- 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. +- Hosted mode serves JSON responses and turns an upstream rejection of the forwarded token into + HTTP 401 (`invalid_token`) or 403 (`insufficient_scope` with the tool's scopes), so OAuth clients + refresh or re-scope instead of retrying a dead token. +- Request bodies are capped (1 MiB hosted, 10 MiB self-hosted, `maxRequestBodyBytes`) and larger + ones get HTTP 413 without being buffered. URL inputs the server downloads are capped + (`maxUrlDownloadBytes`, `urlDownloadTimeoutMs`), and hosted tokens are checked before downloading. +- 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/.changeset/mcp-upstream-header.md b/.changeset/mcp-upstream-header.md new file mode 100644 index 00000000..c3c2be66 --- /dev/null +++ b/.changeset/mcp-upstream-header.md @@ -0,0 +1,16 @@ +--- +"@transloadit/node": minor +"transloadit": minor +--- + +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. +Like `Authorization`, these headers are dropped when a redirect leaves the API origin. + +`listTemplates()` also accepts `fields`, for API2 columns such as `account_id` that the default +Template list omits. + +`prepareInputFiles()` accepts `maxUrlDownloadBytes` (a total for the call) and `urlDownloadTimeoutMs` +(one deadline per download, redirects included) to bound URL downloads and `beforeUrlDownload` to vouch for a requester before a file is fetched locally; its +download errors name only a URL's origin and path, never presigned query parameters. +`createAssembly()` accepts `onAssemblyCreated`, called once API2 accepted the creation request. 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..72219d0b --- /dev/null +++ b/docs/prompts/2026-09-30-mcp-oauth-discovery.md @@ -0,0 +1,125 @@ +# 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 `chatgpt-plugin-plan` (consent page at `/c/oauth/authorize`, QA scenario). + +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 +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) + +- [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. +- [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`. +- [x] Tool results that fail on auth carry `_meta["mcp/www_authenticate"]` with `error` and + `error_description` so ChatGPT shows the account-linking UI. +- [x] Origin validation on the hosted endpoint (allow `chatgpt.com`, `claude.ai`, `claude.com`, + Transloadit origins and loopback; reject others). +- [x] Every tool gets a `title` and accurate `readOnlyHint`, `destructiveHint`, `openWorldHint` + (`true` for URL imports). +- [x] Optional profile tool marked `_meta["openai/profile"]: true` returning a stable opaque + workspace id, so multi-account works in ChatGPT. +- [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) + +- [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. +- [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. +- [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`). +- [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. + +## QA follow-up (2026-10-01, session 2fe9f3b7) + +- [x] Hosted mode answers every unauthenticated request to `/mcp`, including a bare `GET` (Codex + probes with `Accept: */*`), with the `401` challenge; the body keeps `name`/`status`/`docs`. + Self-hosted and unauthenticated deployments keep the bare-`GET` `200`. +- [x] Widget handshake matches MCP Apps `2026-01-26` as implemented by ext-apps `82221c0c` + (`ui/initialize` with `appInfo`, param-less `ui/notifications/initialized`, `ping` and + `ui/resource-teardown` replies, tool-input/cancelled states, failed results shown). The + spec prose still shows `clientInfo`; the reference App and AppBridge use `appInfo`. +- [x] CORS allows `Mcp-Protocol-Version` and exposes `Mcp-Session-Id` and `WWW-Authenticate`. +- [x] Widget CSP adds `https://*.r2.dev`; `TRANSLOADIT_MCP_RESULT_DOMAINS` / `resultDomains` + override it. Previews retry briefly because R2 can lag the Assembly's completion. +- [x] `TRANSLOADIT_SIGNATURE_ALGORITHM` / `signatureAlgorithm` (`sha1`, `sha256`, `sha384`) reach + the SDK; Console keys with Smart CDN signing require `sha256`. `INVALID_SIGNATURE` returns + `mcp_invalid_signature` with a hint naming the required algorithm. +- Verified: devdock bare `GET /mcp` → `401` with the challenge; a self-hosted server with the QA + Console key lists templates once `TRANSLOADIT_SIGNATURE_ALGORITHM=sha256` is set; the widget + renders both previews in the ext-apps basic host + (`/tmp/mcp-oauth/runs/node-sdk-fixes/basic-host-widget.png`). The basic host build bakes its + sandbox port, so Content's `mcp-oauth-apps-host.ts --sandbox-port` has no effect with + `--skip-build`; use the default ports. + +## 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 `api2/node_modules/@transloadit/mcp-server`. To test this branch: + +```bash +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"`, +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()`. + +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. diff --git a/packages/mcp-server/.codex-plugin/plugin.json b/packages/mcp-server/.codex-plugin/plugin.json new file mode 100644 index 00000000..0fa40d7d --- /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. Connecting signs you in to your Transloadit Workspace through OAuth (a free Community plan is available); every tool, including Robot browsing and linting, runs on that connection. Agent Skills for these workflows are published at https://transloadit.com/.well-known/skills/index.json (source: https://github.com/transloadit/skills).", + "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..2930f29e 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -2,23 +2,132 @@ 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. + +The hosted endpoint asks you to sign in before any tool runs, Robot docs and linting included, +because MCP clients only start OAuth when the endpoint challenges them. Robot docs and linting ask +for no scopes. To use them without an account, run the server yourself (see Self-hosted). + +### 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 ``` -## Quick start (self-hosted, recommended) +### 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" +``` + +### 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 +``` + +## CI and headless agents + +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 -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. +Run the server where your agent runs, set `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET`, and the server +handles API auth automatically. -### Stdio (recommended) +### 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 ``` +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 @@ -26,7 +135,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 +175,11 @@ export TRANSLOADIT_MCP_TOKEN="$(openssl rand -hex 32)" npx -y @transloadit/mcp-server http --host 0.0.0.0 --port 5723 ``` -## Hosted endpoint +### Self-hosted client setup -If you cannot run `npx` where the agent runs, use the hosted endpoint: +Most self-hosted users add the server to their MCP client and let the client start it via stdio. -```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 - -Most users add the server to their MCP client and let the client start it automatically via stdio. - -### Claude Code +#### Claude Code ```bash claude mcp add --transport stdio transloadit \ @@ -103,15 +188,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 +206,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 +232,7 @@ Allowlist tools in `~/.gemini/settings.json`: } ``` -### Cursor +#### Cursor `~/.cursor/mcp.json`: @@ -174,7 +251,7 @@ Allowlist tools in `~/.gemini/settings.json`: } ``` -### OpenCode +#### OpenCode `~/.config/opencode/opencode.json`: @@ -193,27 +270,31 @@ 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). +- 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. +- Each tool declares `securitySchemes` (top level and in `_meta`). Because the hosted endpoint + challenges every unauthenticated request, all tools declare `oauth2` there; the Robot docs and + linting tools ask for no scopes. +- A rejected or expired key/secret on a self-hosted server is reported as + `mcp_credentials_rejected` without an OAuth challenge, since only an operator can fix it. +- 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. + Hosted mode refuses to start without it, because every authenticated call would fail. ### Self-hosted @@ -229,7 +310,17 @@ npx -y @transloadit/mcp-server stdio - `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`) - `TRANSLOADIT_MCP_METRICS_PATH` (optional, default `/metrics`) - `TRANSLOADIT_MCP_METRICS_USER` (optional) @@ -241,21 +332,50 @@ 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`, `signatureAlgorithm`, `resultDomains` 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 | Account | OAuth scopes | Notes | +| ---------------------------------------- | ------- | ------------------------------------ | ----------------------------------------------- | +| `transloadit_lint_assembly_instructions` | no | none | read-only | +| `transloadit_list_robots` | no | none | read-only | +| `transloadit_get_robot_help` | no | none | read-only | +| `transloadit_create_assembly` | yes | `assemblies:write`, `templates:read` | destructive, open-world (URL imports), widget | +| `transloadit_get_assembly_status` | yes | `assemblies:read` | read-only | +| `transloadit_wait_for_assembly` | yes | `assemblies:read` | read-only, result widget | +| `transloadit_list_templates` | yes | `templates:read` | read-only | +| `transloadit_get_profile` | yes | `assemblies:read`, `templates:read` | read-only, `_meta["openai/profile"]` | + +Every tool carries `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint` and +`openWorldHint` annotations. `transloadit_create_assembly` is the only destructive one: export +Robots such as `/s3/store` can overwrite files at their destination, so hosts should confirm it. + +`securitySchemes` depend on how the server runs: hosted (`TRANSLOADIT_MCP_RESOURCE_METADATA_URL`) +declares `oauth2` with the scopes above for every tool; a server holding `TRANSLOADIT_KEY` and +`TRANSLOADIT_SECRET` declares `noauth` everywhere; otherwise the account tools declare `oauth2` and +the others `noauth`. `TRANSLOADIT_MCP_TOKEN` and `TRANSLOADIT_MCP_RESOURCE_METADATA_URL` cannot be +combined. `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. 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 ```ts @@ -276,14 +396,57 @@ 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. + +### Files that exist only locally or in your sandbox + +If the file exists only locally or in your sandbox, call `transloadit_create_assembly` with +`expected_uploads` and run the returned `upload_instructions`; this needs outbound HTTPS from the +sandbox to Transloadit (Claude.ai: Settings → code execution network access must allow it). Hosts +such as Claude.ai do not hand chat attachments to connector tools, and base64 in tool arguments is +impractical for anything but tiny files, so the agent uploads the file itself: + +1. Call `transloadit_create_assembly` with your instructions and `expected_uploads: 1` (one per + file, at most 100 per call). The call returns right away, even with `wait_for_completion: true`, + because the Assembly now waits for those uploads. +2. Each `upload_instructions` entry has the tus endpoint, the tus metadata (`assembly_url`, + `fieldname`) and a ready-to-run `curl` command. Set `FILE` to the file's path and run it in a + bash shell; it prints `201` once the file is uploaded (tus creation-with-upload, one request). +3. Call `transloadit_wait_for_assembly` with the Assembly URL. + +The commands contain no credentials: the Assembly URL is the only capability, and it lets the holder +add files to that one Assembly while it waits for uploads. + ## Limits These limits apply to inline JSON/base64 payloads. For larger files, use a public URL or upload from your own machine with `npx -y @transloadit/node upload`. -- Hosted default request body limit: **1 MB** +- Hosted default request body limit: **1 MiB** - Hosted `maxBase64Bytes`: **512,000** decoded bytes -- Self-hosted default request body limit: **10 MB** (configurable) +- Self-hosted default request body limit: **10 MiB** + +Set `maxRequestBodyBytes` (handler option or JSON config key) to change the body limit; larger +requests get HTTP 413 and are not buffered. Express apps that install their own body parser set +its limit there. + +URL inputs that the server downloads (for templates that expect uploads) are capped at 1 GiB in +total per call and 10 minutes per download, redirects included; set `maxUrlDownloadBytes` and +`urlDownloadTimeoutMs` to change that. On the hosted +endpoint the caller's token is checked with API2 before anything is downloaded. ## URL inputs and template behavior @@ -329,7 +492,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 +552,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/assets/icon.png b/packages/mcp-server/assets/icon.png new file mode 100644 index 00000000..d7f2ae80 Binary files /dev/null and b/packages/mcp-server/assets/icon.png differ diff --git a/packages/mcp-server/assets/logo.png b/packages/mcp-server/assets/logo.png new file mode 100644 index 00000000..927df2b4 Binary files /dev/null and b/packages/mcp-server/assets/logo.png differ 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/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/plugin.json b/packages/mcp-server/plugin.json new file mode 100644 index 00000000..e6f00193 --- /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. Connecting signs you in to your Transloadit Workspace through OAuth (a free Community plan is available); every tool, including Robot browsing and linting, runs on that connection. Agent Skills for these workflows are published at https://transloadit.com/.well-known/skills/index.json (source: https://github.com/transloadit/skills).", + "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 } diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 2352ac0e..817f9870 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,7 +21,12 @@ 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 TRANSLOADIT_MCP_METRICS_USER @@ -81,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' @@ -128,10 +156,26 @@ 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 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 + 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 - 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 +184,15 @@ const main = async (): Promise => { endpoint, clientSuffix, mcpToken, + resourceMetadataUrl, + upstreamSecret, + // Validated by the handler, which refuses anything but positive integers. + maxRequestBodyBytes: fileConfig.maxRequestBodyBytes as number | undefined, + maxUrlDownloadBytes: fileConfig.maxUrlDownloadBytes as number | undefined, + urlDownloadTimeoutMs: fileConfig.urlDownloadTimeoutMs as number | undefined, + signatureAlgorithm, + resultDomains, + consoleUrl, allowedOrigins: fileConfig.allowedOrigins as string[] | undefined, allowedHosts: fileConfig.allowedHosts as string[] | undefined, enableDnsRebindingProtection: fileConfig.enableDnsRebindingProtection as boolean | undefined, @@ -173,7 +226,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() @@ -186,6 +242,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/express.ts b/packages/mcp-server/src/express.ts index b53d90af..f749fc43 100644 --- a/packages/mcp-server/src/express.ts +++ b/packages/mcp-server/src/express.ts @@ -1,10 +1,17 @@ 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, + assertHttpOptions, + corsAllowHeaders, + isBasicAuthorized, + rejectMissingBearerToken, + resolveAllowedOrigins, +} from './http-helpers.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' +import { createRequestTransport } from './request-transport.ts' import { createTransloaditMcpServer } from './server.ts' import { buildServerCard, serverCardPath } from './server-card.ts' @@ -13,23 +20,27 @@ export type TransloaditMcpExpressOptions = TransloaditMcpHttpOptions & { } export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpressOptions = {}) { + assertHttpOptions(options) const router = express.Router() const routePath = options.path ?? '/mcp' const metricsPath = options.metricsPath === false ? undefined : (options.metricsPath ?? '/metrics') const metricsAuth = options.metricsAuth + // Explicit `allowedOrigins` or hosted mode add an Origin policy here; embedders own CORS otherwise. + const allowedOrigins = resolveAllowedOrigins(options) 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) => { 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') @@ -43,10 +54,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() }) @@ -59,6 +67,17 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress }) router.all(routePath, async (req: express.Request, res: express.Response) => { + if (allowedOrigins) { + if (!applyCorsHeaders(req, res, allowedOrigins)) return + if (req.method === 'OPTIONS') { + res.status(204).end() + return + } + } + + // Any unauthenticated hosted method gets the OAuth challenge, so GET-probing clients find API2. + if (rejectMissingBearerToken(req, res, options.resourceMetadataUrl)) return + if (req.method !== 'POST') { res.status(405).json({ jsonrpc: '2.0', @@ -68,12 +87,7 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress return } - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - allowedOrigins: options.allowedOrigins, - allowedHosts: options.allowedHosts, - enableDnsRebindingProtection: options.enableDnsRebindingProtection, - }) + const { transport, handle } = createRequestTransport(options) const server = createTransloaditMcpServer(options) res.on('close', () => { void transport.close() @@ -81,7 +95,7 @@ export function createTransloaditMcpExpressRouter(options: TransloaditMcpExpress }) await server.connect(transport) - await transport.handleRequest(req, res, req.body) + await handle(req, res, req.body) }) if (metricsPath) { diff --git a/packages/mcp-server/src/http-helpers.ts b/packages/mcp-server/src/http-helpers.ts index 6c3f62dc..826ca499 100644 --- a/packages/mcp-server/src/http-helpers.ts +++ b/packages/mcp-server/src/http-helpers.ts @@ -2,6 +2,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import { timingSafeEqual } from 'node:crypto' +import { assertRequestBodyLimit, assertServerOptions } from './options.ts' + export const parsePathname = (url: string | undefined, fallback: string): string => { try { return new URL(url ?? fallback, 'http://localhost').pathname @@ -65,6 +67,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 +141,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 @@ -88,11 +153,181 @@ 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-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 +} + +/** + * `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 } + /** Scopes the client should request again (RFC 6750 `scope`, used to re-scope on 403). */ + scopes?: 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('"', "'")}"`, + ) + } + if (options.scopes && options.scopes.length > 0) { + parts.push(`scope="${options.scopes.join(' ')}"`) + } + return parts.length > 0 ? `Bearer ${parts.join(', ')}` : 'Bearer' +} + +/** + * Request body limits the README documents. Hosted requests only carry JSON-RPC and small base64 + * payloads, and pass the bearer gate before API2 has checked the token, so they get the smaller one. + */ +export const resolveMaxRequestBodyBytes = (options: { + maxRequestBodyBytes?: number + resourceMetadataUrl?: string +}): number => options.maxRequestBodyBytes ?? (options.resourceMetadataUrl ? 1 : 10) * 1024 * 1024 + +/** + * Reads a request body up to `maxBytes`. Returns `undefined` once it is larger; the rest is drained + * without being kept, so an oversized body cannot exhaust memory. + */ +export const readBodyWithinLimit = ( + req: IncomingMessage, + maxBytes: number, +): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let size = 0 + let tooLarge = false + req.on('data', (chunk: Buffer) => { + if (tooLarge) return + size += chunk.length + if (size > maxBytes) { + tooLarge = true + chunks.length = 0 + resolve(undefined) + return + } + chunks.push(chunk) + }) + req.on('end', () => { + if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8')) + }) + req.on('error', reject) + }) + +export const sendBodyTooLarge = (res: ServerResponse, maxBytes: number): void => { + res.statusCode = 413 + res.setHeader('Connection', 'close') + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: `Request body exceeds ${maxBytes} bytes.` }, + id: null, + }), ) - res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') +} + +/** + * Refuses HTTP options that would fail silently later. The static-token check runs first and + * would reject every OAuth token with a bare `Bearer` challenge, so hosted OAuth could never start. + */ +export const assertHttpOptions = (options: { + mcpToken?: string + resourceMetadataUrl?: string + upstreamSecret?: string + maxRequestBodyBytes?: unknown + maxUrlDownloadBytes?: unknown + urlDownloadTimeoutMs?: unknown +}): void => { + if (options.mcpToken && options.resourceMetadataUrl) { + throw new Error( + 'Configure either TRANSLOADIT_MCP_TOKEN (self-hosted) or TRANSLOADIT_MCP_RESOURCE_METADATA_URL (hosted OAuth), not both.', + ) + } + assertRequestBodyLimit(options.maxRequestBodyBytes) + // Also checked per server instance; repeated here so a bad config fails at startup. + assertServerOptions(options) +} +/** + * 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, 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, + 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({ + ...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.', + }), + ) return true } diff --git a/packages/mcp-server/src/http-request-handler.ts b/packages/mcp-server/src/http-request-handler.ts index 793d28f6..f968e594 100644 --- a/packages/mcp-server/src/http-request-handler.ts +++ b/packages/mcp-server/src/http-request-handler.ts @@ -3,7 +3,16 @@ 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, + assertHttpOptions, + normalizePath, + parsePathname, + rejectMissingBearerToken, + rejectMissingMcpToken, + resolveAllowedOrigins, + sendServerInfoForBareGet, +} from './http-helpers.ts' import { buildRedactor, getLogger } from './logger.ts' type PathPolicy = { @@ -14,6 +23,7 @@ type PathPolicy = { type RequestHandlerOptions = { allowedOrigins?: string[] mcpToken?: string + resourceMetadataUrl?: string path: PathPolicy logger?: SevLogger redactSecrets?: Array @@ -23,10 +33,12 @@ export const createMcpRequestHandler = ( transport: StreamableHTTPServerTransport, options: RequestHandlerOptions, ) => { + assertHttpOptions(options) const expectedPath = normalizePath(options.path.expectedPath) 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 +48,7 @@ export const createMcpRequestHandler = ( return } - if (!applyCorsHeaders(req, res, options.allowedOrigins)) { + if (!applyCorsHeaders(req, res, allowedOrigins)) { return } @@ -46,28 +58,15 @@ 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 } - // 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 (sendServerInfoForBareGet(req, res)) { return } diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index e3f551bd..9059ed79 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -2,18 +2,26 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { SevLogger } from '@transloadit/sev-logger' +import type { RequestTransport } from './request-transport.ts' import type { TransloaditMcpServerOptions } from './server.ts' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' - import { applyCorsHeaders, - isAuthorized, + assertHttpOptions, isBasicAuthorized, normalizePath, parsePathname, + readBodyWithinLimit, + rejectMissingBearerToken, + rejectMissingMcpToken, + resolveAllowedOrigins, + resolveMaxRequestBodyBytes, + sendBodyTooLarge, + sendServerInfoForBareGet, } from './http-helpers.ts' +import { parseJson } from './json.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' +import { createRequestTransport } from './request-transport.ts' import { createTransloaditMcpServer } from './server.ts' import { buildServerCard, serverCardPath } from './server-card.ts' @@ -25,6 +33,8 @@ export type TransloaditMcpHttpOptions = TransloaditMcpServerOptions & { path?: string metricsPath?: string | false metricsAuth?: { username: string; password: string } + /** Largest accepted request body; defaults to 1 MiB hosted and 10 MiB self-hosted. */ + maxRequestBodyBytes?: number // Ignored on purpose: the hosted HTTP server is stateless and does not mint session IDs. sessionIdGenerator?: (() => string) | undefined logger?: SevLogger @@ -39,41 +49,26 @@ export type TransloaditMcpHttpHandler = (( const defaultPath = '/mcp' -/** Read the full request body and JSON-parse it before handing it to the MCP transport. */ -function readJsonBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - req.on('data', (chunk: Buffer) => chunks.push(chunk)) - req.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf8') - if (!raw) { - resolve(undefined) - return - } - try { - resolve(JSON.parse(raw)) - } catch { - resolve(undefined) - } - }) - req.on('error', reject) - }) -} - export function createTransloaditMcpHttpHandler( options: TransloaditMcpHttpOptions = {}, ): TransloaditMcpHttpHandler { const activeRequests = new Set<{ - transport: StreamableHTTPServerTransport + transport: RequestTransport['transport'] server: Awaited> }>() + assertHttpOptions(options) const expectedPath = options.path ?? defaultPath 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 +126,7 @@ export function createTransloaditMcpHttpHandler( return } - if (!applyCorsHeaders(req, res, options.allowedOrigins)) { + if (!applyCorsHeaders(req, res, allowedOrigins)) { return } @@ -141,27 +136,15 @@ 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 } - // 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 (sendServerInfoForBareGet(req, res)) { return } @@ -178,13 +161,14 @@ export function createTransloaditMcpHttpHandler( return } - const parsedBody = await readJsonBody(req) - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - allowedOrigins: options.allowedOrigins, - allowedHosts: options.allowedHosts, - enableDnsRebindingProtection: options.enableDnsRebindingProtection, - }) + const maxBytes = resolveMaxRequestBodyBytes(options) + const rawBody = await readBodyWithinLimit(req, maxBytes) + if (rawBody === undefined) { + sendBodyTooLarge(res, maxBytes) + return + } + const parsedBody = parseJson(rawBody) + const { transport, handle } = createRequestTransport(options) const server = createTransloaditMcpServer(options) const activeRequest = { transport, server } activeRequests.add(activeRequest) @@ -197,7 +181,7 @@ export function createTransloaditMcpHttpHandler( await server.connect(transport) try { - await transport.handleRequest(req, res, parsedBody) + await handle(req, res, parsedBody) } catch { if (!res.headersSent) { res.statusCode = 500 diff --git a/packages/mcp-server/src/json.ts b/packages/mcp-server/src/json.ts new file mode 100644 index 00000000..3718ec2c --- /dev/null +++ b/packages/mcp-server/src/json.ts @@ -0,0 +1,13 @@ +/** A plain object; arrays are rejected because callers read named fields. */ +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** Parses JSON text, returning `undefined` for empty or invalid input instead of throwing. */ +export const parseJson = (text: string): unknown => { + if (!text) return undefined + try { + return JSON.parse(text) + } catch { + return undefined + } +} 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/options.ts b/packages/mcp-server/src/options.ts new file mode 100644 index 00000000..a670054f --- /dev/null +++ b/packages/mcp-server/src/options.ts @@ -0,0 +1,33 @@ +/** + * Limits also arrive from JSON config files, where a value such as `"1MB"` would silently disable + * a numeric comparison, so each one must be a positive integer. + */ +const assertPositiveInteger = (value: unknown, name: string, unit: string): void => { + if (value === undefined) return + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer number of ${unit}.`) + } +} + +/** Validates what every server instance relies on, however it is constructed. */ +export const assertServerOptions = (options: { + resourceMetadataUrl?: string + upstreamSecret?: string + maxUrlDownloadBytes?: unknown + urlDownloadTimeoutMs?: unknown +}): void => { + // API2 only accepts relayed `aud=mcp` tokens from the hosted service, so without the secret + // every authenticated call fails; refusing to start surfaces that in the deploy's health check. + if (options.resourceMetadataUrl && !options.upstreamSecret) { + throw new Error( + 'TRANSLOADIT_MCP_RESOURCE_METADATA_URL (hosted mode) requires TRANSLOADIT_MCP_UPSTREAM_SECRET: API2 only accepts relayed MCP tokens from the hosted service.', + ) + } + assertPositiveInteger(options.maxUrlDownloadBytes, 'maxUrlDownloadBytes', 'bytes') + assertPositiveInteger(options.urlDownloadTimeoutMs, 'urlDownloadTimeoutMs', 'milliseconds') +} + +/** Validates the HTTP request body limit. */ +export const assertRequestBodyLimit = (value: unknown): void => { + assertPositiveInteger(value, 'maxRequestBodyBytes', 'bytes') +} diff --git a/packages/mcp-server/src/request-transport.ts b/packages/mcp-server/src/request-transport.ts new file mode 100644 index 00000000..16a6dcc6 --- /dev/null +++ b/packages/mcp-server/src/request-transport.ts @@ -0,0 +1,160 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' + +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' + +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' + +import { + readBodyWithinLimit, + resolveMaxRequestBodyBytes, + sendBodyTooLarge, +} from './http-helpers.ts' +import { isRecord, parseJson } from './json.ts' + +type RequestTransportOptions = { + resourceMetadataUrl?: string + allowedHosts?: string[] + enableDnsRebindingProtection?: boolean + maxRequestBodyBytes?: number +} + +/** A per-request MCP transport plus the function that serves the HTTP request through it. */ +export type RequestTransport = { + transport: Transport + handle: (req: IncomingMessage, res: ServerResponse, parsedBody: unknown) => Promise +} + +type UpstreamChallenge = { status: 401 | 403; header: string } + +/** + * Finds the challenge a tool attached after API2 rejected the forwarded token. Tool results are + * the only place that knows, because API2 checks the token when a tool calls it. + */ +const findUpstreamChallenge = (body: string): UpstreamChallenge | undefined => { + const parsed = parseJson(body) + const messages = Array.isArray(parsed) ? parsed : [parsed] + for (const message of messages) { + if (!isRecord(message) || !isRecord(message.result)) continue + const { result } = message + if (result.isError !== true || !isRecord(result._meta)) continue + const challenges = result._meta['mcp/www_authenticate'] + const header = Array.isArray(challenges) + ? challenges.find((challenge) => typeof challenge === 'string') + : undefined + if (typeof header !== 'string') continue + if (header.includes('error="invalid_token"')) return { status: 401, header } + if (header.includes('error="insufficient_scope"')) return { status: 403, header } + } + return undefined +} + +const toWebRequest = (req: IncomingMessage): Request => { + const headers = new Headers() + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) continue + for (const entry of Array.isArray(value) ? value : [value]) headers.append(name, entry) + } + // The body always arrives pre-parsed, so headers and a fixed base URL are all it needs. + return new Request(new URL(req.url ?? '/', 'http://localhost'), { method: req.method, headers }) +} + +const sendParseError = (res: ServerResponse): void => { + res.statusCode = 400 + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32700, message: 'Parse error: Invalid JSON' }, + id: null, + }), + ) +} + +/** + * Returns the JSON-RPC message, reading it within the body limit when no parser consumed the + * stream yet (an Express app without `express.json()`). `undefined` means a response was sent. + */ +const readMessage = async ( + req: IncomingMessage, + res: ServerResponse, + parsedBody: unknown, + maxBytes: number, +): Promise<{ message: unknown } | undefined> => { + if (parsedBody !== undefined || req.method !== 'POST' || req.readableEnded) { + return { message: parsedBody } + } + const rawBody = await readBodyWithinLimit(req, maxBytes) + if (rawBody === undefined) { + sendBodyTooLarge(res, maxBytes) + return undefined + } + const message = parseJson(rawBody) + if (message === undefined) { + sendParseError(res) + return undefined + } + return { message } +} + +/** + * Serves a hosted request through the SDK's web-standard transport in JSON mode, so the status is + * decided after the tool ran: MCP OAuth clients refresh only on HTTP 401 and re-scope only on + * HTTP 403 `insufficient_scope`, and a tool result over HTTP 200 would keep them retrying a + * rejected token. + */ +const relayHostedRequest = async ( + transport: WebStandardStreamableHTTPServerTransport, + req: IncomingMessage, + res: ServerResponse, + message: unknown, +): Promise => { + const response = await transport.handleRequest(toWebRequest(req), { parsedBody: message }) + const body = await response.text() + // A client retries the whole HTTP request after re-authorizing, so a batch keeps its 200: the + // other calls may have succeeded (an Assembly created twice would be charged twice). Its failed + // items still carry the challenge in `_meta["mcp/www_authenticate"]`. + const challenge = + !Array.isArray(message) && response.headers.get('content-type')?.includes('application/json') + ? findUpstreamChallenge(body) + : undefined + res.statusCode = challenge?.status ?? response.status + for (const [name, value] of response.headers) res.setHeader(name, value) + if (challenge) res.setHeader('WWW-Authenticate', challenge.header) + res.end(body) +} + +/** + * Creates the stateless transport for one HTTP request. Origins are checked before this point by + * `applyCorsHeaders`, which understands `*.` and `:*` wildcards; the SDK compares origins + * literally, so it only receives the Host allowlist. + */ +export const createRequestTransport = (options: RequestTransportOptions): RequestTransport => { + const shared = { + sessionIdGenerator: undefined, + allowedHosts: options.allowedHosts, + enableDnsRebindingProtection: options.enableDnsRebindingProtection, + } + const maxBytes = resolveMaxRequestBodyBytes(options) + if (!options.resourceMetadataUrl) { + const transport = new StreamableHTTPServerTransport(shared) + return { + transport, + handle: async (req, res, parsedBody) => { + const read = await readMessage(req, res, parsedBody, maxBytes) + if (read) await transport.handleRequest(req, res, read.message) + }, + } + } + const transport = new WebStandardStreamableHTTPServerTransport({ + ...shared, + enableJsonResponse: true, + }) + return { + transport, + handle: async (req, res, parsedBody) => { + const read = await readMessage(req, res, parsedBody, maxBytes) + if (read) await relayHostedRequest(transport, req, res, read.message) + }, + } +} diff --git a/packages/mcp-server/src/server-card.ts b/packages/mcp-server/src/server-card.ts index 56b846d1..23345274 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 { ToolAuthMode, ToolName, ToolSecurityScheme } from './tool-metadata.ts' + import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js' import packageJson from '../package.json' with { type: 'json' } +import { resolveSecuritySchemes, resolveToolAuthMode, 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,37 @@ const tools: ServerCardToolDefinition[] = [ }, }, }, + { + name: 'transloadit_get_profile', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: {}, + }, + }, ] +/** Tool definitions with the security schemes this deployment mode can honor. */ +const buildTools = (mode: ToolAuthMode): ServerCardToolDefinition[] => + toolInputs.map((tool) => { + const metadata = toolMetadata[tool.name] + return { + ...tool, + title: metadata.title, + description: metadata.description, + annotations: metadata.annotations, + securitySchemes: resolveSecuritySchemes(metadata, mode), + } + }) + 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) + const tools = buildTools(resolveToolAuthMode(options)) + // 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 +212,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..2c221ad1 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,9 +1,16 @@ +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 { 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 +26,47 @@ 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 { assertServerOptions } from './options.ts' +import { mirrorSecuritySchemes } from './tool-list.ts' +import { + buildToolMeta, + resolveSecuritySchemes, + resolveToolAuthMode, + toolMetadata, +} from './tool-metadata.ts' +import { registerAssemblyResultWidget, widgetContextMetaKey } from './ui/assembly-result-widget.ts' +import { buildUploadInstructions, uploadInstructionSchema } from './upload-instructions.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 + /** + * 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 + /** + * 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[] + /** Most bytes the URL inputs of one call may download together; defaults to 1 GiB. */ + maxUrlDownloadBytes?: number + /** Longest one URL input download may take, redirects included; defaults to 10 minutes. */ + urlDownloadTimeoutMs?: number + /** Console origin used for widget deep links; defaults to the public website. */ + consoleUrl?: string endpoint?: string serverName?: string serverVersion?: string @@ -32,6 +74,16 @@ export type TransloaditMcpServerOptions = { clientSuffix?: string } +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' + type LintIssueOutput = { path: string message: string @@ -56,6 +108,10 @@ type ToolExtra = { const maxBase64Bytes = 512_000 +/** URL inputs beyond these limits are not downloaded to this server's disk (per call). */ +const defaultMaxUrlDownloadBytes = 1024 * 1024 * 1024 +const defaultUrlDownloadTimeoutMs = 10 * 60 * 1000 + type LintAssemblyInstructionsInput = Parameters[0] const lintIssueSchema = z.object({ @@ -149,16 +205,31 @@ 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(), upload_concurrency: z.number().int().positive().optional(), upload_chunk_size: z.number().int().positive().optional(), upload_behavior: z.enum(['await', 'background', 'none']).optional(), - expected_uploads: z.number().int().positive().optional(), + // Each expected upload becomes an instruction in the response; keep that response bounded. + expected_uploads: z.number().int().positive().max(100).optional(), assembly_url: z.string().optional(), }) @@ -173,6 +244,7 @@ const createAssemblyOutputSchema = z.object({ upload_urls: z.record(z.string(), z.string()).optional(), }) .optional(), + upload_instructions: z.array(uploadInstructionSchema).optional(), next_steps: z.array(z.string()).optional(), errors: z.array(toolMessageSchema).optional(), warnings: z.array(toolMessageSchema).optional(), @@ -245,6 +317,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 +346,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 +364,72 @@ const buildToolResponse = (payload: Record): CallToolResult => return { content: [content], structuredContent: payload, + ...(extras.isError ? { isError: true } : {}), + ...(extras.meta ? { _meta: extras.meta } : {}), } } +/** + * The SDK client validates `structuredContent` against a tool's output schema even on `isError` + * results, so tools whose schema cannot describe an error (Template lists, the strict profile) + * return errors as text only. The text still carries the code and hint as JSON. + */ +const withoutStructuredContent = (result: CallToolResult): CallToolResult => { + const { structuredContent: _structuredContent, ...rest } = result + return rest +} + +type AuthErrorInput = { + code: 'mcp_missing_auth' | 'mcp_auth_rejected' | 'mcp_insufficient_scope' + oauthError: 'invalid_token' | 'insufficient_scope' + message: string + hint?: string + /** Scopes to request again; only meaningful for `insufficient_scope`. */ + scopes?: 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 }, + scopes: input.scopes, + }), + ], + }, + }, + ) + +const buildMissingAuthError = (options: TransloaditMcpServerOptions): CallToolResult => + // Only a hosted server has an authorization server a host could link an account with. + options.resourceMetadataUrl + ? buildAuthError(options, { + code: 'mcp_missing_auth', + oauthError: 'insufficient_scope', + message: 'Sign in to Transloadit to use this tool.', + hint: 'Connect your Transloadit account through OAuth, then retry.', + }) + : buildCredentialError({ + code: 'mcp_missing_auth', + message: 'This server has no Transloadit credentials for this tool.', + hint: 'Set TRANSLOADIT_KEY/TRANSLOADIT_SECRET or send an Authorization: Bearer token.', + }) + const buildToolError = ( code: string, message: string, @@ -321,7 +478,12 @@ const getHeaderValue = (headers: HeaderMap | undefined, name: string): string | const getBearerToken = (headers: HeaderMap | undefined): string | undefined => extractBearerToken(getHeaderValue(headers, 'authorization')) -type LiveClientResult = { client: Transloadit } | { error: ReturnType } +/** Which credentials a live client signs with; recovery advice differs per kind. */ +type CredentialKind = 'bearer' | 'auth-key' + +type LiveClientResult = + | { client: Transloadit; credentials: CredentialKind } + | { error: ReturnType } const createLiveClient = ( options: TransloaditMcpServerOptions, @@ -332,32 +494,34 @@ const createLiveClient = ( if (authToken) { return { + credentials: 'bearer', client: new Transloadit({ authToken, authKey: options.authKey, authSecret: options.authSecret, endpoint: options.endpoint, clientName: getClientName(options), + signatureAlgorithm: options.signatureAlgorithm, followRedirects: false, + extraHeaders: options.upstreamSecret + ? { [upstreamSecretHeader]: options.upstreamSecret } + : undefined, }), } } 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 { + credentials: 'auth-key', client: new Transloadit({ authKey: options.authKey, authSecret: options.authSecret, endpoint: options.endpoint, clientName: getClientName(options), + signatureAlgorithm: options.signatureAlgorithm, followRedirects: false, }), } @@ -397,6 +561,158 @@ 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, + credentials: CredentialKind, + scopes: string[], +): CallToolResult | undefined => { + const status = getHttpStatusCode(error) + // The token or key is valid but names a different Auth Key than the instructions' + // `auth.key`; reconnecting cannot fix that, so it is reported like any other bad argument. + if (error instanceof ApiError && error.code === 'BEARER_TOKEN_AUTH_KEY_MISMATCH') { + return buildCredentialError({ + code: 'mcp_auth_key_mismatch', + message: 'The instructions name a different Auth Key than the connected credentials.', + hint: 'Remove auth.key from the instructions; the connected credentials supply it.', + }) + } + // INSUFFICIENT_AUTH_SCOPE is API2's only scope rejection. + const scopeRejected = + status === 403 && error instanceof ApiError && error.code === 'INSUFFICIENT_AUTH_SCOPE' + // Only forwarded tokens can be renewed by the host's account linking; an Auth Key configured + // on the server needs an operator, so it gets no OAuth challenge. + if (credentials === 'auth-key') { + if (status === 401) { + return buildCredentialError({ + code: 'mcp_credentials_rejected', + message: 'Transloadit rejected the Auth Key this server is configured with.', + hint: 'Check that TRANSLOADIT_KEY and TRANSLOADIT_SECRET belong to an active Auth Key.', + }) + } + if (scopeRejected) { + return buildCredentialError({ + code: 'mcp_insufficient_scope', + message: 'The configured Auth Key lacks the scope this tool needs.', + hint: 'Grant the Auth Key the scopes this tool declares, or configure another key.', + }) + } + } else 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.', + }) + } else 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.', + scopes, + }) + } + if (error instanceof ApiError && error.code === 'INVALID_SIGNATURE') { + return buildSignatureError(error) + } + return undefined +} + +/** The Assembly exists, but its status could not be read with the caller's credentials. */ +const buildCreatedAssemblyUnavailable = ( + options: TransloaditMcpServerOptions, + assemblyId: string, +): CallToolResult => { + // Matches resolveAssemblyReference: the SDK's default when the endpoint is empty. + const assemblyUrl = `${options.endpoint || 'https://api2.transloadit.com'}/assemblies/${assemblyId}` + return buildToolResponse( + { + status: 'error', + assembly: { assembly_id: assemblyId, assembly_ssl_url: assemblyUrl }, + errors: [ + { + code: 'mcp_assembly_status_unavailable', + message: + 'The Assembly was created, but Transloadit rejected the credentials while reading its status.', + hint: `Reconnect if needed, then call transloadit_get_assembly_status with assembly_url ${assemblyUrl} instead of creating it again.`, + }, + ], + }, + { isError: true }, + ) +} + +/** A failed call caused by server-side credentials; an error result so strict outputs still pass. */ +const buildCredentialError = (error: { + code: string + message: string + hint: string +}): CallToolResult => buildToolResponse({ status: 'error', errors: [error] }, { isError: true }) + +/** + * 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], + ) + return buildCredentialError({ + 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.', + }) +} + +/** The Console origin and base path without a trailing slash, or `undefined` when unusable. */ +const parseConsoleUrl = (value: string): string | undefined => { + if (!URL.canParse(value)) return undefined + const url = new URL(value) + if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined + return `${url.origin}${url.pathname}`.replace(/\/$/, '') +} + +/** Widget-only context: whether the caller is signed in and where the Console deep links go. */ +const buildWidgetContext = ( + options: TransloaditMcpServerOptions, + assembly: AssemblyStatus, +): WidgetContext => { + // Links are a convenience: a malformed consoleUrl must not turn an Assembly that was already + // created into a failed call, which would invite a retry that processes the files twice. + const consoleUrl = parseConsoleUrl(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 || !consoleUrl) return { authenticated: true } + + const workspaceUrl = `${consoleUrl}/c/${encodeURIComponent(slug)}` + const newTemplateUrl = new URL(`${workspaceUrl}/templates/new`) + // The Console seeds the editor from `fromAssembly` since transloadit/content#6207, which ships + // together with this release: it loads the Assembly's effective instructions, overrides + // included. `duplicateFrom` stays as the seed for Consoles that predate it. Hiding or relabeling + // the action was considered and rejected in review of transloadit/node-sdk#529. + if (assemblyId) { + newTemplateUrl.searchParams.set('fromAssembly', assemblyId) + } + 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' @@ -486,6 +802,12 @@ const assemblyUrlSchema = z.url() type AssemblyReference = { assemblyId: string; assemblyUrl: string } +/** Production (`.com`) and development or tunnel (`.dev`) Transloadit hosts. */ +const isTransloaditHost = (hostname: string): boolean => + ['transloadit.com', 'transloadit.dev'].some( + (domain) => hostname === domain || hostname.endsWith(`.${domain}`), + ) + const resolveAssemblyReference = ( options: TransloaditMcpServerOptions, args: { assembly_url?: string; assembly_id?: string }, @@ -504,20 +826,29 @@ const resolveAssemblyReference = ( const endpoint = options.endpoint || 'https://api2.transloadit.com' let assemblyId = args.assembly_id - if (args.assembly_url !== undefined) { + // The message promises "URL or ID", and models pass a bare ID in either field. + if (args.assembly_url !== undefined && assemblyIdSchema.safeParse(args.assembly_url).success) { + assemblyId = args.assembly_url + } else if (args.assembly_url !== undefined) { const parsed = assemblyUrlSchema.safeParse(args.assembly_url) if (!parsed.success) return invalidReference const url = new URL(parsed.data) const apiEndpoint = new URL(endpoint) const usesConfiguredOrigin = url.origin === apiEndpoint.origin - const usesTransloaditOrigin = url.hostname.endsWith('.transloadit.com') && url.port === '' + // A hosted server may answer on a public origin (a tunnel or proxy) other than its API + // endpoint; Assembly URLs it hands out carry that origin. + const usesPublicOrigin = + options.resourceMetadataUrl !== undefined && + URL.canParse(options.resourceMetadataUrl) && + url.origin === new URL(options.resourceMetadataUrl).origin + const usesTransloaditOrigin = isTransloaditHost(url.hostname) && url.port === '' if ( (url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash || - (!usesConfiguredOrigin && !usesTransloaditOrigin) + (!usesConfiguredOrigin && !usesPublicOrigin && !usesTransloaditOrigin) ) { return invalidReference } @@ -542,6 +873,7 @@ const resolveAssemblyReference = ( type AssemblyAccessResult = | { client: Transloadit + credentials: CredentialKind assemblyId: string assemblyUrl?: string } @@ -560,6 +892,7 @@ const resolveAssemblyAccess = ( return { client: liveClient.client, + credentials: liveClient.credentials, ...reference, } } @@ -571,6 +904,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 +948,50 @@ 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) { + // The details only add the Workspace name; an expired or unreadable Assembly must not hide + // the id the list already returned, nor skip the Template fallback. + const status: Partial = await client.getAssembly(latest.id).catch(() => ({})) + 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, + } + } + } + + // API2's default Template list fields omit account_id, so request it explicitly. + const templates = listTemplatesResponseSchema.safeParse( + await client.listTemplates({ pagesize: 1, fields: ['id', 'account_id'] }), + ) + 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 || @@ -651,21 +1029,39 @@ const toAssemblyInstructionsInput = (params: CreateAssemblyParams): AssemblyInst export const createTransloaditMcpServer = ( options: TransloaditMcpServerOptions = {}, ): McpServer => { + assertServerOptions(options) const server = new McpServer({ name: options.serverName ?? 'Transloadit MCP', version: options.serverVersion ?? packageJson.version, }) + const authMode = resolveToolAuthMode(options) + const register = ( + name: ToolName, + inputSchema: Input, + outputSchema: Output, + callback: ToolCallback, + ): void => { + const metadata = toolMetadata[name] + server.registerTool( + name, + { + title: metadata.title, + description: metadata.description, + inputSchema, + outputSchema, + annotations: metadata.annotations, + _meta: buildToolMeta(metadata, resolveSecuritySchemes(metadata, authMode)), + }, + callback, + ) + } + // 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 +1085,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, @@ -715,7 +1107,7 @@ export const createTransloaditMcpServer = ( ) => { const liveClient = createLiveClient(options, extra) if ('error' in liveClient) return liveClient.error - const { client } = liveClient + const { client, credentials } = liveClient const reference = assembly_url === undefined ? undefined : resolveAssemblyReference(options, { assembly_url }) if (reference && 'error' in reference) return reference.error @@ -724,7 +1116,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 @@ -843,6 +1235,14 @@ export const createTransloaditMcpServer = ( ) } } + // The hosted gate only checks that a bearer is present. Before a URL input is downloaded + // to this server's disk (not for /http/import), API2 vouches for the token once + // (templates:read is declared for this tool). + let tokenCheck: Promise | undefined + const verifyTokenBeforeDownload = async (): Promise => { + tokenCheck ??= client.listTemplates({ pagesize: 1 }) + await tokenCheck + } const prep = await prepareInputFiles({ inputFiles: inputFilesForPrep, params, @@ -851,7 +1251,12 @@ export const createTransloaditMcpServer = ( urlStrategy: reference ? 'download' : 'import-if-present', allowPrivateUrls: false, maxBase64Bytes, + maxUrlDownloadBytes: options.maxUrlDownloadBytes ?? defaultMaxUrlDownloadBytes, + urlDownloadTimeoutMs: options.urlDownloadTimeoutMs ?? defaultUrlDownloadTimeoutMs, + beforeUrlDownload: credentials === 'bearer' ? verifyTokenBeforeDownload : undefined, }).catch((error) => { + // API rejections (the token check) are not input mistakes; the outer handler maps them. + if (error instanceof ApiError) throw error const message = error instanceof Error ? error.message : 'Invalid file input.' if (message.startsWith('Duplicate file field')) { return buildToolError('mcp_duplicate_field', message, { path: 'files' }) @@ -878,36 +1283,61 @@ export const createTransloaditMcpServer = ( } const timeout = wait_timeout_ms - const waitForCompletion = wait_for_completion ?? false + // Files the caller uploads itself (from a sandbox) can only start once this call returns, + // so waiting here would just run into the timeout. + const outOfBandUploads = reference ? 0 : Math.max((expected_uploads ?? 0) - totalFiles, 0) + const waitForCompletion = (wait_for_completion ?? false) && outOfBandUploads === 0 + if (wait_for_completion && outOfBandUploads > 0) { + warnings.push({ + code: 'mcp_wait_skipped_for_uploads', + message: + 'Did not wait for completion because the Assembly waits for the expected uploads. Run upload_instructions, then call transloadit_wait_for_assembly.', + }) + } const uploadBehavior = upload_behavior ?? (waitForCompletion ? 'await' : 'background') const uploadConcurrency = upload_concurrency const chunkSize = upload_chunk_size let assembly: Awaited> + // Set by the SDK only after API2 accepted the creation request, so a rejected request + // (including an error body with HTTP 200) is never mistaken for an existing Assembly. + let createdAssemblyId: string | undefined try { - assembly = reference - ? await client.resumeAssemblyUploads({ - assemblyUrl: reference.assemblyUrl, - files: filesMap, - uploads: uploadsMap, - waitForCompletion, - timeout, - uploadConcurrency, - chunkSize, - uploadBehavior, - }) - : await client.createAssembly({ - params, - files: filesMap, - uploads: uploadsMap, - waitForCompletion, - timeout, - uploadConcurrency, - chunkSize, - uploadBehavior, - expectedUploads: expected_uploads, - }) + if (reference) { + assembly = await client.resumeAssemblyUploads({ + assemblyUrl: reference.assemblyUrl, + files: filesMap, + uploads: uploadsMap, + waitForCompletion, + timeout, + uploadConcurrency, + chunkSize, + uploadBehavior, + }) + } else { + const creation = client.createAssembly({ + params, + files: filesMap, + uploads: uploadsMap, + waitForCompletion, + timeout, + uploadConcurrency, + chunkSize, + uploadBehavior, + expectedUploads: expected_uploads, + onAssemblyCreated: (created) => { + createdAssemblyId = created.assembly_id ?? creation.assemblyId + }, + }) + assembly = await creation + } } catch (error) { + // Once API2 accepted the creation request, an auth failure (a token expiring while + // uploads or polling run) must not reach the client as a challenge: it would refresh and + // replay the call, creating and billing a second Assembly. + if (createdAssemblyId && toAuthRejection(options, error, credentials, [])) { + return buildCreatedAssemblyUnavailable(options, createdAssemblyId) + } if (isErrnoException(error) && error.code === 'ENOENT') { return buildToolError( 'mcp_file_not_found', @@ -936,36 +1366,73 @@ export const createTransloaditMcpServer = ( uploadSummary.upload_urls = assembly.upload_urls as Record } - const nextSteps = waitForCompletion - ? [] - : ['transloadit_wait_for_assembly', 'transloadit_get_assembly_status'] - - return buildToolResponse({ - status: 'ok', - assembly, - upload: uploadSummary, - next_steps: nextSteps, - warnings: warnings.length > 0 ? warnings : undefined, - }) + const uploadInstructions = + outOfBandUploads > 0 + ? buildUploadInstructions( + assembly, + outOfBandUploads, + new Set([...Object.keys(filesMap), ...Object.keys(uploadsMap)]), + ) + : undefined + if (outOfBandUploads > 0 && !uploadInstructions) { + warnings.push({ + code: 'mcp_upload_instructions_unavailable', + message: 'The Assembly status has no tus_url or assembly_ssl_url to upload to.', + }) + } + const nextSteps = uploadInstructions + ? ['transloadit_wait_for_assembly'] + : waitForCompletion + ? [] + : ['transloadit_wait_for_assembly', 'transloadit_get_assembly_status'] + + return buildToolResponse( + { + status: 'ok', + assembly, + upload: uploadSummary, + upload_instructions: uploadInstructions, + next_steps: nextSteps, + warnings: warnings.length > 0 ? warnings : undefined, + }, + { meta: { [widgetContextMetaKey]: buildWidgetContext(options, assembly) } }, + ) + } catch (error) { + const rejection = toAuthRejection( + options, + error, + credentials, + toolMetadata.transloadit_create_assembly.scopes, + ) + 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, + access.credentials, + toolMetadata.transloadit_get_assembly_status.scopes, + ) + if (rejection) return rejection + throw error + } return buildToolResponse({ status: 'ok', @@ -974,42 +1441,49 @@ 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, + access.credentials, + toolMetadata.transloadit_wait_for_assembly.scopes, + ) + 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 +1495,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 +1564,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 withoutStructuredContent(liveClient.error) try { const response = await liveClient.client.listTemplates({ @@ -1153,6 +1606,13 @@ export const createTransloaditMcpServer = ( total: parsed.data.count ?? items.length, }) } catch (error) { + const rejection = toAuthRejection( + options, + error, + liveClient.credentials, + toolMetadata.transloadit_list_templates.scopes, + ) + if (rejection) return withoutStructuredContent(rejection) const message = error instanceof Error ? error.message : 'Failed to list templates.' return buildToolResponse({ status: 'error', @@ -1168,5 +1628,52 @@ export const createTransloaditMcpServer = ( }, ) + register( + 'transloadit_get_profile', + getProfileInputSchema, + getProfileOutputSchema, + async (_args, extra) => { + const liveClient = createLiveClient(options, extra) + if ('error' in liveClient) return withoutStructuredContent(liveClient.error) + + let profile: WorkspaceProfile | undefined + try { + profile = await resolveWorkspaceProfile(liveClient.client) + } catch (error) { + const rejection = toAuthRejection( + options, + error, + liveClient.credentials, + toolMetadata.transloadit_get_profile.scopes, + ) + if (rejection) return withoutStructuredContent(rejection) + throw error + } + + if (!profile) { + // The strict profile output schema has no error shape, so this must be an error result. + return withoutStructuredContent( + 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, { resultDomains: options.resultDomains }) + mirrorSecuritySchemes(server) + 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..5c343ccd --- /dev/null +++ b/packages/mcp-server/src/tool-list.ts @@ -0,0 +1,54 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +import { isRecord } from './json.ts' + +type RequestHandler = (request: unknown, extra: unknown) => Promise + +const isRequestHandler = (value: unknown): value is RequestHandler => typeof value === 'function' + +/** + * `@modelcontextprotocol/sdk` keeps request handlers in the private `Protocol._requestHandlers` + * map and offers no getter. Reading it is the only way to wrap the SDK's own `tools/list` handler + * rather than re-implementing it; a failing read throws so an SDK upgrade cannot silently drop the + * field. + */ +const readRequestHandlers = (server: McpServer): Map => { + const handlers: unknown = Object.getOwnPropertyDescriptor( + server.server, + '_requestHandlers', + )?.value + if (!(handlers instanceof Map)) { + throw new Error('@modelcontextprotocol/sdk no longer keeps _requestHandlers in a Map.') + } + return handlers +} + +const withTopLevelSecuritySchemes = (result: unknown): unknown => { + if (!isRecord(result) || !Array.isArray(result.tools)) return result + return { + ...result, + tools: result.tools.map((tool) => { + if (!isRecord(tool) || !isRecord(tool._meta) || !Array.isArray(tool._meta.securitySchemes)) { + return tool + } + return { ...tool, securitySchemes: tool._meta.securitySchemes } + }), + } +} + +/** + * ChatGPT and Claude read `securitySchemes` as a top-level Tool field, which `registerTool()` + * cannot emit. Wrapping the SDK's `tools/list` handler keeps its live registry (tools registered + * later, enable/disable, schema conversion) and copies each tool's `_meta.securitySchemes` up. + * Call it after the first `registerTool()`, which installs the handler. + */ +export const mirrorSecuritySchemes = (server: McpServer): void => { + const handlers = readRequestHandlers(server) + const listTools = handlers.get('tools/list') + if (!isRequestHandler(listTools)) { + throw new Error('Register a tool before mirroring securitySchemes into tools/list.') + } + const wrapped: RequestHandler = async (request, extra) => + withTopLevelSecuritySchemes(await listTools(request, extra)) + handlers.set('tools/list', wrapped) +} diff --git a/packages/mcp-server/src/tool-metadata.ts b/packages/mcp-server/src/tool-metadata.ts new file mode 100644 index 00000000..7685ba1d --- /dev/null +++ b/packages/mcp-server/src/tool-metadata.ts @@ -0,0 +1,194 @@ +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 + /** `public` tools need no Transloadit account; `account` tools call API2 for the caller. */ + access: 'public' | 'account' + /** Every Auth Key scope any code path of the tool may need. */ + scopes: string[] + /** Host-specific `_meta` keys (OpenAI, MCP Apps). `securitySchemes` is mirrored in automatically. */ + meta?: Record +} + +/** + * How callers authenticate with this deployment, which decides what each tool may honestly + * declare: + * - `hosted`: every unauthenticated request gets the OAuth challenge (Claude Code and Codex only + * start OAuth on a 401), so even the public tools need an OAuth token. + * - `server-credentials`: the server signs with its own Auth Key, so no tool needs caller auth. + * - `caller-credentials`: public tools work anonymously; account tools need a forwarded token. + */ +export type ToolAuthMode = 'hosted' | 'server-credentials' | 'caller-credentials' + +export const resolveToolAuthMode = (options: { + resourceMetadataUrl?: string + authKey?: string + authSecret?: string +}): ToolAuthMode => { + if (options.resourceMetadataUrl) return 'hosted' + if (options.authKey && options.authSecret) return 'server-credentials' + return 'caller-credentials' +} + +/** The `securitySchemes` a tool declares in the given deployment mode. */ +export const resolveSecuritySchemes = ( + metadata: ToolMetadata, + mode: ToolAuthMode, +): ToolSecurityScheme[] => { + if (mode === 'server-credentials') return [{ type: 'noauth' }] + if (mode === 'caller-credentials' && metadata.access === 'public') return [{ type: 'noauth' }] + return [{ type: 'oauth2', scopes: metadata.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, access and scopes 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, + access: 'public', + scopes: [], + }, + 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. If a file exists only locally or in your sandbox, set expected_uploads, run each upload_instructions curl command there (it needs outbound HTTPS to Transloadit), then call transloadit_wait_for_assembly.', + annotations: { + readOnlyHint: false, + // Export Robots (/s3/store, /google/store, …) can overwrite files at the destination, so + // hosts should confirm before running caller-supplied instructions. + destructiveHint: true, + idempotentHint: false, + // URL inputs and /http/import Steps fetch caller-supplied locations. + openWorldHint: true, + }, + access: 'account', + // Reading `template_id` instructions needs templates:read; status polling is public in API2. + scopes: [mcpScopes.assembliesWrite, mcpScopes.templatesRead], + 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, + access: 'account', + scopes: [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, + access: 'account', + scopes: [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, + access: 'public', + scopes: [], + }, + transloadit_get_robot_help: { + title: 'Get Robot parameter help', + description: 'Returns a Robot summary and parameter details.', + annotations: readOnly, + access: 'public', + scopes: [], + }, + 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, + access: 'account', + scopes: [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, + access: 'account', + // The Workspace is read from the latest Assembly, falling back to an owned Template. + scopes: [mcpScopes.assembliesRead, mcpScopes.templatesRead], + meta: { + 'openai/profile': true, + }, + }, +} + +/** `_meta` for `tools/list`: carries `securitySchemes` for hosts that only read `_meta`. */ +export const buildToolMeta = ( + metadata: ToolMetadata, + securitySchemes: ToolSecurityScheme[], +): Record => ({ + 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..2c8128d5 --- /dev/null +++ b/packages/mcp-server/src/ui/assembly-result-widget.ts @@ -0,0 +1,450 @@ +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 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' + +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 buildAssemblyResultWidgetMeta = ( + resultDomains: string[] = defaultResultDomains, +): Record => ({ + ui: { + csp: { + connectDomains: resultDomains, + resourceDomains: resultDomains, + }, + prefersBorder: true, + }, + 'openai/widgetDescription': widgetDescription, + 'openai/widgetCSP': { + connect_domains: resultDomains, + resource_domains: resultDomains, + }, + '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…

+ + + +` + +export type AssemblyResultWidgetOptions = { + /** Origins allowed for previews and downloads; defaults to `defaultResultDomains`. */ + resultDomains?: string[] +} + +/** Registers the widget so hosts can `resources/read` it through `_meta.ui.resourceUri`. */ +export const registerAssemblyResultWidget = ( + server: McpServer, + options: AssemblyResultWidgetOptions = {}, +): void => { + const meta = buildAssemblyResultWidgetMeta( + options.resultDomains && options.resultDomains.length > 0 + ? options.resultDomains + : defaultResultDomains, + ) + server.registerResource( + 'assembly-result', + assemblyResultWidgetUri, + { + title: 'Assembly result', + description: widgetDescription, + mimeType: assemblyResultWidgetMimeType, + _meta: meta, + }, + () => ({ + contents: [ + { + uri: assemblyResultWidgetUri, + mimeType: assemblyResultWidgetMimeType, + text: assemblyResultWidgetHtml, + _meta: meta, + }, + ], + }), + ) +} diff --git a/packages/mcp-server/src/upload-instructions.ts b/packages/mcp-server/src/upload-instructions.ts new file mode 100644 index 00000000..4b6e82e7 --- /dev/null +++ b/packages/mcp-server/src/upload-instructions.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' + +/** One out-of-band tus upload the caller runs where the file lives, such as an agent sandbox. */ +export const uploadInstructionSchema = z.object({ + fieldname: z.string(), + tus_endpoint: z.string(), + metadata: z.object({ assembly_url: z.string(), fieldname: z.string() }), + curl: z.string(), +}) + +export type UploadInstruction = z.infer + +const shellQuote = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'` + +const toBase64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64') + +/** Field names `file_1`, `file_2`, … that files sent with the same call do not use yet. */ +const pickFieldnames = (count: number, taken: ReadonlySet): string[] => { + const fieldnames: string[] = [] + for (let index = 1; fieldnames.length < count; index += 1) { + const fieldname = `file_${index}` + if (!taken.has(fieldname)) fieldnames.push(fieldname) + } + return fieldnames +} + +/** + * Builds a single `curl` command per expected upload, using tus creation-with-upload (one POST + * with the file as body), which Transloadit's tusd advertises; it prints `201` on success. The + * shell derives `Upload-Length` and the base64 filename from the file, so any local name works. + * `-T` streams the file from disk (`--data-binary @file` would read it into memory first), and + * `--request-target` keeps curl from appending the local filename to the endpoint path. + * + * The Assembly URL in the metadata is the only capability these commands carry: whoever holds it + * can add files to this Assembly while it waits for uploads. Credentials (Auth Keys, secrets, + * bearer tokens) must never be added here, because the commands are shown to the model and run + * in its sandbox. + */ +export const buildUploadInstructions = ( + assembly: { tus_url?: unknown; assembly_ssl_url?: unknown }, + count: number, + takenFieldnames: ReadonlySet, +): UploadInstruction[] | undefined => { + const tusEndpoint = assembly.tus_url + const assemblyUrl = assembly.assembly_ssl_url + if (typeof tusEndpoint !== 'string' || typeof assemblyUrl !== 'string') return undefined + if (!URL.canParse(tusEndpoint)) return undefined + const { pathname, search } = new URL(tusEndpoint) + + return pickFieldnames(count, takenFieldnames).map((fieldname) => { + const metadata = `assembly_url ${toBase64(assemblyUrl)},fieldname ${toBase64(fieldname)}` + const curl = [ + `FILE='/path/to/the/file'`, + `curl -sS -o /dev/null -w '%{http_code}\\n' -X POST -T "$FILE" \\`, + ` --request-target ${shellQuote(`${pathname}${search}`)} ${shellQuote(tusEndpoint)} \\`, + ` -H 'Tus-Resumable: 1.0.0' \\`, + ` -H "Upload-Length: $(wc -c < "$FILE" | tr -d ' ')" \\`, + ` -H "Upload-Metadata: ${metadata},filename $(basename "$FILE" | tr -d '\\n' | base64 | tr -d '\\n')" \\`, + ` -H 'Content-Type: application/offset+octet-stream'`, + ].join('\n') + return { + fieldname, + tus_endpoint: tusEndpoint, + metadata: { assembly_url: assemblyUrl, fieldname }, + curl, + } + }) +} diff --git a/packages/mcp-server/test/e2e/server-card.test.ts b/packages/mcp-server/test/e2e/server-card.test.ts index 5294260e..723d5a12 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: expect.any(Boolean) }) + 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..f328144f --- /dev/null +++ b/packages/mcp-server/test/unit/auth-errors.test.ts @@ -0,0 +1,260 @@ +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 { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Transloadit } from '@transloadit/node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' +import { createTransloaditMcpServer } from '../../src/server.ts' +import { parseToolPayload } from '../e2e/mcp-client.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 names the missing server credentials when self-hosted', 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(parseToolPayload(result)).toMatchObject({ + status: 'error', + errors: [{ code: 'mcp_missing_auth', hint: expect.stringContaining('TRANSLOADIT_KEY') }], + }) + // There is no authorization server to link an account with, so no OAuth challenge. + expect(result._meta?.['mcp/www_authenticate']).toBeUndefined() + }) + + // The SDK client validates structuredContent against the output schema even for isError results + // once it has listed the tools, so errors from tools whose schema cannot hold them must still + // reach the caller. + it.each([ + 'transloadit_list_templates', + 'transloadit_get_profile', + ])('%s returns a readable error to a client that listed the tools', async (name) => { + await connect() + await client.listTools() + + const result = await client.callTool({ name, arguments: {} }) + expect(result.isError).toBe(true) + expect(parseToolPayload(result)).toMatchObject({ errors: [{ code: 'mcp_missing_auth' }] }) + }) + + it('asks the host to link an account when a hosted server gets no token', async () => { + const server = createTransloaditMcpServer({ + resourceMetadataUrl, + upstreamSecret: 'test-upstream-secret', + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'auth-errors-hosted', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + + const result = await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + expect(result.isError).toBe(true) + expect(wwwAuthenticate(result)).toMatch( + /^Bearer resource_metadata="[^"]+", error="insufficient_scope", error_description="[^"]+"$/, + ) + await server.close() + }) + + 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('names the Auth Key, not OAuth, when API2 rejects key/secret credentials', async () => { + serverOptions.authKey = 'key' + serverOptions.authSecret = 'secret' + vi.spyOn(Transloadit.prototype, 'getAssembly').mockRejectedValue(apiRejection(401)) + await connect() + + 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() + expect(result.structuredContent).toMatchObject({ + status: 'error', + errors: [ + { + code: 'mcp_credentials_rejected', + hint: expect.stringContaining('TRANSLOADIT_KEY'), + }, + ], + }) + }) + + 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('keeps the Workspace id when the latest Assembly details are unavailable', async () => { + vi.spyOn(Transloadit.prototype, 'listAssemblies').mockResolvedValue({ + items: [{ id: 'abcdef', account_id: 'ws_1' }], + count: 1, + }) + vi.spyOn(Transloadit.prototype, 'getAssembly').mockRejectedValue(apiRejection(404)) + + const result = await client.callTool({ name: 'transloadit_get_profile', arguments: {} }) + expect(result.isError).toBeFalsy() + expect(result.structuredContent).toEqual({ id: 'ws_1' }) + }) + + it('falls back to an owned Template when no Assembly exists', async () => { + vi.spyOn(Transloadit.prototype, 'listAssemblies').mockResolvedValue({ items: [], count: 0 }) + // API2's default Template list fields omit account_id; it is returned only when requested. + vi.spyOn(Transloadit.prototype, 'listTemplates').mockImplementation((params) => + Promise.resolve({ + items: [ + { + id: 'tpl_1', + name: 'resize', + content: {}, + ...(params?.fields?.includes('account_id') ? { 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 }) + + await client.listTools() + const result = await client.callTool({ name: 'transloadit_get_profile', arguments: {} }) + expect(result.isError).toBe(true) + expect(parseToolPayload(result)).toMatchObject({ + status: 'error', + errors: [{ code: 'mcp_profile_unavailable' }], + }) + }) +}) diff --git a/packages/mcp-server/test/unit/cli-config.test.ts b/packages/mcp-server/test/unit/cli-config.test.ts new file mode 100644 index 00000000..f056da2d --- /dev/null +++ b/packages/mcp-server/test/unit/cli-config.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { afterEach, describe, expect, it } from 'vitest' + +import { assemblyResultWidgetUri } from '../../src/ui/assembly-result-widget.ts' + +const cliPath = fileURLToPath(new URL('../../src/cli.ts', import.meta.url)) + +describe('transloadit-mcp CLI configuration', { timeout: 20000 }, () => { + let client: Client | undefined + + afterEach(async () => { + await client?.close() + client = undefined + }) + + it('reads TRANSLOADIT_MCP_RESULT_DOMAINS into the widget CSP', async () => { + client = new Client({ name: 'cli-config', version: '1.0.0' }) + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: [cliPath, 'stdio'], + env: { + ...process.env, + TRANSLOADIT_MCP_RESULT_DOMAINS: 'https://cdn.example.com, https://*.example.net', + }, + }), + ) + + const { contents } = await client.readResource({ uri: assemblyResultWidgetUri }) + expect(contents[0]?._meta).toMatchObject({ + ui: { + csp: { + connectDomains: ['https://cdn.example.com', 'https://*.example.net'], + resourceDomains: ['https://cdn.example.com', 'https://*.example.net'], + }, + }, + }) + }) + + it.each([ + [ + { maxRequestBodyBytes: '1MB' }, + 'maxRequestBodyBytes must be a positive integer number of bytes.', + ], + [ + { urlDownloadTimeoutMs: '10m' }, + 'urlDownloadTimeoutMs must be a positive integer number of milliseconds.', + ], + ])('refuses the config file %j', async (config, message) => { + const directory = await mkdtemp(join(tmpdir(), 'mcp-config-')) + const configPath = join(directory, 'config.json') + await writeFile(configPath, JSON.stringify(config)) + + const result = spawnSync(process.execPath, [cliPath, 'http', '--config', configPath], { + env: process.env, + encoding: 'utf8', + input: '', + timeout: 15000, + }) + await rm(directory, { recursive: true, force: true }) + + expect(result.status).toBe(1) + expect(`${result.stdout}${result.stderr}`).toContain(message) + }) + + it('refuses to start with an unsupported TRANSLOADIT_SIGNATURE_ALGORITHM', () => { + const result = spawnSync(process.execPath, [cliPath, 'stdio'], { + env: { ...process.env, TRANSLOADIT_SIGNATURE_ALGORITHM: 'md5' }, + encoding: 'utf8', + input: '', + }) + + expect(result.status).toBe(1) + // The CLI logger writes to stdout; the message may land on either stream. + expect(`${result.stdout}${result.stderr}`).toContain( + 'TRANSLOADIT_SIGNATURE_ALGORITHM must be one of sha1, sha256 or sha384.', + ) + }) +}) diff --git a/packages/mcp-server/test/unit/file-inputs.test.ts b/packages/mcp-server/test/unit/file-inputs.test.ts index 4aefd686..76e80836 100644 --- a/packages/mcp-server/test/unit/file-inputs.test.ts +++ b/packages/mcp-server/test/unit/file-inputs.test.ts @@ -40,6 +40,10 @@ describe('MCP file inputs', () => { delete serverOptions.authSecret delete serverOptions.endpoint delete serverOptions.mcpToken + delete serverOptions.consoleUrl + delete serverOptions.maxUrlDownloadBytes + delete serverOptions.resourceMetadataUrl + delete serverOptions.upstreamSecret fixtureDirectory = await mkdtemp(join(tmpdir(), 'mcp-test-')) fixturePath = join(fixtureDirectory, 'fixture.txt') await writeFile(fixturePath, fixtureContent) @@ -57,6 +61,8 @@ describe('MCP file inputs', () => { vi.spyOn(Transloadit.prototype, 'awaitAssemblyCompletion').mockResolvedValue({ ok: 'ASSEMBLY_COMPLETED', }) + // Forwarded tokens are checked with a Template list before any URL is downloaded. + vi.spyOn(Transloadit.prototype, 'listTemplates').mockResolvedValue({ items: [], count: 0 }) vi.spyOn(Transloadit.prototype, 'getTemplate').mockRejectedValue( new Error('Unexpected template lookup'), ) @@ -602,4 +608,202 @@ 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.each([ + [ + 'an inline Assembly', + undefined, + `https://transloadit.com/c/acme/templates/new?fromAssembly=${assemblyId}`, + ], + [ + 'a Template-based Assembly', + 'tpl_1', + `https://transloadit.com/c/acme/templates/new?fromAssembly=${assemblyId}&duplicateFrom=tpl_1`, + ], + ])('links Save as Template for %s to the Assembly it came from', async (_kind, templateId, newTemplateUrl) => { + vi.mocked(Transloadit.prototype.createAssembly).mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + assembly_id: assemblyId, + account_slug: 'acme', + template_id: templateId, + }) + 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: newTemplateUrl, + }, + }) + }) + + it('still returns the Assembly when the Console URL is malformed', async () => { + serverOptions.consoleUrl = 'not a url' + vi.mocked(Transloadit.prototype.createAssembly).mockResolvedValue({ + ok: 'ASSEMBLY_COMPLETED', + assembly_id: assemblyId, + account_slug: 'acme', + }) + 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', + assembly: { assembly_id: assemblyId }, + }) + expect(result._meta).toEqual({ 'transloadit/widget': { authenticated: true } }) + }) + + it('does not require Template access when /http/import fetches the URL', async () => { + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { source: { robot: '/http/import' } } }, + files: [{ kind: 'url', field: 'file', url: 'https://example.com/fixture.txt' }], + }, + }) + expect(result.structuredContent).toMatchObject({ status: 'ok' }) + expect(Transloadit.prototype.listTemplates).not.toHaveBeenCalled() + }) + + it('stops downloading a URL input above maxUrlDownloadBytes', async () => { + serverOptions.maxUrlDownloadBytes = 1024 + nock('http://198.51.100.10').get('/big.bin').reply(200, 'x'.repeat(4096)) + + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { ':original': { robot: '/upload/handle' } } }, + files: [{ kind: 'url', field: 'file', url: 'http://198.51.100.10/big.bin' }], + }, + }) + expect(result.structuredContent).toMatchObject({ + status: 'error', + errors: [{ message: 'URL downloads exceed 1024 bytes: http://198.51.100.10/big.bin' }], + }) + expect(Transloadit.prototype.createAssembly).not.toHaveBeenCalled() + }) + + it.each([ + ['the tunnel or devdock host', `https://devdock-kvz.transloadit.dev/assemblies/${assemblyId}`], + ['a trailing slash', `https://api2.transloadit.com/assemblies/${assemblyId}/`], + ['a bare Assembly ID', assemblyId], + ])('waits on an Assembly URL with %s, resolving it through the configured API', async (_kind, url) => { + const result = await client.callTool({ + name: 'transloadit_wait_for_assembly', + arguments: { assembly_url: url }, + }) + expect(result.structuredContent).toMatchObject({ status: 'ok' }) + expect(Transloadit.prototype.awaitAssemblyCompletion).toHaveBeenCalledWith( + assemblyId, + expect.objectContaining({ assemblyUrl }), + ) + }) + + it('accepts Assembly URLs on the public origin of the hosted resource metadata', async () => { + serverOptions.resourceMetadataUrl = + 'https://mcp.example.org/.well-known/oauth-protected-resource/mcp' + serverOptions.upstreamSecret = 'test-upstream-secret' + + const result = await client.callTool({ + name: 'transloadit_wait_for_assembly', + arguments: { assembly_url: `https://mcp.example.org/assemblies/${assemblyId}` }, + }) + expect(result.structuredContent).toMatchObject({ status: 'ok' }) + expect(Transloadit.prototype.awaitAssemblyCompletion).toHaveBeenCalledWith( + assemblyId, + expect.objectContaining({ assemblyUrl }), + ) + }) + + it.each([ + ['a foreign host', `https://evil.example/assemblies/${assemblyId}`], + ['a look-alike host', `https://transloadit.dev.evil.example/assemblies/${assemblyId}`], + ['credentials', `https://user:pass@api2.transloadit.com/assemblies/${assemblyId}`], + ['a non-Assembly path', `https://api2.transloadit.com/templates/${assemblyId}`], + ['a non-HTTP scheme', `ftp://api2.transloadit.com/assemblies/${assemblyId}`], + ])('still rejects an Assembly URL with %s', async (_kind, url) => { + const result = await client.callTool({ + name: 'transloadit_wait_for_assembly', + arguments: { assembly_url: url }, + }) + expect(result.structuredContent).toMatchObject({ + status: 'error', + errors: [{ code: 'mcp_invalid_args', path: 'assembly_url' }], + }) + expect(Transloadit.prototype.awaitAssemblyCompletion).not.toHaveBeenCalled() + }) }) 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..6d72aead --- /dev/null +++ b/packages/mcp-server/test/unit/hosted-auth.test.ts @@ -0,0 +1,403 @@ +import type { AddressInfo } from 'node:net' + +import { createServer } from 'node:http' + +import { afterEach, describe, expect, it } from 'vitest' + +import { createTransloaditMcpExpressRouter } from '../../src/express.ts' +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' +import { matchesOriginPattern } from '../../src/http-helpers.ts' +import { createTransloaditMcpServer } from '../../src/server.ts' + +type RunningServer = { url: URL; close: () => Promise } + +const resourceMetadataUrl = 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp' +const hosted = { resourceMetadataUrl, upstreamSecret: 'test-upstream-secret' } + +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(hosted) + + 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({ + name: 'Transloadit MCP Server', + status: 'ok', + docs: expect.stringContaining('transloadit.com'), + error: 'unauthorized', + error_description: expect.stringContaining('OAuth'), + }) + }) + + it('challenges SSE GETs without a bearer token', async () => { + running = await start(hosted) + + const stream = await fetch(running.url, { headers: { Accept: 'text/event-stream' } }) + expect(stream.status).toBe(401) + expect(stream.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ) + }) + + it('challenges a bare GET discovery probe and keeps the status fields in the body', async () => { + running = await start(hosted) + + // Codex probes exactly like this before it looks for protected-resource metadata. + const probe = await fetch(running.url, { + headers: { Accept: '*/*', 'Mcp-Protocol-Version': '2024-11-05' }, + }) + expect(probe.status).toBe(401) + expect(probe.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${resourceMetadataUrl}"`, + ) + await expect(probe.json()).resolves.toMatchObject({ + name: 'Transloadit MCP Server', + status: 'ok', + error: 'unauthorized', + }) + }) + + it('serves the bare GET status to authenticated hosted callers', async () => { + running = await start(hosted) + + const probe = await fetch(running.url, { headers: { Authorization: 'Bearer token' } }) + expect(probe.status).toBe(200) + await expect(probe.json()).resolves.toEqual({ + name: 'Transloadit MCP Server', + status: 'ok', + docs: 'https://transloadit.com/docs/sdks/mcp-server/', + }) + }) + + it('keeps the bare GET health probe at 200 outside hosted mode', async () => { + running = await start() + + 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(hosted) + + 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(hosted) + + const card = await fetch(new URL('/.well-known/mcp/server-card.json', running.url)) + expect(card.status).toBe(200) + const body = await card.json() + expect(body).toMatchObject({ + authentication: { required: true, schemes: ['oauth2', 'bearer'], resourceMetadataUrl }, + }) + expect( + body.tools.find((tool: { name: string }) => tool.name === 'transloadit_list_robots'), + ).toMatchObject({ securitySchemes: [{ type: 'oauth2', scopes: [] }] }) + }) + + it('advertises the documentation tools as noauth in a self-hosted server card', async () => { + running = await start() + + const card = await fetch(new URL('/.well-known/mcp/server-card.json', running.url)) + const body = await card.json() + expect( + body.tools.find((tool: { name: string }) => tool.name === 'transloadit_list_robots'), + ).toMatchObject({ securitySchemes: [{ type: 'noauth' }] }) + }) + + it('refuses hosted mode without the upstream secret API2 requires', () => { + // Every authenticated hosted call would fail; failing at startup shows up in health checks. + const message = + 'TRANSLOADIT_MCP_RESOURCE_METADATA_URL (hosted mode) requires TRANSLOADIT_MCP_UPSTREAM_SECRET' + expect(() => createTransloaditMcpHttpHandler({ resourceMetadataUrl })).toThrow(message) + expect(() => createTransloaditMcpExpressRouter({ resourceMetadataUrl })).toThrow(message) + expect(() => createTransloaditMcpServer({ resourceMetadataUrl })).toThrow(message) + }) + + it('refuses a static MCP token together with hosted OAuth', () => { + // The static token check would reject every OAuth token before the hosted challenge runs. + expect(() => + createTransloaditMcpHttpHandler({ mcpToken: 'static-secret', resourceMetadataUrl }), + ).toThrow( + 'Configure either TRANSLOADIT_MCP_TOKEN (self-hosted) or TRANSLOADIT_MCP_RESOURCE_METADATA_URL (hosted OAuth), not both.', + ) + expect(() => + createTransloaditMcpExpressRouter({ mcpToken: 'static-secret', resourceMetadataUrl }), + ).toThrow('not both') + }) +}) + +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(hosted) + + 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(hosted) + + 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(hosted) + + 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({ ...hosted, 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('*') + }) + + it.each([ + ['self-hosted', {}], + ['hosted', hosted], + ])('answers %s preflights for every Streamable HTTP request header', async (_mode, options) => { + running = await start(options) + + const preflight = await fetch(running.url, { + method: 'OPTIONS', + headers: { + Origin: 'http://localhost:8080', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': + 'authorization,content-type,mcp-protocol-version,mcp-session-id,last-event-id', + }, + }) + expect(preflight.status).toBe(204) + expect(preflight.headers.get('access-control-allow-headers')).toBe( + 'Authorization,Content-Type,Mcp-Protocol-Version,Mcp-Session-Id,Last-Event-ID', + ) + expect(preflight.headers.get('access-control-expose-headers')).toBe( + 'Mcp-Session-Id,WWW-Authenticate', + ) + }) +}) + +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) + }) +}) + +describe('wildcard origins with DNS rebinding protection', () => { + it.each([ + ['self-hosted', {}], + ['hosted', hosted], + ])('accepts a %s wildcard origin match while still checking the Host', async (_mode, mode) => { + const options: Parameters[0] = { + metricsPath: false, + allowedOrigins: ['http://localhost:*'], + enableDnsRebindingProtection: true, + ...mode, + } + const handler = createTransloaditMcpHttpHandler(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 + options.allowedHosts = [`127.0.0.1:${port}`] + const url = new URL(`http://127.0.0.1:${port}/mcp`) + + const allowed = await post(url, { + Origin: 'http://localhost:6274', + Authorization: 'Bearer token', + }) + expect(allowed.status).toBe(200) + + options.allowedHosts = ['api2.transloadit.com'] + const wrongHost = await post(url, { + Origin: 'http://localhost:6274', + Authorization: 'Bearer token', + }) + expect(wrongHost.status).toBe(403) + + await handler.close() + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + }) +}) + +describe('request body limits', () => { + let running: RunningServer | undefined + + afterEach(async () => { + await running?.close() + running = undefined + }) + + const oversized = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { pad: 'x'.repeat(4096) }, + }) + + it.each([ + ['self-hosted', {}], + ['hosted', hosted], + ])('answers 413 for %s bodies above the limit without buffering them', async (_mode, mode) => { + running = await start({ maxRequestBodyBytes: 1024, ...mode }) + + const response = await fetch(running.url, { + method: 'POST', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: oversized, + }) + expect(response.status).toBe(413) + }) + + it.each([0, -1, 1.5, Number.NaN])('refuses maxRequestBodyBytes %s', (maxRequestBodyBytes) => { + expect(() => createTransloaditMcpHttpHandler({ maxRequestBodyBytes })).toThrow( + 'maxRequestBodyBytes must be a positive integer number of bytes.', + ) + }) + + it('accepts bodies within the limit', async () => { + running = await start({ maxRequestBodyBytes: 8192, ...hosted }) + + const response = await post(running.url, { Authorization: 'Bearer token' }) + expect(response.status).toBe(200) + }) +}) diff --git a/packages/mcp-server/test/unit/plugin-manifest.test.ts b/packages/mcp-server/test/unit/plugin-manifest.test.ts new file mode 100644 index 00000000..5ba03b19 --- /dev/null +++ b/packages/mcp-server/test/unit/plugin-manifest.test.ts @@ -0,0 +1,48 @@ +import { readFile } from 'node:fs/promises' + +import { describe, expect, it } from 'vitest' +import { z } from 'zod' + +// Kept as a URL so checkout paths with `#` or spaces resolve correctly. +const packageRoot = new URL('../../', import.meta.url) + +const interfaceSchema = z.object({ + composerIcon: z.string(), + logo: z.string(), + screenshots: z.array(z.string()), +}) + +const readJson = async (path: string): Promise => + JSON.parse(await readFile(new URL(path, packageRoot), 'utf8')) + +const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +/** Plugin submission rejects manifests whose asset paths do not resolve to images. */ +const expectPng = async (assetPath: string): Promise => { + const bytes = await readFile(new URL(assetPath, packageRoot)) + expect([...bytes.subarray(0, 8)], assetPath).toEqual(pngSignature) +} + +describe('plugin manifests', () => { + it('ship every image the ChatGPT manifest references', async () => { + const manifest = z + .object({ extensions: z.object({ 'com.openai': z.object({ interface: interfaceSchema }) }) }) + .parse(await readJson('plugin.json')) + const { composerIcon, logo, screenshots } = manifest.extensions['com.openai'].interface + + await expectPng(composerIcon) + await expectPng(logo) + await Promise.all(screenshots.map(expectPng)) + }) + + it('ship every image the Codex manifest references', async () => { + const manifest = z + .object({ interface: interfaceSchema }) + .parse(await readJson('.codex-plugin/plugin.json')) + const { composerIcon, logo, screenshots } = manifest.interface + + await expectPng(composerIcon) + await expectPng(logo) + await Promise.all(screenshots.map(expectPng)) + }) +}) diff --git a/packages/mcp-server/test/unit/server-card-express.test.ts b/packages/mcp-server/test/unit/server-card-express.test.ts index 85be3d42..43245811 100644 --- a/packages/mcp-server/test/unit/server-card-express.test.ts +++ b/packages/mcp-server/test/unit/server-card-express.test.ts @@ -3,6 +3,7 @@ import type { AddressInfo } from 'node:net' import { createServer } from 'node:http' import express from 'express' +import nock from 'nock' import { afterEach, describe, expect, it } from 'vitest' import { createTransloaditMcpExpressRouter } from '../../src/express.ts' @@ -62,3 +63,150 @@ describe('server card (express router)', () => { expect(getRes.headers.get('access-control-allow-origin')).toBe('*') }) }) + +describe('hosted Express router', () => { + let running: RunningServer | undefined + + afterEach(async () => { + if (running) { + await running.close() + running = undefined + } + }) + + const startHostedRouter = async ( + extra: Parameters[0] = {}, + ): Promise => { + const app = express() + app.use( + createTransloaditMcpExpressRouter({ + path: '/mcp', + resourceMetadataUrl: + 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp', + upstreamSecret: 'test-upstream-secret', + ...extra, + }), + ) + const server = createServer(app) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + return { + baseUrl: new URL(`http://127.0.0.1:${port}`), + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())) + }), + } + } + + const postMcp = (body: string): Promise => + fetch(new URL('/mcp', running?.baseUrl), { + method: 'POST', + headers: { + Authorization: 'Bearer expired-oauth-token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body, + }) + + it('keeps an unparsed batch at 200 so the client does not replay it', async () => { + running = await startHostedRouter() + nock('https://api2.transloadit.com') + .get('/templates') + .query(true) + .reply(401, { error: 'BEARER_TOKEN_EXPIRED' }) + + const response = await postMcp( + JSON.stringify([ + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'transloadit_list_robots', arguments: { limit: 1 } }, + }, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'transloadit_list_templates', arguments: {} }, + }, + ]), + ) + expect(response.status).toBe(200) + expect(await response.text()).toContain('mcp_auth_rejected') + nock.cleanAll() + }) + + it('refuses unparsed bodies above the configured limit', async () => { + running = await startHostedRouter({ maxRequestBodyBytes: 1024 }) + + const response = await postMcp( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { pad: 'x'.repeat(4096) }, + }), + ) + expect(response.status).toBe(413) + }) + + it('refuses unparsed self-hosted bodies above the configured limit', async () => { + running = await startHostedRouter({ resourceMetadataUrl: undefined, maxRequestBodyBytes: 1024 }) + + const response = await postMcp( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { pad: 'x'.repeat(4096) }, + }), + ) + expect(response.status).toBe(413) + }) + + it('serves unparsed self-hosted bodies within the limit', async () => { + running = await startHostedRouter({ resourceMetadataUrl: undefined }) + + const response = await postMcp( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + ) + expect(response.status).toBe(200) + expect(await response.text()).toContain('transloadit_list_robots') + }) + + it('reads the request body itself when no JSON body parser is installed', async () => { + const app = express() + app.use( + createTransloaditMcpExpressRouter({ + path: '/mcp', + resourceMetadataUrl: + 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp', + upstreamSecret: 'test-upstream-secret', + }), + ) + const server = createServer(app) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + running = { + baseUrl: new URL(`http://127.0.0.1:${port}`), + close: () => + new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())) + }), + } + + const response = await fetch(new URL('/mcp', running.baseUrl), { + method: 'POST', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }) + expect(response.status).toBe(200) + expect(await response.text()).toContain('transloadit_list_robots') + }) +}) diff --git a/packages/mcp-server/test/unit/signature-algorithm.test.ts b/packages/mcp-server/test/unit/signature-algorithm.test.ts new file mode 100644 index 00000000..4de98173 --- /dev/null +++ b/packages/mcp-server/test/unit/signature-algorithm.test.ts @@ -0,0 +1,84 @@ +import type { TransloaditMcpServerOptions } from '../../src/server.ts' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import nock from 'nock' +import { afterEach, describe, expect, it } from 'vitest' + +import { createTransloaditMcpServer } from '../../src/server.ts' +import { parseToolPayload } from '../e2e/mcp-client.ts' + +const endpoint = 'https://api2.transloadit.com' + +const connect = async (options: TransloaditMcpServerOptions): Promise => { + const server = createTransloaditMcpServer({ authKey: 'key', authSecret: 'secret', ...options }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'signature-algorithm', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return client +} + +/** Captures the `signature` query parameter API2 receives for `GET /templates`. */ +const captureTemplatesSignature = (): { signature: () => string | undefined } => { + let signature: string | undefined + nock(endpoint) + .get('/templates') + .query((query) => { + signature = typeof query.signature === 'string' ? query.signature : undefined + return true + }) + .reply(200, { items: [], count: 0 }) + return { signature: () => signature } +} + +describe('key/secret signature algorithm', () => { + let client: Client | undefined + + afterEach(async () => { + await client?.close() + client = undefined + nock.cleanAll() + }) + + it.each([ + ['sha1'], + ['sha256'], + ['sha384'], + ] as const)('signs with %s when configured', async (signatureAlgorithm) => { + const captured = captureTemplatesSignature() + client = await connect({ signatureAlgorithm }) + + const result = await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + expect(result.structuredContent).toMatchObject({ status: 'ok' }) + expect(captured.signature()).toMatch(new RegExp(`^${signatureAlgorithm}:[0-9a-f]+$`)) + }) + + it('keeps the SDK default of sha384 when nothing is configured', async () => { + const captured = captureTemplatesSignature() + client = await connect({}) + + await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + expect(captured.signature()).toMatch(/^sha384:/) + }) + + it('tells the caller which algorithm the Auth Key requires', async () => { + nock(endpoint).get('/templates').query(true).reply(400, { + error: 'INVALID_SIGNATURE', + message: 'The given signature does not match ours. This Auth Key requires sha256.', + }) + client = await connect({}) + + await client.listTools() + const result = await client.callTool({ name: 'transloadit_list_templates', arguments: {} }) + expect(result.isError).toBe(true) + expect(parseToolPayload(result)).toMatchObject({ + status: 'error', + errors: [ + { + code: 'mcp_invalid_signature', + hint: expect.stringContaining('TRANSLOADIT_SIGNATURE_ALGORITHM=sha256'), + }, + ], + }) + }) +}) 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..767a8afe --- /dev/null +++ b/packages/mcp-server/test/unit/tool-surface.test.ts @@ -0,0 +1,352 @@ +import type { AddressInfo } from 'node:net' + +import { createServer } from 'node:http' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' +import { createTransloaditMcpServer } from '../../src/server.ts' +import { toolNames } from '../../src/tool-metadata.ts' +import { + 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' + +// Transloadit result buckets plus Cloudflare R2 public buckets, where API2 also stores results. +const resultDomains = ['https://*.transloadit.com', 'https://*.transloadit.net', 'https://*.r2.dev'] + +// 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, + upstreamSecret: 'test-upstream-secret', + }) + 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: expect.any(Boolean), + 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, + ) + } + }) + + // The hosted endpoint answers every unauthenticated request with the OAuth challenge, so even + // the documentation tools are declared oauth2 there, with no scopes. + it.each([ + ['transloadit_list_robots', [{ type: 'oauth2', scopes: [] }]], + ['transloadit_get_robot_help', [{ type: 'oauth2', scopes: [] }]], + ['transloadit_lint_assembly_instructions', [{ type: 'oauth2', scopes: [] }]], + [ + 'transloadit_create_assembly', + [{ type: 'oauth2', scopes: ['assemblies:write', 'templates:read'] }], + ], + ['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: ['assemblies:read', 'templates:read'] }], + ], + ])('hosted %s declares %j', async (name, securitySchemes) => { + const tool = findTool(await listTools(), name) + expect(tool.securitySchemes).toEqual(securitySchemes) + }) + + it('marks only Assembly creation as open-world and 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']) + // Export Robots such as /s3/store can overwrite files at the destination. + const destructive = tools.filter( + (tool) => isRecord(tool.annotations) && tool.annotations.destructiveHint === true, + ) + expect(destructive.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: resultDomains, resourceDomains: resultDomains }, + }, + 'openai/widgetDescription': expect.any(String), + 'openai/widgetCSP': { + connect_domains: resultDomains, + resource_domains: resultDomains, + }, + }, + }) + + 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=/) + }) +}) + +describe('result widget domains', () => { + it('uses configured result domains for the widget CSP', async () => { + const server = createTransloaditMcpServer({ resultDomains: ['https://cdn.example.com'] }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'result-domains', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + + const { contents } = await client.readResource({ uri: assemblyResultWidgetUri }) + expect(contents[0]?._meta).toMatchObject({ + ui: { + csp: { + connectDomains: ['https://cdn.example.com'], + resourceDomains: ['https://cdn.example.com'], + }, + }, + 'openai/widgetCSP': { + connect_domains: ['https://cdn.example.com'], + resource_domains: ['https://cdn.example.com'], + }, + }) + await client.close() + await server.close() + }) +}) + +// The SDK client drops Tool fields it does not know, such as the top-level securitySchemes. +const rawToolsListSchema = z.object({ tools: z.array(z.record(z.string(), z.unknown())) }) + +const connectInMemory = async ( + server: ReturnType, +): Promise => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'tool-surface', version: '1.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return client +} + +const listRawTools = async ( + options: Parameters[0], +): Promise => { + const server = createTransloaditMcpServer(options) + const client = await connectInMemory(server) + const { tools } = await client.request({ method: 'tools/list' }, rawToolsListSchema) + await client.close() + await server.close() + return tools +} + +const schemesByName = (tools: JsonRecord[]): Record => + Object.fromEntries(tools.map((tool) => [tool.name, tool.securitySchemes])) + +describe('security schemes outside hosted mode', () => { + it('declares the documentation tools noauth and the account tools oauth2 without credentials', async () => { + const schemes = schemesByName(await listRawTools({})) + + expect(schemes.transloadit_list_robots).toEqual([{ type: 'noauth' }]) + expect(schemes.transloadit_get_robot_help).toEqual([{ type: 'noauth' }]) + expect(schemes.transloadit_lint_assembly_instructions).toEqual([{ type: 'noauth' }]) + expect(schemes.transloadit_create_assembly).toEqual([ + { type: 'oauth2', scopes: ['assemblies:write', 'templates:read'] }, + ]) + expect(schemes.transloadit_list_templates).toEqual([ + { type: 'oauth2', scopes: ['templates:read'] }, + ]) + }) + + it('declares every tool noauth when the server holds Auth Key credentials', async () => { + const tools = await listRawTools({ authKey: 'key', authSecret: 'secret' }) + + expect(new Set(tools.map((tool) => JSON.stringify(tool.securitySchemes)))).toEqual( + new Set([JSON.stringify([{ type: 'noauth' }])]), + ) + }) +}) + +describe('tools registered after creation', () => { + it('stay discoverable, with their own securitySchemes mirrored to the top level', async () => { + const server = createTransloaditMcpServer({}) + server.registerTool( + 'custom_echo', + { + description: 'Echo for embedders', + inputSchema: z.object({ text: z.string() }), + _meta: { securitySchemes: [{ type: 'noauth' }] }, + }, + ({ text }) => ({ content: [{ type: 'text', text }] }), + ) + const client = await connectInMemory(server) + + const { tools } = await client.request({ method: 'tools/list' }, rawToolsListSchema) + const custom = tools.find((tool) => tool.name === 'custom_echo') + expect(custom).toMatchObject({ + description: 'Echo for embedders', + securitySchemes: [{ type: 'noauth' }], + }) + expect(tools).toHaveLength(toolNames.length + 1) + await client.close() + await server.close() + }) +}) + +describe('direct server construction', () => { + it.each([ + [ + { maxUrlDownloadBytes: Number.NaN }, + 'maxUrlDownloadBytes must be a positive integer number of bytes.', + ], + [ + { urlDownloadTimeoutMs: 0 }, + 'urlDownloadTimeoutMs must be a positive integer number of milliseconds.', + ], + ])('refuses %j', (options, message) => { + expect(() => createTransloaditMcpServer(options)).toThrow(message) + }) +}) diff --git a/packages/mcp-server/test/unit/upload-instructions.test.ts b/packages/mcp-server/test/unit/upload-instructions.test.ts new file mode 100644 index 00000000..a8b3a27f --- /dev/null +++ b/packages/mcp-server/test/unit/upload-instructions.test.ts @@ -0,0 +1,240 @@ +import type { AddressInfo } from 'node:net' + +import type { TransloaditMcpHttpOptions } from '../../src/http.ts' + +import { execFile, spawnSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +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' +import { parseToolPayload } from '../e2e/mcp-client.ts' + +const assemblyId = '0123456789abcdef0123456789abcdef' +const assemblyUrl = `https://api2.transloadit.com/assemblies/${assemblyId}` +const hasCurl = spawnSync('curl', ['--version']).status === 0 + +// Async on purpose: the tus stub runs in this process, so a synchronous spawn would deadlock. +const runBash = promisify(execFile) + +type RecordedUpload = { + url: string | undefined + headers: Record + body: Buffer +} + +const decodeMetadata = (header: string): Record => + Object.fromEntries( + header.split(',').map((pair) => { + const [key, value = ''] = pair.split(' ') + return [key, Buffer.from(value, 'base64').toString('utf8')] + }), + ) + +describe('upload_instructions for files that exist only in the caller sandbox', () => { + const serverOptions: TransloaditMcpHttpOptions = { metricsPath: false } + const handler = createTransloaditMcpHttpHandler(serverOptions) + const recorded: RecordedUpload[] = [] + // One listener serves the MCP endpoint and a tus stub, like a sandbox would reach Transloadit. + const httpServer = createServer((req, res) => { + if (req.url?.startsWith('/resumable/files/')) { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => { + recorded.push({ url: req.url, headers: req.headers, body: Buffer.concat(chunks) }) + res.writeHead(201, { 'Upload-Offset': String(Buffer.concat(chunks).length) }) + res.end() + }) + return + } + void handler(req, res) + }) + let origin: string + let client: Client + + const connect = async (headers: Record = {}): Promise => { + client = new Client({ name: 'upload-instructions', version: '1.0.0' }) + await client.connect( + new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { requestInit: { headers } }), + ) + } + + const createWithExpectedUploads = async (args: Record) => + parseToolPayload( + await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { ':original': { robot: '/upload/handle' } } }, + ...args, + }, + }), + ) + + beforeEach(async () => { + delete serverOptions.authKey + delete serverOptions.authSecret + delete serverOptions.upstreamSecret + recorded.length = 0 + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + origin = `http://127.0.0.1:${port}` + vi.spyOn(Transloadit.prototype, 'createAssembly').mockResolvedValue({ + ok: 'ASSEMBLY_UPLOADING', + assembly_id: assemblyId, + assembly_ssl_url: assemblyUrl, + tus_url: `${origin}/resumable/files/`, + }) + }) + + afterEach(async () => { + await client?.close() + await handler.close() + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ) + vi.restoreAllMocks() + }) + + it('returns one tus upload per expected file and does not wait for them', async () => { + await connect({ Authorization: 'Bearer forwarded-token' }) + + const payload = await createWithExpectedUploads({ + expected_uploads: 2, + wait_for_completion: true, + }) + + expect(payload.upload_instructions).toEqual([ + { + fieldname: 'file_1', + tus_endpoint: `${origin}/resumable/files/`, + metadata: { assembly_url: assemblyUrl, fieldname: 'file_1' }, + curl: expect.stringContaining( + `--request-target '/resumable/files/' '${origin}/resumable/files/'`, + ), + }, + { + fieldname: 'file_2', + tus_endpoint: `${origin}/resumable/files/`, + metadata: { assembly_url: assemblyUrl, fieldname: 'file_2' }, + curl: expect.stringContaining("-H 'Tus-Resumable: 1.0.0'"), + }, + ]) + expect(payload.next_steps).toEqual(['transloadit_wait_for_assembly']) + expect(payload.warnings).toEqual([ + expect.objectContaining({ code: 'mcp_wait_skipped_for_uploads' }), + ]) + expect(Transloadit.prototype.createAssembly).toHaveBeenCalledWith( + expect.objectContaining({ expectedUploads: 2, waitForCompletion: false }), + ) + }) + + it('counts files sent in the same call against the expected uploads', async () => { + await connect({ Authorization: 'Bearer forwarded-token' }) + + const payload = await createWithExpectedUploads({ + expected_uploads: 2, + files: [{ kind: 'base64', field: 'inline', base64: 'aGk=', filename: 'inline.txt' }], + }) + + expect(payload.upload_instructions).toHaveLength(1) + }) + + it('skips field names already used by files sent in the same call', async () => { + await connect({ Authorization: 'Bearer forwarded-token' }) + + const payload = await createWithExpectedUploads({ + expected_uploads: 2, + files: [{ kind: 'base64', field: 'file_1', base64: 'aGk=', filename: 'inline.txt' }], + }) + + expect(payload.upload_instructions).toEqual([expect.objectContaining({ fieldname: 'file_2' })]) + }) + + it('refuses more expected uploads than one call can describe', async () => { + await connect({ Authorization: 'Bearer forwarded-token' }) + + const result = await client.callTool({ + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { ':original': { robot: '/upload/handle' } } }, + expected_uploads: 101, + }, + }) + + expect(result.isError).toBe(true) + expect(Transloadit.prototype.createAssembly).not.toHaveBeenCalled() + }) + + it('returns no upload instructions without expected_uploads', async () => { + await connect({ Authorization: 'Bearer forwarded-token' }) + + const payload = await createWithExpectedUploads({}) + + expect(payload.upload_instructions).toBeUndefined() + }) + + it.skipIf(!hasCurl)( + 'gives a curl command that uploads any filename with tus creation-with-upload', + async () => { + await connect({ Authorization: 'Bearer forwarded-token' }) + const payload = await createWithExpectedUploads({ expected_uploads: 1 }) + const [instruction] = payload.upload_instructions as Array<{ curl: string }> + const directory = await mkdtemp(join(tmpdir(), 'mcp-sandbox-')) + const filePath = join(directory, 'snow flake é.png') + await writeFile(filePath, Buffer.from('fake image bytes é')) + + // Agents replace the FILE placeholder with their path, like this. + const script = instruction.curl.replace("FILE='/path/to/the/file'", `FILE='${filePath}'`) + const run = await runBash('bash', ['-c', script], { timeout: 20000 }) + const fileBytes = await readFile(filePath) + await rm(directory, { recursive: true, force: true }) + + expect(run.stdout.trim()).toBe('201') + // Streamed from disk (curl buffers --data-binary files in memory), to the exact tus path. + expect(instruction.curl).toContain('-T "$FILE"') + expect(instruction.curl).not.toContain('--data-binary') + expect(recorded).toHaveLength(1) + const [upload] = recorded + expect(upload?.url).toBe('/resumable/files/') + expect(upload?.headers['tus-resumable']).toBe('1.0.0') + expect(upload?.headers['content-type']).toBe('application/offset+octet-stream') + expect(upload?.headers['upload-length']).toBe(String(fileBytes.length)) + expect(decodeMetadata(String(upload?.headers['upload-metadata']))).toEqual({ + assembly_url: assemblyUrl, + fieldname: 'file_1', + filename: 'snow flake é.png', + }) + expect(upload?.body.equals(fileBytes)).toBe(true) + }, + ) + + it.each([ + ['a forwarded bearer token', { Authorization: 'Bearer forwarded-secret-token' }], + ['server-side Auth Key credentials', {}], + ])('never puts credentials into the instructions with %s', async (_kind, headers) => { + serverOptions.authKey = 'auth-key-0123' + serverOptions.authSecret = 'auth-secret-4567' + serverOptions.upstreamSecret = 'upstream-secret-89' + await connect(headers) + + const payload = await createWithExpectedUploads({ expected_uploads: 1 }) + const instructions = JSON.stringify(payload.upload_instructions) + + expect(instructions).toContain('file_1') + for (const secret of [ + 'forwarded-secret-token', + 'auth-key-0123', + 'auth-secret-4567', + 'upstream-secret-89', + ]) { + expect(instructions).not.toContain(secret) + } + }) +}) diff --git a/packages/mcp-server/test/unit/upstream-auth-status.test.ts b/packages/mcp-server/test/unit/upstream-auth-status.test.ts new file mode 100644 index 00000000..358b6a04 --- /dev/null +++ b/packages/mcp-server/test/unit/upstream-auth-status.test.ts @@ -0,0 +1,275 @@ +import type { AddressInfo } from 'node:net' + +import type { TransloaditMcpHttpOptions } from '../../src/http.ts' + +import { createServer } from 'node:http' + +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createTransloaditMcpHttpHandler } from '../../src/http.ts' + +const resourceMetadataUrl = 'https://api2.transloadit.com/.well-known/oauth-protected-resource/mcp' +const endpoint = 'https://api2.transloadit.com' + +const listTemplatesCall = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'transloadit_list_templates', arguments: {} }, +}) + +/** + * MCP OAuth clients refresh a token only on HTTP 401 and re-scope only on HTTP 403 with + * `error="insufficient_scope"`, so an upstream rejection of a forwarded token must surface at the + * HTTP level, not only inside the tool result. + */ +describe('upstream token rejections over HTTP', () => { + const serverOptions: TransloaditMcpHttpOptions = { metricsPath: false } + const handler = createTransloaditMcpHttpHandler(serverOptions) + const httpServer = createServer((req, res) => { + void handler(req, res) + }) + let url: URL + + const callListTemplates = (): Promise => + fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer expired-oauth-token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Protocol-Version': '2025-06-18', + }, + body: listTemplatesCall, + }) + + beforeEach(async () => { + delete serverOptions.resourceMetadataUrl + // Hosted mode requires the upstream secret; it is harmless when a test runs self-hosted. + serverOptions.upstreamSecret = 'test-upstream-secret' + 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 handler.close() + await new Promise((resolve, reject) => + httpServer.close((error) => (error ? reject(error) : resolve())), + ) + nock.cleanAll() + }) + + it('answers 401 with an invalid_token challenge when API2 rejects a hosted token', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint).get('/templates').query(true).reply(401, { error: 'BEARER_TOKEN_EXPIRED' }) + + const response = await callListTemplates() + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token", error_description="Transloadit rejected the credentials; the token may have expired."`, + ) + }) + + it('answers 403 with the tool scopes when API2 rejects a hosted token for scope', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint).get('/templates').query(true).reply(403, { error: 'INSUFFICIENT_AUTH_SCOPE' }) + + const response = await callListTemplates() + expect(response.status).toBe(403) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${resourceMetadataUrl}", error="insufficient_scope", error_description="The connected credentials lack the scope this tool needs.", scope="templates:read"`, + ) + }) + + it('keeps successful hosted calls at 200', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint).get('/templates').query(true).reply(200, { items: [], count: 0 }) + + const response = await callListTemplates() + expect(response.status).toBe(200) + expect(await response.text()).toContain('"status":"ok"') + }) + + it('keeps a batch at 200 so a client never replays the calls that already succeeded', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint).get('/templates').query(true).reply(401, { error: 'BEARER_TOKEN_EXPIRED' }) + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer expired-oauth-token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify([ + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'transloadit_list_robots', arguments: { limit: 1 } }, + }, + { ...JSON.parse(listTemplatesCall), id: 2 }, + ]), + }) + expect(response.status).toBe(200) + expect(response.headers.get('www-authenticate')).toBeNull() + const body = await response.text() + expect(body).toContain('"status":"ok"') + expect(body).toContain('mcp_auth_rejected') + }) + + it('reports an Auth Key mismatch as a tool error, not an OAuth challenge', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint) + .post(/\/assemblies/) + .reply(403, { + error: 'BEARER_TOKEN_AUTH_KEY_MISMATCH', + message: 'Bearer token auth key mismatch', + }) + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer expired-oauth-token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'transloadit_create_assembly', + arguments: { + instructions: { + auth: { key: 'another-auth-key' }, + steps: { resized: { robot: '/image/resize', width: 1 } }, + }, + }, + }, + }), + }) + expect(response.status).toBe(200) + expect(response.headers.get('www-authenticate')).toBeNull() + const body = await response.text() + expect(body).toContain('mcp_auth_key_mismatch') + expect(body).not.toContain('mcp/www_authenticate') + }) + + const createAssemblyCall = (): Promise => + fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer expiring-oauth-token', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { resized: { robot: '/image/resize', width: 1 } } }, + wait_for_completion: true, + }, + }, + }), + }) + + it('does not ask for a replay when the token expires after the Assembly was created', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + let createdId: string | undefined + nock(endpoint) + .post(/\/assemblies\/[a-f\d]{32}$/) + .reply((uri) => { + createdId = uri.split('/').at(-1) + return [ + 200, + { + ok: 'ASSEMBLY_EXECUTING', + assembly_id: createdId, + assembly_ssl_url: `${endpoint}/assemblies/${createdId}`, + }, + ] + }) + nock(endpoint) + .get(/\/assemblies\/[a-f\d]{32}$/) + .query(true) + .reply(401, { error: 'BEARER_TOKEN_EXPIRED' }) + + const response = await createAssemblyCall() + expect(response.status).toBe(200) + expect(response.headers.get('www-authenticate')).toBeNull() + const body = await response.text() + expect(body).toContain('mcp_assembly_status_unavailable') + expect(body).toContain(`${endpoint}/assemblies/${createdId}`) + expect(body).not.toContain('mcp/www_authenticate') + }) + + it('still challenges when API2 rejects the token on the creation request itself', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint) + .post(/\/assemblies\/[a-f\d]{32}$/) + .reply(401, { error: 'BEARER_TOKEN_EXPIRED' }) + + const response = await createAssemblyCall() + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toContain('error="invalid_token"') + }) + + it('reports a creation response error itself, not a created-but-unreadable Assembly', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint) + .post(/\/assemblies\/[a-f\d]{32}$/) + .reply(200, { + error: 'INVALID_SIGNATURE', + message: 'The given signature does not match ours. This Auth Key requires sha256.', + }) + + const response = await createAssemblyCall() + const body = await response.text() + expect(body).toContain('mcp_invalid_signature') + expect(body).not.toContain('mcp_assembly_status_unavailable') + }) + + it('checks a forwarded token before downloading any URL input', async () => { + serverOptions.resourceMetadataUrl = resourceMetadataUrl + nock(endpoint).get('/templates').query(true).reply(401, { error: 'BEARER_TOKEN_INVALID' }) + const download = nock('http://198.51.100.10').get('/big.bin').reply(200, 'payload') + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: 'Bearer junk-token-that-api2-rejects', + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'transloadit_create_assembly', + arguments: { + instructions: { steps: { ':original': { robot: '/upload/handle' } } }, + files: [{ kind: 'url', field: 'file', url: 'http://198.51.100.10/big.bin' }], + }, + }, + }), + }) + expect(response.status).toBe(401) + expect(download.isDone()).toBe(false) + }) + + it('keeps self-hosted rejections as tool errors over HTTP 200', async () => { + nock(endpoint).get('/templates').query(true).reply(401, { error: 'BEARER_TOKEN_EXPIRED' }) + + const response = await callListTemplates() + expect(response.status).toBe(200) + expect(await response.text()).toContain('mcp_auth_rejected') + }) +}) 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/mcp-server/test/unit/widget-handshake.test.ts b/packages/mcp-server/test/unit/widget-handshake.test.ts new file mode 100644 index 00000000..0245f044 --- /dev/null +++ b/packages/mcp-server/test/unit/widget-handshake.test.ts @@ -0,0 +1,296 @@ +import { Window } from 'happy-dom' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import packageJson from '../../package.json' with { type: 'json' } +import { + assemblyResultWidgetHtml, + widgetContextMetaKey, +} from '../../src/ui/assembly-result-widget.ts' + +type JsonRpcMessage = Record + +const hostInfo = { name: 'TestHost', version: '9.9.9' } + +/** + * Loads the real widget document in a DOM whose `window.parent` is a fake MCP Apps host, so the + * test sees exactly the JSON-RPC messages the inline script posts. + */ +const mountWidget = ( + options: { maxTimeout?: number } = {}, +): { + window: Window + sent: JsonRpcMessage[] + fromHost: (message: JsonRpcMessage) => Promise + appText: () => string +} => { + const window = new Window({ + url: 'https://sandbox.example/', + // The script under test is our own widget, so evaluating it in the VM is intended. + settings: { + enableJavaScriptEvaluation: true, + suppressInsecureJavaScriptEnvironmentWarning: true, + ...(options.maxTimeout === undefined ? {} : { timer: { maxTimeout: options.maxTimeout } }), + }, + }) + const sent: JsonRpcMessage[] = [] + const host = { postMessage: (message: JsonRpcMessage) => sent.push(message) } + Object.defineProperty(window, 'parent', { value: host, configurable: true }) + window.document.write(assemblyResultWidgetHtml) + + const fromHost = async (message: JsonRpcMessage): Promise => { + window.dispatchEvent(new window.MessageEvent('message', { data: message, source: host })) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + const appText = (): string => window.document.getElementById('app')?.textContent ?? '' + return { window, sent, fromHost, appText } +} + +const withoutSizeChanges = (messages: JsonRpcMessage[]): JsonRpcMessage[] => + messages.filter((message) => message.method !== 'ui/notifications/size-changed') + +describe('assembly result widget handshake (MCP Apps 2026-01-26)', () => { + let widget: ReturnType + + beforeEach(() => { + widget = mountWidget() + }) + + afterEach(async () => { + await widget.window.happyDOM.close() + }) + + const initialize = async (): Promise => { + await widget.fromHost({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2026-01-26', + hostInfo, + hostCapabilities: { openLinks: {} }, + hostContext: { theme: 'dark', displayMode: 'inline' }, + }, + }) + } + + it('opens with ui/initialize carrying appInfo, appCapabilities and protocolVersion', () => { + expect(withoutSizeChanges(widget.sent)).toEqual([ + { + jsonrpc: '2.0', + id: 1, + method: 'ui/initialize', + params: { + appCapabilities: {}, + appInfo: { name: 'transloadit-assembly-result', version: packageJson.version }, + protocolVersion: '2026-01-26', + }, + }, + ]) + }) + + it('confirms with a param-less ui/notifications/initialized and applies the host theme', async () => { + await initialize() + + expect(withoutSizeChanges(widget.sent).at(-1)).toEqual({ + jsonrpc: '2.0', + method: 'ui/notifications/initialized', + }) + expect(widget.window.document.documentElement.dataset.theme).toBe('dark') + }) + + it('renders tool-input progress, then the tool-result previews and actions', async () => { + await initialize() + await widget.fromHost({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-input', + params: { arguments: { instructions: { steps: {} } } }, + }) + expect(widget.appText()).toBe('Running the Assembly…') + + await widget.fromHost({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + content: [{ type: 'text', text: '{}' }], + structuredContent: { + status: 'ok', + assembly: { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'abc123', + results: { + resized: [ + { + name: 'pixel.png', + mime: 'image/png', + ssl_url: 'https://pub-123.r2.dev/pixel.png', + width: 1, + height: 1, + }, + ], + }, + }, + }, + _meta: { + [widgetContextMetaKey]: { + authenticated: true, + assembly_console_url: 'https://transloadit.com/c/acme/assemblies/abc123', + new_template_url: 'https://transloadit.com/c/acme/templates/new', + }, + }, + }, + }) + + const document = widget.window.document + expect(widget.appText()).toContain('Assembly abc123') + expect(widget.appText()).toContain('ASSEMBLY_COMPLETED') + expect(document.querySelector('img')?.getAttribute('src')).toBe( + 'https://pub-123.r2.dev/pixel.png', + ) + expect(widget.appText()).toContain('Download pixel.png') + expect(widget.appText()).toContain('Save as Template') + expect(widget.appText()).toContain('Open in Console') + }) + + it('opens download links through the host so sandboxed frames can still download', async () => { + await initialize() + await widget.fromHost({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + structuredContent: { + status: 'ok', + assembly: { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'abc123', + results: { + resized: [ + { name: 'clip.mp4', mime: 'video/mp4', ssl_url: 'https://pub-123.r2.dev/clip.mp4' }, + ], + }, + }, + }, + }, + }) + + const link = widget.window.document.querySelector('a[download]') + const click = new widget.window.MouseEvent('click', { bubbles: true, cancelable: true }) + link?.dispatchEvent(click) + + expect(click.defaultPrevented).toBe(true) + expect(widget.sent.find((message) => message.method === 'ui/open-link')).toMatchObject({ + params: { url: 'https://pub-123.r2.dev/clip.mp4' }, + }) + }) + + it('shows the recovery advice when a created Assembly could not be read', async () => { + await initialize() + await widget.fromHost({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + isError: true, + structuredContent: { + status: 'error', + assembly: { + assembly_id: 'abc123', + assembly_ssl_url: 'https://api2.transloadit.com/assemblies/abc123', + }, + errors: [ + { + code: 'mcp_assembly_status_unavailable', + message: 'The Assembly was created, but its status could not be read.', + hint: 'Call transloadit_get_assembly_status instead of creating it again.', + }, + ], + }, + }, + }) + + expect(widget.appText()).toContain('Assembly abc123') + expect(widget.appText()).toContain( + 'The Assembly was created, but its status could not be read.', + ) + expect(widget.appText()).toContain( + 'Call transloadit_get_assembly_status instead of creating it again.', + ) + }) + + it('shows the error text of a failed tool call instead of waiting forever', async () => { + await initialize() + await widget.fromHost({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + isError: true, + content: [{ type: 'text', text: 'API error (HTTP 400) INVALID_SIGNATURE' }], + }, + }) + + expect(widget.appText()).toBe('API error (HTTP 400) INVALID_SIGNATURE') + }) + + it('answers ping and ui/resource-teardown, and rejects unknown host requests', async () => { + await initialize() + await widget.fromHost({ jsonrpc: '2.0', id: 7, method: 'ping' }) + await widget.fromHost({ jsonrpc: '2.0', id: 8, method: 'ui/resource-teardown', params: {} }) + await widget.fromHost({ jsonrpc: '2.0', id: 9, method: 'ui/unknown', params: {} }) + + expect(withoutSizeChanges(widget.sent).slice(-3)).toEqual([ + { jsonrpc: '2.0', id: 7, result: {} }, + { jsonrpc: '2.0', id: 8, result: {} }, + { jsonrpc: '2.0', id: 9, error: { code: -32601, message: 'Method not found: ui/unknown' } }, + ]) + }) + + it('reports a rejected ui/initialize instead of staying on the waiting message', async () => { + await widget.fromHost({ + jsonrpc: '2.0', + id: 1, + error: { code: -32602, message: 'Invalid params for ui/initialize' }, + }) + + expect(widget.appText()).toBe('This host could not start the Assembly result view.') + }) +}) + +describe('assembly result widget previews', () => { + it('retries a preview that is not readable yet, then says so', async () => { + // Collapse the widget's 1 s / 3 s / 6 s retry delays. + const widget = mountWidget({ maxTimeout: 0 }) + const resultUrl = 'https://pub-123.r2.dev/resized.jpg' + await widget.fromHost({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: '2026-01-26', hostInfo, hostCapabilities: {}, hostContext: {} }, + }) + await widget.fromHost({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + structuredContent: { + status: 'ok', + assembly: { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'abc123', + results: { resized: [{ name: 'resized.jpg', mime: 'image/jpeg', ssl_url: resultUrl }] }, + }, + }, + }, + }) + + const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 5)) + const image = widget.window.document.querySelector('img') + image?.dispatchEvent(new widget.window.Event('error')) + await settle() + expect(image?.getAttribute('src')).toBe(resultUrl) + image?.dispatchEvent(new widget.window.Event('error')) + await settle() + image?.dispatchEvent(new widget.window.Event('error')) + await settle() + image?.dispatchEvent(new widget.window.Event('error')) + await settle() + + expect(widget.appText()).toContain('Preview not available yet') + expect(widget.appText()).toContain('Download resized.jpg') + await widget.window.happyDOM.close() + }) +}) diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index c1516cc0..3b9287db 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -5,7 +5,13 @@ import type { CompileAssemblyInstructionsResult, } from '@transloadit/utils' import type { SignatureAlgorithm } from '@transloadit/utils/node' -import type { Delays, Headers, OptionsOfJSONResponseBody, RetryOptions } from 'got' +import type { + BeforeRedirectHook, + Delays, + Headers, + OptionsOfJSONResponseBody, + RetryOptions, +} from 'got' import type { Input as IntoStreamInput } from 'into-stream' import type { TransloaditErrorResponseBody } from './ApiError.ts' @@ -296,6 +302,11 @@ export interface CreateAssemblyOptions extends AssemblyUploadOptions { * Expected number of tus uploads when files will be uploaded separately. */ expectedUploads?: number + /** + * Called once API2 has accepted the creation request, before uploads and polling. Callers that + * must not create an Assembly twice use it to tell later failures apart from rejected creation. + */ + onAssemblyCreated?: (assembly: AssemblyStatus) => void } export interface ResumeAssemblyUploadsOptions extends AssemblyUploadOptions { @@ -459,6 +470,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,8 +498,28 @@ export class Transloadit { private _clientName: string + #extraHeaders: Record + private _lastUsedAssemblyUrl = '' + /** + * got drops `Authorization` and cookies when a redirect changes origin, but not custom headers, + * so `extraHeaders` (such as a relay's shared secret) are removed the same way. + */ + #dropExtraHeadersOffOrigin(requestUrl: string): BeforeRedirectHook[] { + const names = Object.keys(this.#extraHeaders).map((name) => name.toLowerCase()) + if (names.length === 0) return [] + // An unparseable request URL counts as a different origin, which drops the headers. + const requestOrigin = URL.canParse(requestUrl) ? new URL(requestUrl).origin : undefined + return [ + (redirectOptions) => { + const redirectUrl = redirectOptions.url + if (requestOrigin && redirectUrl && new URL(redirectUrl).origin === requestOrigin) return + for (const name of names) delete redirectOptions.headers[name] + }, + ] + } + private _validateResponses = false /** Create a client; new combined keys require signatureAlgorithm: 'sha256' explicitly. */ @@ -514,6 +550,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 } @@ -667,6 +704,7 @@ export class Transloadit { uploads = {}, assemblyId, expectedUploads, + onAssemblyCreated, signal, uploadBehavior = 'await', } = opts @@ -738,6 +776,7 @@ export class Transloadit { signal, }) checkResult(result) + onAssemblyCreated?.(result) if (Object.keys(allStreamsMap).length > 0) { const { uploadUrls } = await sendTusRequest({ @@ -1665,8 +1704,10 @@ export class Transloadit { 'Transloadit-Client': this._clientName, 'User-Agent': undefined, // Remove got's user-agent ...(this._authToken ? { Authorization: `Bearer ${this._authToken}` } : {}), + ...this.#extraHeaders, ...headers, }, + hooks: { beforeRedirect: this.#dropExtraHeadersOffOrigin(url) }, responseType: 'json', signal, } diff --git a/packages/node/src/apiTypes.ts b/packages/node/src/apiTypes.ts index 675d6c20..4a20115d 100644 --- a/packages/node/src/apiTypes.ts +++ b/packages/node/src/apiTypes.ts @@ -100,6 +100,8 @@ export type ListTemplatesParams = OptionalAuthParams & { todate?: string keywords?: string[] include_builtin?: 'all' | 'latest' | 'exclusively-all' | 'exclusively-latest' + /** Template columns to return, such as `['id', 'account_id']`; API2's default omits some. */ + fields?: string[] } interface TemplateResponseBase { diff --git a/packages/node/src/inputFiles.ts b/packages/node/src/inputFiles.ts index 14ea9d94..4d9b4ae8 100644 --- a/packages/node/src/inputFiles.ts +++ b/packages/node/src/inputFiles.ts @@ -13,6 +13,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { isIP } from 'node:net' import { tmpdir } from 'node:os' import { basename, join, parse } from 'node:path' +import { Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import got from 'got' @@ -53,6 +54,15 @@ export type PrepareInputFilesOptions = { urlStrategy?: UrlStrategy maxBase64Bytes?: number allowPrivateUrls?: boolean + /** Abort once all URL downloads of this call exceed this many bytes together. */ + maxUrlDownloadBytes?: number + /** Abort a URL download (DNS checks and redirects included) after this many milliseconds. */ + urlDownloadTimeoutMs?: number + /** + * Awaited before each URL input is downloaded locally (not for `/http/import`), so a caller can + * vouch for the requester first; rejecting aborts preparation. + */ + beforeUrlDownload?: () => Promise tempDir?: string } @@ -202,6 +212,16 @@ const isPrivateIp = (address: string): boolean => { return false } +/** + * Names a URL in error messages without its query or fragment: input URLs are often presigned + * (ChatGPT attachments, S3), and these messages reach logs and model-visible tool results. + */ +const describeUrl = (value: string): string => { + if (!URL.canParse(value)) return '[unparseable URL]' + const url = new URL(value) + return `${url.origin}${url.pathname}` +} + export const resolvePublicDownloadAddresses = async ( value: string, ): Promise> => { @@ -211,10 +231,10 @@ export const resolvePublicDownloadAddresses = async ( ? parsed.hostname.slice(1, -1) : parsed.hostname if (!['http:', 'https:'].includes(parsed.protocol)) { - throw new Error(`URL downloads are limited to http/https: ${value}`) + throw new Error(`URL downloads are limited to http/https: ${describeUrl(value)}`) } if (isPrivateIp(hostname)) { - throw new Error(`URL downloads are limited to public hosts: ${value}`) + throw new Error(`URL downloads are limited to public hosts: ${describeUrl(value)}`) } const literalFamily = isIP(hostname) @@ -226,11 +246,11 @@ export const resolvePublicDownloadAddresses = async ( verbatim: true, }) if (resolvedAddresses.some((address) => isPrivateIp(address.address))) { - throw new Error(`URL downloads are limited to public hosts: ${value}`) + throw new Error(`URL downloads are limited to public hosts: ${describeUrl(value)}`) } if (resolvedAddresses.length === 0) { - throw new Error(`Unable to resolve URL hostname: ${value}`) + throw new Error(`Unable to resolve URL hostname: ${describeUrl(value)}`) } return resolvedAddresses.map((address) => ({ @@ -370,22 +390,58 @@ export function createPinnedDnsLookup( return pinnedDnsLookup as PinnedDnsLookup } +/** A budget shared by every URL download of one `prepareInputFiles` call. */ +type DownloadBudget = { totalBytes: number; remainingBytes: number } + +const budgetExceeded = (budget: DownloadBudget, url: string): Error => + new Error(`URL downloads exceed ${budget.totalBytes} bytes: ${describeUrl(url)}`) + +/** Errors once the budget is used up, so temp files stay bounded while they stream in. */ +const limitDownloadBytes = (budget: DownloadBudget, url: string): Transform => + new Transform({ + transform(chunk: Buffer, _encoding, callback) { + budget.remainingBytes -= chunk.length + if (budget.remainingBytes < 0) { + callback(budgetExceeded(budget, url)) + return + } + callback(null, chunk) + }, + }) + const downloadUrlToFile = async ({ allowPrivateUrls, filePath, url, + budget, + timeoutMs, }: { allowPrivateUrls: boolean filePath: string url: string + budget?: DownloadBudget + timeoutMs?: number }): Promise => { let currentUrl = url + // One deadline for the whole download, so DNS checks and redirects cannot each restart it. + const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs + const remainingMs = (): number | undefined => { + if (deadline === undefined) return undefined + const remaining = deadline - Date.now() + if (remaining <= 0) { + throw new Error(`URL download timed out after ${timeoutMs} ms: ${describeUrl(url)}`) + } + return remaining + } for (let redirectCount = 0; redirectCount <= MAX_URL_REDIRECTS; redirectCount += 1) { let validatedAddresses: Array<{ address: string; family: 4 | 6 }> | null = null if (!allowPrivateUrls) { + // Not raced against the deadline: the system resolver bounds each lookup with its own + // short timeout and no bytes are transferred, so checking the clock right after suffices. validatedAddresses = await resolvePublicDownloadAddresses(currentUrl) } + const requestTimeoutMs = remainingMs() const dnsLookup: LookupFunction | undefined = validatedAddresses == null ? undefined : createPinnedDnsLookup(validatedAddresses) @@ -395,6 +451,7 @@ const downloadUrlToFile = async ({ followRedirect: false, retry: { limit: 0 }, throwHttpErrors: false, + ...(requestTimeoutMs === undefined ? {} : { timeout: { request: requestTimeoutMs } }), }) const response = await new Promise< @@ -416,7 +473,7 @@ const downloadUrlToFile = async ({ responseStream.destroy() const location = response.headers.location if (location == null) { - throw new Error(`Redirect response missing Location header: ${currentUrl}`) + throw new Error(`Redirect response missing Location header: ${describeUrl(currentUrl)}`) } currentUrl = new URL(location, currentUrl).toString() continue @@ -424,14 +481,22 @@ const downloadUrlToFile = async ({ if (statusCode >= 400) { responseStream.destroy() - throw new Error(`Failed to download URL: ${currentUrl} (${statusCode})`) + throw new Error(`Failed to download URL: ${describeUrl(currentUrl)} (${statusCode})`) } - await pipeline(responseStream, createWriteStream(filePath)) + if (budget === undefined) { + await pipeline(responseStream, createWriteStream(filePath)) + return + } + if (Number(response.headers['content-length']) > budget.remainingBytes) { + responseStream.destroy() + throw budgetExceeded(budget, url) + } + await pipeline(responseStream, limitDownloadBytes(budget, url), createWriteStream(filePath)) return } - throw new Error(`Too many redirects while downloading URL input: ${url}`) + throw new Error(`Too many redirects while downloading URL input: ${describeUrl(url)}`) } export const prepareInputFiles = async ( @@ -445,6 +510,9 @@ export const prepareInputFiles = async ( urlStrategy = 'import', maxBase64Bytes, allowPrivateUrls = true, + maxUrlDownloadBytes, + urlDownloadTimeoutMs, + beforeUrlDownload, tempDir, } = options @@ -452,6 +520,10 @@ export const prepareInputFiles = async ( const files: Record = {} const uploads: Record = {} const cleanup: Array<() => Promise> = [] + const downloadBudget: DownloadBudget | undefined = + maxUrlDownloadBytes === undefined + ? undefined + : { totalBytes: maxUrlDownloadBytes, remainingBytes: maxUrlDownloadBytes } if (fields && Object.keys(fields).length > 0) { nextParams = { @@ -530,10 +602,13 @@ export const prepareInputFiles = async ( getFilenameFromUrl(file.url) ?? `${file.field}.bin` const filePath = await ensureUniqueTempFilePath(root, filename, usedTempPaths) + await beforeUrlDownload?.() await downloadUrlToFile({ allowPrivateUrls, filePath, url: file.url, + budget: downloadBudget, + timeoutMs: urlDownloadTimeoutMs, }) files[file.field] = filePath } diff --git a/packages/node/test/unit/create-assembly-accepted.test.ts b/packages/node/test/unit/create-assembly-accepted.test.ts new file mode 100644 index 00000000..18ffb117 --- /dev/null +++ b/packages/node/test/unit/create-assembly-accepted.test.ts @@ -0,0 +1,59 @@ +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Transloadit } from '../../src/Transloadit.ts' + +const endpoint = 'https://api2.transloadit.com' + +describe('createAssembly onAssemblyCreated', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('reports the Assembly once API2 accepted the creation request', async () => { + const client = new Transloadit({ authKey: 'key', authSecret: 'secret' }) + nock(endpoint) + .post(/\/assemblies\/[a-f\d]{32}$/) + .reply((uri) => { + const id = uri.split('/').at(-1) + return [ + 200, + { + ok: 'ASSEMBLY_EXECUTING', + assembly_id: id, + assembly_url: `${endpoint}/assemblies/${id}`, + assembly_ssl_url: `${endpoint}/assemblies/${id}`, + }, + ] + }) + const created = vi.fn() + + const creation = client.createAssembly({ + params: { steps: { resized: { robot: '/image/resize', width: 1 } } }, + onAssemblyCreated: created, + }) + await creation + expect(created).toHaveBeenCalledWith( + expect.objectContaining({ assembly_id: creation.assemblyId }), + ) + }) + + it('does not report an Assembly when the creation response carries an error', async () => { + const client = new Transloadit({ authKey: 'key', authSecret: 'secret' }) + nock(endpoint) + .post(/\/assemblies\/[a-f\d]{32}$/) + .reply(200, { + error: 'INVALID_SIGNATURE', + message: 'The given signature does not match ours. This Auth Key requires sha256.', + }) + const created = vi.fn() + + await expect( + client.createAssembly({ + params: { steps: { resized: { robot: '/image/resize', width: 1 } } }, + onAssemblyCreated: created, + }), + ).rejects.toThrow('INVALID_SIGNATURE') + expect(created).not.toHaveBeenCalled() + }) +}) diff --git a/packages/node/test/unit/extra-headers-redirect.test.ts b/packages/node/test/unit/extra-headers-redirect.test.ts new file mode 100644 index 00000000..992f8e0d --- /dev/null +++ b/packages/node/test/unit/extra-headers-redirect.test.ts @@ -0,0 +1,57 @@ +import nock from 'nock' +import { afterEach, describe, expect, it } from 'vitest' + +import { Transloadit } from '../../src/Transloadit.ts' + +const upstreamHeader = 'Transloadit-Mcp-Upstream' + +const completedAssembly = { + ok: 'ASSEMBLY_COMPLETED', + assembly_id: 'abc', + assembly_url: 'https://api2.transloadit.com/assemblies/abc', + assembly_ssl_url: 'https://api2.transloadit.com/assemblies/abc', +} + +describe('extraHeaders on redirects', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('drops extraHeaders when a redirect leaves the API origin', async () => { + const client = new Transloadit({ + authToken: 'forwarded-token-0123456789', + extraHeaders: { [upstreamHeader]: 'shared-secret' }, + }) + nock('https://api2.transloadit.com') + .get('/assemblies/abc') + .query(true) + .reply(302, '', { Location: 'https://elsewhere.example/assemblies/abc' }) + const elsewhere = nock('https://elsewhere.example', { + badheaders: [upstreamHeader.toLowerCase(), 'authorization'], + }) + .get('/assemblies/abc') + .reply(200, completedAssembly) + + await client.getAssembly('abc') + expect(elsewhere.isDone()).toBe(true) + }) + + it('keeps extraHeaders on a same-origin redirect', async () => { + const client = new Transloadit({ + authToken: 'forwarded-token-0123456789', + extraHeaders: { [upstreamHeader]: 'shared-secret' }, + }) + nock('https://api2.transloadit.com') + .get('/assemblies/abc') + .query(true) + .reply(302, '', { Location: 'https://api2.transloadit.com/assemblies/abc2' }) + const sameOrigin = nock('https://api2.transloadit.com', { + reqheaders: { [upstreamHeader.toLowerCase()]: 'shared-secret' }, + }) + .get('/assemblies/abc2') + .reply(200, completedAssembly) + + await client.getAssembly('abc') + expect(sameOrigin.isDone()).toBe(true) + }) +}) diff --git a/packages/node/test/unit/input-files.test.ts b/packages/node/test/unit/input-files.test.ts index 1a8b5f47..ae692f86 100644 --- a/packages/node/test/unit/input-files.test.ts +++ b/packages/node/test/unit/input-files.test.ts @@ -270,6 +270,96 @@ describe('prepareInputFiles', () => { expect(lookupMock).not.toHaveBeenCalled() }) + it('aborts a URL download larger than maxUrlDownloadBytes', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + nock('http://rebind.test').get('/big').reply(200, 'x'.repeat(4096)) + + await expect( + prepareInputFiles({ + inputFiles: [{ kind: 'url', field: 'remote', url: 'http://rebind.test/big' }], + urlStrategy: 'download', + allowPrivateUrls: false, + maxUrlDownloadBytes: 1024, + }), + ).rejects.toThrow('URL downloads exceed 1024 bytes: http://rebind.test/big') + }) + + it('keeps signed query parameters out of download errors', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + nock('http://rebind.test').get('/big').query(true).reply(200, 'x'.repeat(4096)) + nock('http://rebind.test').get('/gone').query(true).reply(404, 'missing') + + await expect( + prepareInputFiles({ + inputFiles: [ + { kind: 'url', field: 'remote', url: 'http://rebind.test/big?X-Amz-Signature=secret' }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + maxUrlDownloadBytes: 1024, + }), + ).rejects.toThrow(/^URL downloads exceed 1024 bytes: http:\/\/rebind\.test\/big$/) + await expect( + prepareInputFiles({ + inputFiles: [ + { kind: 'url', field: 'remote', url: 'http://rebind.test/gone?X-Amz-Signature=secret' }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + }), + ).rejects.toThrow(/^Failed to download URL: http:\/\/rebind\.test\/gone \(404\)$/) + }) + + it('aborts a URL download slower than urlDownloadTimeoutMs', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + nock('http://rebind.test').get('/slow').delay(500).reply(200, 'late') + + await expect( + prepareInputFiles({ + inputFiles: [{ kind: 'url', field: 'remote', url: 'http://rebind.test/slow' }], + urlStrategy: 'download', + allowPrivateUrls: false, + urlDownloadTimeoutMs: 50, + }), + ).rejects.toThrow(/Timeout/) + }) + + it('caps the total bytes of all URL downloads in one call', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + nock('http://rebind.test').get('/one').reply(200, 'x'.repeat(600)) + nock('http://rebind.test').get('/two').reply(200, 'y'.repeat(600)) + + await expect( + prepareInputFiles({ + inputFiles: [ + { kind: 'url', field: 'one', url: 'http://rebind.test/one' }, + { kind: 'url', field: 'two', url: 'http://rebind.test/two' }, + ], + urlStrategy: 'download', + allowPrivateUrls: false, + maxUrlDownloadBytes: 1024, + }), + ).rejects.toThrow('URL downloads exceed 1024 bytes: http://rebind.test/two') + }) + + it('applies urlDownloadTimeoutMs to a download across its redirects', async () => { + lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) + nock('http://rebind.test') + .get('/first') + .delay(150) + .reply(302, undefined, { Location: 'http://rebind.test/second' }) + nock('http://rebind.test').get('/second').delay(150).reply(200, 'late') + + await expect( + prepareInputFiles({ + inputFiles: [{ kind: 'url', field: 'remote', url: 'http://rebind.test/first' }], + urlStrategy: 'download', + allowPrivateUrls: false, + urlDownloadTimeoutMs: 250, + }), + ).rejects.toThrow(/Timeout|timed out/) + }) + it('pins URL downloads to the validated DNS answer', async () => { lookupMock.mockResolvedValue([{ address: '198.51.100.10', family: 4 }]) const downloadScope = nock('http://rebind.test').get('/public').reply(200, 'public-data') diff --git a/packages/node/test/unit/test-transloadit-client.test.ts b/packages/node/test/unit/test-transloadit-client.test.ts index b38aac3a..6fabd4a8 100644 --- a/packages/node/test/unit/test-transloadit-client.test.ts +++ b/packages/node/test/unit/test-transloadit-client.test.ts @@ -1,5 +1,6 @@ import type { Readable } from 'node:stream' +import { createHmac } from 'node:crypto' import { PassThrough } from 'node:stream' import FormData from 'form-data' @@ -409,6 +410,46 @@ describe('Transloadit', () => { expect.objectContaining({ headers: { 'Transloadit-Client': 'mcp-server:1.2.3' } }), ) }) + + it.each([ + ['sha1'], + ['sha256'], + ['sha384'], + ] as const)('should sign params with the configured %s algorithm', (signatureAlgorithm) => { + const client = new Transloadit({ + authKey: 'foo_key', + authSecret: 'foo_secret', + signatureAlgorithm, + }) + + const { signature, params } = client.calcSignature({ steps: {} }) + + const expected = createHmac(signatureAlgorithm, 'foo_secret').update(params).digest('hex') + expect(signature).toBe(`${signatureAlgorithm}:${expected}`) + }) + + 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', () => { diff --git a/yarn.lock b/yarn.lock index b986e125..a01f8317 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1612,6 +1612,7 @@ __metadata: "@types/express": "npm:^5.0.6" "@types/node": "npm:^25.8.0" express: "npm:^5.2.1" + happy-dom: "npm:^20.9.0" nock: "npm:^14.0.15" prom-client: "npm:^15.1.3" zod: "npm:^4.4.3"