@cyanheads/mcp-ts-core 0.5.4 → 0.6.1

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 (143) 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/0.6.x/0.6.1.md +22 -0
  64. package/changelog/unreleased.md +40 -0
  65. package/dist/cli/init.js +1 -0
  66. package/dist/cli/init.js.map +1 -1
  67. package/dist/core/app.d.ts +13 -3
  68. package/dist/core/app.d.ts.map +1 -1
  69. package/dist/core/app.js +20 -13
  70. package/dist/core/app.js.map +1 -1
  71. package/dist/core/index.d.ts +1 -0
  72. package/dist/core/index.d.ts.map +1 -1
  73. package/dist/core/index.js.map +1 -1
  74. package/dist/core/serverManifest.d.ts +259 -0
  75. package/dist/core/serverManifest.d.ts.map +1 -0
  76. package/dist/core/serverManifest.js +334 -0
  77. package/dist/core/serverManifest.js.map +1 -0
  78. package/dist/core/worker.d.ts.map +1 -1
  79. package/dist/core/worker.js +2 -2
  80. package/dist/core/worker.js.map +1 -1
  81. package/dist/linter/rules/landing-rules.d.ts +15 -0
  82. package/dist/linter/rules/landing-rules.d.ts.map +1 -0
  83. package/dist/linter/rules/landing-rules.js +145 -0
  84. package/dist/linter/rules/landing-rules.js.map +1 -0
  85. package/dist/linter/types.d.ts +5 -2
  86. package/dist/linter/types.d.ts.map +1 -1
  87. package/dist/linter/validate.d.ts.map +1 -1
  88. package/dist/linter/validate.js +5 -0
  89. package/dist/linter/validate.js.map +1 -1
  90. package/dist/mcp-server/transports/http/httpTransport.d.ts +4 -3
  91. package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
  92. package/dist/mcp-server/transports/http/httpTransport.js +47 -26
  93. package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
  94. package/dist/mcp-server/transports/http/httpTypes.d.ts +0 -12
  95. package/dist/mcp-server/transports/http/httpTypes.d.ts.map +1 -1
  96. package/dist/mcp-server/transports/http/landing-page.d.ts +48 -0
  97. package/dist/mcp-server/transports/http/landing-page.d.ts.map +1 -0
  98. package/dist/mcp-server/transports/http/landing-page.js +1529 -0
  99. package/dist/mcp-server/transports/http/landing-page.js.map +1 -0
  100. package/dist/mcp-server/transports/http/serverCard.d.ts +67 -0
  101. package/dist/mcp-server/transports/http/serverCard.d.ts.map +1 -0
  102. package/dist/mcp-server/transports/http/serverCard.js +91 -0
  103. package/dist/mcp-server/transports/http/serverCard.js.map +1 -0
  104. package/dist/mcp-server/transports/manager.d.ts +3 -3
  105. package/dist/mcp-server/transports/manager.d.ts.map +1 -1
  106. package/dist/mcp-server/transports/manager.js +4 -4
  107. package/dist/mcp-server/transports/manager.js.map +1 -1
  108. package/dist/utils/formatting/html.d.ts +76 -0
  109. package/dist/utils/formatting/html.d.ts.map +1 -0
  110. package/dist/utils/formatting/html.js +111 -0
  111. package/dist/utils/formatting/html.js.map +1 -0
  112. package/dist/utils/formatting/index.d.ts +1 -0
  113. package/dist/utils/formatting/index.d.ts.map +1 -1
  114. package/dist/utils/formatting/index.js +1 -0
  115. package/dist/utils/formatting/index.js.map +1 -1
  116. package/dist/utils/index.d.ts +1 -1
  117. package/dist/utils/index.d.ts.map +1 -1
  118. package/dist/utils/index.js +1 -1
  119. package/dist/utils/index.js.map +1 -1
  120. package/package.json +5 -1
  121. package/scripts/build-changelog.ts +222 -0
  122. package/scripts/devcheck.ts +16 -1
  123. package/scripts/tree.ts +3 -0
  124. package/skills/add-app-tool/SKILL.md +2 -4
  125. package/skills/add-prompt/SKILL.md +2 -4
  126. package/skills/add-resource/SKILL.md +2 -4
  127. package/skills/add-service/SKILL.md +2 -4
  128. package/skills/add-tool/SKILL.md +6 -5
  129. package/skills/api-context/SKILL.md +2 -2
  130. package/skills/api-services/SKILL.md +1 -1
  131. package/skills/api-services/references/graph.md +1 -1
  132. package/skills/api-utils/SKILL.md +1 -1
  133. package/skills/api-utils/references/parsing.md +1 -1
  134. package/skills/api-utils/references/security.md +1 -1
  135. package/skills/design-mcp-server/SKILL.md +2 -2
  136. package/skills/maintenance/SKILL.md +12 -11
  137. package/skills/polish-docs-meta/SKILL.md +24 -9
  138. package/skills/release/SKILL.md +21 -7
  139. package/skills/setup/SKILL.md +4 -8
  140. package/templates/AGENTS.md +23 -1
  141. package/templates/CLAUDE.md +23 -1
  142. package/templates/changelog/unreleased.md +40 -0
  143. package/templates/package.json +3 -0
@@ -0,0 +1,1529 @@
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 — eyebrow, display-size server name, version + pre-release chips,
13
+ * tagline, single-line status strip, terminal-chrome connection card,
14
+ * framework attribution pill
15
+ * - Tools section — responsive 2-column card grid; prefix-grouped; per-card
16
+ * annotation chips, invocation snippet, view-source link, schema preview
17
+ * - Resources section — URI template, mime type, description, view-source link
18
+ * - Prompts section — args list, view-source link
19
+ * - Extensions section — rendered when SEP-2133 extensions are present
20
+ * - Footer — single-row, dim, separator-dot delimited
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; --space-20: 80px;
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;
42
+ --text-display: clamp(2rem, 4.5vw + 0.5rem, 3.5rem);
43
+
44
+ --radius-xs: 4px; --radius-sm: 6px; --radius-md: 10px;
45
+ --radius-lg: 14px; --radius-pill: 999px;
46
+
47
+ --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
48
+ --font-mono: ui-monospace, "JetBrains Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
49
+
50
+ --duration-fast: 120ms; --duration-base: 200ms; --duration-slow: 320ms;
51
+ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
52
+
53
+ --accent: ${safeAccent};
54
+ --accent-hover: color-mix(in oklab, ${safeAccent}, black 10%);
55
+ --accent-edge: color-mix(in oklab, ${safeAccent}, transparent 65%);
56
+ --accent-soft: color-mix(in oklab, ${safeAccent}, transparent 82%);
57
+ --accent-softer: color-mix(in oklab, ${safeAccent}, transparent 92%);
58
+
59
+ --bg: #fcfcfd;
60
+ --bg-subtle: #f5f5f7;
61
+ --bg-elevated: #ffffff;
62
+ --bg-code: #f7f7f9;
63
+ --fg: #09090b;
64
+ --fg-muted: #52525b;
65
+ --fg-subtle: #a1a1aa;
66
+ --border: #e4e4e7;
67
+ --border-subtle: #ececf0;
68
+ --border-strong: #d4d4d8;
69
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.04);
70
+ --shadow-md: 0 4px 20px -8px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.04);
71
+ --grid-dot: rgb(0 0 0 / 0.045);
72
+ --glow-strength: 0.08;
73
+ }
74
+
75
+ @media (prefers-color-scheme: dark) {
76
+ :root {
77
+ --bg: #08090d;
78
+ --bg-subtle: #0f1015;
79
+ --bg-elevated: #0d0e13;
80
+ --bg-code: #0a0b10;
81
+ --fg: #ededef;
82
+ --fg-muted: #a1a1a8;
83
+ --fg-subtle: #6b6b73;
84
+ --border: #1f2027;
85
+ --border-subtle: #16171c;
86
+ --border-strong: #2a2b33;
87
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.35);
88
+ --shadow-md: 0 4px 24px -8px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
89
+ --grid-dot: rgb(255 255 255 / 0.028);
90
+ --glow-strength: 0.12;
91
+ }
92
+ }
93
+
94
+ *, *::before, *::after { box-sizing: border-box; }
95
+ html { color-scheme: light dark; }
96
+
97
+ body {
98
+ margin: 0;
99
+ min-height: 100vh;
100
+ font-family: var(--font-sans);
101
+ font-size: var(--text-base);
102
+ line-height: 1.5;
103
+ color: var(--fg);
104
+ background: var(--bg);
105
+ -webkit-font-smoothing: antialiased;
106
+ -moz-osx-font-smoothing: grayscale;
107
+ text-rendering: optimizeLegibility;
108
+ position: relative;
109
+ overflow-x: hidden;
110
+ }
111
+
112
+ /* Top accent hairline — spans viewport, brand-variable gradient. */
113
+ body::before {
114
+ content: "";
115
+ position: fixed;
116
+ top: 0; left: 0; right: 0;
117
+ height: 1px;
118
+ background: linear-gradient(90deg,
119
+ transparent 0%,
120
+ color-mix(in oklab, var(--accent), transparent 60%) 15%,
121
+ var(--accent) 50%,
122
+ color-mix(in oklab, var(--accent), transparent 60%) 85%,
123
+ transparent 100%);
124
+ z-index: 100;
125
+ pointer-events: none;
126
+ }
127
+
128
+ /* Ambient background — radial accent glow top-left + fine dot grid. */
129
+ body::after {
130
+ content: "";
131
+ position: fixed;
132
+ inset: 0;
133
+ background-image:
134
+ radial-gradient(ellipse 70% 55% at 12% -5%, color-mix(in oklab, var(--accent), transparent calc((1 - var(--glow-strength)) * 100%)), transparent 60%),
135
+ radial-gradient(circle at center, var(--grid-dot) 1px, transparent 1.5px);
136
+ background-size: 100% 100%, 24px 24px;
137
+ pointer-events: none;
138
+ z-index: -1;
139
+ }
140
+
141
+ ::selection { background: var(--accent-soft); color: var(--fg); }
142
+
143
+ main {
144
+ max-width: 1120px;
145
+ margin: 0 auto;
146
+ padding: var(--space-10) var(--space-6) var(--space-20);
147
+ position: relative;
148
+ }
149
+
150
+ a {
151
+ color: var(--accent);
152
+ text-decoration: none;
153
+ transition: color var(--duration-fast) var(--ease-out);
154
+ }
155
+ a:hover { color: var(--accent-hover); }
156
+ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
157
+
158
+ code, pre {
159
+ font-family: var(--font-mono);
160
+ font-size: 0.875em;
161
+ font-feature-settings: "liga" 0, "calt" 0;
162
+ }
163
+ code {
164
+ background: var(--bg-subtle);
165
+ padding: 0.1em 0.35em;
166
+ border-radius: var(--radius-xs);
167
+ border: 1px solid var(--border-subtle);
168
+ font-size: 0.85em;
169
+ }
170
+ pre {
171
+ background: var(--bg-code);
172
+ border: 1px solid var(--border-subtle);
173
+ border-radius: var(--radius-sm);
174
+ padding: var(--space-4);
175
+ margin: 0;
176
+ overflow-x: auto;
177
+ line-height: 1.55;
178
+ white-space: pre;
179
+ color: var(--fg);
180
+ }
181
+ pre code { background: transparent; padding: 0; border: 0; font-size: inherit; }
182
+
183
+ /* -------------------- Hero -------------------- */
184
+
185
+ .hero {
186
+ padding: var(--space-12) 0 var(--space-10);
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: var(--space-6);
190
+ }
191
+
192
+ .hero-eyebrow {
193
+ display: inline-flex;
194
+ align-items: center;
195
+ gap: var(--space-2);
196
+ font-family: var(--font-mono);
197
+ font-size: 0.6875rem;
198
+ font-weight: 500;
199
+ letter-spacing: 0.14em;
200
+ text-transform: uppercase;
201
+ color: var(--accent);
202
+ }
203
+ .hero-eyebrow::before {
204
+ content: "";
205
+ width: 5px; height: 5px;
206
+ background: var(--accent);
207
+ border-radius: 50%;
208
+ box-shadow: 0 0 10px var(--accent);
209
+ }
210
+
211
+ .hero-title-row {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: var(--space-4);
215
+ flex-wrap: wrap;
216
+ }
217
+ .hero-logo {
218
+ width: 44px; height: 44px;
219
+ border-radius: var(--radius-md);
220
+ object-fit: contain;
221
+ border: 1px solid var(--border);
222
+ background: var(--bg-elevated);
223
+ flex-shrink: 0;
224
+ }
225
+ .hero-heading {
226
+ margin: 0;
227
+ font-size: var(--text-display);
228
+ font-weight: 700;
229
+ letter-spacing: -0.04em;
230
+ line-height: 1.02;
231
+ color: var(--fg);
232
+ word-break: break-word;
233
+ flex: 1 1 auto;
234
+ min-width: 0;
235
+ }
236
+ @supports ((-webkit-background-clip: text) or (background-clip: text)) {
237
+ .hero-heading {
238
+ background: linear-gradient(180deg, var(--fg) 0%, color-mix(in oklab, var(--fg), transparent 25%) 100%);
239
+ -webkit-background-clip: text;
240
+ background-clip: text;
241
+ -webkit-text-fill-color: transparent;
242
+ color: transparent;
243
+ }
244
+ }
245
+ .hero-tagline {
246
+ margin: 0;
247
+ color: var(--fg-muted);
248
+ font-size: var(--text-lg);
249
+ line-height: 1.5;
250
+ max-width: 62ch;
251
+ }
252
+
253
+ /* Version + pre-release chips */
254
+ .badge-version {
255
+ display: inline-flex;
256
+ align-items: center;
257
+ padding: 4px 10px;
258
+ border-radius: var(--radius-pill);
259
+ font-family: var(--font-mono);
260
+ font-size: 0.75rem;
261
+ font-weight: 600;
262
+ letter-spacing: -0.01em;
263
+ color: var(--accent);
264
+ background: var(--accent-softer);
265
+ border: 1px solid var(--accent-edge);
266
+ text-decoration: none;
267
+ transition: background var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);
268
+ }
269
+ .badge-version:hover {
270
+ background: var(--accent-soft);
271
+ color: var(--accent);
272
+ text-decoration: none;
273
+ transform: translateY(-1px);
274
+ }
275
+ .badge-pre {
276
+ display: inline-flex;
277
+ align-items: center;
278
+ padding: 4px 10px;
279
+ border-radius: var(--radius-pill);
280
+ font-family: var(--font-mono);
281
+ font-size: 0.75rem;
282
+ font-weight: 600;
283
+ letter-spacing: -0.01em;
284
+ color: #b45309;
285
+ background: color-mix(in oklab, #f59e0b, transparent 88%);
286
+ border: 1px solid color-mix(in oklab, #f59e0b, transparent 65%);
287
+ }
288
+ @media (prefers-color-scheme: dark) {
289
+ .badge-pre { color: #fbbf24; }
290
+ }
291
+
292
+ /* Status strip — single line under the tagline. */
293
+ .status-strip {
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ align-items: center;
297
+ gap: var(--space-4);
298
+ padding: var(--space-3) 0;
299
+ border-top: 1px solid var(--border-subtle);
300
+ border-bottom: 1px solid var(--border-subtle);
301
+ font-family: var(--font-mono);
302
+ font-size: var(--text-xs);
303
+ color: var(--fg-muted);
304
+ letter-spacing: 0.01em;
305
+ }
306
+ .status-item {
307
+ display: inline-flex;
308
+ align-items: center;
309
+ gap: 6px;
310
+ position: relative;
311
+ }
312
+ .status-item + .status-item::before {
313
+ content: "·";
314
+ color: var(--fg-subtle);
315
+ margin-right: var(--space-2);
316
+ opacity: 0.7;
317
+ }
318
+ .status-dot {
319
+ width: 6px; height: 6px;
320
+ border-radius: 50%;
321
+ display: inline-block;
322
+ flex-shrink: 0;
323
+ }
324
+ .status-dot-public {
325
+ background: #22c55e;
326
+ box-shadow: 0 0 0 3px color-mix(in oklab, #22c55e, transparent 80%);
327
+ animation: status-pulse 2.4s ease-in-out infinite;
328
+ }
329
+ .status-dot-gated {
330
+ background: var(--accent);
331
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 75%);
332
+ }
333
+ @keyframes status-pulse {
334
+ 0%, 100% { box-shadow: 0 0 0 3px color-mix(in oklab, #22c55e, transparent 80%); }
335
+ 50% { box-shadow: 0 0 0 6px color-mix(in oklab, #22c55e, transparent 92%); }
336
+ }
337
+ .status-value {
338
+ color: var(--fg);
339
+ font-weight: 500;
340
+ }
341
+ .status-signin {
342
+ color: var(--fg-muted);
343
+ text-decoration: none;
344
+ border-bottom: 1px dotted var(--border-strong);
345
+ }
346
+ .status-signin:hover { color: var(--accent); border-color: var(--accent); }
347
+
348
+ /* -------------------- Connect card -------------------- */
349
+
350
+ .connect {
351
+ border: 1px solid var(--border);
352
+ border-radius: var(--radius-lg);
353
+ background: var(--bg-elevated);
354
+ overflow: hidden;
355
+ box-shadow: var(--shadow-md);
356
+ position: relative;
357
+ }
358
+
359
+ /* Chrome header — three dots + endpoint path */
360
+ .connect-chrome {
361
+ display: flex;
362
+ align-items: center;
363
+ gap: var(--space-3);
364
+ padding: var(--space-3) var(--space-4);
365
+ border-bottom: 1px solid var(--border-subtle);
366
+ background: var(--bg-subtle);
367
+ }
368
+ .connect-chrome-dots {
369
+ display: inline-flex;
370
+ gap: 6px;
371
+ flex-shrink: 0;
372
+ }
373
+ .connect-chrome-dot {
374
+ width: 10px; height: 10px;
375
+ border-radius: 50%;
376
+ background: color-mix(in oklab, var(--fg-subtle), transparent 60%);
377
+ display: inline-block;
378
+ }
379
+ .connect-chrome-endpoint {
380
+ margin-left: auto;
381
+ font-family: var(--font-mono);
382
+ font-size: 0.6875rem;
383
+ color: var(--fg-muted);
384
+ white-space: nowrap;
385
+ overflow: hidden;
386
+ text-overflow: ellipsis;
387
+ min-width: 0;
388
+ }
389
+
390
+ /* Radio-hack tabs */
391
+ .connect-tab-input { position: absolute; opacity: 0; pointer-events: none; }
392
+ .connect-tabs {
393
+ display: flex;
394
+ gap: 0;
395
+ padding: 0 var(--space-4);
396
+ border-bottom: 1px solid var(--border-subtle);
397
+ overflow-x: auto;
398
+ scrollbar-width: none;
399
+ }
400
+ .connect-tabs::-webkit-scrollbar { display: none; }
401
+ .connect-tab-label {
402
+ padding: var(--space-3) var(--space-4);
403
+ font-size: var(--text-sm);
404
+ font-weight: 500;
405
+ color: var(--fg-muted);
406
+ cursor: pointer;
407
+ border-bottom: 2px solid transparent;
408
+ margin-bottom: -1px;
409
+ white-space: nowrap;
410
+ transition: color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out);
411
+ }
412
+ .connect-tab-label:hover { color: var(--fg); }
413
+ .connect-tab-input:checked + .connect-tab-label {
414
+ color: var(--fg);
415
+ border-bottom-color: var(--accent);
416
+ }
417
+ .connect-tab-input:focus-visible + .connect-tab-label {
418
+ outline: 2px solid var(--accent);
419
+ outline-offset: -6px;
420
+ border-radius: var(--radius-sm);
421
+ }
422
+
423
+ .connect-panels { position: relative; padding: var(--space-5) var(--space-4); }
424
+ .connect-panel { display: none; }
425
+ .connect:has(#connect-tab-stdio:checked) .panel-stdio,
426
+ .connect:has(#connect-tab-http:checked) .panel-http,
427
+ .connect:has(#connect-tab-claude:checked) .panel-claude,
428
+ .connect:has(#connect-tab-curl:checked) .panel-curl { display: block; }
429
+ /* Fallback when :has() unsupported — show first visible panel */
430
+ @supports not selector(:has(*)) {
431
+ .connect-panel:first-of-type { display: block; }
432
+ }
433
+ .connect-panel pre {
434
+ padding: var(--space-4);
435
+ padding-right: var(--space-12);
436
+ background: var(--bg-code);
437
+ border: 1px solid var(--border-subtle);
438
+ border-radius: var(--radius-sm);
439
+ font-size: 0.8125rem;
440
+ line-height: 1.6;
441
+ }
442
+ .connect-copy {
443
+ position: absolute;
444
+ top: calc(var(--space-5) + 8px);
445
+ right: calc(var(--space-4) + 8px);
446
+ font-family: var(--font-mono);
447
+ font-size: 0.6875rem;
448
+ font-weight: 500;
449
+ padding: 4px 10px;
450
+ border-radius: var(--radius-sm);
451
+ border: 1px solid var(--border);
452
+ background: var(--bg);
453
+ color: var(--fg-muted);
454
+ cursor: pointer;
455
+ transition: all var(--duration-fast) var(--ease-out);
456
+ letter-spacing: 0.02em;
457
+ }
458
+ .connect-copy:hover {
459
+ color: var(--accent);
460
+ border-color: var(--accent-edge);
461
+ background: var(--accent-softer);
462
+ }
463
+ .connect-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
464
+ .connect-copy[data-copied="true"] {
465
+ color: #16a34a;
466
+ border-color: color-mix(in oklab, #16a34a, transparent 60%);
467
+ background: color-mix(in oklab, #16a34a, transparent 92%);
468
+ }
469
+
470
+ /* Framework attribution pill */
471
+ .hero-badges {
472
+ display: inline-flex;
473
+ align-items: center;
474
+ gap: var(--space-2);
475
+ }
476
+ .badge-shield {
477
+ display: inline-flex;
478
+ align-items: center;
479
+ gap: 6px;
480
+ padding: 4px 10px;
481
+ border-radius: var(--radius-pill);
482
+ font-family: var(--font-mono);
483
+ font-size: 0.6875rem;
484
+ font-weight: 500;
485
+ letter-spacing: 0.01em;
486
+ color: var(--fg-muted);
487
+ background: var(--bg-subtle);
488
+ border: 1px solid var(--border-subtle);
489
+ text-decoration: none;
490
+ transition: all var(--duration-fast) var(--ease-out);
491
+ }
492
+ .badge-shield:hover {
493
+ color: var(--accent);
494
+ border-color: var(--accent-edge);
495
+ background: var(--accent-softer);
496
+ text-decoration: none;
497
+ transform: translateY(-1px);
498
+ }
499
+ .badge-shield-label { color: var(--fg-subtle); transition: color var(--duration-fast); }
500
+ .badge-shield-value { color: var(--fg-muted); transition: color var(--duration-fast); }
501
+ .badge-shield:hover .badge-shield-label,
502
+ .badge-shield:hover .badge-shield-value { color: var(--accent); }
503
+
504
+ /* -------------------- Sections -------------------- */
505
+
506
+ section { padding: var(--space-12) 0 0; }
507
+
508
+ .section-heading {
509
+ display: flex;
510
+ align-items: baseline;
511
+ gap: var(--space-3);
512
+ margin: 0 0 var(--space-6);
513
+ padding-bottom: var(--space-3);
514
+ border-bottom: 1px solid var(--border-subtle);
515
+ }
516
+ .section-heading h2 {
517
+ margin: 0;
518
+ font-size: var(--text-2xl);
519
+ font-weight: 600;
520
+ letter-spacing: -0.025em;
521
+ color: var(--fg);
522
+ text-transform: lowercase;
523
+ }
524
+ .section-count {
525
+ font-family: var(--font-mono);
526
+ font-size: var(--text-2xl);
527
+ font-weight: 600;
528
+ color: var(--accent);
529
+ font-variant-numeric: tabular-nums;
530
+ letter-spacing: -0.02em;
531
+ line-height: 1;
532
+ }
533
+
534
+ .group-heading {
535
+ margin: var(--space-6) 0 var(--space-3);
536
+ color: var(--fg-muted);
537
+ font-family: var(--font-mono);
538
+ font-size: 0.6875rem;
539
+ font-weight: 600;
540
+ text-transform: uppercase;
541
+ letter-spacing: 0.12em;
542
+ }
543
+ .group-heading:first-child { margin-top: 0; }
544
+
545
+ /* -------------------- Cards -------------------- */
546
+
547
+ .card-grid {
548
+ display: grid;
549
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
550
+ gap: var(--space-3);
551
+ }
552
+ .card {
553
+ border: 1px solid var(--border-subtle);
554
+ border-radius: var(--radius-md);
555
+ padding: var(--space-4) var(--space-5);
556
+ background: var(--bg-elevated);
557
+ display: flex;
558
+ flex-direction: column;
559
+ gap: var(--space-2);
560
+ transition: border-color var(--duration-fast) var(--ease-out),
561
+ transform var(--duration-fast) var(--ease-out),
562
+ box-shadow var(--duration-fast) var(--ease-out);
563
+ position: relative;
564
+ }
565
+ .card:hover {
566
+ border-color: var(--accent-edge);
567
+ transform: translateY(-1px);
568
+ box-shadow: var(--shadow-sm);
569
+ }
570
+ .card-head {
571
+ display: flex;
572
+ align-items: center;
573
+ gap: var(--space-2);
574
+ flex-wrap: wrap;
575
+ }
576
+ .card-title {
577
+ margin: 0;
578
+ font-size: 0.9375rem;
579
+ font-weight: 600;
580
+ font-family: var(--font-mono);
581
+ color: var(--fg);
582
+ letter-spacing: -0.015em;
583
+ }
584
+ .card-title a { color: inherit; }
585
+ .card-title a:hover { color: var(--accent); text-decoration: none; }
586
+ .card-desc {
587
+ margin: 0;
588
+ color: var(--fg-muted);
589
+ font-size: var(--text-sm);
590
+ line-height: 1.5;
591
+ }
592
+ .card-meta {
593
+ display: flex;
594
+ flex-wrap: wrap;
595
+ gap: var(--space-1) var(--space-3);
596
+ font-size: var(--text-xs);
597
+ color: var(--fg-muted);
598
+ font-family: var(--font-mono);
599
+ align-items: center;
600
+ }
601
+ .card-meta-label { color: var(--fg-subtle); }
602
+ .card-meta code {
603
+ font-size: 1em;
604
+ color: var(--fg);
605
+ background: transparent;
606
+ border: 0;
607
+ padding: 0;
608
+ }
609
+
610
+ /* Annotation pills — dot-chip style */
611
+ .pill-row { display: inline-flex; flex-wrap: wrap; gap: 5px; align-items: center; }
612
+ .pill {
613
+ display: inline-flex;
614
+ align-items: center;
615
+ gap: 5px;
616
+ padding: 2px 8px;
617
+ border-radius: var(--radius-pill);
618
+ font-family: var(--font-mono);
619
+ font-size: 0.6875rem;
620
+ font-weight: 500;
621
+ line-height: 1.4;
622
+ color: var(--fg-muted);
623
+ background: var(--bg-subtle);
624
+ border: 1px solid var(--border-subtle);
625
+ letter-spacing: 0.01em;
626
+ }
627
+ .pill::before {
628
+ content: "";
629
+ width: 4px; height: 4px;
630
+ border-radius: 50%;
631
+ background: currentColor;
632
+ flex-shrink: 0;
633
+ }
634
+ .pill-readonly { color: #16a34a; background: color-mix(in oklab, #16a34a, transparent 92%); border-color: color-mix(in oklab, #16a34a, transparent 72%); }
635
+ .pill-destructive { color: #dc2626; background: color-mix(in oklab, #dc2626, transparent 92%); border-color: color-mix(in oklab, #dc2626, transparent 72%); }
636
+ .pill-openworld { color: #2563eb; background: color-mix(in oklab, #2563eb, transparent 92%); border-color: color-mix(in oklab, #2563eb, transparent 72%); }
637
+ .pill-task { color: var(--accent); background: var(--accent-softer); border-color: var(--accent-edge); }
638
+ .pill-app { color: #9333ea; background: color-mix(in oklab, #9333ea, transparent 92%); border-color: color-mix(in oklab, #9333ea, transparent 72%); }
639
+ .pill-auth { color: var(--fg-subtle); font-size: 0.65rem; }
640
+ .pill-auth::before { display: none; }
641
+
642
+ @media (prefers-color-scheme: dark) {
643
+ .pill-readonly { color: #4ade80; }
644
+ .pill-destructive { color: #f87171; }
645
+ .pill-openworld { color: #60a5fa; }
646
+ .pill-app { color: #c084fc; }
647
+ }
648
+
649
+ .source-link {
650
+ font-size: var(--text-xs);
651
+ color: var(--fg-subtle);
652
+ margin-left: auto;
653
+ font-family: var(--font-mono);
654
+ transition: color var(--duration-fast);
655
+ }
656
+ .source-link:hover { color: var(--accent); text-decoration: none; }
657
+
658
+ /* Inline snippet (tool invocation) */
659
+ .snippet {
660
+ position: relative;
661
+ margin-top: var(--space-2);
662
+ }
663
+ .snippet pre {
664
+ padding: var(--space-3);
665
+ padding-right: var(--space-12);
666
+ background: var(--bg-code);
667
+ border: 1px solid var(--border-subtle);
668
+ border-radius: var(--radius-sm);
669
+ font-size: 0.75rem;
670
+ line-height: 1.55;
671
+ }
672
+ .snippet-copy {
673
+ position: absolute;
674
+ top: 6px;
675
+ right: 6px;
676
+ font-family: var(--font-mono);
677
+ font-size: 0.65rem;
678
+ font-weight: 500;
679
+ padding: 3px 8px;
680
+ border-radius: var(--radius-xs);
681
+ border: 1px solid var(--border);
682
+ background: var(--bg);
683
+ color: var(--fg-muted);
684
+ cursor: pointer;
685
+ transition: all var(--duration-fast) var(--ease-out);
686
+ letter-spacing: 0.02em;
687
+ }
688
+ .snippet-copy:hover { color: var(--accent); border-color: var(--accent-edge); }
689
+ .snippet-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
690
+ .snippet-copy[data-copied="true"] {
691
+ color: #16a34a;
692
+ border-color: color-mix(in oklab, #16a34a, transparent 60%);
693
+ }
694
+
695
+ /* Collapsible details (schema preview) */
696
+ details {
697
+ margin-top: var(--space-1);
698
+ border: 0;
699
+ border-radius: 0;
700
+ }
701
+ details > summary {
702
+ cursor: pointer;
703
+ padding: 4px 0;
704
+ font-family: var(--font-mono);
705
+ font-size: var(--text-xs);
706
+ color: var(--fg-subtle);
707
+ list-style: none;
708
+ user-select: none;
709
+ display: inline-flex;
710
+ align-items: center;
711
+ gap: var(--space-2);
712
+ transition: color var(--duration-fast);
713
+ }
714
+ details > summary::-webkit-details-marker { display: none; }
715
+ details > summary::before {
716
+ content: "+";
717
+ display: inline-block;
718
+ width: 10px;
719
+ text-align: center;
720
+ color: var(--fg-subtle);
721
+ font-weight: 600;
722
+ transition: transform var(--duration-fast), color var(--duration-fast);
723
+ }
724
+ details[open] > summary::before { content: "−"; color: var(--accent); }
725
+ details > summary:hover { color: var(--accent); }
726
+ details > pre {
727
+ margin-top: var(--space-2);
728
+ font-size: 0.7rem;
729
+ line-height: 1.55;
730
+ }
731
+
732
+ /* Prompt args */
733
+ .args-list {
734
+ list-style: none;
735
+ padding: 0;
736
+ margin: var(--space-1) 0 0;
737
+ display: flex;
738
+ flex-direction: column;
739
+ gap: 4px;
740
+ font-size: var(--text-xs);
741
+ color: var(--fg-muted);
742
+ font-family: var(--font-mono);
743
+ }
744
+ .args-list li { line-height: 1.6; }
745
+ .args-list code {
746
+ font-size: 1em;
747
+ background: transparent;
748
+ border: 0;
749
+ padding: 0;
750
+ color: var(--fg);
751
+ }
752
+ .args-required {
753
+ color: var(--accent);
754
+ font-size: 0.625rem;
755
+ font-weight: 600;
756
+ margin-left: 5px;
757
+ letter-spacing: 0.08em;
758
+ text-transform: uppercase;
759
+ }
760
+
761
+ /* Extensions */
762
+ .ext-card { background: var(--bg-subtle); border-color: var(--border-subtle); }
763
+ .ext-key { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--fg); }
764
+ .ext-preview {
765
+ margin: var(--space-2) 0 0;
766
+ font-size: 0.7rem;
767
+ line-height: 1.55;
768
+ }
769
+
770
+ /* Empty / degraded state */
771
+ .empty-state {
772
+ padding: var(--space-10) var(--space-6);
773
+ text-align: center;
774
+ color: var(--fg-muted);
775
+ border: 1px dashed var(--border);
776
+ border-radius: var(--radius-md);
777
+ background: var(--bg-subtle);
778
+ font-size: var(--text-sm);
779
+ font-family: var(--font-mono);
780
+ }
781
+
782
+ /* -------------------- Footer -------------------- */
783
+
784
+ footer {
785
+ margin-top: var(--space-20);
786
+ padding: var(--space-6) 0;
787
+ border-top: 1px solid var(--border-subtle);
788
+ display: flex;
789
+ flex-wrap: wrap;
790
+ align-items: center;
791
+ gap: var(--space-2) var(--space-4);
792
+ font-family: var(--font-mono);
793
+ font-size: var(--text-xs);
794
+ color: var(--fg-subtle);
795
+ letter-spacing: 0.01em;
796
+ }
797
+ footer a {
798
+ color: var(--fg-muted);
799
+ text-decoration: none;
800
+ transition: color var(--duration-fast) var(--ease-out);
801
+ }
802
+ footer a:hover { color: var(--accent); text-decoration: none; }
803
+ .footer-sep { color: var(--fg-subtle); opacity: 0.5; user-select: none; }
804
+ .footer-spacer { flex: 1 0 var(--space-4); }
805
+ .footer-attrib { color: var(--fg-subtle); }
806
+ .footer-attrib a { color: var(--fg-muted); }
807
+
808
+ /* -------------------- Responsive -------------------- */
809
+
810
+ @media (max-width: 760px) {
811
+ main { padding: var(--space-6) var(--space-4) var(--space-16); }
812
+ .hero { padding: var(--space-8) 0 var(--space-6); gap: var(--space-5); }
813
+ .hero-heading { font-size: clamp(1.875rem, 7vw + 0.5rem, 2.5rem); letter-spacing: -0.035em; }
814
+ .hero-title-row { gap: var(--space-3); }
815
+ .hero-logo { width: 40px; height: 40px; }
816
+ .hero-tagline { font-size: var(--text-base); }
817
+ .status-strip { gap: var(--space-2); font-size: 0.6875rem; }
818
+ .connect-chrome-endpoint { display: none; }
819
+ .card-grid { grid-template-columns: 1fr; }
820
+ section { padding: var(--space-8) 0 0; }
821
+ .section-heading h2 { font-size: var(--text-xl); }
822
+ .section-count { font-size: var(--text-xl); }
823
+ }
824
+
825
+ @media (prefers-reduced-motion: reduce) {
826
+ *, *::before, *::after {
827
+ transition-duration: 0.01ms !important;
828
+ animation-duration: 0.01ms !important;
829
+ animation-iteration-count: 1 !important;
830
+ }
831
+ }
832
+ `;
833
+ return unsafeRaw(`<style>${css}</style>`);
834
+ }
835
+ // ---------------------------------------------------------------------------
836
+ // Copy-to-clipboard — single inlined script, < 1KB
837
+ // ---------------------------------------------------------------------------
838
+ function renderCopyScript() {
839
+ const js = `
840
+ document.addEventListener('click', function(e) {
841
+ var btn = e.target.closest('[data-copy]');
842
+ if (!btn) return;
843
+ var selector = btn.getAttribute('data-copy-target');
844
+ var text = '';
845
+ if (selector) {
846
+ var node = document.querySelector(selector);
847
+ if (node) text = node.textContent || '';
848
+ } else {
849
+ text = btn.getAttribute('data-copy') || '';
850
+ }
851
+ if (!text || !navigator.clipboard) return;
852
+ navigator.clipboard.writeText(text).then(function() {
853
+ var prev = btn.textContent;
854
+ btn.setAttribute('data-copied', 'true');
855
+ btn.textContent = 'Copied';
856
+ setTimeout(function() {
857
+ btn.removeAttribute('data-copied');
858
+ btn.textContent = prev;
859
+ }, 1500);
860
+ });
861
+ });`;
862
+ return unsafeRaw(`<script>${js}</script>`);
863
+ }
864
+ // ---------------------------------------------------------------------------
865
+ // Primitives
866
+ // ---------------------------------------------------------------------------
867
+ /**
868
+ * shields.io-style bi-part badge: "Built on" label + framework name. Links to
869
+ * the framework's npm page. Lives in the hero when `landing.attribution` is
870
+ * enabled.
871
+ */
872
+ function renderFrameworkBadge(framework) {
873
+ const npmUrl = `https://www.npmjs.com/package/${encodeURIComponent(framework.name)}`;
874
+ 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>`;
875
+ }
876
+ function renderPill(text, variant) {
877
+ return html `<span class="pill pill-${variant}">${text}</span>`;
878
+ }
879
+ function renderSectionHeading(id, label, count) {
880
+ return html `
881
+ <div class="section-heading">
882
+ <h2 id="${id}">${label}</h2>
883
+ <span class="section-count" aria-label="${String(count)} ${label}">${String(count)}</span>
884
+ </div>
885
+ `;
886
+ }
887
+ function renderSnippet(id, text) {
888
+ const targetId = `snippet-${id}`;
889
+ return html `
890
+ <div class="snippet">
891
+ <pre id="${targetId}"><code>${text}</code></pre>
892
+ <button type="button" class="snippet-copy" data-copy data-copy-target="#${targetId}" aria-label="Copy">Copy</button>
893
+ </div>
894
+ `;
895
+ }
896
+ // ---------------------------------------------------------------------------
897
+ // Status strip — replaces the old auth banner
898
+ // ---------------------------------------------------------------------------
899
+ /**
900
+ * Single-line status strip under the hero. Communicates auth mode, capability
901
+ * counts, and protocol version in one mono-spaced, dot-separated row.
902
+ *
903
+ * Accessibility:
904
+ * - `role="status"` so changes are announced live
905
+ * - `aria-label` carries the long-form auth phrase for screen readers
906
+ * ("Public access", "Requires OAuth", etc.) even when the visible label is
907
+ * compact ("public", "oauth")
908
+ */
909
+ function renderStatusStrip(manifest, degraded) {
910
+ const { auth, definitionCounts, protocol } = manifest;
911
+ const authMeta = describeAuth(auth);
912
+ // Counts hidden in degraded mode to avoid leaking inventory shape.
913
+ const counts = degraded
914
+ ? []
915
+ : [
916
+ { n: definitionCounts.tools, label: 'tools' },
917
+ { n: definitionCounts.resources, label: 'resources' },
918
+ { n: definitionCounts.prompts, label: 'prompts' },
919
+ ].filter((c) => c.n > 0);
920
+ const signin = auth.mode === 'oauth' && auth.oauthIssuer
921
+ ? html ` <a class="status-signin" href="${auth.oauthIssuer}" rel="noopener">sign in ↗</a>`
922
+ : html ``;
923
+ return html `
924
+ <div class="status-strip" role="status" aria-label="${authMeta.ariaLabel}">
925
+ <span class="status-item">
926
+ <span class="status-dot ${authMeta.dotClass}" aria-hidden="true"></span>
927
+ <span class="status-value">${authMeta.label}</span>${signin}
928
+ </span>
929
+ ${counts.map((c) => html `
930
+ <span class="status-item">
931
+ <span class="status-value">${String(c.n)}</span>
932
+ <span>${c.label}</span>
933
+ </span>
934
+ `)}
935
+ <span class="status-item">
936
+ <span>MCP</span>
937
+ <span class="status-value">${protocol.latestVersion}</span>
938
+ </span>
939
+ </div>
940
+ `;
941
+ }
942
+ /** Visible label, dot class, and long-form aria phrase for the auth strip item. */
943
+ function describeAuth(auth) {
944
+ if (auth.mode === 'none') {
945
+ return {
946
+ label: 'public',
947
+ dotClass: 'status-dot-public',
948
+ ariaLabel: 'Public access — no authentication required',
949
+ };
950
+ }
951
+ if (auth.mode === 'jwt') {
952
+ return {
953
+ label: 'bearer',
954
+ dotClass: 'status-dot-gated',
955
+ ariaLabel: 'Requires a bearer token',
956
+ };
957
+ }
958
+ return {
959
+ label: 'oauth',
960
+ dotClass: 'status-dot-gated',
961
+ ariaLabel: 'Requires OAuth',
962
+ };
963
+ }
964
+ // ---------------------------------------------------------------------------
965
+ // Hero
966
+ // ---------------------------------------------------------------------------
967
+ function renderHero(manifest, baseUrl, degraded) {
968
+ const { server, landing } = manifest;
969
+ const releaseUrl = landing.repoRoot
970
+ ? `${landing.repoRoot.url}/releases/tag/v${server.version}`
971
+ : undefined;
972
+ const versionBadge = releaseUrl
973
+ ? html `<a class="badge-version" href="${releaseUrl}" aria-label="v${server.version} release notes">v${server.version}</a>`
974
+ : html `<span class="badge-version">v${server.version}</span>`;
975
+ const preReleaseBadge = landing.preRelease.isPreRelease
976
+ ? html `<span class="badge-pre">${landing.preRelease.label ?? 'pre-release'}</span>`
977
+ : html ``;
978
+ const tagline = landing.tagline ?? server.description ?? '';
979
+ const logo = landing.logo
980
+ ? html `<img class="hero-logo" src="${landing.logo}" alt="" aria-hidden="true" />`
981
+ : html ``;
982
+ const frameworkBadge = landing.attribution
983
+ ? html `<div class="hero-badges">${renderFrameworkBadge(manifest.framework)}</div>`
984
+ : html ``;
985
+ const connect = degraded ? html `` : renderConnectSnippets(manifest, baseUrl);
986
+ return html `
987
+ <header class="hero">
988
+ <span class="hero-eyebrow" aria-hidden="true">MCP Server</span>
989
+ <div class="hero-title-row">
990
+ ${logo}
991
+ <h1 class="hero-heading">${server.name}</h1>
992
+ ${versionBadge}
993
+ ${preReleaseBadge}
994
+ </div>
995
+ ${tagline ? html `<p class="hero-tagline">${tagline}</p>` : html ``}
996
+ ${renderStatusStrip(manifest, degraded)}
997
+ ${connect}
998
+ ${frameworkBadge}
999
+ </header>
1000
+ `;
1001
+ }
1002
+ /**
1003
+ * `@scope/pkg-name` → `pkg-name`. Fall through for bare names.
1004
+ * Used as the `mcpServers` key and the Claude CLI server alias.
1005
+ */
1006
+ function deriveShortName(serverName) {
1007
+ const slash = serverName.lastIndexOf('/');
1008
+ return slash >= 0 ? serverName.slice(slash + 1) : serverName;
1009
+ }
1010
+ /** Convert ordered env entries to the `{ KEY: value }` shape MCP clients expect. */
1011
+ function envFromEntries(entries) {
1012
+ return Object.fromEntries(entries.map(({ key, value }) => [key, value]));
1013
+ }
1014
+ /** `claude mcp add --transport stdio <name> [--env K=V]* -- bunx <pkg>@latest` */
1015
+ function buildClaudeStdioCmd(shortName, npmPackage, envExample) {
1016
+ const envFlags = envExample.map(({ key, value }) => `--env ${key}=${value}`).join(' ');
1017
+ const envSegment = envFlags.length > 0 ? ` ${envFlags}` : '';
1018
+ return `claude mcp add --transport stdio ${shortName}${envSegment} -- bunx ${npmPackage}@latest`;
1019
+ }
1020
+ /** `claude mcp add --transport http <name> <url>` */
1021
+ function buildClaudeHttpCmd(shortName, endpoint) {
1022
+ return `claude mcp add --transport http ${shortName} ${endpoint}`;
1023
+ }
1024
+ function renderConnectSnippets(manifest, baseUrl) {
1025
+ const endpoint = `${baseUrl.replace(/\/$/, '')}${manifest.transport.endpointPath}`;
1026
+ const npmPackage = manifest.landing.npmPackage?.name;
1027
+ // `@cyanheads/mcp-ts-core` → `mcp-ts-core`. Short aliases match the convention
1028
+ // used in real Claude Desktop / Cursor configs and make the `claude mcp add`
1029
+ // command more ergonomic.
1030
+ const shortName = deriveShortName(manifest.server.name);
1031
+ const envExample = manifest.landing.envExample;
1032
+ const envObject = envExample.length > 0 ? envFromEntries(envExample) : undefined;
1033
+ // STDIO: prefer native `bunx <pkg>@latest` when the server is published;
1034
+ // fall back to `mcp-remote` as a stdio → HTTP bridge so the tab is always
1035
+ // useful even for unpublished servers.
1036
+ const stdioConfig = JSON.stringify({
1037
+ mcpServers: {
1038
+ [shortName]: {
1039
+ command: 'bunx',
1040
+ args: npmPackage ? [`${npmPackage}@latest`] : ['mcp-remote', endpoint],
1041
+ ...(envObject && { env: envObject }),
1042
+ },
1043
+ },
1044
+ }, null, 2);
1045
+ const httpConfig = JSON.stringify({
1046
+ mcpServers: {
1047
+ [shortName]: {
1048
+ type: 'http',
1049
+ url: endpoint,
1050
+ ...(envObject && { env: envObject }),
1051
+ },
1052
+ },
1053
+ }, null, 2);
1054
+ // `claude mcp add` — published package routes through stdio; otherwise
1055
+ // point it straight at the HTTP endpoint.
1056
+ const claudeCmd = npmPackage
1057
+ ? buildClaudeStdioCmd(shortName, npmPackage, envExample)
1058
+ : buildClaudeHttpCmd(shortName, endpoint);
1059
+ const curl = [
1060
+ `curl -X POST ${endpoint} \\`,
1061
+ ` -H "Content-Type: application/json" \\`,
1062
+ ` -H "MCP-Protocol-Version: ${manifest.protocol.latestVersion}" \\`,
1063
+ ` -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"${manifest.protocol.latestVersion}","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'`,
1064
+ ].join('\n');
1065
+ // Chrome label — npm package when published, else the HTTP endpoint (trimmed).
1066
+ const chromeLabel = npmPackage ?? endpoint.replace(/^https?:\/\//, '');
1067
+ const panels = [
1068
+ { id: 'stdio', label: 'STDIO', content: stdioConfig, copyAriaLabel: 'Copy stdio config' },
1069
+ {
1070
+ id: 'http',
1071
+ label: 'Streamable HTTP',
1072
+ content: httpConfig,
1073
+ copyAriaLabel: 'Copy HTTP config',
1074
+ },
1075
+ {
1076
+ id: 'claude',
1077
+ label: 'Claude',
1078
+ content: claudeCmd,
1079
+ copyAriaLabel: 'Copy claude mcp add command',
1080
+ },
1081
+ { id: 'curl', label: 'curl', content: curl, copyAriaLabel: 'Copy curl command' },
1082
+ ];
1083
+ return html `
1084
+ <div class="connect" aria-label="Connection snippets">
1085
+ <div class="connect-chrome">
1086
+ <span class="connect-chrome-dots" aria-hidden="true">
1087
+ <span class="connect-chrome-dot"></span>
1088
+ <span class="connect-chrome-dot"></span>
1089
+ <span class="connect-chrome-dot"></span>
1090
+ </span>
1091
+ <span class="connect-chrome-endpoint" title="${endpoint}">${chromeLabel}</span>
1092
+ </div>
1093
+ ${panels.map((p, i) => i === 0
1094
+ ? html `<input type="radio" class="connect-tab-input" name="connect" id="connect-tab-${p.id}" checked />`
1095
+ : html `<input type="radio" class="connect-tab-input" name="connect" id="connect-tab-${p.id}" />`)}
1096
+ <div class="connect-tabs" role="tablist">
1097
+ ${panels.map((p) => html `<label for="connect-tab-${p.id}" class="connect-tab-label" role="tab">${p.label}</label>`)}
1098
+ </div>
1099
+ <div class="connect-panels">
1100
+ ${panels.map((p) => renderConnectPanel(p.id, p.content, p.copyAriaLabel))}
1101
+ </div>
1102
+ </div>
1103
+ `;
1104
+ }
1105
+ /** Single panel inside the connect card — pre/code + copy button. */
1106
+ function renderConnectPanel(id, content, copyAriaLabel) {
1107
+ const snippetId = `connect-snippet-${id}`;
1108
+ return html `
1109
+ <div class="connect-panel panel-${id}" role="tabpanel">
1110
+ <pre id="${snippetId}"><code>${content}</code></pre>
1111
+ <button type="button" class="connect-copy" data-copy data-copy-target="#${snippetId}" aria-label="${copyAriaLabel}">Copy</button>
1112
+ </div>
1113
+ `;
1114
+ }
1115
+ // ---------------------------------------------------------------------------
1116
+ // Tools section
1117
+ // ---------------------------------------------------------------------------
1118
+ function groupToolsByPrefix(tools) {
1119
+ if (tools.length < 3)
1120
+ return [{ label: null, tools }];
1121
+ const prefixCounts = new Map();
1122
+ for (const tool of tools) {
1123
+ const prefix = tool.name.split('_', 1)[0];
1124
+ if (!prefix)
1125
+ continue;
1126
+ prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
1127
+ }
1128
+ const groupablePrefixes = new Set([...prefixCounts.entries()].filter(([, count]) => count >= 2).map(([p]) => p));
1129
+ if (groupablePrefixes.size === 0)
1130
+ return [{ label: null, tools }];
1131
+ const groups = new Map();
1132
+ const other = [];
1133
+ for (const tool of tools) {
1134
+ const prefix = tool.name.split('_', 1)[0];
1135
+ if (prefix && groupablePrefixes.has(prefix)) {
1136
+ const list = groups.get(prefix) ?? [];
1137
+ list.push(tool);
1138
+ groups.set(prefix, list);
1139
+ }
1140
+ else {
1141
+ other.push(tool);
1142
+ }
1143
+ }
1144
+ const out = [];
1145
+ for (const [prefix, list] of groups) {
1146
+ out.push({ label: titleCase(prefix), tools: list });
1147
+ }
1148
+ if (other.length > 0)
1149
+ out.push({ label: 'Other', tools: other });
1150
+ return out;
1151
+ }
1152
+ function titleCase(s) {
1153
+ return s.charAt(0).toUpperCase() + s.slice(1);
1154
+ }
1155
+ function buildInvocationSnippet(tool) {
1156
+ const args = {};
1157
+ for (const field of tool.requiredFields) {
1158
+ args[field] = `<${field}>`;
1159
+ }
1160
+ return JSON.stringify({
1161
+ jsonrpc: '2.0',
1162
+ id: 1,
1163
+ method: 'tools/call',
1164
+ params: {
1165
+ name: tool.name,
1166
+ arguments: args,
1167
+ },
1168
+ }, null, 2);
1169
+ }
1170
+ function renderToolCard(tool) {
1171
+ const anchor = `tool-${tool.name}`;
1172
+ const annotations = tool.annotations;
1173
+ const pills = [];
1174
+ if (annotations?.readOnlyHint)
1175
+ pills.push(renderPill('read-only', 'readonly'));
1176
+ if (annotations?.destructiveHint === true)
1177
+ pills.push(renderPill('destructive', 'destructive'));
1178
+ if (annotations?.openWorldHint)
1179
+ pills.push(renderPill('open-world', 'openworld'));
1180
+ if (tool.isTask)
1181
+ pills.push(renderPill('task', 'task'));
1182
+ if (tool.isApp)
1183
+ pills.push(renderPill('app', 'app'));
1184
+ const source = tool.sourceUrl
1185
+ ? html `<a class="source-link" href="${tool.sourceUrl}" rel="noopener">view source ↗</a>`
1186
+ : html ``;
1187
+ const schemaPreview = tool.inputSchema
1188
+ ? html `
1189
+ <details>
1190
+ <summary>Input schema</summary>
1191
+ <pre><code>${JSON.stringify(tool.inputSchema, null, 2)}</code></pre>
1192
+ </details>
1193
+ `
1194
+ : html ``;
1195
+ const invocation = html `
1196
+ <details>
1197
+ <summary>Invocation</summary>
1198
+ ${renderSnippet(`tool-${tool.name}`, buildInvocationSnippet(tool))}
1199
+ </details>
1200
+ `;
1201
+ const authBadges = tool.auth && tool.auth.length > 0
1202
+ ? 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>`
1203
+ : html ``;
1204
+ return html `
1205
+ <article class="card" id="${anchor}">
1206
+ <div class="card-head">
1207
+ <h3 class="card-title"><a href="#${anchor}">${tool.name}</a></h3>
1208
+ <div class="pill-row" role="list">${pills}</div>
1209
+ ${source}
1210
+ </div>
1211
+ <p class="card-desc">${tool.description}</p>
1212
+ ${authBadges}
1213
+ ${invocation}
1214
+ ${schemaPreview}
1215
+ </article>
1216
+ `;
1217
+ }
1218
+ function renderToolsSection(tools) {
1219
+ if (tools.length === 0)
1220
+ return html ``;
1221
+ const groups = groupToolsByPrefix(tools);
1222
+ const body = groups.map((group) => {
1223
+ const heading = group.label ? html `<h4 class="group-heading">${group.label}</h4>` : html ``;
1224
+ return html `${heading}<div class="card-grid">${group.tools.map(renderToolCard)}</div>`;
1225
+ });
1226
+ return html `
1227
+ <section aria-labelledby="section-tools">
1228
+ ${renderSectionHeading('section-tools', 'Tools', tools.length)}
1229
+ ${body}
1230
+ </section>
1231
+ `;
1232
+ }
1233
+ // ---------------------------------------------------------------------------
1234
+ // Resources section
1235
+ // ---------------------------------------------------------------------------
1236
+ function slugifyUri(template) {
1237
+ return template
1238
+ .replace(/[^a-z0-9]+/gi, '-')
1239
+ .replace(/^-+|-+$/g, '')
1240
+ .toLowerCase();
1241
+ }
1242
+ function renderResourceCard(resource) {
1243
+ const anchor = `resource-${slugifyUri(resource.uriTemplate || resource.name)}`;
1244
+ const source = resource.sourceUrl
1245
+ ? html `<a class="source-link" href="${resource.sourceUrl}" rel="noopener">view source ↗</a>`
1246
+ : html ``;
1247
+ return html `
1248
+ <article class="card" id="${anchor}">
1249
+ <div class="card-head">
1250
+ <h3 class="card-title"><a href="#${anchor}">${resource.name}</a></h3>
1251
+ ${source}
1252
+ </div>
1253
+ <p class="card-desc">${resource.description}</p>
1254
+ <div class="card-meta">
1255
+ <span><span class="card-meta-label">uri</span> <code>${resource.uriTemplate}</code></span>
1256
+ ${resource.mimeType ? html `<span><span class="card-meta-label">mime</span> <code>${resource.mimeType}</code></span>` : html ``}
1257
+ </div>
1258
+ </article>
1259
+ `;
1260
+ }
1261
+ function renderResourcesSection(resources) {
1262
+ if (resources.length === 0)
1263
+ return html ``;
1264
+ return html `
1265
+ <section aria-labelledby="section-resources">
1266
+ ${renderSectionHeading('section-resources', 'Resources', resources.length)}
1267
+ <div class="card-grid">${resources.map(renderResourceCard)}</div>
1268
+ </section>
1269
+ `;
1270
+ }
1271
+ // ---------------------------------------------------------------------------
1272
+ // Prompts section
1273
+ // ---------------------------------------------------------------------------
1274
+ function renderPromptCard(prompt) {
1275
+ const anchor = `prompt-${prompt.name}`;
1276
+ const source = prompt.sourceUrl
1277
+ ? html `<a class="source-link" href="${prompt.sourceUrl}" rel="noopener">view source ↗</a>`
1278
+ : html ``;
1279
+ const argsList = prompt.args.length > 0
1280
+ ? html `
1281
+ <ul class="args-list">
1282
+ ${prompt.args.map((arg) => html `
1283
+ <li>
1284
+ <code>${arg.name}</code>${arg.required ? html `<span class="args-required">required</span>` : html ``}
1285
+ ${arg.description ? html ` — ${arg.description}` : html ``}
1286
+ </li>
1287
+ `)}
1288
+ </ul>
1289
+ `
1290
+ : html ``;
1291
+ return html `
1292
+ <article class="card" id="${anchor}">
1293
+ <div class="card-head">
1294
+ <h3 class="card-title"><a href="#${anchor}">${prompt.name}</a></h3>
1295
+ ${source}
1296
+ </div>
1297
+ <p class="card-desc">${prompt.description}</p>
1298
+ ${argsList}
1299
+ </article>
1300
+ `;
1301
+ }
1302
+ function renderPromptsSection(prompts) {
1303
+ if (prompts.length === 0)
1304
+ return html ``;
1305
+ return html `
1306
+ <section aria-labelledby="section-prompts">
1307
+ ${renderSectionHeading('section-prompts', 'Prompts', prompts.length)}
1308
+ <div class="card-grid">${prompts.map(renderPromptCard)}</div>
1309
+ </section>
1310
+ `;
1311
+ }
1312
+ // ---------------------------------------------------------------------------
1313
+ // Extensions section
1314
+ // ---------------------------------------------------------------------------
1315
+ function renderExtensionsSection(extensions) {
1316
+ if (!extensions || Object.keys(extensions).length === 0)
1317
+ return html ``;
1318
+ const entries = Object.entries(extensions);
1319
+ return html `
1320
+ <section aria-labelledby="section-extensions">
1321
+ ${renderSectionHeading('section-extensions', 'Extensions', entries.length)}
1322
+ <div class="card-grid">
1323
+ ${entries.map(([key, value]) => html `
1324
+ <article class="card ext-card">
1325
+ <div class="card-head">
1326
+ <h3 class="card-title ext-key">${key}</h3>
1327
+ </div>
1328
+ <pre class="ext-preview"><code>${JSON.stringify(value, null, 2)}</code></pre>
1329
+ </article>
1330
+ `)}
1331
+ </div>
1332
+ </section>
1333
+ `;
1334
+ }
1335
+ // ---------------------------------------------------------------------------
1336
+ // Footer
1337
+ // ---------------------------------------------------------------------------
1338
+ function renderFooter(manifest) {
1339
+ const { landing, framework } = manifest;
1340
+ const links = [];
1341
+ // User-supplied links
1342
+ for (const link of landing.links) {
1343
+ links.push({ href: link.href, label: link.label });
1344
+ }
1345
+ // Auto-derived GitHub cluster
1346
+ if (landing.repoRoot) {
1347
+ const repo = landing.repoRoot;
1348
+ const version = manifest.server.version;
1349
+ links.push({
1350
+ href: landing.changelogUrl ?? `${repo.url}/blob/main/CHANGELOG.md`,
1351
+ label: 'Changelog',
1352
+ });
1353
+ links.push({
1354
+ href: `${repo.url}/releases/tag/v${version}`,
1355
+ label: `v${version}`,
1356
+ });
1357
+ links.push({ href: `${repo.url}/issues`, label: 'Issues' });
1358
+ links.push({ href: repo.url, label: 'Source' });
1359
+ }
1360
+ // Package / registry
1361
+ if (landing.npmPackage) {
1362
+ links.push({ href: landing.npmPackage.url, label: 'npm' });
1363
+ }
1364
+ const frameworkNpm = `https://www.npmjs.com/package/${encodeURIComponent(framework.name)}`;
1365
+ const linkEls = links.map((l, i) => html `${i > 0 ? html `<span class="footer-sep">·</span>` : html ``}<a href="${l.href}" rel="noopener">${l.label}</a>`);
1366
+ const attribution = landing.attribution
1367
+ ? html `<span class="footer-attrib">built on <a href="${framework.homepage}">${framework.name}</a> v${framework.version} · <a href="${frameworkNpm}" rel="noopener">npm</a></span>`
1368
+ : html ``;
1369
+ return html `
1370
+ <footer>
1371
+ ${linkEls}
1372
+ <span class="footer-spacer"></span>
1373
+ ${attribution}
1374
+ </footer>
1375
+ `;
1376
+ }
1377
+ // ---------------------------------------------------------------------------
1378
+ // <head> metadata
1379
+ // ---------------------------------------------------------------------------
1380
+ function renderHead(manifest, pageUrl) {
1381
+ const { server, landing } = manifest;
1382
+ const title = `${server.name} · MCP server`;
1383
+ const description = landing.tagline ?? server.description ?? `MCP server: ${server.name}`;
1384
+ const ogImage = landing.logo?.startsWith('http')
1385
+ ? html `<meta property="og:image" content="${landing.logo}" />`
1386
+ : html ``;
1387
+ const favicon = landing.logo && isImageDataUri(landing.logo)
1388
+ ? html `<link rel="icon" href="${landing.logo}" />`
1389
+ : html ``;
1390
+ const themeColor = html `<meta name="theme-color" content="${landing.theme.accent}" />`;
1391
+ const jsonLd = JSON.stringify({
1392
+ '@context': 'https://schema.org',
1393
+ '@type': 'SoftwareApplication',
1394
+ name: server.name,
1395
+ description,
1396
+ applicationCategory: 'DeveloperApplication',
1397
+ softwareVersion: server.version,
1398
+ ...(server.homepage && { url: server.homepage }),
1399
+ });
1400
+ return html `
1401
+ <meta charset="utf-8" />
1402
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1403
+ <title>${title}</title>
1404
+ <meta name="description" content="${description}" />
1405
+ ${themeColor}
1406
+ <meta property="og:title" content="${title}" />
1407
+ <meta property="og:description" content="${description}" />
1408
+ <meta property="og:type" content="website" />
1409
+ <meta property="og:url" content="${pageUrl}" />
1410
+ ${ogImage}
1411
+ <meta name="twitter:card" content="summary" />
1412
+ <meta name="twitter:title" content="${title}" />
1413
+ <meta name="twitter:description" content="${description}" />
1414
+ ${favicon}
1415
+ <link rel="mcp-endpoint" href="${manifest.transport.endpointPath}" />
1416
+ <link rel="alternate" type="application/json" href="/.well-known/mcp.json" title="MCP Server Card" />
1417
+ <script type="application/ld+json">${unsafeRaw(escapeLdJson(jsonLd))}</script>
1418
+ `;
1419
+ }
1420
+ function isImageDataUri(value) {
1421
+ if (!value)
1422
+ return false;
1423
+ return /^data:image\/(png|svg\+xml|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon)/i.test(value);
1424
+ }
1425
+ /**
1426
+ * Escape a JSON string for safe embedding inside `<script type="application/ld+json">`.
1427
+ * Replaces HTML-sensitive characters with Unicode escapes — `<` → `<`,
1428
+ * `>` → `>`, `&` → `&`, U+2028/U+2029 line terminators → escaped.
1429
+ * Matches the hardening recommendation in https://html.spec.whatwg.org/#script-data-state.
1430
+ */
1431
+ function escapeLdJson(json) {
1432
+ return json
1433
+ .replace(/</g, '\\u003c')
1434
+ .replace(/>/g, '\\u003e')
1435
+ .replace(/&/g, '\\u0026')
1436
+ .replace(/\u2028/g, '\\u2028')
1437
+ .replace(/\u2029/g, '\\u2029');
1438
+ }
1439
+ // ---------------------------------------------------------------------------
1440
+ // Page composition
1441
+ // ---------------------------------------------------------------------------
1442
+ /**
1443
+ * Render the full landing page. `baseUrl` is the request origin
1444
+ * (e.g. `https://pubmed.example.com`) — used in connect snippets and OG meta.
1445
+ * `degraded` reduces the body when `requireAuth` gates unauth callers.
1446
+ */
1447
+ export function renderLandingPage(manifest, baseUrl, degraded = false) {
1448
+ const pageUrl = `${baseUrl.replace(/\/$/, '')}/`;
1449
+ const body = degraded
1450
+ ? html `
1451
+ ${renderHero(manifest, baseUrl, true)}
1452
+ <section>
1453
+ <p class="empty-state">
1454
+ Full server inventory is available to authenticated callers.
1455
+ </p>
1456
+ </section>
1457
+ ${renderFooter(manifest)}
1458
+ `
1459
+ : html `
1460
+ ${renderHero(manifest, baseUrl, false)}
1461
+ ${renderToolsSection(manifest.definitions.tools)}
1462
+ ${renderResourcesSection(manifest.definitions.resources)}
1463
+ ${renderPromptsSection(manifest.definitions.prompts)}
1464
+ ${renderExtensionsSection(manifest.extensions)}
1465
+ ${renderFooter(manifest)}
1466
+ `;
1467
+ const doc = html `<!DOCTYPE html>
1468
+ <html lang="en">
1469
+ <head>
1470
+ ${renderHead(manifest, pageUrl)}
1471
+ ${renderTokens(manifest.landing.theme.accent)}
1472
+ </head>
1473
+ <body>
1474
+ <main>${body}</main>
1475
+ ${renderCopyScript()}
1476
+ </body>
1477
+ </html>`;
1478
+ return doc.toString();
1479
+ }
1480
+ // ---------------------------------------------------------------------------
1481
+ // Hono handler
1482
+ // ---------------------------------------------------------------------------
1483
+ /**
1484
+ * Factory for the `GET /` route handler.
1485
+ *
1486
+ * Cache behavior and body shape depend on `manifest.landing.requireAuth`:
1487
+ *
1488
+ * | `requireAuth` | Authenticated | Unauthenticated |
1489
+ * |:---|:---|:---|
1490
+ * | `false` (default) | full page · `Cache-Control: public, max-age=60` | full page · same |
1491
+ * | `true` | full page · `Cache-Control: private, max-age=60` · `Vary: Authorization` | reduced hero-only page · same cache headers |
1492
+ *
1493
+ * The check is header-presence based — we don't validate the bearer token
1494
+ * here (that's the MCP endpoint's job). If a caller presents any Authorization
1495
+ * header, the full inventory renders; if not, they see a stub and a pointer
1496
+ * to the docs link when available.
1497
+ */
1498
+ export function createLandingPageHandler(manifest) {
1499
+ return (c) => {
1500
+ const context = requestContextService.createRequestContext({
1501
+ operation: 'landingPageHandler',
1502
+ });
1503
+ const url = new URL(c.req.url);
1504
+ const baseUrl = url.origin;
1505
+ const requireAuth = manifest.landing.requireAuth;
1506
+ const authHeader = c.req.header('authorization');
1507
+ const isAuthenticated = Boolean(authHeader && authHeader.trim().length > 0);
1508
+ const degraded = requireAuth && !isAuthenticated;
1509
+ const html = renderLandingPage(manifest, baseUrl, degraded);
1510
+ logger.debug('Serving landing page.', {
1511
+ ...context,
1512
+ accept: c.req.header('accept'),
1513
+ bytes: html.length,
1514
+ requireAuth,
1515
+ degraded,
1516
+ });
1517
+ c.header('Content-Type', 'text/html; charset=utf-8');
1518
+ c.header('X-Content-Type-Options', 'nosniff');
1519
+ if (requireAuth) {
1520
+ c.header('Cache-Control', 'private, max-age=60');
1521
+ c.header('Vary', 'Authorization');
1522
+ }
1523
+ else {
1524
+ c.header('Cache-Control', 'public, max-age=60');
1525
+ }
1526
+ return c.body(html);
1527
+ };
1528
+ }
1529
+ //# sourceMappingURL=landing-page.js.map