@labelgrid/mcp 0.1.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +299 -0
  4. package/dist/api/content-types.d.ts +33 -0
  5. package/dist/api/content-types.js +87 -0
  6. package/dist/api/http.d.ts +62 -0
  7. package/dist/api/http.js +345 -0
  8. package/dist/api/upload.d.ts +26 -0
  9. package/dist/api/upload.js +104 -0
  10. package/dist/config.d.ts +29 -0
  11. package/dist/config.js +82 -0
  12. package/dist/coverage.d.ts +19 -0
  13. package/dist/coverage.js +150 -0
  14. package/dist/gating.d.ts +13 -0
  15. package/dist/gating.js +22 -0
  16. package/dist/index.d.ts +9 -0
  17. package/dist/index.js +86 -0
  18. package/dist/legal.d.ts +12 -0
  19. package/dist/legal.js +13 -0
  20. package/dist/log.d.ts +16 -0
  21. package/dist/log.js +35 -0
  22. package/dist/server.d.ts +15 -0
  23. package/dist/server.js +83 -0
  24. package/dist/tools/accounting.d.ts +12 -0
  25. package/dist/tools/accounting.js +386 -0
  26. package/dist/tools/analytics.d.ts +3 -0
  27. package/dist/tools/analytics.js +62 -0
  28. package/dist/tools/catalog-read.d.ts +10 -0
  29. package/dist/tools/catalog-read.js +145 -0
  30. package/dist/tools/catalog-write.d.ts +12 -0
  31. package/dist/tools/catalog-write.js +206 -0
  32. package/dist/tools/delivery.d.ts +6 -0
  33. package/dist/tools/delivery.js +40 -0
  34. package/dist/tools/files-read.d.ts +7 -0
  35. package/dist/tools/files-read.js +86 -0
  36. package/dist/tools/full-writes.d.ts +12 -0
  37. package/dist/tools/full-writes.js +248 -0
  38. package/dist/tools/identity.d.ts +3 -0
  39. package/dist/tools/identity.js +28 -0
  40. package/dist/tools/reference.d.ts +3 -0
  41. package/dist/tools/reference.js +36 -0
  42. package/dist/tools/release-write.d.ts +12 -0
  43. package/dist/tools/release-write.js +184 -0
  44. package/dist/tools/review-read.d.ts +7 -0
  45. package/dist/tools/review-read.js +78 -0
  46. package/dist/tools/setup.d.ts +11 -0
  47. package/dist/tools/setup.js +56 -0
  48. package/dist/tools/types.d.ts +41 -0
  49. package/dist/tools/types.js +52 -0
  50. package/dist/tools/webhooks.d.ts +7 -0
  51. package/dist/tools/webhooks.js +124 -0
  52. package/dist/version.d.ts +6 -0
  53. package/dist/version.js +17 -0
  54. package/package.json +34 -0
  55. package/server.json +59 -0
@@ -0,0 +1,345 @@
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
+ constructor(opts) {
170
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
171
+ this.token = opts.token;
172
+ this.fetchFn = opts.fetchFn ?? fetch;
173
+ this.version = opts.version;
174
+ }
175
+ authHeaders() {
176
+ return {
177
+ Authorization: `Bearer ${this.token}`,
178
+ Accept: 'application/json',
179
+ 'User-Agent': `labelgrid-mcp/${this.version}`,
180
+ };
181
+ }
182
+ async send(method, path, opts = {}) {
183
+ const url = `${this.baseUrl}${path}${buildQuery(opts.query)}`;
184
+ const headers = { ...this.authHeaders(), ...opts.headers };
185
+ if (opts.idempotency) {
186
+ // A caller-supplied key is used verbatim (so a caller can dedupe a retry
187
+ // across separate tool calls); otherwise a fresh UUID is generated.
188
+ headers['Idempotency-Key'] = opts.idempotencyKey ?? randomUUID();
189
+ }
190
+ const init = { method, headers };
191
+ if (opts.rawBody !== undefined) {
192
+ init.body = opts.rawBody;
193
+ }
194
+ else if (opts.body !== undefined) {
195
+ headers['Content-Type'] = 'application/json';
196
+ init.body = JSON.stringify(opts.body);
197
+ }
198
+ let res;
199
+ try {
200
+ res = await this.fetchFn(url, init);
201
+ }
202
+ catch (err) {
203
+ return {
204
+ error: {
205
+ code: 'NETWORK_ERROR',
206
+ message: err instanceof Error ? err.message : 'Network request failed.',
207
+ status: 0,
208
+ },
209
+ };
210
+ }
211
+ // Cheap pre-check: bound the response before reading when the length is known.
212
+ const declaredLength = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
213
+ if (!Number.isNaN(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
214
+ return {
215
+ error: {
216
+ code: 'RESPONSE_TOO_LARGE',
217
+ message: `The response is ${declaredLength} bytes, over the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
218
+ status: res.status,
219
+ },
220
+ };
221
+ }
222
+ const tooLarge = {
223
+ error: {
224
+ code: 'RESPONSE_TOO_LARGE',
225
+ message: `The response body exceeds the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
226
+ status: res.status,
227
+ },
228
+ };
229
+ // A chunked/streamed response carries no Content-Length, so bound it AS we
230
+ // read: accumulate chunks with a running byte counter and abort the moment
231
+ // the counter crosses the ceiling — never buffering the whole oversized body.
232
+ let text;
233
+ if (res.body) {
234
+ const reader = res.body.getReader();
235
+ const chunks = [];
236
+ let total = 0;
237
+ for (;;) {
238
+ const { done, value } = await reader.read();
239
+ if (done)
240
+ break;
241
+ if (value) {
242
+ total += value.byteLength;
243
+ if (total > MAX_RESPONSE_BYTES) {
244
+ // cancel() can reject (e.g. an already-errored stream); swallow it so
245
+ // an oversized response ALWAYS returns RESPONSE_TOO_LARGE.
246
+ try {
247
+ await reader.cancel();
248
+ }
249
+ catch {
250
+ // best-effort cleanup — the size bound is what matters here.
251
+ }
252
+ return tooLarge;
253
+ }
254
+ chunks.push(value);
255
+ }
256
+ }
257
+ const merged = new Uint8Array(total);
258
+ let offset = 0;
259
+ for (const chunk of chunks) {
260
+ merged.set(chunk, offset);
261
+ offset += chunk.byteLength;
262
+ }
263
+ text = new TextDecoder('utf-8').decode(merged);
264
+ }
265
+ else {
266
+ // No readable stream (some test stubs) — fall back to text() and measure
267
+ // the true byte length as a backstop (multi-byte chars exceed char count).
268
+ text = await res.text();
269
+ if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
270
+ return tooLarge;
271
+ }
272
+ }
273
+ let body = null;
274
+ if (text.length > 0) {
275
+ try {
276
+ body = JSON.parse(text);
277
+ }
278
+ catch {
279
+ body = text;
280
+ }
281
+ }
282
+ if (res.ok) {
283
+ return { data: body };
284
+ }
285
+ return { error: normalizeError(res, body) };
286
+ }
287
+ get(path, query) {
288
+ return this.send('GET', path, { query });
289
+ }
290
+ post(path, body, opts) {
291
+ return this.send('POST', path, {
292
+ body,
293
+ idempotency: opts?.idempotency,
294
+ idempotencyKey: opts?.idempotencyKey,
295
+ });
296
+ }
297
+ patch(path, body) {
298
+ return this.send('PATCH', path, { body });
299
+ }
300
+ put(path, body, opts) {
301
+ return this.send('PUT', path, {
302
+ body,
303
+ idempotency: opts?.idempotency,
304
+ idempotencyKey: opts?.idempotencyKey,
305
+ });
306
+ }
307
+ delete(path) {
308
+ return this.send('DELETE', path);
309
+ }
310
+ /**
311
+ * Sends a multipart/form-data POST with a single file field plus optional
312
+ * extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
313
+ * error result rather than throwing.
314
+ */
315
+ async postMultipart(path, filePath, fieldName, extra) {
316
+ let bytes;
317
+ try {
318
+ bytes = await readFile(filePath);
319
+ }
320
+ catch (err) {
321
+ return {
322
+ error: {
323
+ code: 'FILE_NOT_FOUND',
324
+ message: `Could not read file at ${filePath}: ${err instanceof Error ? err.message : 'unknown error'}`,
325
+ status: 0,
326
+ },
327
+ };
328
+ }
329
+ const form = new FormData();
330
+ form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: contentType(filePath) }), basename(filePath));
331
+ for (const [key, value] of Object.entries(extra ?? {})) {
332
+ form.append(key, value);
333
+ }
334
+ // Let fetch set the multipart Content-Type boundary; do not override it.
335
+ return this.send('POST', path, { rawBody: form });
336
+ }
337
+ /**
338
+ * Performs a raw request with NO Authorization header — used for presigned
339
+ * upload PUTs, where the signed URL is already the credential and an extra
340
+ * Bearer token would break the signature.
341
+ */
342
+ raw(url, init) {
343
+ return this.fetchFn(url, init);
344
+ }
345
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Presigned-URL upload helper.
3
+ *
4
+ * A large binary asset is never streamed through the LabelGrid API. Instead the
5
+ * flow is three steps:
6
+ * 1. POST the upload-url endpoint (with the filename) to mint a short-lived
7
+ * presigned storage URL and its object key.
8
+ * 2. PUT the file bytes straight to that presigned URL. This request carries
9
+ * NO Authorization header — the signature in the URL is the credential, and
10
+ * an extra Bearer token would break it.
11
+ * 3. PUT the commit endpoint with the returned object key (with an idempotency
12
+ * key) so the API records the finalized file.
13
+ *
14
+ * A failure at step 2 aborts before the commit, so a half-uploaded object is
15
+ * never finalized. Business rules (format checks, transcoding) stay server-side.
16
+ */
17
+ import type { ApiResult, LabelGridClient } from './http.js';
18
+ export type UploadOptions = {
19
+ /** The endpoint that mints the presigned URL, e.g. /tracks/42/files/stereo/upload-url. */
20
+ uploadUrlPath: string;
21
+ /** The endpoint that records the finalized file, e.g. /tracks/42/files/stereo. */
22
+ commitPath: string;
23
+ /** Absolute or relative local path to the file to upload. */
24
+ filePath: string;
25
+ };
26
+ export declare function uploadViaPresignedUrl(client: LabelGridClient, opts: UploadOptions): Promise<ApiResult<unknown>>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Presigned-URL upload helper.
3
+ *
4
+ * A large binary asset is never streamed through the LabelGrid API. Instead the
5
+ * flow is three steps:
6
+ * 1. POST the upload-url endpoint (with the filename) to mint a short-lived
7
+ * presigned storage URL and its object key.
8
+ * 2. PUT the file bytes straight to that presigned URL. This request carries
9
+ * NO Authorization header — the signature in the URL is the credential, and
10
+ * an extra Bearer token would break it.
11
+ * 3. PUT the commit endpoint with the returned object key (with an idempotency
12
+ * key) so the API records the finalized file.
13
+ *
14
+ * A failure at step 2 aborts before the commit, so a half-uploaded object is
15
+ * never finalized. Business rules (format checks, transcoding) stay server-side.
16
+ */
17
+ import { statSync } from 'node:fs';
18
+ import { readFile } from 'node:fs/promises';
19
+ import { basename } from 'node:path';
20
+ import { log } from '../log.js';
21
+ import { contentType } from './content-types.js';
22
+ /** True only for an existing regular file. */
23
+ function isReadableFile(p) {
24
+ try {
25
+ return statSync(p).isFile();
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export async function uploadViaPresignedUrl(client, opts) {
32
+ // Fail fast and locally: never touch the network for a file we cannot read.
33
+ if (!isReadableFile(opts.filePath)) {
34
+ const error = {
35
+ code: 'FILE_NOT_FOUND',
36
+ message: `No readable file at ${opts.filePath}.`,
37
+ status: 0,
38
+ };
39
+ return { error };
40
+ }
41
+ // Step 1: mint the presigned URL.
42
+ const minted = await client.post(opts.uploadUrlPath, {
43
+ filename: basename(opts.filePath),
44
+ });
45
+ if ('error' in minted)
46
+ return minted;
47
+ const uploadUrl = minted.data?.upload_url;
48
+ const key = minted.data?.key;
49
+ if (typeof uploadUrl !== 'string' || typeof key !== 'string') {
50
+ const error = {
51
+ code: 'UPLOAD_URL_INVALID',
52
+ message: 'The upload-url response did not contain a usable upload_url and key.',
53
+ status: 0,
54
+ };
55
+ return { error };
56
+ }
57
+ // Step 2: PUT the bytes directly to storage — NO auth header (the URL is signed).
58
+ // The file passed isReadableFile above, but it can vanish before this read
59
+ // (a TOCTOU race); a structured FILE_NOT_FOUND is the contract, not a throw.
60
+ let bytes;
61
+ try {
62
+ bytes = await readFile(opts.filePath);
63
+ }
64
+ catch {
65
+ const error = {
66
+ code: 'FILE_NOT_FOUND',
67
+ message: `The file at ${opts.filePath} could not be read.`,
68
+ status: 0,
69
+ };
70
+ return { error };
71
+ }
72
+ let putRes;
73
+ try {
74
+ putRes = await client.raw(uploadUrl, {
75
+ method: 'PUT',
76
+ headers: { 'Content-Type': contentType(opts.filePath) },
77
+ body: new Uint8Array(bytes),
78
+ });
79
+ }
80
+ catch (err) {
81
+ // Never surface err.message raw to the log — it can embed the signed URL,
82
+ // and `reason` is not a redacted key. Strip any URL before logging.
83
+ log('error', 'presigned upload PUT failed', {
84
+ reason: err instanceof Error ? err.message.replace(/https?:\/\/\S+/gi, '[url]') : 'network error',
85
+ });
86
+ const error = {
87
+ code: 'UPLOAD_FAILED',
88
+ message: 'Uploading the file to storage failed.',
89
+ status: 0,
90
+ };
91
+ return { error };
92
+ }
93
+ if (!putRes.ok) {
94
+ // Abort BEFORE the commit — a half-uploaded object is never finalized.
95
+ const error = {
96
+ code: 'UPLOAD_FAILED',
97
+ message: `Uploading the file to storage failed with status ${putRes.status}.`,
98
+ status: putRes.status,
99
+ };
100
+ return { error };
101
+ }
102
+ // Step 3: commit the object key (idempotent — a retried commit will not duplicate).
103
+ return client.put(opts.commitPath, { s3_key: key }, { idempotency: true });
104
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Environment parsing into a validated {@link Config}.
3
+ *
4
+ * When the token is absent the server does not fail: it enters setup mode
5
+ * ({@link Config.setupMode}), which exposes only the `setup` helper tool. When a
6
+ * token is present, write access is opt-out (safe writes on by default) and
7
+ * full-write access is doubly opt-in (flag + an exact acknowledgment sentence).
8
+ * A read-only override wins over everything.
9
+ */
10
+ export type Config = {
11
+ baseUrl: string;
12
+ /** The API token, or null when the server is running in setup mode. */
13
+ token: string | null;
14
+ /** True when no token is configured: only the `setup` tool is registered. */
15
+ setupMode: boolean;
16
+ writes: boolean;
17
+ fullWrites: boolean;
18
+ toolsets: Set<string> | null;
19
+ };
20
+ export declare const DEFAULT_BASE_URL = "https://api.labelgrid.com/api/public";
21
+ /** The exact sentence a user must set in LABELGRID_FULL_WRITES_ACK to arm full writes. */
22
+ export declare const FULL_WRITES_ACK = "I accept responsibility for AI-driven distribution actions";
23
+ /** The valid toolset names; unknown names in LABELGRID_TOOLSETS warn and are ignored. */
24
+ export declare const KNOWN_TOOLSETS: ReadonlySet<string>;
25
+ /** Thrown when the environment cannot produce a usable config. */
26
+ export declare class ConfigError extends Error {
27
+ constructor(message: string);
28
+ }
29
+ export declare function loadConfig(env: NodeJS.ProcessEnv): Config;
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Environment parsing into a validated {@link Config}.
3
+ *
4
+ * When the token is absent the server does not fail: it enters setup mode
5
+ * ({@link Config.setupMode}), which exposes only the `setup` helper tool. When a
6
+ * token is present, write access is opt-out (safe writes on by default) and
7
+ * full-write access is doubly opt-in (flag + an exact acknowledgment sentence).
8
+ * A read-only override wins over everything.
9
+ */
10
+ import { log } from './log.js';
11
+ export const DEFAULT_BASE_URL = 'https://api.labelgrid.com/api/public';
12
+ /** The exact sentence a user must set in LABELGRID_FULL_WRITES_ACK to arm full writes. */
13
+ export const FULL_WRITES_ACK = 'I accept responsibility for AI-driven distribution actions';
14
+ /** The valid toolset names; unknown names in LABELGRID_TOOLSETS warn and are ignored. */
15
+ export const KNOWN_TOOLSETS = new Set([
16
+ 'identity',
17
+ 'reference',
18
+ 'catalog',
19
+ 'releases',
20
+ 'review',
21
+ 'analytics',
22
+ 'accounting',
23
+ 'delivery',
24
+ 'webhooks',
25
+ 'distribution',
26
+ ]);
27
+ /** Thrown when the environment cannot produce a usable config. */
28
+ export class ConfigError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = 'ConfigError';
32
+ }
33
+ }
34
+ function isTruthy(value) {
35
+ if (value === undefined)
36
+ return false;
37
+ return /^(1|true|yes|on)$/i.test(value.trim());
38
+ }
39
+ export function loadConfig(env) {
40
+ const baseUrl = env.LABELGRID_API_URL?.trim() || DEFAULT_BASE_URL;
41
+ const token = env.LABELGRID_API_TOKEN?.trim();
42
+ if (!token) {
43
+ // No token: start in setup mode instead of failing. The server registers
44
+ // only the `setup` tool, which guides the user through creating a token. No
45
+ // API calls are possible until a token is configured, so writes are off.
46
+ return {
47
+ baseUrl,
48
+ token: null,
49
+ setupMode: true,
50
+ writes: false,
51
+ fullWrites: false,
52
+ toolsets: null,
53
+ };
54
+ }
55
+ const readOnly = isTruthy(env.LABELGRID_READ_ONLY);
56
+ let writes = env.LABELGRID_ENABLE_WRITES === undefined ? true : isTruthy(env.LABELGRID_ENABLE_WRITES);
57
+ const fullWritesFlag = isTruthy(env.LABELGRID_ENABLE_FULL_WRITES);
58
+ const ackOk = env.LABELGRID_FULL_WRITES_ACK === FULL_WRITES_ACK;
59
+ let fullWrites = fullWritesFlag && ackOk;
60
+ if (fullWritesFlag && !ackOk) {
61
+ log('warn', `LABELGRID_ENABLE_FULL_WRITES is set but full writes stay OFF: set LABELGRID_FULL_WRITES_ACK to exactly "${FULL_WRITES_ACK}" to enable them.`);
62
+ }
63
+ if (readOnly) {
64
+ writes = false;
65
+ fullWrites = false;
66
+ }
67
+ let toolsets = null;
68
+ const rawToolsets = env.LABELGRID_TOOLSETS;
69
+ if (rawToolsets !== undefined && rawToolsets.trim() !== '') {
70
+ toolsets = new Set();
71
+ for (const name of rawToolsets
72
+ .split(',')
73
+ .map((s) => s.trim())
74
+ .filter((s) => s.length > 0)) {
75
+ if (!KNOWN_TOOLSETS.has(name)) {
76
+ log('warn', `Unknown toolset in LABELGRID_TOOLSETS: "${name}" (ignored).`);
77
+ }
78
+ toolsets.add(name);
79
+ }
80
+ }
81
+ return { baseUrl, token, setupMode: false, writes, fullWrites, toolsets };
82
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The endpoint-coverage manifest, consumed by the API-coverage drift check.
3
+ *
4
+ * `COVERAGE` maps every public endpoint (method + path) this server exposes as a
5
+ * tool to that tool's name. `EXCLUDED` lists public endpoints deliberately not
6
+ * exposed in v1, each with a short customer-appropriate reason. `PENDING_DOCS`
7
+ * lists tool endpoints whose reference documentation is still being generated
8
+ * (the drift check tolerates their absence from the API document snapshot).
9
+ *
10
+ * The drift check fails when the live API document contains a path+method that
11
+ * is neither covered nor excluded — the signal to add a tool (or an exclusion)
12
+ * in the same cycle the API grows.
13
+ *
14
+ * Keys use the API document's exact path templates (e.g. `{release}`) and an
15
+ * uppercase method followed by a single space.
16
+ */
17
+ export declare const COVERAGE: Record<string, string>;
18
+ export declare const EXCLUDED: Record<string, string>;
19
+ export declare const PENDING_DOCS: Record<string, string>;