@heyclaude/mcp 0.1.2 → 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.
package/src/registry.js CHANGED
@@ -7,15 +7,21 @@ import {
7
7
  platformFeedSlug,
8
8
  SITE_URL,
9
9
  } from "./platforms.js";
10
+ import { DEFAULT_REMOTE_MCP_URL } from "./endpoint-url.js";
11
+ import { packageName, packageVersion } from "./package-metadata.js";
10
12
  import {
11
13
  formatZodError,
12
14
  jsonSchemaForTool,
15
+ jsonSchemaForToolOutput,
13
16
  parseToolArguments,
14
17
  } from "./schemas.js";
15
18
  import {
16
19
  buildSubmissionUrlsFromSpec,
20
+ getSubmissionExamplesFromSpec,
17
21
  getCategorySubmissionGuidanceFromSpec,
22
+ prepareSubmissionDraftFromSpec,
18
23
  getSubmissionSchemaFromSpec,
24
+ reviewSubmissionDraftFromSpec,
19
25
  searchDuplicateEntries,
20
26
  validateSubmissionDraftFromSpec,
21
27
  } from "./submissions.js";
@@ -26,6 +32,17 @@ const repoRoot = path.resolve(
26
32
  );
27
33
  const defaultDataDir = path.join(repoRoot, "apps", "web", "public", "data");
28
34
  const safePathPartPattern = /^[a-z0-9-]+$/;
35
+ const jsonMimeType = "application/json";
36
+
37
+ export const MCP_PUBLIC_POLICY = {
38
+ apiKeyRequired: false,
39
+ readOnly: true,
40
+ createsIssues: false,
41
+ createsPullRequests: false,
42
+ publishesContent: false,
43
+ writesLocalFiles: false,
44
+ note: "HeyClaude MCP tools only read public registry artifacts or prepare maintainer-reviewed submission drafts.",
45
+ };
29
46
 
30
47
  const platformAliases = new Map([
31
48
  ["claude", "Claude"],
@@ -43,7 +60,15 @@ const platformAliases = new Map([
43
60
 
44
61
  export const READ_ONLY_TOOL_NAMES = [
45
62
  "search_registry",
63
+ "server_info",
64
+ "list_category_entries",
65
+ "get_recent_updates",
66
+ "get_related_entries",
46
67
  "get_entry_detail",
68
+ "get_copyable_asset",
69
+ "compare_entries",
70
+ "get_registry_stats",
71
+ "get_client_setup",
47
72
  "get_compatibility",
48
73
  "get_install_guidance",
49
74
  "get_platform_adapter",
@@ -53,6 +78,9 @@ export const READ_ONLY_TOOL_NAMES = [
53
78
  "search_duplicate_entries",
54
79
  "build_submission_urls",
55
80
  "get_category_submission_guidance",
81
+ "prepare_submission_draft",
82
+ "get_submission_examples",
83
+ "review_submission_draft",
56
84
  ];
57
85
 
58
86
  export const TOOL_DEFINITIONS = [
@@ -61,6 +89,37 @@ export const TOOL_DEFINITIONS = [
61
89
  description:
62
90
  "Search read-only HeyClaude registry entries by query, category, and skill platform compatibility.",
63
91
  inputSchema: jsonSchemaForTool("search_registry"),
92
+ outputSchema: jsonSchemaForToolOutput("search_registry"),
93
+ annotations: {
94
+ readOnlyHint: true,
95
+ destructiveHint: false,
96
+ idempotentHint: true,
97
+ openWorldHint: false,
98
+ },
99
+ },
100
+ {
101
+ name: "server_info",
102
+ description:
103
+ "Fetch read-only HeyClaude MCP package, registry, tool, and public rate-limit metadata.",
104
+ inputSchema: jsonSchemaForTool("server_info"),
105
+ },
106
+ {
107
+ name: "list_category_entries",
108
+ description:
109
+ "List read-only HeyClaude entries with bounded pagination and optional category, platform, tag, and query filters.",
110
+ inputSchema: jsonSchemaForTool("list_category_entries"),
111
+ },
112
+ {
113
+ name: "get_recent_updates",
114
+ description:
115
+ "List recently added or upstream-updated HeyClaude entries from generated registry metadata.",
116
+ inputSchema: jsonSchemaForTool("get_recent_updates"),
117
+ },
118
+ {
119
+ name: "get_related_entries",
120
+ description:
121
+ "Fetch read-only related HeyClaude entries based on category, tags, platforms, keywords, and source metadata.",
122
+ inputSchema: jsonSchemaForTool("get_related_entries"),
64
123
  },
65
124
  {
66
125
  name: "get_entry_detail",
@@ -68,6 +127,30 @@ export const TOOL_DEFINITIONS = [
68
127
  "Fetch a read-only HeyClaude registry entry detail payload by category and slug.",
69
128
  inputSchema: jsonSchemaForTool("get_entry_detail"),
70
129
  },
130
+ {
131
+ name: "get_copyable_asset",
132
+ description:
133
+ "Fetch the category-aware copy/install asset for a HeyClaude entry without writing local files.",
134
+ inputSchema: jsonSchemaForTool("get_copyable_asset"),
135
+ },
136
+ {
137
+ name: "compare_entries",
138
+ description:
139
+ "Compare 2-5 read-only HeyClaude entries by fit, category, platforms, source metadata, and install complexity.",
140
+ inputSchema: jsonSchemaForTool("compare_entries"),
141
+ },
142
+ {
143
+ name: "get_registry_stats",
144
+ description:
145
+ "Fetch aggregate read-only registry stats, freshness, category counts, and real source-signal coverage.",
146
+ inputSchema: jsonSchemaForTool("get_registry_stats"),
147
+ },
148
+ {
149
+ name: "get_client_setup",
150
+ description:
151
+ "Fetch read-only MCP client setup snippets for Codex, Claude Desktop, Cursor, Windsurf, or remote HTTP clients.",
152
+ inputSchema: jsonSchemaForTool("get_client_setup"),
153
+ },
71
154
  {
72
155
  name: "get_compatibility",
73
156
  description:
@@ -122,8 +205,36 @@ export const TOOL_DEFINITIONS = [
122
205
  "Fetch category-specific HeyClaude contribution guidance, required fields, and review expectations.",
123
206
  inputSchema: jsonSchemaForTool("get_category_submission_guidance"),
124
207
  },
208
+ {
209
+ name: "prepare_submission_draft",
210
+ description:
211
+ "Build a read-only maintainer-reviewed HeyClaude submission draft with canonical issue text and URLs.",
212
+ inputSchema: jsonSchemaForTool("prepare_submission_draft"),
213
+ },
214
+ {
215
+ name: "get_submission_examples",
216
+ description:
217
+ "Fetch read-only category examples and templates for faster, more accurate HeyClaude submissions.",
218
+ inputSchema: jsonSchemaForTool("get_submission_examples"),
219
+ },
220
+ {
221
+ name: "review_submission_draft",
222
+ description:
223
+ "Review a HeyClaude submission draft locally for schema errors, duplicate risk, and maintainer checklist items without writing to GitHub.",
224
+ inputSchema: jsonSchemaForTool("review_submission_draft"),
225
+ },
125
226
  ];
126
227
 
228
+ for (const tool of TOOL_DEFINITIONS) {
229
+ tool.outputSchema ||= jsonSchemaForToolOutput(tool.name);
230
+ tool.annotations ||= {
231
+ readOnlyHint: true,
232
+ destructiveHint: false,
233
+ idempotentHint: true,
234
+ openWorldHint: false,
235
+ };
236
+ }
237
+
127
238
  function dataDirFromOptions(options = {}) {
128
239
  return options.dataDir || process.env.HEYCLAUDE_DATA_DIR || defaultDataDir;
129
240
  }
@@ -180,6 +291,12 @@ function normalizeLimit(value, fallback = 10) {
180
291
  return Math.max(1, Math.min(25, Math.trunc(numeric)));
181
292
  }
182
293
 
294
+ function normalizeOffset(value) {
295
+ const numeric = Number(value);
296
+ if (!Number.isFinite(numeric)) return 0;
297
+ return Math.max(0, Math.min(5000, Math.trunc(numeric)));
298
+ }
299
+
183
300
  function normalizePlatform(value) {
184
301
  const normalized = normalizeText(value).replace(/[^a-z0-9]+/g, "-");
185
302
  if (!normalized) return "";
@@ -211,6 +328,13 @@ function entryMatchesPlatform(entry, platform) {
211
328
  return (entry.platforms || []).some((candidate) => candidate === platform);
212
329
  }
213
330
 
331
+ function entryMatchesTag(entry, tag) {
332
+ if (!tag) return true;
333
+ return (entry.tags || []).some(
334
+ (candidate) => normalizeText(candidate) === tag,
335
+ );
336
+ }
337
+
214
338
  function toSearchResult(entry) {
215
339
  return {
216
340
  key: `${entry.category}:${entry.slug}`,
@@ -232,6 +356,170 @@ function toSearchResult(entry) {
232
356
  };
233
357
  }
234
358
 
359
+ function toEntrySummary(entry) {
360
+ return {
361
+ ...toSearchResult(entry),
362
+ dateAdded: entry.dateAdded || "",
363
+ repoUpdatedAt: entry.repoUpdatedAt || null,
364
+ verificationStatus: entry.verificationStatus || "",
365
+ installable: Boolean(entry.installable),
366
+ supportLevels: entry.supportLevels || [],
367
+ };
368
+ }
369
+
370
+ function entryUpdatedAt(entry) {
371
+ return String(
372
+ entry.repoUpdatedAt || entry.updatedAt || entry.dateAdded || "",
373
+ );
374
+ }
375
+
376
+ function sourceHost(value) {
377
+ const text = String(value || "").trim();
378
+ if (!text) return "";
379
+ try {
380
+ return new URL(text).hostname.toLowerCase().replace(/^www\./, "");
381
+ } catch {
382
+ return "";
383
+ }
384
+ }
385
+
386
+ function entrySourceHosts(entry) {
387
+ return [
388
+ entry.documentationUrl,
389
+ entry.repoUrl,
390
+ entry.url,
391
+ entry.canonicalUrl,
392
+ entry.llmsUrl,
393
+ entry.apiUrl,
394
+ ]
395
+ .map(sourceHost)
396
+ .filter(Boolean);
397
+ }
398
+
399
+ function intersection(left = [], right = [], normalize = normalizeText) {
400
+ const rightValues = new Set((right || []).map(normalize).filter(Boolean));
401
+ return (left || [])
402
+ .map(normalize)
403
+ .filter((value, index, values) => value && values.indexOf(value) === index)
404
+ .filter((value) => rightValues.has(value));
405
+ }
406
+
407
+ function unique(values = []) {
408
+ return values.filter(
409
+ (value, index, list) => value && list.indexOf(value) === index,
410
+ );
411
+ }
412
+
413
+ function normalizeDateFloor(value) {
414
+ const text = String(value || "").trim();
415
+ if (!text) return "";
416
+ const timestamp = Date.parse(text);
417
+ if (!Number.isFinite(timestamp)) return "";
418
+ return new Date(timestamp).toISOString().slice(0, 10);
419
+ }
420
+
421
+ function withPublicPolicy(result) {
422
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
423
+ return result;
424
+ }
425
+ if (result.policy) return result;
426
+ return { ...result, policy: MCP_PUBLIC_POLICY };
427
+ }
428
+
429
+ function sourceSummary(entry) {
430
+ return {
431
+ repoUrl: entry.repoUrl || entry.githubUrl || "",
432
+ documentationUrl: entry.documentationUrl || "",
433
+ downloadUrl: entry.downloadUrl || "",
434
+ sourceHosts: unique(entrySourceHosts(entry)),
435
+ githubStars:
436
+ typeof entry.githubStars === "number" ? entry.githubStars : null,
437
+ githubForks:
438
+ typeof entry.githubForks === "number" ? entry.githubForks : null,
439
+ repoUpdatedAt: entry.repoUpdatedAt || null,
440
+ downloadTrust: entry.downloadTrust || null,
441
+ };
442
+ }
443
+
444
+ function contentAsset(type, label, content, format = "markdown") {
445
+ const text =
446
+ content && typeof content === "object"
447
+ ? JSON.stringify(content, null, 2)
448
+ : String(content || "").trim();
449
+ if (!text) return null;
450
+ return {
451
+ type,
452
+ label,
453
+ format,
454
+ content: text,
455
+ length: text.length,
456
+ };
457
+ }
458
+
459
+ function categoryPrimaryAsset(entry) {
460
+ const assets = [
461
+ contentAsset(
462
+ "full_content",
463
+ "Full usable entry content",
464
+ entry.fullCopyableContent || entry.copySnippet || entry.body,
465
+ ),
466
+ contentAsset(
467
+ "install_command",
468
+ "Install command",
469
+ entry.installCommand,
470
+ "shell",
471
+ ),
472
+ contentAsset(
473
+ "config_snippet",
474
+ "Configuration snippet",
475
+ entry.configSnippet,
476
+ "text",
477
+ ),
478
+ contentAsset("script", "Script body", entry.scriptBody, "text"),
479
+ contentAsset(
480
+ "command_syntax",
481
+ "Command syntax",
482
+ entry.commandSyntax,
483
+ "text",
484
+ ),
485
+ contentAsset("usage", "Usage snippet", entry.usageSnippet, "markdown"),
486
+ contentAsset("items", "Collection items", entry.items, "json"),
487
+ ].filter(Boolean);
488
+
489
+ const preferredByCategory = {
490
+ agents: ["full_content", "usage"],
491
+ rules: ["full_content", "script", "usage"],
492
+ hooks: ["config_snippet", "script", "install_command", "usage"],
493
+ mcp: ["config_snippet", "install_command", "usage"],
494
+ skills: ["install_command", "full_content", "usage"],
495
+ statuslines: ["config_snippet", "script", "full_content", "usage"],
496
+ commands: ["command_syntax", "install_command", "full_content", "usage"],
497
+ collections: ["items", "full_content", "usage"],
498
+ guides: ["full_content", "usage"],
499
+ };
500
+ const preferred = preferredByCategory[entry.category] || ["full_content"];
501
+ return (
502
+ preferred
503
+ .map((type) => assets.find((asset) => asset.type === type))
504
+ .find(Boolean) ||
505
+ assets[0] ||
506
+ null
507
+ );
508
+ }
509
+
510
+ function entryInstallComplexity(entry) {
511
+ const pieces = [
512
+ entry.installCommand,
513
+ entry.configSnippet,
514
+ entry.downloadUrl,
515
+ entry.prerequisites,
516
+ ].filter((value) => String(value || "").trim());
517
+ if (pieces.length >= 3) return "higher";
518
+ if (pieces.length === 2) return "medium";
519
+ if (pieces.length === 1) return "low";
520
+ return "unknown";
521
+ }
522
+
235
523
  async function readEntry(category, slug, options = {}) {
236
524
  if (!isSafePathPart(category) || !isSafePathPart(slug)) {
237
525
  return null;
@@ -285,6 +573,190 @@ export async function searchRegistry(args = {}, options = {}) {
285
573
  };
286
574
  }
287
575
 
576
+ export async function getServerInfo(args = {}, options = {}) {
577
+ const manifest = await readJsonArtifact("registry-manifest.json", options);
578
+ return {
579
+ ok: true,
580
+ package: {
581
+ name: packageName,
582
+ version: packageVersion,
583
+ },
584
+ endpoint: {
585
+ url: DEFAULT_REMOTE_MCP_URL,
586
+ auth: "none",
587
+ transport: "streamable-http",
588
+ stdioBridge: "npx -y @heyclaude/mcp",
589
+ requestBodyLimitBytes: 64 * 1024,
590
+ rateLimit: {
591
+ scope: "mcp-streamable",
592
+ limit: 60,
593
+ windowSeconds: 60,
594
+ binding: "API_MCP_RATE_LIMIT",
595
+ note: "Cloudflare enforces the durable production limit when the binding is available; local/dev falls back to an in-process limiter.",
596
+ },
597
+ },
598
+ registry: {
599
+ schemaVersion: manifest.schemaVersion,
600
+ generatedAt: manifest.generatedAt,
601
+ totalEntries: manifest.totalEntries,
602
+ categories: manifest.categories || {},
603
+ },
604
+ tools: READ_ONLY_TOOL_NAMES,
605
+ policy: MCP_PUBLIC_POLICY,
606
+ };
607
+ }
608
+
609
+ export async function listCategoryEntries(args = {}, options = {}) {
610
+ const category = normalizeText(args.category);
611
+ const platform = normalizePlatform(args.platform);
612
+ const tag = normalizeText(args.tag);
613
+ const query = normalizeText(args.query);
614
+ const offset = normalizeOffset(args.offset);
615
+ const limit = normalizeLimit(args.limit, 20);
616
+ const searchIndex = unwrapEntries(
617
+ await readJsonArtifact("search-index.json", options),
618
+ );
619
+
620
+ const entries = searchIndex
621
+ .filter((entry) => !category || entry.category === category)
622
+ .filter((entry) => entryMatchesPlatform(entry, platform))
623
+ .filter((entry) => entryMatchesTag(entry, tag))
624
+ .filter((entry) => entryMatchesQuery(entry, query));
625
+ const page = entries.slice(offset, offset + limit).map(toEntrySummary);
626
+
627
+ return {
628
+ ok: true,
629
+ category: category || "",
630
+ platform: platform || "",
631
+ tag: tag || "",
632
+ query: args.query || "",
633
+ total: entries.length,
634
+ count: page.length,
635
+ offset,
636
+ limit,
637
+ nextOffset: offset + limit < entries.length ? offset + limit : null,
638
+ entries: page,
639
+ };
640
+ }
641
+
642
+ export async function getRecentUpdates(args = {}, options = {}) {
643
+ const category = normalizeText(args.category);
644
+ const since = args.since ? normalizeDateFloor(args.since) : "";
645
+ if (args.since && !since) {
646
+ return invalid("since must be a parseable date such as 2026-05-01.");
647
+ }
648
+ const limit = normalizeLimit(args.limit, 10);
649
+ const searchIndex = unwrapEntries(
650
+ await readJsonArtifact("search-index.json", options),
651
+ );
652
+ const entries = searchIndex
653
+ .filter((entry) => !category || entry.category === category)
654
+ .filter((entry) => !since || entryUpdatedAt(entry) >= since)
655
+ .slice()
656
+ .sort((left, right) => {
657
+ const dateCompare = entryUpdatedAt(right).localeCompare(
658
+ entryUpdatedAt(left),
659
+ );
660
+ if (dateCompare !== 0) return dateCompare;
661
+ return String(left.title || "").localeCompare(String(right.title || ""));
662
+ })
663
+ .slice(0, limit)
664
+ .map((entry) => ({
665
+ ...toEntrySummary(entry),
666
+ updatedAt: entryUpdatedAt(entry),
667
+ updateKind: entry.repoUpdatedAt ? "upstream_update" : "added",
668
+ }));
669
+
670
+ return {
671
+ ok: true,
672
+ category: category || "",
673
+ since,
674
+ count: entries.length,
675
+ entries,
676
+ };
677
+ }
678
+
679
+ function scoreRelatedEntry(target, candidate) {
680
+ if (
681
+ target.category === candidate.category &&
682
+ target.slug === candidate.slug
683
+ ) {
684
+ return null;
685
+ }
686
+
687
+ const sharedTags = intersection(target.tags, candidate.tags);
688
+ const sharedKeywords = intersection(target.keywords, candidate.keywords);
689
+ const sharedPlatforms = intersection(
690
+ target.platforms,
691
+ candidate.platforms,
692
+ (value) => String(value || ""),
693
+ );
694
+ const sharedHosts = intersection(
695
+ entrySourceHosts(target),
696
+ entrySourceHosts(candidate),
697
+ (value) => String(value || ""),
698
+ );
699
+ const score =
700
+ (target.category === candidate.category ? 4 : 0) +
701
+ sharedTags.length * 3 +
702
+ Math.min(sharedKeywords.length, 6) +
703
+ sharedPlatforms.length +
704
+ sharedHosts.length * 2;
705
+
706
+ if (score <= 0) return null;
707
+ return {
708
+ score,
709
+ reasons: [
710
+ ...(target.category === candidate.category ? ["same_category"] : []),
711
+ ...sharedTags.map((tag) => `tag:${tag}`),
712
+ ...sharedPlatforms.map((platform) => `platform:${platform}`),
713
+ ...sharedHosts.map((host) => `source:${host}`),
714
+ ],
715
+ };
716
+ }
717
+
718
+ export async function getRelatedEntries(args = {}, options = {}) {
719
+ const category = normalizeText(args.category);
720
+ const slug = normalizeText(args.slug);
721
+ const limit = normalizeLimit(args.limit, 8);
722
+ const searchIndex = unwrapEntries(
723
+ await readJsonArtifact("search-index.json", options),
724
+ );
725
+ const target = searchIndex.find(
726
+ (entry) => entry.category === category && entry.slug === slug,
727
+ );
728
+ if (!target) {
729
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
730
+ }
731
+
732
+ const entries = searchIndex
733
+ .map((entry) => {
734
+ const related = scoreRelatedEntry(target, entry);
735
+ return related ? { entry, related } : null;
736
+ })
737
+ .filter(Boolean)
738
+ .sort((left, right) => {
739
+ const scoreCompare = right.related.score - left.related.score;
740
+ if (scoreCompare !== 0) return scoreCompare;
741
+ return entryUpdatedAt(right.entry).localeCompare(
742
+ entryUpdatedAt(left.entry),
743
+ );
744
+ })
745
+ .slice(0, limit)
746
+ .map(({ entry, related }) => ({
747
+ ...toEntrySummary(entry),
748
+ relatedScore: related.score,
749
+ relatedReasons: related.reasons,
750
+ }));
751
+
752
+ return {
753
+ ok: true,
754
+ key: `${target.category}:${target.slug}`,
755
+ count: entries.length,
756
+ entries,
757
+ };
758
+ }
759
+
288
760
  export async function getEntryDetail(args = {}, options = {}) {
289
761
  const category = normalizeText(args.category);
290
762
  const slug = normalizeText(args.slug);
@@ -305,6 +777,493 @@ export async function getEntryDetail(args = {}, options = {}) {
305
777
  };
306
778
  }
307
779
 
780
+ export async function getCopyableAsset(args = {}, options = {}) {
781
+ const category = normalizeText(args.category);
782
+ const slug = normalizeText(args.slug);
783
+ const platform = normalizePlatform(args.platform);
784
+ if (!category || !slug) {
785
+ return invalid("category and slug are required.");
786
+ }
787
+
788
+ const entry = await readEntry(category, slug, options);
789
+ if (!entry) {
790
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
791
+ }
792
+
793
+ const primary = categoryPrimaryAsset(entry);
794
+ const assets = [
795
+ contentAsset(
796
+ "full_content",
797
+ "Full usable entry content",
798
+ entry.fullCopyableContent || entry.copySnippet || entry.body,
799
+ ),
800
+ contentAsset(
801
+ "install_command",
802
+ "Install command",
803
+ entry.installCommand,
804
+ "shell",
805
+ ),
806
+ contentAsset(
807
+ "config_snippet",
808
+ "Configuration snippet",
809
+ entry.configSnippet,
810
+ "text",
811
+ ),
812
+ contentAsset("script", "Script body", entry.scriptBody, "text"),
813
+ contentAsset(
814
+ "command_syntax",
815
+ "Command syntax",
816
+ entry.commandSyntax,
817
+ "text",
818
+ ),
819
+ contentAsset("usage", "Usage snippet", entry.usageSnippet, "markdown"),
820
+ contentAsset("items", "Collection items", entry.items, "json"),
821
+ ].filter(Boolean);
822
+ const compatibility = buildSkillPlatformCompatibility(entry);
823
+
824
+ return {
825
+ ok: true,
826
+ key: `${entry.category}:${entry.slug}`,
827
+ category: entry.category,
828
+ slug: entry.slug,
829
+ title: entry.title,
830
+ canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
831
+ platform: platform || "",
832
+ primaryAsset: primary,
833
+ assets,
834
+ installCommand: entry.installCommand || "",
835
+ configSnippet: entry.configSnippet || "",
836
+ usageSnippet: entry.usageSnippet || "",
837
+ downloadUrl: entry.downloadUrl || "",
838
+ platformCompatibility: compatibility,
839
+ source: sourceSummary(entry),
840
+ };
841
+ }
842
+
843
+ export async function compareEntries(args = {}, options = {}) {
844
+ const platform = normalizePlatform(args.platform);
845
+ const entries = [];
846
+ for (const target of args.entries || []) {
847
+ const category = normalizeText(target.category);
848
+ const slug = normalizeText(target.slug);
849
+ const entry = await readEntry(category, slug, options);
850
+ if (!entry) {
851
+ return notFound(`No HeyClaude entry found for ${category}/${slug}.`);
852
+ }
853
+ entries.push(entry);
854
+ }
855
+
856
+ const compared = entries.map((entry) => {
857
+ const compatibility = buildSkillPlatformCompatibility(entry);
858
+ const selectedCompatibility = platform
859
+ ? compatibility.find((item) => item.platform === platform) || null
860
+ : null;
861
+ return {
862
+ key: `${entry.category}:${entry.slug}`,
863
+ category: entry.category,
864
+ slug: entry.slug,
865
+ title: entry.title,
866
+ description: entry.description,
867
+ canonicalUrl: `${SITE_URL}/${entry.category}/${entry.slug}`,
868
+ tags: entry.tags || [],
869
+ platforms: entry.platforms || [],
870
+ selectedCompatibility,
871
+ installComplexity: entryInstallComplexity(entry),
872
+ copyableAssetTypes: [
873
+ categoryPrimaryAsset(entry)?.type,
874
+ entry.configSnippet ? "config_snippet" : "",
875
+ entry.installCommand ? "install_command" : "",
876
+ entry.scriptBody ? "script" : "",
877
+ ].filter(Boolean),
878
+ source: sourceSummary(entry),
879
+ };
880
+ });
881
+
882
+ return {
883
+ ok: true,
884
+ platform: platform || "",
885
+ count: compared.length,
886
+ sharedTags: intersection(
887
+ compared[0]?.tags || [],
888
+ compared.slice(1).flatMap((entry) => entry.tags || []),
889
+ ),
890
+ entries: compared,
891
+ comparisonNotes: [
892
+ "Prefer exact category fit before source popularity.",
893
+ "Treat GitHub stars/forks as source signals only when present; absence is not a negative ranking.",
894
+ "Install complexity is derived from available install/config/download/prerequisite metadata.",
895
+ ],
896
+ };
897
+ }
898
+
899
+ export async function getRegistryStats(args = {}, options = {}) {
900
+ const [manifest, searchIndexPayload] = await Promise.all([
901
+ readJsonArtifact("registry-manifest.json", options),
902
+ readJsonArtifact("search-index.json", options),
903
+ ]);
904
+ const entries = unwrapEntries(searchIndexPayload);
905
+ const platformCounts = new Map();
906
+ const tagCounts = new Map();
907
+ for (const entry of entries) {
908
+ for (const platform of entry.platforms || []) {
909
+ platformCounts.set(platform, (platformCounts.get(platform) || 0) + 1);
910
+ }
911
+ for (const tag of entry.tags || []) {
912
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
913
+ }
914
+ }
915
+
916
+ return {
917
+ ok: true,
918
+ package: {
919
+ name: packageName,
920
+ version: packageVersion,
921
+ },
922
+ registry: {
923
+ schemaVersion: manifest.schemaVersion,
924
+ generatedAt: manifest.generatedAt,
925
+ totalEntries: manifest.totalEntries,
926
+ categories: manifest.categories || {},
927
+ },
928
+ freshness: {
929
+ entriesWithRepoUpdatedAt: entries.filter((entry) => entry.repoUpdatedAt)
930
+ .length,
931
+ entriesAddedLast30Days: entries.filter((entry) => {
932
+ const added = Date.parse(entry.dateAdded || "");
933
+ return (
934
+ Number.isFinite(added) &&
935
+ Date.now() - added <= 30 * 24 * 60 * 60 * 1000
936
+ );
937
+ }).length,
938
+ },
939
+ sourceSignals: {
940
+ entriesWithGithubStats: entries.filter(
941
+ (entry) => typeof entry.githubStars === "number",
942
+ ).length,
943
+ installableEntries: entries.filter((entry) => entry.installable).length,
944
+ },
945
+ platforms: Object.fromEntries(
946
+ [...platformCounts.entries()].sort((left, right) =>
947
+ left[0].localeCompare(right[0]),
948
+ ),
949
+ ),
950
+ topTags: [...tagCounts.entries()]
951
+ .sort(
952
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
953
+ )
954
+ .slice(0, 20)
955
+ .map(([tag, count]) => ({ tag, count })),
956
+ };
957
+ }
958
+
959
+ export async function getClientSetup(args = {}) {
960
+ const endpointUrl = args.endpointUrl || DEFAULT_REMOTE_MCP_URL;
961
+ const snippets = {
962
+ codex: {
963
+ label: "Codex stdio bridge",
964
+ config: {
965
+ mcpServers: {
966
+ heyclaude: {
967
+ command: "npx",
968
+ args: ["-y", "@heyclaude/mcp"],
969
+ },
970
+ },
971
+ },
972
+ },
973
+ "claude-desktop": {
974
+ label: "Claude Desktop stdio bridge",
975
+ config: {
976
+ mcpServers: {
977
+ heyclaude: {
978
+ command: "npx",
979
+ args: ["-y", "@heyclaude/mcp"],
980
+ },
981
+ },
982
+ },
983
+ },
984
+ cursor: {
985
+ label: "Cursor remote MCP",
986
+ config: {
987
+ mcpServers: {
988
+ heyclaude: {
989
+ url: endpointUrl,
990
+ },
991
+ },
992
+ },
993
+ },
994
+ windsurf: {
995
+ label: "Windsurf remote MCP",
996
+ config: {
997
+ mcpServers: {
998
+ heyclaude: {
999
+ serverUrl: endpointUrl,
1000
+ },
1001
+ },
1002
+ },
1003
+ },
1004
+ "remote-http": {
1005
+ label: "Streamable HTTP endpoint",
1006
+ endpointUrl,
1007
+ headers: {
1008
+ accept: "application/json, text/event-stream",
1009
+ "content-type": "application/json",
1010
+ },
1011
+ },
1012
+ };
1013
+ const client = args.client || "";
1014
+ return {
1015
+ ok: true,
1016
+ endpointUrl,
1017
+ apiKeyRequired: false,
1018
+ selectedClient: client,
1019
+ snippets: client ? { [client]: snippets[client] } : snippets,
1020
+ notes: [
1021
+ "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.",
1023
+ "Use --url only when testing a custom preview or deployment.",
1024
+ ],
1025
+ };
1026
+ }
1027
+
1028
+ export const RESOURCE_TEMPLATES = [
1029
+ {
1030
+ uriTemplate: "heyclaude://entry/{category}/{slug}",
1031
+ name: "HeyClaude entry detail",
1032
+ title: "HeyClaude entry detail",
1033
+ description:
1034
+ "Read a single generated HeyClaude entry detail artifact as JSON.",
1035
+ mimeType: jsonMimeType,
1036
+ },
1037
+ {
1038
+ uriTemplate: "heyclaude://category/{category}",
1039
+ name: "HeyClaude category entries",
1040
+ title: "HeyClaude category entries",
1041
+ description:
1042
+ "Read generated summary entries for one HeyClaude category as JSON.",
1043
+ mimeType: jsonMimeType,
1044
+ },
1045
+ ];
1046
+
1047
+ export const PROMPT_DEFINITIONS = [
1048
+ {
1049
+ name: "find_best_asset",
1050
+ title: "Find the best Claude asset",
1051
+ description:
1052
+ "Guide a client through searching, comparing, and recommending HeyClaude entries for a use case.",
1053
+ arguments: [
1054
+ {
1055
+ name: "use_case",
1056
+ description: "The task, workflow, or problem the user wants to solve.",
1057
+ required: true,
1058
+ },
1059
+ {
1060
+ name: "category",
1061
+ description: "Optional HeyClaude category to constrain discovery.",
1062
+ },
1063
+ {
1064
+ name: "platform",
1065
+ description:
1066
+ "Optional client/platform such as Claude, Codex, Cursor, or Windsurf.",
1067
+ },
1068
+ ],
1069
+ },
1070
+ {
1071
+ name: "prepare_submission",
1072
+ title: "Prepare a HeyClaude submission",
1073
+ description:
1074
+ "Guide a user through drafting a maintainer-reviewed HeyClaude submission without opening an issue automatically.",
1075
+ arguments: [
1076
+ { name: "category", description: "Submission category.", required: true },
1077
+ { name: "name", description: "Submission name or title." },
1078
+ {
1079
+ name: "source_url",
1080
+ description: "Primary source, docs, package, or repo URL.",
1081
+ },
1082
+ ],
1083
+ },
1084
+ {
1085
+ name: "review_submission_before_issue",
1086
+ title: "Review submission before opening issue",
1087
+ description:
1088
+ "Check a draft for schema gaps, duplicate risk, source review, and maintainer checklist items.",
1089
+ arguments: [
1090
+ {
1091
+ name: "draft",
1092
+ description: "A concise description or JSON-shaped draft fields.",
1093
+ required: true,
1094
+ },
1095
+ ],
1096
+ },
1097
+ {
1098
+ name: "install_asset_safely",
1099
+ title: "Install a HeyClaude asset safely",
1100
+ description:
1101
+ "Guide installation/use of one entry while keeping source and secret-handling checks explicit.",
1102
+ arguments: [
1103
+ { name: "category", description: "Entry category.", required: true },
1104
+ { name: "slug", description: "Entry slug.", required: true },
1105
+ { name: "platform", description: "Optional target client/platform." },
1106
+ ],
1107
+ },
1108
+ ];
1109
+
1110
+ export async function listRegistryResources(args = {}, options = {}) {
1111
+ const manifest = await readJsonArtifact("registry-manifest.json", options);
1112
+ const categories = Object.keys(manifest.categories || {}).sort();
1113
+ return {
1114
+ resources: [
1115
+ {
1116
+ uri: "heyclaude://feeds/directory",
1117
+ name: "HeyClaude directory index",
1118
+ title: "HeyClaude directory index",
1119
+ description: "Generated public directory index artifact.",
1120
+ mimeType: jsonMimeType,
1121
+ },
1122
+ ...categories.map((category) => ({
1123
+ uri: `heyclaude://category/${category}`,
1124
+ name: `HeyClaude ${category} category`,
1125
+ title: `HeyClaude ${category}`,
1126
+ description: `Generated public ${category} category summary entries.`,
1127
+ mimeType: jsonMimeType,
1128
+ })),
1129
+ ],
1130
+ };
1131
+ }
1132
+
1133
+ export function listRegistryResourceTemplates() {
1134
+ return {
1135
+ resourceTemplates: RESOURCE_TEMPLATES,
1136
+ };
1137
+ }
1138
+
1139
+ export async function readRegistryResource(args = {}, options = {}) {
1140
+ const uri = String(args.uri || "");
1141
+ const resourcePayload = (payload) => ({
1142
+ contents: [
1143
+ {
1144
+ uri: uri || "heyclaude://error",
1145
+ mimeType: jsonMimeType,
1146
+ text: JSON.stringify(withPublicPolicy(payload), null, 2),
1147
+ },
1148
+ ],
1149
+ });
1150
+ let parsed;
1151
+ try {
1152
+ parsed = new URL(uri);
1153
+ } catch {
1154
+ return resourcePayload(
1155
+ notFound(`Unsupported HeyClaude resource URI: ${uri}`),
1156
+ );
1157
+ }
1158
+ if (parsed.protocol !== "heyclaude:") {
1159
+ return resourcePayload(
1160
+ notFound(`Unsupported HeyClaude resource URI: ${uri}`),
1161
+ );
1162
+ }
1163
+
1164
+ const parts = parsed.pathname.split("/").filter(Boolean);
1165
+ let payload;
1166
+ if (parsed.hostname === "feeds" && parts[0] === "directory") {
1167
+ payload = await readJsonArtifact("directory-index.json", options);
1168
+ } else if (parsed.hostname === "category" && parts.length === 1) {
1169
+ const category = normalizeText(parts[0]);
1170
+ if (!isSafePathPart(category)) {
1171
+ return resourcePayload(
1172
+ invalid("Category resource path is not slug-safe."),
1173
+ );
1174
+ }
1175
+ const entries = unwrapEntries(
1176
+ await readJsonArtifact("search-index.json", options),
1177
+ )
1178
+ .filter((entry) => entry.category === category)
1179
+ .map(toEntrySummary);
1180
+ payload = {
1181
+ ok: true,
1182
+ category,
1183
+ total: entries.length,
1184
+ entries,
1185
+ };
1186
+ } else if (parsed.hostname === "entry" && parts.length === 2) {
1187
+ const [category, slug] = parts.map(normalizeText);
1188
+ const detail = await getEntryDetail({ category, slug }, options);
1189
+ payload = detail;
1190
+ } else {
1191
+ return resourcePayload(
1192
+ notFound(`Unsupported HeyClaude resource URI: ${uri}`),
1193
+ );
1194
+ }
1195
+
1196
+ return resourcePayload(payload);
1197
+ }
1198
+
1199
+ function promptArgument(args, name) {
1200
+ return String(args?.[name] || "").trim();
1201
+ }
1202
+
1203
+ export function listRegistryPrompts() {
1204
+ return {
1205
+ prompts: PROMPT_DEFINITIONS,
1206
+ };
1207
+ }
1208
+
1209
+ export function getRegistryPrompt(args = {}) {
1210
+ const name = String(args.name || "");
1211
+ const prompt = PROMPT_DEFINITIONS.find(
1212
+ (candidate) => candidate.name === name,
1213
+ );
1214
+ if (!prompt) {
1215
+ return {
1216
+ description: "Unknown HeyClaude MCP prompt.",
1217
+ messages: [
1218
+ {
1219
+ role: "user",
1220
+ content: {
1221
+ type: "text",
1222
+ text: `Unknown HeyClaude MCP prompt: ${name}`,
1223
+ },
1224
+ },
1225
+ ],
1226
+ };
1227
+ }
1228
+ const values = args.arguments || {};
1229
+ const useCase = promptArgument(values, "use_case");
1230
+ const category = promptArgument(values, "category");
1231
+ const platform = promptArgument(values, "platform");
1232
+ const slug = promptArgument(values, "slug");
1233
+ const sourceUrl = promptArgument(values, "source_url");
1234
+ const draft = promptArgument(values, "draft");
1235
+
1236
+ const promptTextByName = {
1237
+ find_best_asset: `Find the best HeyClaude asset for this use case: ${useCase || "(not provided)"}.
1238
+
1239
+ 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
+ prepare_submission: `Prepare a HeyClaude submission draft${category ? ` for category ${category}` : ""}${promptArgument(values, "name") ? ` named ${promptArgument(values, "name")}` : ""}${sourceUrl ? ` from ${sourceUrl}` : ""}.
1241
+
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:
1244
+
1245
+ ${draft || "(draft not provided)"}
1246
+
1247
+ Use review_submission_draft and search_duplicate_entries where structured fields are available. Treat schema-valid as not publish-valid, call out source-review needs, and keep the result maintainer-reviewed.`,
1248
+ install_asset_safely: `Help install or use the HeyClaude entry ${category || "(category)"}/${slug || "(slug)"}${platform ? ` for ${platform}` : ""}.
1249
+
1250
+ Use get_install_guidance and get_copyable_asset. Include source links, config/install text exactly as returned, and secret-handling cautions where relevant. Do not write local files or claim the install was completed.`,
1251
+ };
1252
+
1253
+ return {
1254
+ description: prompt.description,
1255
+ messages: [
1256
+ {
1257
+ role: "user",
1258
+ content: {
1259
+ type: "text",
1260
+ text: promptTextByName[name],
1261
+ },
1262
+ },
1263
+ ],
1264
+ };
1265
+ }
1266
+
308
1267
  export async function getCompatibility(args = {}, options = {}) {
309
1268
  const category = normalizeText(args.category || "skills");
310
1269
  const slug = normalizeText(args.slug);
@@ -450,6 +1409,25 @@ export async function getCategorySubmissionGuidance(args = {}, options = {}) {
450
1409
  );
451
1410
  }
452
1411
 
1412
+ export async function prepareSubmissionDraft(args = {}, options = {}) {
1413
+ return prepareSubmissionDraftFromSpec(
1414
+ await readSubmissionSpec(options),
1415
+ args,
1416
+ );
1417
+ }
1418
+
1419
+ export async function getSubmissionExamples(args = {}, options = {}) {
1420
+ return getSubmissionExamplesFromSpec(await readSubmissionSpec(options), args);
1421
+ }
1422
+
1423
+ export async function reviewSubmissionDraft(args = {}, options = {}) {
1424
+ const [spec, searchIndex] = await Promise.all([
1425
+ readSubmissionSpec(options),
1426
+ readJsonArtifact("search-index.json", options),
1427
+ ]);
1428
+ return reviewSubmissionDraftFromSpec(spec, args, unwrapEntries(searchIndex));
1429
+ }
1430
+
453
1431
  export async function callRegistryTool(name, args = {}, options = {}) {
454
1432
  if (!READ_ONLY_TOOL_NAMES.includes(name)) {
455
1433
  return invalid(`Unknown read-only HeyClaude MCP tool: ${name}`);
@@ -469,28 +1447,75 @@ export async function callRegistryTool(name, args = {}, options = {}) {
469
1447
  throw error;
470
1448
  }
471
1449
 
1450
+ let result;
472
1451
  switch (name) {
473
1452
  case "search_registry":
474
- return searchRegistry(parsedArgs, options);
1453
+ result = await searchRegistry(parsedArgs, options);
1454
+ break;
1455
+ case "server_info":
1456
+ result = await getServerInfo(parsedArgs, options);
1457
+ break;
1458
+ case "list_category_entries":
1459
+ result = await listCategoryEntries(parsedArgs, options);
1460
+ break;
1461
+ case "get_recent_updates":
1462
+ result = await getRecentUpdates(parsedArgs, options);
1463
+ break;
1464
+ case "get_related_entries":
1465
+ result = await getRelatedEntries(parsedArgs, options);
1466
+ break;
475
1467
  case "get_entry_detail":
476
- return getEntryDetail(parsedArgs, options);
1468
+ result = await getEntryDetail(parsedArgs, options);
1469
+ break;
1470
+ case "get_copyable_asset":
1471
+ result = await getCopyableAsset(parsedArgs, options);
1472
+ break;
1473
+ case "compare_entries":
1474
+ result = await compareEntries(parsedArgs, options);
1475
+ break;
1476
+ case "get_registry_stats":
1477
+ result = await getRegistryStats(parsedArgs, options);
1478
+ break;
1479
+ case "get_client_setup":
1480
+ result = await getClientSetup(parsedArgs, options);
1481
+ break;
477
1482
  case "get_compatibility":
478
- return getCompatibility(parsedArgs, options);
1483
+ result = await getCompatibility(parsedArgs, options);
1484
+ break;
479
1485
  case "get_install_guidance":
480
- return getInstallGuidance(parsedArgs, options);
1486
+ result = await getInstallGuidance(parsedArgs, options);
1487
+ break;
481
1488
  case "get_platform_adapter":
482
- return getPlatformAdapter(parsedArgs, options);
1489
+ result = await getPlatformAdapter(parsedArgs, options);
1490
+ break;
483
1491
  case "list_distribution_feeds":
484
- return listDistributionFeeds(parsedArgs, options);
1492
+ result = await listDistributionFeeds(parsedArgs, options);
1493
+ break;
485
1494
  case "get_submission_schema":
486
- return getSubmissionSchema(parsedArgs, options);
1495
+ result = await getSubmissionSchema(parsedArgs, options);
1496
+ break;
487
1497
  case "validate_submission_draft":
488
- return validateSubmissionDraft(parsedArgs, options);
1498
+ result = await validateSubmissionDraft(parsedArgs, options);
1499
+ break;
489
1500
  case "search_duplicate_entries":
490
- return searchDuplicateRegistryEntries(parsedArgs, options);
1501
+ result = await searchDuplicateRegistryEntries(parsedArgs, options);
1502
+ break;
491
1503
  case "build_submission_urls":
492
- return buildSubmissionUrls(parsedArgs, options);
1504
+ result = await buildSubmissionUrls(parsedArgs, options);
1505
+ break;
493
1506
  case "get_category_submission_guidance":
494
- return getCategorySubmissionGuidance(parsedArgs, options);
1507
+ result = await getCategorySubmissionGuidance(parsedArgs, options);
1508
+ break;
1509
+ case "prepare_submission_draft":
1510
+ result = await prepareSubmissionDraft(parsedArgs, options);
1511
+ break;
1512
+ case "get_submission_examples":
1513
+ result = await getSubmissionExamples(parsedArgs, options);
1514
+ break;
1515
+ case "review_submission_draft":
1516
+ result = await reviewSubmissionDraft(parsedArgs, options);
1517
+ break;
495
1518
  }
1519
+
1520
+ return withPublicPolicy(result);
496
1521
  }