@trillboards/edge-sdk 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +147 -2
  2. package/deploy/docker/Dockerfile.cpu +132 -0
  3. package/deploy/docker/Dockerfile.cuda +134 -0
  4. package/deploy/docker/Dockerfile.openvino +131 -0
  5. package/deploy/docker/README.md +358 -0
  6. package/deploy/helm/README.md +508 -0
  7. package/deploy/helm/trillboards-edge/Chart.yaml +19 -0
  8. package/deploy/helm/trillboards-edge/templates/_helpers.tpl +40 -0
  9. package/deploy/helm/trillboards-edge/templates/daemonset.yaml +120 -0
  10. package/deploy/helm/trillboards-edge/templates/service.yaml +15 -0
  11. package/deploy/helm/trillboards-edge/values.yaml +95 -0
  12. package/deploy/k8s/daemonset.yaml +144 -0
  13. package/dist/CommandRouter.d.ts +113 -0
  14. package/dist/CommandRouter.d.ts.map +1 -0
  15. package/dist/CommandRouter.js +392 -0
  16. package/dist/CommandRouter.js.map +1 -0
  17. package/dist/EdgeAgent.d.ts +6 -1
  18. package/dist/EdgeAgent.d.ts.map +1 -1
  19. package/dist/EdgeAgent.js +277 -10
  20. package/dist/EdgeAgent.js.map +1 -1
  21. package/dist/cli.js +60 -8
  22. package/dist/cli.js.map +1 -1
  23. package/dist/config.d.ts +1 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/demo.d.ts +111 -0
  27. package/dist/demo.d.ts.map +1 -0
  28. package/dist/demo.js +483 -0
  29. package/dist/demo.js.map +1 -0
  30. package/dist/diagnose.d.ts +59 -0
  31. package/dist/diagnose.d.ts.map +1 -0
  32. package/dist/diagnose.js +651 -0
  33. package/dist/diagnose.js.map +1 -0
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +7 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/init.d.ts +19 -0
  39. package/dist/init.d.ts.map +1 -0
  40. package/dist/init.js +364 -0
  41. package/dist/init.js.map +1 -0
  42. package/dist/mcp-server.d.ts +27 -0
  43. package/dist/mcp-server.d.ts.map +1 -0
  44. package/dist/mcp-server.js +1264 -0
  45. package/dist/mcp-server.js.map +1 -0
  46. package/dist/status.d.ts +11 -0
  47. package/dist/status.d.ts.map +1 -0
  48. package/dist/status.js +343 -0
  49. package/dist/status.js.map +1 -0
  50. package/package.json +5 -4
@@ -0,0 +1,1264 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * MCP Server for Trillboards Edge SDK
5
+ *
6
+ * A Model Context Protocol (MCP) server that allows AI agents to discover,
7
+ * configure, and diagnose edge devices. Communicates with the Edge Agent's
8
+ * StatusServer via HTTP (localhost:9090) using JSON-RPC 2.0 over stdio.
9
+ *
10
+ * Uses ONLY Node.js built-in modules — zero external dependencies.
11
+ *
12
+ * Claude Desktop config:
13
+ * {
14
+ * "mcpServers": {
15
+ * "trillboards-edge": {
16
+ * "command": "npx",
17
+ * "args": ["-y", "@trillboards/edge-sdk", "mcp"],
18
+ * "env": { "EDGE_STATUS_PORT": "9090" }
19
+ * }
20
+ * }
21
+ * }
22
+ *
23
+ * Bin entry for package.json (to be added separately):
24
+ * "trillboards-edge-mcp": "./dist/mcp-server.js"
25
+ */
26
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ var desc = Object.getOwnPropertyDescriptor(m, k);
29
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
30
+ desc = { enumerable: true, get: function() { return m[k]; } };
31
+ }
32
+ Object.defineProperty(o, k2, desc);
33
+ }) : (function(o, m, k, k2) {
34
+ if (k2 === undefined) k2 = k;
35
+ o[k2] = m[k];
36
+ }));
37
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
38
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
39
+ }) : function(o, v) {
40
+ o["default"] = v;
41
+ });
42
+ var __importStar = (this && this.__importStar) || (function () {
43
+ var ownKeys = function(o) {
44
+ ownKeys = Object.getOwnPropertyNames || function (o) {
45
+ var ar = [];
46
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
47
+ return ar;
48
+ };
49
+ return ownKeys(o);
50
+ };
51
+ return function (mod) {
52
+ if (mod && mod.__esModule) return mod;
53
+ var result = {};
54
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
55
+ __setModuleDefault(result, mod);
56
+ return result;
57
+ };
58
+ })();
59
+ Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.startServer = main;
61
+ const http = __importStar(require("http"));
62
+ const https = __importStar(require("https"));
63
+ const readline = __importStar(require("readline"));
64
+ const os = __importStar(require("os"));
65
+ const fs = __importStar(require("fs"));
66
+ const path = __importStar(require("path"));
67
+ const child_process = __importStar(require("child_process"));
68
+ // ─── Configuration ──────────────────────────────────────────────────────────
69
+ const STATUS_PORT = parseInt(process.env.EDGE_STATUS_PORT || '9090', 10);
70
+ const STATUS_HOST = process.env.EDGE_STATUS_HOST || 'localhost';
71
+ const SERVER_VERSION = '0.2.3';
72
+ const PROTOCOL_VERSION = '2024-11-05';
73
+ const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
74
+ // ─── JSON-RPC Error Codes ──────────────────────────────────────────────────
75
+ const PARSE_ERROR = -32700;
76
+ const INVALID_REQUEST = -32600;
77
+ const METHOD_NOT_FOUND = -32601;
78
+ const INVALID_PARAMS = -32602;
79
+ const INTERNAL_ERROR = -32603;
80
+ // ─── HTTP Helpers ──────────────────────────────────────────────────────────
81
+ /**
82
+ * Fetch JSON from the local StatusServer.
83
+ * Uses Node.js built-in http module — no external dependencies.
84
+ */
85
+ function fetchStatus(statusPath = '/status', port = STATUS_PORT) {
86
+ return new Promise((resolve, reject) => {
87
+ const req = http.get({
88
+ hostname: STATUS_HOST,
89
+ port,
90
+ path: statusPath,
91
+ timeout: 5000,
92
+ headers: { Accept: 'application/json' },
93
+ }, (res) => {
94
+ if (res.statusCode !== 200) {
95
+ res.resume(); // drain the response
96
+ reject(new Error(`StatusServer returned HTTP ${res.statusCode} for ${statusPath}`));
97
+ return;
98
+ }
99
+ let data = '';
100
+ res.on('data', (chunk) => {
101
+ data += chunk.toString();
102
+ });
103
+ res.on('end', () => {
104
+ try {
105
+ resolve(JSON.parse(data));
106
+ }
107
+ catch {
108
+ reject(new Error(`Invalid JSON from StatusServer: ${data.slice(0, 200)}`));
109
+ }
110
+ });
111
+ });
112
+ req.on('error', (err) => {
113
+ reject(new Error(`Cannot connect to Edge Agent StatusServer at ${STATUS_HOST}:${port} — ${err.message}. ` +
114
+ 'Is the edge agent running? Start it with: trillboards-edge start --config trillboards.config.json'));
115
+ });
116
+ req.on('timeout', () => {
117
+ req.destroy();
118
+ reject(new Error(`StatusServer request timed out after 5s (${STATUS_HOST}:${port}${statusPath})`));
119
+ });
120
+ });
121
+ }
122
+ /**
123
+ * POST JSON to the local StatusServer.
124
+ */
125
+ function postToAgent(agentPath, body, port = STATUS_PORT) {
126
+ return new Promise((resolve, reject) => {
127
+ const payload = JSON.stringify(body);
128
+ const req = http.request({
129
+ hostname: STATUS_HOST,
130
+ port,
131
+ path: agentPath,
132
+ method: 'POST',
133
+ timeout: 10000,
134
+ headers: {
135
+ 'Content-Type': 'application/json',
136
+ 'Content-Length': Buffer.byteLength(payload),
137
+ },
138
+ }, (res) => {
139
+ let data = '';
140
+ res.on('data', (chunk) => {
141
+ data += chunk.toString();
142
+ });
143
+ res.on('end', () => {
144
+ if (res.statusCode && res.statusCode >= 400) {
145
+ reject(new Error(`Agent returned HTTP ${res.statusCode}: ${data.slice(0, 500)}`));
146
+ return;
147
+ }
148
+ try {
149
+ resolve(data ? JSON.parse(data) : { ok: true });
150
+ }
151
+ catch {
152
+ reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`));
153
+ }
154
+ });
155
+ });
156
+ req.on('error', (err) => {
157
+ reject(new Error(`Cannot POST to Edge Agent at ${STATUS_HOST}:${port}${agentPath} — ${err.message}`));
158
+ });
159
+ req.on('timeout', () => {
160
+ req.destroy();
161
+ reject(new Error(`POST to ${agentPath} timed out after 10s`));
162
+ });
163
+ req.write(payload);
164
+ req.end();
165
+ });
166
+ }
167
+ // ─── Tool Definitions ──────────────────────────────────────────────────────
168
+ const TOOL_DEFINITIONS = [
169
+ {
170
+ name: 'get-device-status',
171
+ description: 'Returns full device status from the Edge Agent StatusServer. ' +
172
+ 'Includes device identity, capability tier, subsystem health, ' +
173
+ 'sensing pipeline state, connection status, and uptime.',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {},
177
+ },
178
+ },
179
+ {
180
+ name: 'get-audience-live',
181
+ description: 'Returns live audience sensing data from the edge device. ' +
182
+ 'Includes face count, attention scores, VAS (Viewability Attention Score), ' +
183
+ 'evidence grade, demographics, dwell time, and ambient conditions.',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {},
187
+ },
188
+ },
189
+ {
190
+ name: 'configure-sensing',
191
+ description: 'Push configuration changes to the running edge agent. ' +
192
+ 'Accepts a partial config object that will be deep-merged with the current config. ' +
193
+ 'Use this to adjust sensing parameters, camera settings, model execution providers, etc.',
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ config: {
198
+ type: 'object',
199
+ description: 'Partial configuration to merge. Example: {"camera":{"fps":10},"models":{"executionProvider":"openvino"}}',
200
+ additionalProperties: true,
201
+ },
202
+ },
203
+ required: ['config'],
204
+ },
205
+ },
206
+ {
207
+ name: 'run-benchmark',
208
+ description: 'Run a quick performance benchmark on the edge device. ' +
209
+ 'Measures camera FPS, ML inference latency, memory usage, and dropped frames ' +
210
+ 'over the specified duration.',
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ duration: {
215
+ type: 'number',
216
+ description: 'Benchmark duration in seconds. Default: 10. Min: 3, Max: 120.',
217
+ minimum: 3,
218
+ maximum: 120,
219
+ },
220
+ },
221
+ },
222
+ },
223
+ {
224
+ name: 'diagnose-hardware',
225
+ description: 'Run system diagnostics on the edge device. ' +
226
+ 'Checks camera, audio, GPU/NPU, disk space, network, system temperature, ' +
227
+ 'ONNX runtime, and model files. Returns structured pass/warn/fail results.',
228
+ inputSchema: {
229
+ type: 'object',
230
+ properties: {},
231
+ },
232
+ },
233
+ {
234
+ name: 'list-models',
235
+ description: 'List installed ONNX models on the edge device. ' +
236
+ 'Returns model names, file sizes, file paths, and whether each model is loaded.',
237
+ inputSchema: {
238
+ type: 'object',
239
+ properties: {},
240
+ },
241
+ },
242
+ {
243
+ name: 'get-buffer-stats',
244
+ description: 'Get signal buffer statistics from the edge agent. ' +
245
+ 'Shows buffered signal count, capacity, backend type (sqlite/json), ' +
246
+ 'oldest/newest timestamps, and breakdown by evidence grade.',
247
+ inputSchema: {
248
+ type: 'object',
249
+ properties: {},
250
+ },
251
+ },
252
+ ];
253
+ // ─── Tool Implementations ──────────────────────────────────────────────────
254
+ async function toolGetDeviceStatus() {
255
+ const status = await fetchStatus('/status');
256
+ return {
257
+ content: [
258
+ {
259
+ type: 'text',
260
+ text: JSON.stringify(status, null, 2),
261
+ },
262
+ ],
263
+ };
264
+ }
265
+ async function toolGetAudienceLive() {
266
+ const status = await fetchStatus('/status');
267
+ // Extract audience-relevant fields from the full status payload.
268
+ // The StatusServer exposes a flat status object; we cherry-pick sensing fields.
269
+ const audience = {
270
+ timestamp: Date.now(),
271
+ // Face detection
272
+ faceCount: status.faceCount ?? status.avgFaceCount ?? 0,
273
+ maxFaceCount: status.maxFaceCount ?? 0,
274
+ // Attention
275
+ attentionScore: status.avgAttention ?? status.attentionScore ?? 0,
276
+ // VAS (Viewability Attention Score)
277
+ vasRaw: status.vasRaw ?? 0,
278
+ vasWeighted: status.vasWeighted ?? 0,
279
+ vasQualityMultiplier: status.vasQualityMultiplier ?? 1,
280
+ // Evidence classification
281
+ observationFamily: status.observationFamily ?? 'EMPTY_SCENE',
282
+ evidenceGrade: status.evidenceGrade ?? 'EMPTY',
283
+ decisionability: status.decisionability ?? 'NON_DECISIONABLE',
284
+ decisionBlockReasons: status.decisionBlockReasons ?? [],
285
+ measurementQuality: status.measurementQuality ?? 'unknown',
286
+ // Demographics (on-device)
287
+ demographics: status.onDeviceDemographics ?? status.demographics ?? null,
288
+ // Dwell time
289
+ avgDwellTimeMs: status.avgDwellTimeMs ?? 0,
290
+ // Ambient
291
+ ambientLux: status.ambientLux ?? null,
292
+ dominantAmbience: status.dominantAmbience ?? null,
293
+ // Audio
294
+ avgNoiseLevel: status.avgNoiseLevel ?? 0,
295
+ speech: status.speech ?? false,
296
+ // Foot traffic
297
+ footTraffic: status.footTraffic ?? 0,
298
+ // Engagement
299
+ emotionalEngagement: status.emotionalEngagement ?? 0,
300
+ adReceptivityScore: status.adReceptivityScore ?? 0,
301
+ // Sensing mode
302
+ sensingMode: status.sensingMode ?? 'unknown',
303
+ // Quality telemetry
304
+ edgeQuality: status.edgeQuality ?? null,
305
+ };
306
+ return {
307
+ content: [
308
+ {
309
+ type: 'text',
310
+ text: JSON.stringify(audience, null, 2),
311
+ },
312
+ ],
313
+ };
314
+ }
315
+ async function toolConfigureSensing(params) {
316
+ const config = params.config;
317
+ if (!config || typeof config !== 'object') {
318
+ return {
319
+ isError: true,
320
+ content: [
321
+ {
322
+ type: 'text',
323
+ text: 'Invalid parameters: "config" must be a non-null object with configuration fields to merge.',
324
+ },
325
+ ],
326
+ };
327
+ }
328
+ const result = await postToAgent('/config', config);
329
+ return {
330
+ content: [
331
+ {
332
+ type: 'text',
333
+ text: JSON.stringify({
334
+ success: true,
335
+ message: 'Configuration update sent to edge agent.',
336
+ applied: result,
337
+ }, null, 2),
338
+ },
339
+ ],
340
+ };
341
+ }
342
+ async function toolRunBenchmark(params) {
343
+ let duration = 10;
344
+ if (typeof params.duration === 'number') {
345
+ duration = Math.max(3, Math.min(120, Math.round(params.duration)));
346
+ }
347
+ // Request benchmark from the agent's StatusServer
348
+ const result = await postToAgent('/benchmark', { duration });
349
+ // If the agent returned benchmark data directly, use it.
350
+ // Otherwise, fall back to collecting from /status snapshots.
351
+ if (result &&
352
+ typeof result === 'object' &&
353
+ ('fps' in result || 'cameraFps' in result)) {
354
+ return {
355
+ content: [
356
+ {
357
+ type: 'text',
358
+ text: JSON.stringify(result, null, 2),
359
+ },
360
+ ],
361
+ };
362
+ }
363
+ // Fallback: poll /status at start and end to compute deltas
364
+ const startStatus = await fetchStatus('/status');
365
+ await new Promise((resolve) => setTimeout(resolve, duration * 1000));
366
+ const endStatus = await fetchStatus('/status');
367
+ const startQuality = (startStatus.edgeQuality ?? {});
368
+ const endQuality = (endStatus.edgeQuality ?? {});
369
+ const startTotalFrames = asNumber(startQuality.totalFrames, 0);
370
+ const endTotalFrames = asNumber(endQuality.totalFrames, 0);
371
+ const framesDelta = endTotalFrames - startTotalFrames;
372
+ const startDropped = asNumber(startQuality.droppedFrames, 0);
373
+ const endDropped = asNumber(endQuality.droppedFrames, 0);
374
+ const droppedDelta = endDropped - startDropped;
375
+ const benchmark = {
376
+ durationSeconds: duration,
377
+ fps: framesDelta > 0 ? round(framesDelta / duration, 2) : asNumber(endQuality.cameraFps, 0),
378
+ avgInferenceLatencyMs: asNumber(endQuality.inferenceLatencyMs, 0),
379
+ memoryUsageMb: asNumber(endQuality.memoryUsageMb, 0),
380
+ cpuUsagePercent: asNumber(endQuality.cpuUsagePercent, 0),
381
+ droppedFrames: droppedDelta,
382
+ totalFrames: framesDelta,
383
+ faceDetectionConfidence: asNumber(endQuality.faceDetectionConfidence, 0),
384
+ audioClassificationConfidence: asNumber(endQuality.audioClassificationConfidence, 0),
385
+ modelLoadTimeMs: asNumber(endQuality.modelLoadTimeMs, 0),
386
+ timestamp: Date.now(),
387
+ };
388
+ return {
389
+ content: [
390
+ {
391
+ type: 'text',
392
+ text: JSON.stringify(benchmark, null, 2),
393
+ },
394
+ ],
395
+ };
396
+ }
397
+ async function toolDiagnoseHardware() {
398
+ const checks = [];
399
+ // 1. Edge Agent connectivity
400
+ try {
401
+ const status = await fetchStatus('/status');
402
+ checks.push({
403
+ name: 'edge-agent',
404
+ status: 'pass',
405
+ message: `Edge Agent running on ${STATUS_HOST}:${STATUS_PORT}`,
406
+ details: {
407
+ fingerprint: status.fingerprint ?? null,
408
+ platform: status.platform ?? null,
409
+ uptime: status.uptime ?? null,
410
+ tier: status.tier ?? status.capabilityTier ?? null,
411
+ },
412
+ });
413
+ }
414
+ catch (err) {
415
+ checks.push({
416
+ name: 'edge-agent',
417
+ status: 'fail',
418
+ message: err instanceof Error ? err.message : 'Cannot reach Edge Agent',
419
+ });
420
+ // If we cannot reach the agent, run local-only diagnostics
421
+ return buildDiagnosticResult(checks, true);
422
+ }
423
+ // 2. System memory
424
+ const totalMemMb = Math.round(os.totalmem() / (1024 * 1024));
425
+ const freeMemMb = Math.round(os.freemem() / (1024 * 1024));
426
+ const memUsedPct = round(((totalMemMb - freeMemMb) / totalMemMb) * 100, 1);
427
+ checks.push({
428
+ name: 'memory',
429
+ status: memUsedPct > 90 ? 'fail' : memUsedPct > 75 ? 'warn' : 'pass',
430
+ message: `${freeMemMb} MB free of ${totalMemMb} MB (${memUsedPct}% used)`,
431
+ details: {
432
+ totalMb: totalMemMb,
433
+ freeMb: freeMemMb,
434
+ usedPercent: memUsedPct,
435
+ },
436
+ });
437
+ // 3. CPU
438
+ const cpus = os.cpus();
439
+ checks.push({
440
+ name: 'cpu',
441
+ status: cpus.length >= 2 ? 'pass' : 'warn',
442
+ message: `${cpus.length} cores, ${cpus[0]?.model ?? 'unknown'}`,
443
+ details: {
444
+ cores: cpus.length,
445
+ model: cpus[0]?.model ?? 'unknown',
446
+ architecture: os.arch(),
447
+ },
448
+ });
449
+ // 4. Disk space (check data directory and models directory)
450
+ const dataDir = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards');
451
+ const diskCheck = checkDiskSpace(dataDir);
452
+ checks.push(diskCheck);
453
+ // 5. Camera (V4L2 on Linux, or ask agent)
454
+ const cameraCheck = await checkCamera();
455
+ checks.push(cameraCheck);
456
+ // 6. Audio device
457
+ const audioCheck = checkAudio();
458
+ checks.push(audioCheck);
459
+ // 7. Network connectivity
460
+ const networkCheck = await checkNetwork();
461
+ checks.push(networkCheck);
462
+ // 8. ONNX models
463
+ const modelCheck = checkModels();
464
+ checks.push(modelCheck);
465
+ // 9. System temperature (Linux: /sys/class/thermal)
466
+ const tempCheck = checkTemperature();
467
+ checks.push(tempCheck);
468
+ // 10. Subsystem health from agent
469
+ try {
470
+ const status = await fetchStatus('/status');
471
+ const subsystemHealth = status.subsystemHealth;
472
+ if (subsystemHealth && typeof subsystemHealth === 'object') {
473
+ const failedSubsystems = [];
474
+ for (const [name, info] of Object.entries(subsystemHealth)) {
475
+ const sub = info;
476
+ if (sub.stale === true || sub.status === 'ERROR' || sub.status === 'CRITICAL' || sub.status === 'OFFLINE') {
477
+ failedSubsystems.push(name);
478
+ }
479
+ }
480
+ checks.push({
481
+ name: 'subsystem-health',
482
+ status: failedSubsystems.length > 0 ? 'warn' : 'pass',
483
+ message: failedSubsystems.length > 0
484
+ ? `Degraded subsystems: ${failedSubsystems.join(', ')}`
485
+ : 'All subsystems healthy',
486
+ details: subsystemHealth,
487
+ });
488
+ }
489
+ }
490
+ catch {
491
+ // Already checked agent connectivity above; skip
492
+ }
493
+ return buildDiagnosticResult(checks, false);
494
+ }
495
+ async function toolListModels() {
496
+ const models = [];
497
+ // Try to get model info from the agent first
498
+ try {
499
+ const status = await fetchStatus('/status');
500
+ if (status.models && Array.isArray(status.models)) {
501
+ return {
502
+ content: [
503
+ {
504
+ type: 'text',
505
+ text: JSON.stringify({ source: 'agent', models: status.models }, null, 2),
506
+ },
507
+ ],
508
+ };
509
+ }
510
+ }
511
+ catch {
512
+ // Agent not available — scan filesystem
513
+ }
514
+ // Scan common model directories
515
+ const modelDirs = [
516
+ './models',
517
+ path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards', 'models'),
518
+ '/opt/trillboards/models',
519
+ ];
520
+ for (const dir of modelDirs) {
521
+ const resolved = path.resolve(dir);
522
+ if (!fs.existsSync(resolved))
523
+ continue;
524
+ try {
525
+ const files = fs.readdirSync(resolved);
526
+ for (const file of files) {
527
+ if (!file.endsWith('.onnx'))
528
+ continue;
529
+ const filePath = path.join(resolved, file);
530
+ try {
531
+ const stat = fs.statSync(filePath);
532
+ models.push({
533
+ name: file.replace('.onnx', ''),
534
+ filename: file,
535
+ path: filePath,
536
+ sizeMb: round(stat.size / (1024 * 1024), 2),
537
+ lastModified: stat.mtime.toISOString(),
538
+ });
539
+ }
540
+ catch {
541
+ models.push({
542
+ name: file.replace('.onnx', ''),
543
+ filename: file,
544
+ path: filePath,
545
+ sizeMb: 0,
546
+ lastModified: null,
547
+ error: 'Cannot stat file',
548
+ });
549
+ }
550
+ }
551
+ }
552
+ catch {
553
+ // Cannot read directory — skip
554
+ }
555
+ }
556
+ // Also check if the agent reports loaded models via status
557
+ let loadedModels = [];
558
+ try {
559
+ const status = await fetchStatus('/status');
560
+ if (status.loadedModels && Array.isArray(status.loadedModels)) {
561
+ loadedModels = status.loadedModels;
562
+ }
563
+ }
564
+ catch {
565
+ // Agent not reachable
566
+ }
567
+ // Annotate models with loaded status
568
+ for (const model of models) {
569
+ model.loaded = loadedModels.some((m) => m === model.name ||
570
+ m === model.filename ||
571
+ m.includes(model.name));
572
+ }
573
+ return {
574
+ content: [
575
+ {
576
+ type: 'text',
577
+ text: JSON.stringify({
578
+ source: 'filesystem',
579
+ modelCount: models.length,
580
+ models,
581
+ }, null, 2),
582
+ },
583
+ ],
584
+ };
585
+ }
586
+ async function toolGetBufferStats() {
587
+ // Try to get buffer stats from the agent's StatusServer
588
+ try {
589
+ const status = await fetchStatus('/status');
590
+ const bufferStats = {};
591
+ // The agent exposes buffer stats in the status payload
592
+ if (status.signalBuffer && typeof status.signalBuffer === 'object') {
593
+ Object.assign(bufferStats, status.signalBuffer);
594
+ }
595
+ else {
596
+ // Extract buffer-related fields from flat status
597
+ bufferStats.bufferedCount = status.bufferedSignals ?? status.signalBufferCount ?? 0;
598
+ bufferStats.capacity = 8640; // SignalBuffer.MAX_CAPACITY
599
+ bufferStats.backend = status.signalBufferBackend ?? 'unknown';
600
+ bufferStats.oldestTimestampMs = status.signalBufferOldestMs ?? null;
601
+ bufferStats.newestTimestampMs = status.signalBufferNewestMs ?? null;
602
+ bufferStats.byEvidenceGrade = status.signalBufferByGrade ?? {};
603
+ }
604
+ // Add derived fields
605
+ const bufferedCount = asNumber(bufferStats.bufferedCount, 0);
606
+ const capacity = asNumber(bufferStats.capacity, 8640);
607
+ bufferStats.usagePercent = round((bufferedCount / capacity) * 100, 1);
608
+ bufferStats.remainingCapacity = capacity - bufferedCount;
609
+ // Add human-readable timestamps
610
+ const oldestMs = bufferStats.oldestTimestampMs;
611
+ const newestMs = bufferStats.newestTimestampMs;
612
+ if (typeof oldestMs === 'number' && oldestMs > 0) {
613
+ bufferStats.oldestSignalAge = humanDuration(Date.now() - oldestMs);
614
+ bufferStats.oldestTimestamp = new Date(oldestMs).toISOString();
615
+ }
616
+ if (typeof newestMs === 'number' && newestMs > 0) {
617
+ bufferStats.newestSignalAge = humanDuration(Date.now() - newestMs);
618
+ bufferStats.newestTimestamp = new Date(newestMs).toISOString();
619
+ }
620
+ return {
621
+ content: [
622
+ {
623
+ type: 'text',
624
+ text: JSON.stringify(bufferStats, null, 2),
625
+ },
626
+ ],
627
+ };
628
+ }
629
+ catch (err) {
630
+ // If agent is not reachable, try to read the buffer file directly
631
+ return readBufferFromDisk();
632
+ }
633
+ }
634
+ function buildDiagnosticResult(checks, agentOffline) {
635
+ const passCount = checks.filter((c) => c.status === 'pass').length;
636
+ const warnCount = checks.filter((c) => c.status === 'warn').length;
637
+ const failCount = checks.filter((c) => c.status === 'fail').length;
638
+ let overallStatus;
639
+ if (failCount > 0) {
640
+ overallStatus = 'critical';
641
+ }
642
+ else if (warnCount > 0) {
643
+ overallStatus = 'degraded';
644
+ }
645
+ else {
646
+ overallStatus = 'healthy';
647
+ }
648
+ return {
649
+ content: [
650
+ {
651
+ type: 'text',
652
+ text: JSON.stringify({
653
+ overallStatus,
654
+ agentReachable: !agentOffline,
655
+ summary: `${passCount} pass, ${warnCount} warn, ${failCount} fail`,
656
+ timestamp: new Date().toISOString(),
657
+ checks,
658
+ }, null, 2),
659
+ },
660
+ ],
661
+ };
662
+ }
663
+ function checkDiskSpace(dirPath) {
664
+ try {
665
+ const resolved = path.resolve(dirPath);
666
+ if (!fs.existsSync(resolved)) {
667
+ return {
668
+ name: 'disk',
669
+ status: 'warn',
670
+ message: `Data directory does not exist: ${resolved}`,
671
+ };
672
+ }
673
+ // Use df to check disk space on the partition containing the directory.
674
+ // Falls back gracefully if df is not available (Windows).
675
+ try {
676
+ const dfOutput = child_process
677
+ .execSync(`df -k "${resolved}" 2>/dev/null`, {
678
+ encoding: 'utf-8',
679
+ timeout: 5000,
680
+ })
681
+ .trim();
682
+ const lines = dfOutput.split('\n');
683
+ if (lines.length >= 2) {
684
+ // df -k output: Filesystem 1K-blocks Used Available Use% Mounted-on
685
+ const parts = lines[1].split(/\s+/);
686
+ if (parts.length >= 5) {
687
+ const totalKb = parseInt(parts[1], 10);
688
+ const availableKb = parseInt(parts[3], 10);
689
+ const usePct = parseInt(parts[4], 10);
690
+ const totalGb = round(totalKb / (1024 * 1024), 1);
691
+ const availGb = round(availableKb / (1024 * 1024), 1);
692
+ return {
693
+ name: 'disk',
694
+ status: usePct > 95 ? 'fail' : usePct > 85 ? 'warn' : 'pass',
695
+ message: `${availGb} GB free of ${totalGb} GB (${usePct}% used)`,
696
+ details: {
697
+ totalGb,
698
+ availableGb: availGb,
699
+ usedPercent: usePct,
700
+ path: resolved,
701
+ },
702
+ };
703
+ }
704
+ }
705
+ }
706
+ catch {
707
+ // df not available (Windows) — try statvfs-like approach or just report directory exists
708
+ }
709
+ return {
710
+ name: 'disk',
711
+ status: 'pass',
712
+ message: `Data directory exists: ${resolved}`,
713
+ details: { path: resolved },
714
+ };
715
+ }
716
+ catch (err) {
717
+ return {
718
+ name: 'disk',
719
+ status: 'warn',
720
+ message: `Cannot check disk: ${err instanceof Error ? err.message : String(err)}`,
721
+ };
722
+ }
723
+ }
724
+ async function checkCamera() {
725
+ // Linux: check for /dev/video* devices
726
+ if (process.platform === 'linux') {
727
+ try {
728
+ const videoDevices = [];
729
+ if (fs.existsSync('/dev')) {
730
+ const devFiles = fs.readdirSync('/dev');
731
+ for (const f of devFiles) {
732
+ if (f.startsWith('video')) {
733
+ videoDevices.push(`/dev/${f}`);
734
+ }
735
+ }
736
+ }
737
+ if (videoDevices.length > 0) {
738
+ return {
739
+ name: 'camera',
740
+ status: 'pass',
741
+ message: `Found ${videoDevices.length} V4L2 device(s): ${videoDevices.join(', ')}`,
742
+ details: { devices: videoDevices },
743
+ };
744
+ }
745
+ return {
746
+ name: 'camera',
747
+ status: 'warn',
748
+ message: 'No V4L2 camera devices found in /dev/video*',
749
+ };
750
+ }
751
+ catch {
752
+ return {
753
+ name: 'camera',
754
+ status: 'warn',
755
+ message: 'Cannot enumerate camera devices',
756
+ };
757
+ }
758
+ }
759
+ // Windows/macOS: check via agent status
760
+ try {
761
+ const status = await fetchStatus('/status');
762
+ const subsystemHealth = status.subsystemHealth;
763
+ if (subsystemHealth) {
764
+ const cameraHealth = subsystemHealth.CAMERA;
765
+ if (cameraHealth) {
766
+ const cameraStatus = cameraHealth.status;
767
+ return {
768
+ name: 'camera',
769
+ status: cameraStatus === 'ACTIVE' ? 'pass' : cameraStatus === 'ERROR' ? 'fail' : 'warn',
770
+ message: `Camera status: ${cameraStatus}`,
771
+ details: cameraHealth,
772
+ };
773
+ }
774
+ }
775
+ }
776
+ catch {
777
+ // Agent not reachable
778
+ }
779
+ return {
780
+ name: 'camera',
781
+ status: 'warn',
782
+ message: 'Cannot determine camera status (agent not reachable or not Linux)',
783
+ };
784
+ }
785
+ function checkAudio() {
786
+ // Linux: check for PulseAudio or ALSA
787
+ if (process.platform === 'linux') {
788
+ try {
789
+ // Check if PulseAudio is running
790
+ try {
791
+ child_process.execSync('pactl info 2>/dev/null', {
792
+ encoding: 'utf-8',
793
+ timeout: 3000,
794
+ });
795
+ return {
796
+ name: 'audio',
797
+ status: 'pass',
798
+ message: 'PulseAudio is running',
799
+ };
800
+ }
801
+ catch {
802
+ // PulseAudio not running, check ALSA
803
+ }
804
+ // Check ALSA devices
805
+ if (fs.existsSync('/proc/asound/cards')) {
806
+ const cards = fs.readFileSync('/proc/asound/cards', 'utf-8').trim();
807
+ if (cards.length > 0 && !cards.includes('--- no soundcards ---')) {
808
+ return {
809
+ name: 'audio',
810
+ status: 'pass',
811
+ message: 'ALSA audio devices detected',
812
+ details: { cards: cards.split('\n').slice(0, 4) },
813
+ };
814
+ }
815
+ }
816
+ return {
817
+ name: 'audio',
818
+ status: 'warn',
819
+ message: 'No audio devices detected',
820
+ };
821
+ }
822
+ catch {
823
+ return {
824
+ name: 'audio',
825
+ status: 'warn',
826
+ message: 'Cannot check audio subsystem',
827
+ };
828
+ }
829
+ }
830
+ // Non-Linux: basic check
831
+ return {
832
+ name: 'audio',
833
+ status: 'pass',
834
+ message: `Audio check skipped on ${process.platform} — relies on agent reporting`,
835
+ };
836
+ }
837
+ async function checkNetwork() {
838
+ return new Promise((resolve) => {
839
+ const req = https.get({
840
+ hostname: 'api.trillboards.com',
841
+ port: 443,
842
+ path: '/health/live',
843
+ timeout: 5000,
844
+ }, (res) => {
845
+ res.resume(); // drain
846
+ resolve({
847
+ name: 'network',
848
+ status: res.statusCode === 200 ? 'pass' : 'warn',
849
+ message: `Trillboards API reachable (HTTPS ${res.statusCode})`,
850
+ details: { statusCode: res.statusCode },
851
+ });
852
+ });
853
+ req.on('error', (err) => {
854
+ resolve({
855
+ name: 'network',
856
+ status: 'warn',
857
+ message: `Cannot reach Trillboards API: ${err.message}`,
858
+ });
859
+ });
860
+ req.on('timeout', () => {
861
+ req.destroy();
862
+ resolve({
863
+ name: 'network',
864
+ status: 'warn',
865
+ message: 'Trillboards API connection timed out (5s)',
866
+ });
867
+ });
868
+ });
869
+ }
870
+ function checkModels() {
871
+ const modelDirs = [
872
+ './models',
873
+ path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards', 'models'),
874
+ '/opt/trillboards/models',
875
+ ];
876
+ const expectedModels = ['blazeface.onnx', 'yamnet.onnx'];
877
+ const found = [];
878
+ const missing = [];
879
+ let searchedDir = null;
880
+ for (const dir of modelDirs) {
881
+ const resolved = path.resolve(dir);
882
+ if (!fs.existsSync(resolved))
883
+ continue;
884
+ searchedDir = resolved;
885
+ for (const model of expectedModels) {
886
+ const modelPath = path.join(resolved, model);
887
+ if (fs.existsSync(modelPath)) {
888
+ found.push(model);
889
+ }
890
+ }
891
+ if (found.length > 0)
892
+ break;
893
+ }
894
+ for (const model of expectedModels) {
895
+ if (!found.includes(model)) {
896
+ missing.push(model);
897
+ }
898
+ }
899
+ if (found.length === expectedModels.length) {
900
+ return {
901
+ name: 'models',
902
+ status: 'pass',
903
+ message: `All ${found.length} required models found`,
904
+ details: { found, directory: searchedDir },
905
+ };
906
+ }
907
+ if (found.length > 0) {
908
+ return {
909
+ name: 'models',
910
+ status: 'warn',
911
+ message: `Found ${found.length}/${expectedModels.length} models, missing: ${missing.join(', ')}`,
912
+ details: { found, missing, directory: searchedDir },
913
+ };
914
+ }
915
+ return {
916
+ name: 'models',
917
+ status: 'warn',
918
+ message: `No ONNX models found. Run: trillboards-edge download-models`,
919
+ details: { searchedDirs: modelDirs.map((d) => path.resolve(d)) },
920
+ };
921
+ }
922
+ function checkTemperature() {
923
+ if (process.platform !== 'linux') {
924
+ return {
925
+ name: 'temperature',
926
+ status: 'pass',
927
+ message: `Temperature check skipped on ${process.platform}`,
928
+ };
929
+ }
930
+ try {
931
+ const thermalZones = [
932
+ '/sys/class/thermal/thermal_zone0/temp',
933
+ '/sys/class/thermal/thermal_zone1/temp',
934
+ ];
935
+ for (const zone of thermalZones) {
936
+ if (fs.existsSync(zone)) {
937
+ const raw = fs.readFileSync(zone, 'utf-8').trim();
938
+ const tempC = parseInt(raw, 10) / 1000;
939
+ return {
940
+ name: 'temperature',
941
+ status: tempC > 85 ? 'fail' : tempC > 70 ? 'warn' : 'pass',
942
+ message: `CPU temperature: ${tempC.toFixed(1)}C`,
943
+ details: { temperatureC: round(tempC, 1), source: zone },
944
+ };
945
+ }
946
+ }
947
+ return {
948
+ name: 'temperature',
949
+ status: 'pass',
950
+ message: 'No thermal zones found — temperature monitoring unavailable',
951
+ };
952
+ }
953
+ catch {
954
+ return {
955
+ name: 'temperature',
956
+ status: 'pass',
957
+ message: 'Cannot read thermal sensor',
958
+ };
959
+ }
960
+ }
961
+ function readBufferFromDisk() {
962
+ // Try to read the signal buffer file directly from the data directory.
963
+ // This is a last resort when the agent is not running.
964
+ const dataDir = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards');
965
+ const sqlitePath = path.join(dataDir, 'signal_buffer.db');
966
+ const jsonPath = path.join(dataDir, 'signal_buffer.json');
967
+ if (fs.existsSync(jsonPath)) {
968
+ try {
969
+ const raw = fs.readFileSync(jsonPath, 'utf-8');
970
+ const data = JSON.parse(raw);
971
+ const signals = Array.isArray(data.signals) ? data.signals : [];
972
+ const timestamps = signals
973
+ .map((s) => s.timestamp)
974
+ .filter((t) => typeof t === 'number');
975
+ return {
976
+ content: [
977
+ {
978
+ type: 'text',
979
+ text: JSON.stringify({
980
+ source: 'disk-json',
981
+ agentRunning: false,
982
+ bufferedCount: signals.length,
983
+ capacity: 8640,
984
+ usagePercent: round((signals.length / 8640) * 100, 1),
985
+ oldestTimestampMs: timestamps.length > 0 ? Math.min(...timestamps) : null,
986
+ newestTimestampMs: timestamps.length > 0 ? Math.max(...timestamps) : null,
987
+ filePath: jsonPath,
988
+ }, null, 2),
989
+ },
990
+ ],
991
+ };
992
+ }
993
+ catch {
994
+ // Corrupted JSON file
995
+ }
996
+ }
997
+ if (fs.existsSync(sqlitePath)) {
998
+ return {
999
+ content: [
1000
+ {
1001
+ type: 'text',
1002
+ text: JSON.stringify({
1003
+ source: 'disk-sqlite',
1004
+ agentRunning: false,
1005
+ message: 'SQLite buffer exists but cannot be read without the agent running. ' +
1006
+ 'Start the edge agent to get live buffer statistics.',
1007
+ filePath: sqlitePath,
1008
+ fileSizeMb: round(fs.statSync(sqlitePath).size / (1024 * 1024), 2),
1009
+ }, null, 2),
1010
+ },
1011
+ ],
1012
+ };
1013
+ }
1014
+ return {
1015
+ content: [
1016
+ {
1017
+ type: 'text',
1018
+ text: JSON.stringify({
1019
+ source: 'none',
1020
+ agentRunning: false,
1021
+ bufferedCount: 0,
1022
+ message: 'No signal buffer found. The edge agent may not have been started yet.',
1023
+ }, null, 2),
1024
+ },
1025
+ ],
1026
+ };
1027
+ }
1028
+ // ─── Utility Functions ─────────────────────────────────────────────────────
1029
+ function asNumber(value, fallback) {
1030
+ if (typeof value === 'number' && !Number.isNaN(value))
1031
+ return value;
1032
+ if (typeof value === 'string') {
1033
+ const parsed = parseFloat(value);
1034
+ if (!Number.isNaN(parsed))
1035
+ return parsed;
1036
+ }
1037
+ return fallback;
1038
+ }
1039
+ function round(value, decimals) {
1040
+ const factor = Math.pow(10, decimals);
1041
+ return Math.round(value * factor) / factor;
1042
+ }
1043
+ function humanDuration(ms) {
1044
+ if (ms < 0)
1045
+ return '0s';
1046
+ if (ms < 1000)
1047
+ return `${ms}ms`;
1048
+ if (ms < 60000)
1049
+ return `${round(ms / 1000, 1)}s`;
1050
+ if (ms < 3600000)
1051
+ return `${round(ms / 60000, 1)}m`;
1052
+ if (ms < 86400000)
1053
+ return `${round(ms / 3600000, 1)}h`;
1054
+ return `${round(ms / 86400000, 1)}d`;
1055
+ }
1056
+ // ─── MCP Method Handlers ───────────────────────────────────────────────────
1057
+ function handleInitialize(id) {
1058
+ return {
1059
+ jsonrpc: '2.0',
1060
+ id,
1061
+ result: {
1062
+ protocolVersion: PROTOCOL_VERSION,
1063
+ capabilities: {
1064
+ tools: {},
1065
+ },
1066
+ serverInfo: {
1067
+ name: 'trillboards-edge-mcp',
1068
+ version: SERVER_VERSION,
1069
+ },
1070
+ },
1071
+ };
1072
+ }
1073
+ function handleToolsList(id) {
1074
+ return {
1075
+ jsonrpc: '2.0',
1076
+ id,
1077
+ result: {
1078
+ tools: TOOL_DEFINITIONS,
1079
+ },
1080
+ };
1081
+ }
1082
+ async function handleToolsCall(id, params) {
1083
+ if (!params || typeof params !== 'object') {
1084
+ return makeErrorResponse(id, INVALID_PARAMS, 'Missing params for tools/call');
1085
+ }
1086
+ const callParams = params;
1087
+ const toolName = callParams.name;
1088
+ const toolArgs = callParams.arguments ?? {};
1089
+ if (typeof toolName !== 'string') {
1090
+ return makeErrorResponse(id, INVALID_PARAMS, 'Missing "name" in tools/call params');
1091
+ }
1092
+ const toolDef = TOOL_DEFINITIONS.find((t) => t.name === toolName);
1093
+ if (!toolDef) {
1094
+ return makeErrorResponse(id, INVALID_PARAMS, `Unknown tool: "${toolName}". Available tools: ${TOOL_DEFINITIONS.map((t) => t.name).join(', ')}`);
1095
+ }
1096
+ try {
1097
+ let result;
1098
+ switch (toolName) {
1099
+ case 'get-device-status':
1100
+ result = await toolGetDeviceStatus();
1101
+ break;
1102
+ case 'get-audience-live':
1103
+ result = await toolGetAudienceLive();
1104
+ break;
1105
+ case 'configure-sensing':
1106
+ result = await toolConfigureSensing(toolArgs);
1107
+ break;
1108
+ case 'run-benchmark':
1109
+ result = await toolRunBenchmark(toolArgs);
1110
+ break;
1111
+ case 'diagnose-hardware':
1112
+ result = await toolDiagnoseHardware();
1113
+ break;
1114
+ case 'list-models':
1115
+ result = await toolListModels();
1116
+ break;
1117
+ case 'get-buffer-stats':
1118
+ result = await toolGetBufferStats();
1119
+ break;
1120
+ default:
1121
+ return makeErrorResponse(id, METHOD_NOT_FOUND, `Tool not implemented: ${toolName}`);
1122
+ }
1123
+ return {
1124
+ jsonrpc: '2.0',
1125
+ id,
1126
+ result,
1127
+ };
1128
+ }
1129
+ catch (err) {
1130
+ const message = err instanceof Error ? err.message : String(err);
1131
+ return {
1132
+ jsonrpc: '2.0',
1133
+ id,
1134
+ result: {
1135
+ content: [
1136
+ {
1137
+ type: 'text',
1138
+ text: `Error executing tool "${toolName}": ${message}`,
1139
+ },
1140
+ ],
1141
+ isError: true,
1142
+ },
1143
+ };
1144
+ }
1145
+ }
1146
+ // ─── JSON-RPC Response Helpers ─────────────────────────────────────────────
1147
+ function makeErrorResponse(id, code, message, data) {
1148
+ return {
1149
+ jsonrpc: '2.0',
1150
+ id,
1151
+ error: { code, message, ...(data !== undefined ? { data } : {}) },
1152
+ };
1153
+ }
1154
+ // ─── Message Router ────────────────────────────────────────────────────────
1155
+ async function handleMessage(request) {
1156
+ const { id, method, params } = request;
1157
+ // Notifications (no id) do not require a response per JSON-RPC spec.
1158
+ // MCP uses notifications for things like initialized, cancelled, etc.
1159
+ if (id === undefined || id === null) {
1160
+ // Handle known notifications silently
1161
+ switch (method) {
1162
+ case 'notifications/initialized':
1163
+ case 'notifications/cancelled':
1164
+ case 'initialized':
1165
+ process.stderr.write(`[trillboards-edge-mcp] Notification: ${method}\n`);
1166
+ return null; // No response for notifications
1167
+ default:
1168
+ process.stderr.write(`[trillboards-edge-mcp] Unknown notification: ${method}\n`);
1169
+ return null;
1170
+ }
1171
+ }
1172
+ switch (method) {
1173
+ case 'initialize':
1174
+ return handleInitialize(id);
1175
+ case 'tools/list':
1176
+ return handleToolsList(id);
1177
+ case 'tools/call':
1178
+ return await handleToolsCall(id, params);
1179
+ case 'ping':
1180
+ return { jsonrpc: '2.0', id, result: {} };
1181
+ default:
1182
+ return makeErrorResponse(id, METHOD_NOT_FOUND, `Unknown method: ${method}`);
1183
+ }
1184
+ }
1185
+ // ─── Main — stdio JSON-RPC transport ───────────────────────────────────────
1186
+ async function main() {
1187
+ process.stderr.write(`[trillboards-edge-mcp] MCP server v${SERVER_VERSION} starting (StatusServer: ${STATUS_HOST}:${STATUS_PORT})\n`);
1188
+ const rl = readline.createInterface({
1189
+ input: process.stdin,
1190
+ terminal: false,
1191
+ });
1192
+ let buffer = '';
1193
+ rl.on('line', async (line) => {
1194
+ buffer += line;
1195
+ // Guard against unbounded buffer growth
1196
+ if (buffer.length > MAX_BUFFER_SIZE) {
1197
+ process.stderr.write('[trillboards-edge-mcp] Buffer exceeded 10 MB -- resetting\n');
1198
+ buffer = '';
1199
+ return;
1200
+ }
1201
+ // Try to parse the accumulated buffer as JSON.
1202
+ // MCP clients send one JSON-RPC message per line.
1203
+ let request;
1204
+ try {
1205
+ request = JSON.parse(buffer);
1206
+ buffer = '';
1207
+ }
1208
+ catch {
1209
+ // Incomplete JSON — keep buffering across lines
1210
+ return;
1211
+ }
1212
+ // Validate JSON-RPC 2.0 structure
1213
+ if (request.jsonrpc !== '2.0') {
1214
+ const errorResp = makeErrorResponse(request.id ?? null, INVALID_REQUEST, 'Expected jsonrpc: "2.0"');
1215
+ process.stdout.write(JSON.stringify(errorResp) + '\n');
1216
+ return;
1217
+ }
1218
+ if (typeof request.method !== 'string') {
1219
+ const errorResp = makeErrorResponse(request.id ?? null, INVALID_REQUEST, 'Missing "method" field');
1220
+ process.stdout.write(JSON.stringify(errorResp) + '\n');
1221
+ return;
1222
+ }
1223
+ try {
1224
+ const response = await handleMessage(request);
1225
+ // Notifications return null — do not send a response
1226
+ if (response !== null) {
1227
+ process.stdout.write(JSON.stringify(response) + '\n');
1228
+ }
1229
+ }
1230
+ catch (err) {
1231
+ // Catch-all: never crash the server
1232
+ const message = err instanceof Error ? err.message : String(err);
1233
+ process.stderr.write(`[trillboards-edge-mcp] Unhandled error: ${message}\n`);
1234
+ const errorResp = makeErrorResponse(request.id ?? null, INTERNAL_ERROR, `Internal server error: ${message}`);
1235
+ process.stdout.write(JSON.stringify(errorResp) + '\n');
1236
+ }
1237
+ });
1238
+ rl.on('close', () => {
1239
+ process.stderr.write('[trillboards-edge-mcp] stdin closed, exiting\n');
1240
+ process.exit(0);
1241
+ });
1242
+ // Handle process signals gracefully
1243
+ process.on('SIGINT', () => {
1244
+ process.stderr.write('[trillboards-edge-mcp] SIGINT received, exiting\n');
1245
+ process.exit(0);
1246
+ });
1247
+ process.on('SIGTERM', () => {
1248
+ process.stderr.write('[trillboards-edge-mcp] SIGTERM received, exiting\n');
1249
+ process.exit(0);
1250
+ });
1251
+ // Prevent unhandled promise rejections from crashing the process
1252
+ process.on('unhandledRejection', (reason) => {
1253
+ const message = reason instanceof Error ? reason.message : String(reason);
1254
+ process.stderr.write(`[trillboards-edge-mcp] Unhandled rejection: ${message}\n`);
1255
+ });
1256
+ }
1257
+ // Auto-start when run directly
1258
+ if (require.main === module) {
1259
+ main().catch((err) => {
1260
+ process.stderr.write(`[trillboards-edge-mcp] Fatal error: ${err instanceof Error ? err.message : String(err)}\n`);
1261
+ process.exit(1);
1262
+ });
1263
+ }
1264
+ //# sourceMappingURL=mcp-server.js.map