@mcptoolshop/mcpt-publishing 0.2.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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * NuGet provider — audit + publish.
3
+ *
4
+ * Audit: fetches metadata via NuGet search API + flat-container,
5
+ * detects indexing lag, and classifies drift against git state.
6
+ * Publish: packs .nupkg, computes SHA-256, pushes to nuget.org.
7
+ */
8
+
9
+ import { execSync } from "node:child_process";
10
+ import { readdirSync, rmSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { Provider } from "../provider.mjs";
13
+ import { exec, hashFile } from "../shell.mjs";
14
+
15
+ export default class NuGetProvider extends Provider {
16
+ get name() { return "nuget"; }
17
+
18
+ detect(entry) {
19
+ return entry.ecosystem === "nuget";
20
+ }
21
+
22
+ async audit(entry, ctx) {
23
+ const meta = this.#fetchMeta(entry.name);
24
+ const flatVersions = this.#fetchVersions(entry.name);
25
+ const tags = ctx.tags.get(entry.repo) ?? [];
26
+ const releases = ctx.releases.get(entry.repo) ?? [];
27
+
28
+ const version = flatVersions.length > 0
29
+ ? flatVersions[flatVersions.length - 1]
30
+ : (meta?.version ?? "?");
31
+
32
+ const findings = this.#classify(entry, meta, tags, releases, flatVersions);
33
+ return { version, findings };
34
+ }
35
+
36
+ /**
37
+ * Publish a NuGet package.
38
+ *
39
+ * @param {object} entry - { name, repo, audience, ecosystem }
40
+ * @param {object} opts - { dryRun: boolean, cwd: string }
41
+ * @returns {Promise<{ success: boolean, version: string, artifacts: Array, error?: string }>}
42
+ */
43
+ async publish(entry, opts = {}) {
44
+ const cwd = opts.cwd ?? process.cwd();
45
+
46
+ // Check credentials (skip in dry-run — dotnet pack doesn't need auth)
47
+ if (!process.env.NUGET_API_KEY && !opts.dryRun) {
48
+ return { success: false, version: "", artifacts: [], error: "NUGET_API_KEY environment variable is not set" };
49
+ }
50
+
51
+ const outputDir = join(cwd, "nupkg-output");
52
+
53
+ // dotnet pack
54
+ const packResult = exec("dotnet pack -c Release -o nupkg-output", { cwd });
55
+ if (packResult.exitCode !== 0) {
56
+ return { success: false, version: "", artifacts: [], error: `dotnet pack failed: ${packResult.stderr}` };
57
+ }
58
+
59
+ // Find the .nupkg matching the package name
60
+ if (!existsSync(outputDir)) {
61
+ return { success: false, version: "", artifacts: [], error: "nupkg-output directory not created by dotnet pack" };
62
+ }
63
+
64
+ const nupkgFiles = readdirSync(outputDir).filter(f => f.endsWith(".nupkg") && !f.endsWith(".symbols.nupkg"));
65
+ const targetNupkg = nupkgFiles.find(f => f.toLowerCase().startsWith(entry.name.toLowerCase() + "."));
66
+
67
+ if (!targetNupkg) {
68
+ // Clean up
69
+ try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
70
+ return {
71
+ success: false, version: "", artifacts: [],
72
+ error: `No .nupkg found matching "${entry.name}" in nupkg-output/ (found: ${nupkgFiles.join(", ") || "none"})`,
73
+ };
74
+ }
75
+
76
+ // Extract version from filename: PackageName.1.2.3.nupkg
77
+ const nupkgBasename = targetNupkg.replace(/\.nupkg$/, "");
78
+ const version = nupkgBasename.slice(entry.name.length + 1); // +1 for the dot
79
+
80
+ if (!version) {
81
+ try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
82
+ return { success: false, version: "", artifacts: [], error: `Could not parse version from ${targetNupkg}` };
83
+ }
84
+
85
+ // Compute SHA-256
86
+ const nupkgPath = join(outputDir, targetNupkg);
87
+ const { sha256, size } = hashFile(nupkgPath);
88
+
89
+ // Push (or dry-run)
90
+ if (!opts.dryRun) {
91
+ const pushResult = exec(
92
+ `dotnet nuget push "${nupkgPath}" --api-key "${process.env.NUGET_API_KEY}" --source https://api.nuget.org/v3/index.json --skip-duplicate`,
93
+ { cwd }
94
+ );
95
+ if (pushResult.exitCode !== 0) {
96
+ try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
97
+ return { success: false, version, artifacts: [], error: `dotnet nuget push failed: ${pushResult.stderr}` };
98
+ }
99
+ }
100
+
101
+ // Clean up
102
+ try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
103
+
104
+ // Build artifact metadata
105
+ const artifact = {
106
+ name: targetNupkg,
107
+ sha256,
108
+ size,
109
+ url: `https://www.nuget.org/packages/${entry.name}/${version}`,
110
+ };
111
+
112
+ return { success: true, version, artifacts: [artifact] };
113
+ }
114
+
115
+ // ─── Private ───────────────────────────────────────────────────────────────
116
+
117
+ #fetchMeta(id) {
118
+ try {
119
+ const url = `https://azuresearch-usnc.nuget.org/query?q=packageid:${id}&take=1`;
120
+ const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
121
+ const data = JSON.parse(raw);
122
+ return data.data?.[0] ?? null;
123
+ } catch { return null; }
124
+ }
125
+
126
+ /** Flat container returns actual published versions (updates faster than search). */
127
+ #fetchVersions(id) {
128
+ try {
129
+ const url = `https://api.nuget.org/v3-flatcontainer/${id.toLowerCase()}/index.json`;
130
+ const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
131
+ return JSON.parse(raw).versions ?? [];
132
+ } catch { return []; }
133
+ }
134
+
135
+ #classify(pkg, meta, tags, releases, flatVersions) {
136
+ const findings = [];
137
+
138
+ if (!meta) {
139
+ findings.push({ severity: "RED", code: "nuget-unreachable", msg: `Cannot reach ${pkg.name} on NuGet` });
140
+ return findings;
141
+ }
142
+
143
+ const searchVer = meta.version;
144
+ const latestFlat = flatVersions.length > 0 ? flatVersions[flatVersions.length - 1] : null;
145
+
146
+ // Detect indexing lag: flat container knows about a newer version than the search API
147
+ const indexingLag = latestFlat && latestFlat !== searchVer;
148
+ const ver = latestFlat ?? searchVer;
149
+ const tagName = `v${ver}`;
150
+
151
+ // Published-but-not-tagged
152
+ if (!tags.includes(tagName)) {
153
+ findings.push({ severity: "RED", code: "published-not-tagged", msg: `${pkg.name}@${ver} — no git tag ${tagName}` });
154
+ }
155
+
156
+ // ProjectUrl — suppress during indexing lag (metadata not propagated yet)
157
+ if (!meta.projectUrl && !indexingLag) {
158
+ if (pkg.audience === "front-door") {
159
+ findings.push({ severity: "YELLOW", code: "missing-project-url", msg: `${pkg.name} has no projectUrl on NuGet` });
160
+ }
161
+ }
162
+
163
+ // Icon — suppress during indexing lag
164
+ if (!meta.iconUrl && pkg.audience === "front-door" && !indexingLag) {
165
+ findings.push({ severity: "YELLOW", code: "missing-icon", msg: `${pkg.name} (front-door) has no icon` });
166
+ }
167
+
168
+ // Indexing lag notice (non-failing)
169
+ if (indexingLag) {
170
+ findings.push({
171
+ severity: "INFO",
172
+ code: "pending-index",
173
+ msg: `${pkg.name} v${latestFlat} published but search API still shows v${searchVer} — retry in 60-120 min`
174
+ });
175
+ }
176
+
177
+ // Description
178
+ if (!meta.description) {
179
+ findings.push({ severity: "GRAY", code: "missing-description", msg: `${pkg.name} has no description` });
180
+ }
181
+
182
+ return findings;
183
+ }
184
+
185
+ receipt(result) {
186
+ const [owner, name] = result.repo.split("/");
187
+ return {
188
+ schemaVersion: "1.0.0",
189
+ repo: { owner, name },
190
+ target: "nuget",
191
+ version: result.version,
192
+ packageName: result.name,
193
+ commitSha: result.commitSha,
194
+ timestamp: new Date().toISOString(),
195
+ artifacts: result.artifacts ?? [],
196
+ };
197
+ }
198
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * PyPI provider — audits Python packages on pypi.org.
3
+ *
4
+ * Uses the PyPI JSON API: https://pypi.org/pypi/<pkg>/json
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { Provider } from "../provider.mjs";
9
+
10
+ export default class PyPIProvider extends Provider {
11
+ get name() { return "pypi"; }
12
+
13
+ detect(entry) {
14
+ return entry.ecosystem === "pypi";
15
+ }
16
+
17
+ async audit(entry, ctx) {
18
+ const meta = this.#fetchMeta(entry.name);
19
+ const tags = ctx.tags.get(entry.repo) ?? [];
20
+ const releases = ctx.releases.get(entry.repo) ?? [];
21
+
22
+ if (!meta) {
23
+ return {
24
+ version: "?",
25
+ findings: [{ severity: "RED", code: "pypi-unreachable", msg: `Cannot reach ${entry.name} on PyPI` }],
26
+ };
27
+ }
28
+
29
+ const version = meta.info?.version ?? "?";
30
+ const findings = this.#classify(entry, meta, version, tags, releases);
31
+ return { version, findings };
32
+ }
33
+
34
+ // ─── Private ───────────────────────────────────────────────────────────────
35
+
36
+ #fetchMeta(pkg) {
37
+ try {
38
+ const url = `https://pypi.org/pypi/${pkg}/json`;
39
+ const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
40
+ return JSON.parse(raw);
41
+ } catch { return null; }
42
+ }
43
+
44
+ #classify(entry, meta, version, tags, releases) {
45
+ const findings = [];
46
+ const tagName = `v${version}`;
47
+
48
+ // Published-but-not-tagged (RED)
49
+ if (!tags.includes(tagName)) {
50
+ findings.push({
51
+ severity: "RED",
52
+ code: "published-not-tagged",
53
+ msg: `${entry.name}@${version} — no git tag ${tagName}`,
54
+ });
55
+ }
56
+
57
+ // Tagged-but-not-released (YELLOW for front-door)
58
+ if (tags.includes(tagName) && !releases.includes(tagName) && entry.audience === "front-door") {
59
+ findings.push({
60
+ severity: "YELLOW",
61
+ code: "tagged-not-released",
62
+ msg: `${entry.name} tag ${tagName} has no GitHub Release`,
63
+ });
64
+ }
65
+
66
+ // Description / summary
67
+ const summary = meta.info?.summary ?? "";
68
+ if (!summary) {
69
+ findings.push({
70
+ severity: entry.audience === "front-door" ? "YELLOW" : "GRAY",
71
+ code: "missing-description",
72
+ msg: `${entry.name} has no summary on PyPI`,
73
+ });
74
+ }
75
+
76
+ // Homepage / project URL
77
+ const projectUrl = meta.info?.home_page || meta.info?.project_urls?.Homepage || "";
78
+ if (!projectUrl) {
79
+ findings.push({
80
+ severity: "GRAY",
81
+ code: "missing-homepage",
82
+ msg: `${entry.name} has no homepage on PyPI`,
83
+ });
84
+ }
85
+
86
+ return findings;
87
+ }
88
+
89
+ receipt(result) {
90
+ const [owner, name] = result.repo.split("/");
91
+ return {
92
+ schemaVersion: "1.0.0",
93
+ repo: { owner, name },
94
+ target: "pypi",
95
+ version: result.version,
96
+ packageName: result.name,
97
+ commitSha: result.commitSha,
98
+ timestamp: new Date().toISOString(),
99
+ artifacts: result.artifacts ?? [],
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Receipt writer — validates and persists publish receipts.
3
+ *
4
+ * Receipts are immutable once written (append-only directory).
5
+ * Path: receipts/publish/<owner>--<name>/<target>/<version>.json
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, "..", "..");
14
+
15
+ const VALID_TARGETS = ["npm", "nuget", "pypi", "ghcr"];
16
+
17
+ /**
18
+ * Validate receipt data against the receipt schema (lightweight, no Ajv).
19
+ * @param {object} receipt
20
+ * @throws {Error} on validation failure
21
+ */
22
+ export function validate(receipt) {
23
+ if (!receipt || typeof receipt !== "object") throw new Error("Receipt must be an object");
24
+
25
+ // Required top-level fields
26
+ const required = ["schemaVersion", "repo", "target", "version", "packageName", "commitSha", "timestamp", "artifacts"];
27
+ for (const field of required) {
28
+ if (!(field in receipt)) throw new Error(`Receipt missing required field: ${field}`);
29
+ }
30
+
31
+ // schemaVersion
32
+ if (receipt.schemaVersion !== "1.0.0") {
33
+ throw new Error(`Unknown schemaVersion: ${receipt.schemaVersion}`);
34
+ }
35
+
36
+ // repo
37
+ if (typeof receipt.repo !== "object" || !receipt.repo.owner || !receipt.repo.name) {
38
+ throw new Error("repo must have owner and name");
39
+ }
40
+
41
+ // target
42
+ if (!VALID_TARGETS.includes(receipt.target)) {
43
+ throw new Error(`Invalid target: ${receipt.target} (expected one of ${VALID_TARGETS.join(", ")})`);
44
+ }
45
+
46
+ // version
47
+ if (typeof receipt.version !== "string" || !receipt.version) {
48
+ throw new Error("version must be a non-empty string");
49
+ }
50
+
51
+ // packageName
52
+ if (typeof receipt.packageName !== "string" || !receipt.packageName) {
53
+ throw new Error("packageName must be a non-empty string");
54
+ }
55
+
56
+ // commitSha — 40 hex chars
57
+ if (typeof receipt.commitSha !== "string" || !/^[0-9a-f]{40}$/.test(receipt.commitSha)) {
58
+ throw new Error("commitSha must be a 40-character lowercase hex string");
59
+ }
60
+
61
+ // timestamp — ISO 8601
62
+ if (typeof receipt.timestamp !== "string" || !receipt.timestamp) {
63
+ throw new Error("timestamp must be a non-empty string");
64
+ }
65
+
66
+ // artifacts
67
+ if (!Array.isArray(receipt.artifacts)) {
68
+ throw new Error("artifacts must be an array");
69
+ }
70
+ for (const art of receipt.artifacts) {
71
+ if (!art.name || typeof art.name !== "string") throw new Error("artifact.name required");
72
+ if (typeof art.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(art.sha256)) {
73
+ throw new Error(`Invalid artifact sha256: ${art.sha256}`);
74
+ }
75
+ if (typeof art.size !== "number" || art.size < 0 || !Number.isInteger(art.size)) {
76
+ throw new Error(`Invalid artifact size: ${art.size}`);
77
+ }
78
+ if (!art.url || typeof art.url !== "string") throw new Error("artifact.url required");
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Build the filesystem path for a receipt.
84
+ * @param {object} receipt
85
+ * @returns {string} Absolute path
86
+ */
87
+ function receiptPath(receipt) {
88
+ const slug = `${receipt.repo.owner}--${receipt.repo.name}`;
89
+ return join(ROOT, "receipts", "publish", slug, receipt.target, `${receipt.version}.json`);
90
+ }
91
+
92
+ /**
93
+ * Write a receipt to the immutable store.
94
+ * @param {object} receipt - Validated receipt data
95
+ * @returns {string} Absolute path of written receipt
96
+ * @throws {Error} if receipt already exists (immutability) or validation fails
97
+ */
98
+ export function write(receipt) {
99
+ validate(receipt);
100
+
101
+ const filePath = receiptPath(receipt);
102
+
103
+ if (existsSync(filePath)) {
104
+ throw new Error(`Receipt already exists (immutable): ${filePath}`);
105
+ }
106
+
107
+ mkdirSync(dirname(filePath), { recursive: true });
108
+ writeFileSync(filePath, JSON.stringify(receipt, null, 2) + "\n");
109
+ return filePath;
110
+ }
111
+
112
+ /**
113
+ * Read an existing receipt.
114
+ * @param {string} repoSlug - "owner--name"
115
+ * @param {string} target - "npm" | "nuget" | "pypi" | "ghcr"
116
+ * @param {string} version
117
+ * @returns {object|null}
118
+ */
119
+ export function read(repoSlug, target, version) {
120
+ const filePath = join(ROOT, "receipts", "publish", repoSlug, target, `${version}.json`);
121
+ if (!existsSync(filePath)) return null;
122
+ return JSON.parse(readFileSync(filePath, "utf8"));
123
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Provider registry — auto-discovers and validates providers.
3
+ *
4
+ * Scans scripts/lib/providers/*.mjs, imports each default export,
5
+ * and validates it extends Provider with required method overrides.
6
+ */
7
+
8
+ import { readdirSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import { Provider } from "./provider.mjs";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const PROVIDERS_DIR = join(__dirname, "providers");
15
+
16
+ const REQUIRED_METHODS = ["detect", "audit"];
17
+
18
+ /**
19
+ * Dynamically import all .mjs files from providers/ directory.
20
+ * Each must export default a class extending Provider.
21
+ * @returns {Promise<Provider[]>}
22
+ */
23
+ export async function loadProviders() {
24
+ const files = readdirSync(PROVIDERS_DIR).filter(f => f.endsWith(".mjs"));
25
+ const providers = [];
26
+
27
+ for (const file of files) {
28
+ const filePath = join(PROVIDERS_DIR, file);
29
+ const fileUrl = pathToFileURL(filePath).href;
30
+ const mod = await import(fileUrl);
31
+ const Ctor = mod.default;
32
+
33
+ if (!Ctor || typeof Ctor !== "function") {
34
+ throw new Error(`${file}: default export must be a class`);
35
+ }
36
+
37
+ // Verify it extends Provider
38
+ if (!(Ctor.prototype instanceof Provider)) {
39
+ throw new Error(`${file}: default export must extend Provider`);
40
+ }
41
+
42
+ const instance = new Ctor();
43
+
44
+ // Validate required methods are overridden (not the base class stubs)
45
+ for (const method of REQUIRED_METHODS) {
46
+ if (instance[method] === Provider.prototype[method]) {
47
+ throw new Error(`${file}: must override ${method}()`);
48
+ }
49
+ }
50
+
51
+ providers.push(instance);
52
+ }
53
+
54
+ return providers;
55
+ }
56
+
57
+ /**
58
+ * Given an entry, return the providers that claim it via detect().
59
+ * @param {Provider[]} providers
60
+ * @param {object} entry
61
+ * @returns {Provider[]}
62
+ */
63
+ export function matchProviders(providers, entry) {
64
+ return providers.filter(p => p.detect(entry));
65
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shell utilities for publish providers.
3
+ * Zero dependencies — uses node:child_process, node:crypto, node:fs.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { readFileSync, statSync } from "node:fs";
9
+
10
+ /**
11
+ * Execute a command and capture output.
12
+ * Does NOT throw on non-zero exit — caller decides what to do.
13
+ *
14
+ * @param {string} cmd - Shell command to run
15
+ * @param {object} [opts]
16
+ * @param {string} [opts.cwd] - Working directory
17
+ * @param {number} [opts.timeout] - Timeout in ms (default 120s)
18
+ * @param {object} [opts.env] - Extra environment variables (merged with process.env)
19
+ * @returns {{ stdout: string, stderr: string, exitCode: number }}
20
+ */
21
+ export function exec(cmd, opts = {}) {
22
+ try {
23
+ const stdout = execSync(cmd, {
24
+ encoding: "utf8",
25
+ timeout: opts.timeout ?? 120_000,
26
+ cwd: opts.cwd,
27
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ });
30
+ return { stdout, stderr: "", exitCode: 0 };
31
+ } catch (e) {
32
+ return {
33
+ stdout: e.stdout ?? "",
34
+ stderr: e.stderr ?? e.message,
35
+ exitCode: e.status ?? 1,
36
+ };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Compute SHA-256 hash and size of a file.
42
+ *
43
+ * @param {string} filePath - Absolute path to file
44
+ * @returns {{ sha256: string, size: number }}
45
+ */
46
+ export function hashFile(filePath) {
47
+ const data = readFileSync(filePath);
48
+ const sha256 = createHash("sha256").update(data).digest("hex");
49
+ const { size } = statSync(filePath);
50
+ return { sha256, size };
51
+ }
52
+
53
+ /**
54
+ * Get the current HEAD commit SHA (40 lowercase hex chars).
55
+ *
56
+ * @param {string} [cwd] - Working directory (defaults to process.cwd())
57
+ * @returns {string}
58
+ * @throws {Error} if not in a git repo
59
+ */
60
+ export function getCommitSha(cwd) {
61
+ const { stdout, exitCode } = exec("git rev-parse HEAD", { cwd });
62
+ if (exitCode !== 0) throw new Error("Not in a git repo or git not available");
63
+ return stdout.trim();
64
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Named exit codes for CI-friendly CLI behavior.
3
+ *
4
+ * 0 — success
5
+ * 1 — reserved for uncaught exceptions (Node default)
6
+ * 2 — drift found (audit found RED-severity issues)
7
+ * 3 — config or schema error
8
+ * 4 — missing credentials for a requested operation
9
+ * 5 — one or more publishes failed
10
+ */
11
+ export const EXIT = {
12
+ SUCCESS: 0,
13
+ DRIFT_FOUND: 2,
14
+ CONFIG_ERROR: 3,
15
+ MISSING_CREDENTIALS: 4,
16
+ PUBLISH_FAILURE: 5,
17
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Help text for the mcpt-publishing CLI.
3
+ */
4
+
5
+ export const GLOBAL_HELP = `
6
+ mcpt-publishing — A human-first publishing house for your repos.
7
+
8
+ Usage:
9
+ mcpt-publishing <command> [flags]
10
+
11
+ Commands:
12
+ audit Run publishing health audit across all registries
13
+ init Scaffold publishing.config.json and starter manifest
14
+ publish Publish packages to registries with receipts
15
+ providers List registered providers and their status
16
+ verify-receipt Validate a receipt file (schema + integrity)
17
+ plan Dry-run publish plan [coming soon]
18
+
19
+ Global flags:
20
+ --help Show help for a command
21
+ --version Show version
22
+ --json Machine-readable output (supported by all commands)
23
+
24
+ Examples:
25
+ mcpt-publishing audit # audit all packages
26
+ mcpt-publishing audit --json # JSON output to stdout
27
+ mcpt-publishing publish --target npm --dry-run # dry-run npm publish
28
+ mcpt-publishing verify-receipt receipts/audit/2026-02-17.json
29
+ mcpt-publishing providers # list providers + env vars
30
+
31
+ Environment:
32
+ PUBLISHING_CONFIG Path to publishing.config.json (overrides walk-up discovery)
33
+ GH_TOKEN GitHub token for API access (tags, releases, issues)
34
+ NPM_TOKEN npm publish token (granular, publish rights)
35
+ NUGET_API_KEY NuGet API key
36
+
37
+ Docs: https://github.com/mcp-tool-shop/mcpt-publishing
38
+ `.trim();