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/AGENTS.md +7 -7
- package/API.md +20 -12
- package/CHANGELOG.md +40 -0
- package/LLM_AGENTS.md +141 -175
- package/README.md +78 -266
- package/bin/kanban.js +101 -26
- package/index.html +162 -85
- package/index.js +2 -0
- package/kan2.md +76 -0
- package/kanban.js +284 -35
- package/mcp-server.js +393 -184
- package/package.json +1 -1
- package/plan.js +159 -0
- package/planv2.md +17 -7
- package/tests/run.js +1 -0
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
|
|
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:
|
|
112
|
-
pid:
|
|
113
|
-
url:
|
|
192
|
+
port: ready.port,
|
|
193
|
+
pid: ready.pid,
|
|
194
|
+
url: ready.url
|
|
114
195
|
};
|
|
115
196
|
}
|
|
116
197
|
|
|
117
|
-
function stopGuiServer() {
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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: '
|
|
190
|
-
description: '
|
|
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:
|
|
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:
|
|
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:
|
|
334
|
+
description: "Epic group for 'create', 'update', or 'plan_create' (optional)."
|
|
208
335
|
},
|
|
209
336
|
description: {
|
|
210
337
|
type: 'string',
|
|
211
|
-
description:
|
|
338
|
+
description: "High-level context for 'create', 'update', or 'plan_create'."
|
|
212
339
|
},
|
|
213
340
|
specs: {
|
|
214
341
|
type: 'string',
|
|
215
|
-
description:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
394
|
+
return: {
|
|
272
395
|
type: 'string',
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
408
|
+
project_root: {
|
|
289
409
|
type: 'string',
|
|
290
|
-
|
|
291
|
-
|
|
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: ['
|
|
433
|
+
required: ['action'],
|
|
434
|
+
additionalProperties: false
|
|
295
435
|
}
|
|
296
436
|
},
|
|
297
437
|
{
|
|
298
|
-
name: '
|
|
299
|
-
description: '
|
|
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:
|
|
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 '
|
|
372
|
-
const
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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 '
|
|
440
|
-
|
|
441
|
-
break;
|
|
442
|
-
}
|
|
593
|
+
case 'kanban_gui': {
|
|
594
|
+
const action = args.action;
|
|
443
595
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
}
|