@mmmbuto/nexuscrew 0.8.16 → 0.8.17

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/server.js CHANGED
@@ -11,15 +11,22 @@
11
11
  // Hand-rolled minimale, zero dipendenze SDK (stile del repo). Fail-closed:
12
12
  // garbage in input non crasha MAI il processo — risponde un errore JSON-RPC.
13
13
  // Niente log su stdout (corromperebbe il canale): diagnostica su stderr.
14
+ //
15
+ // Questo modulo e' responsabile SOLO di: config/token/API transport, framing
16
+ // JSON-RPC, initialize/ping/tools/list/tools/call, parsing righe, draining e
17
+ // startMcp. Il registro TOOLS (nomi/schemi/handler/identity gate) vive in
18
+ // `./tools.js`; gli helper cella/deck/topologia (directory, route, payload)
19
+ // vivono in `./cells.js`. Entrambi sono re-esportati per compatibilita'.
14
20
  const readline = require('node:readline');
15
21
  const crypto = require('node:crypto');
16
22
  const os = require('node:os');
17
- const path = require('node:path');
18
23
  const { execFile } = require('node:child_process');
19
24
  const { loadConfig } = require('../config.js');
20
25
  const { readTokenSafe } = require('../auth/token.js');
21
26
  const { isValidSession } = require('../files/store.js');
22
27
  const VERSION = require('../../package.json').version;
28
+ const { TOOLS } = require('./tools.js');
29
+ const cells = require('./cells.js');
23
30
 
24
31
  // Versione protocollo di fallback se il client non ne dichiara una valida.
25
32
  const PROTOCOL_FALLBACK = '2025-03-26';
@@ -53,426 +60,6 @@ function resolveSession({ env, tmuxBin, execFileImpl }) {
53
60
  });
54
61
  }
55
62
 
56
- // --- helper input tool (fail-closed) ------------------------------------------
57
- function argString(args, key, { required = false, max = 4096 } = {}) {
58
- const v = args[key];
59
- if (v === undefined || v === null) {
60
- if (required) throw new Error(`parametro "${key}" obbligatorio`);
61
- return undefined;
62
- }
63
- if (typeof v !== 'string') throw new Error(`parametro "${key}" deve essere una stringa`);
64
- if (required && !v.trim()) throw new Error(`parametro "${key}" non puo' essere vuoto`);
65
- if (v.length > max) throw new Error(`parametro "${key}" troppo lungo (max ${max})`);
66
- return v;
67
- }
68
-
69
- function requireSession(session, tool) {
70
- if (session) return session;
71
- throw new Error(
72
- `${tool}: sessione tmux non identificata — serve $TMUX (dentro tmux) o NEXUSCREW_MCP_SESSION`,
73
- );
74
- }
75
-
76
- // Deck layout is stored column-major, while the UI is read row-major. Preserve
77
- // the visual order so an agent sees the same neighbourhood as the operator.
78
- function orderedDeckMembers(deck) {
79
- const columns = deck && deck.layout && Array.isArray(deck.layout.columns)
80
- ? deck.layout.columns : [];
81
- const rows = Math.max(0, ...columns.map((column) => (
82
- column && Array.isArray(column.tiles) ? column.tiles.length : 0
83
- )));
84
- const out = [];
85
- for (let row = 0; row < rows; row += 1) {
86
- for (const column of columns) {
87
- const tile = column && Array.isArray(column.tiles) ? column.tiles[row] : null;
88
- if (!tile || typeof tile.session !== 'string' || !tile.session) continue;
89
- const member = { tmuxSession: tile.session };
90
- if (typeof tile.node === 'string' && tile.node) member.node = tile.node;
91
- if (typeof tile.ownerId === 'string' && NODE_ID_RE.test(tile.ownerId)) member.ownerId = tile.ownerId;
92
- out.push(member);
93
- }
94
- }
95
- return out;
96
- }
97
-
98
- const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
99
- const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
100
- const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
101
- function fleetStatusPath(node) {
102
- if (!node) return '/api/fleet/status';
103
- const parts = String(node).split('/');
104
- if (!parts.length || parts.some((part) => !NODE_PART_RE.test(part))
105
- || new Set(parts).size !== parts.length) return null;
106
- return `/api/route/${parts.map(encodeURIComponent).join('/')}/_/fleet/status`;
107
- }
108
-
109
- function fleetCellsBySession(payload) {
110
- const out = new Map();
111
- if (!payload || payload.available !== true || !Array.isArray(payload.cells)) return out;
112
- for (const cell of payload.cells) {
113
- if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession
114
- || typeof cell.cell !== 'string' || !cell.cell) continue;
115
- out.set(cell.tmuxSession, cell.cell);
116
- }
117
- return out;
118
- }
119
-
120
- function routePath(route, resource) {
121
- if (!Array.isArray(route) || !route.length || route.length > 4
122
- || route.some((part) => !NODE_PART_RE.test(part)) || new Set(route).size !== route.length) return null;
123
- return `/api/route/${route.map(encodeURIComponent).join('/')}/_/${resource}`;
124
- }
125
-
126
- function topologyOwners(payload) {
127
- const out = [];
128
- const seen = new Set();
129
- for (const node of (payload && Array.isArray(payload.nodes) ? payload.nodes : [])) {
130
- if (!node || !NODE_ID_RE.test(String(node.instanceId || '')) || seen.has(node.instanceId)
131
- || !Array.isArray(node.route) || !routePath(node.route, 'decks')) continue;
132
- seen.add(node.instanceId);
133
- out.push({
134
- instanceId: node.instanceId,
135
- route: [...node.route],
136
- label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
137
- stale: node.stale === true,
138
- });
139
- }
140
- return out;
141
- }
142
-
143
- function memberOwnerId(member, deckOwner, ownerTopology) {
144
- if (member.ownerId && NODE_ID_RE.test(member.ownerId)) return member.ownerId;
145
- if (!member.node) return deckOwner.instanceId;
146
- const found = ownerTopology.find((node) => Array.isArray(node.route) && node.route.join('/') === member.node);
147
- return found ? found.instanceId : null;
148
- }
149
-
150
- function parseCellTarget(value) {
151
- if (typeof value !== 'string') return null;
152
- const split = value.indexOf(':');
153
- if (split < 16) return null;
154
- const instanceId = value.slice(0, split);
155
- const cell = value.slice(split + 1);
156
- return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
157
- }
158
-
159
- function normalizeCellPayload(payload, owner, callerSession = null) {
160
- if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
161
- const route = owner.route.length ? owner.route.join('/') : 'local';
162
- const seen = new Set();
163
- const out = [];
164
- for (const raw of payload.cells) {
165
- if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
166
- || typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
167
- || seen.has(raw.cell)) continue;
168
- seen.add(raw.cell);
169
- out.push({
170
- id: `${owner.instanceId}:${raw.cell}`,
171
- instanceId: owner.instanceId,
172
- owner: owner.label,
173
- route,
174
- cell: raw.cell,
175
- tmuxSession: raw.tmuxSession,
176
- engine: typeof raw.engine === 'string' ? raw.engine : '',
177
- model: typeof raw.model === 'string' ? raw.model : '',
178
- active: raw.active === true,
179
- canReceive: raw.canReceive === true,
180
- lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
181
- self: owner.route.length === 0 && callerSession === raw.tmuxSession,
182
- });
183
- }
184
- return out;
185
- }
186
-
187
- async function readCellDirectory(ctx, callerSession = null) {
188
- const [config, topology] = await Promise.all([
189
- ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
190
- ]);
191
- const localId = String(config && config.instanceId || '');
192
- if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
193
- const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
194
- ...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
195
- const cells = [];
196
- const unavailable = [];
197
- await Promise.all(owners.map(async (owner) => {
198
- const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
199
- if (!apiPath) return;
200
- try {
201
- cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
202
- } catch (_) {
203
- unavailable.push({ instanceId: owner.instanceId, owner: owner.label,
204
- route: owner.route.length ? owner.route.join('/') : 'local' });
205
- }
206
- }));
207
- cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
208
- : a.route.localeCompare(b.route)) || a.cell.localeCompare(b.cell));
209
- unavailable.sort((a, b) => a.route.localeCompare(b.route));
210
- return { nodeId: localId, cells, unavailable };
211
- }
212
-
213
- // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
214
- // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
215
- const TOOLS = [
216
- {
217
- name: 'nc_notify',
218
- description: 'Invia una notifica all\'operatore via NexusCrew (UI aperte + web push). Usala per esiti, avvisi e richieste di attenzione non bloccanti.',
219
- inputSchema: {
220
- type: 'object',
221
- properties: {
222
- title: { type: 'string', description: 'titolo breve della notifica' },
223
- body: { type: 'string', description: 'dettaglio opzionale' },
224
- urgency: { type: 'string', enum: ['normal', 'high'], description: 'default normal' },
225
- },
226
- required: ['title'],
227
- },
228
- async handler(args, ctx) {
229
- const title = argString(args, 'title', { required: true, max: 200 });
230
- const body = argString(args, 'body', { max: 2000 });
231
- const urgency = argString(args, 'urgency', { max: 16 });
232
- if (urgency !== undefined && urgency !== 'normal' && urgency !== 'high') {
233
- throw new Error('urgency deve essere "normal" o "high"');
234
- }
235
- const session = await ctx.session();
236
- const payload = { title, ...(body ? { body } : {}), ...(urgency ? { urgency } : {}) };
237
- if (session) payload.session = session;
238
- const j = await ctx.api('POST', '/api/notify', payload);
239
- return { delivered: j.delivered };
240
- },
241
- },
242
- {
243
- name: 'nc_ask',
244
- description: 'Pone una domanda all\'operatore e ritorna SUBITO (non bloccare in attesa): la risposta arrivera\' come messaggio incollato in questa sessione tmux, prefissato [human reply · ask#<id>] per default.',
245
- inputSchema: {
246
- type: 'object',
247
- properties: {
248
- question: { type: 'string', description: 'la domanda per l\'operatore' },
249
- options: { type: 'array', items: { type: 'string' }, description: 'opzioni di risposta rapida (max 8)' },
250
- },
251
- required: ['question'],
252
- },
253
- async handler(args, ctx) {
254
- const question = argString(args, 'question', { required: true, max: 2000 });
255
- if (args.options !== undefined && !Array.isArray(args.options)) {
256
- throw new Error('options deve essere un array di stringhe');
257
- }
258
- const session = requireSession(await ctx.session(), 'nc_ask');
259
- const j = await ctx.api('POST', '/api/asks', {
260
- question, ...(args.options ? { options: args.options } : {}), session,
261
- });
262
- return {
263
- askId: j.id,
264
- note: 'la risposta dell\'operatore arrivera\' come messaggio incollato in questa sessione tmux',
265
- };
266
- },
267
- },
268
- {
269
- name: 'nc_send_file',
270
- description: 'Consegna un file all\'operatore: lo copia nell\'outbox NexusCrew di questa sessione (badge + notifica automatici). Path assoluto sotto HOME.',
271
- inputSchema: {
272
- type: 'object',
273
- properties: {
274
- path: { type: 'string', description: 'path assoluto del file (sotto HOME)' },
275
- caption: { type: 'string', description: 'didascalia opzionale' },
276
- },
277
- required: ['path'],
278
- },
279
- async handler(args, ctx) {
280
- const p = argString(args, 'path', { required: true, max: 4096 });
281
- const caption = argString(args, 'caption', { max: 500 });
282
- if (!path.isAbsolute(p)) throw new Error('path deve essere assoluto');
283
- // Pre-check locale (errore immediato e chiaro); la validazione autoritativa
284
- // (realpath sotto HOME, file regolare) la rifa' comunque il server.
285
- if (!ctx.fileExists(p)) throw new Error(`file inesistente: ${p}`);
286
- const home = ctx.home();
287
- if (p !== home && !p.startsWith(home + path.sep)) throw new Error('path fuori da HOME');
288
- const session = requireSession(await ctx.session(), 'nc_send_file');
289
- const j = await ctx.api('POST', '/api/files/outbox', {
290
- session, path: p, ...(caption ? { caption } : {}),
291
- });
292
- return { name: j.name, box: 'outbox' };
293
- },
294
- },
295
- {
296
- name: 'nc_status',
297
- description: 'Stato read-only di NexusCrew: sessioni tmux vive e celle della flotta (se abilitata).',
298
- inputSchema: { type: 'object', properties: {} },
299
- annotations: { readOnlyHint: true },
300
- async handler(_args, ctx) {
301
- const s = await ctx.api('GET', '/api/sessions');
302
- const sessions = (Array.isArray(s.sessions) ? s.sessions : [])
303
- .map((x) => ({ name: x.name, active: !!x.attached }));
304
- let fleet = null;
305
- try {
306
- const f = await ctx.api('GET', '/api/fleet/status');
307
- if (f && f.available) {
308
- fleet = {
309
- cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
310
- cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
311
- })),
312
- };
313
- }
314
- } catch (_) { /* fleet opzionale: assente/disabilitata -> null */ }
315
- return { sessions, fleet };
316
- },
317
- },
318
- {
319
- name: 'nc_deck',
320
- description: 'Contesto read-only del deck della sessione chiamante: restituisce i deck che la contengono e i relativi membri con nome cella Fleet, sessione tmux e route.',
321
- inputSchema: { type: 'object', properties: {} },
322
- annotations: { readOnlyHint: true },
323
- async handler(_args, ctx) {
324
- const tmuxSession = requireSession(await ctx.session(), 'nc_deck');
325
- const [config, topology, localDecks] = await Promise.all([
326
- ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'), ctx.api('GET', '/api/decks'),
327
- ]);
328
- const localNodeId = String(config && config.instanceId || '');
329
- if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
330
- if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
331
-
332
- const remotes = topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localNodeId);
333
- const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
334
- const sources = [{
335
- owner: { instanceId: localNodeId, route: [], label: 'Local' },
336
- ownerTopology: topologyOwners(topology), decks: localDecks.decks,
337
- }];
338
- await Promise.all(remotes.map(async (owner) => {
339
- const decksPath = routePath(owner.route, 'decks');
340
- const topologyPath = routePath(owner.route, 'topology');
341
- if (!decksPath || !topologyPath) return;
342
- try {
343
- const [deckPayload, ownerTopology] = await Promise.all([
344
- ctx.api('GET', decksPath), ctx.api('GET', topologyPath).catch(() => ({ nodes: [] })),
345
- ]);
346
- if (deckPayload && Array.isArray(deckPayload.decks)) {
347
- sources.push({ owner, ownerTopology: topologyOwners(ownerTopology), decks: deckPayload.decks });
348
- }
349
- } catch (_) { /* owner offline/withdrawn: no stale deck disclosure */ }
350
- }));
351
-
352
- const decks = [];
353
- for (const source of sources) {
354
- for (const deck of source.decks) {
355
- if (!deck || typeof deck.name !== 'string') continue;
356
- const members = orderedDeckMembers(deck).map((member) => ({
357
- ...member,
358
- ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
359
- }));
360
- if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
361
- decks.push({
362
- id: `${source.owner.instanceId}:${deck.name}`,
363
- name: deck.name,
364
- owner: { instanceId: source.owner.instanceId, route: source.owner.route.length ? source.owner.route.join('/') : 'local', label: source.owner.label },
365
- members,
366
- });
367
- }
368
- }
369
- decks.sort((a, b) => a.owner.route.localeCompare(b.owner.route) || a.name.localeCompare(b.name));
370
-
371
- if (!decks.length) return { tmuxSession, nodeId: localNodeId, decks: [] };
372
-
373
- const routes = new Set(['']);
374
- for (const deck of decks) for (const member of deck.members) {
375
- const route = member.ownerId && viewerById.has(member.ownerId)
376
- ? viewerById.get(member.ownerId).join('/') : null;
377
- member.viewerRoute = route;
378
- if (route !== null) routes.add(route);
379
- }
380
- const cellsByRoute = new Map();
381
- await Promise.all([...routes].map(async (route) => {
382
- const apiPath = fleetStatusPath(route);
383
- if (!apiPath) return;
384
- try { cellsByRoute.set(route, fleetCellsBySession(await ctx.api('GET', apiPath))); }
385
- catch (_) { cellsByRoute.set(route, new Map()); }
386
- }));
387
-
388
- return {
389
- tmuxSession, nodeId: localNodeId,
390
- decks: decks.map((deck) => ({
391
- id: deck.id, name: deck.name, owner: deck.owner,
392
- members: deck.members.map((member) => {
393
- const route = member.viewerRoute;
394
- return {
395
- cell: route === null ? null : (cellsByRoute.get(route)?.get(member.tmuxSession) || null),
396
- tmuxSession: member.tmuxSession,
397
- ownerId: member.ownerId,
398
- route: route === null ? 'unavailable' : (route || 'local'),
399
- self: member.ownerId === localNodeId && member.tmuxSession === tmuxSession,
400
- };
401
- }),
402
- })),
403
- };
404
- },
405
- },
406
- {
407
- name: 'nc_cells',
408
- description: 'Directory read-only di tutte le celle Fleet autorizzate nella rete NexusCrew. Usa l\'id owner-qualified restituito per evitare nomi ambigui.',
409
- inputSchema: { type: 'object', properties: {} },
410
- annotations: { readOnlyHint: true },
411
- async handler(_args, ctx) {
412
- const callerSession = await ctx.session();
413
- return readCellDirectory(ctx, callerSession);
414
- },
415
- },
416
- {
417
- name: 'nc_send_cell',
418
- description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
419
- inputSchema: {
420
- type: 'object',
421
- properties: {
422
- target: { type: 'string', description: 'id owner-qualified restituito da nc_cells: <instanceId>:<cell>' },
423
- message: { type: 'string', description: 'messaggio o task da sottoporre (max 8000 caratteri)' },
424
- },
425
- required: ['target', 'message'],
426
- },
427
- async handler(args, ctx) {
428
- const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
429
- if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
430
- const message = argString(args, 'message', { required: true, max: 8000 });
431
- for (let i = 0; i < message.length; i += 1) {
432
- const code = message.charCodeAt(i);
433
- if (code === 9 || code === 10 || code === 13) continue;
434
- if (code < 32 || code === 127) throw new Error('message contiene caratteri di controllo non ammessi');
435
- }
436
- const callerSession = requireSession(await ctx.session(), 'nc_send_cell');
437
- const directory = await readCellDirectory(ctx, callerSession);
438
- const sender = directory.cells.find((cell) => cell.self && cell.active);
439
- if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
440
- const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
441
- && cell.cell === targetRef.cell);
442
- if (!target) throw new Error('cella destinataria non trovata nella rete autorizzata');
443
- if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
444
- const apiPath = target.route === 'local'
445
- ? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
446
- if (!apiPath) throw new Error('route destinataria non valida');
447
- const id = ctx.messageId();
448
- const receipt = await ctx.api('POST', apiPath, {
449
- id,
450
- from: { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
451
- to: { instanceId: target.instanceId, cell: target.cell, tmuxSession: target.tmuxSession },
452
- message,
453
- });
454
- return {
455
- id: receipt.id,
456
- status: receipt.status,
457
- at: receipt.at,
458
- to: receipt.to,
459
- note: receipt.note || 'submitted conferma il trasporto, non il completamento del task',
460
- };
461
- },
462
- },
463
- {
464
- name: 'nc_inbox',
465
- description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
466
- inputSchema: { type: 'object', properties: {} },
467
- annotations: { readOnlyHint: true },
468
- async handler(_args, ctx) {
469
- const session = requireSession(await ctx.session(), 'nc_inbox');
470
- const j = await ctx.api('GET', `/api/files?session=${encodeURIComponent(session)}`);
471
- return { inbox: Array.isArray(j.inbox) ? j.inbox : [] };
472
- },
473
- },
474
- ];
475
-
476
63
  // --- server --------------------------------------------------------------------
477
64
  function createMcpServer(opts = {}) {
478
65
  const input = opts.input || process.stdin;
@@ -666,5 +253,7 @@ function startMcp(opts = {}) {
666
253
 
667
254
  module.exports = {
668
255
  createMcpServer, startMcp, resolveSession, TOOLS,
669
- parseCellTarget, normalizeCellPayload, readCellDirectory,
256
+ parseCellTarget: cells.parseCellTarget,
257
+ normalizeCellPayload: cells.normalizeCellPayload,
258
+ readCellDirectory: cells.readCellDirectory,
670
259
  };