@powerduck/openapi-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2724 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenAPI MCP Console</title>
7
+ <style>
8
+ :root {
9
+ --bg: #07111f;
10
+ --panel: rgba(15, 23, 42, 0.82);
11
+ --panel-2: rgba(17, 24, 39, 0.92);
12
+ --border: rgba(148, 163, 184, 0.16);
13
+ --text: #e5eefc;
14
+ --muted: #90a3bf;
15
+ --brand: #60a5fa;
16
+ --brand-2: #8b5cf6;
17
+ --success: #22c55e;
18
+ --warn: #f59e0b;
19
+ --danger: #ef4444;
20
+ --shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
21
+ --sidebar-w: 248px;
22
+ }
23
+
24
+ * {
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ html,
29
+ body {
30
+ margin: 0;
31
+ min-height: 100%;
32
+ background:
33
+ radial-gradient(
34
+ circle at top left,
35
+ rgba(96, 165, 250, 0.16),
36
+ transparent 24%
37
+ ),
38
+ radial-gradient(
39
+ circle at top right,
40
+ rgba(139, 92, 246, 0.12),
41
+ transparent 26%
42
+ ),
43
+ linear-gradient(180deg, #07111f 0%, #091322 40%, #07111f 100%);
44
+ color: var(--text);
45
+ font-family:
46
+ Inter,
47
+ ui-sans-serif,
48
+ system-ui,
49
+ -apple-system,
50
+ BlinkMacSystemFont,
51
+ "Segoe UI",
52
+ sans-serif;
53
+ }
54
+
55
+ /* ---------------- Layout ---------------- */
56
+
57
+ .app {
58
+ display: grid;
59
+ grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
60
+ min-height: 100vh;
61
+ }
62
+
63
+ .sidebar {
64
+ border-right: 1px solid var(--border);
65
+ background: rgba(6, 12, 24, 0.72);
66
+ backdrop-filter: blur(18px);
67
+ padding: 22px 16px;
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 18px;
71
+ position: sticky;
72
+ top: 0;
73
+ height: 100vh;
74
+ overflow: auto;
75
+ }
76
+
77
+ .brand {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 10px;
81
+ padding: 0 6px;
82
+ }
83
+
84
+ .brand .dot {
85
+ width: 12px;
86
+ height: 12px;
87
+ border-radius: 999px;
88
+ background: linear-gradient(135deg, var(--brand), var(--brand-2));
89
+ box-shadow: 0 0 14px rgba(96, 165, 250, 0.7);
90
+ flex: none;
91
+ }
92
+
93
+ .brand strong {
94
+ font-size: 15px;
95
+ letter-spacing: 0.2px;
96
+ }
97
+
98
+ .nav {
99
+ display: grid;
100
+ gap: 6px;
101
+ }
102
+
103
+ .nav button {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ gap: 10px;
108
+ width: 100%;
109
+ text-align: left;
110
+ background: transparent;
111
+ border: 1px solid transparent;
112
+ color: var(--muted);
113
+ padding: 11px 12px;
114
+ border-radius: 12px;
115
+ font-size: 14px;
116
+ }
117
+
118
+ .nav button:hover:not(:disabled) {
119
+ background: rgba(51, 65, 85, 0.4);
120
+ color: var(--text);
121
+ transform: none;
122
+ }
123
+
124
+ .nav button[aria-current="page"] {
125
+ background: linear-gradient(
126
+ 135deg,
127
+ rgba(96, 165, 250, 0.22),
128
+ rgba(139, 92, 246, 0.18)
129
+ );
130
+ border-color: rgba(96, 165, 250, 0.32);
131
+ color: var(--text);
132
+ font-weight: 600;
133
+ }
134
+
135
+ .nav .count {
136
+ font-size: 11px;
137
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
138
+ padding: 2px 7px;
139
+ border-radius: 999px;
140
+ background: rgba(148, 163, 184, 0.16);
141
+ color: var(--muted);
142
+ }
143
+
144
+ .sidebar-foot {
145
+ margin-top: auto;
146
+ display: grid;
147
+ gap: 8px;
148
+ font-size: 12px;
149
+ color: var(--muted);
150
+ }
151
+
152
+ .main {
153
+ padding: 26px 30px 60px;
154
+ max-width: 1560px;
155
+ }
156
+
157
+ .page-head {
158
+ display: flex;
159
+ align-items: flex-start;
160
+ justify-content: space-between;
161
+ gap: 16px;
162
+ flex-wrap: wrap;
163
+ margin-bottom: 18px;
164
+ }
165
+
166
+ .page-head h1 {
167
+ margin: 0 0 6px;
168
+ font-size: 26px;
169
+ }
170
+ .page-head p {
171
+ margin: 0;
172
+ color: var(--muted);
173
+ max-width: 860px;
174
+ font-size: 14px;
175
+ }
176
+
177
+ .view {
178
+ display: none;
179
+ }
180
+ .view.active {
181
+ display: block;
182
+ }
183
+
184
+ .stack {
185
+ display: grid;
186
+ gap: 18px;
187
+ align-content: start;
188
+ }
189
+ .cols-2 {
190
+ display: grid;
191
+ grid-template-columns: 1.1fr 0.9fr;
192
+ gap: 18px;
193
+ align-items: start;
194
+ }
195
+ .cols-3 {
196
+ display: grid;
197
+ grid-template-columns: repeat(3, minmax(0, 1fr));
198
+ gap: 18px;
199
+ }
200
+
201
+ /* ---------------- Primitives ---------------- */
202
+
203
+ .card {
204
+ border: 1px solid var(--border);
205
+ background: var(--panel);
206
+ backdrop-filter: blur(16px);
207
+ border-radius: 20px;
208
+ padding: 20px;
209
+ box-shadow: var(--shadow);
210
+ }
211
+
212
+ .card h2,
213
+ .card h3 {
214
+ margin: 0 0 12px;
215
+ font-weight: 700;
216
+ }
217
+ .card h2 {
218
+ font-size: 18px;
219
+ }
220
+ .card h3 {
221
+ font-size: 15px;
222
+ }
223
+
224
+ .row {
225
+ display: flex;
226
+ gap: 10px;
227
+ flex-wrap: wrap;
228
+ align-items: center;
229
+ }
230
+ .row + .row {
231
+ margin-top: 12px;
232
+ }
233
+
234
+ input,
235
+ textarea,
236
+ select,
237
+ button {
238
+ font: inherit;
239
+ }
240
+
241
+ input,
242
+ textarea,
243
+ select {
244
+ width: 100%;
245
+ color: var(--text);
246
+ background: rgba(2, 6, 23, 0.75);
247
+ border: 1px solid rgba(148, 163, 184, 0.18);
248
+ border-radius: 12px;
249
+ padding: 11px 13px;
250
+ outline: none;
251
+ }
252
+
253
+ input:focus,
254
+ textarea:focus,
255
+ select:focus {
256
+ border-color: rgba(96, 165, 250, 0.55);
257
+ }
258
+ textarea {
259
+ min-height: 150px;
260
+ resize: vertical;
261
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
262
+ font-size: 13px;
263
+ }
264
+
265
+ button {
266
+ border: none;
267
+ border-radius: 12px;
268
+ padding: 10px 15px;
269
+ cursor: pointer;
270
+ color: white;
271
+ background: linear-gradient(135deg, var(--brand), var(--brand-2));
272
+ transition:
273
+ transform 0.12s ease,
274
+ opacity 0.12s ease;
275
+ }
276
+
277
+ button:hover:not(:disabled) {
278
+ transform: translateY(-1px);
279
+ }
280
+ button.secondary {
281
+ background: #334155;
282
+ }
283
+ button.ghost {
284
+ background: rgba(51, 65, 85, 0.5);
285
+ border: 1px solid var(--border);
286
+ }
287
+ button.danger {
288
+ background: linear-gradient(135deg, #ef4444, #b91c1c);
289
+ }
290
+ button.success {
291
+ background: linear-gradient(135deg, #16a34a, #15803d);
292
+ }
293
+ button:disabled {
294
+ opacity: 0.45;
295
+ cursor: not-allowed;
296
+ transform: none;
297
+ }
298
+
299
+ .btn-hero {
300
+ flex: 1 1 220px;
301
+ min-height: 92px;
302
+ border-radius: 18px;
303
+ font-size: 19px;
304
+ font-weight: 700;
305
+ display: flex;
306
+ flex-direction: column;
307
+ align-items: center;
308
+ justify-content: center;
309
+ gap: 6px;
310
+ }
311
+
312
+ .btn-hero small {
313
+ font-size: 12px;
314
+ font-weight: 500;
315
+ opacity: 0.85;
316
+ }
317
+
318
+ .pill {
319
+ display: inline-flex;
320
+ align-items: center;
321
+ gap: 7px;
322
+ padding: 5px 10px;
323
+ border-radius: 999px;
324
+ background: rgba(59, 130, 246, 0.18);
325
+ border: 1px solid rgba(96, 165, 250, 0.24);
326
+ font-size: 12px;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ .pill.success {
331
+ color: #bbf7d0;
332
+ background: rgba(34, 197, 94, 0.18);
333
+ border-color: rgba(34, 197, 94, 0.26);
334
+ }
335
+ .pill.warn {
336
+ color: #fde68a;
337
+ background: rgba(245, 158, 11, 0.18);
338
+ border-color: rgba(245, 158, 11, 0.26);
339
+ }
340
+ .pill.danger {
341
+ color: #fecaca;
342
+ background: rgba(239, 68, 68, 0.18);
343
+ border-color: rgba(239, 68, 68, 0.26);
344
+ }
345
+ .pill.plain {
346
+ background: rgba(148, 163, 184, 0.12);
347
+ border-color: var(--border);
348
+ color: var(--muted);
349
+ }
350
+
351
+ .muted {
352
+ color: var(--muted);
353
+ }
354
+ .mono {
355
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
356
+ }
357
+ .hint {
358
+ font-size: 12px;
359
+ color: var(--muted);
360
+ }
361
+ .hidden {
362
+ display: none !important;
363
+ }
364
+
365
+ .alert {
366
+ padding: 12px 14px;
367
+ border-radius: 14px;
368
+ border: 1px solid transparent;
369
+ font-size: 13px;
370
+ display: flex;
371
+ gap: 10px;
372
+ align-items: flex-start;
373
+ }
374
+
375
+ .alert.danger {
376
+ background: rgba(127, 29, 29, 0.3);
377
+ border-color: rgba(239, 68, 68, 0.34);
378
+ }
379
+ .alert.warn {
380
+ background: rgba(113, 63, 18, 0.3);
381
+ border-color: rgba(245, 158, 11, 0.3);
382
+ }
383
+ .alert.success {
384
+ background: rgba(20, 83, 45, 0.3);
385
+ border-color: rgba(34, 197, 94, 0.3);
386
+ }
387
+ .alert.info {
388
+ background: rgba(30, 58, 138, 0.28);
389
+ border-color: rgba(96, 165, 250, 0.3);
390
+ }
391
+ .alert + * {
392
+ margin-top: 14px;
393
+ }
394
+
395
+ .surface {
396
+ border: 1px solid var(--border);
397
+ background: rgba(2, 6, 23, 0.5);
398
+ border-radius: 14px;
399
+ padding: 13px;
400
+ }
401
+
402
+ .scroll {
403
+ max-height: 380px;
404
+ overflow: auto;
405
+ }
406
+ .scroll-lg {
407
+ max-height: 620px;
408
+ overflow: auto;
409
+ }
410
+ pre {
411
+ margin: 0;
412
+ white-space: pre-wrap;
413
+ word-break: break-word;
414
+ font-size: 12.5px;
415
+ line-height: 1.55;
416
+ }
417
+
418
+ .list {
419
+ display: grid;
420
+ gap: 12px;
421
+ }
422
+
423
+ .item {
424
+ border: 1px solid var(--border);
425
+ background: var(--panel-2);
426
+ border-radius: 16px;
427
+ padding: 14px;
428
+ }
429
+
430
+ .item.clickable {
431
+ cursor: pointer;
432
+ }
433
+ .item.clickable:hover {
434
+ border-color: rgba(96, 165, 250, 0.4);
435
+ }
436
+
437
+ .metric-grid {
438
+ display: grid;
439
+ grid-template-columns: repeat(5, minmax(0, 1fr));
440
+ gap: 13px;
441
+ }
442
+
443
+ .metric {
444
+ padding: 15px;
445
+ border-radius: 16px;
446
+ background: linear-gradient(
447
+ 180deg,
448
+ rgba(15, 23, 42, 0.9),
449
+ rgba(17, 24, 39, 0.6)
450
+ );
451
+ border: 1px solid var(--border);
452
+ }
453
+
454
+ .metric .label {
455
+ color: var(--muted);
456
+ font-size: 11px;
457
+ text-transform: uppercase;
458
+ letter-spacing: 0.8px;
459
+ }
460
+ .metric .value {
461
+ margin-top: 7px;
462
+ font-size: 25px;
463
+ font-weight: 800;
464
+ }
465
+
466
+ .tabs {
467
+ display: flex;
468
+ gap: 8px;
469
+ border-bottom: 1px solid var(--border);
470
+ margin-bottom: 16px;
471
+ flex-wrap: wrap;
472
+ }
473
+
474
+ .tabs button {
475
+ background: transparent;
476
+ color: var(--muted);
477
+ border-radius: 10px 10px 0 0;
478
+ border-bottom: 2px solid transparent;
479
+ padding: 10px 14px;
480
+ }
481
+
482
+ .tabs button:hover:not(:disabled) {
483
+ color: var(--text);
484
+ transform: none;
485
+ }
486
+ .tabs button[aria-selected="true"] {
487
+ color: var(--text);
488
+ border-bottom-color: var(--brand);
489
+ font-weight: 600;
490
+ }
491
+
492
+ .switch {
493
+ display: inline-flex;
494
+ align-items: center;
495
+ gap: 9px;
496
+ cursor: pointer;
497
+ font-size: 13px;
498
+ }
499
+
500
+ .switch input {
501
+ position: absolute;
502
+ opacity: 0;
503
+ pointer-events: none;
504
+ }
505
+
506
+ .switch .track {
507
+ width: 42px;
508
+ height: 24px;
509
+ border-radius: 999px;
510
+ background: rgba(148, 163, 184, 0.25);
511
+ border: 1px solid var(--border);
512
+ position: relative;
513
+ transition: background 0.15s ease;
514
+ flex: none;
515
+ }
516
+
517
+ .switch .track::after {
518
+ content: "";
519
+ position: absolute;
520
+ top: 2px;
521
+ left: 2px;
522
+ width: 18px;
523
+ height: 18px;
524
+ border-radius: 999px;
525
+ background: #e5eefc;
526
+ transition: transform 0.15s ease;
527
+ }
528
+
529
+ .switch input:checked + .track {
530
+ background: linear-gradient(135deg, var(--brand), var(--brand-2));
531
+ }
532
+ .switch input:checked + .track::after {
533
+ transform: translateX(18px);
534
+ }
535
+ .switch input:focus-visible + .track {
536
+ border-color: var(--brand);
537
+ }
538
+
539
+ .field {
540
+ display: grid;
541
+ gap: 6px;
542
+ }
543
+ .field > label {
544
+ font-size: 12px;
545
+ color: var(--muted);
546
+ }
547
+ .field .req {
548
+ color: #fca5a5;
549
+ }
550
+ .form-grid {
551
+ display: grid;
552
+ gap: 13px;
553
+ }
554
+
555
+ .kv {
556
+ display: grid;
557
+ grid-template-columns: 130px minmax(0, 1fr);
558
+ gap: 6px 12px;
559
+ font-size: 13px;
560
+ }
561
+ .kv dt {
562
+ color: var(--muted);
563
+ }
564
+ .kv dd {
565
+ margin: 0;
566
+ word-break: break-word;
567
+ }
568
+
569
+ .live-dot {
570
+ width: 8px;
571
+ height: 8px;
572
+ border-radius: 999px;
573
+ background: var(--success);
574
+ box-shadow: 0 0 10px var(--success);
575
+ animation: pulse 1.6s ease-in-out infinite;
576
+ }
577
+
578
+ @keyframes pulse {
579
+ 0%,
580
+ 100% {
581
+ opacity: 1;
582
+ }
583
+ 50% {
584
+ opacity: 0.3;
585
+ }
586
+ }
587
+
588
+ .empty {
589
+ padding: 26px;
590
+ text-align: center;
591
+ color: var(--muted);
592
+ font-size: 13px;
593
+ }
594
+
595
+ @media (max-width: 1180px) {
596
+ .cols-2,
597
+ .cols-3,
598
+ .metric-grid {
599
+ grid-template-columns: 1fr;
600
+ }
601
+ }
602
+
603
+ @media (max-width: 860px) {
604
+ .app {
605
+ grid-template-columns: 1fr;
606
+ }
607
+ .sidebar {
608
+ position: static;
609
+ height: auto;
610
+ border-right: none;
611
+ border-bottom: 1px solid var(--border);
612
+ }
613
+ .nav {
614
+ grid-template-columns: repeat(2, 1fr);
615
+ display: grid;
616
+ }
617
+ .main {
618
+ padding: 20px 16px 50px;
619
+ }
620
+ }
621
+ </style>
622
+ </head>
623
+ <body>
624
+ <div class="app">
625
+ <!-- ============================ Sidebar ============================ -->
626
+ <aside class="sidebar">
627
+ <div class="brand">
628
+ <span class="dot"></span>
629
+ <strong>OpenAPI MCP</strong>
630
+ </div>
631
+
632
+ <nav class="nav" id="nav">
633
+ <button type="button" data-view="dashboard" aria-current="page">
634
+ <span>Dashboard</span>
635
+ <span class="count" id="navStatus">–</span>
636
+ </button>
637
+ <button type="button" data-view="call">
638
+ <span>Call Tool</span>
639
+ </button>
640
+ <button type="button" data-view="logs">
641
+ <span>Logs</span>
642
+ <span class="count" id="navLogs">0</span>
643
+ </button>
644
+ <button type="button" data-view="catalog">
645
+ <span>Catalog</span>
646
+ <span class="count" id="navCatalog">0</span>
647
+ </button>
648
+ <button type="button" data-view="settings">
649
+ <span>Settings</span>
650
+ </button>
651
+ </nav>
652
+
653
+ <div class="sidebar-foot">
654
+ <div class="row" style="gap: 6px">
655
+ <span class="pill plain mono">/mcp</span>
656
+ <span class="pill plain mono">/sse</span>
657
+ </div>
658
+ <div class="hint">
659
+ STDIO transport is reachable from the CLI only — a browser cannot
660
+ open a STDIO stream.
661
+ </div>
662
+ </div>
663
+ </aside>
664
+
665
+ <!-- ============================= Main ============================= -->
666
+ <main class="main">
667
+ <!-- Reserved for cross-cutting failures (auth, network) only. -->
668
+ <div id="globalAlert"></div>
669
+
670
+ <!-- ---------------------- View: Dashboard ---------------------- -->
671
+ <section class="view active" id="view-dashboard">
672
+ <div class="page-head">
673
+ <div>
674
+ <h1>Dashboard</h1>
675
+ <p>
676
+ Runtime overview of the loaded document, the generated MCP
677
+ surface, and the active service.
678
+ </p>
679
+ </div>
680
+ <div class="row">
681
+ <span class="pill plain" id="dashPollState">idle</span>
682
+ <button class="ghost" id="btnDashRefresh">Refresh</button>
683
+ </div>
684
+ </div>
685
+
686
+ <div class="stack">
687
+ <section class="card">
688
+ <div class="metric-grid">
689
+ <div class="metric">
690
+ <div class="label">Spec Loaded</div>
691
+ <div class="value" id="metricSpecLoaded">–</div>
692
+ </div>
693
+ <div class="metric">
694
+ <div class="label">Service</div>
695
+ <div class="value" id="metricActive">–</div>
696
+ </div>
697
+ <div class="metric">
698
+ <div class="label">Tools</div>
699
+ <div class="value" id="metricTools">0</div>
700
+ </div>
701
+ <div class="metric">
702
+ <div class="label">Prompts</div>
703
+ <div class="value" id="metricPrompts">0</div>
704
+ </div>
705
+ <div class="metric">
706
+ <div class="label">Resources</div>
707
+ <div class="value" id="metricResources">0</div>
708
+ </div>
709
+ </div>
710
+ <div class="row" style="margin-top: 16px" id="statusFlags"></div>
711
+ </section>
712
+
713
+ <section class="card">
714
+ <h2>Quick Control</h2>
715
+ <div id="quickAlert"></div>
716
+ <div class="hint" style="margin-bottom: 12px">
717
+ Acts on the currently selected service
718
+ <span class="mono" id="quickTarget">(none)</span>. Starting a
719
+ service opens its MCP endpoints; stopping it drops every live
720
+ session.
721
+ </div>
722
+ <div class="row">
723
+ <button class="btn-hero success" id="btnQuickStart">
724
+ Start Service
725
+ <small>Bring the MCP endpoints online</small>
726
+ </button>
727
+ <button class="btn-hero danger" id="btnQuickStop">
728
+ Stop Service
729
+ <small>Close all sessions immediately</small>
730
+ </button>
731
+ </div>
732
+ </section>
733
+
734
+ <div class="cols-2">
735
+ <section class="card">
736
+ <h2>Managed Services</h2>
737
+ <div id="serviceList" class="list"></div>
738
+ </section>
739
+
740
+ <section class="card">
741
+ <h2>Raw Status</h2>
742
+ <div class="surface scroll">
743
+ <pre id="statusRaw" class="mono"></pre>
744
+ </div>
745
+ </section>
746
+ </div>
747
+ </div>
748
+ </section>
749
+
750
+ <!-- ---------------------- View: Call Tool ---------------------- -->
751
+ <section class="view" id="view-call">
752
+ <div class="page-head">
753
+ <div>
754
+ <h1>Call Tool</h1>
755
+ <p>
756
+ Executes a real upstream request through the active service,
757
+ credentials included. Not a dry run.
758
+ </p>
759
+ </div>
760
+ <button class="ghost" id="btnReloadTools">Reload Tools</button>
761
+ </div>
762
+
763
+ <div class="cols-2">
764
+ <section class="card">
765
+ <h2>Request</h2>
766
+ <div id="callAlert"></div>
767
+
768
+ <div class="field" style="margin-bottom: 14px">
769
+ <label for="toolSelect">Tool</label>
770
+ <select id="toolSelect"></select>
771
+ </div>
772
+
773
+ <div
774
+ id="toolMeta"
775
+ class="surface hidden"
776
+ style="margin-bottom: 14px"
777
+ ></div>
778
+
779
+ <div class="tabs" id="argTabs">
780
+ <button type="button" data-argtab="form" aria-selected="true">
781
+ Form
782
+ </button>
783
+ <button type="button" data-argtab="json" aria-selected="false">
784
+ Raw JSON
785
+ </button>
786
+ </div>
787
+
788
+ <div id="argPanel-form">
789
+ <div id="argForm" class="form-grid"></div>
790
+ </div>
791
+
792
+ <div id="argPanel-json" class="hidden">
793
+ <textarea
794
+ id="toolArgs"
795
+ spellcheck="false"
796
+ placeholder='{ "id": "123" }'
797
+ ></textarea>
798
+ <div class="row" style="margin-top: 10px">
799
+ <button class="ghost" id="btnFormatJson">Format</button>
800
+ <button class="ghost" id="btnFromForm">Fill from Form</button>
801
+ </div>
802
+ <div class="hint" style="margin-top: 8px">
803
+ Keys and string values must use double quotes. Single quotes
804
+ or bare keys are the usual cause of
805
+ <span class="mono">Expected ':' after property name</span> —
806
+ Format will repair those and tell you what it changed.
807
+ </div>
808
+ </div>
809
+
810
+ <div class="row" style="margin-top: 16px">
811
+ <button id="btnDebugCall">Call Tool</button>
812
+ <button class="secondary" id="btnCancelCall" disabled>
813
+ Cancel
814
+ </button>
815
+ <span id="callSpinner" class="pill hidden">
816
+ <span class="live-dot"></span> running
817
+ </span>
818
+ </div>
819
+ </section>
820
+
821
+ <section class="card">
822
+ <h2>Response</h2>
823
+ <div
824
+ id="callResultMeta"
825
+ class="row"
826
+ style="margin-bottom: 12px"
827
+ ></div>
828
+ <div class="surface scroll-lg">
829
+ <pre id="debugResult" class="mono muted">
830
+ No call has been made yet.</pre
831
+ >
832
+ </div>
833
+ </section>
834
+ </div>
835
+ </section>
836
+
837
+ <!-- ------------------------ View: Logs ------------------------ -->
838
+ <section class="view" id="view-logs">
839
+ <div class="page-head">
840
+ <div>
841
+ <h1>Request Logs</h1>
842
+ <p>
843
+ Upstream and protocol traffic recorded by the server, newest
844
+ first.
845
+ </p>
846
+ </div>
847
+ <div class="row">
848
+ <label class="switch" for="autoRefresh">
849
+ <input id="autoRefresh" type="checkbox" checked />
850
+ <span class="track"></span>
851
+ <span>Auto refresh</span>
852
+ </label>
853
+ <select id="autoRefreshInterval" style="width: auto">
854
+ <option value="2000">2s</option>
855
+ <option value="3000" selected>3s</option>
856
+ <option value="5000">5s</option>
857
+ <option value="10000">10s</option>
858
+ </select>
859
+ <button class="ghost" id="btnRefreshLogs">Refresh</button>
860
+ <button class="danger" id="btnClearLogs">Clear</button>
861
+ </div>
862
+ </div>
863
+
864
+ <div id="logsAlert"></div>
865
+
866
+ <section class="card">
867
+ <div class="row" style="margin-bottom: 14px">
868
+ <span class="pill plain" id="logPollState">paused</span>
869
+ <span class="pill plain"
870
+ >Entries <span id="logCount">0</span></span
871
+ >
872
+ <input
873
+ id="logFilter"
874
+ placeholder="Filter by tool, URL, method or status"
875
+ style="flex: 1 1 260px; width: auto"
876
+ />
877
+ <label class="switch" for="logErrorsOnly">
878
+ <input id="logErrorsOnly" type="checkbox" />
879
+ <span class="track"></span>
880
+ <span>Failures only</span>
881
+ </label>
882
+ </div>
883
+ <div id="logList" class="list"></div>
884
+ </section>
885
+ </section>
886
+
887
+ <!-- ----------------------- View: Catalog ----------------------- -->
888
+ <section class="view" id="view-catalog">
889
+ <div class="page-head">
890
+ <div>
891
+ <h1>Catalog</h1>
892
+ <p>
893
+ Everything the server exposes to MCP clients, generated from the
894
+ loaded document.
895
+ </p>
896
+ </div>
897
+ <button class="ghost" id="btnReloadCatalog">Reload</button>
898
+ </div>
899
+
900
+ <div id="catalogAlert"></div>
901
+
902
+ <div class="tabs" id="catalogTabs">
903
+ <button type="button" data-cat="tools" aria-selected="true">
904
+ Tools <span class="count" id="tabToolCount">0</span>
905
+ </button>
906
+ <button type="button" data-cat="prompts" aria-selected="false">
907
+ Prompts <span class="count" id="tabPromptCount">0</span>
908
+ </button>
909
+ <button type="button" data-cat="resources" aria-selected="false">
910
+ Resources <span class="count" id="tabResourceCount">0</span>
911
+ </button>
912
+ </div>
913
+
914
+ <div class="row" style="margin-bottom: 14px">
915
+ <input
916
+ id="catalogFilter"
917
+ placeholder="Filter the current tab"
918
+ style="flex: 1 1 300px; width: auto"
919
+ />
920
+ <label class="switch" for="catalogRaw">
921
+ <input id="catalogRaw" type="checkbox" />
922
+ <span class="track"></span>
923
+ <span>Show raw JSON</span>
924
+ </label>
925
+ </div>
926
+
927
+ <section class="card" id="catPanel-tools">
928
+ <div id="toolCatalog" class="list"></div>
929
+ </section>
930
+
931
+ <section class="card hidden" id="catPanel-prompts">
932
+ <div id="promptCatalog" class="list"></div>
933
+ </section>
934
+
935
+ <section class="card hidden" id="catPanel-resources">
936
+ <div id="resourceCatalog" class="list"></div>
937
+ </section>
938
+
939
+ <section
940
+ class="card hidden"
941
+ id="catPanel-raw"
942
+ style="margin-top: 18px"
943
+ >
944
+ <h3>Raw payload</h3>
945
+ <div class="surface scroll-lg">
946
+ <pre id="catalogRawOut" class="mono"></pre>
947
+ </div>
948
+ </section>
949
+ </section>
950
+
951
+ <!-- ---------------------- View: Settings ---------------------- -->
952
+ <section class="view" id="view-settings">
953
+ <div class="page-head">
954
+ <div>
955
+ <h1>Settings</h1>
956
+ <p>
957
+ Load an OpenAPI document and configure how this console talks to
958
+ the admin API and the upstream host.
959
+ </p>
960
+ </div>
961
+ </div>
962
+
963
+ <div id="settingsAlert"></div>
964
+
965
+ <div class="cols-2">
966
+ <section class="card">
967
+ <h2>Load OpenAPI Document</h2>
968
+
969
+ <div class="field" style="margin-bottom: 14px">
970
+ <label for="fileInput">From file</label>
971
+ <div class="row">
972
+ <input type="file" id="fileInput" accept=".json,.yaml,.yml" />
973
+ <button id="btnUpload">Upload</button>
974
+ </div>
975
+ </div>
976
+
977
+ <div class="field">
978
+ <label for="content">From text</label>
979
+ <textarea
980
+ id="content"
981
+ spellcheck="false"
982
+ placeholder="Paste JSON or YAML here"
983
+ ></textarea>
984
+ </div>
985
+
986
+ <div class="row" style="margin-top: 12px">
987
+ <label class="switch" for="isYaml">
988
+ <input id="isYaml" type="checkbox" />
989
+ <span class="track"></span>
990
+ <span>Force YAML</span>
991
+ </label>
992
+ <button id="btnPaste">Load Text</button>
993
+ <button class="secondary" id="btnClearSpec">Clear Spec</button>
994
+ </div>
995
+ </section>
996
+
997
+ <div class="stack">
998
+ <section class="card">
999
+ <h2>Admin Credential</h2>
1000
+ <div class="field">
1001
+ <label for="apiKey">API key</label>
1002
+ <input
1003
+ id="apiKey"
1004
+ type="password"
1005
+ autocomplete="off"
1006
+ placeholder="Leave empty if the server requires no auth"
1007
+ />
1008
+ </div>
1009
+ <div class="hint" style="margin-top: 10px">
1010
+ Held in this browser tab only (never written to disk) and sent
1011
+ as both <span class="mono">Authorization: Bearer</span> and
1012
+ <span class="mono">x-api-key</span>, because different clients
1013
+ support different header conventions.
1014
+ </div>
1015
+ <div class="row" style="margin-top: 12px">
1016
+ <button class="ghost" id="btnApplyKey">
1017
+ Apply &amp; Test
1018
+ </button>
1019
+ <button class="secondary" id="btnForgetKey">
1020
+ Forget Key
1021
+ </button>
1022
+ </div>
1023
+ </section>
1024
+
1025
+ <section class="card">
1026
+ <h2>Upstream Base URL</h2>
1027
+ <div class="field">
1028
+ <label for="baseUrl">Override</label>
1029
+ <input
1030
+ id="baseUrl"
1031
+ placeholder="https://api.example.com/v1"
1032
+ />
1033
+ </div>
1034
+ <div class="hint" style="margin-top: 10px">
1035
+ Use this when the server URL declared in the document is not
1036
+ routable from the machine running this process.
1037
+ </div>
1038
+ <div class="row" style="margin-top: 12px">
1039
+ <button id="btnSaveBaseUrl">Save</button>
1040
+ <button class="secondary" id="btnResetBaseUrl">
1041
+ Remove Override
1042
+ </button>
1043
+ </div>
1044
+ </section>
1045
+ </div>
1046
+ </div>
1047
+ </section>
1048
+ </main>
1049
+ </div>
1050
+
1051
+ <script>
1052
+ "use strict";
1053
+
1054
+ /* ==================================================================
1055
+ * Constants & state
1056
+ * ================================================================== */
1057
+
1058
+ const API_KEY_STORAGE = "openapi-mcp-console.apiKey";
1059
+ const DEFAULT_VIEW = "dashboard";
1060
+
1061
+ const state = {
1062
+ view: DEFAULT_VIEW,
1063
+ catalogTab: "tools",
1064
+ argTab: "form",
1065
+ status: {},
1066
+ services: [],
1067
+ tools: { tools: [], bindings: [], issues: [] },
1068
+ prompts: { prompts: [] },
1069
+ resources: { resources: [] },
1070
+ logs: [],
1071
+ selectedServiceId: null,
1072
+ };
1073
+
1074
+ /* ==================================================================
1075
+ * Element handles
1076
+ * ================================================================== */
1077
+
1078
+ const $ = (id) => document.getElementById(id);
1079
+
1080
+ const el = {
1081
+ globalAlert: $("globalAlert"),
1082
+ nav: $("nav"),
1083
+ navStatus: $("navStatus"),
1084
+ navLogs: $("navLogs"),
1085
+ navCatalog: $("navCatalog"),
1086
+
1087
+ // dashboard
1088
+ dashPollState: $("dashPollState"),
1089
+ statusFlags: $("statusFlags"),
1090
+ statusRaw: $("statusRaw"),
1091
+ serviceList: $("serviceList"),
1092
+ quickAlert: $("quickAlert"),
1093
+ quickTarget: $("quickTarget"),
1094
+ btnQuickStart: $("btnQuickStart"),
1095
+ btnQuickStop: $("btnQuickStop"),
1096
+
1097
+ // call tool
1098
+ callAlert: $("callAlert"),
1099
+ toolSelect: $("toolSelect"),
1100
+ toolMeta: $("toolMeta"),
1101
+ argForm: $("argForm"),
1102
+ toolArgs: $("toolArgs"),
1103
+ debugResult: $("debugResult"),
1104
+ callResultMeta: $("callResultMeta"),
1105
+ btnCancelCall: $("btnCancelCall"),
1106
+ callSpinner: $("callSpinner"),
1107
+
1108
+ // logs
1109
+ logsAlert: $("logsAlert"),
1110
+ logList: $("logList"),
1111
+ logCount: $("logCount"),
1112
+ logFilter: $("logFilter"),
1113
+ logErrorsOnly: $("logErrorsOnly"),
1114
+ autoRefresh: $("autoRefresh"),
1115
+ autoRefreshInterval: $("autoRefreshInterval"),
1116
+ logPollState: $("logPollState"),
1117
+
1118
+ // catalog
1119
+ catalogAlert: $("catalogAlert"),
1120
+ catalogFilter: $("catalogFilter"),
1121
+ catalogRaw: $("catalogRaw"),
1122
+ catalogRawOut: $("catalogRawOut"),
1123
+ toolCatalog: $("toolCatalog"),
1124
+ promptCatalog: $("promptCatalog"),
1125
+ resourceCatalog: $("resourceCatalog"),
1126
+ tabToolCount: $("tabToolCount"),
1127
+ tabPromptCount: $("tabPromptCount"),
1128
+ tabResourceCount: $("tabResourceCount"),
1129
+
1130
+ // settings
1131
+ settingsAlert: $("settingsAlert"),
1132
+ fileInput: $("fileInput"),
1133
+ content: $("content"),
1134
+ isYaml: $("isYaml"),
1135
+ apiKey: $("apiKey"),
1136
+ baseUrl: $("baseUrl"),
1137
+ };
1138
+
1139
+ /* ==================================================================
1140
+ * Primitives
1141
+ * ================================================================== */
1142
+
1143
+ function escapeHtml(value) {
1144
+ return String(value ?? "")
1145
+ .replaceAll("&", "&amp;")
1146
+ .replaceAll("<", "&lt;")
1147
+ .replaceAll(">", "&gt;")
1148
+ .replaceAll('"', "&quot;")
1149
+ .replaceAll("'", "&#39;");
1150
+ }
1151
+
1152
+ function pretty(value) {
1153
+ try {
1154
+ return JSON.stringify(value, null, 2);
1155
+ } catch {
1156
+ return "[unserializable payload]";
1157
+ }
1158
+ }
1159
+
1160
+ function isAbortError(error) {
1161
+ return Boolean(error) && error.name === "AbortError";
1162
+ }
1163
+
1164
+ /** Rejected credential: callers stop polling instead of retrying forever. */
1165
+ class UnauthorizedError extends Error {
1166
+ constructor(message) {
1167
+ super(
1168
+ message || "Unauthorized: the API key is missing or incorrect.",
1169
+ );
1170
+ this.name = "UnauthorizedError";
1171
+ }
1172
+ }
1173
+
1174
+ function authHeaders(extra = {}) {
1175
+ const apiKey = el.apiKey.value.trim();
1176
+ const merged = { ...extra };
1177
+ if (apiKey) {
1178
+ merged["x-api-key"] = apiKey;
1179
+ merged["Authorization"] = `Bearer ${apiKey}`;
1180
+ }
1181
+ return merged;
1182
+ }
1183
+
1184
+ async function request(url, options = {}) {
1185
+ const response = await fetch(url, options);
1186
+ const body = await response.json().catch(() => ({}));
1187
+ if (response.status === 401 || response.status === 403) {
1188
+ throw new UnauthorizedError(body.error);
1189
+ }
1190
+ if (!response.ok) {
1191
+ const error = new Error(
1192
+ body.error || `HTTP ${response.status} ${response.statusText}`,
1193
+ );
1194
+ error.status = response.status;
1195
+ error.body = body;
1196
+ throw error;
1197
+ }
1198
+ return body;
1199
+ }
1200
+
1201
+ /**
1202
+ * Renders feedback INSIDE the panel that triggered the action.
1203
+ *
1204
+ * The previous console pushed everything to a single banner at the top of
1205
+ * the document; when the operator was scrolled down to the debugger, a
1206
+ * validation failure landed off-screen and the button looked dead.
1207
+ */
1208
+ function notify(
1209
+ target,
1210
+ message,
1211
+ type = "danger",
1212
+ { sticky = false } = {},
1213
+ ) {
1214
+ if (!target) return;
1215
+ target.innerHTML = `<div class="alert ${type}">${escapeHtml(message)}</div>`;
1216
+ if (!sticky && (type === "success" || type === "info")) {
1217
+ const token = (target.dataset.token = String(Date.now()));
1218
+ setTimeout(() => {
1219
+ if (target.dataset.token === token) target.innerHTML = "";
1220
+ }, 4000);
1221
+ }
1222
+ }
1223
+
1224
+ function clearNotice(target) {
1225
+ if (target) target.innerHTML = "";
1226
+ }
1227
+
1228
+ function showGlobal(message, type = "danger") {
1229
+ el.globalAlert.innerHTML = `<div class="alert ${type}">${escapeHtml(message)}</div>`;
1230
+ }
1231
+
1232
+ function clearGlobal() {
1233
+ el.globalAlert.innerHTML = "";
1234
+ }
1235
+
1236
+ /**
1237
+ * Reference-counted busy state, scoped so that navigation and Cancel stay
1238
+ * usable. A plain boolean let whichever request finished first re-enable
1239
+ * controls still owned by a running one.
1240
+ */
1241
+ let busyDepth = 0;
1242
+ const BUSY_EXEMPT = new Set(["btnCancelCall"]);
1243
+
1244
+ function setBusy(busy) {
1245
+ busyDepth = Math.max(0, busyDepth + (busy ? 1 : -1));
1246
+ const disabled = busyDepth > 0;
1247
+ document.querySelectorAll("button").forEach((button) => {
1248
+ if (BUSY_EXEMPT.has(button.id)) return;
1249
+ if (button.closest("#nav, .tabs")) return;
1250
+ button.disabled = disabled;
1251
+ });
1252
+ }
1253
+
1254
+ /**
1255
+ * Wraps a user action: busy state plus error reporting into the panel the
1256
+ * action belongs to.
1257
+ */
1258
+ async function run(action, alertTarget) {
1259
+ const target = alertTarget || currentAlertTarget();
1260
+ clearNotice(target);
1261
+ clearGlobal();
1262
+ setBusy(true);
1263
+ try {
1264
+ await action();
1265
+ } catch (error) {
1266
+ if (isAbortError(error)) return;
1267
+ if (error instanceof UnauthorizedError) {
1268
+ stopLogPolling();
1269
+ stopDashboardPolling();
1270
+ showGlobal(
1271
+ `${error.message} Open Settings to update the key; polling has been stopped.`,
1272
+ );
1273
+ } else {
1274
+ notify(target, error.message);
1275
+ }
1276
+ } finally {
1277
+ setBusy(false);
1278
+ }
1279
+ }
1280
+
1281
+ function currentAlertTarget() {
1282
+ switch (state.view) {
1283
+ case "dashboard":
1284
+ return el.quickAlert;
1285
+ case "call":
1286
+ return el.callAlert;
1287
+ case "logs":
1288
+ return el.logsAlert;
1289
+ case "catalog":
1290
+ return el.catalogAlert;
1291
+ case "settings":
1292
+ return el.settingsAlert;
1293
+ default:
1294
+ return el.globalAlert;
1295
+ }
1296
+ }
1297
+
1298
+ /* ==================================================================
1299
+ * Lenient JSON parsing
1300
+ * ================================================================== */
1301
+
1302
+ /**
1303
+ * Parses tool arguments, tolerating the two mistakes that produce almost
1304
+ * every "Expected ':' after property name" report: bare object keys and
1305
+ * single-quoted strings. A repair is never silent — the caller surfaces it
1306
+ * and rewrites the editor so the operator sees what was accepted.
1307
+ */
1308
+ function parseArguments(raw) {
1309
+ const text = raw.trim();
1310
+ if (!text) return { value: {}, repaired: false };
1311
+
1312
+ try {
1313
+ return { value: JSON.parse(text), repaired: false };
1314
+ } catch (strictError) {
1315
+ const candidate = repairJsonish(text);
1316
+ if (candidate !== text) {
1317
+ try {
1318
+ return {
1319
+ value: JSON.parse(candidate),
1320
+ repaired: true,
1321
+ repairedText: candidate,
1322
+ };
1323
+ } catch {
1324
+ /* fall through to the original, more accurate error */
1325
+ }
1326
+ }
1327
+ const error = new Error(
1328
+ `Tool arguments must be valid JSON. ${strictError.message}. ` +
1329
+ `Keys and string values need double quotes, e.g. {"id": "123"}.`,
1330
+ );
1331
+ error.name = "ArgumentSyntaxError";
1332
+ throw error;
1333
+ }
1334
+ }
1335
+
1336
+ /** Best-effort normaliser. Skips anything inside a double-quoted string. */
1337
+ function repairJsonish(text) {
1338
+ let out = "";
1339
+ let inDouble = false;
1340
+ let inSingle = false;
1341
+
1342
+ for (let i = 0; i < text.length; i += 1) {
1343
+ const char = text[i];
1344
+ const prev = text[i - 1];
1345
+ const escaped = prev === "\\";
1346
+
1347
+ if (inDouble) {
1348
+ out += char;
1349
+ if (char === '"' && !escaped) inDouble = false;
1350
+ continue;
1351
+ }
1352
+
1353
+ if (inSingle) {
1354
+ if (char === "'" && !escaped) {
1355
+ inSingle = false;
1356
+ out += '"';
1357
+ } else if (char === '"' && !escaped) {
1358
+ out += '\\"'; // a double quote inside a single-quoted string
1359
+ } else {
1360
+ out += char;
1361
+ }
1362
+ continue;
1363
+ }
1364
+
1365
+ if (char === '"') {
1366
+ inDouble = true;
1367
+ out += char;
1368
+ continue;
1369
+ }
1370
+
1371
+ if (char === "'") {
1372
+ inSingle = true;
1373
+ out += '"';
1374
+ continue;
1375
+ }
1376
+
1377
+ out += char;
1378
+ }
1379
+
1380
+ // Quote bare object keys: {id: 1} and {id "1"} both become {"id": ...}
1381
+ out = out.replace(
1382
+ /([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*)(:|(?="))/g,
1383
+ (match, lead, key, gap, tail) =>
1384
+ `${lead}"${key}"${tail === ":" ? gap + ":" : ": "}`,
1385
+ );
1386
+
1387
+ // Trailing commas
1388
+ out = out.replace(/,(\s*[}\]])/g, "$1");
1389
+ return out;
1390
+ }
1391
+
1392
+ /* ==================================================================
1393
+ * Navigation
1394
+ * ================================================================== */
1395
+
1396
+ function setView(view, { pushHash = true } = {}) {
1397
+ if (!document.getElementById(`view-${view}`)) view = DEFAULT_VIEW;
1398
+ state.view = view;
1399
+
1400
+ document.querySelectorAll(".view").forEach((section) => {
1401
+ section.classList.toggle("active", section.id === `view-${view}`);
1402
+ });
1403
+ el.nav.querySelectorAll("button[data-view]").forEach((button) => {
1404
+ if (button.dataset.view === view) {
1405
+ button.setAttribute("aria-current", "page");
1406
+ } else {
1407
+ button.removeAttribute("aria-current");
1408
+ }
1409
+ });
1410
+
1411
+ if (pushHash && window.location.hash !== `#${view}`) {
1412
+ window.location.hash = view;
1413
+ }
1414
+
1415
+ window.scrollTo({ top: 0, behavior: "instant" });
1416
+ syncPollers();
1417
+ onViewEntered(view);
1418
+ }
1419
+
1420
+ function onViewEntered(view) {
1421
+ // Fetch what the view actually needs instead of hammering every endpoint
1422
+ // on a global timer the way the old console did.
1423
+ if (view === "dashboard") void refreshStatus().catch(reportBackground);
1424
+ if (view === "call" && state.tools.tools.length === 0) {
1425
+ void refreshCatalog().catch(reportBackground);
1426
+ }
1427
+ if (view === "logs") void refreshLogs().catch(reportBackground);
1428
+ if (view === "catalog") void refreshCatalog().catch(reportBackground);
1429
+ }
1430
+
1431
+ function reportBackground(error) {
1432
+ if (isAbortError(error)) return;
1433
+ if (error instanceof UnauthorizedError) {
1434
+ stopLogPolling();
1435
+ stopDashboardPolling();
1436
+ showGlobal(`${error.message} Polling has been stopped.`);
1437
+ return;
1438
+ }
1439
+ console.warn(error);
1440
+ }
1441
+
1442
+ /* ==================================================================
1443
+ * Data loading
1444
+ * ================================================================== */
1445
+
1446
+ let statusController = null;
1447
+
1448
+ async function refreshStatus() {
1449
+ statusController?.abort();
1450
+ statusController = new AbortController();
1451
+ const headers = authHeaders();
1452
+ const { signal } = statusController;
1453
+
1454
+ const [status, servers] = await Promise.all([
1455
+ request("/api/status", { headers, signal }),
1456
+ request("/api/servers", { headers, signal }),
1457
+ ]);
1458
+
1459
+ state.status = status || {};
1460
+ state.services = servers?.services || [];
1461
+ renderDashboard();
1462
+ renderNavCounts();
1463
+ }
1464
+
1465
+ let catalogController = null;
1466
+
1467
+ async function refreshCatalog() {
1468
+ catalogController?.abort();
1469
+ catalogController = new AbortController();
1470
+ const headers = authHeaders();
1471
+ const { signal } = catalogController;
1472
+
1473
+ const [tools, prompts, resources] = await Promise.all([
1474
+ request("/api/tools", { headers, signal }),
1475
+ request("/api/prompts", { headers, signal }),
1476
+ request("/api/resources", { headers, signal }),
1477
+ ]);
1478
+
1479
+ state.tools = normaliseTools(tools);
1480
+ state.prompts = { prompts: asArray(prompts, "prompts"), raw: prompts };
1481
+ state.resources = {
1482
+ resources: asArray(resources, "resources"),
1483
+ raw: resources,
1484
+ };
1485
+
1486
+ renderCatalog();
1487
+ renderToolPicker();
1488
+ renderNavCounts();
1489
+ }
1490
+
1491
+ let logsController = null;
1492
+
1493
+ async function refreshLogs() {
1494
+ logsController?.abort();
1495
+ logsController = new AbortController();
1496
+ const data = await request("/api/logs", {
1497
+ headers: authHeaders(),
1498
+ signal: logsController.signal,
1499
+ });
1500
+ state.logs = data?.logs || [];
1501
+ renderLogs();
1502
+ renderNavCounts();
1503
+ }
1504
+
1505
+ function normaliseTools(payload) {
1506
+ return {
1507
+ tools: asArray(payload, "tools"),
1508
+ bindings: asArray(payload, "bindings"),
1509
+ issues: asArray(payload, "issues"),
1510
+ raw: payload,
1511
+ };
1512
+ }
1513
+
1514
+ /** Accepts both `{key: [...]}` and a bare array, since DTO shapes vary. */
1515
+ function asArray(payload, key) {
1516
+ if (Array.isArray(payload)) return payload;
1517
+ const value = payload?.[key];
1518
+ return Array.isArray(value) ? value : [];
1519
+ }
1520
+
1521
+ /* ==================================================================
1522
+ * Polling — scoped to the view that owns it
1523
+ * ================================================================== */
1524
+
1525
+ let logTimer = null;
1526
+ let dashTimer = null;
1527
+
1528
+ function logPollInterval() {
1529
+ return Number(el.autoRefreshInterval.value) || 3000;
1530
+ }
1531
+
1532
+ function startLogPolling() {
1533
+ stopLogPolling();
1534
+ logTimer = setInterval(() => {
1535
+ if (document.hidden) return; // no point polling a background tab
1536
+ refreshLogs().catch(reportBackground);
1537
+ }, logPollInterval());
1538
+ el.logPollState.innerHTML = `<span class="live-dot"></span> live · ${logPollInterval() / 1000}s`;
1539
+ }
1540
+
1541
+ function stopLogPolling() {
1542
+ if (logTimer) clearInterval(logTimer);
1543
+ logTimer = null;
1544
+ el.logPollState.textContent = "paused";
1545
+ }
1546
+
1547
+ function startDashboardPolling() {
1548
+ stopDashboardPolling();
1549
+ dashTimer = setInterval(() => {
1550
+ if (document.hidden) return;
1551
+ refreshStatus().catch(reportBackground);
1552
+ }, 5000);
1553
+ el.dashPollState.innerHTML = `<span class="live-dot"></span> live · 5s`;
1554
+ }
1555
+
1556
+ function stopDashboardPolling() {
1557
+ if (dashTimer) clearInterval(dashTimer);
1558
+ dashTimer = null;
1559
+ el.dashPollState.textContent = "idle";
1560
+ }
1561
+
1562
+ /** Only the visible view polls; leaving a view stops its traffic. */
1563
+ function syncPollers() {
1564
+ if (state.view === "logs" && el.autoRefresh.checked) startLogPolling();
1565
+ else stopLogPolling();
1566
+
1567
+ if (state.view === "dashboard") startDashboardPolling();
1568
+ else stopDashboardPolling();
1569
+ }
1570
+
1571
+ /* ==================================================================
1572
+ * Rendering: navigation counters
1573
+ * ================================================================== */
1574
+
1575
+ function renderNavCounts() {
1576
+ const status = state.status;
1577
+ el.navStatus.textContent = status.active
1578
+ ? "live"
1579
+ : status.specLoaded
1580
+ ? "idle"
1581
+ : "–";
1582
+ el.navLogs.textContent = String(state.logs.length);
1583
+ el.navCatalog.textContent = String(
1584
+ state.tools.tools.length +
1585
+ state.prompts.prompts.length +
1586
+ state.resources.resources.length,
1587
+ );
1588
+ }
1589
+
1590
+ /* ==================================================================
1591
+ * Rendering: dashboard
1592
+ * ================================================================== */
1593
+
1594
+ function renderDashboard() {
1595
+ const status = state.status;
1596
+
1597
+ $("metricSpecLoaded").textContent = status.specLoaded ? "Yes" : "No";
1598
+ $("metricActive").textContent = status.active ? "Running" : "Stopped";
1599
+ $("metricTools").textContent = String(status.toolCount ?? 0);
1600
+ $("metricPrompts").textContent = String(status.promptCount ?? 0);
1601
+ $("metricResources").textContent = String(status.resourceCount ?? 0);
1602
+
1603
+ const flags = [
1604
+ `<span class="pill ${status.specLoaded ? "success" : "warn"}">Spec ${status.specLoaded ? "Loaded" : "Missing"}</span>`,
1605
+ `<span class="pill ${status.active ? "success" : "danger"}">Service ${status.active ? "Running" : "Stopped"}</span>`,
1606
+ `<span class="pill plain" title="Streamable HTTP and legacy SSE sessions combined.">Sessions ${escapeHtml(status.activeSessions ?? 0)}</span>`,
1607
+ ];
1608
+ if ((status.pendingSessions ?? 0) > 0) {
1609
+ flags.push(
1610
+ `<span class="pill plain" title="Initialize requests accepted but not yet registered.">Pending ${escapeHtml(status.pendingSessions)}</span>`,
1611
+ );
1612
+ }
1613
+ if ((status.degradedToolCount ?? 0) > 0) {
1614
+ flags.push(
1615
+ `<span class="pill warn">Degraded Tools ${escapeHtml(status.degradedToolCount)}</span>`,
1616
+ );
1617
+ }
1618
+ flags.push(
1619
+ `<span class="pill plain">${status.authRequired ? "Auth Required" : "Auth Open"}</span>`,
1620
+ );
1621
+ if (status.persistEnabled !== undefined) {
1622
+ flags.push(
1623
+ `<span class="pill plain">${status.persistEnabled ? "Persistence On" : "Persistence Off"}</span>`,
1624
+ );
1625
+ }
1626
+ if (status.specSource) {
1627
+ flags.push(
1628
+ `<span class="pill plain">Source ${escapeHtml(status.specSource)}</span>`,
1629
+ );
1630
+ }
1631
+ el.statusFlags.innerHTML = flags.join("");
1632
+
1633
+ el.statusRaw.textContent = pretty(status);
1634
+
1635
+ renderServiceList();
1636
+ renderQuickControl();
1637
+ }
1638
+
1639
+ function renderServiceList() {
1640
+ const services = state.services;
1641
+ if (services.length === 0) {
1642
+ el.serviceList.innerHTML = `<div class="empty">No services registered. Load a document from Settings.</div>`;
1643
+ return;
1644
+ }
1645
+
1646
+ // Keep a valid selection so the hero buttons always have a target.
1647
+ if (
1648
+ !services.some((service) => service.id === state.selectedServiceId)
1649
+ ) {
1650
+ const running = services.find(
1651
+ (service) => service.status === "running",
1652
+ );
1653
+ state.selectedServiceId = (running || services[0]).id;
1654
+ }
1655
+
1656
+ el.serviceList.innerHTML = services
1657
+ .map((service) => {
1658
+ const pillClass =
1659
+ service.status === "running"
1660
+ ? "success"
1661
+ : service.status === "stopped"
1662
+ ? "warn"
1663
+ : "danger";
1664
+ const selected = service.id === state.selectedServiceId;
1665
+ const lastError = service.lastError
1666
+ ? `<div class="hint" style="margin-top:8px;color:#fecaca;">${escapeHtml(service.lastError.message)} (${escapeHtml(service.lastError.at)})</div>`
1667
+ : "";
1668
+ return `
1669
+ <div class="item clickable" data-service-id="${escapeHtml(service.id)}"
1670
+ style="${selected ? "border-color:rgba(96,165,250,0.5);" : ""}">
1671
+ <div class="row">
1672
+ <strong>${escapeHtml(service.title)}</strong>
1673
+ <span class="pill ${pillClass}">${escapeHtml(service.status)}</span>
1674
+ ${selected ? `<span class="pill">selected</span>` : ""}
1675
+ </div>
1676
+ <div class="hint mono" style="margin-top:6px;">${escapeHtml(service.id)}</div>
1677
+ <div class="hint" style="margin-top:6px;">
1678
+ v${escapeHtml(service.version)} · ${escapeHtml(service.source)} ·
1679
+ ${escapeHtml(service.toolCount)} tools ·
1680
+ ${escapeHtml(service.promptCount)} prompts ·
1681
+ ${escapeHtml(service.resourceCount)} resources
1682
+ </div>
1683
+ ${service.baseUrl ? `<div class="hint mono" style="margin-top:6px;">${escapeHtml(service.baseUrl)}</div>` : ""}
1684
+ ${lastError}
1685
+ <div class="row" style="margin-top:10px;">
1686
+ <button class="ghost" data-action="start" data-id="${escapeHtml(service.id)}">Start</button>
1687
+ <button class="secondary" data-action="stop" data-id="${escapeHtml(service.id)}">Stop</button>
1688
+ </div>
1689
+ </div>
1690
+ `;
1691
+ })
1692
+ .join("");
1693
+ }
1694
+
1695
+ function renderQuickControl() {
1696
+ const service = state.services.find(
1697
+ (item) => item.id === state.selectedServiceId,
1698
+ );
1699
+ el.quickTarget.textContent = service
1700
+ ? `${service.title} (${service.id})`
1701
+ : "(none)";
1702
+
1703
+ const running = service?.status === "running";
1704
+ el.btnQuickStart.disabled = !service || running || busyDepth > 0;
1705
+ el.btnQuickStop.disabled = !service || !running || busyDepth > 0;
1706
+ }
1707
+
1708
+ /* ==================================================================
1709
+ * Rendering: tool picker & argument form
1710
+ * ================================================================== */
1711
+
1712
+ function bindingFor(toolName) {
1713
+ return (
1714
+ state.tools.bindings.find((item) => item.toolName === toolName) ||
1715
+ null
1716
+ );
1717
+ }
1718
+
1719
+ function toolFor(toolName) {
1720
+ return state.tools.tools.find((item) => item.name === toolName) || null;
1721
+ }
1722
+
1723
+ function renderToolPicker() {
1724
+ const tools = state.tools.tools;
1725
+
1726
+ if (tools.length === 0) {
1727
+ el.toolSelect.innerHTML = `<option value="">No tools available</option>`;
1728
+ el.toolMeta.classList.add("hidden");
1729
+ el.argForm.innerHTML = `<div class="empty">Load a document to generate tools.</div>`;
1730
+ return;
1731
+ }
1732
+
1733
+ // Preserve the operator's selection across reloads; snapping back to the
1734
+ // first tool would silently desynchronise it from the typed arguments.
1735
+ const previous = el.toolSelect.value;
1736
+ el.toolSelect.innerHTML = tools
1737
+ .map(
1738
+ (tool) =>
1739
+ `<option value="${escapeHtml(tool.name)}">${escapeHtml(tool.name)}</option>`,
1740
+ )
1741
+ .join("");
1742
+ if (previous && tools.some((tool) => tool.name === previous)) {
1743
+ el.toolSelect.value = previous;
1744
+ }
1745
+
1746
+ renderToolMeta();
1747
+ renderArgForm();
1748
+ }
1749
+
1750
+ function renderToolMeta() {
1751
+ const name = el.toolSelect.value;
1752
+ const tool = toolFor(name);
1753
+ const binding = bindingFor(name);
1754
+ if (!tool) {
1755
+ el.toolMeta.classList.add("hidden");
1756
+ return;
1757
+ }
1758
+
1759
+ const method = (binding?.method || "").toUpperCase();
1760
+ const rows = [
1761
+ `<div class="row">
1762
+ <span class="pill mono">${escapeHtml(method)} ${escapeHtml(binding?.path || "")}</span>
1763
+ ${
1764
+ binding
1765
+ ? binding.fullySupported
1766
+ ? `<span class="pill success">Full Support</span>`
1767
+ : `<span class="pill warn">Partial Support</span>`
1768
+ : ""
1769
+ }
1770
+ ${
1771
+ binding?.isStandardMethod === false
1772
+ ? `<span class="pill danger" title="Custom method from additionalOperations; no HTTP semantics are assumed.">Custom Method</span>`
1773
+ : ""
1774
+ }
1775
+ </div>`,
1776
+ ];
1777
+ if (tool.description) {
1778
+ rows.push(
1779
+ `<div class="hint" style="margin-top:8px;">${escapeHtml(tool.description)}</div>`,
1780
+ );
1781
+ }
1782
+ if ((binding?.degradationReasons || []).length) {
1783
+ rows.push(
1784
+ `<div class="hint" style="margin-top:6px;color:#fde68a;">${escapeHtml(binding.degradationReasons.join(" | "))}</div>`,
1785
+ );
1786
+ }
1787
+ el.toolMeta.innerHTML = rows.join("");
1788
+ el.toolMeta.classList.remove("hidden");
1789
+ }
1790
+
1791
+ /** Reads the required flag from either the projected DTO or a raw binding. */
1792
+ function argumentIsRequired(argument) {
1793
+ if (typeof argument.required === "boolean") return argument.required;
1794
+ return argument.parameter?.required === true;
1795
+ }
1796
+
1797
+ /**
1798
+ * Builds an input per argument from the tool's inputSchema.
1799
+ *
1800
+ * The form — not the JSON box — is the primary entry point. Hand-writing
1801
+ * the whole object with no schema hint in front of you is what produced the
1802
+ * malformed-JSON errors in the first place.
1803
+ */
1804
+ function renderArgForm() {
1805
+ const name = el.toolSelect.value;
1806
+ const tool = toolFor(name);
1807
+ if (!tool) {
1808
+ el.argForm.innerHTML = `<div class="empty">Select a tool.</div>`;
1809
+ return;
1810
+ }
1811
+
1812
+ const schema = tool.inputSchema || {};
1813
+ const properties = schema.properties || {};
1814
+ const requiredList = Array.isArray(schema.required)
1815
+ ? schema.required
1816
+ : [];
1817
+ const keys = Object.keys(properties);
1818
+
1819
+ if (keys.length === 0) {
1820
+ el.argForm.innerHTML = `<div class="empty">This tool takes no arguments. Press Call Tool.</div>`;
1821
+ return;
1822
+ }
1823
+
1824
+ const binding = bindingFor(name);
1825
+ const argMeta = new Map(
1826
+ (binding?.arguments || []).map((argument) => [
1827
+ argument.key,
1828
+ argument,
1829
+ ]),
1830
+ );
1831
+
1832
+ el.argForm.innerHTML = keys
1833
+ .map((key) => {
1834
+ const property = properties[key] || {};
1835
+ const meta = argMeta.get(key);
1836
+ const required =
1837
+ requiredList.includes(key) ||
1838
+ (meta ? argumentIsRequired(meta) : false);
1839
+ const type = Array.isArray(property.type)
1840
+ ? property.type.filter((entry) => entry !== "null")[0] || "string"
1841
+ : property.type || inferType(property);
1842
+ const location = meta?.in ? ` · ${escapeHtml(meta.in)}` : "";
1843
+ const description = property.description
1844
+ ? `<div class="hint">${escapeHtml(property.description)}</div>`
1845
+ : "";
1846
+
1847
+ let control;
1848
+ if (Array.isArray(property.enum) && property.enum.length > 0) {
1849
+ const options = [
1850
+ required ? "" : `<option value="">(omit)</option>`,
1851
+ ...property.enum.map(
1852
+ (value) =>
1853
+ `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`,
1854
+ ),
1855
+ ].join("");
1856
+ control = `<select data-arg="${escapeHtml(key)}" data-type="${escapeHtml(type)}">${options}</select>`;
1857
+ } else if (type === "boolean") {
1858
+ control = `<select data-arg="${escapeHtml(key)}" data-type="boolean">
1859
+ ${required ? "" : `<option value="">(omit)</option>`}
1860
+ <option value="true">true</option>
1861
+ <option value="false">false</option>
1862
+ </select>`;
1863
+ } else if (type === "object" || type === "array") {
1864
+ // Structured values still need JSON, but scoped to one field the
1865
+ // error message can point at precisely.
1866
+ control = `<textarea data-arg="${escapeHtml(key)}" data-type="${escapeHtml(type)}"
1867
+ spellcheck="false" style="min-height:96px;"
1868
+ placeholder="${type === "array" ? "[ ]" : "{ }"}"></textarea>`;
1869
+ } else {
1870
+ const inputType =
1871
+ type === "number" || type === "integer" ? "number" : "text";
1872
+ const step = type === "integer" ? ' step="1"' : "";
1873
+ control = `<input type="${inputType}"${step} data-arg="${escapeHtml(key)}"
1874
+ data-type="${escapeHtml(type)}"
1875
+ placeholder="${escapeHtml(property.example ?? property.default ?? "")}" />`;
1876
+ }
1877
+
1878
+ return `
1879
+ <div class="field">
1880
+ <label for="">
1881
+ <span class="mono">${escapeHtml(key)}</span>
1882
+ ${required ? `<span class="req">*</span>` : ""}
1883
+ <span class="muted">${escapeHtml(type)}${location}</span>
1884
+ </label>
1885
+ ${control}
1886
+ ${description}
1887
+ </div>
1888
+ `;
1889
+ })
1890
+ .join("");
1891
+
1892
+ // Body payload, if any, gets its own field appended.
1893
+ if (binding?.bodyKey && !keys.includes(binding.bodyKey)) {
1894
+ el.argForm.insertAdjacentHTML(
1895
+ "beforeend",
1896
+ `<div class="field">
1897
+ <label>
1898
+ <span class="mono">${escapeHtml(binding.bodyKey)}</span>
1899
+ ${binding.bodyRequired ? `<span class="req">*</span>` : ""}
1900
+ <span class="muted">body · ${escapeHtml(binding.bodyMediaType || binding.bodyEncoding || "unknown")}</span>
1901
+ </label>
1902
+ <textarea data-arg="${escapeHtml(binding.bodyKey)}" data-type="object"
1903
+ spellcheck="false" style="min-height:120px;" placeholder="{ }"></textarea>
1904
+ </div>`,
1905
+ );
1906
+ }
1907
+ }
1908
+
1909
+ function inferType(property) {
1910
+ if (property.properties) return "object";
1911
+ if (property.items) return "array";
1912
+ return "string";
1913
+ }
1914
+
1915
+ /**
1916
+ * Collects the form into an arguments object.
1917
+ *
1918
+ * Empty optional fields are omitted rather than sent as "": an empty string
1919
+ * is a real value upstream and would land in the query string.
1920
+ */
1921
+ function collectArgsFromForm() {
1922
+ const args = {};
1923
+ const errors = [];
1924
+
1925
+ el.argForm.querySelectorAll("[data-arg]").forEach((control) => {
1926
+ const key = control.dataset.arg;
1927
+ const type = control.dataset.type;
1928
+ const raw = control.value;
1929
+
1930
+ if (raw === "" || raw === null) return;
1931
+
1932
+ if (type === "number" || type === "integer") {
1933
+ const parsed = Number(raw);
1934
+ if (Number.isNaN(parsed)) {
1935
+ errors.push(`${key} must be a number.`);
1936
+ return;
1937
+ }
1938
+ args[key] = type === "integer" ? Math.trunc(parsed) : parsed;
1939
+ return;
1940
+ }
1941
+
1942
+ if (type === "boolean") {
1943
+ args[key] = raw === "true";
1944
+ return;
1945
+ }
1946
+
1947
+ if (type === "object" || type === "array") {
1948
+ try {
1949
+ args[key] = JSON.parse(raw.trim());
1950
+ } catch (error) {
1951
+ errors.push(`${key} is not valid JSON: ${error.message}`);
1952
+ }
1953
+ return;
1954
+ }
1955
+
1956
+ args[key] = raw;
1957
+ });
1958
+
1959
+ if (errors.length > 0) {
1960
+ const error = new Error(errors.join(" "));
1961
+ error.name = "ArgumentSyntaxError";
1962
+ throw error;
1963
+ }
1964
+ return args;
1965
+ }
1966
+
1967
+ function setArgTab(tab) {
1968
+ state.argTab = tab;
1969
+ $("argPanel-form").classList.toggle("hidden", tab !== "form");
1970
+ $("argPanel-json").classList.toggle("hidden", tab !== "json");
1971
+ document
1972
+ .querySelectorAll("#argTabs button[data-argtab]")
1973
+ .forEach((button) => {
1974
+ button.setAttribute(
1975
+ "aria-selected",
1976
+ button.dataset.argtab === tab ? "true" : "false",
1977
+ );
1978
+ });
1979
+
1980
+ // Moving to Raw JSON prefills from the form so the two views never
1981
+ // disagree about what will actually be sent.
1982
+ if (tab === "json" && !el.toolArgs.value.trim()) {
1983
+ try {
1984
+ el.toolArgs.value = JSON.stringify(collectArgsFromForm(), null, 2);
1985
+ } catch {
1986
+ /* an invalid form field is reported when the call is made */
1987
+ }
1988
+ }
1989
+ }
1990
+
1991
+ /* ==================================================================
1992
+ * Rendering: catalog
1993
+ * ================================================================== */
1994
+
1995
+ function setCatalogTab(tab) {
1996
+ state.catalogTab = tab;
1997
+ ["tools", "prompts", "resources"].forEach((key) => {
1998
+ $(`catPanel-${key}`).classList.toggle("hidden", key !== tab);
1999
+ });
2000
+ document
2001
+ .querySelectorAll("#catalogTabs button[data-cat]")
2002
+ .forEach((button) => {
2003
+ button.setAttribute(
2004
+ "aria-selected",
2005
+ button.dataset.cat === tab ? "true" : "false",
2006
+ );
2007
+ });
2008
+ renderCatalog();
2009
+ }
2010
+
2011
+ function matchesFilter(haystack, needle) {
2012
+ if (!needle) return true;
2013
+ return haystack.toLowerCase().includes(needle.toLowerCase());
2014
+ }
2015
+
2016
+ function renderCatalog() {
2017
+ el.tabToolCount.textContent = String(state.tools.tools.length);
2018
+ el.tabPromptCount.textContent = String(state.prompts.prompts.length);
2019
+ el.tabResourceCount.textContent = String(
2020
+ state.resources.resources.length,
2021
+ );
2022
+
2023
+ renderToolCatalog();
2024
+ renderPromptCatalog();
2025
+ renderResourceCatalog();
2026
+
2027
+ const showRaw = el.catalogRaw.checked;
2028
+ $("catPanel-raw").classList.toggle("hidden", !showRaw);
2029
+ if (showRaw) {
2030
+ const payload =
2031
+ state.catalogTab === "tools"
2032
+ ? state.tools.raw
2033
+ : state.catalogTab === "prompts"
2034
+ ? state.prompts.raw
2035
+ : state.resources.raw;
2036
+ el.catalogRawOut.textContent = pretty(payload);
2037
+ }
2038
+ }
2039
+
2040
+ function renderToolCatalog() {
2041
+ const filter = el.catalogFilter.value.trim();
2042
+ const bindingMap = new Map(
2043
+ state.tools.bindings.map((item) => [item.toolName, item]),
2044
+ );
2045
+
2046
+ const items = state.tools.tools.filter((tool) => {
2047
+ const binding = bindingMap.get(tool.name) || {};
2048
+ return matchesFilter(
2049
+ `${tool.name} ${tool.description || ""} ${binding.method || ""} ${binding.path || ""}`,
2050
+ filter,
2051
+ );
2052
+ });
2053
+
2054
+ if (items.length === 0) {
2055
+ el.toolCatalog.innerHTML = `<div class="empty">${
2056
+ state.tools.tools.length === 0
2057
+ ? "No tools generated."
2058
+ : "No tool matches the filter."
2059
+ }</div>`;
2060
+ return;
2061
+ }
2062
+
2063
+ el.toolCatalog.innerHTML = items
2064
+ .map((tool) => {
2065
+ const binding = bindingMap.get(tool.name) || {};
2066
+ const supportPill = binding.fullySupported
2067
+ ? `<span class="pill success">Full Support</span>`
2068
+ : `<span class="pill warn">Partial Support</span>`;
2069
+ const customPill =
2070
+ binding.isStandardMethod === false
2071
+ ? `<span class="pill danger" title="Custom method from additionalOperations; no HTTP semantics are assumed.">Custom Method</span>`
2072
+ : "";
2073
+ const args = (binding.arguments || [])
2074
+ .map(
2075
+ (argument) =>
2076
+ `${argument.key} → ${argument.in}${argumentIsRequired(argument) ? " (required)" : ""}`,
2077
+ )
2078
+ .join(", ");
2079
+ const bodyLine = binding.bodyKey
2080
+ ? `<div class="hint" style="margin-top:6px;">Body: <span class="mono">${escapeHtml(binding.bodyKey)}</span> as ${escapeHtml(binding.bodyMediaType || binding.bodyEncoding || "unknown")}${binding.bodyRequired ? " (required)" : ""}</div>`
2081
+ : "";
2082
+ const omitted = (binding.omittedParameters || []).length
2083
+ ? `<div class="hint" style="margin-top:6px;">Server-managed: ${escapeHtml(binding.omittedParameters.map((entry) => entry.name).join(", "))}</div>`
2084
+ : "";
2085
+ const notes = (binding.usageNotes || []).length
2086
+ ? `<div class="hint" style="margin-top:6px;">Note: ${escapeHtml(binding.usageNotes.join(" | "))}</div>`
2087
+ : "";
2088
+ const reasons = (binding.degradationReasons || []).length
2089
+ ? `<div class="hint" style="margin-top:6px;color:#fde68a;">${escapeHtml(binding.degradationReasons.join(" | "))}</div>`
2090
+ : "";
2091
+ return `
2092
+ <div class="item">
2093
+ <div class="row">
2094
+ <strong class="mono">${escapeHtml(tool.name)}</strong>
2095
+ ${supportPill}
2096
+ ${customPill}
2097
+ <span class="pill mono">${escapeHtml((binding.method || "").toUpperCase())} ${escapeHtml(binding.path || "")}</span>
2098
+ </div>
2099
+ <div class="hint" style="margin-top:8px;">${escapeHtml(tool.description || "")}</div>
2100
+ <div class="hint" style="margin-top:6px;">Arguments: ${escapeHtml(args || "None")}</div>
2101
+ ${bodyLine}
2102
+ ${omitted}
2103
+ ${notes}
2104
+ ${reasons}
2105
+ <div class="row" style="margin-top:10px;">
2106
+ <button class="ghost" data-call-tool="${escapeHtml(tool.name)}">Open in Call Tool</button>
2107
+ </div>
2108
+ <details style="margin-top:10px;">
2109
+ <summary class="hint">Input schema</summary>
2110
+ <div class="surface" style="margin-top:10px;"><pre class="mono">${escapeHtml(pretty(tool.inputSchema || {}))}</pre></div>
2111
+ </details>
2112
+ </div>
2113
+ `;
2114
+ })
2115
+ .join("");
2116
+ }
2117
+
2118
+ /**
2119
+ * Prompts rendered as cards rather than a JSON dump.
2120
+ *
2121
+ * Field names differ between MCP revisions, so each accessor falls back
2122
+ * across the known spellings instead of assuming one shape.
2123
+ */
2124
+ function renderPromptCatalog() {
2125
+ const filter = el.catalogFilter.value.trim();
2126
+ const items = state.prompts.prompts.filter((prompt) =>
2127
+ matchesFilter(
2128
+ `${prompt.name || ""} ${prompt.title || ""} ${prompt.description || ""}`,
2129
+ filter,
2130
+ ),
2131
+ );
2132
+
2133
+ if (items.length === 0) {
2134
+ el.promptCatalog.innerHTML = `<div class="empty">${
2135
+ state.prompts.prompts.length === 0
2136
+ ? "No prompts generated."
2137
+ : "No prompt matches the filter."
2138
+ }</div>`;
2139
+ return;
2140
+ }
2141
+
2142
+ el.promptCatalog.innerHTML = items
2143
+ .map((prompt) => {
2144
+ const promptArgs = Array.isArray(prompt.arguments)
2145
+ ? prompt.arguments
2146
+ : [];
2147
+ const argRows = promptArgs.length
2148
+ ? `<div class="list" style="margin-top:10px;">${promptArgs
2149
+ .map(
2150
+ (argument) => `
2151
+ <div class="surface">
2152
+ <div class="row">
2153
+ <span class="mono">${escapeHtml(argument.name)}</span>
2154
+ ${argument.required ? `<span class="pill danger">required</span>` : `<span class="pill plain">optional</span>`}
2155
+ </div>
2156
+ ${argument.description ? `<div class="hint" style="margin-top:6px;">${escapeHtml(argument.description)}</div>` : ""}
2157
+ </div>
2158
+ `,
2159
+ )
2160
+ .join("")}</div>`
2161
+ : `<div class="hint" style="margin-top:8px;">No arguments.</div>`;
2162
+
2163
+ return `
2164
+ <div class="item">
2165
+ <div class="row">
2166
+ <strong class="mono">${escapeHtml(prompt.name || "(unnamed)")}</strong>
2167
+ ${prompt.title ? `<span class="pill plain">${escapeHtml(prompt.title)}</span>` : ""}
2168
+ <span class="pill plain">${promptArgs.length} args</span>
2169
+ </div>
2170
+ ${prompt.description ? `<div class="hint" style="margin-top:8px;">${escapeHtml(prompt.description)}</div>` : ""}
2171
+ ${argRows}
2172
+ <details style="margin-top:10px;">
2173
+ <summary class="hint">Raw definition</summary>
2174
+ <div class="surface" style="margin-top:10px;"><pre class="mono">${escapeHtml(pretty(prompt))}</pre></div>
2175
+ </details>
2176
+ </div>
2177
+ `;
2178
+ })
2179
+ .join("");
2180
+ }
2181
+
2182
+ function renderResourceCatalog() {
2183
+ const filter = el.catalogFilter.value.trim();
2184
+ const items = state.resources.resources.filter((resource) =>
2185
+ matchesFilter(
2186
+ `${resource.name || ""} ${resource.uri || resource.uriTemplate || ""} ${resource.description || ""} ${resource.mimeType || ""}`,
2187
+ filter,
2188
+ ),
2189
+ );
2190
+
2191
+ if (items.length === 0) {
2192
+ el.resourceCatalog.innerHTML = `<div class="empty">${
2193
+ state.resources.resources.length === 0
2194
+ ? "No resources generated."
2195
+ : "No resource matches the filter."
2196
+ }</div>`;
2197
+ return;
2198
+ }
2199
+
2200
+ el.resourceCatalog.innerHTML = items
2201
+ .map((resource) => {
2202
+ // A templated resource carries uriTemplate; a static one carries uri.
2203
+ const uri = resource.uri || resource.uriTemplate || "";
2204
+ const templated = Boolean(resource.uriTemplate);
2205
+ return `
2206
+ <div class="item">
2207
+ <div class="row">
2208
+ <strong class="mono">${escapeHtml(resource.name || "(unnamed)")}</strong>
2209
+ ${templated ? `<span class="pill">template</span>` : `<span class="pill plain">static</span>`}
2210
+ ${resource.mimeType ? `<span class="pill plain mono">${escapeHtml(resource.mimeType)}</span>` : ""}
2211
+ </div>
2212
+ <div class="hint mono" style="margin-top:8px;word-break:break-all;">${escapeHtml(uri)}</div>
2213
+ ${resource.description ? `<div class="hint" style="margin-top:6px;">${escapeHtml(resource.description)}</div>` : ""}
2214
+ <details style="margin-top:10px;">
2215
+ <summary class="hint">Raw definition</summary>
2216
+ <div class="surface" style="margin-top:10px;"><pre class="mono">${escapeHtml(pretty(resource))}</pre></div>
2217
+ </details>
2218
+ </div>
2219
+ `;
2220
+ })
2221
+ .join("");
2222
+ }
2223
+
2224
+ /* ==================================================================
2225
+ * Rendering: logs
2226
+ * ================================================================== */
2227
+
2228
+ function renderLogs() {
2229
+ el.logCount.textContent = String(state.logs.length);
2230
+
2231
+ const filter = el.logFilter.value.trim();
2232
+ const errorsOnly = el.logErrorsOnly.checked;
2233
+
2234
+ const items = state.logs.filter((log) => {
2235
+ if (errorsOnly && log.success !== false) return false;
2236
+ return matchesFilter(
2237
+ `${log.toolName || ""} ${log.url || ""} ${log.method || ""} ${log.protocol || ""} ${log.status ?? ""} ${log.error || ""}`,
2238
+ filter,
2239
+ );
2240
+ });
2241
+
2242
+ if (items.length === 0) {
2243
+ el.logList.innerHTML = `<div class="empty">${
2244
+ state.logs.length === 0
2245
+ ? "No traffic recorded yet."
2246
+ : "No entry matches the current filter."
2247
+ }</div>`;
2248
+ return;
2249
+ }
2250
+
2251
+ el.logList.innerHTML = items
2252
+ .map((log) => {
2253
+ const pillClass =
2254
+ log.success === true
2255
+ ? "success"
2256
+ : log.success === false
2257
+ ? "danger"
2258
+ : "warn";
2259
+ const duration =
2260
+ typeof log.durationMs === "number"
2261
+ ? `<span class="pill plain mono">${escapeHtml(log.durationMs)}ms</span>`
2262
+ : "";
2263
+ const truncated = log.truncated
2264
+ ? `<span class="pill warn">truncated</span>`
2265
+ : "";
2266
+ return `
2267
+ <div class="item">
2268
+ <div class="row">
2269
+ <span class="pill ${pillClass}">${escapeHtml(log.direction)}</span>
2270
+ <span class="pill plain mono">${escapeHtml(log.method || log.protocol || "internal")}</span>
2271
+ <span class="pill plain mono">${escapeHtml(log.status ?? "-")}</span>
2272
+ ${duration}
2273
+ ${truncated}
2274
+ <span class="hint">${escapeHtml(log.at)}</span>
2275
+ </div>
2276
+ <div class="hint mono" style="margin-top:8px;word-break:break-all;">${escapeHtml(log.toolName || "")} ${escapeHtml(log.url || "")}</div>
2277
+ ${log.error ? `<div class="hint" style="margin-top:6px;color:#fecaca;">${escapeHtml(log.error)}</div>` : ""}
2278
+ <details style="margin-top:10px;">
2279
+ <summary class="hint">View payload</summary>
2280
+ <div class="surface" style="margin-top:10px;"><pre class="mono">${escapeHtml(pretty(log))}</pre></div>
2281
+ </details>
2282
+ </div>
2283
+ `;
2284
+ })
2285
+ .join("");
2286
+ }
2287
+
2288
+ /* ==================================================================
2289
+ * Actions
2290
+ * ================================================================== */
2291
+
2292
+ function uploadFile() {
2293
+ return run(async () => {
2294
+ const file = el.fileInput.files[0];
2295
+ if (!file) {
2296
+ notify(el.settingsAlert, "Select a file first.", "warn");
2297
+ return;
2298
+ }
2299
+ const form = new FormData();
2300
+ form.append("file", file);
2301
+ // No Content-Type header: the multipart boundary must come from fetch.
2302
+ await request("/api/spec/upload", {
2303
+ method: "POST",
2304
+ body: form,
2305
+ headers: authHeaders(),
2306
+ });
2307
+ notify(el.settingsAlert, `Loaded "${file.name}".`, "success");
2308
+ await Promise.all([refreshStatus(), refreshCatalog()]);
2309
+ }, el.settingsAlert);
2310
+ }
2311
+
2312
+ function pasteSpec() {
2313
+ return run(async () => {
2314
+ const content = el.content.value;
2315
+ if (!content.trim()) {
2316
+ notify(el.settingsAlert, "Content cannot be empty.", "warn");
2317
+ return;
2318
+ }
2319
+ await request("/api/spec/paste", {
2320
+ method: "POST",
2321
+ headers: authHeaders({ "Content-Type": "application/json" }),
2322
+ body: JSON.stringify({ content, isYaml: el.isYaml.checked }),
2323
+ });
2324
+ notify(el.settingsAlert, "Specification applied.", "success");
2325
+ await Promise.all([refreshStatus(), refreshCatalog()]);
2326
+ }, el.settingsAlert);
2327
+ }
2328
+
2329
+ function clearSpec() {
2330
+ return run(async () => {
2331
+ if (
2332
+ !window.confirm(
2333
+ "Clear the loaded specification and stop the service?",
2334
+ )
2335
+ ) {
2336
+ return;
2337
+ }
2338
+ await request("/api/spec/clear", {
2339
+ method: "POST",
2340
+ headers: authHeaders(),
2341
+ });
2342
+ notify(el.settingsAlert, "Specification cleared.", "success");
2343
+ await Promise.all([refreshStatus(), refreshCatalog()]);
2344
+ }, el.settingsAlert);
2345
+ }
2346
+
2347
+ function saveBaseUrl(clear = false) {
2348
+ return run(async () => {
2349
+ const baseUrl = clear ? "" : el.baseUrl.value.trim();
2350
+ await request("/api/config/base-url", {
2351
+ method: "POST",
2352
+ headers: authHeaders({ "Content-Type": "application/json" }),
2353
+ body: JSON.stringify({ baseUrl }),
2354
+ });
2355
+ if (clear) el.baseUrl.value = "";
2356
+ delete el.baseUrl.dataset.dirty;
2357
+ notify(
2358
+ el.settingsAlert,
2359
+ baseUrl ? "Base URL override saved." : "Base URL override removed.",
2360
+ "success",
2361
+ );
2362
+ await refreshStatus();
2363
+ }, el.settingsAlert);
2364
+ }
2365
+
2366
+ function startService(id) {
2367
+ return run(async () => {
2368
+ await request(`/api/servers/${encodeURIComponent(id)}/start`, {
2369
+ method: "POST",
2370
+ headers: authHeaders(),
2371
+ });
2372
+ notify(el.quickAlert, "Service started.", "success");
2373
+ await refreshStatus();
2374
+ }, el.quickAlert);
2375
+ }
2376
+
2377
+ function stopService(id) {
2378
+ return run(async () => {
2379
+ await request(`/api/servers/${encodeURIComponent(id)}/stop`, {
2380
+ method: "POST",
2381
+ headers: authHeaders(),
2382
+ });
2383
+ notify(el.quickAlert, "Service stopped.", "success");
2384
+ await refreshStatus();
2385
+ }, el.quickAlert);
2386
+ }
2387
+
2388
+ let debugController = null;
2389
+
2390
+ function debugCallTool() {
2391
+ return run(async () => {
2392
+ const toolName = el.toolSelect.value;
2393
+ if (!toolName) {
2394
+ notify(el.callAlert, "No tool is selected.", "warn");
2395
+ return;
2396
+ }
2397
+
2398
+ let args;
2399
+ try {
2400
+ if (state.argTab === "form") {
2401
+ args = collectArgsFromForm();
2402
+ } else {
2403
+ const parsed = parseArguments(el.toolArgs.value);
2404
+ args = parsed.value;
2405
+ if (parsed.repaired) {
2406
+ el.toolArgs.value = JSON.stringify(args, null, 2);
2407
+ notify(
2408
+ el.callAlert,
2409
+ "The arguments were not valid JSON, so quotes and trailing commas were normalised. The corrected payload is shown above — confirm it before relying on the result.",
2410
+ "warn",
2411
+ { sticky: true },
2412
+ );
2413
+ }
2414
+ }
2415
+ } catch (error) {
2416
+ notify(el.callAlert, error.message, "warn");
2417
+ return;
2418
+ }
2419
+
2420
+ if (
2421
+ args === null ||
2422
+ typeof args !== "object" ||
2423
+ Array.isArray(args)
2424
+ ) {
2425
+ notify(
2426
+ el.callAlert,
2427
+ "Tool arguments must be a JSON object.",
2428
+ "warn",
2429
+ );
2430
+ return;
2431
+ }
2432
+
2433
+ debugController = new AbortController();
2434
+ el.btnCancelCall.disabled = false;
2435
+ el.callSpinner.classList.remove("hidden");
2436
+ el.callResultMeta.innerHTML = "";
2437
+ el.debugResult.classList.add("muted");
2438
+ el.debugResult.textContent = "Waiting for the upstream response…";
2439
+
2440
+ const startedAt = performance.now();
2441
+ try {
2442
+ const result = await request("/api/debug/call-tool", {
2443
+ method: "POST",
2444
+ headers: authHeaders({ "Content-Type": "application/json" }),
2445
+ body: JSON.stringify({ toolName, arguments: args }),
2446
+ signal: debugController.signal,
2447
+ });
2448
+ const elapsed = Math.round(performance.now() - startedAt);
2449
+ el.debugResult.classList.remove("muted");
2450
+ el.debugResult.textContent = pretty(result);
2451
+ // isError is the MCP-level failure flag: a transport 200 can still
2452
+ // carry a failed tool result, so it must be surfaced separately.
2453
+ const failed = result?.isError === true;
2454
+ el.callResultMeta.innerHTML = `
2455
+ <span class="pill ${failed ? "danger" : "success"}">${failed ? "Tool Error" : "Success"}</span>
2456
+ <span class="pill plain mono">${elapsed}ms</span>
2457
+ <span class="pill plain mono">${escapeHtml(toolName)}</span>`;
2458
+ if (!failed) clearNotice(el.callAlert);
2459
+ } catch (error) {
2460
+ if (!isAbortError(error)) {
2461
+ el.debugResult.classList.remove("muted");
2462
+ el.debugResult.textContent = pretty(
2463
+ error.body || { error: error.message },
2464
+ );
2465
+ el.callResultMeta.innerHTML = `<span class="pill danger">Failed${error.status ? ` · HTTP ${escapeHtml(error.status)}` : ""}</span>`;
2466
+ }
2467
+ throw error;
2468
+ } finally {
2469
+ el.btnCancelCall.disabled = true;
2470
+ el.callSpinner.classList.add("hidden");
2471
+ debugController = null;
2472
+ // The call is logged either way, so keep the log view honest.
2473
+ refreshLogs().catch(reportBackground);
2474
+ }
2475
+ }, el.callAlert);
2476
+ }
2477
+
2478
+ function cancelDebugCall() {
2479
+ if (!debugController) return;
2480
+ debugController.abort();
2481
+ el.debugResult.classList.add("muted");
2482
+ el.debugResult.textContent = "Cancelled by the operator.";
2483
+ el.callResultMeta.innerHTML = `<span class="pill warn">Cancelled</span>`;
2484
+ notify(
2485
+ el.callAlert,
2486
+ "The request was aborted locally. Whether the upstream call was also cancelled depends on the server honouring the abort signal.",
2487
+ "info",
2488
+ );
2489
+ }
2490
+
2491
+ function clearLogs() {
2492
+ return run(async () => {
2493
+ await request("/api/logs/clear", {
2494
+ method: "POST",
2495
+ headers: authHeaders(),
2496
+ });
2497
+ await refreshLogs();
2498
+ }, el.logsAlert);
2499
+ }
2500
+
2501
+ function formatRawJson() {
2502
+ try {
2503
+ const parsed = parseArguments(el.toolArgs.value);
2504
+ el.toolArgs.value = JSON.stringify(parsed.value, null, 2);
2505
+ notify(
2506
+ el.callAlert,
2507
+ parsed.repaired
2508
+ ? "Formatted, and invalid quoting was repaired. Check the result."
2509
+ : "Formatted.",
2510
+ parsed.repaired ? "warn" : "success",
2511
+ );
2512
+ } catch (error) {
2513
+ notify(el.callAlert, error.message, "warn");
2514
+ }
2515
+ }
2516
+
2517
+ function applyApiKey() {
2518
+ const value = el.apiKey.value.trim();
2519
+ // sessionStorage rather than localStorage: the credential dies with the
2520
+ // tab instead of persisting to disk.
2521
+ try {
2522
+ if (value) sessionStorage.setItem(API_KEY_STORAGE, value);
2523
+ else sessionStorage.removeItem(API_KEY_STORAGE);
2524
+ } catch {
2525
+ // Private-browsing modes can block storage; the field still works.
2526
+ }
2527
+ clearGlobal();
2528
+ return run(async () => {
2529
+ await Promise.all([refreshStatus(), refreshCatalog(), refreshLogs()]);
2530
+ notify(el.settingsAlert, "Credential accepted.", "success");
2531
+ syncPollers();
2532
+ }, el.settingsAlert);
2533
+ }
2534
+
2535
+ function forgetApiKey() {
2536
+ el.apiKey.value = "";
2537
+ try {
2538
+ sessionStorage.removeItem(API_KEY_STORAGE);
2539
+ } catch {
2540
+ /* nothing to clean up */
2541
+ }
2542
+ notify(el.settingsAlert, "Stored key removed from this tab.", "info");
2543
+ }
2544
+
2545
+ /* ==================================================================
2546
+ * Wiring
2547
+ * ================================================================== */
2548
+
2549
+ el.nav.addEventListener("click", (event) => {
2550
+ const button = event.target.closest("button[data-view]");
2551
+ if (button) setView(button.dataset.view);
2552
+ });
2553
+
2554
+ window.addEventListener("hashchange", () => {
2555
+ const view = window.location.hash.replace("#", "") || DEFAULT_VIEW;
2556
+ if (view !== state.view) setView(view, { pushHash: false });
2557
+ });
2558
+
2559
+ // Dashboard
2560
+ $("btnDashRefresh").addEventListener("click", () =>
2561
+ run(() => refreshStatus(), el.quickAlert),
2562
+ );
2563
+ el.btnQuickStart.addEventListener("click", () => {
2564
+ if (state.selectedServiceId) void startService(state.selectedServiceId);
2565
+ });
2566
+ el.btnQuickStop.addEventListener("click", () => {
2567
+ if (!state.selectedServiceId) return;
2568
+ if (!window.confirm("Stop the service and drop every live session?"))
2569
+ return;
2570
+ void stopService(state.selectedServiceId);
2571
+ });
2572
+
2573
+ // Event delegation rather than inline onclick: the service id is
2574
+ // interpolated into markup, and leaning on encodeURIComponent to neutralise
2575
+ // quotes would be an accident, not a defence.
2576
+ el.serviceList.addEventListener("click", (event) => {
2577
+ const button = event.target.closest("button[data-action]");
2578
+ if (button && !button.disabled) {
2579
+ const { action, id } = button.dataset;
2580
+ if (!id) return;
2581
+ if (action === "start") void startService(id);
2582
+ if (action === "stop") void stopService(id);
2583
+ return;
2584
+ }
2585
+ const card = event.target.closest("[data-service-id]");
2586
+ if (card) {
2587
+ state.selectedServiceId = card.dataset.serviceId;
2588
+ renderServiceList();
2589
+ renderQuickControl();
2590
+ }
2591
+ });
2592
+
2593
+ // Call Tool
2594
+ $("btnReloadTools").addEventListener("click", () =>
2595
+ run(() => refreshCatalog(), el.callAlert),
2596
+ );
2597
+ el.toolSelect.addEventListener("change", () => {
2598
+ clearNotice(el.callAlert);
2599
+ el.toolArgs.value = "";
2600
+ renderToolMeta();
2601
+ renderArgForm();
2602
+ });
2603
+ $("argTabs").addEventListener("click", (event) => {
2604
+ const button = event.target.closest("button[data-argtab]");
2605
+ if (button) setArgTab(button.dataset.argtab);
2606
+ });
2607
+ $("btnFormatJson").addEventListener("click", formatRawJson);
2608
+ $("btnFromForm").addEventListener("click", () => {
2609
+ try {
2610
+ el.toolArgs.value = JSON.stringify(collectArgsFromForm(), null, 2);
2611
+ notify(el.callAlert, "Filled from the form.", "success");
2612
+ } catch (error) {
2613
+ notify(el.callAlert, error.message, "warn");
2614
+ }
2615
+ });
2616
+ $("btnDebugCall").addEventListener("click", debugCallTool);
2617
+ el.btnCancelCall.addEventListener("click", cancelDebugCall);
2618
+
2619
+ // Submitting with the keyboard is the reflex for a form like this.
2620
+ el.toolArgs.addEventListener("keydown", (event) => {
2621
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
2622
+ event.preventDefault();
2623
+ void debugCallTool();
2624
+ }
2625
+ });
2626
+
2627
+ // Logs
2628
+ el.autoRefresh.addEventListener("change", syncPollers);
2629
+ el.autoRefreshInterval.addEventListener("change", () => {
2630
+ if (el.autoRefresh.checked && state.view === "logs") startLogPolling();
2631
+ });
2632
+ $("btnRefreshLogs").addEventListener("click", () =>
2633
+ run(() => refreshLogs(), el.logsAlert),
2634
+ );
2635
+ $("btnClearLogs").addEventListener("click", clearLogs);
2636
+ el.logFilter.addEventListener("input", renderLogs);
2637
+ el.logErrorsOnly.addEventListener("change", renderLogs);
2638
+
2639
+ // Catalog
2640
+ $("catalogTabs").addEventListener("click", (event) => {
2641
+ const button = event.target.closest("button[data-cat]");
2642
+ if (button) setCatalogTab(button.dataset.cat);
2643
+ });
2644
+ $("btnReloadCatalog").addEventListener("click", () =>
2645
+ run(() => refreshCatalog(), el.catalogAlert),
2646
+ );
2647
+ el.catalogFilter.addEventListener("input", renderCatalog);
2648
+ el.catalogRaw.addEventListener("change", renderCatalog);
2649
+ el.toolCatalog.addEventListener("click", (event) => {
2650
+ const button = event.target.closest("button[data-call-tool]");
2651
+ if (!button) return;
2652
+ setView("call");
2653
+ el.toolSelect.value = button.dataset.callTool;
2654
+ el.toolArgs.value = "";
2655
+ renderToolMeta();
2656
+ renderArgForm();
2657
+ });
2658
+
2659
+ // Settings
2660
+ $("btnUpload").addEventListener("click", uploadFile);
2661
+ $("btnPaste").addEventListener("click", pasteSpec);
2662
+ $("btnClearSpec").addEventListener("click", clearSpec);
2663
+ $("btnSaveBaseUrl").addEventListener("click", () => saveBaseUrl(false));
2664
+ $("btnResetBaseUrl").addEventListener("click", () => saveBaseUrl(true));
2665
+ $("btnApplyKey").addEventListener("click", applyApiKey);
2666
+ $("btnForgetKey").addEventListener("click", forgetApiKey);
2667
+ el.baseUrl.addEventListener("input", () => {
2668
+ el.baseUrl.dataset.dirty = "1";
2669
+ });
2670
+
2671
+ // Pause polling in a hidden tab and resume on return, so the console does
2672
+ // not keep hitting the admin API while nobody is watching.
2673
+ document.addEventListener("visibilitychange", () => {
2674
+ if (!document.hidden) {
2675
+ syncPollers();
2676
+ onViewEntered(state.view);
2677
+ }
2678
+ });
2679
+
2680
+ window.addEventListener("beforeunload", () => {
2681
+ statusController?.abort();
2682
+ catalogController?.abort();
2683
+ logsController?.abort();
2684
+ debugController?.abort();
2685
+ });
2686
+
2687
+ /* ==================================================================
2688
+ * Boot
2689
+ * ================================================================== */
2690
+
2691
+ window.addEventListener("load", async () => {
2692
+ try {
2693
+ const stored = sessionStorage.getItem(API_KEY_STORAGE);
2694
+ if (stored) el.apiKey.value = stored;
2695
+ } catch {
2696
+ // An unreadable store just means the field starts empty.
2697
+ }
2698
+
2699
+ setView(window.location.hash.replace("#", "") || DEFAULT_VIEW, {
2700
+ pushHash: false,
2701
+ });
2702
+
2703
+ try {
2704
+ await Promise.all([refreshStatus(), refreshCatalog(), refreshLogs()]);
2705
+ } catch (error) {
2706
+ if (isAbortError(error)) return;
2707
+ if (error instanceof UnauthorizedError) {
2708
+ showGlobal(`${error.message} Open Settings to enter a key.`);
2709
+ setView("settings");
2710
+ } else {
2711
+ showGlobal(`Initial load failed: ${error.message}`);
2712
+ }
2713
+ return;
2714
+ }
2715
+
2716
+ // A freshly started server with nothing loaded is most usefully opened on
2717
+ // Settings rather than an empty dashboard.
2718
+ if (!state.status.specLoaded && !window.location.hash) {
2719
+ setView("settings");
2720
+ }
2721
+ });
2722
+ </script>
2723
+ </body>
2724
+ </html>