@cyanheads/mcp-ts-core 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CLAUDE.md +39 -1
  2. package/README.md +1 -1
  3. package/changelog/0.1.x/0.1.0.md +78 -0
  4. package/changelog/0.1.x/0.1.1.md +28 -0
  5. package/changelog/0.1.x/0.1.10.md +32 -0
  6. package/changelog/0.1.x/0.1.11.md +51 -0
  7. package/changelog/0.1.x/0.1.12.md +21 -0
  8. package/changelog/0.1.x/0.1.13.md +16 -0
  9. package/changelog/0.1.x/0.1.14.md +20 -0
  10. package/changelog/0.1.x/0.1.15.md +24 -0
  11. package/changelog/0.1.x/0.1.16.md +17 -0
  12. package/changelog/0.1.x/0.1.17.md +14 -0
  13. package/changelog/0.1.x/0.1.18.md +18 -0
  14. package/changelog/0.1.x/0.1.19.md +19 -0
  15. package/changelog/0.1.x/0.1.2.md +25 -0
  16. package/changelog/0.1.x/0.1.20.md +21 -0
  17. package/changelog/0.1.x/0.1.21.md +17 -0
  18. package/changelog/0.1.x/0.1.22.md +28 -0
  19. package/changelog/0.1.x/0.1.23.md +23 -0
  20. package/changelog/0.1.x/0.1.24.md +17 -0
  21. package/changelog/0.1.x/0.1.25.md +16 -0
  22. package/changelog/0.1.x/0.1.26.md +22 -0
  23. package/changelog/0.1.x/0.1.27.md +30 -0
  24. package/changelog/0.1.x/0.1.28.md +16 -0
  25. package/changelog/0.1.x/0.1.29.md +19 -0
  26. package/changelog/0.1.x/0.1.3.md +22 -0
  27. package/changelog/0.1.x/0.1.4.md +17 -0
  28. package/changelog/0.1.x/0.1.5.md +25 -0
  29. package/changelog/0.1.x/0.1.6.md +26 -0
  30. package/changelog/0.1.x/0.1.7.md +29 -0
  31. package/changelog/0.1.x/0.1.8.md +33 -0
  32. package/changelog/0.1.x/0.1.9.md +19 -0
  33. package/changelog/0.2.x/0.2.0.md +32 -0
  34. package/changelog/0.2.x/0.2.1.md +12 -0
  35. package/changelog/0.2.x/0.2.10.md +38 -0
  36. package/changelog/0.2.x/0.2.11.md +29 -0
  37. package/changelog/0.2.x/0.2.12.md +31 -0
  38. package/changelog/0.2.x/0.2.2.md +19 -0
  39. package/changelog/0.2.x/0.2.3.md +15 -0
  40. package/changelog/0.2.x/0.2.4.md +24 -0
  41. package/changelog/0.2.x/0.2.5.md +27 -0
  42. package/changelog/0.2.x/0.2.6.md +23 -0
  43. package/changelog/0.2.x/0.2.7.md +23 -0
  44. package/changelog/0.2.x/0.2.8.md +12 -0
  45. package/changelog/0.2.x/0.2.9.md +25 -0
  46. package/changelog/0.3.x/0.3.0.md +45 -0
  47. package/changelog/0.3.x/0.3.1.md +16 -0
  48. package/changelog/0.3.x/0.3.2.md +24 -0
  49. package/changelog/0.3.x/0.3.3.md +31 -0
  50. package/changelog/0.3.x/0.3.4.md +31 -0
  51. package/changelog/0.3.x/0.3.5.md +32 -0
  52. package/changelog/0.3.x/0.3.6.md +48 -0
  53. package/changelog/0.3.x/0.3.7.md +23 -0
  54. package/changelog/0.3.x/0.3.8.md +21 -0
  55. package/changelog/0.4.x/0.4.0.md +38 -0
  56. package/changelog/0.4.x/0.4.1.md +31 -0
  57. package/changelog/0.5.x/0.5.0.md +29 -0
  58. package/changelog/0.5.x/0.5.1.md +18 -0
  59. package/changelog/0.5.x/0.5.2.md +38 -0
  60. package/changelog/0.5.x/0.5.3.md +26 -0
  61. package/changelog/0.5.x/0.5.4.md +29 -0
  62. package/changelog/0.6.x/0.6.0.md +39 -0
  63. package/changelog/unreleased.md +40 -0
  64. package/dist/cli/init.js +1 -0
  65. package/dist/cli/init.js.map +1 -1
  66. package/dist/core/app.d.ts +13 -3
  67. package/dist/core/app.d.ts.map +1 -1
  68. package/dist/core/app.js +20 -13
  69. package/dist/core/app.js.map +1 -1
  70. package/dist/core/index.d.ts +1 -0
  71. package/dist/core/index.d.ts.map +1 -1
  72. package/dist/core/index.js.map +1 -1
  73. package/dist/core/serverManifest.d.ts +237 -0
  74. package/dist/core/serverManifest.d.ts.map +1 -0
  75. package/dist/core/serverManifest.js +310 -0
  76. package/dist/core/serverManifest.js.map +1 -0
  77. package/dist/core/worker.d.ts.map +1 -1
  78. package/dist/core/worker.js +2 -2
  79. package/dist/core/worker.js.map +1 -1
  80. package/dist/linter/rules/landing-rules.d.ts +15 -0
  81. package/dist/linter/rules/landing-rules.d.ts.map +1 -0
  82. package/dist/linter/rules/landing-rules.js +125 -0
  83. package/dist/linter/rules/landing-rules.js.map +1 -0
  84. package/dist/linter/types.d.ts +5 -2
  85. package/dist/linter/types.d.ts.map +1 -1
  86. package/dist/linter/validate.d.ts.map +1 -1
  87. package/dist/linter/validate.js +5 -0
  88. package/dist/linter/validate.js.map +1 -1
  89. package/dist/mcp-server/transports/http/httpTransport.d.ts +4 -3
  90. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  91. package/dist/mcp-server/transports/http/httpTransport.js +47 -26
  92. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  93. package/dist/mcp-server/transports/http/httpTypes.d.ts +0 -12
  94. package/dist/mcp-server/transports/http/httpTypes.d.ts.map +1 -1
  95. package/dist/mcp-server/transports/http/landing-page.d.ts +48 -0
  96. package/dist/mcp-server/transports/http/landing-page.d.ts.map +1 -0
  97. package/dist/mcp-server/transports/http/landing-page.js +912 -0
  98. package/dist/mcp-server/transports/http/landing-page.js.map +1 -0
  99. package/dist/mcp-server/transports/http/serverCard.d.ts +67 -0
  100. package/dist/mcp-server/transports/http/serverCard.d.ts.map +1 -0
  101. package/dist/mcp-server/transports/http/serverCard.js +91 -0
  102. package/dist/mcp-server/transports/http/serverCard.js.map +1 -0
  103. package/dist/mcp-server/transports/manager.d.ts +3 -3
  104. package/dist/mcp-server/transports/manager.d.ts.map +1 -1
  105. package/dist/mcp-server/transports/manager.js +4 -4
  106. package/dist/mcp-server/transports/manager.js.map +1 -1
  107. package/dist/utils/formatting/html.d.ts +76 -0
  108. package/dist/utils/formatting/html.d.ts.map +1 -0
  109. package/dist/utils/formatting/html.js +111 -0
  110. package/dist/utils/formatting/html.js.map +1 -0
  111. package/dist/utils/formatting/index.d.ts +1 -0
  112. package/dist/utils/formatting/index.d.ts.map +1 -1
  113. package/dist/utils/formatting/index.js +1 -0
  114. package/dist/utils/formatting/index.js.map +1 -1
  115. package/dist/utils/index.d.ts +1 -1
  116. package/dist/utils/index.d.ts.map +1 -1
  117. package/dist/utils/index.js +1 -1
  118. package/dist/utils/index.js.map +1 -1
  119. package/package.json +5 -1
  120. package/scripts/build-changelog.ts +222 -0
  121. package/scripts/devcheck.ts +16 -1
  122. package/scripts/tree.ts +3 -0
  123. package/skills/add-app-tool/SKILL.md +2 -4
  124. package/skills/add-prompt/SKILL.md +2 -4
  125. package/skills/add-resource/SKILL.md +2 -4
  126. package/skills/add-service/SKILL.md +2 -4
  127. package/skills/add-tool/SKILL.md +6 -5
  128. package/skills/api-context/SKILL.md +2 -2
  129. package/skills/api-services/SKILL.md +1 -1
  130. package/skills/api-services/references/graph.md +1 -1
  131. package/skills/api-utils/SKILL.md +1 -1
  132. package/skills/api-utils/references/parsing.md +1 -1
  133. package/skills/api-utils/references/security.md +1 -1
  134. package/skills/design-mcp-server/SKILL.md +2 -2
  135. package/skills/maintenance/SKILL.md +12 -11
  136. package/skills/polish-docs-meta/SKILL.md +24 -9
  137. package/skills/release/SKILL.md +21 -7
  138. package/skills/setup/SKILL.md +4 -8
  139. package/templates/AGENTS.md +23 -1
  140. package/templates/CLAUDE.md +23 -1
  141. package/templates/changelog/unreleased.md +40 -0
  142. package/templates/package.json +3 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * @fileoverview HTML landing page served at `GET /`. Self-contained,
3
+ * zero-dependency renderer producing a branded, a11y-conscious,
4
+ * `prefers-color-scheme`-aware page from the shared `ServerManifest`.
5
+ *
6
+ * The page is the human-facing sibling of `/mcp` (bespoke JSON) and
7
+ * `/.well-known/mcp.json` (SEP-1649 Server Card). Framework owns the design
8
+ * system; servers supply content through `LandingConfig`.
9
+ *
10
+ * ## Surfaces
11
+ *
12
+ * - Hero — name, clickable version badge, pre-release pill, tagline, logo,
13
+ * auth-status banner, copy-to-clipboard connect snippets
14
+ * - Tools section — counts in header; auto-grouped by shared prefix; per-card
15
+ * annotations, invocation snippet, view-source link, schema preview
16
+ * - Resources section — URI template, mime type, description, view-source link
17
+ * - Prompts section — args list, view-source link
18
+ * - Extensions section — rendered when SEP-2133 extensions are present
19
+ * - Footer — configured links + auto-derived GitHub cluster + npm/registry +
20
+ * attribution
21
+ *
22
+ * @module src/mcp-server/transports/http/landing-page
23
+ */
24
+ import { escapeHtml, html, unsafeRaw } from '../../../utils/formatting/html.js';
25
+ import { logger } from '../../../utils/internal/logger.js';
26
+ import { requestContextService } from '../../../utils/internal/requestContext.js';
27
+ // ---------------------------------------------------------------------------
28
+ // Tokens — inlined once per page
29
+ // ---------------------------------------------------------------------------
30
+ /** Single inlined `<style>` block. No external CSS, no fonts. */
31
+ function renderTokens(accent) {
32
+ const safeAccent = escapeHtml(accent);
33
+ const css = `
34
+ :root {
35
+ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
36
+ --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-10: 40px;
37
+ --space-12: 48px; --space-16: 64px;
38
+
39
+ --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem;
40
+ --text-lg: 1.125rem; --text-xl: 1.25rem; --text-2xl: 1.5rem;
41
+ --text-3xl: 1.875rem; --text-4xl: 2.25rem;
42
+
43
+ --radius-sm: 6px; --radius-md: 8px; --radius-lg: 12px; --radius-pill: 999px;
44
+
45
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
46
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
47
+
48
+ --duration-fast: 120ms; --duration-base: 200ms;
49
+ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
50
+
51
+ --accent: ${safeAccent};
52
+ --accent-fg: color-mix(in oklab, ${safeAccent}, white 85%);
53
+ --accent-hover: color-mix(in oklab, ${safeAccent}, black 12%);
54
+ --accent-soft: color-mix(in oklab, ${safeAccent}, transparent 86%);
55
+
56
+ --bg: #ffffff;
57
+ --bg-subtle: #f6f8fa;
58
+ --bg-elevated: #ffffff;
59
+ --fg: #1f2328;
60
+ --fg-muted: #656d76;
61
+ --fg-subtle: #8c959f;
62
+ --border: #d0d7de;
63
+ --border-subtle: #eaeef2;
64
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.04);
65
+ --shadow-md: 0 4px 16px -4px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.04);
66
+ }
67
+
68
+ @media (prefers-color-scheme: dark) {
69
+ :root {
70
+ --bg: #0d1117;
71
+ --bg-subtle: #161b22;
72
+ --bg-elevated: #161b22;
73
+ --fg: #e6edf3;
74
+ --fg-muted: #8d96a0;
75
+ --fg-subtle: #6e7681;
76
+ --border: #30363d;
77
+ --border-subtle: #21262d;
78
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.3);
79
+ --shadow-md: 0 4px 16px -4px rgb(0 0 0 / 0.4), 0 1px 2px rgb(0 0 0 / 0.2);
80
+ --accent-fg: color-mix(in oklab, ${safeAccent}, white 10%);
81
+ }
82
+ }
83
+
84
+ *, *::before, *::after { box-sizing: border-box; }
85
+
86
+ html { color-scheme: light dark; }
87
+
88
+ body {
89
+ margin: 0;
90
+ font-family: var(--font-sans);
91
+ font-size: var(--text-base);
92
+ line-height: 1.5;
93
+ color: var(--fg);
94
+ background: var(--bg);
95
+ -webkit-font-smoothing: antialiased;
96
+ -moz-osx-font-smoothing: grayscale;
97
+ text-rendering: optimizeLegibility;
98
+ }
99
+
100
+ ::selection { background: var(--accent-soft); color: var(--fg); }
101
+
102
+ main { max-width: 960px; margin: 0 auto; padding: var(--space-8) var(--space-6) var(--space-16); }
103
+
104
+ a {
105
+ color: var(--accent);
106
+ text-decoration: none;
107
+ transition: color var(--duration-fast) var(--ease-out);
108
+ }
109
+ a:hover { color: var(--accent-hover); text-decoration: underline; }
110
+ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
111
+
112
+ code, pre {
113
+ font-family: var(--font-mono);
114
+ font-size: 0.85em;
115
+ }
116
+ code { background: var(--bg-subtle); padding: 0.15em 0.35em; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); }
117
+ pre {
118
+ background: var(--bg-subtle);
119
+ border: 1px solid var(--border-subtle);
120
+ border-radius: var(--radius-md);
121
+ padding: var(--space-4);
122
+ margin: 0;
123
+ overflow-x: auto;
124
+ line-height: 1.5;
125
+ white-space: pre;
126
+ }
127
+ pre code { background: transparent; padding: 0; border: 0; }
128
+
129
+ .hero { padding: var(--space-10) 0 var(--space-8); border-bottom: 1px solid var(--border-subtle); }
130
+ .hero-top { display: flex; align-items: flex-start; gap: var(--space-4); margin-bottom: var(--space-4); }
131
+ .hero-logo { width: 56px; height: 56px; border-radius: var(--radius-md); object-fit: contain; background: var(--bg-subtle); border: 1px solid var(--border-subtle); flex-shrink: 0; }
132
+ .hero-identity { flex: 1; min-width: 0; }
133
+ .hero-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); margin: 0; font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em; color: var(--fg); }
134
+ .hero-tagline { margin: var(--space-3) 0 0; color: var(--fg-muted); font-size: var(--text-lg); max-width: 60ch; }
135
+
136
+ .badge {
137
+ display: inline-flex; align-items: center; gap: var(--space-1);
138
+ padding: 2px var(--space-2);
139
+ border-radius: var(--radius-pill);
140
+ font-size: var(--text-xs);
141
+ font-weight: 500;
142
+ line-height: 1.4;
143
+ border: 1px solid var(--border);
144
+ color: var(--fg-muted);
145
+ background: var(--bg-subtle);
146
+ text-decoration: none;
147
+ white-space: nowrap;
148
+ }
149
+ .badge-version { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); font-weight: 600; }
150
+ .badge-version:hover { background: color-mix(in oklab, var(--accent), transparent 75%); text-decoration: none; }
151
+ .badge-pre { background: color-mix(in oklab, #f59e0b, transparent 85%); border-color: color-mix(in oklab, #f59e0b, transparent 60%); color: #b45309; }
152
+ @media (prefers-color-scheme: dark) { .badge-pre { color: #fbbf24; } }
153
+
154
+ .hero-badges { margin-top: var(--space-4); display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
155
+
156
+ .badge-shield {
157
+ display: inline-flex;
158
+ align-items: stretch;
159
+ border-radius: var(--radius-sm);
160
+ overflow: hidden;
161
+ font-size: 0.7rem;
162
+ font-weight: 600;
163
+ line-height: 1.4;
164
+ letter-spacing: 0.01em;
165
+ text-decoration: none;
166
+ font-family: var(--font-sans);
167
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 0.04), 0 1px 1px rgb(0 0 0 / 0.04);
168
+ transition: transform var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out);
169
+ }
170
+ .badge-shield:hover { text-decoration: none; transform: translateY(-1px); box-shadow: 0 0 0 1px rgb(0 0 0 / 0.08), 0 2px 4px rgb(0 0 0 / 0.08); }
171
+ .badge-shield-label, .badge-shield-value { padding: 3px var(--space-2); white-space: nowrap; }
172
+ .badge-shield-label { background: #555; color: #fff; }
173
+ .badge-shield-value { background: #2259c9; color: #fff; }
174
+ @media (prefers-color-scheme: dark) {
175
+ .badge-shield-label { background: #3a3a3a; }
176
+ .badge-shield-value { background: #3b6fd4; }
177
+ }
178
+
179
+ .auth-banner {
180
+ margin: var(--space-5) 0 0;
181
+ padding: var(--space-3) var(--space-4);
182
+ border-radius: var(--radius-md);
183
+ border: 1px solid var(--border-subtle);
184
+ background: var(--bg-subtle);
185
+ color: var(--fg-muted);
186
+ font-size: var(--text-sm);
187
+ display: flex; align-items: center; gap: var(--space-2);
188
+ }
189
+ .auth-banner-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
190
+ .auth-banner-public .auth-banner-dot { background: #22c55e; }
191
+ .auth-banner-gated .auth-banner-dot { background: var(--accent); }
192
+
193
+ section { padding: var(--space-10) 0 0; }
194
+ .section-heading { display: flex; align-items: baseline; gap: var(--space-3); margin: 0 0 var(--space-6); }
195
+ .section-heading h2 { margin: 0; font-size: var(--text-2xl); font-weight: 600; letter-spacing: -0.01em; }
196
+ .section-count { color: var(--fg-subtle); font-size: var(--text-sm); font-variant-numeric: tabular-nums; font-weight: 500; }
197
+
198
+ .group-heading { margin: var(--space-6) 0 var(--space-3); color: var(--fg-muted); font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; }
199
+ .group-heading:first-child { margin-top: 0; }
200
+
201
+ .card {
202
+ border: 1px solid var(--border-subtle);
203
+ border-radius: var(--radius-lg);
204
+ padding: var(--space-5);
205
+ margin-bottom: var(--space-3);
206
+ background: var(--bg-elevated);
207
+ transition: border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out);
208
+ }
209
+ .card:hover { border-color: var(--border); box-shadow: var(--shadow-sm); }
210
+ .card-head { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-2); }
211
+ .card-title { margin: 0; font-size: var(--text-base); font-weight: 600; font-family: var(--font-mono); color: var(--fg); }
212
+ .card-title a { color: inherit; }
213
+ .card-desc { margin: 0; color: var(--fg-muted); font-size: var(--text-sm); }
214
+ .card-meta { margin-top: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--fg-muted); align-items: center; }
215
+ .card-meta-label { color: var(--fg-subtle); }
216
+ .card-meta code { font-size: 0.9em; }
217
+
218
+ .pill-row { display: inline-flex; flex-wrap: wrap; gap: var(--space-1); }
219
+ .pill {
220
+ display: inline-flex; align-items: center;
221
+ padding: 1px 8px;
222
+ border-radius: var(--radius-pill);
223
+ font-size: 0.7rem;
224
+ font-weight: 500;
225
+ line-height: 1.5;
226
+ border: 1px solid var(--border-subtle);
227
+ color: var(--fg-muted);
228
+ background: var(--bg-subtle);
229
+ }
230
+ .pill-readonly { color: #16a34a; border-color: color-mix(in oklab, #16a34a, transparent 70%); background: color-mix(in oklab, #16a34a, transparent 90%); }
231
+ .pill-destructive { color: #dc2626; border-color: color-mix(in oklab, #dc2626, transparent 70%); background: color-mix(in oklab, #dc2626, transparent 90%); }
232
+ .pill-openworld { color: #2563eb; border-color: color-mix(in oklab, #2563eb, transparent 70%); background: color-mix(in oklab, #2563eb, transparent 90%); }
233
+ .pill-task { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
234
+ .pill-app { color: #9333ea; border-color: color-mix(in oklab, #9333ea, transparent 70%); background: color-mix(in oklab, #9333ea, transparent 90%); }
235
+ .pill-auth { color: var(--fg-muted); font-family: var(--font-mono); font-size: 0.65rem; }
236
+
237
+ .snippet { position: relative; margin-top: var(--space-3); }
238
+ .snippet pre { padding-right: var(--space-12); font-size: 0.8rem; }
239
+ .snippet-copy {
240
+ position: absolute; top: var(--space-2); right: var(--space-2);
241
+ font-family: var(--font-sans);
242
+ font-size: var(--text-xs);
243
+ padding: 2px var(--space-2);
244
+ border-radius: var(--radius-sm);
245
+ border: 1px solid var(--border);
246
+ background: var(--bg);
247
+ color: var(--fg-muted);
248
+ cursor: pointer;
249
+ transition: color var(--duration-fast), border-color var(--duration-fast);
250
+ }
251
+ .snippet-copy:hover { color: var(--accent); border-color: var(--accent); }
252
+ .snippet-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
253
+ .snippet-copy[data-copied="true"] { color: #16a34a; border-color: #16a34a; }
254
+
255
+ details { margin-top: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
256
+ details > summary {
257
+ cursor: pointer;
258
+ padding: var(--space-2) var(--space-3);
259
+ font-size: var(--text-xs);
260
+ color: var(--fg-muted);
261
+ list-style: none;
262
+ user-select: none;
263
+ transition: background var(--duration-fast);
264
+ }
265
+ details > summary::-webkit-details-marker { display: none; }
266
+ details > summary:hover { background: var(--bg-subtle); color: var(--fg); }
267
+ details > summary::before { content: "▸ "; margin-right: 4px; transition: transform var(--duration-fast); display: inline-block; color: var(--fg-subtle); }
268
+ details[open] > summary::before { transform: rotate(90deg); }
269
+ details[open] > summary { border-bottom: 1px solid var(--border-subtle); }
270
+ details > pre { border: 0; border-radius: 0 0 var(--radius-md) var(--radius-md); margin: 0; }
271
+
272
+ .connect-tabs { display: flex; gap: var(--space-1); margin-top: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
273
+ .connect-tab-input { position: absolute; opacity: 0; pointer-events: none; }
274
+ .connect-tab-label {
275
+ padding: var(--space-2) var(--space-4);
276
+ font-size: var(--text-sm);
277
+ color: var(--fg-muted);
278
+ cursor: pointer;
279
+ border-bottom: 2px solid transparent;
280
+ margin-bottom: -1px;
281
+ transition: color var(--duration-fast), border-color var(--duration-fast);
282
+ }
283
+ .connect-tab-label:hover { color: var(--fg); }
284
+ .connect-tab-input:focus-visible + .connect-tab-label { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }
285
+ .connect-panels { position: relative; margin-top: var(--space-3); }
286
+ .connect-panel { display: none; }
287
+ .connect-tab-input:checked + .connect-tab-label { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
288
+ ${[0, 1, 2]
289
+ .map((i) => `.connect-tab-input:nth-of-type(${i + 1}):checked ~ .connect-panels .connect-panel:nth-child(${i + 1}) { display: block; }`)
290
+ .join('\n')}
291
+
292
+ footer {
293
+ margin-top: var(--space-16);
294
+ padding: var(--space-8) 0 var(--space-6);
295
+ border-top: 1px solid var(--border-subtle);
296
+ font-size: var(--text-sm);
297
+ color: var(--fg-muted);
298
+ }
299
+ .footer-links { display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-6); margin-bottom: var(--space-4); }
300
+ .footer-group { display: flex; flex-direction: column; gap: var(--space-2); min-width: 140px; }
301
+ .footer-group-label { color: var(--fg-subtle); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; }
302
+ .footer-attrib { font-size: var(--text-xs); color: var(--fg-subtle); }
303
+ .footer-attrib a { color: inherit; text-decoration: underline; text-decoration-color: var(--border); }
304
+ .footer-attrib a:hover { color: var(--accent); text-decoration-color: var(--accent); }
305
+
306
+ .source-link { font-size: var(--text-xs); color: var(--fg-muted); margin-left: auto; }
307
+ .source-link:hover { color: var(--accent); }
308
+
309
+ .args-list { list-style: none; padding: 0; margin: var(--space-3) 0 0; display: flex; flex-direction: column; gap: var(--space-1); font-size: var(--text-sm); }
310
+ .args-list code { font-size: 0.85em; }
311
+ .args-required { color: var(--accent); font-size: 0.7rem; font-weight: 600; margin-left: var(--space-1); }
312
+
313
+ .ext-card { background: var(--bg-subtle); border-color: var(--border-subtle); }
314
+ .ext-key { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--fg); }
315
+ .ext-preview { margin-top: var(--space-2); font-size: var(--text-xs); color: var(--fg-muted); }
316
+
317
+ .empty-state { padding: var(--space-8); text-align: center; color: var(--fg-muted); border: 1px dashed var(--border); border-radius: var(--radius-md); }
318
+
319
+ @media (max-width: 640px) {
320
+ main { padding: var(--space-5) var(--space-4) var(--space-12); }
321
+ .hero-heading { font-size: var(--text-2xl); }
322
+ .hero-top { flex-direction: column; gap: var(--space-3); }
323
+ .hero-logo { width: 48px; height: 48px; }
324
+ }
325
+
326
+ @media (prefers-reduced-motion: reduce) {
327
+ *, *::before, *::after {
328
+ transition-duration: 0.01ms !important;
329
+ animation-duration: 0.01ms !important;
330
+ }
331
+ }
332
+ `;
333
+ return unsafeRaw(`<style>${css}</style>`);
334
+ }
335
+ // ---------------------------------------------------------------------------
336
+ // Copy-to-clipboard — single inlined script, < 1KB
337
+ // ---------------------------------------------------------------------------
338
+ function renderCopyScript() {
339
+ // Works without JS too — the button is inert but the pre/code is still selectable.
340
+ // `data-copy-target` holds a CSS selector or inline text content.
341
+ const js = `
342
+ document.addEventListener('click', function(e) {
343
+ var btn = e.target.closest('[data-copy]');
344
+ if (!btn) return;
345
+ var selector = btn.getAttribute('data-copy-target');
346
+ var text = '';
347
+ if (selector) {
348
+ var node = document.querySelector(selector);
349
+ if (node) text = node.textContent || '';
350
+ } else {
351
+ text = btn.getAttribute('data-copy') || '';
352
+ }
353
+ if (!text || !navigator.clipboard) return;
354
+ navigator.clipboard.writeText(text).then(function() {
355
+ var prev = btn.textContent;
356
+ btn.setAttribute('data-copied', 'true');
357
+ btn.textContent = 'Copied';
358
+ setTimeout(function() {
359
+ btn.removeAttribute('data-copied');
360
+ btn.textContent = prev;
361
+ }, 1500);
362
+ });
363
+ });`;
364
+ return unsafeRaw(`<script>${js}</script>`);
365
+ }
366
+ // ---------------------------------------------------------------------------
367
+ // Primitives
368
+ // ---------------------------------------------------------------------------
369
+ function renderBadge(label, variant, href) {
370
+ const cls = variant === 'version' ? 'badge badge-version' : variant === 'pre' ? 'badge badge-pre' : 'badge';
371
+ if (href) {
372
+ return html `<a class="${cls}" href="${href}">${label}</a>`;
373
+ }
374
+ return html `<span class="${cls}">${label}</span>`;
375
+ }
376
+ /**
377
+ * shields.io-style bi-part badge: grey "Built on" label + accent framework name.
378
+ * Links to the framework's npm page for discoverability. Lives in the hero
379
+ * badge strip when `landing.attribution` is enabled.
380
+ */
381
+ function renderFrameworkBadge(framework) {
382
+ const npmUrl = `https://www.npmjs.com/package/${encodeURIComponent(framework.name)}`;
383
+ return html `<a class="badge-shield" href="${npmUrl}" rel="noopener" aria-label="Built on ${framework.name} v${framework.version}"><span class="badge-shield-label">Built on</span><span class="badge-shield-value">${framework.name} v${framework.version}</span></a>`;
384
+ }
385
+ function renderPill(text, variant) {
386
+ return html `<span class="pill pill-${variant}">${text}</span>`;
387
+ }
388
+ function renderAuthBanner(auth) {
389
+ if (auth.mode === 'none') {
390
+ return html `<div class="auth-banner auth-banner-public" role="status"><span class="auth-banner-dot" aria-hidden="true"></span><span>Public access — no authentication required.</span></div>`;
391
+ }
392
+ if (auth.mode === 'jwt') {
393
+ return html `<div class="auth-banner auth-banner-gated" role="status"><span class="auth-banner-dot" aria-hidden="true"></span><span>Requires a bearer token.</span></div>`;
394
+ }
395
+ // oauth
396
+ const issuer = auth.oauthIssuer ? html ` <a href="${auth.oauthIssuer}">Sign in ↗</a>` : html ``;
397
+ return html `<div class="auth-banner auth-banner-gated" role="status"><span class="auth-banner-dot" aria-hidden="true"></span><span>Requires OAuth.${issuer}</span></div>`;
398
+ }
399
+ function renderSectionHeading(id, label, count) {
400
+ return html `
401
+ <div class="section-heading">
402
+ <h2 id="${id}">${label}</h2>
403
+ <span class="section-count" aria-label="${String(count)} ${label.toLowerCase()}">(${count})</span>
404
+ </div>
405
+ `;
406
+ }
407
+ function renderSnippet(id, text, variant = 'code') {
408
+ const targetId = `snippet-${id}`;
409
+ return html `
410
+ <div class="snippet">
411
+ <pre id="${targetId}" class="snippet-${variant}"><code>${text}</code></pre>
412
+ <button type="button" class="snippet-copy" data-copy data-copy-target="#${targetId}" aria-label="Copy">Copy</button>
413
+ </div>
414
+ `;
415
+ }
416
+ // ---------------------------------------------------------------------------
417
+ // Hero
418
+ // ---------------------------------------------------------------------------
419
+ function renderHero(manifest, baseUrl) {
420
+ const { server, landing } = manifest;
421
+ const releaseUrl = landing.repoRoot
422
+ ? `${landing.repoRoot.url}/releases/tag/v${server.version}`
423
+ : undefined;
424
+ const versionBadge = renderBadge(`v${server.version}`, 'version', releaseUrl);
425
+ const preReleaseBadge = landing.preRelease.isPreRelease
426
+ ? renderBadge(landing.preRelease.label ?? 'pre-release', 'pre')
427
+ : html ``;
428
+ const tagline = landing.tagline ?? server.description ?? '';
429
+ const logo = landing.logo
430
+ ? html `<img class="hero-logo" src="${landing.logo}" alt="" aria-hidden="true" />`
431
+ : html ``;
432
+ const frameworkBadge = landing.attribution
433
+ ? html `<div class="hero-badges">${renderFrameworkBadge(manifest.framework)}</div>`
434
+ : html ``;
435
+ return html `
436
+ <header class="hero">
437
+ <div class="hero-top">
438
+ ${logo}
439
+ <div class="hero-identity">
440
+ <h1 class="hero-heading">
441
+ <span>${server.name}</span>
442
+ ${versionBadge}
443
+ ${preReleaseBadge}
444
+ </h1>
445
+ ${tagline ? html `<p class="hero-tagline">${tagline}</p>` : html ``}
446
+ ${frameworkBadge}
447
+ </div>
448
+ </div>
449
+ ${renderAuthBanner(manifest.auth)}
450
+ ${renderConnectSnippets(manifest, baseUrl)}
451
+ </header>
452
+ `;
453
+ }
454
+ function renderConnectSnippets(manifest, baseUrl) {
455
+ const endpoint = `${baseUrl.replace(/\/$/, '')}${manifest.transport.endpointPath}`;
456
+ const claudeDesktopConfig = JSON.stringify({
457
+ mcpServers: {
458
+ [manifest.server.name]: {
459
+ type: 'http',
460
+ url: endpoint,
461
+ },
462
+ },
463
+ }, null, 2);
464
+ const mcpRemoteConfig = JSON.stringify({
465
+ mcpServers: {
466
+ [manifest.server.name]: {
467
+ command: 'npx',
468
+ args: ['-y', 'mcp-remote', endpoint],
469
+ },
470
+ },
471
+ }, null, 2);
472
+ const curl = [
473
+ `curl -X POST ${endpoint} \\`,
474
+ ` -H "Content-Type: application/json" \\`,
475
+ ` -H "MCP-Protocol-Version: ${manifest.protocol.latestVersion}" \\`,
476
+ ` -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"${manifest.protocol.latestVersion}","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'`,
477
+ ].join('\n');
478
+ // Radio-hack tabs — no JS required for tab switching.
479
+ return html `
480
+ <div class="connect-tabs" role="tablist" aria-label="Connection snippets">
481
+ <input type="radio" class="connect-tab-input" name="connect" id="connect-tab-http" checked />
482
+ <label for="connect-tab-http" class="connect-tab-label" role="tab">HTTP client</label>
483
+ <input type="radio" class="connect-tab-input" name="connect" id="connect-tab-remote" />
484
+ <label for="connect-tab-remote" class="connect-tab-label" role="tab">mcp-remote (stdio)</label>
485
+ <input type="radio" class="connect-tab-input" name="connect" id="connect-tab-curl" />
486
+ <label for="connect-tab-curl" class="connect-tab-label" role="tab">curl</label>
487
+ <div class="connect-panels">
488
+ <div class="connect-panel" role="tabpanel">${renderSnippet('http', claudeDesktopConfig)}</div>
489
+ <div class="connect-panel" role="tabpanel">${renderSnippet('remote', mcpRemoteConfig)}</div>
490
+ <div class="connect-panel" role="tabpanel">${renderSnippet('curl', curl)}</div>
491
+ </div>
492
+ </div>
493
+ `;
494
+ }
495
+ // ---------------------------------------------------------------------------
496
+ // Tools section
497
+ // ---------------------------------------------------------------------------
498
+ function groupToolsByPrefix(tools) {
499
+ if (tools.length < 3)
500
+ return [{ label: null, tools }];
501
+ // Count first-segment prefixes. A prefix earns a group when >= 2 tools share it.
502
+ const prefixCounts = new Map();
503
+ for (const tool of tools) {
504
+ const prefix = tool.name.split('_', 1)[0];
505
+ if (!prefix)
506
+ continue;
507
+ prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
508
+ }
509
+ const groupablePrefixes = new Set([...prefixCounts.entries()].filter(([, count]) => count >= 2).map(([p]) => p));
510
+ if (groupablePrefixes.size === 0)
511
+ return [{ label: null, tools }];
512
+ // Preserve encounter order; create one group per qualifying prefix plus "Other".
513
+ const groups = new Map();
514
+ const other = [];
515
+ for (const tool of tools) {
516
+ const prefix = tool.name.split('_', 1)[0];
517
+ if (prefix && groupablePrefixes.has(prefix)) {
518
+ const list = groups.get(prefix) ?? [];
519
+ list.push(tool);
520
+ groups.set(prefix, list);
521
+ }
522
+ else {
523
+ other.push(tool);
524
+ }
525
+ }
526
+ const out = [];
527
+ for (const [prefix, list] of groups) {
528
+ out.push({ label: titleCase(prefix), tools: list });
529
+ }
530
+ if (other.length > 0)
531
+ out.push({ label: 'Other', tools: other });
532
+ return out;
533
+ }
534
+ function titleCase(s) {
535
+ return s.charAt(0).toUpperCase() + s.slice(1);
536
+ }
537
+ function buildInvocationSnippet(tool) {
538
+ const args = {};
539
+ for (const field of tool.requiredFields) {
540
+ args[field] = `<${field}>`;
541
+ }
542
+ return JSON.stringify({
543
+ jsonrpc: '2.0',
544
+ id: 1,
545
+ method: 'tools/call',
546
+ params: {
547
+ name: tool.name,
548
+ arguments: args,
549
+ },
550
+ }, null, 2);
551
+ }
552
+ function renderToolCard(tool) {
553
+ const anchor = `tool-${tool.name}`;
554
+ const annotations = tool.annotations;
555
+ const pills = [];
556
+ if (annotations?.readOnlyHint)
557
+ pills.push(renderPill('read-only', 'readonly'));
558
+ if (annotations?.destructiveHint === true)
559
+ pills.push(renderPill('destructive', 'destructive'));
560
+ if (annotations?.openWorldHint)
561
+ pills.push(renderPill('open-world', 'openworld'));
562
+ if (tool.isTask)
563
+ pills.push(renderPill('task', 'task'));
564
+ if (tool.isApp)
565
+ pills.push(renderPill('app', 'app'));
566
+ const source = tool.sourceUrl
567
+ ? html `<a class="source-link" href="${tool.sourceUrl}" rel="noopener">view source ↗</a>`
568
+ : html ``;
569
+ const schemaPreview = tool.inputSchema
570
+ ? html `
571
+ <details>
572
+ <summary>Input schema</summary>
573
+ <pre><code>${JSON.stringify(tool.inputSchema, null, 2)}</code></pre>
574
+ </details>
575
+ `
576
+ : html ``;
577
+ const invocation = buildInvocationSnippet(tool);
578
+ const authBadges = tool.auth && tool.auth.length > 0
579
+ ? html `<div class="card-meta"><span class="card-meta-label">scopes:</span>${tool.auth.map((scope) => html ` <span class="pill pill-auth">${scope}</span>`)}</div>`
580
+ : html ``;
581
+ return html `
582
+ <article class="card" id="${anchor}">
583
+ <div class="card-head">
584
+ <h3 class="card-title"><a href="#${anchor}">${tool.name}</a></h3>
585
+ <div class="pill-row" role="list">${pills}</div>
586
+ ${source}
587
+ </div>
588
+ <p class="card-desc">${tool.description}</p>
589
+ ${authBadges}
590
+ ${renderSnippet(`tool-${tool.name}`, invocation)}
591
+ ${schemaPreview}
592
+ </article>
593
+ `;
594
+ }
595
+ function renderToolsSection(tools) {
596
+ if (tools.length === 0)
597
+ return html ``;
598
+ const groups = groupToolsByPrefix(tools);
599
+ const body = groups.map((group) => {
600
+ const heading = group.label ? html `<h4 class="group-heading">${group.label}</h4>` : html ``;
601
+ return html `${heading}${group.tools.map(renderToolCard)}`;
602
+ });
603
+ return html `
604
+ <section aria-labelledby="section-tools">
605
+ ${renderSectionHeading('section-tools', 'Tools', tools.length)}
606
+ ${body}
607
+ </section>
608
+ `;
609
+ }
610
+ // ---------------------------------------------------------------------------
611
+ // Resources section
612
+ // ---------------------------------------------------------------------------
613
+ function slugifyUri(template) {
614
+ return template
615
+ .replace(/[^a-z0-9]+/gi, '-')
616
+ .replace(/^-+|-+$/g, '')
617
+ .toLowerCase();
618
+ }
619
+ function renderResourceCard(resource) {
620
+ const anchor = `resource-${slugifyUri(resource.uriTemplate || resource.name)}`;
621
+ const source = resource.sourceUrl
622
+ ? html `<a class="source-link" href="${resource.sourceUrl}" rel="noopener">view source ↗</a>`
623
+ : html ``;
624
+ return html `
625
+ <article class="card" id="${anchor}">
626
+ <div class="card-head">
627
+ <h3 class="card-title"><a href="#${anchor}">${resource.name}</a></h3>
628
+ ${source}
629
+ </div>
630
+ <p class="card-desc">${resource.description}</p>
631
+ <div class="card-meta">
632
+ <span><span class="card-meta-label">uri:</span> <code>${resource.uriTemplate}</code></span>
633
+ ${resource.mimeType ? html `<span><span class="card-meta-label">mime:</span> <code>${resource.mimeType}</code></span>` : html ``}
634
+ </div>
635
+ </article>
636
+ `;
637
+ }
638
+ function renderResourcesSection(resources) {
639
+ if (resources.length === 0)
640
+ return html ``;
641
+ return html `
642
+ <section aria-labelledby="section-resources">
643
+ ${renderSectionHeading('section-resources', 'Resources', resources.length)}
644
+ ${resources.map(renderResourceCard)}
645
+ </section>
646
+ `;
647
+ }
648
+ // ---------------------------------------------------------------------------
649
+ // Prompts section
650
+ // ---------------------------------------------------------------------------
651
+ function renderPromptCard(prompt) {
652
+ const anchor = `prompt-${prompt.name}`;
653
+ const source = prompt.sourceUrl
654
+ ? html `<a class="source-link" href="${prompt.sourceUrl}" rel="noopener">view source ↗</a>`
655
+ : html ``;
656
+ const argsList = prompt.args.length > 0
657
+ ? html `
658
+ <ul class="args-list">
659
+ ${prompt.args.map((arg) => html `
660
+ <li>
661
+ <code>${arg.name}</code>${arg.required ? html `<span class="args-required">required</span>` : html ``}
662
+ ${arg.description ? html ` — ${arg.description}` : html ``}
663
+ </li>
664
+ `)}
665
+ </ul>
666
+ `
667
+ : html ``;
668
+ return html `
669
+ <article class="card" id="${anchor}">
670
+ <div class="card-head">
671
+ <h3 class="card-title"><a href="#${anchor}">${prompt.name}</a></h3>
672
+ ${source}
673
+ </div>
674
+ <p class="card-desc">${prompt.description}</p>
675
+ ${argsList}
676
+ </article>
677
+ `;
678
+ }
679
+ function renderPromptsSection(prompts) {
680
+ if (prompts.length === 0)
681
+ return html ``;
682
+ return html `
683
+ <section aria-labelledby="section-prompts">
684
+ ${renderSectionHeading('section-prompts', 'Prompts', prompts.length)}
685
+ ${prompts.map(renderPromptCard)}
686
+ </section>
687
+ `;
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // Extensions section
691
+ // ---------------------------------------------------------------------------
692
+ function renderExtensionsSection(extensions) {
693
+ if (!extensions || Object.keys(extensions).length === 0)
694
+ return html ``;
695
+ const entries = Object.entries(extensions);
696
+ return html `
697
+ <section aria-labelledby="section-extensions">
698
+ ${renderSectionHeading('section-extensions', 'Extensions', entries.length)}
699
+ ${entries.map(([key, value]) => html `
700
+ <article class="card ext-card">
701
+ <div class="card-head">
702
+ <h3 class="card-title ext-key">${key}</h3>
703
+ </div>
704
+ <pre class="ext-preview"><code>${JSON.stringify(value, null, 2)}</code></pre>
705
+ </article>
706
+ `)}
707
+ </section>
708
+ `;
709
+ }
710
+ // ---------------------------------------------------------------------------
711
+ // Footer
712
+ // ---------------------------------------------------------------------------
713
+ function renderFooter(manifest) {
714
+ const { landing, framework } = manifest;
715
+ const groups = [];
716
+ // User-supplied links
717
+ if (landing.links.length > 0) {
718
+ groups.push({
719
+ label: 'Links',
720
+ links: landing.links.map((l) => ({ href: l.href, label: l.label })),
721
+ });
722
+ }
723
+ // Auto-derived GitHub cluster
724
+ if (landing.repoRoot) {
725
+ const repo = landing.repoRoot;
726
+ const version = manifest.server.version;
727
+ groups.push({
728
+ label: 'Repository',
729
+ links: [
730
+ { href: landing.changelogUrl ?? `${repo.url}/blob/main/CHANGELOG.md`, label: 'Changelog' },
731
+ { href: `${repo.url}/releases/tag/v${version}`, label: `Release v${version}` },
732
+ { href: `${repo.url}/issues`, label: 'Issues' },
733
+ { href: repo.url, label: 'Source' },
734
+ ],
735
+ });
736
+ }
737
+ // Package / registry
738
+ if (landing.npmPackage) {
739
+ groups.push({
740
+ label: 'Registry',
741
+ links: [{ href: landing.npmPackage.url, label: `npm: ${landing.npmPackage.name}` }],
742
+ });
743
+ }
744
+ const groupsHtml = groups.map((g) => html `
745
+ <div class="footer-group">
746
+ <span class="footer-group-label">${g.label}</span>
747
+ ${g.links.map((l) => html `<a href="${l.href}" rel="noopener">${l.label}</a>`)}
748
+ </div>
749
+ `);
750
+ const frameworkNpm = `https://www.npmjs.com/package/${encodeURIComponent(framework.name)}`;
751
+ const attribution = landing.attribution
752
+ ? html `<p class="footer-attrib">Built on <a href="${framework.homepage}">${framework.name}</a> v${framework.version} · <a href="${frameworkNpm}" rel="noopener">npm</a></p>`
753
+ : html ``;
754
+ return html `
755
+ <footer>
756
+ ${groups.length > 0 ? html `<div class="footer-links">${groupsHtml}</div>` : html ``}
757
+ ${attribution}
758
+ </footer>
759
+ `;
760
+ }
761
+ // ---------------------------------------------------------------------------
762
+ // <head> metadata
763
+ // ---------------------------------------------------------------------------
764
+ function renderHead(manifest, pageUrl) {
765
+ const { server, landing } = manifest;
766
+ const title = `${server.name} · MCP server`;
767
+ const description = landing.tagline ?? server.description ?? `MCP server: ${server.name}`;
768
+ const ogImage = landing.logo?.startsWith('http')
769
+ ? html `<meta property="og:image" content="${landing.logo}" />`
770
+ : html ``;
771
+ const favicon = landing.logo && isImageDataUri(landing.logo)
772
+ ? html `<link rel="icon" href="${landing.logo}" />`
773
+ : html ``;
774
+ const jsonLd = JSON.stringify({
775
+ '@context': 'https://schema.org',
776
+ '@type': 'SoftwareApplication',
777
+ name: server.name,
778
+ description,
779
+ applicationCategory: 'DeveloperApplication',
780
+ softwareVersion: server.version,
781
+ ...(server.homepage && { url: server.homepage }),
782
+ });
783
+ return html `
784
+ <meta charset="utf-8" />
785
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
786
+ <title>${title}</title>
787
+ <meta name="description" content="${description}" />
788
+ <meta property="og:title" content="${title}" />
789
+ <meta property="og:description" content="${description}" />
790
+ <meta property="og:type" content="website" />
791
+ <meta property="og:url" content="${pageUrl}" />
792
+ ${ogImage}
793
+ <meta name="twitter:card" content="summary" />
794
+ <meta name="twitter:title" content="${title}" />
795
+ <meta name="twitter:description" content="${description}" />
796
+ ${favicon}
797
+ <link rel="mcp-endpoint" href="${manifest.transport.endpointPath}" />
798
+ <link rel="alternate" type="application/json" href="/.well-known/mcp.json" title="MCP Server Card" />
799
+ <script type="application/ld+json">${unsafeRaw(escapeLdJson(jsonLd))}</script>
800
+ `;
801
+ }
802
+ function isImageDataUri(value) {
803
+ if (!value)
804
+ return false;
805
+ return /^data:image\/(png|svg\+xml|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon)/i.test(value);
806
+ }
807
+ /**
808
+ * Escape a JSON string for safe embedding inside `<script type="application/ld+json">`.
809
+ * Replaces HTML-sensitive characters with Unicode escapes — `<` → `<`,
810
+ * `>` → `>`, `&` → `&`, U+2028/U+2029 line terminators → escaped.
811
+ * Matches the hardening recommendation in https://html.spec.whatwg.org/#script-data-state.
812
+ */
813
+ function escapeLdJson(json) {
814
+ return json
815
+ .replace(/</g, '\\u003c')
816
+ .replace(/>/g, '\\u003e')
817
+ .replace(/&/g, '\\u0026')
818
+ .replace(/\u2028/g, '\\u2028')
819
+ .replace(/\u2029/g, '\\u2029');
820
+ }
821
+ // ---------------------------------------------------------------------------
822
+ // Page composition
823
+ // ---------------------------------------------------------------------------
824
+ /**
825
+ * Render the full landing page. `baseUrl` is the request origin
826
+ * (e.g. `https://pubmed.example.com`) — used in connect snippets and OG meta.
827
+ * `degraded` reduces the body when `requireAuth` gates unauth callers.
828
+ */
829
+ export function renderLandingPage(manifest, baseUrl, degraded = false) {
830
+ const pageUrl = `${baseUrl.replace(/\/$/, '')}/`;
831
+ const landing = manifest.landing;
832
+ const body = degraded
833
+ ? html `
834
+ ${renderHero(manifest, baseUrl)}
835
+ <section>
836
+ <p class="empty-state">
837
+ Full server inventory is available to authenticated callers.
838
+ </p>
839
+ </section>
840
+ ${renderFooter(manifest)}
841
+ `
842
+ : html `
843
+ ${renderHero(manifest, baseUrl)}
844
+ ${renderToolsSection(manifest.definitions.tools)}
845
+ ${renderResourcesSection(manifest.definitions.resources)}
846
+ ${renderPromptsSection(manifest.definitions.prompts)}
847
+ ${renderExtensionsSection(manifest.extensions)}
848
+ ${renderFooter(manifest)}
849
+ `;
850
+ const doc = html `<!DOCTYPE html>
851
+ <html lang="en">
852
+ <head>
853
+ ${renderHead(manifest, pageUrl)}
854
+ ${renderTokens(landing.theme.accent)}
855
+ </head>
856
+ <body>
857
+ <main>${body}</main>
858
+ ${renderCopyScript()}
859
+ </body>
860
+ </html>`;
861
+ return doc.toString();
862
+ }
863
+ // ---------------------------------------------------------------------------
864
+ // Hono handler
865
+ // ---------------------------------------------------------------------------
866
+ /**
867
+ * Factory for the `GET /` route handler.
868
+ *
869
+ * Cache behavior and body shape depend on `manifest.landing.requireAuth`:
870
+ *
871
+ * | `requireAuth` | Authenticated | Unauthenticated |
872
+ * |:---|:---|:---|
873
+ * | `false` (default) | full page · `Cache-Control: public, max-age=60` | full page · same |
874
+ * | `true` | full page · `Cache-Control: private, max-age=60` · `Vary: Authorization` | reduced hero-only page · same cache headers |
875
+ *
876
+ * The check is header-presence based — we don't validate the bearer token
877
+ * here (that's the MCP endpoint's job). If a caller presents any Authorization
878
+ * header, the full inventory renders; if not, they see a stub and a pointer
879
+ * to the docs link when available.
880
+ */
881
+ export function createLandingPageHandler(manifest) {
882
+ return (c) => {
883
+ const context = requestContextService.createRequestContext({
884
+ operation: 'landingPageHandler',
885
+ });
886
+ const url = new URL(c.req.url);
887
+ const baseUrl = url.origin;
888
+ const requireAuth = manifest.landing.requireAuth;
889
+ const authHeader = c.req.header('authorization');
890
+ const isAuthenticated = Boolean(authHeader && authHeader.trim().length > 0);
891
+ const degraded = requireAuth && !isAuthenticated;
892
+ const html = renderLandingPage(manifest, baseUrl, degraded);
893
+ logger.debug('Serving landing page.', {
894
+ ...context,
895
+ accept: c.req.header('accept'),
896
+ bytes: html.length,
897
+ requireAuth,
898
+ degraded,
899
+ });
900
+ c.header('Content-Type', 'text/html; charset=utf-8');
901
+ c.header('X-Content-Type-Options', 'nosniff');
902
+ if (requireAuth) {
903
+ c.header('Cache-Control', 'private, max-age=60');
904
+ c.header('Vary', 'Authorization');
905
+ }
906
+ else {
907
+ c.header('Cache-Control', 'public, max-age=60');
908
+ }
909
+ return c.body(html);
910
+ };
911
+ }
912
+ //# sourceMappingURL=landing-page.js.map