@yawlabs/mcph 0.39.0 → 0.41.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/index.js +437 -175
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `@yawlabs/mcph` are documented here. This project uses [semantic versioning](https://semver.org) and a CI-gated release flow: pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which publishes to npm.
4
4
 
5
+ ## 0.41.0 — 2026-04-18
6
+
7
+ - **`mcph doctor --json` — machine-readable diagnostic output** — Doctor already tracks a lot of state (config files, token source, env overrides, persisted learning, installed clients, shell-history shadow hits, upgrade availability, diagnosis summary) and the text output optimises for pasting into a support ticket. `--json` emits the same data as a single structured blob so dashboards, CI scripts, and support tooling can pick fields with `jq` instead of parsing the text layout. Token is fingerprinted the same way in both modes (never raw). Section data is 1:1 with the text renderer: config (token/apiBase/loadedFiles/warnings), env overrides (null when unset), state (path/savedAt/entries; `disabled: true` when `MCPH_DISABLE_PERSISTENCE` is set), reliability (same `selectFlakyNamespaces` rollup that `mcp_connect_health` and the text RELIABILITY section use), clients probe results, shell shadow hits, upgrade info, and the exit-code diagnosis. Completes the `--json` pattern across `servers`, `bundles`, and now `doctor` — every CLI that reads state has a pipeline mode.
8
+
9
+ ## 0.40.0 — 2026-04-18
10
+
11
+ - **`mcph bundles` CLI subcommand** — CLI counterpart to the `mcp_connect_bundles` meta-tool (v0.28.0). Two actions mirror the meta-tool's `action` parameter: `list` prints every curated bundle grouped by category with activate hints (static, no network, no token needed — good for browsing or sharing in onboarding docs), and `match` partitions the curated set against the user's enabled servers from the backend into ready-to-activate vs partially-installed, so a human can see in the terminal what the LLM-facing tool would suggest. The LLM tool has always been primary surface, but "what bundles exist?" is a frequent enough support question that surfacing them in the CLI earns its keep. Match only counts `isActive: true` servers — disabled ones don't auto-activate, so they shouldn't count toward "ready" — matching the LLM tool's filter so both surfaces agree. Partial bundles sort fewest-missing first to match the discover inline hint ranking. `--json` emits machine-readable output (`{bundles}` for list, `{installed, ready, partial}` for match). Exit codes: 0 success, 1 match needs a token and none resolved, 2 match couldn't reach the backend.
12
+
5
13
  ## 0.39.0 — 2026-04-18
6
14
 
7
15
  - **`mcph servers` CLI subcommand** — Lists the servers currently configured for your account in the mcp.hosting dashboard, hitting the same `/api/connect/config` endpoint that `runServer` polls at startup. Fills a gap between `mcph doctor` (local state: config files, clients, state.json) and the web dashboard: users can sanity-check their dashboard edits from the terminal, support engineers can ask for `mcph servers --json` output in a ticket, and scripts can pick a namespace up-front before piping into `mcph compliance` or `mcph install`. Table view groups the relevant columns (namespace, name, type, enabled/disabled, compliance grade, cached tool count) and is sorted alphabetically by namespace for diffable re-runs; `--json` emits the raw backend response verbatim. Exit codes: 0 success, 1 no token, 2 fetch error.
package/dist/index.js CHANGED
@@ -1,104 +1,76 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/compliance-cmd.ts
4
- import { spawn } from "child_process";
5
- import { request } from "undici";
6
- async function runComplianceCommand(argv) {
7
- const publish = argv.includes("--publish");
8
- const args = argv.filter((a) => a !== "--publish");
9
- if (args.length === 0) {
10
- process.stderr.write(
11
- '\n Usage: mcph compliance <target> [extraArgs...] [--publish]\n\n Examples:\n mcph compliance "npx -y @modelcontextprotocol/server-filesystem /tmp"\n mcph compliance https://example.com/mcp --publish\n\n'
12
- );
13
- return 1;
3
+ // src/bundles.ts
4
+ var CURATED_BUNDLES = [
5
+ {
6
+ id: "devops-incident",
7
+ name: "DevOps Incident Triage",
8
+ description: "GitHub + PagerDuty + Slack for on-call triage",
9
+ namespaces: ["github", "pagerduty", "slack"],
10
+ category: "ops"
11
+ },
12
+ {
13
+ id: "pr-review",
14
+ name: "PR Review",
15
+ description: "GitHub + Linear for issue-to-PR traceability",
16
+ namespaces: ["github", "linear"],
17
+ category: "dev"
18
+ },
19
+ {
20
+ id: "growth-stack",
21
+ name: "Growth Stack",
22
+ description: "HubSpot + Slack + GA for lifecycle + funnel signals",
23
+ namespaces: ["hubspot", "slack", "ga"],
24
+ category: "growth"
25
+ },
26
+ {
27
+ id: "data-ops",
28
+ name: "Data Ops",
29
+ description: "Postgres + S3 + Snowflake for pipeline debugging",
30
+ namespaces: ["postgres", "s3", "snowflake"],
31
+ category: "data"
32
+ },
33
+ {
34
+ id: "product-release",
35
+ name: "Product Release",
36
+ description: "GitHub + Linear + Slack for ship-day coordination",
37
+ namespaces: ["github", "linear", "slack"],
38
+ category: "dev"
39
+ },
40
+ {
41
+ id: "support-ops",
42
+ name: "Support Ops",
43
+ description: "Zendesk + Slack + HubSpot for escalation handoffs",
44
+ namespaces: ["zendesk", "slack", "hubspot"],
45
+ category: "ops"
14
46
  }
15
- const apiUrl6 = process.env.MCPH_URL ?? "https://mcp.hosting";
16
- const report = await runTest(args);
17
- if (!report) return 1;
18
- printSummary(report);
19
- if (publish) {
20
- const result = await publishReport(apiUrl6, report);
21
- if (!result) return 1;
22
- process.stdout.write(`
23
- Published: ${result.reportUrl}
24
- `);
25
- process.stdout.write(`Badge: ${result.badgeUrl}
26
- `);
27
- if (result.deleteToken) {
28
- process.stdout.write(`
29
- Delete token (save this): ${result.deleteToken}
30
- `);
47
+ ];
48
+ function matchBundles(installedNamespaces) {
49
+ const installed = new Set(installedNamespaces);
50
+ const ready = [];
51
+ const partial = [];
52
+ for (const bundle of CURATED_BUNDLES) {
53
+ const have = bundle.namespaces.filter((ns) => installed.has(ns));
54
+ const missing = bundle.namespaces.filter((ns) => !installed.has(ns));
55
+ if (missing.length === 0) {
56
+ ready.push(bundle);
57
+ } else if (have.length > 0) {
58
+ partial.push({ bundle, have, missing });
31
59
  }
32
60
  }
33
- return 0;
34
- }
35
- function runTest(args) {
36
- return new Promise((resolve4) => {
37
- const child = spawn("npx", ["-y", "@yawlabs/mcp-compliance", "test", "--format", "json", ...args], {
38
- stdio: ["ignore", "pipe", "inherit"],
39
- shell: process.platform === "win32"
40
- });
41
- let stdout = "";
42
- child.stdout.on("data", (chunk) => {
43
- stdout += chunk.toString();
44
- });
45
- child.on("error", (err) => {
46
- process.stderr.write(`
47
- Failed to launch mcp-compliance: ${err.message}
48
- `);
49
- resolve4(null);
50
- });
51
- child.on("close", (code) => {
52
- try {
53
- const parsed = JSON.parse(stdout);
54
- if (!parsed.grade || !parsed.summary) {
55
- process.stderr.write(`
56
- mcp-compliance returned unexpected JSON (exit ${code}).
57
- `);
58
- resolve4(null);
59
- return;
60
- }
61
- resolve4(parsed);
62
- } catch {
63
- process.stderr.write(`
64
- mcp-compliance exited ${code} without valid JSON output.
65
- `);
66
- resolve4(null);
67
- }
68
- });
69
- });
61
+ return { ready, partial };
70
62
  }
71
- function printSummary(report) {
72
- const { grade, score, summary, url } = report;
73
- process.stdout.write(
74
- `
75
- Compliance: ${grade} (${score.toFixed(1)}%) \u2014 ${summary.passed}/${summary.total} passed, ${summary.requiredPassed}/${summary.required} required
76
- Target: ${url}
77
- `
78
- );
63
+ function bundleActivateHint(bundle) {
64
+ return `mcp_connect_activate({ namespaces: ${JSON.stringify(bundle.namespaces)} })`;
79
65
  }
80
- async function publishReport(apiUrl6, report) {
81
- try {
82
- const res = await request(`${apiUrl6.replace(/\/$/, "")}/api/compliance/ext`, {
83
- method: "POST",
84
- headers: { "Content-Type": "application/json" },
85
- body: JSON.stringify(report)
86
- });
87
- if (res.statusCode !== 200) {
88
- const body = await res.body.text().catch(() => "");
89
- process.stderr.write(`
90
- Publish failed: HTTP ${res.statusCode}${body ? ` \u2014 ${body}` : ""}
91
- `);
92
- return null;
93
- }
94
- const parsed = await res.body.json();
95
- return parsed;
96
- } catch (err) {
97
- process.stderr.write(`
98
- Publish failed: ${err?.message ?? String(err)}
99
- `);
100
- return null;
101
- }
66
+ function topPartialBundles(installedNamespaces, limit) {
67
+ if (limit <= 0) return [];
68
+ const { partial } = matchBundles(installedNamespaces);
69
+ return partial.slice().sort((a, b) => {
70
+ if (a.missing.length !== b.missing.length) return a.missing.length - b.missing.length;
71
+ if (a.have.length !== b.have.length) return b.have.length - a.have.length;
72
+ return a.bundle.id.localeCompare(b.bundle.id);
73
+ }).slice(0, limit);
102
74
  }
103
75
 
104
76
  // src/config-loader.ts
@@ -468,7 +440,7 @@ function profileAllows(profile, namespace) {
468
440
  }
469
441
 
470
442
  // src/config.ts
471
- import { request as request2 } from "undici";
443
+ import { request } from "undici";
472
444
  async function fetchConfig(apiUrl6, token6, currentVersion) {
473
445
  const url = `${apiUrl6.replace(/\/$/, "")}/api/connect/config`;
474
446
  const headers = {
@@ -478,7 +450,7 @@ async function fetchConfig(apiUrl6, token6, currentVersion) {
478
450
  if (currentVersion) {
479
451
  headers["If-None-Match"] = `"${currentVersion}"`;
480
452
  }
481
- const res = await request2(url, {
453
+ const res = await request(url, {
482
454
  method: "GET",
483
455
  headers,
484
456
  headersTimeout: 1e4,
@@ -534,6 +506,249 @@ var ConfigError = class extends Error {
534
506
  fatal;
535
507
  };
536
508
 
509
+ // src/bundles-cmd.ts
510
+ var BUNDLES_USAGE = `Usage: mcph bundles [list|match] [--json]
511
+
512
+ Curated multi-server bundles \u2014 hand-picked stacks you can activate in one step.
513
+
514
+ list List every curated bundle (default, no network).
515
+ match Partition bundles against your installed servers (reads the backend).
516
+
517
+ --json Emit machine-readable JSON instead of a table.`;
518
+ function parseBundlesArgs(argv) {
519
+ let action = "list";
520
+ let json = false;
521
+ let actionSet = false;
522
+ for (const a of argv) {
523
+ if (a === "--json") {
524
+ json = true;
525
+ } else if (a === "--help" || a === "-h") {
526
+ return { ok: false, error: BUNDLES_USAGE };
527
+ } else if (a === "list" || a === "match") {
528
+ if (actionSet) {
529
+ return { ok: false, error: `mcph bundles: action already set to "${action}" (got "${a}")
530
+
531
+ ${BUNDLES_USAGE}` };
532
+ }
533
+ action = a;
534
+ actionSet = true;
535
+ } else {
536
+ return { ok: false, error: `mcph bundles: unknown argument "${a}"
537
+
538
+ ${BUNDLES_USAGE}` };
539
+ }
540
+ }
541
+ return { ok: true, options: { action, json } };
542
+ }
543
+ async function runBundlesCommand(opts = {}) {
544
+ const write = opts.out ?? ((s) => process.stdout.write(s));
545
+ const writeErr = opts.err ?? ((s) => process.stderr.write(s));
546
+ const lines = [];
547
+ const print = (s = "") => {
548
+ lines.push(s);
549
+ write(`${s}
550
+ `);
551
+ };
552
+ const printErr = (s) => {
553
+ lines.push(s);
554
+ writeErr(`${s}
555
+ `);
556
+ };
557
+ const action = opts.action ?? "list";
558
+ if (action === "list") {
559
+ if (opts.json) {
560
+ print(JSON.stringify({ bundles: CURATED_BUNDLES }, null, 2));
561
+ } else {
562
+ renderList(print);
563
+ }
564
+ return { exitCode: 0, lines };
565
+ }
566
+ const config = await loadMcphConfig({
567
+ cwd: opts.cwd,
568
+ home: opts.home,
569
+ env: opts.env
570
+ });
571
+ if (!config.token) {
572
+ printErr("mcph bundles match: no token resolved. Run `mcph install <client> --token mcp_pat_\u2026` or set MCPH_TOKEN.");
573
+ return { exitCode: 1, lines };
574
+ }
575
+ const fetcher = opts.fetcher ?? fetchConfig;
576
+ let backend;
577
+ try {
578
+ backend = await fetcher(config.apiBase, config.token);
579
+ } catch (err) {
580
+ const msg = err instanceof ConfigError || err instanceof Error ? err.message : String(err);
581
+ printErr(`mcph bundles match: ${msg}`);
582
+ return { exitCode: 2, lines };
583
+ }
584
+ if (!backend) {
585
+ printErr("mcph bundles match: backend returned no data (unexpected 304).");
586
+ return { exitCode: 2, lines };
587
+ }
588
+ const installed = backend.servers.filter((s) => s.isActive).map((s) => s.namespace);
589
+ const match = matchBundles(installed);
590
+ if (opts.json) {
591
+ print(JSON.stringify({ installed, ...match }, null, 2));
592
+ return { exitCode: 0, lines };
593
+ }
594
+ renderMatch(match, installed, print);
595
+ return { exitCode: 0, lines };
596
+ }
597
+ function renderList(print) {
598
+ print(`${CURATED_BUNDLES.length} curated bundles`);
599
+ print("");
600
+ const byCategory = /* @__PURE__ */ new Map();
601
+ for (const b of CURATED_BUNDLES) {
602
+ const list = byCategory.get(b.category) ?? [];
603
+ list.push(b);
604
+ byCategory.set(b.category, list);
605
+ }
606
+ const categories = [...byCategory.keys()].sort();
607
+ for (const cat of categories) {
608
+ const list = (byCategory.get(cat) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id));
609
+ print(` [${cat}]`);
610
+ for (const b of list) {
611
+ print(` ${b.id.padEnd(18)} ${b.name}`);
612
+ print(` ${b.description}`);
613
+ print(` \u2192 ${bundleActivateHint(b)}`);
614
+ }
615
+ print("");
616
+ }
617
+ }
618
+ function renderMatch(match, installed, print) {
619
+ const installedList = installed.length === 0 ? "(none)" : installed.slice().sort().join(", ");
620
+ print(`Checked ${CURATED_BUNDLES.length} bundles against ${installed.length} enabled servers: ${installedList}`);
621
+ print("");
622
+ if (match.ready.length === 0 && match.partial.length === 0) {
623
+ print("No curated bundles match your current config.");
624
+ print("Run `mcph bundles list` to see the full catalog.");
625
+ return;
626
+ }
627
+ if (match.ready.length > 0) {
628
+ print("Ready to activate (every namespace installed):");
629
+ for (const b of match.ready.slice().sort((a, c) => a.id.localeCompare(c.id))) {
630
+ print(` ${b.id.padEnd(18)} ${b.description}`);
631
+ print(` \u2192 ${bundleActivateHint(b)}`);
632
+ }
633
+ print("");
634
+ }
635
+ if (match.partial.length > 0) {
636
+ const sorted = match.partial.slice().sort((a, b) => {
637
+ if (a.missing.length !== b.missing.length) return a.missing.length - b.missing.length;
638
+ if (a.have.length !== b.have.length) return b.have.length - a.have.length;
639
+ return a.bundle.id.localeCompare(b.bundle.id);
640
+ });
641
+ print("Partially installed (install more to complete):");
642
+ for (const entry of sorted) {
643
+ const have = entry.have.join(", ");
644
+ const missing = entry.missing.join(", ");
645
+ print(` ${entry.bundle.id.padEnd(18)} have: ${have}; missing: ${missing}`);
646
+ }
647
+ print("");
648
+ }
649
+ }
650
+
651
+ // src/compliance-cmd.ts
652
+ import { spawn } from "child_process";
653
+ import { request as request2 } from "undici";
654
+ async function runComplianceCommand(argv) {
655
+ const publish = argv.includes("--publish");
656
+ const args = argv.filter((a) => a !== "--publish");
657
+ if (args.length === 0) {
658
+ process.stderr.write(
659
+ '\n Usage: mcph compliance <target> [extraArgs...] [--publish]\n\n Examples:\n mcph compliance "npx -y @modelcontextprotocol/server-filesystem /tmp"\n mcph compliance https://example.com/mcp --publish\n\n'
660
+ );
661
+ return 1;
662
+ }
663
+ const apiUrl6 = process.env.MCPH_URL ?? "https://mcp.hosting";
664
+ const report = await runTest(args);
665
+ if (!report) return 1;
666
+ printSummary(report);
667
+ if (publish) {
668
+ const result = await publishReport(apiUrl6, report);
669
+ if (!result) return 1;
670
+ process.stdout.write(`
671
+ Published: ${result.reportUrl}
672
+ `);
673
+ process.stdout.write(`Badge: ${result.badgeUrl}
674
+ `);
675
+ if (result.deleteToken) {
676
+ process.stdout.write(`
677
+ Delete token (save this): ${result.deleteToken}
678
+ `);
679
+ }
680
+ }
681
+ return 0;
682
+ }
683
+ function runTest(args) {
684
+ return new Promise((resolve4) => {
685
+ const child = spawn("npx", ["-y", "@yawlabs/mcp-compliance", "test", "--format", "json", ...args], {
686
+ stdio: ["ignore", "pipe", "inherit"],
687
+ shell: process.platform === "win32"
688
+ });
689
+ let stdout = "";
690
+ child.stdout.on("data", (chunk) => {
691
+ stdout += chunk.toString();
692
+ });
693
+ child.on("error", (err) => {
694
+ process.stderr.write(`
695
+ Failed to launch mcp-compliance: ${err.message}
696
+ `);
697
+ resolve4(null);
698
+ });
699
+ child.on("close", (code) => {
700
+ try {
701
+ const parsed = JSON.parse(stdout);
702
+ if (!parsed.grade || !parsed.summary) {
703
+ process.stderr.write(`
704
+ mcp-compliance returned unexpected JSON (exit ${code}).
705
+ `);
706
+ resolve4(null);
707
+ return;
708
+ }
709
+ resolve4(parsed);
710
+ } catch {
711
+ process.stderr.write(`
712
+ mcp-compliance exited ${code} without valid JSON output.
713
+ `);
714
+ resolve4(null);
715
+ }
716
+ });
717
+ });
718
+ }
719
+ function printSummary(report) {
720
+ const { grade, score, summary, url } = report;
721
+ process.stdout.write(
722
+ `
723
+ Compliance: ${grade} (${score.toFixed(1)}%) \u2014 ${summary.passed}/${summary.total} passed, ${summary.requiredPassed}/${summary.required} required
724
+ Target: ${url}
725
+ `
726
+ );
727
+ }
728
+ async function publishReport(apiUrl6, report) {
729
+ try {
730
+ const res = await request2(`${apiUrl6.replace(/\/$/, "")}/api/compliance/ext`, {
731
+ method: "POST",
732
+ headers: { "Content-Type": "application/json" },
733
+ body: JSON.stringify(report)
734
+ });
735
+ if (res.statusCode !== 200) {
736
+ const body = await res.body.text().catch(() => "");
737
+ process.stderr.write(`
738
+ Publish failed: HTTP ${res.statusCode}${body ? ` \u2014 ${body}` : ""}
739
+ `);
740
+ return null;
741
+ }
742
+ const parsed = await res.body.json();
743
+ return parsed;
744
+ } catch (err) {
745
+ process.stderr.write(`
746
+ Publish failed: ${err?.message ?? String(err)}
747
+ `);
748
+ return null;
749
+ }
750
+ }
751
+
537
752
  // src/doctor-cmd.ts
538
753
  import { existsSync, readFileSync, statSync } from "fs";
539
754
  import { readFile as readFile3 } from "fs/promises";
@@ -1004,8 +1219,9 @@ function selectFlakyNamespaces(entries, limit) {
1004
1219
  }
1005
1220
 
1006
1221
  // src/doctor-cmd.ts
1007
- var VERSION = true ? "0.39.0" : "dev";
1222
+ var VERSION = true ? "0.41.0" : "dev";
1008
1223
  async function runDoctor(opts = {}) {
1224
+ if (opts.json) return runDoctorJson(opts);
1009
1225
  const lines = [];
1010
1226
  const write = opts.out ?? ((s) => process.stdout.write(s));
1011
1227
  const print = (s = "") => {
@@ -1094,6 +1310,101 @@ async function runDoctor(opts = {}) {
1094
1310
  }
1095
1311
  return { exitCode, lines, snapshot: { version: VERSION, config, clients } };
1096
1312
  }
1313
+ async function runDoctorJson(opts) {
1314
+ const lines = [];
1315
+ const write = opts.out ?? ((s) => process.stdout.write(s));
1316
+ const cwd = opts.cwd ?? process.cwd();
1317
+ const home = opts.home ?? homedir4();
1318
+ const os = opts.os ?? CURRENT_OS;
1319
+ const env = opts.env ?? process.env;
1320
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1321
+ const config = await loadMcphConfig({ cwd, home, env });
1322
+ const clients = probeClients({ home, os, cwd });
1323
+ const envVarNames = [
1324
+ "MCPH_POLL_INTERVAL",
1325
+ "MCPH_SERVER_CAP",
1326
+ "MCPH_MIN_COMPLIANCE",
1327
+ "MCPH_AUTO_LOAD",
1328
+ "MCPH_PRUNE_RESPONSES"
1329
+ ];
1330
+ const envOverrides = {};
1331
+ for (const name of envVarNames) {
1332
+ const raw = env[name];
1333
+ envOverrides[name] = raw === void 0 || raw === "" ? null : raw;
1334
+ }
1335
+ const persistRaw = env.MCPH_DISABLE_PERSISTENCE;
1336
+ const persistDisabled = persistRaw !== void 0 && persistRaw !== "" && (persistRaw === "1" || persistRaw.toLowerCase() === "true");
1337
+ const state = persistDisabled ? { disabled: true, path: null, savedAt: null, learningEntries: null, packHistoryEntries: null } : await (async () => {
1338
+ const filePath = join4(userConfigDir(home), STATE_FILENAME);
1339
+ const persisted = await loadState(filePath);
1340
+ const fresh = persisted.savedAt === 0;
1341
+ return {
1342
+ disabled: false,
1343
+ path: filePath,
1344
+ savedAt: fresh ? null : new Date(persisted.savedAt).toISOString(),
1345
+ learningEntries: fresh ? 0 : Object.keys(persisted.learning).length,
1346
+ packHistoryEntries: fresh ? 0 : persisted.packHistory.length
1347
+ };
1348
+ })();
1349
+ const reliability = [];
1350
+ if (!persistDisabled) {
1351
+ const filePath = join4(userConfigDir(home), STATE_FILENAME);
1352
+ const persisted = await loadState(filePath);
1353
+ if (persisted.savedAt !== 0) {
1354
+ const entries = Object.entries(persisted.learning).map(([namespace, usage]) => ({ namespace, usage }));
1355
+ for (const { namespace, usage } of selectFlakyNamespaces(entries, 5)) {
1356
+ reliability.push({
1357
+ namespace,
1358
+ dispatched: usage.dispatched,
1359
+ succeeded: usage.succeeded,
1360
+ successRate: usage.succeeded / usage.dispatched,
1361
+ lastUsedAt: new Date(usage.lastUsedAt).toISOString()
1362
+ });
1363
+ }
1364
+ }
1365
+ }
1366
+ const shellShadows = scanShellHistoryForShadows({ home, env });
1367
+ const skipCheck = opts.skipRegistryCheck === true || Boolean(process.env.VITEST);
1368
+ const latest = skipCheck ? null : await fetchLatestVersion(opts.registryFetch);
1369
+ const stale = latest !== null && VERSION !== "dev" && compareSemver(VERSION, latest) < 0;
1370
+ let exitCode = 0;
1371
+ let summary;
1372
+ if (config.token === null) {
1373
+ exitCode = 1;
1374
+ summary = "No token resolved \u2014 mcph cannot start.";
1375
+ } else if (config.warnings.length > 0) {
1376
+ exitCode = 2;
1377
+ summary = "Token present, but warnings need attention.";
1378
+ } else {
1379
+ summary = stale ? "Healthy, but an upgrade is available." : "All good. mcph should start cleanly.";
1380
+ }
1381
+ const snapshotJson = {
1382
+ timestamp,
1383
+ version: VERSION,
1384
+ platform: os,
1385
+ token: { fingerprint: tokenFingerprint(config.token), source: config.tokenSource },
1386
+ apiBase: { value: config.apiBase, source: config.apiBaseSource },
1387
+ loadedFiles: config.loadedFiles.map((f) => ({
1388
+ scope: f.scope,
1389
+ path: f.path,
1390
+ ...f.version !== void 0 ? { schemaVersion: f.version } : {},
1391
+ schemaAhead: f.version !== void 0 && f.version > CURRENT_SCHEMA_VERSION
1392
+ })),
1393
+ warnings: config.warnings,
1394
+ env: envOverrides,
1395
+ state,
1396
+ reliability,
1397
+ clients,
1398
+ shellShadows,
1399
+ upgrade: { current: VERSION, latest, stale },
1400
+ diagnosis: { exitCode, summary }
1401
+ };
1402
+ const blob = JSON.stringify(snapshotJson, null, 2);
1403
+ lines.push(blob);
1404
+ write(`${blob}
1405
+ `);
1406
+ return { exitCode, lines, snapshot: { version: VERSION, config, clients } };
1407
+ }
1097
1408
  function renderEnvSection(opts) {
1098
1409
  const { env, print } = opts;
1099
1410
  const vars = [
@@ -1965,79 +2276,6 @@ async function shutdownAnalytics() {
1965
2276
  dispatchBuffer.length = 0;
1966
2277
  }
1967
2278
 
1968
- // src/bundles.ts
1969
- var CURATED_BUNDLES = [
1970
- {
1971
- id: "devops-incident",
1972
- name: "DevOps Incident Triage",
1973
- description: "GitHub + PagerDuty + Slack for on-call triage",
1974
- namespaces: ["github", "pagerduty", "slack"],
1975
- category: "ops"
1976
- },
1977
- {
1978
- id: "pr-review",
1979
- name: "PR Review",
1980
- description: "GitHub + Linear for issue-to-PR traceability",
1981
- namespaces: ["github", "linear"],
1982
- category: "dev"
1983
- },
1984
- {
1985
- id: "growth-stack",
1986
- name: "Growth Stack",
1987
- description: "HubSpot + Slack + GA for lifecycle + funnel signals",
1988
- namespaces: ["hubspot", "slack", "ga"],
1989
- category: "growth"
1990
- },
1991
- {
1992
- id: "data-ops",
1993
- name: "Data Ops",
1994
- description: "Postgres + S3 + Snowflake for pipeline debugging",
1995
- namespaces: ["postgres", "s3", "snowflake"],
1996
- category: "data"
1997
- },
1998
- {
1999
- id: "product-release",
2000
- name: "Product Release",
2001
- description: "GitHub + Linear + Slack for ship-day coordination",
2002
- namespaces: ["github", "linear", "slack"],
2003
- category: "dev"
2004
- },
2005
- {
2006
- id: "support-ops",
2007
- name: "Support Ops",
2008
- description: "Zendesk + Slack + HubSpot for escalation handoffs",
2009
- namespaces: ["zendesk", "slack", "hubspot"],
2010
- category: "ops"
2011
- }
2012
- ];
2013
- function matchBundles(installedNamespaces) {
2014
- const installed = new Set(installedNamespaces);
2015
- const ready = [];
2016
- const partial = [];
2017
- for (const bundle of CURATED_BUNDLES) {
2018
- const have = bundle.namespaces.filter((ns) => installed.has(ns));
2019
- const missing = bundle.namespaces.filter((ns) => !installed.has(ns));
2020
- if (missing.length === 0) {
2021
- ready.push(bundle);
2022
- } else if (have.length > 0) {
2023
- partial.push({ bundle, have, missing });
2024
- }
2025
- }
2026
- return { ready, partial };
2027
- }
2028
- function bundleActivateHint(bundle) {
2029
- return `mcp_connect_activate({ namespaces: ${JSON.stringify(bundle.namespaces)} })`;
2030
- }
2031
- function topPartialBundles(installedNamespaces, limit) {
2032
- if (limit <= 0) return [];
2033
- const { partial } = matchBundles(installedNamespaces);
2034
- return partial.slice().sort((a, b) => {
2035
- if (a.missing.length !== b.missing.length) return a.missing.length - b.missing.length;
2036
- if (a.have.length !== b.have.length) return b.have.length - a.have.length;
2037
- return a.bundle.id.localeCompare(b.bundle.id);
2038
- }).slice(0, limit);
2039
- }
2040
-
2041
2279
  // src/compliance.ts
2042
2280
  var GRADE_ORDER = {
2043
2281
  A: 4,
@@ -4065,7 +4303,7 @@ function categorizeSpawnError(err) {
4065
4303
  }
4066
4304
  async function connectToUpstream(config, onDisconnect, onListChanged) {
4067
4305
  const client = new Client(
4068
- { name: "mcph", version: true ? "0.39.0" : "dev" },
4306
+ { name: "mcph", version: true ? "0.41.0" : "dev" },
4069
4307
  { capabilities: {} }
4070
4308
  );
4071
4309
  let transport;
@@ -4546,7 +4784,7 @@ var ConnectServer = class _ConnectServer {
4546
4784
  this.apiUrl = apiUrl6;
4547
4785
  this.token = token6;
4548
4786
  this.server = new Server(
4549
- { name: "mcph", version: true ? "0.39.0" : "dev" },
4787
+ { name: "mcph", version: true ? "0.41.0" : "dev" },
4550
4788
  {
4551
4789
  capabilities: {
4552
4790
  tools: { listChanged: true },
@@ -6709,6 +6947,7 @@ var KNOWN_SUBCOMMANDS = [
6709
6947
  "doctor",
6710
6948
  "reset-learning",
6711
6949
  "servers",
6950
+ "bundles",
6712
6951
  "help",
6713
6952
  "--help",
6714
6953
  "-h",
@@ -6727,7 +6966,21 @@ if (subcommand === "compliance") {
6727
6966
  }
6728
6967
  runInstall(parsed.options).then((r) => process.exit(r.exitCode));
6729
6968
  } else if (subcommand === "doctor") {
6730
- runDoctor().then((r) => process.exit(r.exitCode));
6969
+ const doctorArgs = process.argv.slice(3);
6970
+ const doctorJson = doctorArgs.includes("--json");
6971
+ const doctorUnknown = doctorArgs.find((a) => a !== "--json" && a !== "--help" && a !== "-h");
6972
+ if (doctorArgs.includes("--help") || doctorArgs.includes("-h")) {
6973
+ process.stdout.write(
6974
+ "Usage: mcph doctor [--json]\n\n Print a diagnostic of your mcph setup.\n\n --json Emit machine-readable JSON instead of text.\n"
6975
+ );
6976
+ process.exit(0);
6977
+ }
6978
+ if (doctorUnknown) {
6979
+ process.stderr.write(`mcph doctor: unknown argument "${doctorUnknown}"
6980
+ `);
6981
+ process.exit(2);
6982
+ }
6983
+ runDoctor({ json: doctorJson }).then((r) => process.exit(r.exitCode));
6731
6984
  } else if (subcommand === "reset-learning") {
6732
6985
  runResetLearning().then((r) => process.exit(r.exitCode));
6733
6986
  } else if (subcommand === "servers") {
@@ -6738,6 +6991,14 @@ if (subcommand === "compliance") {
6738
6991
  process.exit(2);
6739
6992
  }
6740
6993
  runServersCommand(parsed.options).then((r) => process.exit(r.exitCode));
6994
+ } else if (subcommand === "bundles") {
6995
+ const parsed = parseBundlesArgs(process.argv.slice(3));
6996
+ if (!parsed.ok) {
6997
+ process.stderr.write(`${parsed.error}
6998
+ `);
6999
+ process.exit(2);
7000
+ }
7001
+ runBundlesCommand(parsed.options).then((r) => process.exit(r.exitCode));
6741
7002
  } else if (subcommand === "--help" || subcommand === "-h" || subcommand === "help") {
6742
7003
  const installBlock = ` ${INSTALL_USAGE.replace(/^Usage: /, "").replace(/\n/g, "\n ")}`;
6743
7004
  process.stdout.write(
@@ -6747,8 +7008,9 @@ if (subcommand === "compliance") {
6747
7008
  Usage:
6748
7009
  mcph Run as MCP server (requires a token)
6749
7010
  mcph install <client> [flags] Auto-edit an MCP client's config to launch mcph
6750
- mcph doctor Print loaded config + detected clients (support diagnostic)
7011
+ mcph doctor [--json] Print loaded config + detected clients (support diagnostic)
6751
7012
  mcph servers [--json] List servers configured in your mcp.hosting dashboard
7013
+ mcph bundles [list|match] Browse curated multi-server bundles
6752
7014
  mcph compliance <target> [flags] Run the compliance suite against an MCP server
6753
7015
  mcph reset-learning Clear cross-session learning history (~/.mcph/state.json)
6754
7016
  mcph --version Print version
@@ -6771,7 +7033,7 @@ ${installBlock}
6771
7033
  );
6772
7034
  process.exit(0);
6773
7035
  } else if (subcommand === "--version" || subcommand === "-V") {
6774
- process.stdout.write(`mcph ${true ? "0.39.0" : "dev"}
7036
+ process.stdout.write(`mcph ${true ? "0.41.0" : "dev"}
6775
7037
  `);
6776
7038
  process.exit(0);
6777
7039
  } else if (subcommand && !subcommand.startsWith("-")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/mcph",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "mcp.hosting — one install, all your MCP servers, managed from the cloud",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",