🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@labelgrid/mcp

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@labelgrid/mcp - npm Package Compare versions

Comparing version
0.3.0
to
0.3.1
+11
-0
CHANGELOG.md

@@ -8,2 +8,13 @@ # Changelog

## [0.3.1] - 2026-07-20
### Changed
- Internal restructure: the repository is now an npm workspace and the shared
LabelGrid API client (HTTP transport, uploads, content types, the
catalog-entity registry, and redacting logging) moved into the
`@labelgrid/core` package, which this server now depends on. No behavior
change — the tool catalog, gating, toolsets, resources and responses are
identical to 0.3.0.
## [0.3.0] - 2026-07-16

@@ -10,0 +21,0 @@

+1
-1

@@ -10,3 +10,3 @@ /**

*/
import { log } from './log.js';
import { log } from '@labelgrid/core';
export const DEFAULT_BASE_URL = 'https://api.labelgrid.com/api/public';

@@ -13,0 +13,0 @@ /** The exact sentence a user must set in LABELGRID_FULL_WRITES_ACK to arm full writes. */

@@ -9,8 +9,7 @@ #!/usr/bin/env node

*/
import { LabelGridClient, log } from '@labelgrid/core';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { LabelGridClient } from './api/http.js';
import { ConfigError, loadConfig } from './config.js';
import { isToolEnabled } from './gating.js';
import { FULL_WRITES_NOTICE, LEGAL_SUMMARY } from './legal.js';
import { log } from './log.js';
import { buildServer } from './server.js';

@@ -17,0 +16,0 @@ import { allTools } from './tools/all.js';

@@ -26,3 +26,3 @@ /**

*/
import type { ApiResult } from './api/http.js';
import type { ApiResult } from '@labelgrid/core';
/** Per-tool concise-mode field allowlists, keyed by tool name. */

@@ -29,0 +29,0 @@ export declare const CONCISE_ALLOWLISTS: Record<string, readonly string[]>;

@@ -12,4 +12,4 @@ /**

*/
import type { LabelGridClient } from '@labelgrid/core';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { LabelGridClient } from './api/http.js';
import type { Config } from './config.js';

@@ -16,0 +16,0 @@ /** The reference dataset types as a tuple, for the tool's zod enum. */

@@ -11,6 +11,6 @@ /**

*/
import { type LabelGridClient } from '@labelgrid/core';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { LabelGridClient } from './api/http.js';
import { type Config } from './config.js';
import { type ToolDef } from './tools/types.js';
export declare function buildServer(config: Config, client: LabelGridClient, tools: ToolDef[]): McpServer;

@@ -11,2 +11,3 @@ /**

*/
import { log } from '@labelgrid/core';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

@@ -16,3 +17,2 @@ import { defaultExcludedToolsets } from './config.js';

import { DATA_HANDLING_NOTE, FULL_WRITES_NOTICE, LEGAL_SUMMARY } from './legal.js';
import { log } from './log.js';
import { registerReferenceResources } from './resources.js';

@@ -19,0 +19,0 @@ import { setupTools } from './tools/setup.js';

@@ -6,3 +6,3 @@ /**

*
* Every tool selects its entity via the registry in `src/entities.ts`.
* Every tool selects its entity via the entity registry in `@labelgrid/core`.
* Create/update forward a permissive `fields` object straight to the API,

@@ -9,0 +9,0 @@ * which owns all validation — this keeps each tool a thin wrapper and lets the

@@ -6,3 +6,3 @@ /**

*
* Every tool selects its entity via the registry in `src/entities.ts`.
* Every tool selects its entity via the entity registry in `@labelgrid/core`.
* Create/update forward a permissive `fields` object straight to the API,

@@ -15,5 +15,4 @@ * which owns all validation — this keeps each tool a thin wrapper and lets the

import { statSync } from 'node:fs';
import { ENTITIES, ENTITY_NAMES, assertAllowedExtension, } from '@labelgrid/core';
import { z } from 'zod';
import { assertAllowedExtension } from '../api/content-types.js';
import { ENTITIES, ENTITY_NAMES } from '../entities.js';
import { applyProjection } from '../projection.js';

@@ -20,0 +19,0 @@ /** Accepted image extensions for the catalog image uploads. */

@@ -12,5 +12,4 @@ /**

*/
import { assertAllowedExtension, uploadViaPresignedUrl } from '@labelgrid/core';
import { z } from 'zod';
import { assertAllowedExtension } from '../api/content-types.js';
import { uploadViaPresignedUrl } from '../api/upload.js';
/** Per-target upload extension allow-lists: an upload tool never reads an arbitrary file. */

@@ -17,0 +16,0 @@ const TRACK_UPLOAD_EXTS = {

@@ -10,4 +10,4 @@ /**

*/
import type { ApiResult, LabelGridClient } from '@labelgrid/core';
import type { z } from 'zod';
import type { ApiResult, LabelGridClient } from '../api/http.js';
import type { Config } from '../config.js';

@@ -14,0 +14,0 @@ import type { Gate } from '../gating.js';

{
"name": "@labelgrid/mcp",
"version": "0.3.0",
"version": "0.3.1",
"mcpName": "io.github.labelgrid/labelgrid-mcp",

@@ -15,16 +15,11 @@ "description": "Official LabelGrid MCP server — connect your AI client to your LabelGrid account",

"build": "tsc",
"build:mcpb": "npm run build && node scripts/build-mcpb.mjs",
"start": "node dist/index.js",
"dev": "tsc --watch",
"test": "vitest run --exclude 'test/contract/**'",
"test:contract": "vitest run test/contract",
"lint": "biome check .",
"leak-guard": "node scripts/leak-guard.mjs",
"check-coverage": "node scripts/check-api-coverage.mjs",
"gen-docs": "node scripts/gen-tool-docs.mjs",
"measure-tokens": "node scripts/measure-tool-tokens.mjs"
"test:contract": "vitest run test/contract"
},
"repository": {
"type": "git",
"url": "git+https://github.com/labelgrid/labelgrid-mcp.git"
"url": "git+https://github.com/labelgrid/labelgrid-mcp.git",
"directory": "packages/mcp"
},

@@ -36,11 +31,6 @@ "license": "MIT",

"dependencies": {
"@labelgrid/core": "0.1.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

@@ -5,3 +5,3 @@ {

"description": "Official LabelGrid MCP server — manage your music catalog, releases, analytics and distribution.",
"version": "0.3.0",
"version": "0.3.1",
"websiteUrl": "https://labelgrid.com",

@@ -16,3 +16,3 @@ "repository": {

"identifier": "@labelgrid/mcp",
"version": "0.3.0",
"version": "0.3.1",
"transport": {

@@ -19,0 +19,0 @@ "type": "stdio"

/**
* Shared file content-type inference and the upload extension allow-list guard.
*
* CONTENT_TYPES (moved here from upload.ts) infers a best-effort MIME type from
* a file extension — used for the presigned PUT and the multipart Blob.
* assertAllowedExtension is the per-tool guard: each file-accepting tool
* declares exactly which extensions it accepts, and the guard rejects anything
* else BEFORE the file is read or any HTTP call is made, so an upload tool can
* never be pointed at an arbitrary local file.
*/
import type { ApiError } from './http.js';
/** Best-effort Content-Type inferred from a file extension. */
export declare const CONTENT_TYPES: Record<string, string>;
/** Best-effort Content-Type for a file path (default application/octet-stream). */
export declare function contentType(filePath: string): string;
/**
* Rejects a file whose extension is not in `allowed` (case-insensitive), before
* any read or HTTP call, and resolves the path to its real target. The supplied
* path's extension is checked first (the fast path); then the path is resolved
* with realpathSync and the REAL target's extension is checked too, so a symlink
* named `cover.jpg` that points at an arbitrary local file cannot slip past the
* guard. On success it returns `{ realPath }` — the resolved canonical path,
* which the caller MUST use as the path it reads/uploads (never the original
* argument), so a symlink retargeted after validation cannot redirect the read
* (the resolved target is what gets uploaded). On failure it returns `{ error }`
* — a structured FILE_TYPE_NOT_ALLOWED, or FILE_NOT_FOUND if the path does not
* resolve.
*/
export declare function assertAllowedExtension(filePath: string, allowed: string[]): {
error: ApiError;
} | {
realPath: string;
};
/**
* Shared file content-type inference and the upload extension allow-list guard.
*
* CONTENT_TYPES (moved here from upload.ts) infers a best-effort MIME type from
* a file extension — used for the presigned PUT and the multipart Blob.
* assertAllowedExtension is the per-tool guard: each file-accepting tool
* declares exactly which extensions it accepts, and the guard rejects anything
* else BEFORE the file is read or any HTTP call is made, so an upload tool can
* never be pointed at an arbitrary local file.
*/
import { realpathSync } from 'node:fs';
import { extname } from 'node:path';
/** Best-effort Content-Type inferred from a file extension. */
export const CONTENT_TYPES = {
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.aif': 'audio/aiff',
'.aiff': 'audio/aiff',
'.mp3': 'audio/mpeg',
'.lrc': 'text/plain',
'.txt': 'text/plain',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
};
/** Best-effort Content-Type for a file path (default application/octet-stream). */
export function contentType(filePath) {
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
}
/**
* Rejects a file whose extension is not in `allowed` (case-insensitive), before
* any read or HTTP call, and resolves the path to its real target. The supplied
* path's extension is checked first (the fast path); then the path is resolved
* with realpathSync and the REAL target's extension is checked too, so a symlink
* named `cover.jpg` that points at an arbitrary local file cannot slip past the
* guard. On success it returns `{ realPath }` — the resolved canonical path,
* which the caller MUST use as the path it reads/uploads (never the original
* argument), so a symlink retargeted after validation cannot redirect the read
* (the resolved target is what gets uploaded). On failure it returns `{ error }`
* — a structured FILE_TYPE_NOT_ALLOWED, or FILE_NOT_FOUND if the path does not
* resolve.
*/
export function assertAllowedExtension(filePath, allowed) {
const isAllowed = (candidate) => allowed.some((a) => a.toLowerCase() === candidate);
const ext = extname(filePath).toLowerCase();
// Fast path: reject a plainly-disallowed extension before touching the disk.
if (!isAllowed(ext)) {
return {
error: {
code: 'FILE_TYPE_NOT_ALLOWED',
message: `This tool only accepts ${allowed.join(', ')} files (got "${ext || 'no extension'}").`,
status: 0,
},
};
}
// The supplied name is allowed; resolve symlinks and re-check the real target.
let realPath;
try {
realPath = realpathSync(filePath);
}
catch {
return {
error: {
code: 'FILE_NOT_FOUND',
message: `No readable file at ${filePath}.`,
status: 0,
},
};
}
const realExt = extname(realPath).toLowerCase();
if (!isAllowed(realExt)) {
return {
error: {
code: 'FILE_TYPE_NOT_ALLOWED',
message: `The file resolves to a "${realExt || 'no extension'}" file; this tool only accepts ${allowed.join(', ')}.`,
status: 0,
},
};
}
return { realPath };
}
/**
* The single typed HTTP client for the LabelGrid public API.
*
* Every tool goes through this client. It owns transport, header injection,
* query serialization, optional idempotency keys and — critically — error
* normalization: HTTP failures are turned into a structured {@link ApiError}
* and returned, never thrown. Business rules live server-side; this file is
* transport only (no retries, no queues).
*/
export type ApiError = {
code: string;
message: string;
status: number;
field?: string;
suggestion?: string;
retry_after_seconds?: number;
errors?: unknown;
/** Structured validation detail passed through verbatim from the API (422). */
errors_structured?: unknown;
};
export type ApiResult<T = unknown> = {
data: T;
} | {
error: ApiError;
};
export declare class LabelGridClient {
private readonly baseUrl;
private readonly token;
private readonly fetchFn;
private readonly version;
private readonly timeoutMs;
private readonly rawTimeoutMs;
constructor(opts: {
baseUrl: string;
token: string;
fetchFn?: typeof fetch;
version: string;
/** API request timeout (default 60s) — a hung call must never hang a tool. */
timeoutMs?: number;
/** Timeout for raw transfers like presigned uploads (default 10min). */
rawTimeoutMs?: number;
});
private authHeaders;
private send;
/**
* Reads a response body with the byte ceiling enforced mid-stream. Returns
* the decoded text, or the supplied too-large error result when the ceiling
* is crossed. Abort/timeout rejections propagate to the caller for mapping.
*/
private readBody;
get<T>(path: string, query?: Record<string, unknown>): Promise<ApiResult<T>>;
post<T>(path: string, body?: unknown, opts?: {
idempotency?: boolean;
idempotencyKey?: string;
}): Promise<ApiResult<T>>;
patch<T>(path: string, body?: unknown): Promise<ApiResult<T>>;
put<T>(path: string, body?: unknown, opts?: {
idempotency?: boolean;
idempotencyKey?: string;
}): Promise<ApiResult<T>>;
delete<T>(path: string): Promise<ApiResult<T>>;
/**
* Sends a multipart/form-data POST with a single file field plus optional
* extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
* error result rather than throwing.
*/
postMultipart<T>(path: string, filePath: string, fieldName: string, extra?: Record<string, string>): Promise<ApiResult<T>>;
/**
* Performs a raw request with NO Authorization header — used for presigned
* upload PUTs, where the signed URL is already the credential and an extra
* Bearer token would break the signature.
*/
raw(url: string, init: RequestInit): Promise<Response>;
}
/**
* The single typed HTTP client for the LabelGrid public API.
*
* Every tool goes through this client. It owns transport, header injection,
* query serialization, optional idempotency keys and — critically — error
* normalization: HTTP failures are turned into a structured {@link ApiError}
* and returned, never thrown. Business rules live server-side; this file is
* transport only (no retries, no queues).
*/
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { contentType } from './content-types.js';
/** Hard ceiling on a single response body, in bytes/characters. */
const MAX_RESPONSE_BYTES = 10_000_000;
const TOKEN_SUGGESTION = 'Check LABELGRID_API_TOKEN — create a new token in your dashboard under Profile → API Tokens.';
/**
* Serializes a query object into a URL search string, supporting nested
* `filter[label_id]=5` objects and repeated `metrics[]=a&metrics[]=b` arrays.
* Null/undefined values are skipped. Bracket structure is kept literal; only
* key names and values are percent-encoded.
*/
function buildQuery(query) {
if (!query)
return '';
const parts = [];
const push = (rawKey, value) => {
if (value === undefined || value === null)
return;
parts.push(`${rawKey}=${encodeURIComponent(String(value))}`);
};
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null)
continue;
const ek = encodeURIComponent(key);
if (Array.isArray(value)) {
for (const item of value)
push(`${ek}[]`, item);
}
else if (typeof value === 'object') {
for (const [subKey, subValue] of Object.entries(value)) {
const esk = encodeURIComponent(subKey);
if (Array.isArray(subValue)) {
for (const item of subValue)
push(`${ek}[${esk}][]`, item);
}
else {
push(`${ek}[${esk}]`, subValue);
}
}
}
else {
push(ek, value);
}
}
return parts.length > 0 ? `?${parts.join('&')}` : '';
}
/**
* Extracts a code/message/errors triple from any of the four backend error body
* shapes: `{message}`, `{error: string}`, `{errors}`, `{error: {code, message}}`.
*/
function extractServerError(body) {
if (typeof body === 'string') {
return { message: body };
}
if (body === null || typeof body !== 'object') {
return {};
}
const record = body;
const errors = record.errors;
const errorsStructured = record.errors_structured;
// Shape: { error: { code, message } }
if (record.error !== null && typeof record.error === 'object') {
const nested = record.error;
return {
code: typeof nested.code === 'string' ? nested.code : undefined,
message: typeof nested.message === 'string'
? nested.message
: typeof nested.error === 'string'
? nested.error
: undefined,
errors,
errors_structured: errorsStructured,
};
}
// Shape: { error: 'string' }
if (typeof record.error === 'string') {
return {
code: typeof record.code === 'string' ? record.code : undefined,
message: record.error,
errors,
errors_structured: errorsStructured,
};
}
// Shapes: { message } and/or { errors } and/or top-level { code }
const parts = {
code: typeof record.code === 'string' ? record.code : undefined,
message: typeof record.message === 'string' ? record.message : undefined,
field: typeof record.field === 'string' ? record.field : undefined,
errors,
errors_structured: errorsStructured,
};
// Derive a message from the first validation error when none was given.
if (parts.message === undefined && errors !== null && typeof errors === 'object') {
const first = Object.values(errors)[0];
if (Array.isArray(first) && typeof first[0] === 'string') {
parts.message = first[0];
}
else if (typeof first === 'string') {
parts.message = first;
}
}
return parts;
}
function parseRetryAfter(res) {
const raw = res.headers.get('Retry-After');
if (raw === null)
return undefined;
const seconds = Number.parseInt(raw, 10);
return Number.isNaN(seconds) ? undefined : seconds;
}
/** Normalizes a non-2xx HTTP response into a structured {@link ApiError}. */
function normalizeError(res, body) {
const server = extractServerError(body);
const status = res.status;
const withCommon = (code, message, extra = {}) => ({
code,
message,
status,
...(server.field !== undefined ? { field: server.field } : {}),
...(server.errors !== undefined ? { errors: server.errors } : {}),
...extra,
});
switch (status) {
case 401:
return withCommon('TOKEN_INVALID', server.message ?? 'Your API token was rejected.', {
suggestion: TOKEN_SUGGESTION,
});
case 403:
return withCommon(server.code ?? 'FORBIDDEN', server.message ?? 'Forbidden.');
case 404:
return withCommon('NOT_FOUND', server.message ?? 'The requested resource was not found.');
case 409:
return withCommon(server.code ?? 'CONFLICT', server.message ?? 'The request conflicts with the current state.');
case 422:
return withCommon('VALIDATION_FAILED', server.message ?? 'The submitted data was invalid.', {
...(server.errors_structured !== undefined
? { errors_structured: server.errors_structured }
: {}),
});
case 429: {
const retryAfter = parseRetryAfter(res);
return withCommon('RATE_LIMITED', server.message ?? 'Rate limit exceeded.', {
...(retryAfter !== undefined ? { retry_after_seconds: retryAfter } : {}),
});
}
default:
if (status >= 500) {
return withCommon('SERVER_ERROR', server.message ?? 'The server encountered an error.');
}
return withCommon(server.code ?? 'ERROR', server.message ?? `Request failed with status ${status}.`);
}
}
export class LabelGridClient {
baseUrl;
token;
fetchFn;
version;
timeoutMs;
rawTimeoutMs;
constructor(opts) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
this.token = opts.token;
this.fetchFn = opts.fetchFn ?? fetch;
this.version = opts.version;
this.timeoutMs = opts.timeoutMs ?? 60_000;
this.rawTimeoutMs = opts.rawTimeoutMs ?? 600_000;
}
authHeaders() {
return {
Authorization: `Bearer ${this.token}`,
Accept: 'application/json',
'User-Agent': `labelgrid-mcp/${this.version}`,
};
}
async send(method, path, opts = {}) {
const url = `${this.baseUrl}${path}${buildQuery(opts.query)}`;
const headers = { ...this.authHeaders(), ...opts.headers };
if (opts.idempotency) {
// A caller-supplied key is used verbatim (so a caller can dedupe a retry
// across separate tool calls); otherwise a fresh UUID is generated.
headers['Idempotency-Key'] = opts.idempotencyKey ?? randomUUID();
}
const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
if (opts.rawBody !== undefined) {
init.body = opts.rawBody;
}
else if (opts.body !== undefined) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.body);
}
let res;
try {
res = await this.fetchFn(url, init);
}
catch (err) {
if (err instanceof DOMException &&
(err.name === 'TimeoutError' || err.name === 'AbortError')) {
return {
error: {
code: 'TIMEOUT',
message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds. Try again, or narrow the request.`,
status: 0,
},
};
}
return {
error: {
code: 'NETWORK_ERROR',
message: err instanceof Error ? err.message : 'Network request failed.',
status: 0,
},
};
}
// Cheap pre-check: bound the response before reading when the length is known.
const declaredLength = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
if (!Number.isNaN(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
return {
error: {
code: 'RESPONSE_TOO_LARGE',
message: `The response is ${declaredLength} bytes, over the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
status: res.status,
},
};
}
const tooLarge = {
error: {
code: 'RESPONSE_TOO_LARGE',
message: `The response body exceeds the ${MAX_RESPONSE_BYTES}-byte limit. Narrow the request with pagination or filters.`,
status: res.status,
},
};
// A chunked/streamed response carries no Content-Length, so bound it AS we
// read: accumulate chunks with a running byte counter and abort the moment
// the counter crosses the ceiling — never buffering the whole oversized body.
// The request timeout keeps running while the body streams, so a read can
// also abort here — map that to the same structured TIMEOUT.
let text;
try {
text = await this.readBody(res, tooLarge);
}
catch (err) {
if (err instanceof DOMException &&
(err.name === 'TimeoutError' || err.name === 'AbortError')) {
return {
error: {
code: 'TIMEOUT',
message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
status: 0,
},
};
}
return {
error: {
code: 'NETWORK_ERROR',
message: err instanceof Error ? err.message : 'Reading the response failed.',
status: 0,
},
};
}
if (typeof text !== 'string') {
return text; // the bounded reader returned the too-large error result
}
let body = null;
if (text.length > 0) {
try {
body = JSON.parse(text);
}
catch {
body = text;
}
}
if (res.ok) {
return { data: body };
}
return { error: normalizeError(res, body) };
}
/**
* Reads a response body with the byte ceiling enforced mid-stream. Returns
* the decoded text, or the supplied too-large error result when the ceiling
* is crossed. Abort/timeout rejections propagate to the caller for mapping.
*/
async readBody(res, tooLarge) {
if (res.body) {
const reader = res.body.getReader();
const chunks = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
if (value) {
total += value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
// cancel() can reject (e.g. an already-errored stream); swallow it so
// an oversized response ALWAYS returns RESPONSE_TOO_LARGE.
try {
await reader.cancel();
}
catch {
// best-effort cleanup — the size bound is what matters here.
}
return tooLarge;
}
chunks.push(value);
}
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder('utf-8').decode(merged);
}
// No readable stream (some test stubs) — fall back to text() and measure
// the true byte length as a backstop (multi-byte chars exceed char count).
const text = await res.text();
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
return tooLarge;
}
return text;
}
get(path, query) {
return this.send('GET', path, { query });
}
post(path, body, opts) {
return this.send('POST', path, {
body,
idempotency: opts?.idempotency,
idempotencyKey: opts?.idempotencyKey,
});
}
patch(path, body) {
return this.send('PATCH', path, { body });
}
put(path, body, opts) {
return this.send('PUT', path, {
body,
idempotency: opts?.idempotency,
idempotencyKey: opts?.idempotencyKey,
});
}
delete(path) {
return this.send('DELETE', path);
}
/**
* Sends a multipart/form-data POST with a single file field plus optional
* extra string fields. A missing/unreadable file yields a FILE_NOT_FOUND
* error result rather than throwing.
*/
async postMultipart(path, filePath, fieldName, extra) {
let bytes;
try {
bytes = await readFile(filePath);
}
catch (err) {
return {
error: {
code: 'FILE_NOT_FOUND',
message: `Could not read file at ${filePath}: ${err instanceof Error ? err.message : 'unknown error'}`,
status: 0,
},
};
}
const form = new FormData();
form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: contentType(filePath) }), basename(filePath));
for (const [key, value] of Object.entries(extra ?? {})) {
form.append(key, value);
}
// Let fetch set the multipart Content-Type boundary; do not override it.
return this.send('POST', path, { rawBody: form });
}
/**
* Performs a raw request with NO Authorization header — used for presigned
* upload PUTs, where the signed URL is already the credential and an extra
* Bearer token would break the signature.
*/
raw(url, init) {
return this.fetchFn(url, { signal: AbortSignal.timeout(this.rawTimeoutMs), ...init });
}
}
/**
* Presigned-URL upload helper.
*
* A large binary asset is never streamed through the LabelGrid API. Instead the
* flow is three steps:
* 1. POST the upload-url endpoint (with the filename) to mint a short-lived
* presigned storage URL and its object key.
* 2. PUT the file bytes straight to that presigned URL. This request carries
* NO Authorization header — the signature in the URL is the credential, and
* an extra Bearer token would break it.
* 3. PUT the commit endpoint with the returned object key (with an idempotency
* key) so the API records the finalized file.
*
* A failure at step 2 aborts before the commit, so a half-uploaded object is
* never finalized. Business rules (format checks, transcoding) stay server-side.
*/
import type { ApiResult, LabelGridClient } from './http.js';
export type UploadOptions = {
/** The endpoint that mints the presigned URL, e.g. /tracks/42/files/stereo/upload-url. */
uploadUrlPath: string;
/** The endpoint that records the finalized file, e.g. /tracks/42/files/stereo. */
commitPath: string;
/** Absolute or relative local path to the file to upload. */
filePath: string;
};
export declare function uploadViaPresignedUrl(client: LabelGridClient, opts: UploadOptions): Promise<ApiResult<unknown>>;
/**
* Presigned-URL upload helper.
*
* A large binary asset is never streamed through the LabelGrid API. Instead the
* flow is three steps:
* 1. POST the upload-url endpoint (with the filename) to mint a short-lived
* presigned storage URL and its object key.
* 2. PUT the file bytes straight to that presigned URL. This request carries
* NO Authorization header — the signature in the URL is the credential, and
* an extra Bearer token would break it.
* 3. PUT the commit endpoint with the returned object key (with an idempotency
* key) so the API records the finalized file.
*
* A failure at step 2 aborts before the commit, so a half-uploaded object is
* never finalized. Business rules (format checks, transcoding) stay server-side.
*/
import { statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { log } from '../log.js';
import { contentType } from './content-types.js';
/** True only for an existing regular file. */
function isReadableFile(p) {
try {
return statSync(p).isFile();
}
catch {
return false;
}
}
export async function uploadViaPresignedUrl(client, opts) {
// Fail fast and locally: never touch the network for a file we cannot read.
if (!isReadableFile(opts.filePath)) {
const error = {
code: 'FILE_NOT_FOUND',
message: `No readable file at ${opts.filePath}.`,
status: 0,
};
return { error };
}
// Step 1: mint the presigned URL.
const minted = await client.post(opts.uploadUrlPath, {
filename: basename(opts.filePath),
});
if ('error' in minted)
return minted;
const uploadUrl = minted.data?.upload_url;
const key = minted.data?.key;
if (typeof uploadUrl !== 'string' || typeof key !== 'string') {
const error = {
code: 'UPLOAD_URL_INVALID',
message: 'The upload-url response did not contain a usable upload_url and key.',
status: 0,
};
return { error };
}
// Step 2: PUT the bytes directly to storage — NO auth header (the URL is signed).
// The file passed isReadableFile above, but it can vanish before this read
// (a TOCTOU race); a structured FILE_NOT_FOUND is the contract, not a throw.
let bytes;
try {
bytes = await readFile(opts.filePath);
}
catch {
const error = {
code: 'FILE_NOT_FOUND',
message: `The file at ${opts.filePath} could not be read.`,
status: 0,
};
return { error };
}
let putRes;
try {
putRes = await client.raw(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType(opts.filePath) },
body: new Uint8Array(bytes),
});
}
catch (err) {
// Never surface err.message raw to the log — it can embed the signed URL,
// and `reason` is not a redacted key. Strip any URL before logging.
log('error', 'presigned upload PUT failed', {
reason: err instanceof Error ? err.message.replace(/https?:\/\/\S+/gi, '[url]') : 'network error',
});
const error = {
code: 'UPLOAD_FAILED',
message: 'Uploading the file to storage failed.',
status: 0,
};
return { error };
}
if (!putRes.ok) {
// Abort BEFORE the commit — a half-uploaded object is never finalized.
const error = {
code: 'UPLOAD_FAILED',
message: `Uploading the file to storage failed with status ${putRes.status}.`,
status: putRes.status,
};
return { error };
}
// Step 3: commit the object key (idempotent — a retried commit will not duplicate).
return client.put(opts.commitPath, { s3_key: key }, { idempotency: true });
}
/**
* The catalog-entity registry: the six entity kinds the consolidated catalog
* tools operate on, each with its endpoint path and the reviewed documentation
* fragments (list filters, create/update fields, delete refusals) the tool
* descriptions are assembled from.
*
* This is data, not behavior — the catalog tools stay thin wrappers and the
* API owns all validation. The wording here carries the caveats from the
* per-entity tool descriptions it replaces (recording_country on track create,
* RELEASE_LOCKED_FIELDS on release update, the delete refusals).
*/
export type EntityName = 'label' | 'artist' | 'writer' | 'publisher' | 'release' | 'track';
/** The entity names as a tuple, for zod enum inputs. */
export declare const ENTITY_NAMES: readonly ["label", "artist", "writer", "publisher", "release", "track"];
export type EntitySpec = {
/** The collection endpoint path, e.g. '/labels'. */
path: string;
/** One-line doc of the useful list filters for search_catalog. */
filtersDoc: string;
/** One-line doc of required + common create/update fields. */
fieldsDoc: string;
/** One-line doc of the server-side delete refusals. */
deleteNote: string;
};
export declare const ENTITIES: Record<EntityName, EntitySpec>;
/**
* The catalog-entity registry: the six entity kinds the consolidated catalog
* tools operate on, each with its endpoint path and the reviewed documentation
* fragments (list filters, create/update fields, delete refusals) the tool
* descriptions are assembled from.
*
* This is data, not behavior — the catalog tools stay thin wrappers and the
* API owns all validation. The wording here carries the caveats from the
* per-entity tool descriptions it replaces (recording_country on track create,
* RELEASE_LOCKED_FIELDS on release update, the delete refusals).
*/
/** The entity names as a tuple, for zod enum inputs. */
export const ENTITY_NAMES = ['label', 'artist', 'writer', 'publisher', 'release', 'track'];
export const ENTITIES = {
label: {
path: '/labels',
filtersDoc: 'label: no documented filters — paginate with page/per_page.',
fieldsDoc: 'label — required: name, default_email; optional: support email, website/platform URLs, default copyright lines, isrc_base.',
deleteNote: 'label: refused while the label still has releases — remove or reassign its releases first.',
},
artist: {
path: '/artists',
filtersDoc: 'artist: artist_name (filter by artist name).',
fieldsDoc: 'artist — required: artist_name; optional: full_name, email, location, bios, isni, default_language, platform profile URLs.',
deleteNote: 'artist: refused while still referenced by releases or tracks.',
},
writer: {
path: '/writers',
filtersDoc: 'writer: name (writer name), ipi (IPI number).',
fieldsDoc: 'writer — required: first_name, last_name; optional: middle_name, display_credits, email, country, pro, ipi, isni, publisher_id (or publisher_name/publisher_pro/publisher_ipi).',
deleteNote: 'writer: refused while still referenced by tracks.',
},
publisher: {
path: '/publishers',
filtersDoc: 'publisher: name (publisher name), ipi (IPI number).',
fieldsDoc: 'publisher — required: name; optional: ipi, pro, isni, controlled_publisher.',
deleteNote: 'publisher: refused while still referenced by writers.',
},
release: {
path: '/releases',
filtersDoc: 'release: label_id (owning label id), is_live (1 = live/distributed only), barcode_number (UPC/EAN), cat (catalog number).',
fieldsDoc: 'release — required on create: content_type, label_id, artists, titles, cat (catalog number), artwork_ai_usage, primary_genre_id; many optional fields (dates, copyright lines, genres, per-outlet URLs). Once submitted or distributed some fields are locked — changing one returns a 403 with code RELEASE_LOCKED_FIELDS naming exactly which fields cannot change.',
deleteNote: 'release: only a never-submitted draft can be deleted.',
},
track: {
path: '/tracks',
filtersDoc: 'track: release_id (one release’s tracks), isrc (filter by ISRC).',
fieldsDoc: 'track — required on create: release_id, disc, track_num, composition_type, artists, audio_ai_usage, composition_ai_usage, commercial_samples, audio_language, contributors, and recording_country (ISO 3166-1 alpha-2, e.g. "US"); optional: titles, isrc, iswc, writers, publishers, splits, and more.',
deleteNote: 'track: allowed while the parent release is an editable draft; refused once submitted or distributed.',
},
};
/**
* stderr-only structured logging with secret redaction.
*
* stdout is reserved for the MCP protocol stream, so every log line goes to
* stderr. Any structured metadata is passed through {@link redactSecrets}
* first so tokens, passwords and signed URLs never reach the log.
*/
/**
* Deep-clones a value, replacing the value of any object key whose name looks
* like a secret with a fixed mask. Non-secret values, arrays and primitives are
* preserved (arrays and nested objects are walked recursively).
*/
export declare function redactSecrets(v: unknown): unknown;
export type LogLevel = 'info' | 'warn' | 'error';
/** Writes a single redacted log line to stderr (never stdout). */
export declare function log(level: LogLevel, msg: string, meta?: unknown): void;
/**
* stderr-only structured logging with secret redaction.
*
* stdout is reserved for the MCP protocol stream, so every log line goes to
* stderr. Any structured metadata is passed through {@link redactSecrets}
* first so tokens, passwords and signed URLs never reach the log.
*/
const SECRET_KEY = /token|password|secret|nonce|authorization|key/i;
const MASK = '***REDACTED***';
/**
* Deep-clones a value, replacing the value of any object key whose name looks
* like a secret with a fixed mask. Non-secret values, arrays and primitives are
* preserved (arrays and nested objects are walked recursively).
*/
export function redactSecrets(v) {
if (Array.isArray(v)) {
return v.map((item) => redactSecrets(item));
}
if (v !== null && typeof v === 'object') {
const out = {};
for (const [key, value] of Object.entries(v)) {
out[key] = SECRET_KEY.test(key) ? MASK : redactSecrets(value);
}
return out;
}
return v;
}
/** Writes a single redacted log line to stderr (never stdout). */
export function log(level, msg, meta) {
let line = `[${level}] ${msg}`;
if (meta !== undefined) {
line += ` ${JSON.stringify(redactSecrets(meta))}`;
}
process.stderr.write(`${line}\n`);
}