@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 +29 -0
- package/README.md +75 -231
- package/dist/api.js +92 -9
- package/dist/batches.js +110 -0
- package/dist/cli.js +1 -0
- package/dist/io.js +69 -12
- package/dist/server.js +452 -14
- package/dist/version.js +1 -1
- package/package.json +2 -1
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
|
-
[
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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
|
|
31
|
+
### Claude Desktop, Cursor and other `mcpServers` clients
|
|
37
32
|
|
|
38
|
-
Add this
|
|
39
|
-
|
|
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
|
-
|
|
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 |
|
|
214
|
-
| --- | --- |
|
|
215
|
-
| `check_api_key` |
|
|
216
|
-
| `watermark_media` |
|
|
217
|
-
| `detect_media` |
|
|
218
|
-
| `get_job` | Read
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `download_asset` |
|
|
222
|
-
| `update_asset` | Rename
|
|
223
|
-
| `delete_asset` | Delete an asset
|
|
224
|
-
| `list_storage_destinations`, `list_storage_deliveries`, `get_storage_delivery` |
|
|
225
|
-
| `verify_storage_destination`, `store_asset`, `retry_storage_delivery` |
|
|
226
|
-
| `list_webhooks`, `list_webhook_deliveries` |
|
|
227
|
-
| `redeliver_webhook` | Resend an existing event
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
237
|
-
|
|
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,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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: "
|
|
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(
|
|
22
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/batches.js
ADDED
|
@@ -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);
|