@ntcore-ts/mcp 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,998 @@
1
+ import { BUILT_IN_STRUCT_TYPE_NAMES, getBuiltInDescriptor, unpack, NetworkTables, LogLevel } from '@ntcore-ts/client';
2
+ import { McpServer } from '@modelcontextprotocol/server';
3
+ import * as z from 'zod/v4';
4
+
5
+ /** Shared response helpers and payload caps for MCP tool results. */
6
+ const MAX_TOPICS = 500;
7
+ const MAX_SAMPLES_PER_TOPIC = 200;
8
+ const MAX_SUBSCRIBE_TOPICS = 100;
9
+ const MAX_GET_MULTIPLE = 50;
10
+ const DEFAULT_SUBSCRIBE_DURATION_S = 2;
11
+ const MAX_SUBSCRIBE_DURATION_S = 30;
12
+ function jsonResult(payload, isError = false) {
13
+ const structuredContent = payload !== null && typeof payload === 'object' && !Array.isArray(payload)
14
+ ? payload
15
+ : { value: payload };
16
+ const content = [{ type: 'text', text: JSON.stringify(payload, jsonReplacer, 2) }];
17
+ if (isError) {
18
+ return { content, structuredContent, isError: true };
19
+ }
20
+ return { content, structuredContent };
21
+ }
22
+ function jsonReplacer(_key, value) {
23
+ if (value instanceof Uint8Array) {
24
+ return {
25
+ __type: 'Uint8Array',
26
+ length: value.length,
27
+ hexPreview: Buffer.from(value.subarray(0, Math.min(32, value.length))).toString('hex'),
28
+ };
29
+ }
30
+ if (typeof value === 'bigint') {
31
+ return value.toString();
32
+ }
33
+ return value;
34
+ }
35
+ /** Convert a value into JSON-safe form for tool responses. */
36
+ function toJsonSafe(value) {
37
+ if (value === null || value === undefined)
38
+ return null;
39
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string')
40
+ return value;
41
+ if (typeof value === 'bigint')
42
+ return value.toString();
43
+ if (value instanceof Uint8Array) {
44
+ return {
45
+ __type: 'Uint8Array',
46
+ length: value.length,
47
+ hexPreview: Buffer.from(value.subarray(0, Math.min(32, value.length))).toString('hex'),
48
+ };
49
+ }
50
+ if (Array.isArray(value))
51
+ return value.map(toJsonSafe);
52
+ if (typeof value === 'object') {
53
+ const out = {};
54
+ for (const [k, v] of Object.entries(value)) {
55
+ out[k] = toJsonSafe(v);
56
+ }
57
+ return out;
58
+ }
59
+ return String(value);
60
+ }
61
+ /** Match topic paths against simple glob patterns (`*` = one segment, `**` = any depth). */
62
+ function matchGlob(path, pattern) {
63
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
64
+ const normalizedPattern = pattern.startsWith('/') ? pattern : `/${pattern}`;
65
+ const regex = globToRegExp(normalizedPattern);
66
+ return regex.test(normalizedPath);
67
+ }
68
+ function globToRegExp(pattern) {
69
+ let i = 0;
70
+ let out = '^';
71
+ while (i < pattern.length) {
72
+ const c = pattern[i];
73
+ if (c === '*') {
74
+ if (pattern[i + 1] === '*') {
75
+ out += '.*';
76
+ i += 2;
77
+ if (pattern[i] === '/')
78
+ i += 1;
79
+ }
80
+ else {
81
+ out += '[^/]*';
82
+ i += 1;
83
+ }
84
+ }
85
+ else if (c === '?') {
86
+ out += '[^/]';
87
+ i += 1;
88
+ }
89
+ else if ('\\.[]{}()+-^$|'.includes(c)) {
90
+ out += `\\${c}`;
91
+ i += 1;
92
+ }
93
+ else {
94
+ out += c;
95
+ i += 1;
96
+ }
97
+ }
98
+ out += '$';
99
+ return new RegExp(out);
100
+ }
101
+
102
+ const BUILTIN_STRUCT_TYPE_NAMES = [...BUILT_IN_STRUCT_TYPE_NAMES];
103
+ /** Resolve a typed topic handle from a NetworkTables wire type string. */
104
+ function resolveTopic(nt, name, type) {
105
+ switch (type) {
106
+ case 'boolean':
107
+ return nt.getBooleanTopic(name);
108
+ case 'double':
109
+ return nt.getDoubleTopic(name);
110
+ case 'int':
111
+ return nt.getIntegerTopic(name);
112
+ case 'float':
113
+ return nt.getFloatTopic(name);
114
+ case 'string':
115
+ return nt.getStringTopic(name);
116
+ case 'boolean[]':
117
+ return nt.getBooleanArrayTopic(name);
118
+ case 'double[]':
119
+ return nt.getDoubleArrayTopic(name);
120
+ case 'int[]':
121
+ return nt.getIntegerArrayTopic(name);
122
+ case 'float[]':
123
+ return nt.getFloatArrayTopic(name);
124
+ case 'string[]':
125
+ return nt.getStringArrayTopic(name);
126
+ case 'json':
127
+ return nt.getJsonTopic(name);
128
+ case 'raw':
129
+ return nt.getRawTopic(name);
130
+ }
131
+ if (type.startsWith('struct:')) {
132
+ const typeName = type.slice('struct:'.length);
133
+ return nt.getStructTopic(name, { typeName });
134
+ }
135
+ if (type.startsWith('proto:') || type === 'protobuf') {
136
+ return nt.getProtobufTopic(name);
137
+ }
138
+ return nt.getRawTopic(name);
139
+ }
140
+ function isStructOrProtobufType(type) {
141
+ return type.startsWith('struct:') || type.startsWith('proto:') || type === 'protobuf';
142
+ }
143
+
144
+ /** Decode a struct wire payload using built-in descriptors when possible. */
145
+ function decodeStructBytes(typeName, bytes) {
146
+ const isArray = typeName.endsWith('[]');
147
+ const base = isArray ? typeName.slice(0, -2) : typeName;
148
+ const desc = getBuiltInDescriptor(base);
149
+ if (!desc)
150
+ return null;
151
+ try {
152
+ if (isArray) {
153
+ const elemSize = desc.size;
154
+ if (bytes.length % elemSize !== 0)
155
+ return null;
156
+ const n = bytes.length / elemSize;
157
+ const arr = [];
158
+ for (let i = 0; i < n; i++) {
159
+ arr.push(unpack(new Uint8Array(bytes.buffer, bytes.byteOffset + i * elemSize, elemSize), desc));
160
+ }
161
+ return toJsonSafe(arr);
162
+ }
163
+ return toJsonSafe(unpack(bytes, desc));
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ function decodeInventoryValue(type, rawValue) {
170
+ if (rawValue == null)
171
+ return null;
172
+ if (rawValue instanceof Uint8Array) {
173
+ if (type.startsWith('struct:')) {
174
+ return decodeStructBytes(type.slice('struct:'.length), rawValue);
175
+ }
176
+ return toJsonSafe(rawValue);
177
+ }
178
+ return toJsonSafe(rawValue);
179
+ }
180
+ /** Parent prefix for a topic path, e.g. /MyTable/Gyro -> /MyTable/ */
181
+ function topicParentPrefix(topic) {
182
+ const idx = topic.lastIndexOf('/');
183
+ if (idx <= 0)
184
+ return '/';
185
+ return topic.slice(0, idx + 1);
186
+ }
187
+ async function snapshotTopic(nt, topic, settleMs = 400) {
188
+ const prefix = topicParentPrefix(topic);
189
+ let found = null;
190
+ const prefixTopic = nt.getPrefixTopic(prefix);
191
+ const subuid = prefixTopic.subscribe((value, params) => {
192
+ if (params.name === topic) {
193
+ found = { type: params.type, rawValue: value };
194
+ }
195
+ }, { immediateNotify: true, all: true });
196
+ await new Promise((r) => setTimeout(r, settleMs));
197
+ try {
198
+ prefixTopic.unsubscribe(subuid);
199
+ }
200
+ catch {
201
+ // ignore
202
+ }
203
+ return found;
204
+ }
205
+
206
+ /**
207
+ * Topic discovery cache.
208
+ *
209
+ * Uses short-lived prefix subscriptions for snapshots. A long-lived prefix `/`
210
+ * subscription prevents later typed topic subscriptions from receiving values
211
+ * (ntcore-ts / NT4 interaction), so we never leave a prefix sub open.
212
+ */
213
+ class TopicInventory {
214
+ constructor() {
215
+ this.topics = new Map();
216
+ this.nt = null;
217
+ this.started = false;
218
+ }
219
+ async start(nt, prefix = '/') {
220
+ this.nt = nt;
221
+ this.started = true;
222
+ await this.refresh(prefix, 400);
223
+ }
224
+ stop() {
225
+ this.nt = null;
226
+ this.topics.clear();
227
+ this.started = false;
228
+ }
229
+ get isStarted() {
230
+ return this.started;
231
+ }
232
+ async refresh(prefix = '/', settleMs = 300) {
233
+ if (!this.nt)
234
+ return;
235
+ const prefixTopic = this.nt.getPrefixTopic(prefix);
236
+ const subuid = prefixTopic.subscribe((value, params) => {
237
+ this.upsert(params, value);
238
+ }, { immediateNotify: true, all: true });
239
+ await new Promise((r) => setTimeout(r, settleMs));
240
+ try {
241
+ prefixTopic.unsubscribe(subuid);
242
+ }
243
+ catch {
244
+ // ignore
245
+ }
246
+ }
247
+ upsert(params, value) {
248
+ const existing = this.topics.get(params.name);
249
+ this.topics.set(params.name, {
250
+ name: params.name,
251
+ type: params.type,
252
+ id: params.id,
253
+ properties: params.properties ?? null,
254
+ lastValue: toJsonSafe(value),
255
+ rawValue: value,
256
+ lastChangedTime: existing?.lastChangedTime ?? null,
257
+ });
258
+ }
259
+ updateValue(name, value, lastChangedTime) {
260
+ const existing = this.topics.get(name);
261
+ if (!existing)
262
+ return;
263
+ existing.lastValue = toJsonSafe(value);
264
+ existing.rawValue = value;
265
+ if (lastChangedTime != null)
266
+ existing.lastChangedTime = lastChangedTime;
267
+ }
268
+ get(name) {
269
+ return this.topics.get(name);
270
+ }
271
+ list(prefix, limit = MAX_TOPICS) {
272
+ const all = [...this.topics.values()].sort((a, b) => a.name.localeCompare(b.name));
273
+ const filtered = prefix ? all.filter((t) => t.name.startsWith(prefix)) : all;
274
+ return filtered.slice(0, limit);
275
+ }
276
+ discoveredStructTypes() {
277
+ const names = new Set();
278
+ for (const topic of this.topics.values()) {
279
+ if (topic.type.startsWith('struct:')) {
280
+ const raw = topic.type.slice('struct:'.length);
281
+ names.add(raw.endsWith('[]') ? raw.slice(0, -2) : raw);
282
+ }
283
+ if (topic.name.startsWith('/.schema/struct:')) {
284
+ names.add(topic.name.slice('/.schema/struct:'.length));
285
+ }
286
+ }
287
+ return [...names].sort();
288
+ }
289
+ }
290
+
291
+ /** Stderr-only logging for stdio MCP servers (stdout is the protocol channel). */
292
+ function writeStderr(prefix, message, details) {
293
+ // Single-line messages only — multi-line / multi-arg console.error can confuse MCP hosts.
294
+ if (details && Object.keys(details).length > 0) {
295
+ console.error(`${prefix} ${message} ${JSON.stringify(details)}`);
296
+ }
297
+ else {
298
+ console.error(`${prefix} ${message}`);
299
+ }
300
+ }
301
+ function logInfo(message, details) {
302
+ writeStderr('[ntcore-ts-mcp]', message, details);
303
+ }
304
+ function logWarn(message, details) {
305
+ writeStderr('[ntcore-ts-mcp:warn]', message, details);
306
+ }
307
+
308
+ function isNumeric(value) {
309
+ return typeof value === 'number' && Number.isFinite(value);
310
+ }
311
+ function valuesEqual(a, b, epsilon) {
312
+ if (isNumeric(a) && isNumeric(b))
313
+ return Math.abs(a - b) <= epsilon;
314
+ return JSON.stringify(a) === JSON.stringify(b);
315
+ }
316
+ /**
317
+ * Collect samples via short-lived prefix subscriptions only.
318
+ * (Typed topic subscriptions do not receive values after a prefix sub has been used
319
+ * on the same NetworkTables instance — see inventory snapshot design.)
320
+ */
321
+ async function collectSamples(nt, options) {
322
+ const durationSeconds = Math.min(Math.max(options.durationSeconds ?? DEFAULT_SUBSCRIBE_DURATION_S, 0.1), MAX_SUBSCRIBE_DURATION_S);
323
+ const format = options.format ?? 'summary';
324
+ const changeOnly = options.changeOnly ?? false;
325
+ const epsilon = options.changeEpsilon ?? 1e-9;
326
+ const sampleIntervalMs = options.sampleIntervalMs ?? 0;
327
+ const typeMap = { ...(options.types ?? {}) };
328
+ const buckets = new Map();
329
+ let truncated = false;
330
+ const ensureBucket = (name, type) => {
331
+ let bucket = buckets.get(name);
332
+ if (!bucket) {
333
+ if (buckets.size >= MAX_SUBSCRIBE_TOPICS) {
334
+ truncated = true;
335
+ return null;
336
+ }
337
+ bucket = { type, points: [], lastRaw: undefined };
338
+ buckets.set(name, bucket);
339
+ }
340
+ return bucket;
341
+ };
342
+ const record = (name, type, value) => {
343
+ if (value == null)
344
+ return;
345
+ let decoded = value;
346
+ if (value instanceof Uint8Array) {
347
+ decoded = decodeInventoryValue(type, value);
348
+ if (decoded == null)
349
+ return;
350
+ }
351
+ const bucket = ensureBucket(name, type);
352
+ if (!bucket)
353
+ return;
354
+ if (changeOnly && bucket.lastRaw !== undefined && valuesEqual(bucket.lastRaw, decoded, epsilon)) {
355
+ return;
356
+ }
357
+ const now = Date.now();
358
+ if (sampleIntervalMs > 0 && bucket.points.length > 0) {
359
+ const lastT = bucket.points[bucket.points.length - 1].t;
360
+ if (now - lastT < sampleIntervalMs)
361
+ return;
362
+ }
363
+ if (bucket.points.length >= MAX_SAMPLES_PER_TOPIC) {
364
+ truncated = true;
365
+ return;
366
+ }
367
+ bucket.lastRaw = decoded;
368
+ bucket.points.push({ t: now, value: toJsonSafe(decoded) });
369
+ };
370
+ const prefixTopics = options.prefixes.map((prefix) => {
371
+ const pt = nt.getPrefixTopic(prefix);
372
+ const subuid = pt.subscribe((value, params) => {
373
+ const type = typeMap[params.name] ?? params.type;
374
+ typeMap[params.name] = type;
375
+ record(params.name, type, value);
376
+ }, { all: true, immediateNotify: true });
377
+ return { pt, subuid };
378
+ });
379
+ await new Promise((r) => setTimeout(r, durationSeconds * 1000));
380
+ for (const { pt, subuid } of prefixTopics) {
381
+ try {
382
+ pt.unsubscribe(subuid);
383
+ }
384
+ catch {
385
+ // ignore
386
+ }
387
+ }
388
+ const summaries = [];
389
+ const samples = {};
390
+ for (const [topic, bucket] of buckets) {
391
+ if (format === 'samples') {
392
+ samples[topic] = bucket.points;
393
+ }
394
+ const nums = bucket.points.map((p) => p.value).filter(isNumeric);
395
+ const first = bucket.points[0]?.value ?? null;
396
+ const last = bucket.points[bucket.points.length - 1]?.value ?? null;
397
+ const elapsedSec = bucket.points.length >= 2
398
+ ? (bucket.points[bucket.points.length - 1].t - bucket.points[0].t) / 1000
399
+ : durationSeconds;
400
+ const summary = {
401
+ topic,
402
+ type: bucket.type,
403
+ count: bucket.points.length,
404
+ first,
405
+ last,
406
+ rateHz: elapsedSec > 0 ? bucket.points.length / elapsedSec : undefined,
407
+ };
408
+ if (nums.length > 0) {
409
+ summary.min = Math.min(...nums);
410
+ summary.max = Math.max(...nums);
411
+ summary.mean = nums.reduce((a, b) => a + b, 0) / nums.length;
412
+ }
413
+ summaries.push(summary);
414
+ }
415
+ summaries.sort((a, b) => a.topic.localeCompare(b.topic));
416
+ return {
417
+ durationSeconds,
418
+ topicCount: buckets.size,
419
+ format,
420
+ ...(format === 'summary' ? { summaries } : { samples, summaries }),
421
+ truncated,
422
+ };
423
+ }
424
+
425
+ class NtSession {
426
+ constructor() {
427
+ this.nt = null;
428
+ }
429
+ get client() {
430
+ return this.nt;
431
+ }
432
+ getConnectionInfo() {
433
+ if (!this.nt) {
434
+ return { connected: false, connecting: false, uri: null, port: null, rttMs: -1 };
435
+ }
436
+ return {
437
+ connected: this.nt.isRobotConnected(),
438
+ connecting: this.nt.isRobotConnecting(),
439
+ uri: this.nt.getURI(),
440
+ port: this.nt.getPort(),
441
+ rttMs: this.nt.getRttMs(),
442
+ };
443
+ }
444
+ async connect(options) {
445
+ const port = options.port ?? 5810;
446
+ const timeoutMs = options.timeoutMs ?? 10000;
447
+ if (options.team == null && !options.host) {
448
+ throw new Error('Provide either team or host to connect.');
449
+ }
450
+ if (options.team != null && options.host) {
451
+ throw new Error('Provide either team or host, not both.');
452
+ }
453
+ this.disconnect();
454
+ if (options.team != null) {
455
+ this.nt = NetworkTables.getInstanceByTeam(options.team, port, options.platform ?? 'roborio');
456
+ logInfo('Connecting by team', { team: options.team, port, platform: options.platform ?? 'roborio' });
457
+ }
458
+ else {
459
+ this.nt = NetworkTables.getInstanceByURI(options.host, port);
460
+ logInfo('Connecting by host', { host: options.host, port });
461
+ }
462
+ await this.waitUntilConnected(timeoutMs);
463
+ return this.getConnectionInfo();
464
+ }
465
+ disconnect() {
466
+ if (this.nt) {
467
+ logInfo('Disconnecting', { uri: this.nt.getURI(), port: this.nt.getPort() });
468
+ try {
469
+ this.nt.close();
470
+ }
471
+ catch {
472
+ // ignore teardown races
473
+ }
474
+ this.nt = null;
475
+ }
476
+ }
477
+ requireClient() {
478
+ if (!this.nt) {
479
+ throw new Error('Not connected. Call nt_connect first (team or host/port).');
480
+ }
481
+ return this.nt;
482
+ }
483
+ waitUntilConnected(timeoutMs = 10000) {
484
+ const nt = this.requireClient();
485
+ return new Promise((resolve, reject) => {
486
+ if (nt.isRobotConnected()) {
487
+ resolve();
488
+ return;
489
+ }
490
+ const timer = setTimeout(() => {
491
+ remove();
492
+ reject(new Error(`Connection timeout after ${timeoutMs}ms`));
493
+ }, timeoutMs);
494
+ const remove = nt.addRobotConnectionListener((connected) => {
495
+ if (connected) {
496
+ clearTimeout(timer);
497
+ remove();
498
+ resolve();
499
+ }
500
+ }, true);
501
+ });
502
+ }
503
+ }
504
+
505
+ const DEFAULT_ALLOWLIST = ['/SmartDashboard/**', '/Tuning/**', '/MyTable/**'];
506
+ class WriteGate {
507
+ constructor(options) {
508
+ this.sessionEnabled = false;
509
+ this.wasToggledOff = false;
510
+ const fromEnv = process.env.NT_WRITE_ALLOWLIST?.split(',')
511
+ .map((s) => s.trim())
512
+ .filter(Boolean);
513
+ this.allowlist = options?.allowlist ?? (fromEnv?.length ? fromEnv : DEFAULT_ALLOWLIST);
514
+ this.requireLocalhost =
515
+ options?.requireLocalhost ??
516
+ (process.env.NT_REQUIRE_LOCALHOST === '1' || process.env.NT_REQUIRE_LOCALHOST === 'true');
517
+ this.envBootstrap =
518
+ options?.envAllowWrites ?? (process.env.NT_ALLOW_WRITES === '1' || process.env.NT_ALLOW_WRITES === 'true');
519
+ if (this.envBootstrap) {
520
+ this.sessionEnabled = true;
521
+ logInfo('Write gate enabled from NT_ALLOW_WRITES');
522
+ }
523
+ }
524
+ getStatus() {
525
+ return {
526
+ enabled: this.sessionEnabled,
527
+ source: this.sessionEnabled ? (this.envBootstrap && !this.wasToggledOff ? 'env' : 'session') : 'off',
528
+ allowlist: [...this.allowlist],
529
+ requireLocalhost: this.requireLocalhost,
530
+ };
531
+ }
532
+ setWriteMode(enabled, confirm) {
533
+ if (enabled && !confirm) {
534
+ throw new Error('Enabling writes requires confirm: true. Writes can change robot behavior.');
535
+ }
536
+ this.sessionEnabled = enabled;
537
+ if (!enabled)
538
+ this.wasToggledOff = true;
539
+ logInfo(`Write mode ${enabled ? 'ENABLED' : 'disabled'}`, { confirm });
540
+ return this.getStatus();
541
+ }
542
+ assertCanWrite(topic, host) {
543
+ if (!this.sessionEnabled) {
544
+ throw new Error('Writes are disabled. Call nt_set_write_mode({ enabled: true, confirm: true }) or set NT_ALLOW_WRITES=1.');
545
+ }
546
+ if (this.requireLocalhost) {
547
+ const h = (host ?? '').toLowerCase();
548
+ if (h !== 'localhost' && h !== '127.0.0.1' && h !== '::1') {
549
+ throw new Error(`NT_REQUIRE_LOCALHOST is set; refusing write to non-local host "${host ?? '(unknown)'}".`);
550
+ }
551
+ }
552
+ if (!this.allowlist.some((pattern) => matchGlob(topic, pattern))) {
553
+ throw new Error(`Topic "${topic}" is not on the write allowlist (${this.allowlist.join(', ')}).`);
554
+ }
555
+ }
556
+ auditWrite(topic, valueDigest) {
557
+ logWarn('WRITE', { topic, valueDigest });
558
+ }
559
+ }
560
+
561
+ function sleep(ms) {
562
+ return new Promise((resolve) => setTimeout(resolve, ms));
563
+ }
564
+ class NtMcpService {
565
+ constructor() {
566
+ this.session = new NtSession();
567
+ this.inventory = new TopicInventory();
568
+ this.writeGate = new WriteGate();
569
+ }
570
+ guide() {
571
+ const conn = this.session.getConnectionInfo();
572
+ const writes = this.writeGate.getStatus();
573
+ return jsonResult({
574
+ connected: conn.connected,
575
+ writeGate: writes,
576
+ connection: conn,
577
+ callFirst: 'nt_guide',
578
+ workflow: [
579
+ '1. Call nt_guide (this tool) if unfamiliar.',
580
+ '2. nt_connect with team or host/port (sim: host=127.0.0.1 or localhost, port=5810).',
581
+ '3. nt_list_topics to discover names/types.',
582
+ '4. Prefer nt_get_decoded for struct:/proto: topics; nt_get for scalars.',
583
+ '5. Use nt_subscribe for timed evidence (summary) instead of a single get when debugging dynamics.',
584
+ '6. Writes are off by default. Enable with nt_set_write_mode({ enabled: true, confirm: true }) or NT_ALLOW_WRITES=1.',
585
+ ],
586
+ tools: {
587
+ nt_connect: 'Open NT4 connection (team XOR host).',
588
+ nt_disconnect: 'Close connection and clear inventory.',
589
+ nt_connection_info: 'connected / RTT / endpoint.',
590
+ nt_list_topics: 'Cached topic inventory (prefix filter).',
591
+ nt_get: 'Single topic value (scalars/arrays/json; raw preview for bytes).',
592
+ nt_get_multiple: 'Batch get (max 50).',
593
+ nt_get_decoded: 'Decode struct/protobuf topics to JSON objects.',
594
+ nt_subscribe: 'Sample prefixes for N seconds; format=summary|samples.',
595
+ nt_list_struct_types: 'Built-in + discovered struct type names.',
596
+ nt_set_write_mode: 'Enable/disable session writes (confirm required to enable).',
597
+ nt_set: 'Publish one topic (gated + allowlist).',
598
+ nt_set_multiple: 'Publish several topics (gated + allowlist).',
599
+ },
600
+ safety: [
601
+ 'Never use NT writes as a substitute for Driver Station enable/disable.',
602
+ 'Default allowlist: /SmartDashboard/**, /Tuning/**, /MyTable/** (override with NT_WRITE_ALLOWLIST).',
603
+ 'Set NT_REQUIRE_LOCALHOST=1 to refuse writes to non-local hosts.',
604
+ 'One sample is not root cause — prefer nt_subscribe summaries for live evidence.',
605
+ ],
606
+ outOfScope: [
607
+ 'WPILOG / offline log analysis (use wpilog-mcp).',
608
+ 'roboRIO DS status / subsystems (use refinery-roborio-mcp).',
609
+ 'HTTP MCP transport, MCP Resources, persistent poll subscriptions.',
610
+ ],
611
+ });
612
+ }
613
+ async connect(options) {
614
+ try {
615
+ this.inventory.stop();
616
+ const info = await this.session.connect(options);
617
+ const nt = this.session.requireClient();
618
+ await this.inventory.start(nt, '/');
619
+ return jsonResult({ ...info, inventoryStarted: true });
620
+ }
621
+ catch (err) {
622
+ return jsonResult({ connected: false, error: err instanceof Error ? err.message : String(err) }, true);
623
+ }
624
+ }
625
+ disconnect() {
626
+ this.inventory.stop();
627
+ this.session.disconnect();
628
+ return jsonResult({ connected: false, disconnected: true });
629
+ }
630
+ connectionInfo() {
631
+ const info = this.session.getConnectionInfo();
632
+ return jsonResult({ ...info, writeGate: this.writeGate.getStatus() });
633
+ }
634
+ async listTopics(prefix, limit = MAX_TOPICS) {
635
+ try {
636
+ const nt = this.session.requireClient();
637
+ if (!this.inventory.isStarted)
638
+ await this.inventory.start(nt, '/');
639
+ else
640
+ await this.inventory.refresh(prefix ?? '/', 250);
641
+ const topics = this.inventory.list(prefix, limit);
642
+ return jsonResult({
643
+ connected: nt.isRobotConnected(),
644
+ prefix: prefix ?? '/',
645
+ count: topics.length,
646
+ topics: topics.map(({ name, type, id, properties }) => ({ name, type, id, properties })),
647
+ });
648
+ }
649
+ catch (err) {
650
+ return jsonResult({ connected: false, error: err instanceof Error ? err.message : String(err) }, true);
651
+ }
652
+ }
653
+ async get(topic) {
654
+ try {
655
+ const nt = this.session.requireClient();
656
+ await this.inventory.refresh(topic.includes('/') ? topic.slice(0, topic.lastIndexOf('/') + 1) || '/' : '/', 350);
657
+ let record = this.inventory.get(topic);
658
+ if (!record) {
659
+ const snap = await snapshotTopic(nt, topic, 400);
660
+ if (snap) {
661
+ this.inventory.upsert({ name: topic, id: -1, type: snap.type, properties: {} }, snap.rawValue);
662
+ record = this.inventory.get(topic);
663
+ }
664
+ }
665
+ if (!record) {
666
+ return jsonResult({
667
+ connected: nt.isRobotConnected(),
668
+ topic,
669
+ error: `Unknown topic "${topic}". Call nt_list_topics first so the inventory can learn its type.`,
670
+ }, true);
671
+ }
672
+ const value = decodeInventoryValue(record.type, record.rawValue) ?? record.lastValue;
673
+ return jsonResult({
674
+ connected: nt.isRobotConnected(),
675
+ topic,
676
+ type: record.type,
677
+ timestamp: record.lastChangedTime,
678
+ value,
679
+ });
680
+ }
681
+ catch (err) {
682
+ return jsonResult({
683
+ connected: this.session.getConnectionInfo().connected,
684
+ topic,
685
+ error: err instanceof Error ? err.message : String(err),
686
+ }, true);
687
+ }
688
+ }
689
+ async getMultiple(topics) {
690
+ const slice = topics.slice(0, MAX_GET_MULTIPLE);
691
+ const results = [];
692
+ for (const topic of slice) {
693
+ results.push((await this.get(topic)).structuredContent);
694
+ }
695
+ return jsonResult({
696
+ connected: this.session.getConnectionInfo().connected,
697
+ count: results.length,
698
+ truncated: topics.length > MAX_GET_MULTIPLE,
699
+ results,
700
+ });
701
+ }
702
+ async getDecoded(topic, typeHint) {
703
+ try {
704
+ const nt = this.session.requireClient();
705
+ await this.inventory.refresh(topic.includes('/') ? topic.slice(0, topic.lastIndexOf('/') + 1) || '/' : '/', 400);
706
+ let record = this.inventory.get(topic);
707
+ if (!record) {
708
+ const snap = await snapshotTopic(nt, topic, 500);
709
+ if (snap) {
710
+ this.inventory.upsert({ name: topic, id: -1, type: snap.type, properties: {} }, snap.rawValue);
711
+ record = this.inventory.get(topic);
712
+ }
713
+ }
714
+ const type = typeHint ?? record?.type;
715
+ if (!type) {
716
+ return jsonResult({
717
+ connected: nt.isRobotConnected(),
718
+ topic,
719
+ error: `Unknown topic type for "${topic}". Pass typeHint or call nt_list_topics first.`,
720
+ }, true);
721
+ }
722
+ if (!isStructOrProtobufType(type) && !(typeHint && isStructOrProtobufType(typeHint))) {
723
+ return jsonResult({
724
+ connected: nt.isRobotConnected(),
725
+ topic,
726
+ type,
727
+ error: `Topic type "${type}" is not struct/protobuf. Use nt_get instead, or pass typeHint like "struct:Pose2d".`,
728
+ }, true);
729
+ }
730
+ const wireType = typeHint && isStructOrProtobufType(typeHint) ? typeHint : type;
731
+ const raw = record?.rawValue;
732
+ let value = decodeInventoryValue(wireType, raw);
733
+ // Fallback: short prefix snapshot dedicated to this topic
734
+ if (value == null ||
735
+ (raw instanceof Uint8Array && value && typeof value === 'object' && '__type' in value)) {
736
+ const snap = await snapshotTopic(nt, topic, 500);
737
+ if (snap)
738
+ value = decodeInventoryValue(snap.type.startsWith('struct:') ? snap.type : wireType, snap.rawValue);
739
+ }
740
+ if (value == null) {
741
+ return jsonResult({
742
+ connected: nt.isRobotConnected(),
743
+ topic,
744
+ type: wireType,
745
+ error: 'Decoded value is null. For custom structs, ensure the schema is published; built-in geometry types decode from inventory snapshots.',
746
+ }, true);
747
+ }
748
+ return jsonResult({
749
+ connected: nt.isRobotConnected(),
750
+ topic,
751
+ type: wireType,
752
+ timestamp: record?.lastChangedTime ?? null,
753
+ value,
754
+ });
755
+ }
756
+ catch (err) {
757
+ return jsonResult({
758
+ connected: this.session.getConnectionInfo().connected,
759
+ topic,
760
+ error: err instanceof Error ? err.message : String(err),
761
+ }, true);
762
+ }
763
+ }
764
+ listStructTypes() {
765
+ const discovered = this.inventory.discoveredStructTypes();
766
+ return jsonResult({
767
+ connected: this.session.getConnectionInfo().connected,
768
+ builtin: BUILTIN_STRUCT_TYPE_NAMES,
769
+ discovered,
770
+ });
771
+ }
772
+ async subscribe(options) {
773
+ try {
774
+ const nt = this.session.requireClient();
775
+ const types = {};
776
+ for (const t of this.inventory.list()) {
777
+ types[t.name] = t.type;
778
+ }
779
+ const result = await collectSamples(nt, { ...options, types: { ...types, ...options.types } });
780
+ return jsonResult({ connected: nt.isRobotConnected(), ...result });
781
+ }
782
+ catch (err) {
783
+ return jsonResult({
784
+ connected: this.session.getConnectionInfo().connected,
785
+ error: err instanceof Error ? err.message : String(err),
786
+ }, true);
787
+ }
788
+ }
789
+ setWriteMode(enabled, confirm) {
790
+ try {
791
+ const status = this.writeGate.setWriteMode(enabled, confirm);
792
+ return jsonResult({ connected: this.session.getConnectionInfo().connected, writeGate: status });
793
+ }
794
+ catch (err) {
795
+ return jsonResult({
796
+ connected: this.session.getConnectionInfo().connected,
797
+ error: err instanceof Error ? err.message : String(err),
798
+ }, true);
799
+ }
800
+ }
801
+ async set(topic, value, expectedType) {
802
+ try {
803
+ const nt = this.session.requireClient();
804
+ const host = nt.getURI();
805
+ this.writeGate.assertCanWrite(topic, host);
806
+ const record = this.inventory.get(topic);
807
+ const type = expectedType ?? record?.type;
808
+ if (!type) {
809
+ throw new Error(`Cannot determine type for "${topic}". Pass expected_type or ensure the topic is announced (nt_list_topics).`);
810
+ }
811
+ if (expectedType && record?.type && expectedType !== record.type) {
812
+ throw new Error(`Type mismatch: expected_type=${expectedType} but announced type=${record.type}`);
813
+ }
814
+ const handle = resolveTopic(nt, topic, type);
815
+ await handle.publish({ retained: true });
816
+ // Allow publish announce to settle (server rejects set before publish is registered)
817
+ await sleep(50);
818
+ handle.setValue(value);
819
+ this.writeGate.auditWrite(topic, JSON.stringify(toJsonSafe(value)).slice(0, 200));
820
+ this.inventory.updateValue(topic, value);
821
+ logInfo('Published', { topic, type });
822
+ return jsonResult({
823
+ connected: nt.isRobotConnected(),
824
+ topic,
825
+ type,
826
+ published: true,
827
+ value: toJsonSafe(value),
828
+ });
829
+ }
830
+ catch (err) {
831
+ return jsonResult({
832
+ connected: this.session.getConnectionInfo().connected,
833
+ topic,
834
+ error: err instanceof Error ? err.message : String(err),
835
+ }, true);
836
+ }
837
+ }
838
+ async setMultiple(updates) {
839
+ const results = [];
840
+ for (const u of updates) {
841
+ results.push((await this.set(u.topic, u.value, u.expected_type)).structuredContent);
842
+ }
843
+ const anyError = results.some((r) => typeof r.error === 'string');
844
+ return jsonResult({
845
+ connected: this.session.getConnectionInfo().connected,
846
+ count: results.length,
847
+ results,
848
+ }, anyError);
849
+ }
850
+ }
851
+
852
+ /** Build an MCP server exposing NetworkTables tools. */
853
+ function createNtMcpServer(options) {
854
+ // Keep client logging off when serving over stdio (stdout = JSON-RPC).
855
+ NetworkTables.setLogLevel(LogLevel.SILENT);
856
+ const service = options?.service ?? new NtMcpService();
857
+ const server = new McpServer({ name: 'ntcore-ts', version: '1.0.0-beta.2' });
858
+ server.registerTool('nt_guide', {
859
+ title: 'NetworkTables MCP guide',
860
+ description: 'Call this first if unfamiliar. Explains workflow, tool choice (nt_get vs nt_get_decoded vs nt_subscribe), write-gate status, and safety rules. Read-only.',
861
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
862
+ }, async () => service.guide());
863
+ server.registerTool('nt_connect', {
864
+ title: 'Connect to NetworkTables',
865
+ description: 'Connect to an NT4 server. Provide either team (mDNS/team IP) OR host (e.g. localhost for sim). Default port 5810. Starts topic inventory. Prefer this before list/get/subscribe.',
866
+ inputSchema: z.object({
867
+ team: z.number().int().positive().optional().describe('FRC team number (mutually exclusive with host)'),
868
+ host: z
869
+ .string()
870
+ .optional()
871
+ .describe('Hostname or IP (e.g. localhost, 127.0.0.1). Mutually exclusive with team'),
872
+ port: z.number().int().positive().optional().describe('NT4 port (default 5810)'),
873
+ platform: z
874
+ .enum(['roborio', 'systemcore'])
875
+ .optional()
876
+ .describe('Address resolution when using team (default roborio)'),
877
+ timeout_ms: z.number().int().positive().optional().describe('Connection wait timeout in ms (default 10000)'),
878
+ }),
879
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
880
+ }, async (args) => service.connect({
881
+ team: args.team,
882
+ host: args.host,
883
+ port: args.port,
884
+ platform: args.platform,
885
+ timeoutMs: args.timeout_ms,
886
+ }));
887
+ server.registerTool('nt_disconnect', {
888
+ title: 'Disconnect from NetworkTables',
889
+ description: 'Close the NT connection and clear the topic inventory cache.',
890
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
891
+ }, async () => service.disconnect());
892
+ server.registerTool('nt_connection_info', {
893
+ title: 'Connection info',
894
+ description: 'Return connected/connecting flags, endpoint, RTT, and write-gate status. Read-only.',
895
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
896
+ }, async () => service.connectionInfo());
897
+ server.registerTool('nt_list_topics', {
898
+ title: 'List NetworkTables topics',
899
+ description: 'List announced topics from the inventory cache (name, type, properties). Filter with prefix (e.g. /MyTable/). Call nt_connect first. Prefer over guessing topic names. Read-only.',
900
+ inputSchema: z.object({
901
+ prefix: z.string().optional().describe('Only topics starting with this prefix'),
902
+ limit: z.number().int().positive().max(500).optional().describe('Max topics to return (default 500)'),
903
+ }),
904
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
905
+ }, async (args) => await service.listTopics(args.prefix, args.limit));
906
+ server.registerTool('nt_get', {
907
+ title: 'Get topic value',
908
+ description: 'Read one topic value. Best for scalars/arrays/json. For struct:/proto: topics prefer nt_get_decoded. Topic must be known via nt_list_topics. Read-only.',
909
+ inputSchema: z.object({
910
+ topic: z.string().describe('Full topic name, e.g. /MyTable/Gyro'),
911
+ }),
912
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
913
+ }, async (args) => service.get(args.topic));
914
+ server.registerTool('nt_get_multiple', {
915
+ title: 'Get multiple topic values',
916
+ description: 'Batch nt_get for up to 50 topics. Read-only.',
917
+ inputSchema: z.object({
918
+ topics: z.array(z.string()).min(1).max(50).describe('Topic names to read'),
919
+ }),
920
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
921
+ }, async (args) => service.getMultiple(args.topics));
922
+ server.registerTool('nt_get_decoded', {
923
+ title: 'Get decoded struct/protobuf topic',
924
+ description: 'Decode a struct: or proto: topic into a JSON object using @ntcore-ts/client. Prefer this over nt_get for Pose2d and other structured types. Optional type_hint like struct:Pose2d. Read-only.',
925
+ inputSchema: z.object({
926
+ topic: z.string().describe('Full topic name'),
927
+ type_hint: z.string().optional().describe('Override wire type, e.g. struct:Pose2d or proto:Pose2d'),
928
+ }),
929
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
930
+ }, async (args) => service.getDecoded(args.topic, args.type_hint));
931
+ server.registerTool('nt_list_struct_types', {
932
+ title: 'List struct types',
933
+ description: 'List built-in WPILib geometry struct types and any discovered from /.schema/struct:* or live topics. Read-only.',
934
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
935
+ }, async () => service.listStructTypes());
936
+ server.registerTool('nt_subscribe', {
937
+ title: 'Timed subscribe / sample',
938
+ description: 'Sample topic updates under prefixes for duration_seconds. Use format=summary for min/max/mean/last/rate (preferred evidence). format=samples returns capped point lists. Prefer over repeated nt_get for dynamics. Read-only.',
939
+ inputSchema: z.object({
940
+ prefixes: z.array(z.string()).min(1).max(20).describe('Topic prefixes to sample (e.g. ["/MyTable/"])'),
941
+ duration_seconds: z.number().positive().max(30).optional().describe('How long to sample (default 2, max 30)'),
942
+ sample_interval_ms: z
943
+ .number()
944
+ .nonnegative()
945
+ .optional()
946
+ .describe('Min ms between stored samples per topic (0 = every change)'),
947
+ change_only: z.boolean().optional().describe('Skip numeric changes at or below change_epsilon'),
948
+ change_epsilon: z.number().optional().describe('Epsilon for change_only numerics (default 1e-9)'),
949
+ format: z.enum(['summary', 'samples']).optional().describe('summary (default) or samples'),
950
+ }),
951
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
952
+ }, async (args) => service.subscribe({
953
+ prefixes: args.prefixes,
954
+ durationSeconds: args.duration_seconds,
955
+ sampleIntervalMs: args.sample_interval_ms,
956
+ changeOnly: args.change_only,
957
+ changeEpsilon: args.change_epsilon,
958
+ format: args.format,
959
+ }));
960
+ server.registerTool('nt_set_write_mode', {
961
+ title: 'Enable or disable NT writes',
962
+ description: 'Session write gate. Enabling REQUIRES confirm=true. Also enabled when NT_ALLOW_WRITES=1 at process start. Does not bypass path allowlist. Destructive.',
963
+ inputSchema: z.object({
964
+ enabled: z.boolean().describe('true to allow nt_set / nt_set_multiple'),
965
+ confirm: z.boolean().optional().describe('Must be true when enabling writes'),
966
+ }),
967
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
968
+ }, async (args) => service.setWriteMode(args.enabled, args.confirm ?? false));
969
+ server.registerTool('nt_set', {
970
+ title: 'Set / publish a topic value',
971
+ description: 'Publish a value to one topic. Fails if write gate is off, topic is outside NT_WRITE_ALLOWLIST, or types mismatch. Prefer expected_type when known. Destructive — can affect robot behavior. Not for DS enable.',
972
+ inputSchema: z.object({
973
+ topic: z.string().describe('Full topic name'),
974
+ value: z.unknown().describe('Value to publish (JSON-compatible for scalars/structs)'),
975
+ expected_type: z.string().optional().describe('NT type string, e.g. double or struct:Pose2d'),
976
+ }),
977
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
978
+ }, async (args) => await service.set(args.topic, args.value, args.expected_type));
979
+ server.registerTool('nt_set_multiple', {
980
+ title: 'Set / publish multiple topics',
981
+ description: 'Batch nt_set. Same write-gate and allowlist rules. Destructive.',
982
+ inputSchema: z.object({
983
+ updates: z
984
+ .array(z.object({
985
+ topic: z.string(),
986
+ value: z.unknown(),
987
+ expected_type: z.string().optional(),
988
+ }))
989
+ .min(1)
990
+ .max(50),
991
+ }),
992
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
993
+ }, async (args) => await service.setMultiple(args.updates));
994
+ return server;
995
+ }
996
+
997
+ export { BUILTIN_STRUCT_TYPE_NAMES, NtMcpService, NtSession, TopicInventory, WriteGate, collectSamples, createNtMcpServer, jsonResult, matchGlob, resolveTopic, toJsonSafe };
998
+ //# sourceMappingURL=index.esm.js.map