@labelgrid/mcp 0.3.1 → 0.5.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 +51 -0
- package/README.md +27 -23
- package/dist/config.d.ts +10 -0
- package/dist/config.js +61 -2
- package/dist/coverage.js +2 -0
- package/dist/index.js +2 -0
- package/dist/tools/catalog.js +16 -20
- package/dist/tools/distribution.js +15 -15
- package/dist/tools/finance.js +235 -84
- package/dist/tools/insights.d.ts +3 -3
- package/dist/tools/insights.js +74 -18
- package/dist/tools/releases.js +9 -11
- package/package.json +2 -2
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,57 @@ 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.5.0] - 2026-07-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `get_analytics_availability` — one static call returning the section-by-platform
|
|
13
|
+
`availability` matrix and the per-platform `platform_cadence` map (`daily` or
|
|
14
|
+
`weekly`). Fetch it once before `get_analytics` so an unreported section is
|
|
15
|
+
treated as unavailable rather than as an empty chart.
|
|
16
|
+
- 22 new analytics section keys (37 total), including library adds, shazams
|
|
17
|
+
(plus by-city and by-state), playlist adds, detailed source split, discovery
|
|
18
|
+
and repeat rates, listener plan mix, listeners by region, Apple streams by
|
|
19
|
+
city, Apple discovery cohorts, average listen time, and hour-of-day.
|
|
20
|
+
- 7 new `platform` filter values (10 total): `DEEZER`, `BOOMPLAY`, `AWA`,
|
|
21
|
+
`AUDIOMACK`, `KUGOU`, `KUWO`, `QQMUSIC`. The three Tencent platforms report
|
|
22
|
+
weekly — one point per week carrying the whole week.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Breaking:** `get_analytics` now requires `metrics` (1–12 section keys per
|
|
27
|
+
request), matching the API contract. Split a larger selection across
|
|
28
|
+
requests — responses are cached per scope and window.
|
|
29
|
+
- The reporting window cap rose from 30 to 400 days. Windows over 90 days draw
|
|
30
|
+
a separate, lower rate budget; a 429 response carries `retry_after_seconds`.
|
|
31
|
+
- Tool descriptions across the catalog were tightened. No tool names,
|
|
32
|
+
parameters, or behavior changed beyond the items above.
|
|
33
|
+
|
|
34
|
+
## [0.4.0] - 2026-07-23
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `LABELGRID_TIMEOUT_MS` and `LABELGRID_TRANSFER_TIMEOUT_MS` configure the JSON
|
|
39
|
+
request timeout and the upload/download transfer timeout. A non-positive-
|
|
40
|
+
integer value is ignored with a warning and the built-in default applies.
|
|
41
|
+
- `LABELGRID_DOWNLOAD_DIR` — the only directory `download_statement` may write a
|
|
42
|
+
`save_to_path` into (default: `~/Downloads` if present, else the working
|
|
43
|
+
directory). A path resolving outside it is refused with a structured error.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `download_statement` now streams both the invoice PDF and a saved CSV export
|
|
48
|
+
straight to disk instead of buffering the whole file in memory. An inline CSV
|
|
49
|
+
(no `save_to_path`) is read with a 10 MB byte ceiling enforced up front and
|
|
50
|
+
mid-stream; a larger export returns `RESPONSE_TOO_LARGE` and must be saved to
|
|
51
|
+
a path.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- `download_statement` now writes a `save_to_path` file via a temp sibling that
|
|
56
|
+
is atomically linked into place, so a failed download never leaves a partial
|
|
57
|
+
file, and reports `saved_to` as the realpath-resolved canonical path.
|
|
58
|
+
|
|
8
59
|
## [0.3.1] - 2026-07-20
|
|
9
60
|
|
|
10
61
|
### 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
|
|
|
@@ -101,7 +104,7 @@ The nine reference datasets are also exposed as MCP **resources** at `labelgrid:
|
|
|
101
104
|
|
|
102
105
|
<!-- TOOLS:BEGIN -->
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
_31 tools across 8 toolsets. This table is generated from the
|
|
105
108
|
tool definitions by `npm run gen-docs` — do not edit it by hand._
|
|
106
109
|
|
|
107
110
|
### Account `account`
|
|
@@ -121,32 +124,33 @@ tool definitions by `npm run gen-docs` — do not edit it by hand._
|
|
|
121
124
|
|
|
122
125
|
| Tool | Gate | Description |
|
|
123
126
|
| --- | --- | --- |
|
|
124
|
-
| `search_catalog` | read | List catalog entities of one kind, paginated.
|
|
125
|
-
| `get_catalog_item` | read | Retrieve one catalog entity by id, with
|
|
126
|
-
| `create_catalog_item` | write | Create a catalog entity
|
|
127
|
-
| `update_catalog_item` | write | Update a catalog entity
|
|
128
|
-
| `delete_catalog_item` | write | Delete a catalog entity
|
|
129
|
-
| `upload_image` | write | Upload a label image
|
|
130
|
-
| `get_asset` | read | Read a track or release asset. Valid
|
|
127
|
+
| `search_catalog` | read | List catalog entities of one kind, paginated. `filters` takes the endpoint’s own filter names, passed through verbatim — label: no documented filters — paginate with page/per_page. artist: artist_name (filter by artist name). writer: name (writer name), ipi (IPI number). publisher: name (publisher name), ipi (IPI number). release: label_id (owning label id), is_live (1 = live/distributed only), barcode_number (UPC/EAN), cat (catalog number). track: release_id (one release’s tracks), isrc (filter by ISRC). Use get_catalog_item for full detail. |
|
|
128
|
+
| `get_catalog_item` | read | Retrieve one catalog entity by id, with full detail (e.g. a release’s metadata and track listing, a track’s contributors and royalty splits, a writer’s PRO/IPI). |
|
|
129
|
+
| `create_catalog_item` | write | Create a catalog entity: pass its attributes in `fields` — the API owns all validation. Required and common fields per entity: label — required: name, default_email; optional: support email, website/platform URLs, default copyright lines, isrc_base. artist — required: artist_name; optional: full_name, email, location, bios, isni, default_language, platform profile URLs. 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). publisher — required: name; optional: ipi, pro, isni, controlled_publisher. 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. 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. A release is created in DRAFT state — add tracks, then run the release checks before distributing. |
|
|
130
|
+
| `update_catalog_item` | write | Update a catalog entity: supply only the fields to change in `fields` (same field sets as create_catalog_item). Once a release is submitted or distributed, some release and track fields are locked — changing one returns a 403 with code RELEASE_LOCKED_FIELDS naming exactly which fields cannot change. |
|
|
131
|
+
| `delete_catalog_item` | write | Delete a catalog entity. The API refuses deletes that would orphan data — label: refused while the label still has releases — remove or reassign its releases first. artist: refused while still referenced by releases or tracks. writer: refused while still referenced by tracks. publisher: refused while still referenced by writers. release: only a never-submitted draft can be deleted. track: allowed while the parent release is an editable draft; refused once submitted or distributed. |
|
|
132
|
+
| `upload_image` | write | Upload a label image (logo, dark-mode logo, or background) or an artist photo from a local image file, per `target`. |
|
|
133
|
+
| `get_asset` | read | Read a track or release asset. Valid combinations: (1) mode='info' + parent='track' + asset stereo\|dolby\|lyrics — file metadata (not the bytes) incl. processing state. (2) mode='info' + parent='release' + asset square\|tall — motion-artwork (animated cover) video metadata. (3) mode='download_url' + parent='track' + asset audio_16\|audio_24\|audio_32 (WAV master) or audio_preview_full\|audio_preview_clip (MP3 preview) — returns { download_url, expires_in }, a signed URL that expires roughly 10 minutes after issue; fetch it directly — do not send your API token to it. Any other combination returns a structured error. |
|
|
131
134
|
|
|
132
135
|
### Releases (review, delivery, links, licenses, checks) `releases`
|
|
133
136
|
|
|
134
137
|
| Tool | Gate | Description |
|
|
135
138
|
| --- | --- | --- |
|
|
136
|
-
| `get_release_review` | read | Read a release's automated quality-check results. Pick ONE view with `view`: `issues` lists the review issues raised against the release — each
|
|
137
|
-
| `get_delivery_queue` | read | List
|
|
138
|
-
| `get_landing_config` | read | Retrieve
|
|
139
|
-
| `list_track_licenses` | read | List
|
|
140
|
-
| `run_release_checks` | write | Run an automated check on a release. Pick ONE with `check`: `validate` returns
|
|
141
|
-
| `manage_release_links` | write | Manage a release's smart-link landing page. Pick ONE action with `action`: `update_landing_config` replaces the
|
|
139
|
+
| `get_release_review` | read | Read a release's automated quality-check results. Pick ONE view with `view`: `issues` lists the review issues raised against the release — each with a code (see list_reference_data type issue_definitions), severity, and whether it blocks distribution. `quality_report` returns the Preflight QC quality report — customer-facing issues to review before confirming distribution; Preflight QC is an optional add-on — without it the API returns a 403, surfaced verbatim. |
|
|
140
|
+
| `get_delivery_queue` | read | List your account's distribution queue, paginated — one entry per (release, outlet) delivery with its status (e.g. pending review, processing, scheduled, complete, error). Filter by `release_id`, `outlet_id`, or `status`. |
|
|
141
|
+
| `get_landing_config` | read | Retrieve a release's smart-link landing-page configuration: enabled state, style/mode, custom copy, action list, pre-order links. Change it via manage_release_links (action update_landing_config). |
|
|
142
|
+
| `list_track_licenses` | read | List a track's licenses (e.g. cover/mechanical or sample clearances), paginated. Pass `license_id` to retrieve one license instead. |
|
|
143
|
+
| `run_release_checks` | write | Run an automated check on a release. Pick ONE with `check`: `validate` returns problems that would block distribution (human-readable `errors` + machine-readable `errors_structured`); it changes nothing and is safe to repeat — run it before distributing. `refresh_quality_report` re-runs the Preflight QC checks (read the report with get_release_review view quality_report); an hourly refresh budget may rate-limit frequent calls. Preflight QC is an optional add-on. |
|
|
144
|
+
| `manage_release_links` | write | Manage a release's smart-link landing page. Pick ONE action with `action`: `update_landing_config` replaces the configuration with `config` (required for this action) — `config.actions` uses the v2 action-list contract (one entry per call-to-action); other keys: links_page_enabled, config_mode, page_style, custom_cta_text, custom_description, pre_order_links. `create_short_url` creates (or returns the existing) short URL for the landing page — safe to repeat. |
|
|
142
145
|
| `add_review_issue_note` | write | Add a note to a release review issue — to explain a fix or add reviewer context. `review_issue_id` comes from get_release_review view issues. |
|
|
143
146
|
|
|
144
147
|
### Insights (analytics & artificial streaming) `insights`
|
|
145
148
|
|
|
146
149
|
| Tool | Gate | Description |
|
|
147
150
|
| --- | --- | --- |
|
|
148
|
-
| `get_analytics` | read |
|
|
149
|
-
| `
|
|
151
|
+
| `get_analytics` | read | Streaming analytics summary. Window capped at 400 days; `metrics` takes 1-12 section keys per request (split larger selections — responses are cached). KUGOU/KUWO/QQMUSIC report weekly: one point per week carrying the whole week — never average it per day. `meta` carries `platform_cadence`, `section_granularity`, `sections_as_of` and `sections_complete_through` (later dates still filling in). Call get_analytics_availability first for section-per-platform support. Rate-limited ~60/min; windows over 90 days draw a separate lower ~30/min budget — prefer shorter windows for polling. A 429 carries retry_after_seconds. |
|
|
152
|
+
| `get_analytics_availability` | read | Static `availability` matrix (per section, per platform) plus `platform_cadence` (daily\|weekly per platform). Account- and date-independent: fetch once, reuse. Read it before get_analytics so an unreported section is treated as unavailable, not an empty chart. |
|
|
153
|
+
| `query_artificial_streaming` | read | Artificial-streaming (streaming-integrity) reads. Pick ONE `view`: `flags` — Stream Radar early-warning flags, paginated (`filters`: status, severity, dsp, isrc, release_id, detected_from/detected_to). Stream Radar is an optional add-on; without it the API returns a 403, surfaced verbatim. `flag_detail` — one flag by `flag_id`. `records` — reported artificial-streaming records, cursor-paginated; the detail behind any artificial-streaming fee (`filters`: dsp, start_date/end_date, release_id, isrc). `fee_breakdown` — per-release fee breakdown for one `period` (YYYY-MM). response_format:'detailed' returns the verbatim API response. |
|
|
150
154
|
|
|
151
155
|
### Finance (statements, transactions, royalties) `finance`
|
|
152
156
|
|
|
@@ -166,13 +170,13 @@ tool definitions by `npm run gen-docs` — do not edit it by hand._
|
|
|
166
170
|
|
|
167
171
|
| Tool | Gate | Description |
|
|
168
172
|
| --- | --- | --- |
|
|
169
|
-
| `upload_asset` | full-write | Upload a finalized track or release asset from a local file. `id` is the track id for track_* targets, the release id for release_*. `track_stereo` (
|
|
170
|
-
| `delete_asset` | full-write | Delete a track
|
|
171
|
-
| `manage_track_license` | full-write | Manage
|
|
172
|
-
| `distribute_release` | full-write | Submit a release
|
|
173
|
-
| `takedown_release` | full-write | Take a release down from ALL outlets/stores — a final
|
|
174
|
-
| `confirm_review` | full-write | Confirm a release
|
|
175
|
-
| `enable_beatport` | full-write | Request Beatport onboarding for a label.
|
|
173
|
+
| `upload_asset` | full-write | Upload a finalized track or release asset from a local file. `id` is the track id for track_* targets, the release id for release_*. `track_stereo` (WAV/FLAC/AIFF), `track_dolby` (Dolby Atmos WAV) and `track_lyrics` (LRC) process asynchronously — check state with get_asset (mode info). `release_cover_art` uploads or replaces the static cover art image. `release_motion_square` / `release_motion_tall` upload the square or tall animated cover (motion artwork) video, also asynchronous. All assets become immutable once the release is distributed — upload final files first. |
|
|
174
|
+
| `delete_asset` | full-write | Delete a track asset (track_*) or an animated cover / motion artwork video (release_motion_*). Allowed only while the parent release is an editable draft; refused once locked or distributed. Cover art cannot be deleted. |
|
|
175
|
+
| `manage_track_license` | full-write | Manage license documents on a track (cover or cleared sample). `upload` attaches a new license — `file_path` required, `type` selects cover/sample; optional metadata fields. `update` replaces the file and/or metadata — `track_license_id` (from list_track_licenses) and `file_path` required. `delete` permanently removes a license and its file — `track_license_id` required; cannot be undone. Immutability-governed once the release is live. |
|
|
176
|
+
| `distribute_release` | full-write | Submit a release to the stores/outlets — the FINAL action that sends it out; run_release_checks (check validate) should pass first. The server enforces the account’s weekly submission limit. Reuse the SAME idempotency_key when retrying an unobserved call; without one each call is a new submission. |
|
|
177
|
+
| `takedown_release` | full-write | Take a release down from ALL outlets/stores — a final action that removes it everywhere it was delivered. Re-distribution is a fresh submission. |
|
|
178
|
+
| `confirm_review` | full-write | Confirm a release Preflight QC placed on hold, moving it into distribution review, after reviewing the quality report and accepting the release as-is. Safe to repeat. |
|
|
179
|
+
| `enable_beatport` | full-write | Request Beatport onboarding for a label. One-time and cannot be un-requested — confirm the label is correct first. |
|
|
176
180
|
|
|
177
181
|
<!-- TOOLS:END -->
|
|
178
182
|
|
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/coverage.js
CHANGED
|
@@ -29,6 +29,7 @@ export const COVERAGE = {
|
|
|
29
29
|
'GET /territories': 'list_reference_data',
|
|
30
30
|
// insights
|
|
31
31
|
'GET /analytics/summary': 'get_analytics',
|
|
32
|
+
'GET /analytics/availability': 'get_analytics_availability',
|
|
32
33
|
// catalog reads
|
|
33
34
|
'GET /labels': 'search_catalog',
|
|
34
35
|
'GET /labels/{label}': 'get_catalog_item',
|
|
@@ -146,5 +147,6 @@ export const EXCLUDED = {
|
|
|
146
147
|
};
|
|
147
148
|
export const PENDING_DOCS = {
|
|
148
149
|
'GET /account': 'get_account',
|
|
150
|
+
'GET /analytics/availability': 'get_analytics_availability',
|
|
149
151
|
'GET /tracks/{track}/files/{assetType}/download-url': 'get_asset',
|
|
150
152
|
};
|
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/catalog.js
CHANGED
|
@@ -21,7 +21,7 @@ const idArg = z.number().int().positive().describe('The entity id.');
|
|
|
21
21
|
const responseFormat = z
|
|
22
22
|
.enum(['concise', 'detailed'])
|
|
23
23
|
.optional()
|
|
24
|
-
.describe("'concise' (default) keeps
|
|
24
|
+
.describe("'concise' (default) keeps high-signal fields (ids always kept); 'detailed' returns the verbatim API response.");
|
|
25
25
|
/** A permissive body of API fields, forwarded verbatim to the endpoint. */
|
|
26
26
|
function fieldsBody(desc) {
|
|
27
27
|
return z.record(z.string(), z.unknown()).describe(desc);
|
|
@@ -32,7 +32,7 @@ const idempotencyKey = z
|
|
|
32
32
|
.min(8)
|
|
33
33
|
.max(128)
|
|
34
34
|
.optional()
|
|
35
|
-
.describe('
|
|
35
|
+
.describe('Release and track only (ignored for other entities): the server deduplicates by this key for 24h — reuse the SAME key when retrying an unobserved call.');
|
|
36
36
|
/** Rejects a path that is not an existing regular file, before any HTTP call. */
|
|
37
37
|
function fileError(p) {
|
|
38
38
|
let isFile = false;
|
|
@@ -54,13 +54,10 @@ const searchCatalog = {
|
|
|
54
54
|
toolset: 'catalog',
|
|
55
55
|
gate: 'read',
|
|
56
56
|
title: 'Search the catalog',
|
|
57
|
-
description: `List catalog entities of one kind, paginated.
|
|
57
|
+
description: `List catalog entities of one kind, paginated. \`filters\` takes the endpoint’s own filter names, passed through verbatim — ${entityDoc((s) => s.filtersDoc)} Use get_catalog_item for full detail.`,
|
|
58
58
|
inputShape: {
|
|
59
59
|
entity: entityArg,
|
|
60
|
-
filters: z
|
|
61
|
-
.record(z.string(), z.unknown())
|
|
62
|
-
.optional()
|
|
63
|
-
.describe('Filter names → values, passed through verbatim.'),
|
|
60
|
+
filters: z.record(z.string(), z.unknown()).optional().describe('Filter names → values.'),
|
|
64
61
|
page: z.number().int().positive().optional().describe('1-based page number.'),
|
|
65
62
|
per_page: z.number().int().positive().optional().describe('Items per page.'),
|
|
66
63
|
response_format: responseFormat,
|
|
@@ -81,8 +78,7 @@ const getCatalogItem = {
|
|
|
81
78
|
toolset: 'catalog',
|
|
82
79
|
gate: 'read',
|
|
83
80
|
title: 'Get a catalog item',
|
|
84
|
-
description: 'Retrieve one catalog entity by id, with
|
|
85
|
-
"Pick the kind with `entity`: label, artist, writer, publisher, release, or track. response_format:'detailed' returns the verbatim API response.",
|
|
81
|
+
description: 'Retrieve one catalog entity by id, with full detail (e.g. a release’s metadata and track listing, a track’s contributors and royalty splits, a writer’s PRO/IPI).',
|
|
86
82
|
inputShape: {
|
|
87
83
|
entity: entityArg,
|
|
88
84
|
id: idArg,
|
|
@@ -100,7 +96,7 @@ const createCatalogItem = {
|
|
|
100
96
|
toolset: 'catalog',
|
|
101
97
|
gate: 'safe_write',
|
|
102
98
|
title: 'Create a catalog item',
|
|
103
|
-
description: `Create a catalog entity
|
|
99
|
+
description: `Create a catalog entity: pass its attributes in \`fields\` — the API owns all validation. Required and common fields per entity: ${entityDoc((s) => s.fieldsDoc)} A release is created in DRAFT state — add tracks, then run the release checks before distributing.`,
|
|
104
100
|
inputShape: {
|
|
105
101
|
entity: entityArg,
|
|
106
102
|
fields: fieldsBody('The entity attributes, forwarded verbatim.'),
|
|
@@ -126,8 +122,8 @@ const updateCatalogItem = {
|
|
|
126
122
|
toolset: 'catalog',
|
|
127
123
|
gate: 'safe_write',
|
|
128
124
|
title: 'Update a catalog item',
|
|
129
|
-
description: 'Update a catalog entity
|
|
130
|
-
'
|
|
125
|
+
description: 'Update a catalog entity: supply only the fields to change in `fields` (same field sets as create_catalog_item). ' +
|
|
126
|
+
'Once a release is submitted or distributed, some release and track fields are locked — changing one returns a 403 with code RELEASE_LOCKED_FIELDS naming exactly which fields cannot change.',
|
|
131
127
|
inputShape: {
|
|
132
128
|
entity: entityArg,
|
|
133
129
|
id: idArg,
|
|
@@ -144,7 +140,7 @@ const deleteCatalogItem = {
|
|
|
144
140
|
toolset: 'catalog',
|
|
145
141
|
gate: 'safe_write',
|
|
146
142
|
title: 'Delete a catalog item',
|
|
147
|
-
description: `Delete a catalog entity
|
|
143
|
+
description: `Delete a catalog entity. The API refuses deletes that would orphan data — ${entityDoc((s) => s.deleteNote)}`,
|
|
148
144
|
inputShape: { entity: entityArg, id: idArg },
|
|
149
145
|
annotations: { destructiveHint: true },
|
|
150
146
|
handler: (args, { client }) => {
|
|
@@ -163,13 +159,13 @@ const uploadImage = {
|
|
|
163
159
|
toolset: 'catalog',
|
|
164
160
|
gate: 'safe_write',
|
|
165
161
|
title: 'Upload a catalog image',
|
|
166
|
-
description: 'Upload a label image
|
|
162
|
+
description: 'Upload a label image (logo, dark-mode logo, or background) or an artist photo from a local image file, per `target`.',
|
|
167
163
|
inputShape: {
|
|
168
164
|
target: z
|
|
169
165
|
.enum(['label_logo', 'label_logo_dark', 'label_background', 'artist_photo'])
|
|
170
166
|
.describe('Which image asset to upload.'),
|
|
171
167
|
id: z.number().int().positive().describe('The label id (label_*) or artist id (artist_photo).'),
|
|
172
|
-
file_path: z.string().describe('Local path to the image file
|
|
168
|
+
file_path: z.string().describe('Local path to the image file.'),
|
|
173
169
|
},
|
|
174
170
|
annotations: {},
|
|
175
171
|
handler: async (args, { client }) => {
|
|
@@ -204,11 +200,11 @@ const getAsset = {
|
|
|
204
200
|
toolset: 'catalog',
|
|
205
201
|
gate: 'read',
|
|
206
202
|
title: 'Get an asset',
|
|
207
|
-
description: 'Read a track or release asset. Valid
|
|
208
|
-
"(1) mode='info'
|
|
209
|
-
"(2) mode='info'
|
|
210
|
-
"(3) mode='download_url'
|
|
211
|
-
'Any other combination
|
|
203
|
+
description: 'Read a track or release asset. Valid combinations: ' +
|
|
204
|
+
"(1) mode='info' + parent='track' + asset stereo|dolby|lyrics — file metadata (not the bytes) incl. processing state. " +
|
|
205
|
+
"(2) mode='info' + parent='release' + asset square|tall — motion-artwork (animated cover) video metadata. " +
|
|
206
|
+
"(3) mode='download_url' + parent='track' + asset audio_16|audio_24|audio_32 (WAV master) or audio_preview_full|audio_preview_clip (MP3 preview) — returns { download_url, expires_in }, a signed URL that expires roughly 10 minutes after issue; fetch it directly — do not send your API token to it. " +
|
|
207
|
+
'Any other combination returns a structured error.',
|
|
212
208
|
inputShape: {
|
|
213
209
|
parent: z.enum(['track', 'release']).describe('Whose asset.'),
|
|
214
210
|
id: z.number().int().positive().describe('The track id or release id, per `parent`.'),
|
|
@@ -38,7 +38,7 @@ const idempotencyKey = z
|
|
|
38
38
|
.min(8)
|
|
39
39
|
.max(128)
|
|
40
40
|
.optional()
|
|
41
|
-
.describe('
|
|
41
|
+
.describe('Deduplicated by the server for 24h — reuse the SAME key when retrying a call whose outcome you did not observe.');
|
|
42
42
|
/** Optional license metadata shared by the license upload/update actions. */
|
|
43
43
|
const licenseMeta = {
|
|
44
44
|
license_id: z.string().optional().describe('The license/clearance reference number, if any.'),
|
|
@@ -71,10 +71,10 @@ const uploadAsset = {
|
|
|
71
71
|
gate: 'full_write',
|
|
72
72
|
title: 'Upload a release/track asset',
|
|
73
73
|
description: 'Upload a finalized track or release asset from a local file. `id` is the track id for track_* targets, the release id for release_*. ' +
|
|
74
|
-
'`track_stereo` (
|
|
75
|
-
|
|
76
|
-
'`release_motion_square` / `release_motion_tall` upload the animated cover (motion artwork) video
|
|
77
|
-
'
|
|
74
|
+
'`track_stereo` (WAV/FLAC/AIFF), `track_dolby` (Dolby Atmos WAV) and `track_lyrics` (LRC) process asynchronously — check state with get_asset (mode info). ' +
|
|
75
|
+
'`release_cover_art` uploads or replaces the static cover art image. ' +
|
|
76
|
+
'`release_motion_square` / `release_motion_tall` upload the square or tall animated cover (motion artwork) video, also asynchronous. ' +
|
|
77
|
+
'All assets become immutable once the release is distributed — upload final files first.',
|
|
78
78
|
inputShape: {
|
|
79
79
|
target: z
|
|
80
80
|
.enum([
|
|
@@ -126,7 +126,7 @@ const deleteAsset = {
|
|
|
126
126
|
toolset: 'distribution',
|
|
127
127
|
gate: 'full_write',
|
|
128
128
|
title: 'Delete a release/track asset',
|
|
129
|
-
description: 'Delete a track
|
|
129
|
+
description: 'Delete a track asset (track_*) or an animated cover / motion artwork video (release_motion_*). Allowed only while the parent release is an editable draft; refused once locked or distributed. Cover art cannot be deleted.',
|
|
130
130
|
inputShape: {
|
|
131
131
|
target: z
|
|
132
132
|
.enum([
|
|
@@ -157,11 +157,11 @@ const manageTrackLicense = {
|
|
|
157
157
|
toolset: 'distribution',
|
|
158
158
|
gate: 'full_write',
|
|
159
159
|
title: 'Manage a track license',
|
|
160
|
-
description: 'Manage
|
|
161
|
-
|
|
162
|
-
'`update` replaces the file and/or metadata
|
|
163
|
-
'`delete` permanently
|
|
164
|
-
'
|
|
160
|
+
description: 'Manage license documents on a track (cover or cleared sample). ' +
|
|
161
|
+
'`upload` attaches a new license — `file_path` required, `type` selects cover/sample; optional metadata fields. ' +
|
|
162
|
+
'`update` replaces the file and/or metadata — `track_license_id` (from list_track_licenses) and `file_path` required. ' +
|
|
163
|
+
'`delete` permanently removes a license and its file — `track_license_id` required; cannot be undone. ' +
|
|
164
|
+
'Immutability-governed once the release is live.',
|
|
165
165
|
inputShape: {
|
|
166
166
|
action: z.enum(['upload', 'update', 'delete']).describe('Which license action.'),
|
|
167
167
|
track_id: z.number().int().positive().describe('The track id.'),
|
|
@@ -216,7 +216,7 @@ const distributeRelease = {
|
|
|
216
216
|
toolset: 'distribution',
|
|
217
217
|
gate: 'full_write',
|
|
218
218
|
title: 'Distribute a release',
|
|
219
|
-
description: 'Submit a release
|
|
219
|
+
description: 'Submit a release to the stores/outlets — the FINAL action that sends it out; run_release_checks (check validate) should pass first. The server enforces the account’s weekly submission limit. Reuse the SAME idempotency_key when retrying an unobserved call; without one each call is a new submission.',
|
|
220
220
|
inputShape: { release_id: releaseId, idempotency_key: idempotencyKey },
|
|
221
221
|
annotations: { destructiveHint: true },
|
|
222
222
|
handler: (args, { client }) => client.post(`/releases/${args.release_id}/distribute`, undefined, {
|
|
@@ -229,7 +229,7 @@ const takedownRelease = {
|
|
|
229
229
|
toolset: 'distribution',
|
|
230
230
|
gate: 'full_write',
|
|
231
231
|
title: 'Take down a release',
|
|
232
|
-
description: 'Take a release down from ALL outlets/stores — a final
|
|
232
|
+
description: 'Take a release down from ALL outlets/stores — a final action that removes it everywhere it was delivered. Re-distribution is a fresh submission.',
|
|
233
233
|
inputShape: { release_id: releaseId },
|
|
234
234
|
annotations: { destructiveHint: true },
|
|
235
235
|
handler: (args, { client }) => client.post(`/releases/${args.release_id}/takedown-all`),
|
|
@@ -239,7 +239,7 @@ const confirmReview = {
|
|
|
239
239
|
toolset: 'distribution',
|
|
240
240
|
gate: 'full_write',
|
|
241
241
|
title: 'Confirm a held release into review',
|
|
242
|
-
description: 'Confirm a release
|
|
242
|
+
description: 'Confirm a release Preflight QC placed on hold, moving it into distribution review, after reviewing the quality report and accepting the release as-is. Safe to repeat.',
|
|
243
243
|
inputShape: { release_id: releaseId },
|
|
244
244
|
annotations: { destructiveHint: true, idempotentHint: true },
|
|
245
245
|
handler: (args, { client }) => client.post(`/releases/${args.release_id}/confirm-review`),
|
|
@@ -249,7 +249,7 @@ const enableBeatport = {
|
|
|
249
249
|
toolset: 'distribution',
|
|
250
250
|
gate: 'full_write',
|
|
251
251
|
title: 'Request Beatport onboarding for a label',
|
|
252
|
-
description: 'Request Beatport onboarding for a label.
|
|
252
|
+
description: 'Request Beatport onboarding for a label. One-time and cannot be un-requested — confirm the label is correct first.',
|
|
253
253
|
inputShape: { label_id: z.number().int().positive().describe('The label id.') },
|
|
254
254
|
annotations: { destructiveHint: true },
|
|
255
255
|
handler: (args, { client }) => client.post(`/labels/${args.label_id}/enable-beatport`),
|
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/dist/tools/insights.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Insights toolset: the streaming analytics summary
|
|
3
|
-
* artificial-streaming query (early-warning
|
|
4
|
-
* fee breakdown). All read-only.
|
|
2
|
+
* Insights toolset: the streaming analytics summary, the availability-discovery
|
|
3
|
+
* endpoint, and the consolidated artificial-streaming query (early-warning
|
|
4
|
+
* flags, reported records, and the fee breakdown). All read-only.
|
|
5
5
|
*/
|
|
6
6
|
import type { ToolDef } from './types.js';
|
|
7
7
|
export declare const insightsTools: ToolDef[];
|
package/dist/tools/insights.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Insights toolset: the streaming analytics summary
|
|
3
|
-
* artificial-streaming query (early-warning
|
|
4
|
-
* fee breakdown). All read-only.
|
|
2
|
+
* Insights toolset: the streaming analytics summary, the availability-discovery
|
|
3
|
+
* endpoint, and the consolidated artificial-streaming query (early-warning
|
|
4
|
+
* flags, reported records, and the fee breakdown). All read-only.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { applyProjection } from '../projection.js';
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* The 37 metric sections the summary endpoint can return, in the server's
|
|
10
|
+
* canonical order.
|
|
11
|
+
*/
|
|
9
12
|
const METRICS = [
|
|
10
13
|
'streams',
|
|
11
14
|
'listeners',
|
|
@@ -22,24 +25,62 @@ const METRICS = [
|
|
|
22
25
|
'streams-by-gender',
|
|
23
26
|
'streams-by-age',
|
|
24
27
|
'shares-by-country',
|
|
28
|
+
'library-adds',
|
|
29
|
+
'shazams',
|
|
30
|
+
'playlist-adds',
|
|
31
|
+
'source-split-detailed',
|
|
32
|
+
'discovery-rate',
|
|
33
|
+
'repeat-rate',
|
|
34
|
+
'listener-plan-mix',
|
|
35
|
+
'listeners-by-age',
|
|
36
|
+
'listeners-by-gender',
|
|
37
|
+
'listeners-by-region',
|
|
38
|
+
'apple-streams-by-city',
|
|
39
|
+
'apple-streams-by-storefront',
|
|
40
|
+
'apple-discovery-cohorts',
|
|
41
|
+
'avg-listen-time',
|
|
42
|
+
'shuffle-rate',
|
|
43
|
+
'promoted-rate',
|
|
44
|
+
'device-breakdown',
|
|
45
|
+
'os-split',
|
|
46
|
+
'audio-format-split',
|
|
47
|
+
'hour-of-day',
|
|
48
|
+
'shazams-by-city',
|
|
49
|
+
'shazams-by-state',
|
|
50
|
+
];
|
|
51
|
+
/** The maximum number of section keys the server accepts per summary request. */
|
|
52
|
+
const MAX_METRICS_PER_REQUEST = 12;
|
|
53
|
+
/** The platform values `filter[platform]` accepts (APPLE_MUSIC aliases ITUNES). */
|
|
54
|
+
const PLATFORMS = [
|
|
55
|
+
'SPOTIFY',
|
|
56
|
+
'ITUNES',
|
|
57
|
+
'APPLE_MUSIC',
|
|
58
|
+
'DEEZER',
|
|
59
|
+
'BOOMPLAY',
|
|
60
|
+
'AWA',
|
|
61
|
+
'AUDIOMACK',
|
|
62
|
+
'KUGOU',
|
|
63
|
+
'KUWO',
|
|
64
|
+
'QQMUSIC',
|
|
25
65
|
];
|
|
26
66
|
const getAnalytics = {
|
|
27
67
|
name: 'get_analytics',
|
|
28
68
|
toolset: 'insights',
|
|
29
69
|
gate: 'read',
|
|
30
70
|
title: 'Get streaming analytics',
|
|
31
|
-
description: '
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'Rate-limited
|
|
71
|
+
description: 'Streaming analytics summary. Window capped at 400 days; `metrics` takes 1-12 section keys per request (split larger selections — responses are cached). ' +
|
|
72
|
+
'KUGOU/KUWO/QQMUSIC report weekly: one point per week carrying the whole week — never average it per day. `meta` carries `platform_cadence`, `section_granularity`, `sections_as_of` and `sections_complete_through` (later dates still filling in). ' +
|
|
73
|
+
'Call get_analytics_availability first for section-per-platform support. ' +
|
|
74
|
+
'Rate-limited ~60/min; windows over 90 days draw a separate lower ~30/min budget — prefer shorter windows for polling. A 429 carries retry_after_seconds.',
|
|
35
75
|
inputShape: {
|
|
36
76
|
start_date: z.string().describe('Start of the reporting window, YYYY-MM-DD.'),
|
|
37
|
-
end_date: z.string().describe('End of the reporting window, YYYY-MM-DD (max
|
|
77
|
+
end_date: z.string().describe('End of the reporting window, YYYY-MM-DD (max 400-day span).'),
|
|
38
78
|
metrics: z
|
|
39
79
|
.array(z.enum(METRICS))
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
|
|
80
|
+
.min(1)
|
|
81
|
+
.max(MAX_METRICS_PER_REQUEST)
|
|
82
|
+
.describe('Section keys to return, 1-12 per request.'),
|
|
83
|
+
platform: z.enum(PLATFORMS).optional(),
|
|
43
84
|
release_id: z.number().int().positive().optional(),
|
|
44
85
|
isrc: z.string().optional(),
|
|
45
86
|
upc: z.string().optional(),
|
|
@@ -61,16 +102,27 @@ const getAnalytics = {
|
|
|
61
102
|
limit: args.limit,
|
|
62
103
|
}),
|
|
63
104
|
};
|
|
105
|
+
const getAnalyticsAvailability = {
|
|
106
|
+
name: 'get_analytics_availability',
|
|
107
|
+
toolset: 'insights',
|
|
108
|
+
gate: 'read',
|
|
109
|
+
title: 'Get analytics availability',
|
|
110
|
+
description: 'Static `availability` matrix (per section, per platform) plus `platform_cadence` (daily|weekly per platform). Account- and date-independent: fetch once, reuse. ' +
|
|
111
|
+
'Read it before get_analytics so an unreported section is treated as unavailable, not an empty chart.',
|
|
112
|
+
inputShape: {},
|
|
113
|
+
annotations: { readOnlyHint: true },
|
|
114
|
+
handler: (_args, { client }) => client.get('/analytics/availability'),
|
|
115
|
+
};
|
|
64
116
|
const queryArtificialStreaming = {
|
|
65
117
|
name: 'query_artificial_streaming',
|
|
66
118
|
toolset: 'insights',
|
|
67
119
|
gate: 'read',
|
|
68
120
|
title: 'Query artificial-streaming data',
|
|
69
|
-
description: '
|
|
70
|
-
'`flags`
|
|
71
|
-
'`flag_detail`
|
|
72
|
-
'`records`
|
|
73
|
-
'`fee_breakdown`
|
|
121
|
+
description: 'Artificial-streaming (streaming-integrity) reads. Pick ONE `view`: ' +
|
|
122
|
+
'`flags` — Stream Radar early-warning flags, paginated (`filters`: status, severity, dsp, isrc, release_id, detected_from/detected_to). Stream Radar is an optional add-on; without it the API returns a 403, surfaced verbatim. ' +
|
|
123
|
+
'`flag_detail` — one flag by `flag_id`. ' +
|
|
124
|
+
'`records` — reported artificial-streaming records, cursor-paginated; the detail behind any artificial-streaming fee (`filters`: dsp, start_date/end_date, release_id, isrc). ' +
|
|
125
|
+
'`fee_breakdown` — per-release fee breakdown for one `period` (YYYY-MM). ' +
|
|
74
126
|
"response_format:'detailed' returns the verbatim API response.",
|
|
75
127
|
inputShape: {
|
|
76
128
|
view: z
|
|
@@ -136,4 +188,8 @@ const queryArtificialStreaming = {
|
|
|
136
188
|
return applyProjection(result, 'query_artificial_streaming', args.response_format);
|
|
137
189
|
},
|
|
138
190
|
};
|
|
139
|
-
export const insightsTools = [
|
|
191
|
+
export const insightsTools = [
|
|
192
|
+
getAnalytics,
|
|
193
|
+
getAnalyticsAvailability,
|
|
194
|
+
queryArtificialStreaming,
|
|
195
|
+
];
|
package/dist/tools/releases.js
CHANGED
|
@@ -20,9 +20,8 @@ const getReleaseReview = {
|
|
|
20
20
|
gate: 'read',
|
|
21
21
|
title: 'Get release review results',
|
|
22
22
|
description: "Read a release's automated quality-check results. Pick ONE view with `view`: " +
|
|
23
|
-
'`issues` lists the review issues raised against the release — each
|
|
24
|
-
'`quality_report`
|
|
25
|
-
"response_format:'detailed' returns the verbatim API response.",
|
|
23
|
+
'`issues` lists the review issues raised against the release — each with a code (see list_reference_data type issue_definitions), severity, and whether it blocks distribution. ' +
|
|
24
|
+
'`quality_report` returns the Preflight QC quality report — customer-facing issues to review before confirming distribution; Preflight QC is an optional add-on — without it the API returns a 403, surfaced verbatim.',
|
|
26
25
|
inputShape: {
|
|
27
26
|
release_id: releaseId,
|
|
28
27
|
view: z.enum(['issues', 'quality_report']).describe('Which review read.'),
|
|
@@ -41,8 +40,7 @@ const getDeliveryQueue = {
|
|
|
41
40
|
toolset: 'releases',
|
|
42
41
|
gate: 'read',
|
|
43
42
|
title: 'Get the distribution queue',
|
|
44
|
-
description:
|
|
45
|
-
"response_format:'detailed' returns the verbatim API response.",
|
|
43
|
+
description: "List your account's distribution queue, paginated — one entry per (release, outlet) delivery with its status (e.g. pending review, processing, scheduled, complete, error). Filter by `release_id`, `outlet_id`, or `status`.",
|
|
46
44
|
inputShape: {
|
|
47
45
|
release_id: z.number().int().positive().optional().describe('Filter to one release.'),
|
|
48
46
|
outlet_id: z.number().int().positive().optional().describe('Filter to one outlet/store.'),
|
|
@@ -70,7 +68,7 @@ const getLandingConfig = {
|
|
|
70
68
|
toolset: 'releases',
|
|
71
69
|
gate: 'read',
|
|
72
70
|
title: 'Get a release landing-page config',
|
|
73
|
-
description:
|
|
71
|
+
description: "Retrieve a release's smart-link landing-page configuration: enabled state, style/mode, custom copy, action list, pre-order links. Change it via manage_release_links (action update_landing_config).",
|
|
74
72
|
inputShape: { release_id: releaseId },
|
|
75
73
|
annotations: { readOnlyHint: true },
|
|
76
74
|
handler: (args, { client }) => client.get(`/releases/${args.release_id}/landing-config`),
|
|
@@ -80,7 +78,7 @@ const listTrackLicenses = {
|
|
|
80
78
|
toolset: 'releases',
|
|
81
79
|
gate: 'read',
|
|
82
80
|
title: 'List track licenses',
|
|
83
|
-
description:
|
|
81
|
+
description: "List a track's licenses (e.g. cover/mechanical or sample clearances), paginated. Pass `license_id` to retrieve one license instead.",
|
|
84
82
|
inputShape: {
|
|
85
83
|
track_id: z.number().int().positive().describe('The track id.'),
|
|
86
84
|
license_id: z
|
|
@@ -109,8 +107,8 @@ const runReleaseChecks = {
|
|
|
109
107
|
gate: 'safe_write',
|
|
110
108
|
title: 'Run release checks',
|
|
111
109
|
description: 'Run an automated check on a release. Pick ONE with `check`: ' +
|
|
112
|
-
'`validate` returns
|
|
113
|
-
'`refresh_quality_report` re-runs the Preflight QC checks
|
|
110
|
+
'`validate` returns problems that would block distribution (human-readable `errors` + machine-readable `errors_structured`); it changes nothing and is safe to repeat — run it before distributing. ' +
|
|
111
|
+
'`refresh_quality_report` re-runs the Preflight QC checks (read the report with get_release_review view quality_report); an hourly refresh budget may rate-limit frequent calls. Preflight QC is an optional add-on.',
|
|
114
112
|
inputShape: {
|
|
115
113
|
release_id: releaseId,
|
|
116
114
|
check: z.enum(['validate', 'refresh_quality_report']).describe('Which check to run.'),
|
|
@@ -126,8 +124,8 @@ const manageReleaseLinks = {
|
|
|
126
124
|
gate: 'safe_write',
|
|
127
125
|
title: 'Manage a release smart link',
|
|
128
126
|
description: "Manage a release's smart-link landing page. Pick ONE action with `action`: " +
|
|
129
|
-
'`update_landing_config` replaces the
|
|
130
|
-
|
|
127
|
+
'`update_landing_config` replaces the configuration with `config` (required for this action) — `config.actions` uses the v2 action-list contract (one entry per call-to-action); other keys: links_page_enabled, config_mode, page_style, custom_cta_text, custom_description, pre_order_links. ' +
|
|
128
|
+
'`create_short_url` creates (or returns the existing) short URL for the landing page — safe to repeat.',
|
|
131
129
|
inputShape: {
|
|
132
130
|
release_id: releaseId,
|
|
133
131
|
action: z.enum(['update_landing_config', 'create_short_url']).describe('Which action.'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@labelgrid/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.labelgrid/labelgrid-mcp",
|
|
5
5
|
"description": "Official LabelGrid MCP server — connect your AI client to your LabelGrid account",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=20"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@labelgrid/core": "0.
|
|
30
|
+
"@labelgrid/core": "0.2.0",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
32
|
"zod": "^3.24.0"
|
|
33
33
|
}
|
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
|
},
|