@labelgrid/mcp 0.3.0 → 0.4.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.
@@ -7,8 +7,8 @@
7
7
  * into a registered MCP tool. This keeps every tool a thin wrapper: one HTTP
8
8
  * call, no client-side business logic.
9
9
  */
10
+ import type { ApiResult, LabelGridClient } from '@labelgrid/core';
10
11
  import type { z } from 'zod';
11
- import type { ApiResult, LabelGridClient } from '../api/http.js';
12
12
  import type { Config } from '../config.js';
13
13
  import type { Gate } from '../gating.js';
14
14
  export type ToolAnnotations = {
package/package.json CHANGED
@@ -1,44 +1,47 @@
1
1
  {
2
2
  "name": "@labelgrid/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.labelgrid/labelgrid-mcp",
5
- "description": "Official LabelGrid MCP server — connect your AI client to your LabelGrid account",
5
+ "description": "Official LabelGrid MCP server \u2014 connect your AI client to your LabelGrid account",
6
6
  "type": "module",
7
- "keywords": ["mcp", "model-context-protocol", "labelgrid", "music-distribution", "ai", "claude"],
7
+ "keywords": [
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "labelgrid",
11
+ "music-distribution",
12
+ "ai",
13
+ "claude"
14
+ ],
8
15
  "main": "dist/index.js",
9
16
  "bin": {
10
17
  "labelgrid-mcp": "dist/index.js"
11
18
  },
12
- "files": ["dist", "README.md", "CHANGELOG.md", "LICENSE", "server.json"],
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE",
24
+ "server.json"
25
+ ],
13
26
  "scripts": {
14
27
  "build": "tsc",
15
- "build:mcpb": "npm run build && node scripts/build-mcpb.mjs",
16
28
  "start": "node dist/index.js",
17
29
  "dev": "tsc --watch",
18
30
  "test": "vitest run --exclude 'test/contract/**'",
19
- "test:contract": "vitest run test/contract",
20
- "lint": "biome check .",
21
- "leak-guard": "node scripts/leak-guard.mjs",
22
- "check-coverage": "node scripts/check-api-coverage.mjs",
23
- "gen-docs": "node scripts/gen-tool-docs.mjs",
24
- "measure-tokens": "node scripts/measure-tool-tokens.mjs"
31
+ "test:contract": "vitest run test/contract"
25
32
  },
26
33
  "repository": {
27
34
  "type": "git",
28
- "url": "git+https://github.com/labelgrid/labelgrid-mcp.git"
35
+ "url": "git+https://github.com/labelgrid/labelgrid-mcp.git",
36
+ "directory": "packages/mcp"
29
37
  },
30
38
  "license": "MIT",
31
39
  "engines": {
32
40
  "node": ">=20"
33
41
  },
34
42
  "dependencies": {
43
+ "@labelgrid/core": "0.2.0",
35
44
  "@modelcontextprotocol/sdk": "^1.12.0",
36
45
  "zod": "^3.24.0"
37
- },
38
- "devDependencies": {
39
- "@biomejs/biome": "^1.9.0",
40
- "@types/node": "^22.0.0",
41
- "typescript": "^5.7.0",
42
- "vitest": "^3.0.0"
43
46
  }
44
47
  }
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.labelgrid/labelgrid-mcp",
4
4
  "description": "Official LabelGrid MCP server — manage your music catalog, releases, analytics and distribution.",
5
- "version": "0.3.0",
5
+ "version": "0.4.0",
6
6
  "websiteUrl": "https://labelgrid.com",
7
7
  "repository": {
8
8
  "url": "https://github.com/labelgrid/labelgrid-mcp",
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@labelgrid/mcp",
15
- "version": "0.3.0",
15
+ "version": "0.4.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -1,33 +0,0 @@
1
- /**
2
- * Shared file content-type inference and the upload extension allow-list guard.
3
- *
4
- * CONTENT_TYPES (moved here from upload.ts) infers a best-effort MIME type from
5
- * a file extension — used for the presigned PUT and the multipart Blob.
6
- * assertAllowedExtension is the per-tool guard: each file-accepting tool
7
- * declares exactly which extensions it accepts, and the guard rejects anything
8
- * else BEFORE the file is read or any HTTP call is made, so an upload tool can
9
- * never be pointed at an arbitrary local file.
10
- */
11
- import type { ApiError } from './http.js';
12
- /** Best-effort Content-Type inferred from a file extension. */
13
- export declare const CONTENT_TYPES: Record<string, string>;
14
- /** Best-effort Content-Type for a file path (default application/octet-stream). */
15
- export declare function contentType(filePath: string): string;
16
- /**
17
- * Rejects a file whose extension is not in `allowed` (case-insensitive), before
18
- * any read or HTTP call, and resolves the path to its real target. The supplied
19
- * path's extension is checked first (the fast path); then the path is resolved
20
- * with realpathSync and the REAL target's extension is checked too, so a symlink
21
- * named `cover.jpg` that points at an arbitrary local file cannot slip past the
22
- * guard. On success it returns `{ realPath }` — the resolved canonical path,
23
- * which the caller MUST use as the path it reads/uploads (never the original
24
- * argument), so a symlink retargeted after validation cannot redirect the read
25
- * (the resolved target is what gets uploaded). On failure it returns `{ error }`
26
- * — a structured FILE_TYPE_NOT_ALLOWED, or FILE_NOT_FOUND if the path does not
27
- * resolve.
28
- */
29
- export declare function assertAllowedExtension(filePath: string, allowed: string[]): {
30
- error: ApiError;
31
- } | {
32
- realPath: string;
33
- };
@@ -1,87 +0,0 @@
1
- /**
2
- * Shared file content-type inference and the upload extension allow-list guard.
3
- *
4
- * CONTENT_TYPES (moved here from upload.ts) infers a best-effort MIME type from
5
- * a file extension — used for the presigned PUT and the multipart Blob.
6
- * assertAllowedExtension is the per-tool guard: each file-accepting tool
7
- * declares exactly which extensions it accepts, and the guard rejects anything
8
- * else BEFORE the file is read or any HTTP call is made, so an upload tool can
9
- * never be pointed at an arbitrary local file.
10
- */
11
- import { realpathSync } from 'node:fs';
12
- import { extname } from 'node:path';
13
- /** Best-effort Content-Type inferred from a file extension. */
14
- export const CONTENT_TYPES = {
15
- '.wav': 'audio/wav',
16
- '.flac': 'audio/flac',
17
- '.aif': 'audio/aiff',
18
- '.aiff': 'audio/aiff',
19
- '.mp3': 'audio/mpeg',
20
- '.lrc': 'text/plain',
21
- '.txt': 'text/plain',
22
- '.jpg': 'image/jpeg',
23
- '.jpeg': 'image/jpeg',
24
- '.png': 'image/png',
25
- '.webp': 'image/webp',
26
- '.tif': 'image/tiff',
27
- '.tiff': 'image/tiff',
28
- '.pdf': 'application/pdf',
29
- '.mp4': 'video/mp4',
30
- '.mov': 'video/quicktime',
31
- };
32
- /** Best-effort Content-Type for a file path (default application/octet-stream). */
33
- export function contentType(filePath) {
34
- return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
35
- }
36
- /**
37
- * Rejects a file whose extension is not in `allowed` (case-insensitive), before
38
- * any read or HTTP call, and resolves the path to its real target. The supplied
39
- * path's extension is checked first (the fast path); then the path is resolved
40
- * with realpathSync and the REAL target's extension is checked too, so a symlink
41
- * named `cover.jpg` that points at an arbitrary local file cannot slip past the
42
- * guard. On success it returns `{ realPath }` — the resolved canonical path,
43
- * which the caller MUST use as the path it reads/uploads (never the original
44
- * argument), so a symlink retargeted after validation cannot redirect the read
45
- * (the resolved target is what gets uploaded). On failure it returns `{ error }`
46
- * — a structured FILE_TYPE_NOT_ALLOWED, or FILE_NOT_FOUND if the path does not
47
- * resolve.
48
- */
49
- export function assertAllowedExtension(filePath, allowed) {
50
- const isAllowed = (candidate) => allowed.some((a) => a.toLowerCase() === candidate);
51
- const ext = extname(filePath).toLowerCase();
52
- // Fast path: reject a plainly-disallowed extension before touching the disk.
53
- if (!isAllowed(ext)) {
54
- return {
55
- error: {
56
- code: 'FILE_TYPE_NOT_ALLOWED',
57
- message: `This tool only accepts ${allowed.join(', ')} files (got "${ext || 'no extension'}").`,
58
- status: 0,
59
- },
60
- };
61
- }
62
- // The supplied name is allowed; resolve symlinks and re-check the real target.
63
- let realPath;
64
- try {
65
- realPath = realpathSync(filePath);
66
- }
67
- catch {
68
- return {
69
- error: {
70
- code: 'FILE_NOT_FOUND',
71
- message: `No readable file at ${filePath}.`,
72
- status: 0,
73
- },
74
- };
75
- }
76
- const realExt = extname(realPath).toLowerCase();
77
- if (!isAllowed(realExt)) {
78
- return {
79
- error: {
80
- code: 'FILE_TYPE_NOT_ALLOWED',
81
- message: `The file resolves to a "${realExt || 'no extension'}" file; this tool only accepts ${allowed.join(', ')}.`,
82
- status: 0,
83
- },
84
- };
85
- }
86
- return { realPath };
87
- }
@@ -1,74 +0,0 @@
1
- /**
2
- * The single typed HTTP client for the LabelGrid public API.
3
- *
4
- * Every tool goes through this client. It owns transport, header injection,
5
- * query serialization, optional idempotency keys and — critically — error
6
- * normalization: HTTP failures are turned into a structured {@link ApiError}
7
- * and returned, never thrown. Business rules live server-side; this file is
8
- * transport only (no retries, no queues).
9
- */
10
- export type ApiError = {
11
- code: string;
12
- message: string;
13
- status: number;
14
- field?: string;
15
- suggestion?: string;
16
- retry_after_seconds?: number;
17
- errors?: unknown;
18
- /** Structured validation detail passed through verbatim from the API (422). */
19
- errors_structured?: unknown;
20
- };
21
- export type ApiResult<T = unknown> = {
22
- data: T;
23
- } | {
24
- error: ApiError;
25
- };
26
- export declare class LabelGridClient {
27
- private readonly baseUrl;
28
- private readonly token;
29
- private readonly fetchFn;
30
- private readonly version;
31
- private readonly timeoutMs;
32
- private readonly rawTimeoutMs;
33
- constructor(opts: {
34
- baseUrl: string;
35
- token: string;
36
- fetchFn?: typeof fetch;
37
- version: string;
38
- /** API request timeout (default 60s) — a hung call must never hang a tool. */
39
- timeoutMs?: number;
40
- /** Timeout for raw transfers like presigned uploads (default 10min). */
41
- rawTimeoutMs?: number;
42
- });
43
- private authHeaders;
44
- private send;
45
- /**
46
- * Reads a response body with the byte ceiling enforced mid-stream. Returns
47
- * the decoded text, or the supplied too-large error result when the ceiling
48
- * is crossed. Abort/timeout rejections propagate to the caller for mapping.
49
- */
50
- private readBody;
51
- get<T>(path: string, query?: Record<string, unknown>): Promise<ApiResult<T>>;
52
- post<T>(path: string, body?: unknown, opts?: {
53
- idempotency?: boolean;
54
- idempotencyKey?: string;
55
- }): Promise<ApiResult<T>>;
56
- patch<T>(path: string, body?: unknown): Promise<ApiResult<T>>;
57
- put<T>(path: string, body?: unknown, opts?: {
58
- idempotency?: boolean;
59
- idempotencyKey?: string;
60
- }): Promise<ApiResult<T>>;
61
- delete<T>(path: string): Promise<ApiResult<T>>;
62
- /**
63
- * Sends a multipart/form-data POST with a single file field plus optional
64
- * extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
65
- * error result rather than throwing.
66
- */
67
- postMultipart<T>(path: string, filePath: string, fieldName: string, extra?: Record<string, string>): Promise<ApiResult<T>>;
68
- /**
69
- * Performs a raw request with NO Authorization header — used for presigned
70
- * upload PUTs, where the signed URL is already the credential and an extra
71
- * Bearer token would break the signature.
72
- */
73
- raw(url: string, init: RequestInit): Promise<Response>;
74
- }
package/dist/api/http.js DELETED
@@ -1,392 +0,0 @@
1
- /**
2
- * The single typed HTTP client for the LabelGrid public API.
3
- *
4
- * Every tool goes through this client. It owns transport, header injection,
5
- * query serialization, optional idempotency keys and — critically — error
6
- * normalization: HTTP failures are turned into a structured {@link ApiError}
7
- * and returned, never thrown. Business rules live server-side; this file is
8
- * transport only (no retries, no queues).
9
- */
10
- import { randomUUID } from 'node:crypto';
11
- import { readFile } from 'node:fs/promises';
12
- import { basename } from 'node:path';
13
- import { contentType } from './content-types.js';
14
- /** Hard ceiling on a single response body, in bytes/characters. */
15
- const MAX_RESPONSE_BYTES = 10_000_000;
16
- const TOKEN_SUGGESTION = 'Check LABELGRID_API_TOKEN — create a new token in your dashboard under Profile → API Tokens.';
17
- /**
18
- * Serializes a query object into a URL search string, supporting nested
19
- * `filter[label_id]=5` objects and repeated `metrics[]=a&metrics[]=b` arrays.
20
- * Null/undefined values are skipped. Bracket structure is kept literal; only
21
- * key names and values are percent-encoded.
22
- */
23
- function buildQuery(query) {
24
- if (!query)
25
- return '';
26
- const parts = [];
27
- const push = (rawKey, value) => {
28
- if (value === undefined || value === null)
29
- return;
30
- parts.push(`${rawKey}=${encodeURIComponent(String(value))}`);
31
- };
32
- for (const [key, value] of Object.entries(query)) {
33
- if (value === undefined || value === null)
34
- continue;
35
- const ek = encodeURIComponent(key);
36
- if (Array.isArray(value)) {
37
- for (const item of value)
38
- push(`${ek}[]`, item);
39
- }
40
- else if (typeof value === 'object') {
41
- for (const [subKey, subValue] of Object.entries(value)) {
42
- const esk = encodeURIComponent(subKey);
43
- if (Array.isArray(subValue)) {
44
- for (const item of subValue)
45
- push(`${ek}[${esk}][]`, item);
46
- }
47
- else {
48
- push(`${ek}[${esk}]`, subValue);
49
- }
50
- }
51
- }
52
- else {
53
- push(ek, value);
54
- }
55
- }
56
- return parts.length > 0 ? `?${parts.join('&')}` : '';
57
- }
58
- /**
59
- * Extracts a code/message/errors triple from any of the four backend error body
60
- * shapes: `{message}`, `{error: string}`, `{errors}`, `{error: {code, message}}`.
61
- */
62
- function extractServerError(body) {
63
- if (typeof body === 'string') {
64
- return { message: body };
65
- }
66
- if (body === null || typeof body !== 'object') {
67
- return {};
68
- }
69
- const record = body;
70
- const errors = record.errors;
71
- const errorsStructured = record.errors_structured;
72
- // Shape: { error: { code, message } }
73
- if (record.error !== null && typeof record.error === 'object') {
74
- const nested = record.error;
75
- return {
76
- code: typeof nested.code === 'string' ? nested.code : undefined,
77
- message: typeof nested.message === 'string'
78
- ? nested.message
79
- : typeof nested.error === 'string'
80
- ? nested.error
81
- : undefined,
82
- errors,
83
- errors_structured: errorsStructured,
84
- };
85
- }
86
- // Shape: { error: 'string' }
87
- if (typeof record.error === 'string') {
88
- return {
89
- code: typeof record.code === 'string' ? record.code : undefined,
90
- message: record.error,
91
- errors,
92
- errors_structured: errorsStructured,
93
- };
94
- }
95
- // Shapes: { message } and/or { errors } and/or top-level { code }
96
- const parts = {
97
- code: typeof record.code === 'string' ? record.code : undefined,
98
- message: typeof record.message === 'string' ? record.message : undefined,
99
- field: typeof record.field === 'string' ? record.field : undefined,
100
- errors,
101
- errors_structured: errorsStructured,
102
- };
103
- // Derive a message from the first validation error when none was given.
104
- if (parts.message === undefined && errors !== null && typeof errors === 'object') {
105
- const first = Object.values(errors)[0];
106
- if (Array.isArray(first) && typeof first[0] === 'string') {
107
- parts.message = first[0];
108
- }
109
- else if (typeof first === 'string') {
110
- parts.message = first;
111
- }
112
- }
113
- return parts;
114
- }
115
- function parseRetryAfter(res) {
116
- const raw = res.headers.get('Retry-After');
117
- if (raw === null)
118
- return undefined;
119
- const seconds = Number.parseInt(raw, 10);
120
- return Number.isNaN(seconds) ? undefined : seconds;
121
- }
122
- /** Normalizes a non-2xx HTTP response into a structured {@link ApiError}. */
123
- function normalizeError(res, body) {
124
- const server = extractServerError(body);
125
- const status = res.status;
126
- const withCommon = (code, message, extra = {}) => ({
127
- code,
128
- message,
129
- status,
130
- ...(server.field !== undefined ? { field: server.field } : {}),
131
- ...(server.errors !== undefined ? { errors: server.errors } : {}),
132
- ...extra,
133
- });
134
- switch (status) {
135
- case 401:
136
- return withCommon('TOKEN_INVALID', server.message ?? 'Your API token was rejected.', {
137
- suggestion: TOKEN_SUGGESTION,
138
- });
139
- case 403:
140
- return withCommon(server.code ?? 'FORBIDDEN', server.message ?? 'Forbidden.');
141
- case 404:
142
- return withCommon('NOT_FOUND', server.message ?? 'The requested resource was not found.');
143
- case 409:
144
- return withCommon(server.code ?? 'CONFLICT', server.message ?? 'The request conflicts with the current state.');
145
- case 422:
146
- return withCommon('VALIDATION_FAILED', server.message ?? 'The submitted data was invalid.', {
147
- ...(server.errors_structured !== undefined
148
- ? { errors_structured: server.errors_structured }
149
- : {}),
150
- });
151
- case 429: {
152
- const retryAfter = parseRetryAfter(res);
153
- return withCommon('RATE_LIMITED', server.message ?? 'Rate limit exceeded.', {
154
- ...(retryAfter !== undefined ? { retry_after_seconds: retryAfter } : {}),
155
- });
156
- }
157
- default:
158
- if (status >= 500) {
159
- return withCommon('SERVER_ERROR', server.message ?? 'The server encountered an error.');
160
- }
161
- return withCommon(server.code ?? 'ERROR', server.message ?? `Request failed with status ${status}.`);
162
- }
163
- }
164
- export class LabelGridClient {
165
- baseUrl;
166
- token;
167
- fetchFn;
168
- version;
169
- timeoutMs;
170
- rawTimeoutMs;
171
- constructor(opts) {
172
- this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
173
- this.token = opts.token;
174
- this.fetchFn = opts.fetchFn ?? fetch;
175
- this.version = opts.version;
176
- this.timeoutMs = opts.timeoutMs ?? 60_000;
177
- this.rawTimeoutMs = opts.rawTimeoutMs ?? 600_000;
178
- }
179
- authHeaders() {
180
- return {
181
- Authorization: `Bearer ${this.token}`,
182
- Accept: 'application/json',
183
- 'User-Agent': `labelgrid-mcp/${this.version}`,
184
- };
185
- }
186
- async send(method, path, opts = {}) {
187
- const url = `${this.baseUrl}${path}${buildQuery(opts.query)}`;
188
- const headers = { ...this.authHeaders(), ...opts.headers };
189
- if (opts.idempotency) {
190
- // A caller-supplied key is used verbatim (so a caller can dedupe a retry
191
- // across separate tool calls); otherwise a fresh UUID is generated.
192
- headers['Idempotency-Key'] = opts.idempotencyKey ?? randomUUID();
193
- }
194
- const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
195
- if (opts.rawBody !== undefined) {
196
- init.body = opts.rawBody;
197
- }
198
- else if (opts.body !== undefined) {
199
- headers['Content-Type'] = 'application/json';
200
- init.body = JSON.stringify(opts.body);
201
- }
202
- let res;
203
- try {
204
- res = await this.fetchFn(url, init);
205
- }
206
- catch (err) {
207
- if (err instanceof DOMException &&
208
- (err.name === 'TimeoutError' || err.name === 'AbortError')) {
209
- return {
210
- error: {
211
- code: 'TIMEOUT',
212
- message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds. Try again, or narrow the request.`,
213
- status: 0,
214
- },
215
- };
216
- }
217
- return {
218
- error: {
219
- code: 'NETWORK_ERROR',
220
- message: err instanceof Error ? err.message : 'Network request failed.',
221
- status: 0,
222
- },
223
- };
224
- }
225
- // Cheap pre-check: bound the response before reading when the length is known.
226
- const declaredLength = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
227
- if (!Number.isNaN(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
228
- return {
229
- error: {
230
- code: 'RESPONSE_TOO_LARGE',
231
- message: `The response is ${declaredLength} bytes, over the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
232
- status: res.status,
233
- },
234
- };
235
- }
236
- const tooLarge = {
237
- error: {
238
- code: 'RESPONSE_TOO_LARGE',
239
- message: `The response body exceeds the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
240
- status: res.status,
241
- },
242
- };
243
- // A chunked/streamed response carries no Content-Length, so bound it AS we
244
- // read: accumulate chunks with a running byte counter and abort the moment
245
- // the counter crosses the ceiling — never buffering the whole oversized body.
246
- // The request timeout keeps running while the body streams, so a read can
247
- // also abort here — map that to the same structured TIMEOUT.
248
- let text;
249
- try {
250
- text = await this.readBody(res, tooLarge);
251
- }
252
- catch (err) {
253
- if (err instanceof DOMException &&
254
- (err.name === 'TimeoutError' || err.name === 'AbortError')) {
255
- return {
256
- error: {
257
- code: 'TIMEOUT',
258
- message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
259
- status: 0,
260
- },
261
- };
262
- }
263
- return {
264
- error: {
265
- code: 'NETWORK_ERROR',
266
- message: err instanceof Error ? err.message : 'Reading the response failed.',
267
- status: 0,
268
- },
269
- };
270
- }
271
- if (typeof text !== 'string') {
272
- return text; // the bounded reader returned the too-large error result
273
- }
274
- let body = null;
275
- if (text.length > 0) {
276
- try {
277
- body = JSON.parse(text);
278
- }
279
- catch {
280
- body = text;
281
- }
282
- }
283
- if (res.ok) {
284
- return { data: body };
285
- }
286
- return { error: normalizeError(res, body) };
287
- }
288
- /**
289
- * Reads a response body with the byte ceiling enforced mid-stream. Returns
290
- * the decoded text, or the supplied too-large error result when the ceiling
291
- * is crossed. Abort/timeout rejections propagate to the caller for mapping.
292
- */
293
- async readBody(res, tooLarge) {
294
- if (res.body) {
295
- const reader = res.body.getReader();
296
- const chunks = [];
297
- let total = 0;
298
- for (;;) {
299
- const { done, value } = await reader.read();
300
- if (done)
301
- break;
302
- if (value) {
303
- total += value.byteLength;
304
- if (total > MAX_RESPONSE_BYTES) {
305
- // cancel() can reject (e.g. an already-errored stream); swallow it so
306
- // an oversized response ALWAYS returns RESPONSE_TOO_LARGE.
307
- try {
308
- await reader.cancel();
309
- }
310
- catch {
311
- // best-effort cleanup — the size bound is what matters here.
312
- }
313
- return tooLarge;
314
- }
315
- chunks.push(value);
316
- }
317
- }
318
- const merged = new Uint8Array(total);
319
- let offset = 0;
320
- for (const chunk of chunks) {
321
- merged.set(chunk, offset);
322
- offset += chunk.byteLength;
323
- }
324
- return new TextDecoder('utf-8').decode(merged);
325
- }
326
- // No readable stream (some test stubs) — fall back to text() and measure
327
- // the true byte length as a backstop (multi-byte chars exceed char count).
328
- const text = await res.text();
329
- if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
330
- return tooLarge;
331
- }
332
- return text;
333
- }
334
- get(path, query) {
335
- return this.send('GET', path, { query });
336
- }
337
- post(path, body, opts) {
338
- return this.send('POST', path, {
339
- body,
340
- idempotency: opts?.idempotency,
341
- idempotencyKey: opts?.idempotencyKey,
342
- });
343
- }
344
- patch(path, body) {
345
- return this.send('PATCH', path, { body });
346
- }
347
- put(path, body, opts) {
348
- return this.send('PUT', path, {
349
- body,
350
- idempotency: opts?.idempotency,
351
- idempotencyKey: opts?.idempotencyKey,
352
- });
353
- }
354
- delete(path) {
355
- return this.send('DELETE', path);
356
- }
357
- /**
358
- * Sends a multipart/form-data POST with a single file field plus optional
359
- * extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
360
- * error result rather than throwing.
361
- */
362
- async postMultipart(path, filePath, fieldName, extra) {
363
- let bytes;
364
- try {
365
- bytes = await readFile(filePath);
366
- }
367
- catch (err) {
368
- return {
369
- error: {
370
- code: 'FILE_NOT_FOUND',
371
- message: `Could not read file at ${filePath}: ${err instanceof Error ? err.message : 'unknown error'}`,
372
- status: 0,
373
- },
374
- };
375
- }
376
- const form = new FormData();
377
- form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: contentType(filePath) }), basename(filePath));
378
- for (const [key, value] of Object.entries(extra ?? {})) {
379
- form.append(key, value);
380
- }
381
- // Let fetch set the multipart Content-Type boundary; do not override it.
382
- return this.send('POST', path, { rawBody: form });
383
- }
384
- /**
385
- * Performs a raw request with NO Authorization header — used for presigned
386
- * upload PUTs, where the signed URL is already the credential and an extra
387
- * Bearer token would break the signature.
388
- */
389
- raw(url, init) {
390
- return this.fetchFn(url, { signal: AbortSignal.timeout(this.rawTimeoutMs), ...init });
391
- }
392
- }