50c 3.3.0 → 3.7.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/lib/mcp-tv.js ADDED
@@ -0,0 +1,1015 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP-TV: Universal MCP Visualization Platform
4
+ * FREE - Any MCP can stream JSON events to display in browser
5
+ *
6
+ * Protocol: POST JSON to http://localhost:50888/stream
7
+ * {
8
+ * "channel": "my-mcp-name",
9
+ * "event": "stage|progress|result|error|log",
10
+ * "data": { ...any payload... }
11
+ * }
12
+ *
13
+ * Or use Server-Sent Events: GET /events?channel=my-mcp
14
+ *
15
+ * Usage:
16
+ * npx 50c tv # Start MCP-TV server
17
+ * npx 50c tv --port=3000 # Custom port
18
+ */
19
+
20
+ const http = require('http');
21
+ const { spawn } = require('child_process');
22
+ const crypto = require('crypto');
23
+
24
+ const DEFAULT_PORT = 50888;
25
+
26
+ // Channel state management
27
+ const channels = new Map();
28
+ const clients = new Map(); // channel -> [response streams]
29
+
30
+ function getChannel(name) {
31
+ if (!channels.has(name)) {
32
+ channels.set(name, {
33
+ name,
34
+ created: Date.now(),
35
+ events: [],
36
+ stages: [],
37
+ status: 'idle',
38
+ lastUpdate: Date.now()
39
+ });
40
+ }
41
+ return channels.get(name);
42
+ }
43
+
44
+ function broadcast(channel, event) {
45
+ const channelClients = clients.get(channel) || [];
46
+ const data = `data: ${JSON.stringify(event)}\n\n`;
47
+ channelClients.forEach(res => {
48
+ try { res.write(data); } catch (e) {}
49
+ });
50
+
51
+ // Also broadcast to "all" channel listeners
52
+ const allClients = clients.get('__all__') || [];
53
+ allClients.forEach(res => {
54
+ try { res.write(data); } catch (e) {}
55
+ });
56
+ }
57
+
58
+ const HTML_PAGE = `<!DOCTYPE html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="UTF-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
63
+ <meta name="theme-color" content="#141414">
64
+ <meta name="description" content="MCP-TV: Universal visualization for Model Context Protocol tools">
65
+ <link rel="manifest" href="/manifest.json">
66
+ <title>MCP-TV</title>
67
+ <style>
68
+ :root {
69
+ --bg-0: #0d0d0d;
70
+ --bg-1: #141414;
71
+ --bg-2: #1a1a1a;
72
+ --bg-3: #222222;
73
+ --bg-4: #2a2a2a;
74
+ --border: rgba(255,255,255,0.06);
75
+ --border-hover: rgba(255,255,255,0.1);
76
+ --text-0: #f5f5f5;
77
+ --text-1: #a0a0a0;
78
+ --text-2: #606060;
79
+ --accent: #4a9a8a;
80
+ --accent-dim: rgba(74,154,138,0.15);
81
+ --running: #8a8a4a;
82
+ --error: #8a4a4a;
83
+ }
84
+
85
+ * { margin: 0; padding: 0; box-sizing: border-box; }
86
+
87
+ body {
88
+ background: var(--bg-0);
89
+ color: var(--text-1);
90
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
91
+ font-size: 13px;
92
+ min-height: 100vh;
93
+ line-height: 1.5;
94
+ -webkit-font-smoothing: antialiased;
95
+ }
96
+
97
+ .header {
98
+ background: var(--bg-1);
99
+ border-bottom: 1px solid var(--border);
100
+ padding: 0 20px;
101
+ height: 48px;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ position: sticky;
106
+ top: 0;
107
+ z-index: 100;
108
+ }
109
+
110
+ .logo {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 8px;
114
+ font-weight: 500;
115
+ font-size: 14px;
116
+ color: var(--text-0);
117
+ }
118
+
119
+ .logo-icon {
120
+ width: 24px;
121
+ height: 24px;
122
+ background: var(--bg-3);
123
+ border: 1px solid var(--border);
124
+ border-radius: 6px;
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ font-size: 12px;
129
+ }
130
+
131
+ .header-actions {
132
+ display: flex;
133
+ gap: 12px;
134
+ align-items: center;
135
+ }
136
+
137
+ .channel-selector {
138
+ background: var(--bg-2);
139
+ border: 1px solid var(--border);
140
+ border-radius: 4px;
141
+ padding: 5px 10px;
142
+ color: var(--text-1);
143
+ font-size: 12px;
144
+ outline: none;
145
+ }
146
+
147
+ .channel-selector:focus {
148
+ border-color: var(--border-hover);
149
+ }
150
+
151
+ .status-dot {
152
+ width: 6px;
153
+ height: 6px;
154
+ border-radius: 50%;
155
+ background: var(--accent);
156
+ }
157
+
158
+ .status-dot.live {
159
+ animation: pulse 2.5s ease-in-out infinite;
160
+ }
161
+
162
+ @keyframes pulse {
163
+ 0%, 100% { opacity: 0.4; }
164
+ 50% { opacity: 1; }
165
+ }
166
+
167
+ .main {
168
+ display: grid;
169
+ grid-template-columns: 220px 1fr;
170
+ min-height: calc(100vh - 48px);
171
+ }
172
+
173
+ .sidebar {
174
+ background: var(--bg-1);
175
+ border-right: 1px solid var(--border);
176
+ overflow-y: auto;
177
+ }
178
+
179
+ .sidebar-section {
180
+ padding: 16px 12px;
181
+ }
182
+
183
+ .sidebar-title {
184
+ font-size: 10px;
185
+ text-transform: uppercase;
186
+ letter-spacing: 0.5px;
187
+ color: var(--text-2);
188
+ margin-bottom: 10px;
189
+ padding: 0 8px;
190
+ }
191
+
192
+ .channel-list {
193
+ display: flex;
194
+ flex-direction: column;
195
+ gap: 2px;
196
+ }
197
+
198
+ .channel-item {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 10px;
202
+ padding: 8px;
203
+ border-radius: 6px;
204
+ cursor: pointer;
205
+ transition: background 0.15s;
206
+ }
207
+
208
+ .channel-item:hover { background: var(--bg-2); }
209
+ .channel-item.active {
210
+ background: var(--accent-dim);
211
+ border-left: 2px solid var(--accent);
212
+ padding-left: 6px;
213
+ }
214
+
215
+ .channel-icon {
216
+ width: 28px;
217
+ height: 28px;
218
+ background: var(--bg-3);
219
+ border-radius: 6px;
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: center;
223
+ font-size: 14px;
224
+ }
225
+
226
+ .channel-info { flex: 1; min-width: 0; }
227
+ .channel-name { font-size: 12px; color: var(--text-0); font-weight: 500; }
228
+ .channel-status { font-size: 10px; color: var(--text-2); }
229
+
230
+ .channel-badge {
231
+ font-size: 9px;
232
+ padding: 2px 5px;
233
+ border-radius: 3px;
234
+ background: var(--accent-dim);
235
+ color: var(--accent);
236
+ font-weight: 500;
237
+ }
238
+
239
+ .content {
240
+ padding: 20px 24px;
241
+ overflow-y: auto;
242
+ background: var(--bg-0);
243
+ }
244
+
245
+ .empty-state {
246
+ display: flex;
247
+ flex-direction: column;
248
+ align-items: center;
249
+ justify-content: center;
250
+ height: 60vh;
251
+ text-align: center;
252
+ }
253
+
254
+ .empty-icon {
255
+ font-size: 48px;
256
+ margin-bottom: 16px;
257
+ opacity: 0.3;
258
+ filter: grayscale(1);
259
+ }
260
+ .empty-title {
261
+ font-size: 16px;
262
+ color: var(--text-0);
263
+ margin-bottom: 6px;
264
+ font-weight: 500;
265
+ }
266
+ .empty-subtitle {
267
+ max-width: 360px;
268
+ color: var(--text-2);
269
+ font-size: 12px;
270
+ }
271
+
272
+ .code-block {
273
+ background: var(--bg-1);
274
+ border: 1px solid var(--border);
275
+ border-radius: 6px;
276
+ padding: 14px 16px;
277
+ margin-top: 20px;
278
+ text-align: left;
279
+ font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
280
+ font-size: 11px;
281
+ overflow-x: auto;
282
+ color: var(--text-1);
283
+ }
284
+
285
+ .code-block code { color: var(--accent); }
286
+
287
+ .pipeline-header {
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: space-between;
291
+ margin-bottom: 20px;
292
+ padding-bottom: 12px;
293
+ border-bottom: 1px solid var(--border);
294
+ }
295
+
296
+ .pipeline-title {
297
+ font-size: 14px;
298
+ font-weight: 500;
299
+ color: var(--text-0);
300
+ }
301
+
302
+ .pipeline-meta {
303
+ display: flex;
304
+ gap: 16px;
305
+ font-size: 11px;
306
+ color: var(--text-2);
307
+ }
308
+
309
+ .pipeline-stages {
310
+ display: flex;
311
+ gap: 6px;
312
+ flex-wrap: wrap;
313
+ margin-bottom: 24px;
314
+ }
315
+
316
+ .stage {
317
+ flex: 1;
318
+ min-width: 80px;
319
+ max-width: 120px;
320
+ background: var(--bg-1);
321
+ border: 1px solid var(--border);
322
+ border-radius: 6px;
323
+ padding: 12px 10px;
324
+ text-align: center;
325
+ position: relative;
326
+ transition: all 0.2s;
327
+ }
328
+
329
+ .stage::after {
330
+ content: '';
331
+ position: absolute;
332
+ top: 50%;
333
+ right: -8px;
334
+ width: 6px;
335
+ height: 1px;
336
+ background: var(--border);
337
+ }
338
+
339
+ .stage:last-child::after { display: none; }
340
+
341
+ .stage.pending { opacity: 0.4; }
342
+ .stage.running {
343
+ border-color: var(--running);
344
+ background: rgba(138,138,74,0.08);
345
+ }
346
+ .stage.complete {
347
+ border-color: var(--accent);
348
+ border-color: rgba(74,154,138,0.4);
349
+ }
350
+ .stage.error {
351
+ border-color: var(--error);
352
+ background: rgba(138,74,74,0.08);
353
+ }
354
+
355
+ .stage-icon { font-size: 18px; margin-bottom: 4px; filter: grayscale(0.5); }
356
+ .stage-name { font-size: 9px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.3px; }
357
+ .stage-time { font-size: 9px; color: var(--text-2); margin-top: 4px; }
358
+
359
+ .stage.running .stage-name { color: var(--running); }
360
+ .stage.complete .stage-name { color: var(--accent); }
361
+
362
+ @keyframes spin { to { transform: rotate(360deg); } }
363
+
364
+ .event-log {
365
+ background: var(--bg-1);
366
+ border: 1px solid var(--border);
367
+ border-radius: 6px;
368
+ overflow: hidden;
369
+ }
370
+
371
+ .event-log-header {
372
+ padding: 10px 14px;
373
+ border-bottom: 1px solid var(--border);
374
+ font-size: 11px;
375
+ font-weight: 500;
376
+ color: var(--text-1);
377
+ display: flex;
378
+ align-items: center;
379
+ gap: 6px;
380
+ background: var(--bg-2);
381
+ }
382
+
383
+ .event-log-content {
384
+ max-height: 320px;
385
+ overflow-y: auto;
386
+ font-family: 'SF Mono', Consolas, monospace;
387
+ font-size: 11px;
388
+ }
389
+
390
+ .event-log-content::-webkit-scrollbar { width: 6px; }
391
+ .event-log-content::-webkit-scrollbar-track { background: transparent; }
392
+ .event-log-content::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
393
+
394
+ .event-item {
395
+ padding: 6px 14px;
396
+ border-bottom: 1px solid rgba(255,255,255,0.02);
397
+ display: flex;
398
+ gap: 12px;
399
+ }
400
+
401
+ .event-item:hover { background: var(--bg-2); }
402
+
403
+ .event-time { color: var(--text-2); min-width: 60px; }
404
+ .event-type { min-width: 50px; font-weight: 500; }
405
+ .event-type.stage { color: var(--accent); }
406
+ .event-type.progress { color: var(--running); }
407
+ .event-type.result { color: var(--accent); }
408
+ .event-type.error { color: var(--error); }
409
+ .event-type.log { color: var(--text-2); }
410
+ .event-data { flex: 1; color: var(--text-1); word-break: break-word; }
411
+
412
+ .result-card {
413
+ background: var(--bg-1);
414
+ border: 1px solid var(--accent);
415
+ border-color: rgba(74,154,138,0.3);
416
+ border-radius: 6px;
417
+ padding: 16px;
418
+ margin-top: 20px;
419
+ }
420
+
421
+ .result-card h3 {
422
+ color: var(--accent);
423
+ font-size: 11px;
424
+ font-weight: 500;
425
+ text-transform: uppercase;
426
+ letter-spacing: 0.5px;
427
+ margin-bottom: 12px;
428
+ display: flex;
429
+ align-items: center;
430
+ gap: 6px;
431
+ }
432
+
433
+ .result-content {
434
+ background: var(--bg-0);
435
+ border-radius: 4px;
436
+ padding: 14px;
437
+ max-height: 400px;
438
+ overflow-y: auto;
439
+ white-space: pre-wrap;
440
+ font-size: 12px;
441
+ line-height: 1.6;
442
+ color: var(--text-1);
443
+ }
444
+
445
+ .result-content::-webkit-scrollbar { width: 6px; }
446
+ .result-content::-webkit-scrollbar-track { background: transparent; }
447
+ .result-content::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
448
+
449
+ @media (max-width: 768px) {
450
+ .main { grid-template-columns: 1fr; }
451
+ .sidebar { display: none; }
452
+ }
453
+
454
+ .install-banner {
455
+ display: none;
456
+ background: var(--bg-2);
457
+ border-bottom: 1px solid var(--border);
458
+ color: var(--text-1);
459
+ padding: 8px 20px;
460
+ text-align: center;
461
+ font-size: 11px;
462
+ cursor: pointer;
463
+ }
464
+
465
+ .install-banner:hover { background: var(--bg-3); }
466
+ .install-banner.show { display: block; }
467
+ </style>
468
+ </head>
469
+ <body>
470
+ <div id="install-banner" class="install-banner" onclick="installPWA()">
471
+ 📱 Install MCP-TV as an app for quick access
472
+ </div>
473
+
474
+ <header class="header">
475
+ <div class="logo">
476
+ <div class="logo-icon">📺</div>
477
+ <span>MCP-TV</span>
478
+ </div>
479
+ <div class="header-actions">
480
+ <select id="channel-select" class="channel-selector" onchange="switchChannel(this.value)">
481
+ <option value="__all__">All Channels</option>
482
+ </select>
483
+ <div class="status-dot" title="Connected"></div>
484
+ </div>
485
+ </header>
486
+
487
+ <main class="main">
488
+ <aside class="sidebar">
489
+ <div class="sidebar-section">
490
+ <div class="sidebar-title">Active Channels</div>
491
+ <div id="channel-list" class="channel-list"></div>
492
+ </div>
493
+ </aside>
494
+
495
+ <section class="content">
496
+ <div id="empty-state" class="empty-state">
497
+ <div class="empty-icon">📺</div>
498
+ <h2 class="empty-title">Waiting for MCP streams...</h2>
499
+ <p class="empty-subtitle">
500
+ Any MCP can stream events here. POST JSON to this server or connect via SSE.
501
+ </p>
502
+ <div class="code-block">
503
+ <code>// Stream from any MCP or script
504
+ fetch('http://localhost:' + PORT + '/stream', {
505
+ method: 'POST',
506
+ headers: { 'Content-Type': 'application/json' },
507
+ body: JSON.stringify({
508
+ channel: 'my-mcp',
509
+ event: 'stage',
510
+ data: { name: 'processing', status: 'running' }
511
+ })
512
+ });</code>
513
+ </div>
514
+ </div>
515
+
516
+ <div id="pipeline-view" style="display:none;">
517
+ <div class="pipeline-header">
518
+ <div class="pipeline-title" id="pipeline-title">Pipeline</div>
519
+ <div class="pipeline-meta">
520
+ <span id="pipeline-duration">0.0s</span>
521
+ <span id="pipeline-status">Running</span>
522
+ </div>
523
+ </div>
524
+
525
+ <div id="stages" class="pipeline-stages"></div>
526
+
527
+ <div class="event-log">
528
+ <div class="event-log-header">
529
+ <span>📋</span> Event Log
530
+ </div>
531
+ <div id="event-log" class="event-log-content"></div>
532
+ </div>
533
+
534
+ <div id="result-card" class="result-card" style="display:none;">
535
+ <h3>✨ Result</h3>
536
+ <div id="result-content" class="result-content"></div>
537
+ </div>
538
+ </div>
539
+ </section>
540
+ </main>
541
+
542
+ <script>
543
+ const PORT = location.port || 50888;
544
+ let currentChannel = '__all__';
545
+ let channelData = new Map();
546
+ let startTime = Date.now();
547
+
548
+ // Connect to SSE
549
+ const eventSource = new EventSource('/events?channel=__all__');
550
+
551
+ eventSource.onmessage = (e) => {
552
+ const event = JSON.parse(e.data);
553
+ handleEvent(event);
554
+ };
555
+
556
+ eventSource.onerror = () => {
557
+ document.querySelector('.status-dot').style.background = 'var(--accent-red)';
558
+ };
559
+
560
+ function handleEvent(event) {
561
+ const { channel, event: eventType, data, timestamp } = event;
562
+
563
+ // Update channel data
564
+ if (!channelData.has(channel)) {
565
+ channelData.set(channel, {
566
+ name: channel,
567
+ events: [],
568
+ stages: [],
569
+ status: 'active',
570
+ result: null
571
+ });
572
+ updateChannelList();
573
+ }
574
+
575
+ const ch = channelData.get(channel);
576
+ ch.events.push({ type: eventType, data, time: timestamp || Date.now() });
577
+ ch.lastUpdate = Date.now();
578
+
579
+ // Process event types
580
+ if (eventType === 'stage') {
581
+ updateStage(ch, data);
582
+ } else if (eventType === 'result') {
583
+ ch.result = data;
584
+ ch.status = 'complete';
585
+ } else if (eventType === 'error') {
586
+ ch.status = 'error';
587
+ } else if (eventType === 'init') {
588
+ ch.title = data.title || data.problem || channel;
589
+ ch.stages = (data.stages || []).map(s => ({ ...s, status: 'pending' }));
590
+ startTime = Date.now();
591
+ }
592
+
593
+ // Update UI if viewing this channel or all
594
+ if (currentChannel === '__all__' || currentChannel === channel) {
595
+ renderChannel(ch);
596
+ }
597
+
598
+ updateChannelList();
599
+ }
600
+
601
+ function updateStage(ch, stageData) {
602
+ const existing = ch.stages.find(s => s.name === stageData.name);
603
+ if (existing) {
604
+ Object.assign(existing, stageData);
605
+ } else {
606
+ ch.stages.push(stageData);
607
+ }
608
+ }
609
+
610
+ function updateChannelList() {
611
+ const list = document.getElementById('channel-list');
612
+ const select = document.getElementById('channel-select');
613
+
614
+ const channels = Array.from(channelData.entries());
615
+
616
+ list.innerHTML = channels.map(([name, ch]) => \`
617
+ <div class="channel-item \${currentChannel === name ? 'active' : ''}" onclick="switchChannel('\${name}')">
618
+ <div class="channel-icon" style="background: \${getChannelColor(name)}">\${getChannelIcon(ch)}</div>
619
+ <div class="channel-info">
620
+ <div class="channel-name">\${name}</div>
621
+ <div class="channel-status">\${ch.events.length} events</div>
622
+ </div>
623
+ \${ch.status === 'active' ? '<span class="channel-badge">LIVE</span>' : ''}
624
+ </div>
625
+ \`).join('');
626
+
627
+ // Update select
628
+ const currentOptions = new Set([...select.options].map(o => o.value));
629
+ channels.forEach(([name]) => {
630
+ if (!currentOptions.has(name)) {
631
+ const opt = document.createElement('option');
632
+ opt.value = name;
633
+ opt.textContent = name;
634
+ select.appendChild(opt);
635
+ }
636
+ });
637
+ }
638
+
639
+ function switchChannel(name) {
640
+ currentChannel = name;
641
+ document.getElementById('channel-select').value = name;
642
+
643
+ if (name === '__all__' && channelData.size === 0) {
644
+ document.getElementById('empty-state').style.display = 'flex';
645
+ document.getElementById('pipeline-view').style.display = 'none';
646
+ } else if (name === '__all__') {
647
+ // Show most recent channel
648
+ const latest = [...channelData.entries()].sort((a, b) => b[1].lastUpdate - a[1].lastUpdate)[0];
649
+ if (latest) renderChannel(latest[1]);
650
+ } else {
651
+ const ch = channelData.get(name);
652
+ if (ch) renderChannel(ch);
653
+ }
654
+
655
+ updateChannelList();
656
+ }
657
+
658
+ function renderChannel(ch) {
659
+ document.getElementById('empty-state').style.display = 'none';
660
+ document.getElementById('pipeline-view').style.display = 'block';
661
+
662
+ // Title
663
+ document.getElementById('pipeline-title').textContent = ch.title || ch.name;
664
+
665
+ // Duration
666
+ const duration = ((ch.lastUpdate || Date.now()) - startTime) / 1000;
667
+ document.getElementById('pipeline-duration').textContent = duration.toFixed(1) + 's';
668
+
669
+ // Status
670
+ document.getElementById('pipeline-status').textContent = ch.status;
671
+
672
+ // Stages
673
+ const stagesEl = document.getElementById('stages');
674
+ stagesEl.innerHTML = ch.stages.map(s => \`
675
+ <div class="stage \${s.status || 'pending'}">
676
+ <div class="stage-icon">\${getStageIcon(s.name)}</div>
677
+ <div class="stage-name">\${s.name}</div>
678
+ \${s.duration_ms ? '<div class="stage-time">' + (s.duration_ms/1000).toFixed(1) + 's</div>' : ''}
679
+ </div>
680
+ \`).join('');
681
+
682
+ // Event log
683
+ const logEl = document.getElementById('event-log');
684
+ logEl.innerHTML = ch.events.slice(-50).map(e => \`
685
+ <div class="event-item">
686
+ <span class="event-time">\${formatTime(e.time)}</span>
687
+ <span class="event-type \${e.type}">\${e.type}</span>
688
+ <span class="event-data">\${formatData(e.data)}</span>
689
+ </div>
690
+ \`).join('');
691
+ logEl.scrollTop = logEl.scrollHeight;
692
+
693
+ // Result
694
+ if (ch.result) {
695
+ document.getElementById('result-card').style.display = 'block';
696
+ document.getElementById('result-content').textContent =
697
+ typeof ch.result === 'string' ? ch.result : JSON.stringify(ch.result, null, 2);
698
+ }
699
+ }
700
+
701
+ function getChannelColor(name) {
702
+ const colors = ['#00ff88', '#00d4ff', '#ff6b6b', '#ffd93d', '#6c5ce7', '#a29bfe'];
703
+ let hash = 0;
704
+ for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
705
+ return colors[Math.abs(hash) % colors.length];
706
+ }
707
+
708
+ function getChannelIcon(ch) {
709
+ if (ch.name.includes('invent')) return '🔬';
710
+ if (ch.name.includes('genius')) return '💡';
711
+ if (ch.name.includes('compute')) return '⚡';
712
+ if (ch.name.includes('search')) return '🔍';
713
+ return '📺';
714
+ }
715
+
716
+ function getStageIcon(name) {
717
+ const icons = {
718
+ 'mind_opener': '🧠', 'idea_fold': '🔬', 'bcalc': '📊',
719
+ 'web_search': '🔍', 'genius_plus': '💡', 'cvi_verify': '✓',
720
+ 'roast': '🔥', 'compute': '⚡', 'init': '🚀'
721
+ };
722
+ return icons[name] || '⚙️';
723
+ }
724
+
725
+ function formatTime(ts) {
726
+ const d = new Date(ts);
727
+ return d.toLocaleTimeString('en-US', { hour12: false });
728
+ }
729
+
730
+ function formatData(data) {
731
+ if (typeof data === 'string') return data.slice(0, 100);
732
+ if (data && data.name) return data.name + (data.status ? ' → ' + data.status : '');
733
+ return JSON.stringify(data).slice(0, 100);
734
+ }
735
+
736
+ // Update duration every 100ms
737
+ setInterval(() => {
738
+ if (channelData.size > 0) {
739
+ const duration = (Date.now() - startTime) / 1000;
740
+ document.getElementById('pipeline-duration').textContent = duration.toFixed(1) + 's';
741
+ }
742
+ }, 100);
743
+
744
+ // PWA Install
745
+ let deferredPrompt;
746
+ window.addEventListener('beforeinstallprompt', (e) => {
747
+ e.preventDefault();
748
+ deferredPrompt = e;
749
+ document.getElementById('install-banner').classList.add('show');
750
+ });
751
+
752
+ function installPWA() {
753
+ if (deferredPrompt) {
754
+ deferredPrompt.prompt();
755
+ deferredPrompt.userChoice.then(() => {
756
+ document.getElementById('install-banner').classList.remove('show');
757
+ deferredPrompt = null;
758
+ });
759
+ }
760
+ }
761
+
762
+ // Service Worker
763
+ if ('serviceWorker' in navigator) {
764
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
765
+ }
766
+ </script>
767
+ </body>
768
+ </html>`;
769
+
770
+ const MANIFEST = {
771
+ name: "MCP-TV",
772
+ short_name: "MCP-TV",
773
+ description: "Universal MCP Visualization Platform",
774
+ start_url: "/",
775
+ display: "standalone",
776
+ background_color: "#0a0a0f",
777
+ theme_color: "#00ff88",
778
+ icons: [
779
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
780
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" }
781
+ ]
782
+ };
783
+
784
+ const SW_JS = `
785
+ self.addEventListener('install', () => self.skipWaiting());
786
+ self.addEventListener('activate', (e) => e.waitUntil(clients.claim()));
787
+ self.addEventListener('fetch', (e) => {
788
+ if (e.request.method === 'GET' && !e.request.url.includes('/events')) {
789
+ e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
790
+ }
791
+ });
792
+ `;
793
+
794
+ // Simple SVG icon as data URI
795
+ const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="#0a0a0f" width="100" height="100" rx="20"/><text x="50" y="65" text-anchor="middle" font-size="50">📺</text></svg>`;
796
+
797
+ function createServer(port = DEFAULT_PORT) {
798
+ const server = http.createServer((req, res) => {
799
+ const url = new URL(req.url, `http://localhost:${port}`);
800
+
801
+ // CORS headers
802
+ res.setHeader('Access-Control-Allow-Origin', '*');
803
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
804
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
805
+
806
+ if (req.method === 'OPTIONS') {
807
+ res.writeHead(204);
808
+ return res.end();
809
+ }
810
+
811
+ // SSE endpoint
812
+ if (url.pathname === '/events') {
813
+ const channel = url.searchParams.get('channel') || '__all__';
814
+
815
+ res.writeHead(200, {
816
+ 'Content-Type': 'text/event-stream',
817
+ 'Cache-Control': 'no-cache',
818
+ 'Connection': 'keep-alive'
819
+ });
820
+
821
+ if (!clients.has(channel)) clients.set(channel, []);
822
+ clients.get(channel).push(res);
823
+
824
+ req.on('close', () => {
825
+ const arr = clients.get(channel) || [];
826
+ clients.set(channel, arr.filter(c => c !== res));
827
+ });
828
+
829
+ return;
830
+ }
831
+
832
+ // Stream endpoint - receive events from MCPs
833
+ if (url.pathname === '/stream' && req.method === 'POST') {
834
+ let body = '';
835
+ req.on('data', chunk => body += chunk);
836
+ req.on('end', () => {
837
+ try {
838
+ const event = JSON.parse(body);
839
+ const channel = event.channel || 'default';
840
+
841
+ // Add timestamp
842
+ event.timestamp = Date.now();
843
+
844
+ // Store in channel
845
+ const ch = getChannel(channel);
846
+ ch.events.push(event);
847
+ ch.lastUpdate = Date.now();
848
+
849
+ // Broadcast to listeners
850
+ broadcast(channel, event);
851
+
852
+ res.writeHead(200, { 'Content-Type': 'application/json' });
853
+ res.end(JSON.stringify({ ok: true, channel }));
854
+ } catch (e) {
855
+ res.writeHead(400, { 'Content-Type': 'application/json' });
856
+ res.end(JSON.stringify({ ok: false, error: e.message }));
857
+ }
858
+ });
859
+ return;
860
+ }
861
+
862
+ // PWA assets
863
+ if (url.pathname === '/manifest.json') {
864
+ res.writeHead(200, { 'Content-Type': 'application/manifest+json' });
865
+ return res.end(JSON.stringify(MANIFEST));
866
+ }
867
+
868
+ if (url.pathname === '/sw.js') {
869
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
870
+ return res.end(SW_JS);
871
+ }
872
+
873
+ if (url.pathname.includes('icon-')) {
874
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
875
+ return res.end(ICON_SVG);
876
+ }
877
+
878
+ // API: List channels
879
+ if (url.pathname === '/api/channels') {
880
+ res.writeHead(200, { 'Content-Type': 'application/json' });
881
+ return res.end(JSON.stringify([...channels.keys()]));
882
+ }
883
+
884
+ // API: Get channel
885
+ if (url.pathname.startsWith('/api/channel/')) {
886
+ const name = url.pathname.split('/').pop();
887
+ const ch = channels.get(name);
888
+ res.writeHead(ch ? 200 : 404, { 'Content-Type': 'application/json' });
889
+ return res.end(JSON.stringify(ch || { error: 'Not found' }));
890
+ }
891
+
892
+ // Main page
893
+ res.writeHead(200, { 'Content-Type': 'text/html' });
894
+ res.end(HTML_PAGE.replace(/\$\{PORT\}/g, port));
895
+ });
896
+
897
+ server.listen(port, () => {
898
+ console.log(`
899
+ 📺 MCP-TV is live!
900
+
901
+ Local: http://localhost:${port}
902
+
903
+ Stream events from any MCP:
904
+ POST http://localhost:${port}/stream
905
+ {
906
+ "channel": "my-mcp",
907
+ "event": "stage",
908
+ "data": { "name": "processing", "status": "running" }
909
+ }
910
+
911
+ Or connect via SSE:
912
+ GET http://localhost:${port}/events?channel=my-mcp
913
+
914
+ Press Ctrl+C to stop.
915
+ `);
916
+ });
917
+
918
+ return server;
919
+ }
920
+
921
+ function openBrowser(url, width = 1200, height = 800) {
922
+ const platform = process.platform;
923
+
924
+ if (platform === 'win32') {
925
+ // Try Chrome first with app mode for clean window
926
+ const chromePaths = [
927
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
928
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
929
+ process.env.LOCALAPPDATA + '\\Google\\Chrome\\Application\\chrome.exe'
930
+ ];
931
+ const edgePath = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
932
+
933
+ let browserPath = null;
934
+ for (const p of chromePaths) {
935
+ try { if (require('fs').existsSync(p)) { browserPath = p; break; } } catch {}
936
+ }
937
+ if (!browserPath) {
938
+ try { if (require('fs').existsSync(edgePath)) browserPath = edgePath; } catch {}
939
+ }
940
+
941
+ if (browserPath) {
942
+ spawn(browserPath, [
943
+ `--app=${url}`,
944
+ `--window-size=${width},${height}`,
945
+ '--window-position=100,50'
946
+ ], { detached: true, stdio: 'ignore' }).unref();
947
+ } else {
948
+ spawn('cmd', ['/c', 'start', url], { detached: true, stdio: 'ignore' }).unref();
949
+ }
950
+ } else if (platform === 'darwin') {
951
+ // macOS - use osascript to set window bounds
952
+ spawn('open', ['-na', 'Google Chrome', '--args', `--app=${url}`, `--window-size=${width},${height}`],
953
+ { detached: true, stdio: 'ignore' }).unref();
954
+ } else {
955
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
956
+ }
957
+ }
958
+
959
+ // Stream helper for other MCPs to use
960
+ function createMCPTVClient(channel, serverUrl = 'http://localhost:50888') {
961
+ return {
962
+ init: (data) => fetch(`${serverUrl}/stream`, {
963
+ method: 'POST',
964
+ headers: { 'Content-Type': 'application/json' },
965
+ body: JSON.stringify({ channel, event: 'init', data })
966
+ }),
967
+
968
+ stage: (name, status, duration_ms) => fetch(`${serverUrl}/stream`, {
969
+ method: 'POST',
970
+ headers: { 'Content-Type': 'application/json' },
971
+ body: JSON.stringify({ channel, event: 'stage', data: { name, status, duration_ms } })
972
+ }),
973
+
974
+ progress: (message, percent) => fetch(`${serverUrl}/stream`, {
975
+ method: 'POST',
976
+ headers: { 'Content-Type': 'application/json' },
977
+ body: JSON.stringify({ channel, event: 'progress', data: { message, percent } })
978
+ }),
979
+
980
+ log: (message) => fetch(`${serverUrl}/stream`, {
981
+ method: 'POST',
982
+ headers: { 'Content-Type': 'application/json' },
983
+ body: JSON.stringify({ channel, event: 'log', data: { message } })
984
+ }),
985
+
986
+ result: (data) => fetch(`${serverUrl}/stream`, {
987
+ method: 'POST',
988
+ headers: { 'Content-Type': 'application/json' },
989
+ body: JSON.stringify({ channel, event: 'result', data })
990
+ }),
991
+
992
+ error: (message) => fetch(`${serverUrl}/stream`, {
993
+ method: 'POST',
994
+ headers: { 'Content-Type': 'application/json' },
995
+ body: JSON.stringify({ channel, event: 'error', data: { message } })
996
+ })
997
+ };
998
+ }
999
+
1000
+ module.exports = { createServer, createMCPTVClient, broadcast, getChannel };
1001
+
1002
+ // CLI
1003
+ if (require.main === module) {
1004
+ const args = process.argv.slice(2);
1005
+ let port = DEFAULT_PORT;
1006
+ let openOnStart = true;
1007
+
1008
+ for (const arg of args) {
1009
+ if (arg.startsWith('--port=')) port = parseInt(arg.split('=')[1]);
1010
+ if (arg === '--no-open') openOnStart = false;
1011
+ }
1012
+
1013
+ createServer(port);
1014
+ if (openOnStart) setTimeout(() => openBrowser(`http://localhost:${port}`), 500);
1015
+ }