@labelgrid/mcp
Advanced tools
+25
-0
@@ -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 @@ |
+10
-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, | ||
| }; | ||
| } |
+2
-0
@@ -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; |
+18
-5
| { | ||
| "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" |
+3
-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`. |
+2
-2
@@ -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" |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
172887
6.7%2802
8.6%347
0.87%6
20%+ Added
- Removed
Updated