@etchv-labs/mcp-server 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 1.2.0
4
+
5
+ - New `watermark_batch` tool: watermark up to 100 local files in one batch,
6
+ named by `input_paths`, `input_dir` with `glob`, or `items` with their own
7
+ data. `data_template` values may use `{filename}`, `{stem}` and `{index}`.
8
+ Files are uploaded to signed URLs without the API key, the batch is started,
9
+ and with `wait` it is polled at the pace the API asks for (`Retry-After`).
10
+ `wait_seconds` bounds the wait, progress notifications report uploads and
11
+ checks, and a canceled call stops waiting. A retry resumes the same batch
12
+ (sending only files not yet received); a batch that expired unstarted or
13
+ whose results expired is reported with a request for a new
14
+ `idempotency_key`. The 100-file cap is checked before anything is sent, and
15
+ globs cannot leave `input_dir` (braces and `..` are refused).
16
+ Directory listings filter by file type while walking and refuse a directory
17
+ too large to walk rather than return part of it. Uploads to signed URLs time
18
+ out (45 s plus time for the file at 128 KiB/s).
19
+ - New `get_batch` tool to follow a batch, and `download_batch` to save each
20
+ successful result (named as in the batch zip) or the batch's zip and report failed items with their
21
+ error codes.
22
+
23
+ ## 1.1.0
24
+
25
+ - `watermark_media` and `detect_media` send files over 40 MiB
26
+ (`ETCHV_LARGE_FILE_THRESHOLD`) through an upload session, without the API
27
+ key, and submit its `upload_id`.
28
+ - Detection reads delivered files up to 192 MiB; image or PDF detection above
29
+ 95 MiB returns a job receipt for `get_job_result`.
package/README.md CHANGED
@@ -1,43 +1,37 @@
1
1
  # Etchv MCP server
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@etchv-labs/mcp-server)](https://www.npmjs.com/package/@etchv-labs/mcp-server)
4
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
3
+ A local stdio [Model Context Protocol](https://modelcontextprotocol.io) server
4
+ that lets AI assistants watermark and detect images, PDFs and videos with
5
+ [Etchv](https://etchv.com).
5
6
 
6
- Let an AI assistant watermark and inspect images, PDFs and videos with the
7
- [Etchv API](https://etchv.com/docs). Supports synchronous results and asynchronous
8
- jobs, asset management, customer storage destinations and webhook deliveries.
7
+ ## Requirements
9
8
 
10
- This is a local **stdio** [Model Context Protocol](https://modelcontextprotocol.io)
11
- server. It connects to the hosted Etchv API with your organization's scoped API
12
- key. There is no hosted MCP URL to configure. Node.js 24 or later is required.
9
+ - Node.js 24 or later
10
+ - An Etchv API key with only the [scopes](#tools) you need
11
+ - An existing directory for input and output files
13
12
 
14
13
  ## Configuration
15
14
 
16
15
  | Variable | Required | Description |
17
16
  | --- | --- | --- |
18
- | `ETCHV_API_KEY` | Yes | Etchv API key. Grant only the [scopes](#tools-and-key-scopes) you need. |
17
+ | `ETCHV_API_KEY` | Yes | Etchv API key. Keep it in your client's secret or env config, never in chat. |
19
18
  | `ETCHV_FILES_ROOT` | Yes | Absolute path of an existing directory. All input and output files must be inside it. |
20
19
  | `ETCHV_API_BASE_URL` | No | API origin. Defaults to `https://api.etchv.com`. |
21
-
22
- Create the media directory first. Store the API key in your client's secret or
23
- environment configuration; do not paste it into a conversation or commit it.
24
- The examples pin the major version with `@etchv-labs/mcp-server@1`.
20
+ | `ETCHV_LARGE_FILE_THRESHOLD` | No | Files above this many bytes are uploaded once to a signed URL and sent by ID. Defaults to 40 MiB. |
25
21
 
26
22
  ### Claude Code
27
23
 
28
24
  ```sh
29
- export ETCHV_API_KEY=... # or load it from your secret manager
30
25
  claude mcp add etchv --scope user \
31
26
  -e ETCHV_API_KEY="$ETCHV_API_KEY" \
32
27
  -e ETCHV_FILES_ROOT="$HOME/etchv-media" \
33
28
  -- npx -y @etchv-labs/mcp-server@1
34
29
  ```
35
30
 
36
- ### Claude Desktop, Cursor, Windsurf and other `mcpServers` clients
31
+ ### Claude Desktop, Cursor and other `mcpServers` clients
37
32
 
38
- Add this entry to the client's MCP configuration (for Claude Desktop,
39
- `claude_desktop_config.json`; for Cursor, `~/.cursor/mcp.json`), then restart
40
- the client:
33
+ Add this to the client's MCP config (for example `claude_desktop_config.json`
34
+ or `~/.cursor/mcp.json`) and restart it:
41
35
 
42
36
  ```json
43
37
  {
@@ -54,229 +48,79 @@ the client:
54
48
  }
55
49
  ```
56
50
 
57
- If the client does not inherit your shell `PATH`, use the absolute path of `npx`.
58
-
59
- ### VS Code
60
-
61
- Add to `.vscode/mcp.json`. VS Code prompts for the key and stores it securely:
62
-
63
- ```json
64
- {
65
- "inputs": [
66
- {
67
- "type": "promptString",
68
- "id": "etchv-api-key",
69
- "description": "Etchv API key",
70
- "password": true
71
- }
72
- ],
73
- "servers": {
74
- "etchv": {
75
- "type": "stdio",
76
- "command": "npx",
77
- "args": ["-y", "@etchv-labs/mcp-server@1"],
78
- "env": {
79
- "ETCHV_API_KEY": "${input:etchv-api-key}",
80
- "ETCHV_FILES_ROOT": "/absolute/path/to/etchv-media"
81
- }
82
- }
83
- }
84
- }
85
- ```
86
-
87
- ### Global install
88
-
89
- `npm install -g @etchv-labs/mcp-server@1` installs the `etchv-mcp` command. Use
90
- `etchv-mcp` as the configured command with the same environment variables.
91
-
92
- Ask the assistant to run `check_api_key` to confirm the connection. It returns
93
- your organization ID, key ID and granted scopes without spending credits.
94
-
95
- ## Try it
96
-
97
- Put `photo.jpg` inside your configured media directory, then ask your assistant:
98
-
99
- > Watermark photo.jpg for recipient delivery-42 using Etchv. Use an async job,
100
- > collect the result as photo-watermarked.jpg, then detect its watermark.
101
-
102
- The assistant can read `etchv://capabilities` for format and workflow guidance.
103
- To watermark directly, call `watermark_media` with:
104
-
105
- ```json
106
- {
107
- "media": "images",
108
- "input_path": "photo.jpg",
109
- "data": { "recipient": "delivery-42" },
110
- "mode": "async",
111
- "idempotency_key": "delivery-42-photo-v1"
112
- }
113
- ```
114
-
115
- The result includes `request_id`. Check `get_job` with `operation: "embed"`, then
116
- call `get_job_result` with that request ID, operation and
117
- `output_path: "photo-watermarked.jpg"`. Pending results return a 202 receipt;
118
- call again later. Use the same pattern with `operation: "detect"` for an async
119
- detection result. Detection returns JSON and does not accept an output path.
120
-
121
- For **sync**, set `mode: "sync"` and provide `output_path` when watermarking.
122
- The API waits for a bounded period. It can still return a 202 receipt if processing
123
- continues; collect it through the job tools. Async is the MCP default for every
124
- media type. There is no automatic polling or background retry loop in the server.
125
-
126
- Every submission requires a stable `idempotency_key`. Reuse it for the same input,
127
- data and options after an uncertain failure. Do not generate another key on a
128
- retry: that can create another billable operation. Results include the key and,
129
- when supplied by the API, a request ID. A client cancellation or timeout does not
130
- cancel an accepted API job. API failures set MCP `isError` with an HTTP status and
131
- safe message; retry delays are returned when available.
132
-
133
- ## Formats and credits
134
-
135
- | Media value | Formats | Preservation |
136
- | --- | --- | --- |
137
- | `images` | JPEG, PNG, APNG, TIFF, PSD, PSB, GIF, BMP, PPM, WebP | Original format, supported animation, pages and layers |
138
- | `documents` | PDF | Selectable text and vector content |
139
- | `videos` | MP4, MOV with supported H.264 encoding | Container and supported audio; audio is not watermarked |
140
-
141
- Format availability follows your plan. The server does not bypass API validation
142
- or media limits. Uploads are limited to 20 MiB and MCP file downloads to 512 MiB.
143
- Watermarking uses one credit per image or PDF file, or per started video minute.
144
- Detection also uses API credits. See [limits](https://etchv.com/docs/api/errors).
145
-
146
- Watermarks encode the digest of the provided JSON. Detection recovers that digest,
147
- not the original data. Test your compression and transformation workflow before
148
- relying on detection; a recovered identifier is not proof of who shared a file.
149
-
150
- ## Tools and key scopes
151
-
152
- | Tools | Required scope |
153
- | --- | --- |
154
- | `check_api_key` | Any valid key |
155
- | `watermark_media` | `watermarks:embed` |
156
- | `detect_media` | `watermarks:detect` |
157
- | `get_job`, `get_job_result` | Corresponding embed or detect scope |
158
- | `list_assets`, `get_asset`, `download_asset` | `assets:read` |
159
- | `update_asset` | `assets:write` |
160
- | `delete_asset` | `assets:delete`, owner/admin |
161
- | `list_storage_destinations`, `list_storage_deliveries`, `get_storage_delivery` | `storage:read` |
162
- | `verify_storage_destination`, `store_asset`, `retry_storage_delivery` | `storage:write`, owner/admin |
163
- | `list_webhooks`, `list_webhook_deliveries` | `webhooks:read` |
164
- | `redeliver_webhook` | `webhooks:write`, owner/admin |
165
-
166
- Give the key only the scopes you need. Your MCP client's approval settings govern
167
- billable and destructive tools; MCP annotations describe their effects. The API
168
- always enforces organization isolation, scopes, roles and plan access.
169
-
170
- Asset listing supports cursor pagination and media/kind/watermark filters.
171
- Updates require the current asset `version` to prevent lost edits; `metadata`
172
- replaces the whole object, and null clears it. Downloads create new local files
173
- and return paths, byte counts and SHA-256 hashes, never media bytes in chat.
174
-
175
- ## Storage and webhooks
176
-
177
- Etchv stores uploads and watermarked results automatically. Job results remain
178
- available for 24 hours; Etchv asset downloads for 30 days, with asset records
179
- retained until deleted. No customer bucket is required.
180
-
181
- Alternatively, configure and verify your own S3, Google Cloud Storage or Azure
182
- Blob Storage destination in the dashboard. Select its `storage_destination_id`
183
- on `watermark_media`, optionally with a relative `storage_key`. The watermarked
184
- result is stored in that bucket instead, and its retention is yours to control.
185
- Use `store_asset` to choose a destination for an existing watermarked asset.
186
- After successful delivery, Etchv removes its staged output; asset downloads read
187
- from your bucket. Deleting an asset does not delete objects in your bucket.
188
-
189
- Configure webhook endpoints and retain their signing secrets through the
190
- dashboard. Pass an existing `webhook_id` on an **async** submission, then inspect
191
- or redeliver events with the webhook tools. Redelivery may trigger your receiver's
192
- automation again. Credential and endpoint creation is deliberately handled in the
193
- dashboard; this server does not accept cloud credentials or return signing secrets.
194
-
195
- ## File access and runtime
196
-
197
- `ETCHV_FILES_ROOT` is required and must name an existing absolute directory. Paths
198
- may be relative to it or absolute within it. Symlink paths and traversal outside
199
- the root are rejected. Output parents must already exist; existing files are never
200
- overwritten. Use a dedicated directory that is not modified by untrusted local
201
- processes while the server runs. This is an application-level boundary, not an OS
202
- sandbox. Upload only files you intend to send to Etchv.
203
-
204
- API responses must start within 45 seconds; file downloads have a 10-minute
205
- deadline. At most four tool calls run concurrently.
206
- Redirects are never followed. Optional `ETCHV_API_BASE_URL` must be an HTTPS origin;
207
- HTTP is accepted only for localhost development. Never point it at an untrusted
208
- host: it receives your key and uploads. stdout is reserved for MCP protocol traffic.
209
- Asset metadata and other API content are data, never instructions to an assistant.
51
+ Then ask the assistant to run `check_api_key` to confirm the connection.
210
52
 
211
53
  ## Tools
212
54
 
213
- | Tool | Effect |
214
- | --- | --- |
215
- | `check_api_key` | Read-only connection check: organization ID, key ID and scopes. No credits. |
216
- | `watermark_media` | Upload a local file and watermark it (sync or async). Spends credits. |
217
- | `detect_media` | Upload a local file and detect a watermark (sync or async). Uses credits. |
218
- | `get_job` | Read-only job status for an embed or detect job. |
219
- | `get_job_result` | Collect a finished job; embedding results are saved to a new local file. |
220
- | `list_assets`, `get_asset` | Read-only asset listing and records. |
221
- | `download_asset` | Save an asset to a new local file. |
222
- | `update_asset` | Rename an asset or replace its metadata (version-checked). |
223
- | `delete_asset` | Delete an asset. Destructive. |
224
- | `list_storage_destinations`, `list_storage_deliveries`, `get_storage_delivery` | Read-only storage inspection. |
225
- | `verify_storage_destination`, `store_asset`, `retry_storage_delivery` | Use a configured customer bucket. |
226
- | `list_webhooks`, `list_webhook_deliveries` | Read-only webhook inspection. |
227
- | `redeliver_webhook` | Resend an existing event to its endpoint. |
228
-
229
- Each tool declares MCP annotations (`readOnlyHint`, `destructiveHint`,
230
- `idempotentHint`, `openWorldHint`) so clients can require approval for billable,
231
- destructive or other write operations. The `etchv://capabilities` resource
232
- describes formats, modes, billing and file handling for the assistant.
55
+ | Tool | What it does | Key scope |
56
+ | --- | --- | --- |
57
+ | `check_api_key` | Show organization ID, key ID and scopes; no credits | Any valid key |
58
+ | `watermark_media` | Watermark a local file, sync or async; spends credits | `watermarks:embed` |
59
+ | `detect_media` | Detect a watermark, sync or async; uses credits | `watermarks:detect` |
60
+ | `get_job`, `get_job_result` | Read job status; collect a finished result | Matching embed/detect scope |
61
+ | `watermark_batch` | Watermark up to 100 local files in one batch, each with its own data; spends credits | `watermarks:embed` |
62
+ | `get_batch`, `download_batch` | Follow a batch; save each result or the batch's zip | `watermarks:embed` |
63
+ | `list_assets`, `get_asset`, `download_asset` | List, inspect and download assets | `assets:read` |
64
+ | `update_asset` | Rename or replace metadata (version-checked) | `assets:write` |
65
+ | `delete_asset` | Delete an asset | `assets:delete`, owner/admin |
66
+ | `list_storage_destinations`, `list_storage_deliveries`, `get_storage_delivery` | Inspect customer storage | `storage:read` |
67
+ | `verify_storage_destination`, `store_asset`, `retry_storage_delivery` | Test a bucket; deliver or retry a result | `storage:write`, owner/admin |
68
+ | `list_webhooks`, `list_webhook_deliveries` | Inspect webhook endpoints and deliveries | `webhooks:read` |
69
+ | `redeliver_webhook` | Resend an existing event | `webhooks:write`, owner/admin |
70
+
71
+ Submissions require a stable `idempotency_key`; reuse it when retrying so you
72
+ are not charged twice. Image uploads are limited to 50 MiB and PDF and video
73
+ uploads to 20 MiB. Detection takes the files Etchv delivered: up to 192 MiB for
74
+ images, 64 MiB for PDFs and 100 MiB for video. Files over 40 MiB go through an
75
+ upload session automatically, and image or PDF detection above 95 MiB returns a
76
+ job receipt to collect with `get_job_result`. Storage destinations and webhook endpoints are created in the
77
+ dashboard, not through this server.
78
+
79
+ `watermark_media` and `detect_media` accept an optional `accelerator` (`cpu`,
80
+ the default, or `gpu`). GPU processing requires a Business or Enterprise plan
81
+ and costs 3× credits; if no GPU is ready, the job runs on CPU at normal credits.
82
+ Results report the hardware actually used as `accelerator`, and `get_job` also
83
+ returns `accelerator_requested`. When a rate or concurrency limit is reached,
84
+ errors include `retry_after_seconds` and point to the plan upgrade page. Trial and
85
+ Launch plans run one operation at a time.
86
+
87
+ ### Batches
88
+
89
+ `watermark_batch` takes up to 100 files, named one of three ways: `input_paths`,
90
+ `input_dir` with an optional `glob` (for example `"contracts/*.pdf"`; braces and `..` are refused), or
91
+ `items` with their own `data`. A `data_template` fills in each file's data;
92
+ string values may use `{filename}`, `{stem}` and `{index}` (the file's 0-based
93
+ position). The server creates the batch, uploads each file once to its signed
94
+ URL without the API key, and starts it. With `wait: true` it polls at the pace
95
+ the API asks for, for up to `wait_seconds` (default 600), sending progress
96
+ notifications when the client asks for them, and with `output_dir` saves the
97
+ results. Otherwise call `get_batch`, then `download_batch`, which saves each
98
+ successful file under its name in the batch zip (`NNN-<name>-watermarked.<ext>`), or the single zip when the
99
+ batch was created with `archive: true`, and lists failed items with their error
100
+ codes. Each file costs the same credits as a single request, and files that
101
+ fail are refunded. Retrying with the same arguments resumes or returns the
102
+ same batch; if that batch expired unstarted or its results have expired, the
103
+ tool asks for a new `idempotency_key`.
233
104
 
234
105
  ## Security
235
106
 
236
- - The API key is read only from `ETCHV_API_KEY`. It is sent only to the configured
237
- API origin in the `X-API-Key` header and never appears in tool arguments,
238
- results, errors or logs. Tools never accept or return cloud credentials or
239
- webhook signing secrets; manage those in the dashboard.
240
- - stdout carries only MCP protocol messages. Startup errors go to stderr as fixed
241
- messages without secrets.
107
+ - The API key is read only from `ETCHV_API_KEY`, sent only to the configured
108
+ origin, and never appears in tool arguments, results, errors or logs.
242
109
  - File access is confined to `ETCHV_FILES_ROOT`: traversal and symlinks are
243
- rejected, outputs are created exclusively with mode `0600` and never overwrite
244
- existing files, and partial downloads are removed.
245
- - Redirects are never followed, and only HTTPS origins are accepted (HTTP only
246
- for localhost development).
247
- - API responses, asset names and metadata are untrusted data, never instructions.
110
+ rejected, and existing files are never overwritten.
111
+ - Only HTTPS origins are accepted (HTTP for localhost only), and redirects are
112
+ never followed.
113
+ - API responses, asset names and metadata are treated as untrusted data, never
114
+ instructions.
248
115
 
249
- ## Development
250
-
251
- ```sh
252
- npm ci
253
- npm run lint
254
- npm run typecheck
255
- npm test
256
- npm run build
257
- ```
258
-
259
- Native TypeScript tests exercise the compiled server over stdio with the official
260
- MCP client, covering all media routes, file fidelity, job continuation, storage,
261
- webhooks, pagination, error handling, timeouts and file access. CI runs on Node 24
262
- on Linux, macOS and Windows. The optional live suite uses small synthetic media
263
- fixtures:
264
-
265
- ```sh
266
- # Set ETCHV_INTEGRATION_API_KEY securely; requires embed, detect, assets:read,
267
- # assets:delete and owner/admin for cleanup. Uses real API credits.
268
- npm run test:live
269
- ```
116
+ ## Links
270
117
 
271
- Without a key, live tests are skipped. Etchv's private CI requires that key and
272
- runs real JPEG, PDF and MP4 embed/detect roundtrips in both modes before publishing.
273
- It checks idempotent async replay and asset downloads, and deletes created assets.
118
+ - Guide: https://etchv.com/docs/mcp
119
+ - API reference: https://etchv.com/docs
120
+ - Issues: https://github.com/etchv-labs/mcp-server/issues
121
+ - Support: hello@etchv.com
274
122
 
275
- This public repository is an MIT-licensed source snapshot of `mcp/` in Etchv's
276
- monorepo, with independent history. Contributions are reviewed and incorporated
277
- there before publication. Private service code, credentials and customer fixtures
278
- are not included. Report issues at
279
- [github.com/etchv-labs/mcp-server/issues](https://github.com/etchv-labs/mcp-server/issues).
123
+ Questions or bug reports: open an issue here or email hello@etchv.com.
280
124
 
281
125
  ## License
282
126
 
package/dist/api.js CHANGED
@@ -11,24 +11,25 @@ const messages = {
11
11
  413: "The upload exceeds API limits.",
12
12
  415: "Media format or encoding is unsupported.",
13
13
  422: "The API rejected the file or request fields. Check the format limits in the documentation.",
14
- 429: "Rate limit reached. Retry after the indicated delay.",
14
+ 429: "Plan rate or concurrency limit reached. Retry after the indicated delay, or upgrade at https://etchv.com/dashboard/billing to run more operations at once.",
15
15
  };
16
16
  export class ApiError extends SafeError {
17
17
  status;
18
18
  request_id;
19
19
  retry_after_seconds;
20
- constructor(response) {
21
- super(messages[response.status] ||
22
- `Etchv request failed (HTTP ${response.status}).`);
20
+ constructor(response, gpuRequested = false) {
21
+ super(response.status === 403 && gpuRequested
22
+ ? `${messages[403]} GPU processing requires Business or a higher plan.`
23
+ : messages[response.status] ||
24
+ `Etchv request failed (HTTP ${response.status}).`);
23
25
  this.status = response.status;
24
26
  const id = response.headers.get("x-request-id");
25
27
  this.request_id = /^req_[a-f0-9]{64}$/.test(id ?? "") ? id : undefined;
26
- const retry = response.headers.get("retry-after");
27
- this.retry_after_seconds = /^\d{1,6}$/.test(retry ?? "")
28
- ? Number(retry)
29
- : undefined;
28
+ this.retry_after_seconds = retryAfter(response);
30
29
  }
31
30
  }
31
+ /** Slowest upload rate a signed PUT is given time for, in bytes per second. */
32
+ export const UPLOAD_FLOOR = 128 * 1024;
32
33
  export function apiClient({ apiKey, baseUrl = "https://api.etchv.com", timeout = 45000, bodyTimeout = 600000, }) {
33
34
  if (!apiKey || /[\r\n]/.test(apiKey))
34
35
  throw new SafeError("Set ETCHV_API_KEY to an Etchv API key.");
@@ -90,12 +91,94 @@ export function apiClient({ apiKey, baseUrl = "https://api.etchv.com", timeout =
90
91
  timer.unref();
91
92
  if (!response.ok) {
92
93
  await response.body?.cancel();
93
- throw new ApiError(response);
94
+ throw new ApiError(response, url.searchParams.get("accelerator") === "gpu");
94
95
  }
95
96
  return response;
96
97
  },
98
+ /**
99
+ * Upload a file once through an upload session and return its upload_id, to
100
+ * send instead of the file. The signed URL carries its own authorization, so
101
+ * the API key never goes to it.
102
+ */
103
+ async upload(kind, bytes, filename, signal) {
104
+ const session = await jsonResponse(await this.request("/uploads", {
105
+ method: "POST",
106
+ body: { kind, filename, size: bytes.length },
107
+ signal,
108
+ }));
109
+ if (!/^upl_[a-f0-9]{32}$/.test(String(session.upload_id)))
110
+ throw new SafeError("API returned an invalid upload session.");
111
+ await this.put(session.upload, bytes, signal);
112
+ return String(session.upload_id);
113
+ },
114
+ /**
115
+ * PUT bytes to a signed upload URL from the API (an upload session or a
116
+ * batch item). Only the signature in the URL authorizes it: the API key is
117
+ * never sent there.
118
+ */
119
+ async put(upload, bytes, signal) {
120
+ const { method, url } = (upload ?? {});
121
+ let target;
122
+ try {
123
+ target = new URL(String(url));
124
+ }
125
+ catch {
126
+ throw new SafeError("API returned an invalid upload session.");
127
+ }
128
+ const local = base.protocol === "http:" &&
129
+ target.protocol === "http:" &&
130
+ ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname);
131
+ if (method !== "PUT" ||
132
+ target.username ||
133
+ target.password ||
134
+ (target.protocol !== "https:" && !local))
135
+ throw new SafeError("API returned an invalid upload session.");
136
+ // Each attempt gets the request deadline plus time for the body at a
137
+ // floor of 128 KiB/s, so a stalled upload cannot hang the call.
138
+ const deadline = timeout + Math.ceil((bytes.length / UPLOAD_FLOOR) * 1000);
139
+ for (let attempt = 0;; attempt++) {
140
+ let response;
141
+ try {
142
+ response = await fetch(target, {
143
+ method: "PUT",
144
+ body: new Blob([bytes]),
145
+ headers: {
146
+ "Content-Type": "application/octet-stream",
147
+ "User-Agent": USER_AGENT,
148
+ },
149
+ redirect: "manual",
150
+ signal: AbortSignal.any([
151
+ AbortSignal.timeout(deadline),
152
+ ...(signal ? [signal] : []),
153
+ ]),
154
+ });
155
+ }
156
+ catch (error) {
157
+ if (signal?.aborted)
158
+ throw error;
159
+ if (attempt < 2)
160
+ continue;
161
+ if (error instanceof Error && error.name === "TimeoutError")
162
+ throw new SafeError("The file upload timed out. Check the network connection.");
163
+ throw error;
164
+ }
165
+ await response.body?.cancel();
166
+ if (response.status === 200)
167
+ return;
168
+ if (response.status >= 500 && attempt < 2)
169
+ continue;
170
+ throw new SafeError(response.status === 413
171
+ ? "The file exceeds the upload limit for this operation."
172
+ : `The upload was refused (HTTP ${response.status}).`);
173
+ }
174
+ },
97
175
  };
98
176
  }
177
+ /** Seconds from a Retry-After header, when it holds a plain number. */
178
+ export function retryAfter(response) {
179
+ const value = response.headers.get("retry-after");
180
+ return /^\d{1,6}$/.test(value ?? "") ? Number(value) : undefined;
181
+ }
99
182
  export async function jsonResponse(response) {
100
183
  if (response.status === 204)
101
184
  return { success: true };
@@ -0,0 +1,110 @@
1
+ import { basename, extname } from "node:path";
2
+ import { SafeError } from "./io.js";
3
+ /** Files per batch, as the API enforces. */
4
+ export const MAX_BATCH_ITEMS = 100;
5
+ const MIB = 1024 * 1024;
6
+ /** Batch file types by extension, with the API's per-kind size caps. */
7
+ const KINDS = {
8
+ ".jpg": "image",
9
+ ".jpeg": "image",
10
+ ".png": "image",
11
+ ".tif": "image",
12
+ ".tiff": "image",
13
+ ".psd": "image",
14
+ ".psb": "image",
15
+ ".gif": "image",
16
+ ".bmp": "image",
17
+ ".ppm": "image",
18
+ ".webp": "image",
19
+ ".pdf": "document",
20
+ ".mp4": "video",
21
+ ".mov": "video",
22
+ };
23
+ const LIMITS = { image: 50 * MIB, document: 20 * MIB, video: 20 * MIB };
24
+ /** States after which a batch makes no further progress. */
25
+ export const BATCH_DONE = new Set([
26
+ "completed",
27
+ "failed",
28
+ "cancelled",
29
+ "expired",
30
+ ]);
31
+ export const isBatchFile = (path) => extname(path).toLowerCase() in KINDS;
32
+ /** Upload size cap for a batch file, or an error naming the file. */
33
+ export function batchLimit(path) {
34
+ const kind = KINDS[extname(path).toLowerCase()];
35
+ if (!kind)
36
+ throw new SafeError(`${path}: unsupported file type. Batches take images, PDFs and MP4 or MOV videos.`);
37
+ return LIMITS[kind];
38
+ }
39
+ /** Replace {filename}, {stem} and {index} in every string value. */
40
+ export function fillTemplate(value, vars) {
41
+ if (typeof value === "string")
42
+ return value.replace(/\{(filename|stem|index)\}/g, (_, name) => vars[name]);
43
+ if (Array.isArray(value))
44
+ return value.map((v) => fillTemplate(v, vars));
45
+ if (value && typeof value === "object")
46
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, fillTemplate(v, vars)]));
47
+ return value;
48
+ }
49
+ export function checkData(data, label) {
50
+ if (!data ||
51
+ typeof data !== "object" ||
52
+ Array.isArray(data) ||
53
+ !Object.keys(data).length ||
54
+ Buffer.byteLength(JSON.stringify(data)) > 8192)
55
+ throw new SafeError(`${label}: data must be a non-empty JSON object up to 8 KiB.`);
56
+ return data;
57
+ }
58
+ export const stemOf = (filename) => basename(filename, extname(filename));
59
+ /** The API's file stem rule: other characters become -, trimmed, 180 at most. */
60
+ export function safeStem(filename) {
61
+ const name = filename.split(/[\\/]/).pop() ?? "";
62
+ const stem = name.slice(0, name.length - extname(name).length);
63
+ return (stem
64
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
65
+ .replace(/^[-.]+|[-.]+$/g, "")
66
+ .slice(0, 180) || "image");
67
+ }
68
+ /**
69
+ * Local name for a downloaded item, the same as its entry in the batch zip:
70
+ * NNN- plus the result's own file name (from Content-Disposition), or the
71
+ * name the API derives from the uploaded file when the header has none.
72
+ */
73
+ export function outputName(index, filename, disposition) {
74
+ const prefix = `${String(index).padStart(3, "0")}-`;
75
+ const given = /filename="([A-Za-z0-9._-]{1,240})"/.exec(disposition ?? "")?.[1];
76
+ if (given && !given.startsWith("."))
77
+ return prefix + given;
78
+ const ext = extname(filename.split(/[\\/]/).pop() ?? "")
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9.]/g, "");
81
+ return `${prefix}${safeStem(filename)}-watermarked${ext}`;
82
+ }
83
+ /** The batch without signed upload URLs, which are credentials. */
84
+ export function publicBatch(view) {
85
+ const items = Array.isArray(view.items) ? view.items : undefined;
86
+ return {
87
+ ...view,
88
+ ...(items
89
+ ? {
90
+ items: items.map(({ upload, ...item }) => ({
91
+ ...item,
92
+ ...(upload ? { awaiting_upload: true } : {}),
93
+ })),
94
+ }
95
+ : {}),
96
+ };
97
+ }
98
+ /** Items of a finished batch that did not succeed, with their error codes. */
99
+ export function failedItems(view) {
100
+ const items = Array.isArray(view.items) ? view.items : [];
101
+ return items
102
+ .filter((item) => item.status !== "succeeded")
103
+ .map(({ index, filename, status, error_code, error_detail }) => ({
104
+ index,
105
+ filename,
106
+ status,
107
+ error_code,
108
+ error_detail,
109
+ }));
110
+ }
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ try {
7
7
  apiKey: process.env.ETCHV_API_KEY,
8
8
  baseUrl: process.env.ETCHV_API_BASE_URL,
9
9
  filesRoot: process.env.ETCHV_FILES_ROOT,
10
+ largeFileThreshold: process.env.ETCHV_LARGE_FILE_THRESHOLD,
10
11
  };
11
12
  // Validate before accepting a connection. stdout is reserved for MCP messages.
12
13
  const initial = await createServer(config);