@labelgrid/mcp 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +61 -2
- package/dist/index.js +2 -0
- package/dist/tools/finance.js +235 -84
- package/package.json +18 -5
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ All notable changes to `@labelgrid/mcp` are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-07-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `LABELGRID_TIMEOUT_MS` and `LABELGRID_TRANSFER_TIMEOUT_MS` configure the JSON
|
|
13
|
+
request timeout and the upload/download transfer timeout. A non-positive-
|
|
14
|
+
integer value is ignored with a warning and the built-in default applies.
|
|
15
|
+
- `LABELGRID_DOWNLOAD_DIR` — the only directory `download_statement` may write a
|
|
16
|
+
`save_to_path` into (default: `~/Downloads` if present, else the working
|
|
17
|
+
directory). A path resolving outside it is refused with a structured error.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `download_statement` now streams both the invoice PDF and a saved CSV export
|
|
22
|
+
straight to disk instead of buffering the whole file in memory. An inline CSV
|
|
23
|
+
(no `save_to_path`) is read with a 10 MB byte ceiling enforced up front and
|
|
24
|
+
mid-stream; a larger export returns `RESPONSE_TOO_LARGE` and must be saved to
|
|
25
|
+
a path.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- `download_statement` now writes a `save_to_path` file via a temp sibling that
|
|
30
|
+
is atomically linked into place, so a failed download never leaves a partial
|
|
31
|
+
file, and reports `saved_to` as the realpath-resolved canonical path.
|
|
32
|
+
|
|
8
33
|
## [0.3.1] - 2026-07-20
|
|
9
34
|
|
|
10
35
|
### Changed
|
package/README.md
CHANGED
|
@@ -89,6 +89,9 @@ All configuration is via environment variables in your client config.
|
|
|
89
89
|
| `LABELGRID_FULL_WRITES_ACK` | — | Must equal the exact acknowledgment sentence to arm full writes. |
|
|
90
90
|
| `LABELGRID_READ_ONLY` | `false` | Force reads only; overrides both write flags. |
|
|
91
91
|
| `LABELGRID_TOOLSETS` | all except `webhooks` | Comma-separated subset of toolsets to expose. |
|
|
92
|
+
| `LABELGRID_TIMEOUT_MS` | `60000` | JSON request timeout in milliseconds. Must be a positive integer; a bad value is ignored with a warning. |
|
|
93
|
+
| `LABELGRID_TRANSFER_TIMEOUT_MS` | `600000` | Upload/download transfer timeout in milliseconds (for presigned uploads and statement downloads). Same validation. |
|
|
94
|
+
| `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. |
|
|
92
95
|
|
|
93
96
|
Valid toolsets (8): `account`, `reference`, `catalog`, `releases`, `insights`, `finance`, `webhooks`, `distribution`.
|
|
94
97
|
|
package/dist/config.d.ts
CHANGED
|
@@ -16,6 +16,16 @@ export type Config = {
|
|
|
16
16
|
writes: boolean;
|
|
17
17
|
fullWrites: boolean;
|
|
18
18
|
toolsets: Set<string> | null;
|
|
19
|
+
/** JSON request timeout override (ms); undefined uses the client default. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Raw transfer (upload/download) timeout override (ms); undefined = default. */
|
|
22
|
+
rawTimeoutMs?: number;
|
|
23
|
+
/**
|
|
24
|
+
* The only directory a file-writing tool (download_statement) may write into,
|
|
25
|
+
* resolved to a real path. From LABELGRID_DOWNLOAD_DIR, else ~/Downloads if it
|
|
26
|
+
* exists, else the process cwd.
|
|
27
|
+
*/
|
|
28
|
+
downloadDir?: string;
|
|
19
29
|
};
|
|
20
30
|
export declare const DEFAULT_BASE_URL = "https://api.labelgrid.com/api/public";
|
|
21
31
|
/** The exact sentence a user must set in LABELGRID_FULL_WRITES_ACK to arm full writes. */
|
package/dist/config.js
CHANGED
|
@@ -7,7 +7,50 @@
|
|
|
7
7
|
* full-write access is doubly opt-in (flag + an exact acknowledgment sentence).
|
|
8
8
|
* A read-only override wins over everything.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { log, parseTimeoutMs } from '@labelgrid/core';
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the download allow-list root: LABELGRID_DOWNLOAD_DIR if set, else the
|
|
16
|
+
* user's ~/Downloads when it exists, else the process cwd. Resolved to a real
|
|
17
|
+
* path so a symlinked root is compared canonically.
|
|
18
|
+
*/
|
|
19
|
+
function resolveDownloadDir(env) {
|
|
20
|
+
const explicit = env.LABELGRID_DOWNLOAD_DIR?.trim();
|
|
21
|
+
let candidate;
|
|
22
|
+
if (explicit !== undefined && explicit.length > 0) {
|
|
23
|
+
candidate = explicit;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const downloads = join(homedir(), 'Downloads');
|
|
27
|
+
let hasDownloads = false;
|
|
28
|
+
try {
|
|
29
|
+
hasDownloads = statSync(downloads).isDirectory();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
hasDownloads = false;
|
|
33
|
+
}
|
|
34
|
+
candidate = hasDownloads ? downloads : process.cwd();
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return realpathSync(candidate);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parses a timeout env var into a positive-integer ms, warning once (and
|
|
45
|
+
* falling back to the client default) when the value is not a positive integer.
|
|
46
|
+
*/
|
|
47
|
+
function timeoutFromEnv(raw, varName) {
|
|
48
|
+
const parsed = parseTimeoutMs(raw);
|
|
49
|
+
if (parsed.invalid) {
|
|
50
|
+
log('warn', `${varName} must be a positive integer of milliseconds; ignoring "${raw}".`);
|
|
51
|
+
}
|
|
52
|
+
return parsed.value;
|
|
53
|
+
}
|
|
11
54
|
export const DEFAULT_BASE_URL = 'https://api.labelgrid.com/api/public';
|
|
12
55
|
/** The exact sentence a user must set in LABELGRID_FULL_WRITES_ACK to arm full writes. */
|
|
13
56
|
export const FULL_WRITES_ACK = 'I accept responsibility for AI-driven distribution actions';
|
|
@@ -57,6 +100,9 @@ function isTruthy(value) {
|
|
|
57
100
|
}
|
|
58
101
|
export function loadConfig(env) {
|
|
59
102
|
const baseUrl = env.LABELGRID_API_URL?.trim() || DEFAULT_BASE_URL;
|
|
103
|
+
const timeoutMs = timeoutFromEnv(env.LABELGRID_TIMEOUT_MS, 'LABELGRID_TIMEOUT_MS');
|
|
104
|
+
const rawTimeoutMs = timeoutFromEnv(env.LABELGRID_TRANSFER_TIMEOUT_MS, 'LABELGRID_TRANSFER_TIMEOUT_MS');
|
|
105
|
+
const downloadDir = resolveDownloadDir(env);
|
|
60
106
|
const token = env.LABELGRID_API_TOKEN?.trim();
|
|
61
107
|
if (!token) {
|
|
62
108
|
// No token: start in setup mode instead of failing. The server registers
|
|
@@ -69,6 +115,9 @@ export function loadConfig(env) {
|
|
|
69
115
|
writes: false,
|
|
70
116
|
fullWrites: false,
|
|
71
117
|
toolsets: null,
|
|
118
|
+
timeoutMs,
|
|
119
|
+
rawTimeoutMs,
|
|
120
|
+
downloadDir,
|
|
72
121
|
};
|
|
73
122
|
}
|
|
74
123
|
const readOnly = isTruthy(env.LABELGRID_READ_ONLY);
|
|
@@ -110,5 +159,15 @@ export function loadConfig(env) {
|
|
|
110
159
|
toolsets.add(name);
|
|
111
160
|
}
|
|
112
161
|
}
|
|
113
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
baseUrl,
|
|
164
|
+
token,
|
|
165
|
+
setupMode: false,
|
|
166
|
+
writes,
|
|
167
|
+
fullWrites,
|
|
168
|
+
toolsets,
|
|
169
|
+
timeoutMs,
|
|
170
|
+
rawTimeoutMs,
|
|
171
|
+
downloadDir,
|
|
172
|
+
};
|
|
114
173
|
}
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,8 @@ async function main() {
|
|
|
32
32
|
baseUrl: config.baseUrl,
|
|
33
33
|
token: config.token ?? '',
|
|
34
34
|
version: VERSION,
|
|
35
|
+
timeoutMs: config.timeoutMs,
|
|
36
|
+
rawTimeoutMs: config.rawTimeoutMs,
|
|
35
37
|
});
|
|
36
38
|
if (config.setupMode) {
|
|
37
39
|
const server = buildServer(config, client, allTools());
|
package/dist/tools/finance.js
CHANGED
|
@@ -8,19 +8,92 @@
|
|
|
8
8
|
* shared client's JSON path would corrupt binary PDFs), with the same auth
|
|
9
9
|
* headers the client sends.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { copyFileSync, createWriteStream, constants as fsConstants, linkSync, openSync, realpathSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
14
|
+
import { Readable } from 'node:stream';
|
|
15
|
+
import { pipeline } from 'node:stream/promises';
|
|
13
16
|
import { z } from 'zod';
|
|
14
17
|
import { applyProjection } from '../projection.js';
|
|
15
|
-
import { VERSION } from '../version.js';
|
|
16
18
|
const INLINE_CSV_LIMIT = 100 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Hard ceiling on the CSV body read into memory when NO save_to_path is given.
|
|
21
|
+
* A larger export must be written to disk (save_to_path streams it); reading an
|
|
22
|
+
* unbounded body inline is exactly the memory blow-up this bound prevents.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_INLINE_DOWNLOAD_BYTES = 10 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Reads a text body with a byte ceiling enforced up front (Content-Length) AND
|
|
27
|
+
* mid-stream: it aborts the moment the running byte count crosses `max`, so an
|
|
28
|
+
* oversized body is never fully buffered. Returns the decoded text or a
|
|
29
|
+
* RESPONSE_TOO_LARGE error naming save_to_path as the way to handle a big export.
|
|
30
|
+
*/
|
|
31
|
+
async function readBoundedText(res, max) {
|
|
32
|
+
const tooLarge = {
|
|
33
|
+
code: 'RESPONSE_TOO_LARGE',
|
|
34
|
+
message: `The export exceeds the ${max}-byte inline limit. Pass save_to_path to stream it to a file instead.`,
|
|
35
|
+
status: res.status,
|
|
36
|
+
};
|
|
37
|
+
const declared = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
|
|
38
|
+
if (!Number.isNaN(declared) && declared > max) {
|
|
39
|
+
// Cancel the still-live body so the connection is released rather than held
|
|
40
|
+
// open (the mid-stream path below cancels via the reader).
|
|
41
|
+
await res.body?.cancel().catch(() => { });
|
|
42
|
+
return tooLarge;
|
|
43
|
+
}
|
|
44
|
+
if (!res.body) {
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
return Buffer.byteLength(text) > max ? tooLarge : text;
|
|
47
|
+
}
|
|
48
|
+
const reader = res.body.getReader();
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (;;) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done)
|
|
54
|
+
break;
|
|
55
|
+
if (value) {
|
|
56
|
+
total += value.byteLength;
|
|
57
|
+
if (total > max) {
|
|
58
|
+
try {
|
|
59
|
+
await reader.cancel();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// best-effort — the size bound is what matters
|
|
63
|
+
}
|
|
64
|
+
return tooLarge;
|
|
65
|
+
}
|
|
66
|
+
chunks.push(value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const merged = new Uint8Array(total);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
merged.set(chunk, offset);
|
|
73
|
+
offset += chunk.byteLength;
|
|
74
|
+
}
|
|
75
|
+
return new TextDecoder('utf-8').decode(merged);
|
|
76
|
+
}
|
|
77
|
+
/** True when `child` is `root` itself or nested beneath it (after realpath). */
|
|
78
|
+
function isWithin(root, child) {
|
|
79
|
+
const rel = relative(root, child);
|
|
80
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
81
|
+
}
|
|
17
82
|
/**
|
|
18
83
|
* Validates that save_to_path is absolute and its parent resolves (via
|
|
19
84
|
* realpathSync, so a dangling/symlinked parent is rejected) to an existing real
|
|
20
|
-
* directory
|
|
21
|
-
*
|
|
85
|
+
* directory, AND — when an `allowedRoot` is given — that the resolved parent is
|
|
86
|
+
* inside that allow-list root, so a tool can only write under a sanctioned
|
|
87
|
+
* directory even if an injected path points elsewhere. The parent is resolved
|
|
88
|
+
* to its real target BEFORE the prefix check (the file itself does not exist
|
|
89
|
+
* yet), so a symlinked parent cannot escape the root. On success it RETURNS the
|
|
90
|
+
* canonical write path — `join(realpath(parent), basename)` — so the caller
|
|
91
|
+
* writes to the resolved location, not the caller-supplied path whose parent
|
|
92
|
+
* symlink could be swapped between this check and the write (a TOCTOU escape).
|
|
93
|
+
* Writing itself is exclusive (see writeNewFile), so this never overwrites an
|
|
94
|
+
* existing file.
|
|
22
95
|
*/
|
|
23
|
-
function validateSavePath(p) {
|
|
96
|
+
function validateSavePath(p, allowedRoot) {
|
|
24
97
|
if (!isAbsolute(p)) {
|
|
25
98
|
return {
|
|
26
99
|
code: 'INVALID_PATH',
|
|
@@ -54,7 +127,25 @@ function validateSavePath(p) {
|
|
|
54
127
|
status: 0,
|
|
55
128
|
};
|
|
56
129
|
}
|
|
57
|
-
|
|
130
|
+
if (allowedRoot !== undefined && !isWithin(allowedRoot, realDir)) {
|
|
131
|
+
return {
|
|
132
|
+
code: 'DOWNLOAD_DIR_NOT_ALLOWED',
|
|
133
|
+
message: `save_to_path must be inside the allowed download directory (${allowedRoot}). Set LABELGRID_DOWNLOAD_DIR to change it.`,
|
|
134
|
+
status: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { canonicalPath: join(realDir, basename(p)) };
|
|
138
|
+
}
|
|
139
|
+
/** Filesystem errors that mean "hardlinks are not supported here". */
|
|
140
|
+
const HARDLINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV', 'ENOSYS']);
|
|
141
|
+
/** Best-effort removal of a temp file — a missing file is not an error. */
|
|
142
|
+
function unlinkSafe(p) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(p);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// already gone / never created — nothing to clean up
|
|
148
|
+
}
|
|
58
149
|
}
|
|
59
150
|
/**
|
|
60
151
|
* Writes a file with exclusive creation ('wx'): an existing path is NEVER
|
|
@@ -81,65 +172,120 @@ function writeNewFile(path, data) {
|
|
|
81
172
|
};
|
|
82
173
|
}
|
|
83
174
|
}
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
175
|
+
/**
|
|
176
|
+
* Streams a web response body to a NEW file, never overwriting an existing one
|
|
177
|
+
* and never leaving a partial file at the destination. The body is streamed to
|
|
178
|
+
* a temp sibling in the SAME directory (`<path>.partial-<pid>`, created 'wx'),
|
|
179
|
+
* then atomically hard-linked into place — the link is both atomic AND exclusive
|
|
180
|
+
* (EEXIST → FILE_EXISTS), so a transfer that fails mid-stream leaves NO file at
|
|
181
|
+
* `path` and NO temp sibling behind. On a filesystem without hardlinks
|
|
182
|
+
* (EPERM/ENOTSUP/EXDEV/…) it falls back to an exclusive copy (COPYFILE_EXCL).
|
|
183
|
+
* Never buffers the whole body in memory.
|
|
184
|
+
*/
|
|
185
|
+
async function streamNewFile(path, body) {
|
|
186
|
+
if (body === null) {
|
|
187
|
+
const err = writeNewFile(path, Buffer.alloc(0));
|
|
188
|
+
return err ?? { bytes: 0 };
|
|
189
|
+
}
|
|
190
|
+
const source = Readable.fromWeb(body);
|
|
191
|
+
const tmpResult = await streamToTempSibling(path, source);
|
|
192
|
+
if ('code' in tmpResult)
|
|
193
|
+
return tmpResult;
|
|
194
|
+
return finalizeNewFile(tmpResult.tmp, path);
|
|
95
195
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Streams `source` into a temp sibling of `finalPath`, created exclusively
|
|
198
|
+
* ('wx'). A collision with a stale temp (a dead process) is retried once with a
|
|
199
|
+
* random suffix. On a mid-stream failure the partial temp is removed. Returns
|
|
200
|
+
* the temp path, or a structured error.
|
|
201
|
+
*/
|
|
202
|
+
async function streamToTempSibling(finalPath, source) {
|
|
203
|
+
const candidates = [
|
|
204
|
+
`${finalPath}.partial-${process.pid}`,
|
|
205
|
+
`${finalPath}.partial-${process.pid}-${randomBytes(6).toString('hex')}`,
|
|
206
|
+
];
|
|
207
|
+
// Secure the temp fd BEFORE attaching the pipeline: pipeline() destroys its
|
|
208
|
+
// streams on failure, so an open-time EEXIST (stale temp) must be resolved
|
|
209
|
+
// without touching the source, or the retry would pipe a destroyed body.
|
|
210
|
+
let tmp;
|
|
211
|
+
let fd;
|
|
212
|
+
let lastErr;
|
|
213
|
+
for (const candidate of candidates) {
|
|
214
|
+
try {
|
|
215
|
+
fd = openSync(candidate, 'wx', 0o600);
|
|
216
|
+
tmp = candidate;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
lastErr = err;
|
|
221
|
+
if (err.code !== 'EEXIST')
|
|
222
|
+
return writeFailed(finalPath, err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (tmp === undefined || fd === undefined)
|
|
226
|
+
return writeFailed(finalPath, lastErr);
|
|
227
|
+
const ws = createWriteStream(tmp, { fd }); // autoClose closes the fd either way
|
|
100
228
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
headers: {
|
|
104
|
-
Authorization: `Bearer ${ctx.config.token}`,
|
|
105
|
-
Accept: 'application/json',
|
|
106
|
-
'User-Agent': `labelgrid-mcp/${VERSION}`,
|
|
107
|
-
},
|
|
108
|
-
});
|
|
229
|
+
await pipeline(source, ws);
|
|
230
|
+
return { tmp };
|
|
109
231
|
}
|
|
110
232
|
catch (err) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
error: {
|
|
114
|
-
code: 'NETWORK_ERROR',
|
|
115
|
-
message: err instanceof Error ? err.message : 'Network request failed.',
|
|
116
|
-
status: 0,
|
|
117
|
-
},
|
|
118
|
-
};
|
|
233
|
+
unlinkSafe(tmp); // we created it, then the transfer failed — drop the partial
|
|
234
|
+
return writeFailed(finalPath, err);
|
|
119
235
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Moves a finished temp file into `path` exclusively: a hard link (atomic +
|
|
239
|
+
* exclusive) with an exclusive-copy fallback where hardlinks are unavailable.
|
|
240
|
+
* The temp is always removed. Returns the byte count or a structured error.
|
|
241
|
+
*/
|
|
242
|
+
function finalizeNewFile(tmp, path) {
|
|
243
|
+
try {
|
|
244
|
+
linkSync(tmp, path);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
const code = err.code;
|
|
248
|
+
if (code === 'EEXIST') {
|
|
249
|
+
unlinkSafe(tmp);
|
|
250
|
+
return fileExists(path);
|
|
251
|
+
}
|
|
252
|
+
if (code !== undefined && HARDLINK_UNSUPPORTED.has(code)) {
|
|
253
|
+
// Non-atomic fallback for filesystems without hardlinks: a reader can see
|
|
254
|
+
// the destination mid-copy (accepted for these rare filesystems), but an
|
|
255
|
+
// interrupted copy must not LEAVE a partial destination — COPYFILE_EXCL
|
|
256
|
+
// proved it did not pre-exist, so removing it on failure is safe.
|
|
257
|
+
try {
|
|
258
|
+
copyFileSync(tmp, path, fsConstants.COPYFILE_EXCL);
|
|
259
|
+
}
|
|
260
|
+
catch (copyErr) {
|
|
261
|
+
unlinkSafe(tmp);
|
|
262
|
+
if (copyErr.code === 'EEXIST')
|
|
263
|
+
return fileExists(path);
|
|
264
|
+
unlinkSafe(path);
|
|
265
|
+
return writeFailed(path, copyErr);
|
|
135
266
|
}
|
|
136
267
|
}
|
|
137
|
-
|
|
138
|
-
|
|
268
|
+
else {
|
|
269
|
+
unlinkSafe(tmp);
|
|
270
|
+
return writeFailed(path, err);
|
|
139
271
|
}
|
|
140
|
-
return { ok: false, error: { code: statusToCode(res.status), message, status: res.status } };
|
|
141
272
|
}
|
|
142
|
-
|
|
273
|
+
unlinkSafe(tmp);
|
|
274
|
+
return { bytes: statSync(path).size };
|
|
275
|
+
}
|
|
276
|
+
function fileExists(path) {
|
|
277
|
+
return {
|
|
278
|
+
code: 'FILE_EXISTS',
|
|
279
|
+
message: `A file already exists at ${path}. This tool never overwrites — choose a new path.`,
|
|
280
|
+
status: 0,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function writeFailed(path, err) {
|
|
284
|
+
return {
|
|
285
|
+
code: 'WRITE_FAILED',
|
|
286
|
+
message: `Could not write to ${path}: ${err instanceof Error ? err.message : 'unknown error'}.`,
|
|
287
|
+
status: 0,
|
|
288
|
+
};
|
|
143
289
|
}
|
|
144
290
|
const queryFinancials = {
|
|
145
291
|
name: 'query_financials',
|
|
@@ -240,7 +386,7 @@ const downloadStatement = {
|
|
|
240
386
|
.describe('Absolute path (existing parent dir) to write the file to. Optional for csv (otherwise returned inline); required for invoice_pdf.'),
|
|
241
387
|
},
|
|
242
388
|
annotations: { readOnlyHint: true },
|
|
243
|
-
handler: async (args,
|
|
389
|
+
handler: async (args, { client, config }) => {
|
|
244
390
|
const invoice = args.invoice_number;
|
|
245
391
|
const savePath = args.save_to_path;
|
|
246
392
|
if (args.format === 'invoice_pdf') {
|
|
@@ -262,47 +408,52 @@ const downloadStatement = {
|
|
|
262
408
|
},
|
|
263
409
|
};
|
|
264
410
|
}
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
return { error:
|
|
268
|
-
const
|
|
411
|
+
const validated = validateSavePath(savePath, config.downloadDir);
|
|
412
|
+
if ('code' in validated)
|
|
413
|
+
return { error: validated };
|
|
414
|
+
const canonicalPath = validated.canonicalPath;
|
|
415
|
+
const result = await client.getRaw(`/statements/${encodeURIComponent(invoice)}/invoice`);
|
|
269
416
|
if (!result.ok)
|
|
270
417
|
return { error: result.error };
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return { data: { saved_to: savePath, bytes: bytes.length } };
|
|
418
|
+
const written = await streamNewFile(canonicalPath, result.res.body);
|
|
419
|
+
if ('code' in written)
|
|
420
|
+
return { error: written };
|
|
421
|
+
return { data: { saved_to: canonicalPath, bytes: written.bytes } };
|
|
276
422
|
}
|
|
277
423
|
// format === 'csv'
|
|
424
|
+
let canonicalPath;
|
|
278
425
|
if (savePath !== undefined) {
|
|
279
|
-
const
|
|
280
|
-
if (
|
|
281
|
-
return { error:
|
|
426
|
+
const validated = validateSavePath(savePath, config.downloadDir);
|
|
427
|
+
if ('code' in validated)
|
|
428
|
+
return { error: validated };
|
|
429
|
+
canonicalPath = validated.canonicalPath;
|
|
282
430
|
}
|
|
283
431
|
let path;
|
|
432
|
+
let query;
|
|
284
433
|
if (invoice !== undefined && invoice !== '') {
|
|
285
434
|
path = `/statements/${encodeURIComponent(invoice)}/csv`;
|
|
286
435
|
}
|
|
287
436
|
else {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
parts.push(`end_date=${encodeURIComponent(String(args.end_date))}`);
|
|
293
|
-
path = `/statements/export/csv${parts.length > 0 ? `?${parts.join('&')}` : ''}`;
|
|
437
|
+
// Let the core client serialize the range (its buildQuery), not a
|
|
438
|
+
// hand-rolled query string.
|
|
439
|
+
path = '/statements/export/csv';
|
|
440
|
+
query = { start_date: args.start_date, end_date: args.end_date };
|
|
294
441
|
}
|
|
295
|
-
const result = await
|
|
442
|
+
const result = await client.getRaw(path, query);
|
|
296
443
|
if (!result.ok)
|
|
297
444
|
return { error: result.error };
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return { data: { saved_to: savePath, bytes: totalBytes } };
|
|
445
|
+
if (canonicalPath !== undefined) {
|
|
446
|
+
// Stream the export straight to disk — never buffer the whole CSV.
|
|
447
|
+
const written = await streamNewFile(canonicalPath, result.res.body);
|
|
448
|
+
if ('code' in written)
|
|
449
|
+
return { error: written };
|
|
450
|
+
return { data: { saved_to: canonicalPath, bytes: written.bytes } };
|
|
305
451
|
}
|
|
452
|
+
// Inline: read with the byte ceiling enforced (Content-Length + mid-stream).
|
|
453
|
+
const text = await readBoundedText(result.res, MAX_INLINE_DOWNLOAD_BYTES);
|
|
454
|
+
if (typeof text !== 'string')
|
|
455
|
+
return { error: text };
|
|
456
|
+
const totalBytes = Buffer.byteLength(text);
|
|
306
457
|
const truncated = text.length > INLINE_CSV_LIMIT;
|
|
307
458
|
const content = truncated ? text.slice(0, INLINE_CSV_LIMIT) : text;
|
|
308
459
|
return { data: { content, truncated, bytes: totalBytes } };
|
package/package.json
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@labelgrid/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.labelgrid/labelgrid-mcp",
|
|
5
|
-
"description": "Official LabelGrid MCP server
|
|
5
|
+
"description": "Official LabelGrid MCP server \u2014 connect your AI client to your LabelGrid account",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"keywords": [
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"labelgrid",
|
|
11
|
+
"music-distribution",
|
|
12
|
+
"ai",
|
|
13
|
+
"claude"
|
|
14
|
+
],
|
|
8
15
|
"main": "dist/index.js",
|
|
9
16
|
"bin": {
|
|
10
17
|
"labelgrid-mcp": "dist/index.js"
|
|
11
18
|
},
|
|
12
|
-
"files": [
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"server.json"
|
|
25
|
+
],
|
|
13
26
|
"scripts": {
|
|
14
27
|
"build": "tsc",
|
|
15
28
|
"start": "node dist/index.js",
|
|
@@ -27,7 +40,7 @@
|
|
|
27
40
|
"node": ">=20"
|
|
28
41
|
},
|
|
29
42
|
"dependencies": {
|
|
30
|
-
"@labelgrid/core": "0.
|
|
43
|
+
"@labelgrid/core": "0.2.0",
|
|
31
44
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
45
|
"zod": "^3.24.0"
|
|
33
46
|
}
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.labelgrid/labelgrid-mcp",
|
|
4
4
|
"description": "Official LabelGrid MCP server — manage your music catalog, releases, analytics and distribution.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"websiteUrl": "https://labelgrid.com",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/labelgrid/labelgrid-mcp",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@labelgrid/mcp",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.4.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|