kanbango 2.1.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,51 +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'.",
220
357
  items: { type: 'string' }
221
358
  },
222
359
  test_cases: {
223
360
  type: 'array',
224
- description: 'Test case scenarios verifying acceptance criteria',
361
+ description: "Verification scenarios for 'create', 'update', or 'plan_create'.",
225
362
  items: { type: 'string' }
226
363
  },
227
364
  subtasks: {
228
365
  type: 'array',
229
- description: 'Optional subtask list',
366
+ description: "Optional subtask list for 'create', 'update', or internally generated by 'plan_create'.",
230
367
  items: {
231
368
  type: 'object',
232
369
  properties: {
@@ -239,93 +376,82 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
239
376
  },
240
377
  notes: {
241
378
  type: 'string',
242
- description: 'Optional freeform notes'
243
- }
244
- },
245
- required: ['title']
246
- }
247
- },
248
- {
249
- name: 'kanban_update',
250
- description: 'Move tasks, toggle subtasks, or apply patch-style updates with configurable response size.',
251
- inputSchema: {
252
- type: 'object',
253
- properties: {
254
- operation: {
255
- type: 'string',
256
- enum: ['move', 'toggle', 'update'],
257
- description: "'move' changes column, 'toggle' flips one subtask, 'update' applies a patch"
379
+ description: "Optional freeform notes for 'create', 'update', or 'plan_create'."
258
380
  },
259
381
  task_id: {
260
382
  type: 'string',
261
- description: 'Task ID to update'
383
+ description: "Task ID required for 'move', 'update', and all plan_* actions except 'plan_create'. Use '014' or '14'."
262
384
  },
263
385
  column: {
264
386
  type: 'string',
265
387
  enum: COLS,
266
- description: "New column for 'move'"
267
- },
268
- idx: {
269
- type: 'integer',
270
- description: "Subtask index for 'toggle'"
388
+ description: "Target column required for 'move'."
271
389
  },
272
390
  patch: {
273
391
  type: 'object',
274
- 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."
275
393
  },
276
- title: {
394
+ return: {
277
395
  type: 'string',
278
- 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."
279
398
  },
280
- 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: {
281
404
  type: 'array',
282
- description: 'Backward-compatible subtask update shortcut',
283
- items: {
284
- type: 'object',
285
- properties: {
286
- id: { type: 'string' },
287
- done: { type: 'boolean' },
288
- text: { type: 'string' },
289
- description: { type: 'string' }
290
- }
291
- }
405
+ items: { type: 'string' },
406
+ description: "Implementation steps inserted between the default plan workflow steps for 'plan_create'."
292
407
  },
293
- return: {
408
+ project_root: {
294
409
  type: 'string',
295
- enum: ['none', 'summary', 'full'],
296
- 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."
297
431
  }
298
432
  },
299
- required: ['operation', 'task_id']
433
+ required: ['action'],
434
+ additionalProperties: false
300
435
  }
301
436
  },
302
437
  {
303
- name: 'kanban_gui_start',
304
- 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.',
305
440
  inputSchema: {
306
441
  type: 'object',
307
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
+ },
308
448
  port: {
309
449
  type: 'integer',
310
- 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)."
311
451
  }
312
- }
313
- }
314
- },
315
- {
316
- name: 'kanban_gui_stop',
317
- description: 'Stop the web GUI server if it is running.',
318
- inputSchema: {
319
- type: 'object',
320
- properties: {}
321
- }
322
- },
323
- {
324
- name: 'kanban_gui_status',
325
- description: 'Get status of the web GUI server.',
326
- inputSchema: {
327
- type: 'object',
328
- properties: {}
452
+ },
453
+ required: ['action'],
454
+ additionalProperties: false
329
455
  }
330
456
  }
331
457
  ]
@@ -373,86 +499,120 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
373
499
  break;
374
500
  }
375
501
 
376
- case 'kanban_create': {
377
- const created = await kanban.doCreate(args.title, args.col || 'planned', args.epic || '—', {
378
- description: args.description,
379
- specs: args.specs,
380
- acceptance_criteria: args.acceptance_criteria,
381
- subtasks: args.subtasks,
382
- notes: args.notes
383
- });
384
- result = kanban.shapeTask(created, { view: 'full' });
385
- break;
386
- }
387
-
388
- case 'kanban_update': {
389
- const operation = args.operation;
502
+ case 'kanban_manage': {
503
+ const action = args.action;
390
504
  const returnShape = normalizeReturnShape(args.return);
391
505
 
392
- if (operation === 'move') {
393
- if (!args.column) {
394
- throw invalidRequest(
395
- "column is required for 'move'",
396
- 'Provide one target column',
397
- { operation }
398
- );
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;
399
520
  }
400
- const updated = await kanban.updateTask(args.task_id, { column: args.column });
401
- result = formatTaskResult(updated, returnShape);
402
- } else if (operation === 'toggle') {
403
- if (!Number.isInteger(args.idx)) {
404
- throw invalidRequest(
405
- "idx is required for 'toggle'",
406
- 'Provide a zero-based subtask index',
407
- { operation, idx: args.idx }
408
- );
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;
409
540
  }
410
- const current = await kanban.getTask(args.task_id);
411
- if (args.idx < 0 || args.idx >= current.subtasks.length) {
412
- throw kanban.createKanbanError(
413
- 'INVALID_SUBTASK_INDEX',
414
- `Subtask index ${args.idx} is not valid for task ${args.task_id}`,
415
- 'Read the task first and use an index between 0 and subtasks.length - 1',
416
- { task_id: args.task_id, idx: args.idx, total_subtasks: current.subtasks.length },
417
- false,
418
- 400
419
- );
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;
420
565
  }
421
566
 
422
- const subtasks = current.subtasks.map((subtask, idx) => ({
423
- ...subtask,
424
- done: idx === args.idx ? !subtask.done : subtask.done
425
- }));
426
- const updated = await kanban.updateTask(args.task_id, { subtasks });
427
- result = formatTaskResult(updated, returnShape);
428
- } else if (operation === 'update') {
429
- const patch = args.patch ? { ...args.patch } : {};
430
- if (args.title !== undefined) patch.title = args.title;
431
- if (args.tasks !== undefined) patch.subtasks = args.tasks;
432
- const updated = await kanban.updateTask(args.task_id, patch);
433
- result = formatTaskResult(updated, returnShape);
434
- } else {
435
- throw invalidRequest(
436
- `Unknown operation: ${operation}`,
437
- 'Use one of: move, toggle, update',
438
- { operation }
439
- );
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
+ );
440
589
  }
441
590
  break;
442
591
  }
443
592
 
444
- case 'kanban_gui_start': {
445
- result = await startGuiServer(args.port);
446
- break;
447
- }
593
+ case 'kanban_gui': {
594
+ const action = args.action;
448
595
 
449
- case 'kanban_gui_stop': {
450
- result = stopGuiServer();
451
- break;
452
- }
453
-
454
- case 'kanban_gui_status': {
455
- 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
+ }
456
616
  break;
457
617
  }
458
618
 
@@ -460,35 +620,79 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
460
620
  throw invalidRequest(`Unknown tool: ${name}`, 'Call tools/list to discover available tools', { name });
461
621
  }
462
622
 
463
- return {
464
- content: [
465
- {
466
- type: 'text',
467
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
468
- }
469
- ]
470
- };
623
+ return textResponse(result);
471
624
  } catch (error) {
472
- return {
473
- content: [
474
- {
475
- type: 'text',
476
- text: JSON.stringify(serializeError(error), null, 2)
477
- }
478
- ],
479
- isError: true
480
- };
625
+ return textResponse(serializeError(error), true);
481
626
  }
482
627
  });
483
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
+
484
672
  async function main() {
485
673
  await kanban.ensureBacklogDir();
674
+ installGuiShutdownHooks();
675
+ await maybeAutoStartGui();
486
676
  const transport = new StdioServerTransport();
487
677
  await server.connect(transport);
488
678
  console.error('kanbango MCP server running');
489
679
  }
490
680
 
491
- main().catch((error) => {
492
- console.error('Fatal error in main():', error);
493
- process.exit(1);
494
- });
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
+ }