🎩 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.1
to
0.4.0
+25
-0
CHANGELOG.md

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

## [0.4.0] - 2026-07-23
### Added
- `LABELGRID_TIMEOUT_MS` and `LABELGRID_TRANSFER_TIMEOUT_MS` configure the JSON
request timeout and the upload/download transfer timeout. A non-positive-
integer value is ignored with a warning and the built-in default applies.
- `LABELGRID_DOWNLOAD_DIR` — the only directory `download_statement` may write a
`save_to_path` into (default: `~/Downloads` if present, else the working
directory). A path resolving outside it is refused with a structured error.
### Changed
- `download_statement` now streams both the invoice PDF and a saved CSV export
straight to disk instead of buffering the whole file in memory. An inline CSV
(no `save_to_path`) is read with a 10 MB byte ceiling enforced up front and
mid-stream; a larger export returns `RESPONSE_TOO_LARGE` and must be saved to
a path.
### Fixed
- `download_statement` now writes a `save_to_path` file via a temp sibling that
is atomically linked into place, so a failed download never leaves a partial
file, and reports `saved_to` as the realpath-resolved canonical path.
## [0.3.1] - 2026-07-20

@@ -10,0 +35,0 @@

@@ -19,2 +19,12 @@ /**

toolsets: Set<string> | null;
/** JSON request timeout override (ms); undefined uses the client default. */
timeoutMs?: number;
/** Raw transfer (upload/download) timeout override (ms); undefined = default. */
rawTimeoutMs?: number;
/**
* The only directory a file-writing tool (download_statement) may write into,
* resolved to a real path. From LABELGRID_DOWNLOAD_DIR, else ~/Downloads if it
* exists, else the process cwd.
*/
downloadDir?: string;
};

@@ -21,0 +31,0 @@ export declare const DEFAULT_BASE_URL = "https://api.labelgrid.com/api/public";

+61
-2

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

*/
import { log } from '@labelgrid/core';
import { realpathSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { log, parseTimeoutMs } from '@labelgrid/core';
/**
* Resolves the download allow-list root: LABELGRID_DOWNLOAD_DIR if set, else the
* user's ~/Downloads when it exists, else the process cwd. Resolved to a real
* path so a symlinked root is compared canonically.
*/
function resolveDownloadDir(env) {
const explicit = env.LABELGRID_DOWNLOAD_DIR?.trim();
let candidate;
if (explicit !== undefined && explicit.length > 0) {
candidate = explicit;
}
else {
const downloads = join(homedir(), 'Downloads');
let hasDownloads = false;
try {
hasDownloads = statSync(downloads).isDirectory();
}
catch {
hasDownloads = false;
}
candidate = hasDownloads ? downloads : process.cwd();
}
try {
return realpathSync(candidate);
}
catch {
return candidate;
}
}
/**
* Parses a timeout env var into a positive-integer ms, warning once (and
* falling back to the client default) when the value is not a positive integer.
*/
function timeoutFromEnv(raw, varName) {
const parsed = parseTimeoutMs(raw);
if (parsed.invalid) {
log('warn', `${varName} must be a positive integer of milliseconds; ignoring "${raw}".`);
}
return parsed.value;
}
export const DEFAULT_BASE_URL = 'https://api.labelgrid.com/api/public';

@@ -61,2 +104,5 @@ /** The exact sentence a user must set in LABELGRID_FULL_WRITES_ACK to arm full writes. */

const baseUrl = env.LABELGRID_API_URL?.trim() || DEFAULT_BASE_URL;
const timeoutMs = timeoutFromEnv(env.LABELGRID_TIMEOUT_MS, 'LABELGRID_TIMEOUT_MS');
const rawTimeoutMs = timeoutFromEnv(env.LABELGRID_TRANSFER_TIMEOUT_MS, 'LABELGRID_TRANSFER_TIMEOUT_MS');
const downloadDir = resolveDownloadDir(env);
const token = env.LABELGRID_API_TOKEN?.trim();

@@ -74,2 +120,5 @@ if (!token) {

toolsets: null,
timeoutMs,
rawTimeoutMs,
downloadDir,
};

@@ -116,3 +165,13 @@ }

}
return { baseUrl, token, setupMode: false, writes, fullWrites, toolsets };
return {
baseUrl,
token,
setupMode: false,
writes,
fullWrites,
toolsets,
timeoutMs,
rawTimeoutMs,
downloadDir,
};
}

@@ -35,2 +35,4 @@ #!/usr/bin/env node

version: VERSION,
timeoutMs: config.timeoutMs,
rawTimeoutMs: config.rawTimeoutMs,
});

@@ -37,0 +39,0 @@ if (config.setupMode) {

+235
-84

@@ -11,15 +11,88 @@ /**

*/
import { realpathSync, statSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute } from 'node:path';
import { randomBytes } from 'node:crypto';
import { copyFileSync, createWriteStream, constants as fsConstants, linkSync, openSync, realpathSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { z } from 'zod';
import { applyProjection } from '../projection.js';
import { VERSION } from '../version.js';
const INLINE_CSV_LIMIT = 100 * 1024;
/**
* Hard ceiling on the CSV body read into memory when NO save_to_path is given.
* A larger export must be written to disk (save_to_path streams it); reading an
* unbounded body inline is exactly the memory blow-up this bound prevents.
*/
const MAX_INLINE_DOWNLOAD_BYTES = 10 * 1024 * 1024;
/**
* Reads a text body with a byte ceiling enforced up front (Content-Length) AND
* mid-stream: it aborts the moment the running byte count crosses `max`, so an
* oversized body is never fully buffered. Returns the decoded text or a
* RESPONSE_TOO_LARGE error naming save_to_path as the way to handle a big export.
*/
async function readBoundedText(res, max) {
const tooLarge = {
code: 'RESPONSE_TOO_LARGE',
message: `The export exceeds the ${max}-byte inline limit. Pass save_to_path to stream it to a file instead.`,
status: res.status,
};
const declared = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
if (!Number.isNaN(declared) && declared > max) {
// Cancel the still-live body so the connection is released rather than held
// open (the mid-stream path below cancels via the reader).
await res.body?.cancel().catch(() => { });
return tooLarge;
}
if (!res.body) {
const text = await res.text();
return Buffer.byteLength(text) > max ? tooLarge : text;
}
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) {
try {
await reader.cancel();
}
catch {
// best-effort — the size bound is what matters
}
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);
}
/** True when `child` is `root` itself or nested beneath it (after realpath). */
function isWithin(root, child) {
const rel = relative(root, child);
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
/**
* Validates that save_to_path is absolute and its parent resolves (via
* realpathSync, so a dangling/symlinked parent is rejected) to an existing real
* directory. Writing itself is exclusive (see writeNewFile), so this never
* overwrites an existing file.
* directory, AND — when an `allowedRoot` is given — that the resolved parent is
* inside that allow-list root, so a tool can only write under a sanctioned
* directory even if an injected path points elsewhere. The parent is resolved
* to its real target BEFORE the prefix check (the file itself does not exist
* yet), so a symlinked parent cannot escape the root. On success it RETURNS the
* canonical write path — `join(realpath(parent), basename)` — so the caller
* writes to the resolved location, not the caller-supplied path whose parent
* symlink could be swapped between this check and the write (a TOCTOU escape).
* Writing itself is exclusive (see writeNewFile), so this never overwrites an
* existing file.
*/
function validateSavePath(p) {
function validateSavePath(p, allowedRoot) {
if (!isAbsolute(p)) {

@@ -58,4 +131,22 @@ return {

}
return null;
if (allowedRoot !== undefined && !isWithin(allowedRoot, realDir)) {
return {
code: 'DOWNLOAD_DIR_NOT_ALLOWED',
message: `save_to_path must be inside the allowed download directory (${allowedRoot}). Set LABELGRID_DOWNLOAD_DIR to change it.`,
status: 0,
};
}
return { canonicalPath: join(realDir, basename(p)) };
}
/** Filesystem errors that mean "hardlinks are not supported here". */
const HARDLINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV', 'ENOSYS']);
/** Best-effort removal of a temp file — a missing file is not an error. */
function unlinkSafe(p) {
try {
unlinkSync(p);
}
catch {
// already gone / never created — nothing to clean up
}
}
/**

@@ -86,62 +177,117 @@ * Writes a file with exclusive creation ('wx'): an existing path is NEVER

}
/** Maps an error HTTP status from a raw download into a structured code. */
function statusToCode(status) {
if (status === 401)
return 'TOKEN_INVALID';
if (status === 403)
return 'FORBIDDEN';
if (status === 404)
return 'NOT_FOUND';
if (status >= 500)
return 'SERVER_ERROR';
return 'ERROR';
/**
* Streams a web response body to a NEW file, never overwriting an existing one
* and never leaving a partial file at the destination. The body is streamed to
* a temp sibling in the SAME directory (`<path>.partial-<pid>`, created 'wx'),
* then atomically hard-linked into place — the link is both atomic AND exclusive
* (EEXIST → FILE_EXISTS), so a transfer that fails mid-stream leaves NO file at
* `path` and NO temp sibling behind. On a filesystem without hardlinks
* (EPERM/ENOTSUP/EXDEV/…) it falls back to an exclusive copy (COPYFILE_EXCL).
* Never buffers the whole body in memory.
*/
async function streamNewFile(path, body) {
if (body === null) {
const err = writeNewFile(path, Buffer.alloc(0));
return err ?? { bytes: 0 };
}
const source = Readable.fromWeb(body);
const tmpResult = await streamToTempSibling(path, source);
if ('code' in tmpResult)
return tmpResult;
return finalizeNewFile(tmpResult.tmp, path);
}
/** Authenticated raw GET for file downloads; returns the Response or an error. */
async function authedGet(ctx, path) {
const base = ctx.config.baseUrl.replace(/\/+$/, '');
let res;
/**
* Streams `source` into a temp sibling of `finalPath`, created exclusively
* ('wx'). A collision with a stale temp (a dead process) is retried once with a
* random suffix. On a mid-stream failure the partial temp is removed. Returns
* the temp path, or a structured error.
*/
async function streamToTempSibling(finalPath, source) {
const candidates = [
`${finalPath}.partial-${process.pid}`,
`${finalPath}.partial-${process.pid}-${randomBytes(6).toString('hex')}`,
];
// Secure the temp fd BEFORE attaching the pipeline: pipeline() destroys its
// streams on failure, so an open-time EEXIST (stale temp) must be resolved
// without touching the source, or the retry would pipe a destroyed body.
let tmp;
let fd;
let lastErr;
for (const candidate of candidates) {
try {
fd = openSync(candidate, 'wx', 0o600);
tmp = candidate;
break;
}
catch (err) {
lastErr = err;
if (err.code !== 'EEXIST')
return writeFailed(finalPath, err);
}
}
if (tmp === undefined || fd === undefined)
return writeFailed(finalPath, lastErr);
const ws = createWriteStream(tmp, { fd }); // autoClose closes the fd either way
try {
res = await ctx.client.raw(`${base}${path}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${ctx.config.token}`,
Accept: 'application/json',
'User-Agent': `labelgrid-mcp/${VERSION}`,
},
});
await pipeline(source, ws);
return { tmp };
}
catch (err) {
return {
ok: false,
error: {
code: 'NETWORK_ERROR',
message: err instanceof Error ? err.message : 'Network request failed.',
status: 0,
},
};
unlinkSafe(tmp); // we created it, then the transfer failed — drop the partial
return writeFailed(finalPath, err);
}
if (!res.ok) {
let message = `Request failed with status ${res.status}.`;
try {
const text = await res.text();
if (text) {
try {
const body = JSON.parse(text);
if (typeof body.message === 'string')
message = body.message;
else if (typeof body.error === 'string')
message = body.error;
}
catch {
message = text;
}
}
/**
* Moves a finished temp file into `path` exclusively: a hard link (atomic +
* exclusive) with an exclusive-copy fallback where hardlinks are unavailable.
* The temp is always removed. Returns the byte count or a structured error.
*/
function finalizeNewFile(tmp, path) {
try {
linkSync(tmp, path);
}
catch (err) {
const code = err.code;
if (code === 'EEXIST') {
unlinkSafe(tmp);
return fileExists(path);
}
if (code !== undefined && HARDLINK_UNSUPPORTED.has(code)) {
// Non-atomic fallback for filesystems without hardlinks: a reader can see
// the destination mid-copy (accepted for these rare filesystems), but an
// interrupted copy must not LEAVE a partial destination — COPYFILE_EXCL
// proved it did not pre-exist, so removing it on failure is safe.
try {
copyFileSync(tmp, path, fsConstants.COPYFILE_EXCL);
}
catch (copyErr) {
unlinkSafe(tmp);
if (copyErr.code === 'EEXIST')
return fileExists(path);
unlinkSafe(path);
return writeFailed(path, copyErr);
}
}
catch {
// keep the default message
else {
unlinkSafe(tmp);
return writeFailed(path, err);
}
return { ok: false, error: { code: statusToCode(res.status), message, status: res.status } };
}
return { ok: true, res };
unlinkSafe(tmp);
return { bytes: statSync(path).size };
}
function fileExists(path) {
return {
code: 'FILE_EXISTS',
message: `A file already exists at ${path}. This tool never overwrites — choose a new path.`,
status: 0,
};
}
function writeFailed(path, err) {
return {
code: 'WRITE_FAILED',
message: `Could not write to ${path}: ${err instanceof Error ? err.message : 'unknown error'}.`,
status: 0,
};
}
const queryFinancials = {

@@ -246,3 +392,3 @@ name: 'query_financials',

annotations: { readOnlyHint: true },
handler: async (args, ctx) => {
handler: async (args, { client, config }) => {
const invoice = args.invoice_number;

@@ -269,21 +415,24 @@ const savePath = args.save_to_path;

}
const err = validateSavePath(savePath);
if (err)
return { error: err };
const result = await authedGet(ctx, `/statements/${encodeURIComponent(invoice)}/invoice`);
const validated = validateSavePath(savePath, config.downloadDir);
if ('code' in validated)
return { error: validated };
const canonicalPath = validated.canonicalPath;
const result = await client.getRaw(`/statements/${encodeURIComponent(invoice)}/invoice`);
if (!result.ok)
return { error: result.error };
const bytes = Buffer.from(await result.res.arrayBuffer());
const writeErr = writeNewFile(savePath, bytes);
if (writeErr)
return { error: writeErr };
return { data: { saved_to: savePath, bytes: bytes.length } };
const written = await streamNewFile(canonicalPath, result.res.body);
if ('code' in written)
return { error: written };
return { data: { saved_to: canonicalPath, bytes: written.bytes } };
}
// format === 'csv'
let canonicalPath;
if (savePath !== undefined) {
const err = validateSavePath(savePath);
if (err)
return { error: err };
const validated = validateSavePath(savePath, config.downloadDir);
if ('code' in validated)
return { error: validated };
canonicalPath = validated.canonicalPath;
}
let path;
let query;
if (invoice !== undefined && invoice !== '') {

@@ -293,20 +442,22 @@ path = `/statements/${encodeURIComponent(invoice)}/csv`;

else {
const parts = [];
if (args.start_date !== undefined)
parts.push(`start_date=${encodeURIComponent(String(args.start_date))}`);
if (args.end_date !== undefined)
parts.push(`end_date=${encodeURIComponent(String(args.end_date))}`);
path = `/statements/export/csv${parts.length > 0 ? `?${parts.join('&')}` : ''}`;
// Let the core client serialize the range (its buildQuery), not a
// hand-rolled query string.
path = '/statements/export/csv';
query = { start_date: args.start_date, end_date: args.end_date };
}
const result = await authedGet(ctx, path);
const result = await client.getRaw(path, query);
if (!result.ok)
return { error: result.error };
const text = await result.res.text();
if (canonicalPath !== undefined) {
// Stream the export straight to disk — never buffer the whole CSV.
const written = await streamNewFile(canonicalPath, result.res.body);
if ('code' in written)
return { error: written };
return { data: { saved_to: canonicalPath, bytes: written.bytes } };
}
// Inline: read with the byte ceiling enforced (Content-Length + mid-stream).
const text = await readBoundedText(result.res, MAX_INLINE_DOWNLOAD_BYTES);
if (typeof text !== 'string')
return { error: text };
const totalBytes = Buffer.byteLength(text);
if (savePath !== undefined) {
const writeErr = writeNewFile(savePath, text);
if (writeErr)
return { error: writeErr };
return { data: { saved_to: savePath, bytes: totalBytes } };
}
const truncated = text.length > INLINE_CSV_LIMIT;

@@ -313,0 +464,0 @@ const content = truncated ? text.slice(0, INLINE_CSV_LIMIT) : text;

{
"name": "@labelgrid/mcp",
"version": "0.3.1",
"version": "0.4.0",
"mcpName": "io.github.labelgrid/labelgrid-mcp",
"description": "Official LabelGrid MCP server — connect your AI client to your LabelGrid account",
"description": "Official LabelGrid MCP server \u2014 connect your AI client to your LabelGrid account",
"type": "module",
"keywords": ["mcp", "model-context-protocol", "labelgrid", "music-distribution", "ai", "claude"],
"keywords": [
"mcp",
"model-context-protocol",
"labelgrid",
"music-distribution",
"ai",
"claude"
],
"main": "dist/index.js",

@@ -12,3 +19,9 @@ "bin": {

},
"files": ["dist", "README.md", "CHANGELOG.md", "LICENSE", "server.json"],
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE",
"server.json"
],
"scripts": {

@@ -31,3 +44,3 @@ "build": "tsc",

"dependencies": {
"@labelgrid/core": "0.1.0",
"@labelgrid/core": "0.2.0",
"@modelcontextprotocol/sdk": "^1.12.0",

@@ -34,0 +47,0 @@ "zod": "^3.24.0"

@@ -92,2 +92,5 @@ # LabelGrid MCP Server

| `LABELGRID_TOOLSETS` | all except `webhooks` | Comma-separated subset of toolsets to expose. |
| `LABELGRID_TIMEOUT_MS` | `60000` | JSON request timeout in milliseconds. Must be a positive integer; a bad value is ignored with a warning. |
| `LABELGRID_TRANSFER_TIMEOUT_MS` | `600000` | Upload/download transfer timeout in milliseconds (for presigned uploads and statement downloads). Same validation. |
| `LABELGRID_DOWNLOAD_DIR` | `~/Downloads` if it exists, else the working directory | The only directory `download_statement` may write a `save_to_path` into; a path outside it is refused. |

@@ -94,0 +97,0 @@ Valid toolsets (8): `account`, `reference`, `catalog`, `releases`, `insights`, `finance`, `webhooks`, `distribution`.

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

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

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

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

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