@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.
- package/README.md +116 -0
- package/dist/admin-server-B4fQWUnc.d.cts +313 -0
- package/dist/admin-server-B4fQWUnc.d.ts +313 -0
- package/dist/cli.cjs +3703 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.mjs +3690 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.cjs +3697 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.mjs +3658 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server/admin-server.cjs +3570 -0
- package/dist/server/admin-server.cjs.map +1 -0
- package/dist/server/admin-server.d.cts +5 -0
- package/dist/server/admin-server.d.ts +5 -0
- package/dist/server/admin-server.mjs +3557 -0
- package/dist/server/admin-server.mjs.map +1 -0
- package/dist/webui/index.html +2724 -0
- package/package.json +62 -0
|
@@ -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 & 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("&", "&")
|
|
1146
|
+
.replaceAll("<", "<")
|
|
1147
|
+
.replaceAll(">", ">")
|
|
1148
|
+
.replaceAll('"', """)
|
|
1149
|
+
.replaceAll("'", "'");
|
|
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>
|