kanbango 2.0.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp-server.js CHANGED
@@ -5,19 +5,53 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
5
5
  const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
6
6
  const { spawn } = require('child_process');
7
7
  const path = require('path');
8
+ const pkg = require('./package.json');
8
9
  const kanban = require('./kanban.js');
10
+ const plan = require('./plan.js');
9
11
 
10
12
  const COLS = kanban.COLS;
11
13
  const READ_VIEWS = Object.keys(kanban.VIEW_FIELDS);
14
+ const GUI_READY_TIMEOUT_MS = 8000;
15
+ const GUI_READY_POLL_MS = 50;
12
16
  let guiProcess = null;
13
17
  let guiPort = null;
14
18
 
15
19
  function normalizePort(value) {
16
- const parsed = Number.parseInt(value, 10);
17
- if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
18
- return null;
20
+ return kanban.normalizeGuiPort(value);
21
+ }
22
+
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ function envFlagEnabled(name) {
28
+ const raw = process.env[name];
29
+ if (raw === undefined || raw === null || raw === '') return false;
30
+ return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
31
+ }
32
+
33
+ async function waitForGuiReady(pid, timeoutMs = GUI_READY_TIMEOUT_MS) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ if (guiProcess && guiProcess.pid === pid && guiProcess.exitCode !== null) {
37
+ throw invalidRequest(
38
+ 'GUI process exited before becoming ready',
39
+ 'Check whether another process holds the port or inspect MCP stderr',
40
+ { pid }
41
+ );
42
+ }
43
+
44
+ const info = await kanban.discoverRunningGui();
45
+ if (info && info.pid === pid) return info;
46
+
47
+ await sleep(GUI_READY_POLL_MS);
19
48
  }
20
- return parsed;
49
+
50
+ throw invalidRequest(
51
+ 'Timed out waiting for GUI to publish its port',
52
+ 'Retry kanban_gui start or set KANBANGO_GUI_PORT to a free port',
53
+ { pid, timeout_ms: timeoutMs }
54
+ );
21
55
  }
22
56
 
23
57
  function serializeError(error) {
@@ -36,6 +70,34 @@ function invalidRequest(message, hint, details) {
36
70
  return kanban.createKanbanError('VALIDATION_ERROR', message, hint, details, false, 400);
37
71
  }
38
72
 
73
+ function serializeResult(result) {
74
+ if (typeof result === 'string') return result;
75
+
76
+ const text = JSON.stringify(result, null, 2);
77
+ if (typeof text === 'string') return text;
78
+
79
+ throw kanban.createKanbanError(
80
+ 'INTERNAL_ERROR',
81
+ 'Tool completed without a response payload',
82
+ 'This is a server bug. Inspect the MCP handler for the requested tool/action.',
83
+ { result_type: typeof result },
84
+ true,
85
+ 500
86
+ );
87
+ }
88
+
89
+ function textResponse(result, isError = false) {
90
+ return {
91
+ content: [
92
+ {
93
+ type: 'text',
94
+ text: serializeResult(result)
95
+ }
96
+ ],
97
+ ...(isError ? { isError: true } : {})
98
+ };
99
+ }
100
+
39
101
  function normalizeReturnShape(returnShape) {
40
102
  if (returnShape === undefined) return 'summary';
41
103
  if (!['none', 'summary', 'full'].includes(returnShape)) {
@@ -79,63 +141,122 @@ function formatTaskResult(task, returnShape) {
79
141
  }
80
142
 
81
143
  async function startGuiServer(port) {
82
- const desiredPort = normalizePort(port ?? 5500);
83
- if (!desiredPort) {
144
+ if (port !== undefined && port !== null && port !== '' && !normalizePort(port)) {
84
145
  throw invalidRequest('Invalid port', 'Use an integer between 1 and 65535', { port });
85
146
  }
86
147
 
87
- if (guiProcess && guiProcess.exitCode === null) {
148
+ if (guiProcess && guiProcess.exitCode === null && guiPort) {
88
149
  return {
89
150
  status: 'already_running',
90
151
  port: guiPort,
152
+ pid: guiProcess.pid,
91
153
  url: `http://localhost:${guiPort}`
92
154
  };
93
155
  }
94
156
 
157
+ const existing = await kanban.discoverRunningGui();
158
+ if (existing) {
159
+ guiPort = existing.port;
160
+ return {
161
+ status: 'already_running',
162
+ port: existing.port,
163
+ pid: existing.pid,
164
+ url: existing.url
165
+ };
166
+ }
167
+
168
+ const desiredPort = kanban.resolvePreferredGuiPort(port);
95
169
  await kanban.ensureBacklogDir();
96
170
 
97
171
  const scriptPath = path.join(__dirname, 'bin', 'kanban.js');
98
172
  guiProcess = spawn(process.execPath, [scriptPath, 'serve', String(desiredPort)], {
173
+ cwd: process.cwd(),
99
174
  stdio: 'ignore',
100
- windowsHide: true
175
+ windowsHide: true,
176
+ detached: false
101
177
  });
102
- guiPort = desiredPort;
103
178
 
179
+ const childPid = guiProcess.pid;
104
180
  guiProcess.on('exit', () => {
105
- guiProcess = null;
106
- guiPort = null;
181
+ if (guiProcess && guiProcess.pid === childPid) {
182
+ guiProcess = null;
183
+ guiPort = null;
184
+ }
107
185
  });
108
186
 
187
+ const ready = await waitForGuiReady(childPid);
188
+ guiPort = ready.port;
189
+
109
190
  return {
110
191
  status: 'started',
111
- port: desiredPort,
112
- pid: guiProcess.pid,
113
- url: `http://localhost:${desiredPort}`
192
+ port: ready.port,
193
+ pid: ready.pid,
194
+ url: ready.url
114
195
  };
115
196
  }
116
197
 
117
- function stopGuiServer() {
118
- if (!guiProcess || guiProcess.exitCode !== null) {
198
+ async function stopGuiServer() {
199
+ const trackedRunning = guiProcess && guiProcess.exitCode === null;
200
+ const discovered = trackedRunning ? null : await kanban.discoverRunningGui();
201
+
202
+ if (!trackedRunning && !discovered) {
119
203
  guiProcess = null;
120
204
  guiPort = null;
121
205
  return { status: 'not_running' };
122
206
  }
123
207
 
124
- guiProcess.kill();
125
- return { status: 'stopping', port: guiPort };
208
+ const port = trackedRunning ? guiPort : discovered.port;
209
+ const pid = trackedRunning ? guiProcess.pid : discovered.pid;
210
+
211
+ if (trackedRunning) {
212
+ guiProcess.kill();
213
+ } else if (pid) {
214
+ try {
215
+ process.kill(pid, 'SIGTERM');
216
+ } catch {
217
+ // process may already be gone
218
+ }
219
+ }
220
+
221
+ const deadline = Date.now() + 2000;
222
+ while (Date.now() < deadline) {
223
+ const still = await kanban.discoverRunningGui();
224
+ if (!still || still.pid !== pid) break;
225
+ await sleep(50);
226
+ }
227
+
228
+ await kanban.clearGuiPortFile({ force: true });
229
+ guiProcess = null;
230
+ guiPort = null;
231
+
232
+ return { status: 'stopping', port, pid };
126
233
  }
127
234
 
128
- function guiStatus() {
129
- if (!guiProcess || guiProcess.exitCode !== null) {
235
+ async function guiStatus() {
236
+ if (guiProcess && guiProcess.exitCode === null && guiPort) {
237
+ return {
238
+ status: 'running',
239
+ port: guiPort,
240
+ pid: guiProcess.pid,
241
+ url: `http://localhost:${guiPort}`
242
+ };
243
+ }
244
+
245
+ const discovered = await kanban.discoverRunningGui();
246
+ if (!discovered) {
247
+ guiProcess = null;
248
+ guiPort = null;
130
249
  return { status: 'not_running' };
131
250
  }
132
- return { status: 'running', port: guiPort, pid: guiProcess.pid, url: `http://localhost:${guiPort}` };
251
+
252
+ guiPort = discovered.port;
253
+ return discovered;
133
254
  }
134
255
 
135
256
  const server = new Server(
136
257
  {
137
258
  name: 'kanbango',
138
- version: '2.0.0'
259
+ version: pkg.version
139
260
  },
140
261
  {
141
262
  capabilities: {
@@ -149,7 +270,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
149
270
  tools: [
150
271
  {
151
272
  name: 'kanban_read',
152
- description: 'Read tasks from kanban board with compact views or explicit fields.',
273
+ description: 'Read tasks from the board. operation=list returns multiple tasks with optional col/epic filters. operation=show requires task_id. Use view for preset payload sizes or fields for exact field selection.',
153
274
  inputSchema: {
154
275
  type: 'object',
155
276
  properties: {
@@ -161,7 +282,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
161
282
  },
162
283
  task_id: {
163
284
  type: 'string',
164
- description: "Task ID (required for 'show' operation, e.g. 'PI-014-google-calendar')"
285
+ description: "Task ID (optional for 'list', required for 'show'). Use a numeric ID like '014' or just a number like '14'."
165
286
  },
166
287
  col: {
167
288
  type: 'string',
@@ -182,46 +303,67 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
182
303
  description: 'Explicit fields to return. When provided, fields override view.',
183
304
  items: { type: 'string' }
184
305
  }
185
- }
306
+ },
307
+ additionalProperties: false
186
308
  }
187
309
  },
188
310
  {
189
- name: 'kanban_create',
190
- description: 'Create a new task on the kanban board with optional rich planning fields.',
311
+ name: 'kanban_manage',
312
+ description: 'Mutate tasks and accepted plans. Required fields by action: create -> title; move -> task_id + column; update -> task_id plus patch or field shortcuts; plan_create -> title; plan_advance/plan_done/plan_status -> task_id; plan_evidence -> task_id + diff + test_command + stdout + stderr + exit_code. Example create: {"action":"create","title":"Ship Docker image","col":"planned","epic":"Release"}.',
191
313
  inputSchema: {
192
314
  type: 'object',
193
315
  properties: {
316
+ action: {
317
+ type: 'string',
318
+ enum: ['create', 'move', 'update', 'plan_create', 'plan_advance', 'plan_evidence', 'plan_done', 'plan_status'],
319
+ description: 'Create, move, update, or operate the accepted-plan workflow'
320
+ },
194
321
  title: {
195
322
  type: 'string',
196
- description: 'Title of new task'
323
+ description: "Non-empty title. Required for 'create' and 'plan_create'."
197
324
  },
198
325
  col: {
199
326
  type: 'string',
200
327
  enum: COLS,
201
328
  default: 'planned',
202
- description: 'Column to place task in (active|planned|icebox|done)'
329
+ description: "Column for 'create' or shortcut patch field for 'update' (default: 'planned')."
203
330
  },
204
331
  epic: {
205
332
  type: 'string',
206
333
  default: '—',
207
- description: 'Epic group name (optional)'
334
+ description: "Epic group for 'create', 'update', or 'plan_create' (optional)."
208
335
  },
209
336
  description: {
210
337
  type: 'string',
211
- description: 'High-level context and implementation plan'
338
+ description: "High-level context for 'create', 'update', or 'plan_create'."
212
339
  },
213
340
  specs: {
214
341
  type: 'string',
215
- description: 'Technical constraints, APIs, and edge cases'
342
+ description: "Technical constraints, APIs, and edge cases for 'create', 'update', or 'plan_create'."
343
+ },
344
+ in_scope: {
345
+ type: 'array',
346
+ description: "What this task includes for 'create', 'update', or 'plan_create'.",
347
+ items: { type: 'string' }
348
+ },
349
+ out_of_scope: {
350
+ type: 'array',
351
+ description: "Explicit non-goals / exclusions for 'create', 'update', or 'plan_create'.",
352
+ items: { type: 'string' }
216
353
  },
217
354
  acceptance_criteria: {
218
355
  type: 'array',
219
- description: 'What must be true for the task to be complete',
356
+ description: "Completion requirements for 'create', 'update', or 'plan_create'.",
357
+ items: { type: 'string' }
358
+ },
359
+ test_cases: {
360
+ type: 'array',
361
+ description: "Verification scenarios for 'create', 'update', or 'plan_create'.",
220
362
  items: { type: 'string' }
221
363
  },
222
364
  subtasks: {
223
365
  type: 'array',
224
- description: 'Optional subtask list',
366
+ description: "Optional subtask list for 'create', 'update', or internally generated by 'plan_create'.",
225
367
  items: {
226
368
  type: 'object',
227
369
  properties: {
@@ -234,93 +376,82 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
234
376
  },
235
377
  notes: {
236
378
  type: 'string',
237
- description: 'Optional freeform notes'
238
- }
239
- },
240
- required: ['title']
241
- }
242
- },
243
- {
244
- name: 'kanban_update',
245
- description: 'Move tasks, toggle subtasks, or apply patch-style updates with configurable response size.',
246
- inputSchema: {
247
- type: 'object',
248
- properties: {
249
- operation: {
250
- type: 'string',
251
- enum: ['move', 'toggle', 'update'],
252
- description: "'move' changes column, 'toggle' flips one subtask, 'update' applies a patch"
379
+ description: "Optional freeform notes for 'create', 'update', or 'plan_create'."
253
380
  },
254
381
  task_id: {
255
382
  type: 'string',
256
- description: 'Task ID to update'
383
+ description: "Task ID required for 'move', 'update', and all plan_* actions except 'plan_create'. Use '014' or '14'."
257
384
  },
258
385
  column: {
259
386
  type: 'string',
260
387
  enum: COLS,
261
- description: "New column for 'move'"
262
- },
263
- idx: {
264
- type: 'integer',
265
- description: "Subtask index for 'toggle'"
388
+ description: "Target column required for 'move'."
266
389
  },
267
390
  patch: {
268
391
  type: 'object',
269
- description: "Patch payload for 'update'"
392
+ description: "Patch payload for 'update'. Use this for bulk field changes; top-level shortcuts are merged into the patch."
270
393
  },
271
- title: {
394
+ return: {
272
395
  type: 'string',
273
- description: 'Backward-compatible title update shortcut'
396
+ enum: ['none', 'summary', 'full'],
397
+ description: "Response shape for 'move' and 'update'. Defaults to summary. 'create' returns the full created task."
274
398
  },
275
- tasks: {
399
+ index: {
400
+ type: 'integer',
401
+ description: "Zero-based plan subtask index for 'plan_advance'. Defaults to the first incomplete step when omitted."
402
+ },
403
+ steps: {
276
404
  type: 'array',
277
- description: 'Backward-compatible subtask update shortcut',
278
- items: {
279
- type: 'object',
280
- properties: {
281
- id: { type: 'string' },
282
- done: { type: 'boolean' },
283
- text: { type: 'string' },
284
- description: { type: 'string' }
285
- }
286
- }
405
+ items: { type: 'string' },
406
+ description: "Implementation steps inserted between the default plan workflow steps for 'plan_create'."
287
407
  },
288
- return: {
408
+ project_root: {
289
409
  type: 'string',
290
- enum: ['none', 'summary', 'full'],
291
- description: 'Returned payload size after update. Defaults to summary.'
410
+ description: "Project root used for test runner detection in 'plan_create'. Defaults to the MCP server working directory."
411
+ },
412
+ diff: {
413
+ type: 'string',
414
+ description: "Required for 'plan_evidence'. Include the relevant code diff or summary."
415
+ },
416
+ test_command: {
417
+ type: 'string',
418
+ description: "Required for 'plan_evidence'. The exact verification command that was run."
419
+ },
420
+ stdout: {
421
+ type: 'string',
422
+ description: "Required for 'plan_evidence'. Captured standard output from the verification command."
423
+ },
424
+ stderr: {
425
+ type: 'string',
426
+ description: "Required for 'plan_evidence'. Captured standard error from the verification command."
427
+ },
428
+ exit_code: {
429
+ type: 'integer',
430
+ description: "Required for 'plan_evidence'. Integer process exit code from the verification command."
292
431
  }
293
432
  },
294
- required: ['operation', 'task_id']
433
+ required: ['action'],
434
+ additionalProperties: false
295
435
  }
296
436
  },
297
437
  {
298
- name: 'kanban_gui_start',
299
- description: 'Start the web GUI server for the kanban board.',
438
+ name: 'kanban_gui',
439
+ description: 'Control the web GUI server: start, stop, or check status.',
300
440
  inputSchema: {
301
441
  type: 'object',
302
442
  properties: {
443
+ action: {
444
+ type: 'string',
445
+ enum: ['start', 'stop', 'status'],
446
+ description: "Action to perform: 'start' launches GUI, 'stop' kills it, 'status' checks if running"
447
+ },
303
448
  port: {
304
449
  type: 'integer',
305
- description: 'Port for the GUI server (default 5500)'
450
+ description: "Port for the GUI server (only for 'start'). Defaults to KANBANGO_GUI_PORT or a stable hash of the project cwd (5510-5999)."
306
451
  }
307
- }
308
- }
309
- },
310
- {
311
- name: 'kanban_gui_stop',
312
- description: 'Stop the web GUI server if it is running.',
313
- inputSchema: {
314
- type: 'object',
315
- properties: {}
316
- }
317
- },
318
- {
319
- name: 'kanban_gui_status',
320
- description: 'Get status of the web GUI server.',
321
- inputSchema: {
322
- type: 'object',
323
- properties: {}
452
+ },
453
+ required: ['action'],
454
+ additionalProperties: false
324
455
  }
325
456
  }
326
457
  ]
@@ -368,86 +499,120 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
368
499
  break;
369
500
  }
370
501
 
371
- case 'kanban_create': {
372
- const created = await kanban.doCreate(args.title, args.col || 'planned', args.epic || '—', {
373
- description: args.description,
374
- specs: args.specs,
375
- acceptance_criteria: args.acceptance_criteria,
376
- subtasks: args.subtasks,
377
- notes: args.notes
378
- });
379
- result = kanban.shapeTask(created, { view: 'full' });
380
- break;
381
- }
382
-
383
- case 'kanban_update': {
384
- const operation = args.operation;
502
+ case 'kanban_manage': {
503
+ const action = args.action;
385
504
  const returnShape = normalizeReturnShape(args.return);
386
505
 
387
- if (operation === 'move') {
388
- if (!args.column) {
389
- throw invalidRequest(
390
- "column is required for 'move'",
391
- 'Provide one target column',
392
- { operation }
393
- );
506
+ switch (action) {
507
+ case 'create': {
508
+ const created = await kanban.doCreate(args.title, args.col || 'planned', args.epic || '—', {
509
+ description: args.description,
510
+ specs: args.specs,
511
+ in_scope: args.in_scope,
512
+ out_of_scope: args.out_of_scope,
513
+ acceptance_criteria: args.acceptance_criteria,
514
+ test_cases: args.test_cases,
515
+ subtasks: args.subtasks,
516
+ notes: args.notes
517
+ });
518
+ result = kanban.shapeTask(created, { view: 'full' });
519
+ break;
394
520
  }
395
- const updated = await kanban.updateTask(args.task_id, { column: args.column });
396
- result = formatTaskResult(updated, returnShape);
397
- } else if (operation === 'toggle') {
398
- if (!Number.isInteger(args.idx)) {
399
- throw invalidRequest(
400
- "idx is required for 'toggle'",
401
- 'Provide a zero-based subtask index',
402
- { operation, idx: args.idx }
403
- );
521
+
522
+ case 'move': {
523
+ if (!args.task_id) {
524
+ throw invalidRequest(
525
+ "task_id is required for 'move'",
526
+ 'Provide a task ID',
527
+ { action }
528
+ );
529
+ }
530
+ if (!args.column) {
531
+ throw invalidRequest(
532
+ "column is required for 'move'",
533
+ 'Provide one target column',
534
+ { action }
535
+ );
536
+ }
537
+ const updated = await kanban.updateTask(args.task_id, { column: args.column });
538
+ result = formatTaskResult(updated, returnShape);
539
+ break;
404
540
  }
405
- const current = await kanban.getTask(args.task_id);
406
- if (args.idx < 0 || args.idx >= current.subtasks.length) {
407
- throw kanban.createKanbanError(
408
- 'INVALID_SUBTASK_INDEX',
409
- `Subtask index ${args.idx} is not valid for task ${args.task_id}`,
410
- 'Read the task first and use an index between 0 and subtasks.length - 1',
411
- { task_id: args.task_id, idx: args.idx, total_subtasks: current.subtasks.length },
412
- false,
413
- 400
414
- );
541
+
542
+ case 'update': {
543
+ if (!args.task_id) {
544
+ throw invalidRequest(
545
+ "task_id is required for 'update'",
546
+ 'Provide a task ID',
547
+ { action }
548
+ );
549
+ }
550
+ const patch = args.patch ? { ...args.patch } : {};
551
+ if (args.title !== undefined) patch.title = args.title;
552
+ if (args.description !== undefined) patch.description = args.description;
553
+ if (args.specs !== undefined) patch.specs = args.specs;
554
+ if (args.in_scope !== undefined) patch.in_scope = args.in_scope;
555
+ if (args.out_of_scope !== undefined) patch.out_of_scope = args.out_of_scope;
556
+ if (args.acceptance_criteria !== undefined) patch.acceptance_criteria = args.acceptance_criteria;
557
+ if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
558
+ if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
559
+ if (args.notes !== undefined) patch.notes = args.notes;
560
+ if (args.epic !== undefined) patch.epic_group = args.epic;
561
+ if (args.col !== undefined) patch.column = args.col;
562
+ const updated = await kanban.updateTask(args.task_id, patch);
563
+ result = formatTaskResult(updated, returnShape);
564
+ break;
415
565
  }
416
566
 
417
- const subtasks = current.subtasks.map((subtask, idx) => ({
418
- ...subtask,
419
- done: idx === args.idx ? !subtask.done : subtask.done
420
- }));
421
- const updated = await kanban.updateTask(args.task_id, { subtasks });
422
- result = formatTaskResult(updated, returnShape);
423
- } else if (operation === 'update') {
424
- const patch = args.patch ? { ...args.patch } : {};
425
- if (args.title !== undefined) patch.title = args.title;
426
- if (args.tasks !== undefined) patch.subtasks = args.tasks;
427
- const updated = await kanban.updateTask(args.task_id, patch);
428
- result = formatTaskResult(updated, returnShape);
429
- } else {
430
- throw invalidRequest(
431
- `Unknown operation: ${operation}`,
432
- 'Use one of: move, toggle, update',
433
- { operation }
434
- );
567
+ case 'plan_create':
568
+ result = await plan.create(args);
569
+ break;
570
+ case 'plan_advance':
571
+ result = await plan.advance({ task_id: args.task_id, index: args.index });
572
+ break;
573
+ case 'plan_evidence':
574
+ result = await plan.evidence(args);
575
+ break;
576
+ case 'plan_done':
577
+ result = await plan.done({ task_id: args.task_id });
578
+ break;
579
+ case 'plan_status':
580
+ result = await plan.status(args.task_id);
581
+ break;
582
+
583
+ default:
584
+ throw invalidRequest(
585
+ `Unknown action: ${action}`,
586
+ 'Use create, move, update, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
587
+ { action }
588
+ );
435
589
  }
436
590
  break;
437
591
  }
438
592
 
439
- case 'kanban_gui_start': {
440
- result = await startGuiServer(args.port);
441
- break;
442
- }
593
+ case 'kanban_gui': {
594
+ const action = args.action;
443
595
 
444
- case 'kanban_gui_stop': {
445
- result = stopGuiServer();
446
- break;
447
- }
448
-
449
- case 'kanban_gui_status': {
450
- result = guiStatus();
596
+ switch (action) {
597
+ case 'start': {
598
+ result = await startGuiServer(args.port);
599
+ break;
600
+ }
601
+ case 'stop': {
602
+ result = await stopGuiServer();
603
+ break;
604
+ }
605
+ case 'status': {
606
+ result = await guiStatus();
607
+ break;
608
+ }
609
+ default:
610
+ throw invalidRequest(
611
+ `Unknown action: ${action}`,
612
+ 'Use one of: start, stop, status',
613
+ { action }
614
+ );
615
+ }
451
616
  break;
452
617
  }
453
618
 
@@ -455,35 +620,79 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
455
620
  throw invalidRequest(`Unknown tool: ${name}`, 'Call tools/list to discover available tools', { name });
456
621
  }
457
622
 
458
- return {
459
- content: [
460
- {
461
- type: 'text',
462
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
463
- }
464
- ]
465
- };
623
+ return textResponse(result);
466
624
  } catch (error) {
467
- return {
468
- content: [
469
- {
470
- type: 'text',
471
- text: JSON.stringify(serializeError(error), null, 2)
472
- }
473
- ],
474
- isError: true
475
- };
625
+ return textResponse(serializeError(error), true);
476
626
  }
477
627
  });
478
628
 
629
+ async function maybeAutoStartGui() {
630
+ if (!envFlagEnabled('KANBANGO_AUTO_GUI')) return null;
631
+
632
+ try {
633
+ const result = await startGuiServer();
634
+ console.error(`kanbango GUI ${result.status}: ${result.url}`);
635
+ return result;
636
+ } catch (error) {
637
+ console.error(`kanbango GUI auto-start failed: ${error.message}`);
638
+ return null;
639
+ }
640
+ }
641
+
642
+ function installGuiShutdownHooks() {
643
+ let shuttingDown = false;
644
+
645
+ async function shutdown() {
646
+ if (shuttingDown) return;
647
+ shuttingDown = true;
648
+ try {
649
+ await stopGuiServer();
650
+ } catch {
651
+ // best-effort
652
+ }
653
+ }
654
+
655
+ process.once('exit', () => {
656
+ if (guiProcess && guiProcess.exitCode === null) {
657
+ try {
658
+ guiProcess.kill();
659
+ } catch {
660
+ // ignore
661
+ }
662
+ }
663
+ });
664
+ process.once('SIGINT', () => {
665
+ shutdown().finally(() => process.exit(0));
666
+ });
667
+ process.once('SIGTERM', () => {
668
+ shutdown().finally(() => process.exit(0));
669
+ });
670
+ }
671
+
479
672
  async function main() {
480
673
  await kanban.ensureBacklogDir();
674
+ installGuiShutdownHooks();
675
+ await maybeAutoStartGui();
481
676
  const transport = new StdioServerTransport();
482
677
  await server.connect(transport);
483
678
  console.error('kanbango MCP server running');
484
679
  }
485
680
 
486
- main().catch((error) => {
487
- console.error('Fatal error in main():', error);
488
- process.exit(1);
489
- });
681
+ module.exports = {
682
+ serializeError,
683
+ serializeResult,
684
+ textResponse,
685
+ startGuiServer,
686
+ stopGuiServer,
687
+ guiStatus,
688
+ resolvePreferredGuiPort: kanban.resolvePreferredGuiPort,
689
+ server,
690
+ main
691
+ };
692
+
693
+ if (require.main === module) {
694
+ main().catch((error) => {
695
+ console.error('Fatal error in main():', error);
696
+ process.exit(1);
697
+ });
698
+ }