@cyanheads/mcp-ts-core 0.6.8 → 0.6.9

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