@heyclaude/mcp 0.2.0 → 0.3.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/src/registry.js CHANGED
@@ -7,7 +7,10 @@ import {
7
7
  platformFeedSlug,
8
8
  SITE_URL,
9
9
  } from "./platforms.js";
10
- import { DEFAULT_REMOTE_MCP_URL } from "./endpoint-url.js";
10
+ import {
11
+ DEFAULT_REMOTE_MCP_URL,
12
+ normalizeEndpointUrl,
13
+ } from "./endpoint-url.js";
11
14
  import { packageName, packageVersion } from "./package-metadata.js";
12
15
  import {
13
16
  formatZodError,
@@ -26,13 +29,18 @@ import {
26
29
  validateSubmissionDraftFromSpec,
27
30
  } from "./submissions.js";
28
31
 
29
- const repoRoot = path.resolve(
30
- path.dirname(fileURLToPath(import.meta.url)),
31
- "../../..",
32
- );
33
- const defaultDataDir = path.join(repoRoot, "apps", "web", "public", "data");
34
32
  const safePathPartPattern = /^[a-z0-9-]+$/;
35
33
  const jsonMimeType = "application/json";
34
+ const DISCOVERY_RESOURCE_LIMIT = 25;
35
+ const DISCOVERY_FETCH_TIMEOUT_MS = 5000;
36
+
37
+ function entryCanonicalUrl(entry) {
38
+ return (
39
+ entry.canonicalUrl ||
40
+ entry.url ||
41
+ `${SITE_URL}/entry/${entry.category}/${entry.slug}`
42
+ );
43
+ }
36
44
 
37
45
  export const MCP_PUBLIC_POLICY = {
38
46
  apiKeyRequired: false,
@@ -60,6 +68,7 @@ const platformAliases = new Map([
60
68
 
61
69
  export const READ_ONLY_TOOL_NAMES = [
62
70
  "search_registry",
71
+ "plan_workflow_toolbox",
63
72
  "server_info",
64
73
  "list_category_entries",
65
74
  "get_recent_updates",
@@ -81,6 +90,9 @@ export const READ_ONLY_TOOL_NAMES = [
81
90
  "prepare_submission_draft",
82
91
  "get_submission_examples",
83
92
  "review_submission_draft",
93
+ "get_submission_policy",
94
+ "explain_entry_trust",
95
+ "review_entry_safety",
84
96
  ];
85
97
 
86
98
  export const TOOL_DEFINITIONS = [
@@ -97,6 +109,19 @@ export const TOOL_DEFINITIONS = [
97
109
  openWorldHint: false,
98
110
  },
99
111
  },
112
+ {
113
+ name: "plan_workflow_toolbox",
114
+ description:
115
+ "Plan a read-only Claude or Codex workflow toolbox from ranked HeyClaude registry entries with trust, install, and follow-up guidance.",
116
+ inputSchema: jsonSchemaForTool("plan_workflow_toolbox"),
117
+ outputSchema: jsonSchemaForToolOutput("plan_workflow_toolbox"),
118
+ annotations: {
119
+ readOnlyHint: true,
120
+ destructiveHint: false,
121
+ idempotentHint: true,
122
+ openWorldHint: false,
123
+ },
124
+ },
100
125
  {
101
126
  name: "server_info",
102
127
  description:
@@ -178,25 +203,25 @@ export const TOOL_DEFINITIONS = [
178
203
  {
179
204
  name: "get_submission_schema",
180
205
  description:
181
- "Fetch read-only HeyClaude submission schemas and GitHub issue template fields by category.",
206
+ "Fetch read-only HeyClaude submission schemas for PR-first intake by category.",
182
207
  inputSchema: jsonSchemaForTool("get_submission_schema"),
183
208
  },
184
209
  {
185
210
  name: "validate_submission_draft",
186
211
  description:
187
- "Validate a HeyClaude content submission draft locally without creating GitHub issues or publishing content.",
212
+ "Validate a HeyClaude content submission draft locally without creating GitHub issues, pull requests, or publishing content.",
188
213
  inputSchema: jsonSchemaForTool("validate_submission_draft"),
189
214
  },
190
215
  {
191
216
  name: "search_duplicate_entries",
192
217
  description:
193
- "Search generated registry artifacts for likely duplicate entries before a user opens a submission issue.",
218
+ "Search generated registry artifacts for likely duplicate entries before a user opens a submission PR.",
194
219
  inputSchema: jsonSchemaForTool("search_duplicate_entries"),
195
220
  },
196
221
  {
197
222
  name: "build_submission_urls",
198
223
  description:
199
- "Build prefilled HeyClaude submit and GitHub issue URLs for a validated submission draft without making write calls.",
224
+ "Build prefilled HeyClaude submit and review URLs for a validated PR-first submission draft without making write calls.",
200
225
  inputSchema: jsonSchemaForTool("build_submission_urls"),
201
226
  },
202
227
  {
@@ -208,7 +233,7 @@ export const TOOL_DEFINITIONS = [
208
233
  {
209
234
  name: "prepare_submission_draft",
210
235
  description:
211
- "Build a read-only maintainer-reviewed HeyClaude submission draft with canonical issue text and URLs.",
236
+ "Build a read-only maintainer-reviewed HeyClaude submission draft with canonical PR text and URLs.",
212
237
  inputSchema: jsonSchemaForTool("prepare_submission_draft"),
213
238
  },
214
239
  {
@@ -223,6 +248,24 @@ export const TOOL_DEFINITIONS = [
223
248
  "Review a HeyClaude submission draft locally for schema errors, duplicate risk, and maintainer checklist items without writing to GitHub.",
224
249
  inputSchema: jsonSchemaForTool("review_submission_draft"),
225
250
  },
251
+ {
252
+ name: "get_submission_policy",
253
+ description:
254
+ "Fetch HeyClaude's read-only submission, artifact, import, and maintainer-review policy for contributors and agents.",
255
+ inputSchema: jsonSchemaForTool("get_submission_policy"),
256
+ },
257
+ {
258
+ name: "explain_entry_trust",
259
+ description:
260
+ "Explain deterministic trust, source, package, safety, privacy, and review metadata signals for one HeyClaude entry. This is a metadata review only and does not provide malware scanning, automatic safety guarantees, or installation approval.",
261
+ inputSchema: jsonSchemaForTool("explain_entry_trust"),
262
+ },
263
+ {
264
+ name: "review_entry_safety",
265
+ description:
266
+ "Review 1-5 HeyClaude entries for source, package, safety, and privacy metadata fit before install or recommendation. This is a metadata review only and does not provide malware scanning, automatic safety guarantees, or installation approval.",
267
+ inputSchema: jsonSchemaForTool("review_entry_safety"),
268
+ },
226
269
  ];
227
270
 
228
271
  for (const tool of TOOL_DEFINITIONS) {
@@ -236,7 +279,24 @@ for (const tool of TOOL_DEFINITIONS) {
236
279
  }
237
280
 
238
281
  function dataDirFromOptions(options = {}) {
239
- return options.dataDir || process.env.HEYCLAUDE_DATA_DIR || defaultDataDir;
282
+ const envDataDir =
283
+ typeof process !== "undefined" ? process.env?.HEYCLAUDE_DATA_DIR : "";
284
+ if (options.dataDir || envDataDir) {
285
+ return options.dataDir || envDataDir;
286
+ }
287
+
288
+ const moduleUrl = import.meta.url;
289
+ if (!moduleUrl) {
290
+ throw new Error(
291
+ "HEYCLAUDE_DATA_DIR or readTextArtifact is required outside the Node package runtime.",
292
+ );
293
+ }
294
+
295
+ const repoRoot = path.resolve(
296
+ path.dirname(fileURLToPath(moduleUrl)),
297
+ "../../..",
298
+ );
299
+ return path.join(repoRoot, "apps", "web", "public", "data");
240
300
  }
241
301
 
242
302
  function isSafePathPart(value) {
@@ -315,6 +375,8 @@ function entryMatchesQuery(entry, query) {
315
375
  entry.submittedBy,
316
376
  entry.brandName,
317
377
  entry.brandDomain,
378
+ ...notes(entry.safetyNotes),
379
+ ...notes(entry.privacyNotes),
318
380
  ...(entry.tags || []),
319
381
  ...(entry.keywords || []),
320
382
  ]
@@ -323,6 +385,122 @@ function entryMatchesQuery(entry, query) {
323
385
  return haystack.includes(query);
324
386
  }
325
387
 
388
+ function searchTokens(query) {
389
+ return normalizeText(query)
390
+ .split(/[^a-z0-9+#.-]+/i)
391
+ .map((token) => token.trim())
392
+ .filter((token) => token.length >= 2)
393
+ .slice(0, 12);
394
+ }
395
+
396
+ function entrySearchText(entry) {
397
+ return [
398
+ entry.title,
399
+ entry.description,
400
+ entry.cardDescription,
401
+ entry.category,
402
+ entry.slug,
403
+ entry.author,
404
+ entry.submittedBy,
405
+ entry.brandName,
406
+ entry.brandDomain,
407
+ ...notes(entry.safetyNotes),
408
+ ...notes(entry.privacyNotes),
409
+ ...(entry.tags || []),
410
+ ...(entry.keywords || []),
411
+ ]
412
+ .map(normalizeText)
413
+ .join(" ")
414
+ .toLowerCase();
415
+ }
416
+
417
+ function scoreSearchEntry(entry, query) {
418
+ const normalizedQuery = normalizeText(query);
419
+ const tokens = searchTokens(normalizedQuery);
420
+ if (!tokens.length) return { score: 0, reasons: [] };
421
+
422
+ const title = normalizeText(entry.title);
423
+ const category = normalizeText(entry.category);
424
+ const tags = new Set((entry.tags || []).map(normalizeText));
425
+ const keywords = new Set((entry.keywords || []).map(normalizeText));
426
+ const haystack = entrySearchText(entry);
427
+ const reasons = new Set();
428
+ let score = 0;
429
+
430
+ if (title.includes(normalizedQuery)) {
431
+ score += 90;
432
+ reasons.add("title phrase");
433
+ }
434
+ if (category === normalizedQuery) {
435
+ score += 45;
436
+ reasons.add("category match");
437
+ }
438
+
439
+ for (const token of tokens) {
440
+ if (title.includes(token)) {
441
+ score += 35;
442
+ reasons.add("title term");
443
+ }
444
+ if (tags.has(token)) {
445
+ score += 24;
446
+ reasons.add("tag match");
447
+ }
448
+ if (keywords.has(token)) {
449
+ score += 18;
450
+ reasons.add("keyword match");
451
+ }
452
+ if (category.includes(token)) {
453
+ score += 12;
454
+ reasons.add("category term");
455
+ }
456
+ if (haystack.includes(token)) score += 4;
457
+ }
458
+
459
+ if (entrySourceStatus(entry) === "available") {
460
+ score += 8;
461
+ reasons.add("source-backed");
462
+ }
463
+ if (
464
+ entryPackageTrust(entry) === "first-party" ||
465
+ entry.packageVerified ||
466
+ entry.trustSignals?.packageVerified
467
+ ) {
468
+ score += 8;
469
+ reasons.add("trusted package");
470
+ }
471
+ if (notes(entry.safetyNotes).length) {
472
+ score += 4;
473
+ reasons.add("safety notes");
474
+ }
475
+ if (notes(entry.privacyNotes).length) {
476
+ score += 4;
477
+ reasons.add("privacy notes");
478
+ }
479
+ if (entry.claimStatus === "verified" || entry.reviewedBy) {
480
+ score += 4;
481
+ reasons.add("reviewed");
482
+ }
483
+
484
+ return { score, reasons: [...reasons].slice(0, 6) };
485
+ }
486
+
487
+ function rankSearchEntries(entries, query) {
488
+ return entries
489
+ .map((entry, index) => ({
490
+ entry,
491
+ index,
492
+ ...scoreSearchEntry(entry, query),
493
+ }))
494
+ .sort((left, right) => {
495
+ if (left.score !== right.score) return right.score - left.score;
496
+ const dateCompare = String(right.entry.dateAdded || "").localeCompare(
497
+ String(left.entry.dateAdded || ""),
498
+ );
499
+ if (dateCompare !== 0) return dateCompare;
500
+ return left.index - right.index;
501
+ });
502
+ }
503
+
326
504
  function entryMatchesPlatform(entry, platform) {
327
505
  if (!platform) return true;
328
506
  return (entry.platforms || []).some((candidate) => candidate === platform);
@@ -335,7 +513,84 @@ function entryMatchesTag(entry, tag) {
335
513
  );
336
514
  }
337
515
 
338
- function toSearchResult(entry) {
516
+ function booleanFilterMatches(value, filter = "all") {
517
+ if (!filter || filter === "all") return true;
518
+ return filter === "true" ? Boolean(value) : !value;
519
+ }
520
+
521
+ function entryPackageTrust(entry) {
522
+ return entry.downloadTrust || (entry.downloadUrl ? "external" : "none");
523
+ }
524
+
525
+ function entryClaimStatus(entry) {
526
+ return entry.claimStatus || "unclaimed";
527
+ }
528
+
529
+ function entrySourceStatus(entry) {
530
+ const sourceUrls = [
531
+ entry.documentationUrl,
532
+ entry.repoUrl,
533
+ entry.githubUrl,
534
+ entry.sourceUrl,
535
+ ].filter((value) => String(value || "").trim());
536
+ return (
537
+ entry.trustSignals?.sourceStatus ||
538
+ (sourceUrls.length ? "available" : "missing")
539
+ );
540
+ }
541
+
542
+ function entryMatchesTrustFilters(entry, args = {}) {
543
+ if (
544
+ !booleanFilterMatches(
545
+ notes(entry.safetyNotes).length > 0,
546
+ args.hasSafetyNotes,
547
+ )
548
+ ) {
549
+ return false;
550
+ }
551
+ if (
552
+ !booleanFilterMatches(
553
+ notes(entry.privacyNotes).length > 0,
554
+ args.hasPrivacyNotes,
555
+ )
556
+ ) {
557
+ return false;
558
+ }
559
+ if (
560
+ args.downloadTrust &&
561
+ args.downloadTrust !== "all" &&
562
+ entryPackageTrust(entry) !== args.downloadTrust
563
+ ) {
564
+ return false;
565
+ }
566
+ if (
567
+ args.claimStatus &&
568
+ args.claimStatus !== "all" &&
569
+ entryClaimStatus(entry) !== args.claimStatus
570
+ ) {
571
+ return false;
572
+ }
573
+ if (
574
+ args.sourceStatus &&
575
+ args.sourceStatus !== "all" &&
576
+ entrySourceStatus(entry) !== args.sourceStatus
577
+ ) {
578
+ return false;
579
+ }
580
+ return true;
581
+ }
582
+
583
+ function parsedTrustArgs(args = {}) {
584
+ return {
585
+ hasSafetyNotes: args.hasSafetyNotes || "all",
586
+ hasPrivacyNotes: args.hasPrivacyNotes || "all",
587
+ downloadTrust: args.downloadTrust || "all",
588
+ claimStatus: args.claimStatus || "all",
589
+ sourceStatus: args.sourceStatus || "all",
590
+ };
591
+ }
592
+
593
+ function toSearchResult(entry, ranking = null) {
339
594
  return {
340
595
  key: `${entry.category}:${entry.slug}`,
341
596
  category: entry.category,
@@ -348,11 +603,14 @@ function toSearchResult(entry) {
348
603
  brandDomain: entry.brandDomain || "",
349
604
  submittedBy: entry.submittedBy || "",
350
605
  claimStatus: entry.claimStatus || "",
351
- url: entry.url || `${SITE_URL}/${entry.category}/${entry.slug}`,
352
- canonicalUrl:
353
- entry.canonicalUrl ||
354
- entry.url ||
355
- `${SITE_URL}/${entry.category}/${entry.slug}`,
606
+ downloadTrust: entry.downloadTrust || null,
607
+ safetyNotes: notes(entry.safetyNotes),
608
+ privacyNotes: notes(entry.privacyNotes),
609
+ url: entry.url || entryCanonicalUrl(entry),
610
+ canonicalUrl: entryCanonicalUrl(entry),
611
+ searchScore: ranking?.score ?? 0,
612
+ searchReasons: ranking?.reasons ?? [],
613
+ trust: entryTrustSummary(entry),
356
614
  };
357
615
  }
358
616
 
@@ -363,6 +621,8 @@ function toEntrySummary(entry) {
363
621
  repoUpdatedAt: entry.repoUpdatedAt || null,
364
622
  verificationStatus: entry.verificationStatus || "",
365
623
  installable: Boolean(entry.installable),
624
+ safetyNotes: notes(entry.safetyNotes),
625
+ privacyNotes: notes(entry.privacyNotes),
366
626
  supportLevels: entry.supportLevels || [],
367
627
  };
368
628
  }
@@ -410,6 +670,12 @@ function unique(values = []) {
410
670
  );
411
671
  }
412
672
 
673
+ function notes(values) {
674
+ return Array.isArray(values)
675
+ ? values.map((value) => String(value || "").trim()).filter(Boolean)
676
+ : [];
677
+ }
678
+
413
679
  function normalizeDateFloor(value) {
414
680
  const text = String(value || "").trim();
415
681
  if (!text) return "";
@@ -441,6 +707,87 @@ function sourceSummary(entry) {
441
707
  };
442
708
  }
443
709
 
710
+ function entryTrustRecommendations(entry) {
711
+ const recommendations = [];
712
+ const safetyNotes = notes(entry.safetyNotes);
713
+ const privacyNotes = notes(entry.privacyNotes);
714
+ const packageTrust = entryPackageTrust(entry);
715
+ const source = sourceSummary(entry);
716
+
717
+ if (!source.repoUrl && !source.documentationUrl) {
718
+ recommendations.push(
719
+ "Verify a canonical source before relying on this entry.",
720
+ );
721
+ }
722
+ if (packageTrust === "external") {
723
+ recommendations.push(
724
+ "Review the upstream package source and checksum before installing.",
725
+ );
726
+ }
727
+ if (entry.downloadUrl && packageTrust !== "first-party") {
728
+ recommendations.push(
729
+ "Treat the download as external unless maintainers have rebuilt and verified it.",
730
+ );
731
+ }
732
+ if (!safetyNotes.length) {
733
+ recommendations.push(
734
+ "No structured safety notes are present; inspect commands and permissions manually.",
735
+ );
736
+ }
737
+ if (!privacyNotes.length) {
738
+ recommendations.push(
739
+ "No structured privacy notes are present; review file, credential, telemetry, and network behavior manually.",
740
+ );
741
+ }
742
+ return unique(recommendations).slice(0, 6);
743
+ }
744
+
745
+ function entryTrustSummary(entry) {
746
+ const safetyNotes = notes(entry.safetyNotes);
747
+ const privacyNotes = notes(entry.privacyNotes);
748
+ const source = sourceSummary(entry);
749
+ const packageTrust = entryPackageTrust(entry);
750
+ const claimStatus = entryClaimStatus(entry);
751
+ return {
752
+ source: {
753
+ status: entrySourceStatus(entry),
754
+ repoUrl: source.repoUrl,
755
+ documentationUrl: source.documentationUrl,
756
+ sourceHosts: source.sourceHosts,
757
+ githubStars: source.githubStars,
758
+ githubForks: source.githubForks,
759
+ repoUpdatedAt: source.repoUpdatedAt,
760
+ },
761
+ package: {
762
+ downloadUrl: source.downloadUrl,
763
+ downloadTrust: packageTrust,
764
+ packageVerified: Boolean(
765
+ entry.packageVerified || entry.trustSignals?.packageVerified,
766
+ ),
767
+ checksum:
768
+ entry.checksum ||
769
+ entry.packageChecksum ||
770
+ entry.downloadSha256 ||
771
+ entry.skillPackage?.sha256 ||
772
+ "",
773
+ },
774
+ disclosures: {
775
+ safetyNotes,
776
+ privacyNotes,
777
+ hasSafetyNotes: safetyNotes.length > 0,
778
+ hasPrivacyNotes: privacyNotes.length > 0,
779
+ },
780
+ review: {
781
+ claimStatus,
782
+ reviewedBy: entry.reviewedBy || "",
783
+ reviewedAt: entry.reviewedAt || "",
784
+ submittedBy: entry.submittedBy || "",
785
+ sourceSubmissionUrl: entry.sourceSubmissionUrl || "",
786
+ },
787
+ recommendations: entryTrustRecommendations(entry),
788
+ };
789
+ }
790
+
444
791
  function contentAsset(type, label, content, format = "markdown") {
445
792
  const text =
446
793
  content && typeof content === "object"
@@ -552,16 +899,19 @@ export async function searchRegistry(args = {}, options = {}) {
552
899
  const category = normalizeText(args.category);
553
900
  const platform = normalizePlatform(args.platform);
554
901
  const limit = normalizeLimit(args.limit);
902
+ const trustFilters = parsedTrustArgs(args);
555
903
  const searchIndex = unwrapEntries(
556
904
  await readJsonArtifact("search-index.json", options),
557
905
  );
558
906
 
559
- const entries = searchIndex
907
+ const matched = searchIndex
560
908
  .filter((entry) => !category || entry.category === category)
561
909
  .filter((entry) => entryMatchesPlatform(entry, platform))
562
910
  .filter((entry) => entryMatchesQuery(entry, query))
911
+ .filter((entry) => entryMatchesTrustFilters(entry, trustFilters));
912
+ const entries = rankSearchEntries(matched, query)
563
913
  .slice(0, limit)
564
- .map(toSearchResult);
914
+ .map((item) => toSearchResult(item.entry, item));
565
915
 
566
916
  return {
567
917
  ok: true,
@@ -569,10 +919,205 @@ export async function searchRegistry(args = {}, options = {}) {
569
919
  query: args.query || "",
570
920
  category: category || "",
571
921
  platform: platform || "",
922
+ filters: trustFilters,
572
923
  entries,
573
924
  };
574
925
  }
575
926
 
927
+ function selectDiverseRankedEntries(ranked, limit) {
928
+ const selected = [];
929
+ const byCategory = new Map();
930
+
931
+ for (const item of ranked) {
932
+ const category = item.entry.category || "";
933
+ const current = byCategory.get(category) || 0;
934
+ if (current >= 2) continue;
935
+ selected.push(item);
936
+ byCategory.set(category, current + 1);
937
+ if (selected.length >= limit) return selected;
938
+ }
939
+
940
+ for (const item of ranked) {
941
+ if (selected.includes(item)) continue;
942
+ selected.push(item);
943
+ if (selected.length >= limit) return selected;
944
+ }
945
+
946
+ return selected;
947
+ }
948
+
949
+ function toolboxFitReasons(entry, ranking) {
950
+ const reasons = [...(ranking.reasons || []).slice(0, 4)];
951
+ if (entry.category) {
952
+ reasons.push(`${entry.category} workflow surface`);
953
+ }
954
+ if (entrySourceStatus(entry) === "available") {
955
+ reasons.push("source-backed metadata");
956
+ }
957
+ if (
958
+ entryPackageTrust(entry) === "first-party" ||
959
+ entry.packageVerified ||
960
+ entry.trustSignals?.packageVerified
961
+ ) {
962
+ reasons.push("first-party or verified package signal");
963
+ }
964
+ if (notes(entry.safetyNotes).length && notes(entry.privacyNotes).length) {
965
+ reasons.push("safety and privacy notes present");
966
+ } else if (notes(entry.safetyNotes).length) {
967
+ reasons.push("safety notes present");
968
+ } else if (notes(entry.privacyNotes).length) {
969
+ reasons.push("privacy notes present");
970
+ }
971
+ if (entry.installCommand || entry.downloadUrl || entry.configSnippet) {
972
+ reasons.push("actionable setup surface");
973
+ }
974
+ if ((entry.platforms || []).length) {
975
+ reasons.push(
976
+ `platform compatibility: ${(entry.platforms || []).slice(0, 3).join(", ")}`,
977
+ );
978
+ }
979
+ if ((entry.supportLevels || []).length) {
980
+ reasons.push("support levels documented");
981
+ }
982
+ if (entry.claimStatus === "verified" || entry.reviewedBy) {
983
+ reasons.push("review/provenance metadata");
984
+ }
985
+ return unique(reasons).slice(0, 8);
986
+ }
987
+
988
+ function toolboxCaveats(entry) {
989
+ const caveats = [];
990
+ const packageTrust = entryPackageTrust(entry);
991
+ const safetyNotes = notes(entry.safetyNotes);
992
+ const privacyNotes = notes(entry.privacyNotes);
993
+ if (entrySourceStatus(entry) !== "available") {
994
+ caveats.push("Source metadata is missing or incomplete.");
995
+ }
996
+ if (packageTrust === "external") {
997
+ caveats.push("Package/download is external; verify upstream before use.");
998
+ }
999
+ if (entry.downloadUrl && !entryTrustSummary(entry).package.checksum) {
1000
+ caveats.push("Download checksum metadata is not present.");
1001
+ }
1002
+ if (!safetyNotes.length) {
1003
+ caveats.push("No structured safety notes are present.");
1004
+ }
1005
+ if (!privacyNotes.length) {
1006
+ caveats.push("No structured privacy notes are present.");
1007
+ }
1008
+ if (
1009
+ ["mcp", "hooks", "commands", "skills", "statuslines"].includes(
1010
+ entry.category,
1011
+ )
1012
+ ) {
1013
+ caveats.push(
1014
+ "Risk-bearing workflow surface; inspect commands, permissions, and data access before use.",
1015
+ );
1016
+ }
1017
+ return unique(caveats).slice(0, 5);
1018
+ }
1019
+
1020
+ function toolboxNextActions(entry) {
1021
+ return [
1022
+ `Inspect get_entry_detail with category=${entry.category} and slug=${entry.slug}.`,
1023
+ `Run explain_entry_trust with category=${entry.category} and slug=${entry.slug}; this is still metadata review only.`,
1024
+ "Use compare_entries with nearby candidates before recommending a final stack.",
1025
+ `Use get_copyable_asset with category=${entry.category} and slug=${entry.slug} only after trust review.`,
1026
+ ];
1027
+ }
1028
+
1029
+ function toolboxCategoryMix(entries) {
1030
+ const counts = new Map();
1031
+ for (const entry of entries) {
1032
+ const category = entry.category || "unknown";
1033
+ counts.set(category, (counts.get(category) || 0) + 1);
1034
+ }
1035
+ return [...counts]
1036
+ .map(([category, count]) => ({ category, count }))
1037
+ .sort((left, right) => left.category.localeCompare(right.category));
1038
+ }
1039
+
1040
+ function toolboxTrustSummary(entries) {
1041
+ return {
1042
+ sourceBacked: entries.filter(
1043
+ (entry) => entry.trust?.source?.status === "available",
1044
+ ).length,
1045
+ firstPartyOrVerifiedPackages: entries.filter(
1046
+ (entry) =>
1047
+ entry.trust?.package?.downloadTrust === "first-party" ||
1048
+ entry.trust?.package?.packageVerified,
1049
+ ).length,
1050
+ entriesWithSafetyNotes: entries.filter(
1051
+ (entry) => entry.trust?.disclosures?.hasSafetyNotes,
1052
+ ).length,
1053
+ entriesWithPrivacyNotes: entries.filter(
1054
+ (entry) => entry.trust?.disclosures?.hasPrivacyNotes,
1055
+ ).length,
1056
+ externalPackages: entries.filter(
1057
+ (entry) => entry.trust?.package?.downloadTrust === "external",
1058
+ ).length,
1059
+ missingSource: entries.filter(
1060
+ (entry) => entry.trust?.source?.status !== "available",
1061
+ ).length,
1062
+ };
1063
+ }
1064
+
1065
+ export async function planWorkflowToolbox(args = {}, options = {}) {
1066
+ const goal = String(args.goal || "").trim();
1067
+ if (goal.length < 2) {
1068
+ return invalid("Planner goal must be at least 2 characters.");
1069
+ }
1070
+ const query = normalizeText(goal);
1071
+ const category = normalizeText(args.category);
1072
+ const platform = normalizePlatform(args.platform);
1073
+ const limit = Math.min(10, normalizeLimit(args.limit, 6));
1074
+ const searchIndex = unwrapEntries(
1075
+ await readJsonArtifact("search-index.json", options),
1076
+ );
1077
+ const scoped = searchIndex
1078
+ .filter((entry) => !category || entry.category === category)
1079
+ .filter((entry) => entryMatchesPlatform(entry, platform));
1080
+ let matched = scoped.filter((entry) => entryMatchesQuery(entry, query));
1081
+ const queryTokens = searchTokens(query);
1082
+ if (!matched.length && queryTokens.length) {
1083
+ matched = scoped.filter((entry) =>
1084
+ queryTokens.some((token) => entrySearchText(entry).includes(token)),
1085
+ );
1086
+ }
1087
+ const ranked = rankSearchEntries(matched, query);
1088
+ const selected = selectDiverseRankedEntries(ranked, limit).map((item) => ({
1089
+ ...toEntrySummary(item.entry),
1090
+ searchScore: item.score,
1091
+ searchReasons: item.reasons,
1092
+ toolboxReasons: toolboxFitReasons(item.entry, item),
1093
+ caveats: toolboxCaveats(item.entry),
1094
+ nextActions: toolboxNextActions(item.entry),
1095
+ }));
1096
+
1097
+ return {
1098
+ ok: true,
1099
+ goal,
1100
+ category: category || "",
1101
+ platform: platform || "",
1102
+ count: selected.length,
1103
+ entries: selected,
1104
+ categoryMix: toolboxCategoryMix(selected),
1105
+ trustSummary: toolboxTrustSummary(selected),
1106
+ recommendedNextTools: [
1107
+ "get_entry_detail",
1108
+ "explain_entry_trust",
1109
+ "compare_entries",
1110
+ "get_copyable_asset",
1111
+ ],
1112
+ plannerNotes: [
1113
+ "This planner is metadata review only; it is not install approval or malware scanning, and it does not execute or install entries.",
1114
+ "Recommendations are bounded and category-diverse where matching entries allow it.",
1115
+ "Prefer source-backed entries with safety/privacy notes for risk-bearing MCP, hooks, skills, commands, and statuslines.",
1116
+ "Use get_entry_detail, explain_entry_trust, compare_entries, and get_copyable_asset before relying on any entry.",
1117
+ ],
1118
+ };
1119
+ }
1120
+
576
1121
  export async function getServerInfo(args = {}, options = {}) {
577
1122
  const manifest = await readJsonArtifact("registry-manifest.json", options);
578
1123
  return {
@@ -729,6 +1274,39 @@ export async function getRelatedEntries(args = {}, options = {}) {
729
1274
  return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
730
1275
  }
731
1276
 
1277
+ const graph = await readJsonArtifact("relation-graph.json", options).catch(
1278
+ () => null,
1279
+ );
1280
+ const graphRow = Array.isArray(graph?.entries)
1281
+ ? graph.entries.find((entry) => entry.key === `${category}:${slug}`)
1282
+ : null;
1283
+ if (graphRow?.related?.length) {
1284
+ const searchByKey = new Map(
1285
+ searchIndex.map((entry) => [`${entry.category}:${entry.slug}`, entry]),
1286
+ );
1287
+ const entries = graphRow.related
1288
+ .map((relation) => {
1289
+ const entry = searchByKey.get(relation.key);
1290
+ if (!entry) return null;
1291
+ return {
1292
+ ...toEntrySummary(entry),
1293
+ relation: relation.relation,
1294
+ relatedScore: relation.score,
1295
+ relatedReasons: relation.reasons || [],
1296
+ };
1297
+ })
1298
+ .filter(Boolean)
1299
+ .slice(0, limit);
1300
+
1301
+ return {
1302
+ ok: true,
1303
+ key: `${target.category}:${target.slug}`,
1304
+ relationGraph: true,
1305
+ count: entries.length,
1306
+ entries,
1307
+ };
1308
+ }
1309
+
732
1310
  const entries = searchIndex
733
1311
  .map((entry) => {
734
1312
  const related = scoreRelatedEntry(target, entry);
@@ -772,8 +1350,13 @@ export async function getEntryDetail(args = {}, options = {}) {
772
1350
  return {
773
1351
  ok: true,
774
1352
  key: `${entry.category}:${entry.slug}`,
775
- canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
776
- entry,
1353
+ canonicalUrl: entryCanonicalUrl(entry),
1354
+ entry: {
1355
+ ...entry,
1356
+ safetyNotes: notes(entry.safetyNotes),
1357
+ privacyNotes: notes(entry.privacyNotes),
1358
+ },
1359
+ trust: entryTrustSummary(entry),
777
1360
  };
778
1361
  }
779
1362
 
@@ -827,7 +1410,7 @@ export async function getCopyableAsset(args = {}, options = {}) {
827
1410
  category: entry.category,
828
1411
  slug: entry.slug,
829
1412
  title: entry.title,
830
- canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
1413
+ canonicalUrl: entryCanonicalUrl(entry),
831
1414
  platform: platform || "",
832
1415
  primaryAsset: primary,
833
1416
  assets,
@@ -835,8 +1418,11 @@ export async function getCopyableAsset(args = {}, options = {}) {
835
1418
  configSnippet: entry.configSnippet || "",
836
1419
  usageSnippet: entry.usageSnippet || "",
837
1420
  downloadUrl: entry.downloadUrl || "",
1421
+ safetyNotes: notes(entry.safetyNotes),
1422
+ privacyNotes: notes(entry.privacyNotes),
838
1423
  platformCompatibility: compatibility,
839
1424
  source: sourceSummary(entry),
1425
+ trust: entryTrustSummary(entry),
840
1426
  };
841
1427
  }
842
1428
 
@@ -864,7 +1450,7 @@ export async function compareEntries(args = {}, options = {}) {
864
1450
  slug: entry.slug,
865
1451
  title: entry.title,
866
1452
  description: entry.description,
867
- canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
1453
+ canonicalUrl: entryCanonicalUrl(entry),
868
1454
  tags: entry.tags || [],
869
1455
  platforms: entry.platforms || [],
870
1456
  selectedCompatibility,
@@ -876,22 +1462,29 @@ export async function compareEntries(args = {}, options = {}) {
876
1462
  entry.scriptBody ? "script" : "",
877
1463
  ].filter(Boolean),
878
1464
  source: sourceSummary(entry),
1465
+ trust: entryTrustSummary(entry),
879
1466
  };
880
1467
  });
1468
+ const sharedTags = compared.length
1469
+ ? compared
1470
+ .slice(1)
1471
+ .reduce(
1472
+ (tags, entry) => intersection(tags, entry.tags || []),
1473
+ compared[0].tags || [],
1474
+ )
1475
+ : [];
881
1476
 
882
1477
  return {
883
1478
  ok: true,
884
1479
  platform: platform || "",
885
1480
  count: compared.length,
886
- sharedTags: intersection(
887
- compared[0]?.tags || [],
888
- compared.slice(1).flatMap((entry) => entry.tags || []),
889
- ),
1481
+ sharedTags,
890
1482
  entries: compared,
891
1483
  comparisonNotes: [
892
1484
  "Prefer exact category fit before source popularity.",
893
1485
  "Treat GitHub stars/forks as source signals only when present; absence is not a negative ranking.",
894
1486
  "Install complexity is derived from available install/config/download/prerequisite metadata.",
1487
+ "Safety/privacy notes are disclosure metadata, not a malware verdict.",
895
1488
  ],
896
1489
  };
897
1490
  }
@@ -957,7 +1550,18 @@ export async function getRegistryStats(args = {}, options = {}) {
957
1550
  }
958
1551
 
959
1552
  export async function getClientSetup(args = {}) {
960
- const endpointUrl = args.endpointUrl || DEFAULT_REMOTE_MCP_URL;
1553
+ let endpointUrl;
1554
+ try {
1555
+ const rawEndpointUrl = Object.prototype.hasOwnProperty.call(
1556
+ args,
1557
+ "endpointUrl",
1558
+ )
1559
+ ? args.endpointUrl
1560
+ : DEFAULT_REMOTE_MCP_URL;
1561
+ endpointUrl = normalizeEndpointUrl(rawEndpointUrl).toString();
1562
+ } catch (error) {
1563
+ return invalid(error?.message || "Invalid endpoint URL.");
1564
+ }
961
1565
  const snippets = {
962
1566
  codex: {
963
1567
  label: "Codex stdio bridge",
@@ -1019,7 +1623,7 @@ export async function getClientSetup(args = {}) {
1019
1623
  snippets: client ? { [client]: snippets[client] } : snippets,
1020
1624
  notes: [
1021
1625
  "The public endpoint is read-only and does not need an API key.",
1022
- "Submission tools prepare maintainer-reviewed drafts; they do not open GitHub issues.",
1626
+ "Submission tools prepare maintainer-reviewed PR-first drafts; they do not open GitHub issues.",
1023
1627
  "Use --url only when testing a custom preview or deployment.",
1024
1628
  ],
1025
1629
  };
@@ -1044,6 +1648,330 @@ export const RESOURCE_TEMPLATES = [
1044
1648
  },
1045
1649
  ];
1046
1650
 
1651
+ /**
1652
+ * Static MCP resource descriptors for the bounded discovery surfaces
1653
+ * exposed alongside the directory and category feeds. Appended to
1654
+ * {@link listRegistryResources} output and routed by
1655
+ * {@link readRegistryResource}.
1656
+ *
1657
+ * @type {Array<{ uri: string, name: string, title: string, description: string, mimeType: string }>}
1658
+ */
1659
+ const DISCOVERY_RESOURCES = [
1660
+ {
1661
+ uri: "heyclaude://registry/recent",
1662
+ name: "HeyClaude recent registry updates",
1663
+ title: "HeyClaude recent registry updates",
1664
+ description:
1665
+ "Bounded list of recently added or upstream-updated HeyClaude entries from the generated search index.",
1666
+ mimeType: jsonMimeType,
1667
+ },
1668
+ {
1669
+ uri: "heyclaude://registry/trending",
1670
+ name: "HeyClaude trending registry entries",
1671
+ title: "HeyClaude trending registry entries",
1672
+ description:
1673
+ "Bounded list of trending HeyClaude entries from the public /api/registry/trending endpoint; degrades gracefully when dynamic state is unavailable.",
1674
+ mimeType: jsonMimeType,
1675
+ },
1676
+ {
1677
+ uri: "heyclaude://jobs/active",
1678
+ name: "HeyClaude active jobs",
1679
+ title: "HeyClaude active jobs",
1680
+ description:
1681
+ "Bounded list of active public job listings from the public /api/jobs endpoint; degrades gracefully when dynamic state is unavailable.",
1682
+ mimeType: jsonMimeType,
1683
+ },
1684
+ ];
1685
+
1686
+ /**
1687
+ * Resolve the public HeyClaude API base URL. Prefers an explicit override
1688
+ * on `options.publicApiBaseUrl`, then the `HEYCLAUDE_PUBLIC_API_URL`
1689
+ * environment variable, then falls back to the canonical site URL.
1690
+ *
1691
+ * @param {{ publicApiBaseUrl?: string }} [options]
1692
+ * @returns {string} Base URL used to build `/api/...` requests.
1693
+ */
1694
+ function publicApiBaseUrl(options = {}) {
1695
+ return (
1696
+ options.publicApiBaseUrl || process.env.HEYCLAUDE_PUBLIC_API_URL || SITE_URL
1697
+ );
1698
+ }
1699
+
1700
+ /**
1701
+ * Remove trailing slashes without using a potentially expensive regex on
1702
+ * caller-controlled API base URL overrides.
1703
+ *
1704
+ * @param {string} value
1705
+ * @returns {string}
1706
+ */
1707
+ function stripTrailingSlashes(value) {
1708
+ let end = value.length;
1709
+ while (end > 0 && value.charCodeAt(end - 1) === 47) {
1710
+ end -= 1;
1711
+ }
1712
+ return value.slice(0, end);
1713
+ }
1714
+
1715
+ /**
1716
+ * Fetch JSON from a public HeyClaude API path. Tests inject a deterministic
1717
+ * fetcher via `options.fetchPublicApi`; production uses `fetch()` with a
1718
+ * bounded {@link DISCOVERY_FETCH_TIMEOUT_MS} timeout, `redirect: "error"`,
1719
+ * and a JSON `accept` header. Throws on non-2xx responses so callers can
1720
+ * convert failures into the "unavailable" graceful-degradation envelope.
1721
+ *
1722
+ * @param {string} apiPath API path beginning with `/api/...`.
1723
+ * @param {{
1724
+ * publicApiBaseUrl?: string,
1725
+ * fetchPublicApi?: (apiPath: string) => Promise<unknown>,
1726
+ * }} [options]
1727
+ * @returns {Promise<unknown>} Parsed JSON body from the upstream response.
1728
+ */
1729
+ async function fetchPublicApiJson(apiPath, options = {}) {
1730
+ if (typeof options.fetchPublicApi === "function") {
1731
+ return options.fetchPublicApi(apiPath);
1732
+ }
1733
+ const baseUrl = stripTrailingSlashes(publicApiBaseUrl(options));
1734
+ const url = `${baseUrl}${apiPath.startsWith("/") ? "" : "/"}${apiPath}`;
1735
+ const controller = new AbortController();
1736
+ const timeout = setTimeout(
1737
+ () => controller.abort(),
1738
+ DISCOVERY_FETCH_TIMEOUT_MS,
1739
+ );
1740
+ try {
1741
+ const response = await fetch(url, {
1742
+ method: "GET",
1743
+ headers: { accept: jsonMimeType },
1744
+ redirect: "error",
1745
+ signal: controller.signal,
1746
+ });
1747
+ if (!response.ok) {
1748
+ throw new Error(`Public API ${apiPath} returned ${response.status}.`);
1749
+ }
1750
+ return await response.json();
1751
+ } finally {
1752
+ clearTimeout(timeout);
1753
+ }
1754
+ }
1755
+
1756
+ /**
1757
+ * Build the standard "unavailable" error envelope used when a dynamic
1758
+ * resource cannot be loaded. Distinct from `notFound` / `invalid` so MCP
1759
+ * clients can tell apart "endpoint failed" from "no such resource" and
1760
+ * keep the surface read-only.
1761
+ *
1762
+ * @param {string} message Human-readable explanation.
1763
+ * @param {string} [details] Optional underlying error message.
1764
+ * @returns {{ ok: false, error: { code: "unavailable", message: string, details?: string } }}
1765
+ */
1766
+ function unavailable(message, details) {
1767
+ return {
1768
+ ok: false,
1769
+ error: {
1770
+ code: "unavailable",
1771
+ message,
1772
+ ...(details ? { details } : {}),
1773
+ },
1774
+ };
1775
+ }
1776
+
1777
+ /**
1778
+ * Build the `heyclaude://registry/recent` resource payload. Reads the
1779
+ * generated `search-index.json` artifact, sorts entries by `repoUpdatedAt`
1780
+ * (falling back to `updatedAt` / `dateAdded`) descending, and bounds
1781
+ * output to {@link DISCOVERY_RESOURCE_LIMIT} entries. Each entry carries
1782
+ * the standard `toEntrySummary` shape plus `updatedAt` and `updateKind`.
1783
+ *
1784
+ * @param {import("./registry.d.ts").RegistryArtifactLoaders} [options]
1785
+ * @returns {Promise<import("./registry.d.ts").RegistryToolResult>}
1786
+ */
1787
+ export async function listRegistryRecent(options = {}) {
1788
+ const searchIndex = unwrapEntries(
1789
+ await readJsonArtifact("search-index.json", options),
1790
+ );
1791
+ const entries = searchIndex
1792
+ .slice()
1793
+ .sort((left, right) => {
1794
+ const dateCompare = entryUpdatedAt(right).localeCompare(
1795
+ entryUpdatedAt(left),
1796
+ );
1797
+ if (dateCompare !== 0) return dateCompare;
1798
+ return String(left.title || "").localeCompare(String(right.title || ""));
1799
+ })
1800
+ .slice(0, DISCOVERY_RESOURCE_LIMIT)
1801
+ .map((entry) => ({
1802
+ ...toEntrySummary(entry),
1803
+ updatedAt: entryUpdatedAt(entry),
1804
+ updateKind: entry.repoUpdatedAt ? "upstream_update" : "added",
1805
+ }));
1806
+
1807
+ return {
1808
+ ok: true,
1809
+ kind: "registry-recent",
1810
+ schemaVersion: 1,
1811
+ limit: DISCOVERY_RESOURCE_LIMIT,
1812
+ count: entries.length,
1813
+ entries,
1814
+ };
1815
+ }
1816
+
1817
+ /**
1818
+ * Normalize a raw `/api/registry/trending` entry into the small, stable
1819
+ * shape published by the MCP `registry/trending` resource. Defends against
1820
+ * upstream field churn (missing arrays, non-numeric scores, dropped
1821
+ * `trustSignals`) so MCP clients see a predictable schema.
1822
+ *
1823
+ * @param {Record<string, unknown> & { category: string, slug: string }} entry
1824
+ * @returns {Record<string, unknown>} Normalized trending entry.
1825
+ */
1826
+ function toTrendingEntry(entry) {
1827
+ return {
1828
+ key: `${entry.category}:${entry.slug}`,
1829
+ category: entry.category,
1830
+ slug: entry.slug,
1831
+ title: entry.title || "",
1832
+ description: entry.description || "",
1833
+ canonicalUrl: entryCanonicalUrl(entry),
1834
+ platforms: Array.isArray(entry.platforms) ? entry.platforms : [],
1835
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
1836
+ dateAdded: entry.dateAdded || "",
1837
+ score: typeof entry.score === "number" ? entry.score : 0,
1838
+ reasons: Array.isArray(entry.reasons) ? entry.reasons : [],
1839
+ trustSignals: entry.trustSignals || { sourceStatus: "missing" },
1840
+ };
1841
+ }
1842
+
1843
+ /**
1844
+ * Build the `heyclaude://registry/trending` resource payload. Reuses the
1845
+ * public `/api/registry/trending` endpoint (no DB access from the MCP
1846
+ * package). Returns an `unavailable` envelope when the upstream fetch
1847
+ * fails so MCP clients degrade gracefully. Output is bounded to
1848
+ * {@link DISCOVERY_RESOURCE_LIMIT} entries and forwards `signalsAvailable`
1849
+ * when present so consumers can tell which scoring signals applied.
1850
+ *
1851
+ * @param {import("./registry.d.ts").RegistryArtifactLoaders & {
1852
+ * publicApiBaseUrl?: string,
1853
+ * fetchPublicApi?: (apiPath: string) => Promise<unknown>,
1854
+ * }} [options]
1855
+ * @returns {Promise<import("./registry.d.ts").RegistryToolResult>}
1856
+ */
1857
+ export async function listRegistryTrending(options = {}) {
1858
+ let payload;
1859
+ try {
1860
+ payload = await fetchPublicApiJson(
1861
+ `/api/registry/trending?limit=${DISCOVERY_RESOURCE_LIMIT}`,
1862
+ options,
1863
+ );
1864
+ } catch (error) {
1865
+ return unavailable(
1866
+ "Trending registry state is currently unavailable.",
1867
+ String(error?.message || error || ""),
1868
+ );
1869
+ }
1870
+
1871
+ if (!payload || !Array.isArray(payload.entries)) {
1872
+ return unavailable(
1873
+ "Trending registry state is currently unavailable.",
1874
+ "Upstream payload is missing the expected entries array.",
1875
+ );
1876
+ }
1877
+ const entries = payload.entries
1878
+ .slice(0, DISCOVERY_RESOURCE_LIMIT)
1879
+ .map(toTrendingEntry);
1880
+
1881
+ return {
1882
+ ok: true,
1883
+ kind: "registry-trending",
1884
+ schemaVersion: payload?.schemaVersion ?? 1,
1885
+ category: payload?.category || "all",
1886
+ platform: payload?.platform || "all",
1887
+ limit: DISCOVERY_RESOURCE_LIMIT,
1888
+ count: entries.length,
1889
+ signalsAvailable:
1890
+ payload?.signalsAvailable && typeof payload.signalsAvailable === "object"
1891
+ ? payload.signalsAvailable
1892
+ : null,
1893
+ source: "public-api",
1894
+ entries,
1895
+ };
1896
+ }
1897
+
1898
+ /**
1899
+ * Normalize a raw `/api/jobs` entry into the small, stable shape published
1900
+ * by the MCP `jobs/active` resource. Defends against upstream field churn
1901
+ * and never exposes private/admin-only fields (we only project the public
1902
+ * subset already returned by `buildPublicJobsIndex`).
1903
+ *
1904
+ * @param {Record<string, unknown>} job
1905
+ * @returns {Record<string, unknown>} Normalized public job entry.
1906
+ */
1907
+ function toJobEntry(job) {
1908
+ return {
1909
+ id: job.id || job.slug || "",
1910
+ title: job.title || "",
1911
+ company: job.company || "",
1912
+ location: job.location || "",
1913
+ type: job.type || "",
1914
+ isRemote: Boolean(job.isRemote),
1915
+ tier: job.tier || "",
1916
+ applyUrl: job.applyUrl || job.url || "",
1917
+ sourceLabel: job.sourceLabel || "",
1918
+ postedAt: job.postedAt || job.publishedAt || "",
1919
+ labels: Array.isArray(job.labels) ? job.labels : [],
1920
+ };
1921
+ }
1922
+
1923
+ /**
1924
+ * Build the `heyclaude://jobs/active` resource payload. Reuses the public
1925
+ * `/api/jobs` endpoint (no DB access from the MCP package) and returns an
1926
+ * `unavailable` envelope when the upstream fetch fails. Output is bounded
1927
+ * to {@link DISCOVERY_RESOURCE_LIMIT} entries and forwards `totalAvailable`
1928
+ * when the upstream reports it.
1929
+ *
1930
+ * @param {import("./registry.d.ts").RegistryArtifactLoaders & {
1931
+ * publicApiBaseUrl?: string,
1932
+ * fetchPublicApi?: (apiPath: string) => Promise<unknown>,
1933
+ * }} [options]
1934
+ * @returns {Promise<import("./registry.d.ts").RegistryToolResult>}
1935
+ */
1936
+ export async function listJobsActive(options = {}) {
1937
+ let payload;
1938
+ try {
1939
+ payload = await fetchPublicApiJson(
1940
+ `/api/jobs?limit=${DISCOVERY_RESOURCE_LIMIT}`,
1941
+ options,
1942
+ );
1943
+ } catch (error) {
1944
+ return unavailable(
1945
+ "Active jobs state is currently unavailable.",
1946
+ String(error?.message || error || ""),
1947
+ );
1948
+ }
1949
+
1950
+ if (!payload || !Array.isArray(payload.entries)) {
1951
+ return unavailable(
1952
+ "Active jobs state is currently unavailable.",
1953
+ "Upstream payload is missing the expected entries array.",
1954
+ );
1955
+ }
1956
+ const entries = payload.entries
1957
+ .slice(0, DISCOVERY_RESOURCE_LIMIT)
1958
+ .map(toJobEntry);
1959
+
1960
+ return {
1961
+ ok: true,
1962
+ kind: "jobs-active",
1963
+ schemaVersion: payload?.schemaVersion ?? 1,
1964
+ limit: DISCOVERY_RESOURCE_LIMIT,
1965
+ count: entries.length,
1966
+ totalAvailable:
1967
+ typeof payload?.totalAvailable === "number"
1968
+ ? payload.totalAvailable
1969
+ : null,
1970
+ source: "public-api",
1971
+ entries,
1972
+ };
1973
+ }
1974
+
1047
1975
  export const PROMPT_DEFINITIONS = [
1048
1976
  {
1049
1977
  name: "find_best_asset",
@@ -1071,7 +1999,7 @@ export const PROMPT_DEFINITIONS = [
1071
1999
  name: "prepare_submission",
1072
2000
  title: "Prepare a HeyClaude submission",
1073
2001
  description:
1074
- "Guide a user through drafting a maintainer-reviewed HeyClaude submission without opening an issue automatically.",
2002
+ "Guide a user through drafting a maintainer-reviewed HeyClaude submission without opening a PR automatically.",
1075
2003
  arguments: [
1076
2004
  { name: "category", description: "Submission category.", required: true },
1077
2005
  { name: "name", description: "Submission name or title." },
@@ -1082,8 +2010,8 @@ export const PROMPT_DEFINITIONS = [
1082
2010
  ],
1083
2011
  },
1084
2012
  {
1085
- name: "review_submission_before_issue",
1086
- title: "Review submission before opening issue",
2013
+ name: "review_submission_before_pr",
2014
+ title: "Review submission before opening PR",
1087
2015
  description:
1088
2016
  "Check a draft for schema gaps, duplicate risk, source review, and maintainer checklist items.",
1089
2017
  arguments: [
@@ -1126,6 +2054,7 @@ export async function listRegistryResources(args = {}, options = {}) {
1126
2054
  description: `Generated public ${category} category summary entries.`,
1127
2055
  mimeType: jsonMimeType,
1128
2056
  })),
2057
+ ...DISCOVERY_RESOURCES,
1129
2058
  ],
1130
2059
  };
1131
2060
  }
@@ -1187,6 +2116,24 @@ export async function readRegistryResource(args = {}, options = {}) {
1187
2116
  const [category, slug] = parts.map(normalizeText);
1188
2117
  const detail = await getEntryDetail({ category, slug }, options);
1189
2118
  payload = detail;
2119
+ } else if (
2120
+ parsed.hostname === "registry" &&
2121
+ parts.length === 1 &&
2122
+ parts[0] === "recent"
2123
+ ) {
2124
+ payload = await listRegistryRecent(options);
2125
+ } else if (
2126
+ parsed.hostname === "registry" &&
2127
+ parts.length === 1 &&
2128
+ parts[0] === "trending"
2129
+ ) {
2130
+ payload = await listRegistryTrending(options);
2131
+ } else if (
2132
+ parsed.hostname === "jobs" &&
2133
+ parts.length === 1 &&
2134
+ parts[0] === "active"
2135
+ ) {
2136
+ payload = await listJobsActive(options);
1190
2137
  } else {
1191
2138
  return resourcePayload(
1192
2139
  notFound(`Unsupported HeyClaude resource URI: ${uri}`),
@@ -1239,8 +2186,8 @@ export function getRegistryPrompt(args = {}) {
1239
2186
  Use the read-only HeyClaude MCP tools. Start with search_registry or list_category_entries${category ? ` in category ${category}` : ""}${platform ? ` for platform ${platform}` : ""}. Compare credible candidates with compare_entries, inspect details with get_entry_detail, and cite exact category/slug pairs. Do not invent popularity metrics when source stats are absent.`,
1240
2187
  prepare_submission: `Prepare a HeyClaude submission draft${category ? ` for category ${category}` : ""}${promptArgument(values, "name") ? ` named ${promptArgument(values, "name")}` : ""}${sourceUrl ? ` from ${sourceUrl}` : ""}.
1241
2188
 
1242
- Use get_submission_schema, get_submission_examples, prepare_submission_draft, review_submission_draft, and search_duplicate_entries. Return missing fields and the canonical issue draft URL/body. Do not create a GitHub issue or publish content.`,
1243
- review_submission_before_issue: `Review this HeyClaude submission draft before an issue is opened:
2189
+ Use get_submission_schema, get_submission_examples, prepare_submission_draft, review_submission_draft, and search_duplicate_entries. Return missing fields and the canonical PR-first submit URL/body. Do not create GitHub issues or publish content.`,
2190
+ review_submission_before_pr: `Review this HeyClaude submission draft before a PR is opened:
1244
2191
 
1245
2192
  ${draft || "(draft not provided)"}
1246
2193
 
@@ -1304,7 +2251,7 @@ export async function getInstallGuidance(args = {}, options = {}) {
1304
2251
  return {
1305
2252
  ok: true,
1306
2253
  key: `${entry.category}:${entry.slug}`,
1307
- canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
2254
+ canonicalUrl: entryCanonicalUrl(entry),
1308
2255
  title: entry.title,
1309
2256
  installCommand: entry.installCommand || entry.commandSyntax || "",
1310
2257
  configSnippet: entry.configSnippet || "",
@@ -1312,6 +2259,9 @@ export async function getInstallGuidance(args = {}, options = {}) {
1312
2259
  downloadUrl: entry.downloadUrl || "",
1313
2260
  documentationUrl: entry.documentationUrl || "",
1314
2261
  repoUrl: entry.repoUrl || "",
2262
+ safetyNotes: notes(entry.safetyNotes),
2263
+ privacyNotes: notes(entry.privacyNotes),
2264
+ trust: entryTrustSummary(entry),
1315
2265
  platform: platform || "",
1316
2266
  selectedCompatibility,
1317
2267
  platformCompatibility: compatibility,
@@ -1428,6 +2378,112 @@ export async function reviewSubmissionDraft(args = {}, options = {}) {
1428
2378
  return reviewSubmissionDraftFromSpec(spec, args, unwrapEntries(searchIndex));
1429
2379
  }
1430
2380
 
2381
+ export async function getSubmissionPolicy() {
2382
+ return {
2383
+ ok: true,
2384
+ publicPolicy: MCP_PUBLIC_POLICY,
2385
+ reviewModel: {
2386
+ prFirst: true,
2387
+ maintainerReviewRequired: true,
2388
+ autoMerge: "content_only_private_gate",
2389
+ autoMergeRequires: [
2390
+ "single content file only",
2391
+ "validate-content",
2392
+ "Superagent Security Scan",
2393
+ "private maintainer-agent review",
2394
+ ],
2395
+ mutatingAutomationOwner: "private submission gate",
2396
+ },
2397
+ artifactPolicy: {
2398
+ communityHostedArchivesAllowed: false,
2399
+ communityZipHostingAllowed: false,
2400
+ communityMcpbHostingAllowed: false,
2401
+ maintainerBuiltDownloadsOnly: true,
2402
+ firstPartyDownloadsRequireVerification: true,
2403
+ },
2404
+ submissionGuidance: [
2405
+ "Use source-backed or copyable-content submissions for community content.",
2406
+ "Do not request public HeyClaude /downloads hosting for community ZIP/MCPB artifacts.",
2407
+ "Add safety_notes when a submission runs code, writes externally, uses permissions, or starts background workers.",
2408
+ "Add privacy_notes when a submission reads local files, logs, credentials, telemetry, or third-party user data.",
2409
+ "Commercial, affiliate, sponsored, or paid product listings go through maintainer review and disclosure, not the free content queue.",
2410
+ ],
2411
+ };
2412
+ }
2413
+
2414
+ export async function explainEntryTrust(args = {}, options = {}) {
2415
+ const category = normalizeText(args.category);
2416
+ const slug = normalizeText(args.slug);
2417
+ if (!category || !slug) {
2418
+ return invalid("category and slug are required.");
2419
+ }
2420
+
2421
+ const entry = await readEntry(category, slug, options);
2422
+ if (!entry) {
2423
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
2424
+ }
2425
+
2426
+ return {
2427
+ ok: true,
2428
+ key: `${entry.category}:${entry.slug}`,
2429
+ title: entry.title,
2430
+ canonicalUrl: entryCanonicalUrl(entry),
2431
+ trust: entryTrustSummary(entry),
2432
+ };
2433
+ }
2434
+
2435
+ export async function reviewEntrySafety(args = {}, options = {}) {
2436
+ const platform = normalizePlatform(args.platform);
2437
+ const entries = [];
2438
+ for (const target of args.entries || []) {
2439
+ const category = normalizeText(target.category);
2440
+ const slug = normalizeText(target.slug);
2441
+ const entry = await readEntry(category, slug, options);
2442
+ if (!entry) {
2443
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
2444
+ }
2445
+ const compatibility = buildSkillPlatformCompatibility(entry);
2446
+ entries.push({
2447
+ key: `${entry.category}:${entry.slug}`,
2448
+ category: entry.category,
2449
+ slug: entry.slug,
2450
+ title: entry.title,
2451
+ canonicalUrl: entryCanonicalUrl(entry),
2452
+ selectedCompatibility: platform
2453
+ ? compatibility.find((item) => item.platform === platform) || null
2454
+ : null,
2455
+ trust: entryTrustSummary(entry),
2456
+ });
2457
+ }
2458
+
2459
+ const entriesWithNotes = entries.filter(
2460
+ (entry) =>
2461
+ entry.trust.disclosures.hasSafetyNotes ||
2462
+ entry.trust.disclosures.hasPrivacyNotes,
2463
+ );
2464
+
2465
+ return {
2466
+ ok: true,
2467
+ platform: platform || "",
2468
+ count: entries.length,
2469
+ entries,
2470
+ summary: {
2471
+ entriesWithSafetyOrPrivacyNotes: entriesWithNotes.length,
2472
+ firstPartyPackages: entries.filter(
2473
+ (entry) => entry.trust.package.downloadTrust === "first-party",
2474
+ ).length,
2475
+ sourceBacked: entries.filter(
2476
+ (entry) => entry.trust.source.status === "available",
2477
+ ).length,
2478
+ },
2479
+ reviewNotes: [
2480
+ "This is a metadata review, not a malware scan or install verdict.",
2481
+ "Prefer source-backed entries and first-party maintainer-built downloads when installing packages.",
2482
+ "Inspect commands, requested permissions, and external writes before running any copied content.",
2483
+ ],
2484
+ };
2485
+ }
2486
+
1431
2487
  export async function callRegistryTool(name, args = {}, options = {}) {
1432
2488
  if (!READ_ONLY_TOOL_NAMES.includes(name)) {
1433
2489
  return invalid(`Unknown read-only HeyClaude MCP tool: ${name}`);
@@ -1452,6 +2508,9 @@ export async function callRegistryTool(name, args = {}, options = {}) {
1452
2508
  case "search_registry":
1453
2509
  result = await searchRegistry(parsedArgs, options);
1454
2510
  break;
2511
+ case "plan_workflow_toolbox":
2512
+ result = await planWorkflowToolbox(parsedArgs, options);
2513
+ break;
1455
2514
  case "server_info":
1456
2515
  result = await getServerInfo(parsedArgs, options);
1457
2516
  break;
@@ -1515,6 +2574,15 @@ export async function callRegistryTool(name, args = {}, options = {}) {
1515
2574
  case "review_submission_draft":
1516
2575
  result = await reviewSubmissionDraft(parsedArgs, options);
1517
2576
  break;
2577
+ case "get_submission_policy":
2578
+ result = await getSubmissionPolicy(parsedArgs, options);
2579
+ break;
2580
+ case "explain_entry_trust":
2581
+ result = await explainEntryTrust(parsedArgs, options);
2582
+ break;
2583
+ case "review_entry_safety":
2584
+ result = await reviewEntrySafety(parsedArgs, options);
2585
+ break;
1518
2586
  }
1519
2587
 
1520
2588
  return withPublicPolicy(result);