kanban-lite 1.0.4

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.
Files changed (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,632 @@
1
+ import * as path from 'path'
2
+ import * as fs from 'fs/promises'
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
+ import { z } from 'zod'
6
+ import { KanbanSDK } from '../sdk/KanbanSDK'
7
+ import type { FeatureStatus, Priority } from '../shared/types'
8
+ import { readConfig, writeConfig, configToSettings, settingsToConfig } from '../shared/config'
9
+ import { loadWebhooks, createWebhook, deleteWebhook } from '../standalone/webhooks'
10
+
11
+ // --- Resolve features directory ---
12
+
13
+ async function findWorkspaceRoot(startDir: string): Promise<string> {
14
+ let dir = startDir
15
+ while (true) {
16
+ try {
17
+ await fs.access(path.join(dir, '.git'))
18
+ return dir
19
+ } catch { /* continue */ }
20
+ try {
21
+ await fs.access(path.join(dir, 'package.json'))
22
+ return dir
23
+ } catch { /* continue */ }
24
+ const parent = path.dirname(dir)
25
+ if (parent === dir) return startDir
26
+ dir = parent
27
+ }
28
+ }
29
+
30
+ async function resolveFeaturesDir(): Promise<string> {
31
+ // 1. CLI arg --dir
32
+ const dirIndex = process.argv.indexOf('--dir')
33
+ if (dirIndex !== -1 && process.argv[dirIndex + 1]) {
34
+ return path.resolve(process.argv[dirIndex + 1])
35
+ }
36
+ // 2. Environment variable
37
+ if (process.env.KANBAN_FEATURES_DIR) {
38
+ return path.resolve(process.env.KANBAN_FEATURES_DIR)
39
+ }
40
+ // 3. Auto-detect from cwd
41
+ const root = await findWorkspaceRoot(process.cwd())
42
+ return path.join(root, '.devtool', 'features')
43
+ }
44
+
45
+ function getTitleFromContent(content: string): string {
46
+ const match = content.match(/^#\s+(.+)$/m)
47
+ if (match) return match[1].trim()
48
+ const firstLine = content.split('\n').map(l => l.trim()).find(l => l.length > 0)
49
+ return firstLine || 'Untitled'
50
+ }
51
+
52
+ // --- Main ---
53
+
54
+ async function main(): Promise<void> {
55
+ const featuresDir = await resolveFeaturesDir()
56
+ const sdk = new KanbanSDK(featuresDir)
57
+
58
+ const server = new McpServer({
59
+ name: 'kanban-lite',
60
+ version: '1.0.0',
61
+ })
62
+
63
+ // --- Card Tools ---
64
+
65
+ server.tool(
66
+ 'list_cards',
67
+ 'List all kanban cards. Optionally filter by status, priority, assignee, or label.',
68
+ {
69
+ status: z.enum(['backlog', 'todo', 'in-progress', 'review', 'done']).optional().describe('Filter by status'),
70
+ priority: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Filter by priority'),
71
+ assignee: z.string().optional().describe('Filter by assignee name'),
72
+ label: z.string().optional().describe('Filter by label'),
73
+ },
74
+ async ({ status, priority, assignee, label }) => {
75
+ let cards = await sdk.listCards()
76
+ if (status) cards = cards.filter(c => c.status === status)
77
+ if (priority) cards = cards.filter(c => c.priority === priority)
78
+ if (assignee) cards = cards.filter(c => c.assignee === assignee)
79
+ if (label) cards = cards.filter(c => c.labels.includes(label))
80
+
81
+ const summary = cards.map(c => ({
82
+ id: c.id,
83
+ title: getTitleFromContent(c.content),
84
+ status: c.status,
85
+ priority: c.priority,
86
+ assignee: c.assignee,
87
+ labels: c.labels,
88
+ dueDate: c.dueDate,
89
+ }))
90
+
91
+ return {
92
+ content: [{
93
+ type: 'text' as const,
94
+ text: JSON.stringify(summary, null, 2),
95
+ }],
96
+ }
97
+ }
98
+ )
99
+
100
+ server.tool(
101
+ 'get_card',
102
+ 'Get full details of a specific kanban card by ID. Supports partial ID matching.',
103
+ {
104
+ cardId: z.string().describe('Card ID (or partial ID)'),
105
+ },
106
+ async ({ cardId }) => {
107
+ let card = await sdk.getCard(cardId)
108
+ if (!card) {
109
+ // Try partial match
110
+ const all = await sdk.listCards()
111
+ const matches = all.filter(c => c.id.includes(cardId))
112
+ if (matches.length === 1) {
113
+ card = matches[0]
114
+ } else if (matches.length > 1) {
115
+ return {
116
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
117
+ isError: true,
118
+ }
119
+ } else {
120
+ return {
121
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
122
+ isError: true,
123
+ }
124
+ }
125
+ }
126
+
127
+ return {
128
+ content: [{
129
+ type: 'text' as const,
130
+ text: JSON.stringify(card, null, 2),
131
+ }],
132
+ }
133
+ }
134
+ )
135
+
136
+ server.tool(
137
+ 'create_card',
138
+ 'Create a new kanban card. Returns the created card.',
139
+ {
140
+ title: z.string().describe('Card title'),
141
+ body: z.string().optional().describe('Card body/description (markdown)'),
142
+ status: z.enum(['backlog', 'todo', 'in-progress', 'review', 'done']).optional().describe('Initial status (default: backlog)'),
143
+ priority: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Priority level (default: medium)'),
144
+ assignee: z.string().optional().describe('Assignee name'),
145
+ dueDate: z.string().optional().describe('Due date (ISO format or YYYY-MM-DD)'),
146
+ labels: z.array(z.string()).optional().describe('Labels/tags'),
147
+ },
148
+ async ({ title, body, status, priority, assignee, dueDate, labels }) => {
149
+ const content = `# ${title}${body ? '\n\n' + body : ''}`
150
+
151
+ const card = await sdk.createCard({
152
+ content,
153
+ status: status as FeatureStatus | undefined,
154
+ priority: priority as Priority | undefined,
155
+ assignee: assignee || null,
156
+ dueDate: dueDate || null,
157
+ labels: labels || [],
158
+ })
159
+
160
+ return {
161
+ content: [{
162
+ type: 'text' as const,
163
+ text: JSON.stringify(card, null, 2),
164
+ }],
165
+ }
166
+ }
167
+ )
168
+
169
+ server.tool(
170
+ 'update_card',
171
+ 'Update fields of an existing kanban card. Only specified fields are changed.',
172
+ {
173
+ cardId: z.string().describe('Card ID (or partial ID)'),
174
+ status: z.enum(['backlog', 'todo', 'in-progress', 'review', 'done']).optional().describe('New status'),
175
+ priority: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('New priority'),
176
+ assignee: z.string().optional().describe('New assignee'),
177
+ dueDate: z.string().optional().describe('New due date'),
178
+ labels: z.array(z.string()).optional().describe('New labels (replaces existing)'),
179
+ content: z.string().optional().describe('New markdown content (replaces existing body)'),
180
+ },
181
+ async ({ cardId, status, priority, assignee, dueDate, labels, content }) => {
182
+ // Resolve partial ID
183
+ let resolvedId = cardId
184
+ const card = await sdk.getCard(cardId)
185
+ if (!card) {
186
+ const all = await sdk.listCards()
187
+ const matches = all.filter(c => c.id.includes(cardId))
188
+ if (matches.length === 1) {
189
+ resolvedId = matches[0].id
190
+ } else if (matches.length > 1) {
191
+ return {
192
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
193
+ isError: true,
194
+ }
195
+ } else {
196
+ return {
197
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
198
+ isError: true,
199
+ }
200
+ }
201
+ }
202
+
203
+ const updates: Record<string, unknown> = {}
204
+ if (status) updates.status = status
205
+ if (priority) updates.priority = priority
206
+ if (assignee !== undefined) updates.assignee = assignee || null
207
+ if (dueDate !== undefined) updates.dueDate = dueDate || null
208
+ if (labels) updates.labels = labels
209
+ if (content !== undefined) updates.content = content
210
+
211
+ const updated = await sdk.updateCard(resolvedId, updates)
212
+
213
+ return {
214
+ content: [{
215
+ type: 'text' as const,
216
+ text: JSON.stringify(updated, null, 2),
217
+ }],
218
+ }
219
+ }
220
+ )
221
+
222
+ server.tool(
223
+ 'move_card',
224
+ 'Move a kanban card to a different status column.',
225
+ {
226
+ cardId: z.string().describe('Card ID (or partial ID)'),
227
+ status: z.enum(['backlog', 'todo', 'in-progress', 'review', 'done']).describe('Target status column'),
228
+ },
229
+ async ({ cardId, status }) => {
230
+ // Resolve partial ID
231
+ let resolvedId = cardId
232
+ const card = await sdk.getCard(cardId)
233
+ if (!card) {
234
+ const all = await sdk.listCards()
235
+ const matches = all.filter(c => c.id.includes(cardId))
236
+ if (matches.length === 1) {
237
+ resolvedId = matches[0].id
238
+ } else if (matches.length > 1) {
239
+ return {
240
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
241
+ isError: true,
242
+ }
243
+ } else {
244
+ return {
245
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
246
+ isError: true,
247
+ }
248
+ }
249
+ }
250
+
251
+ const updated = await sdk.moveCard(resolvedId, status as FeatureStatus)
252
+
253
+ return {
254
+ content: [{
255
+ type: 'text' as const,
256
+ text: JSON.stringify({ id: updated.id, status: updated.status, order: updated.order }, null, 2),
257
+ }],
258
+ }
259
+ }
260
+ )
261
+
262
+ server.tool(
263
+ 'delete_card',
264
+ 'Permanently delete a kanban card.',
265
+ {
266
+ cardId: z.string().describe('Card ID (or partial ID)'),
267
+ },
268
+ async ({ cardId }) => {
269
+ // Resolve partial ID
270
+ let resolvedId = cardId
271
+ const card = await sdk.getCard(cardId)
272
+ if (!card) {
273
+ const all = await sdk.listCards()
274
+ const matches = all.filter(c => c.id.includes(cardId))
275
+ if (matches.length === 1) {
276
+ resolvedId = matches[0].id
277
+ } else if (matches.length > 1) {
278
+ return {
279
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
280
+ isError: true,
281
+ }
282
+ } else {
283
+ return {
284
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
285
+ isError: true,
286
+ }
287
+ }
288
+ }
289
+
290
+ await sdk.deleteCard(resolvedId)
291
+
292
+ return {
293
+ content: [{
294
+ type: 'text' as const,
295
+ text: `Deleted card: ${resolvedId}`,
296
+ }],
297
+ }
298
+ }
299
+ )
300
+
301
+ // --- Attachment Tools ---
302
+
303
+ server.tool(
304
+ 'list_attachments',
305
+ 'List all attachments on a kanban card.',
306
+ {
307
+ cardId: z.string().describe('Card ID (or partial ID)'),
308
+ },
309
+ async ({ cardId }) => {
310
+ let resolvedId = cardId
311
+ const card = await sdk.getCard(cardId)
312
+ if (!card) {
313
+ const all = await sdk.listCards()
314
+ const matches = all.filter(c => c.id.includes(cardId))
315
+ if (matches.length === 1) {
316
+ resolvedId = matches[0].id
317
+ } else if (matches.length > 1) {
318
+ return {
319
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
320
+ isError: true,
321
+ }
322
+ } else {
323
+ return {
324
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
325
+ isError: true,
326
+ }
327
+ }
328
+ }
329
+
330
+ const attachments = await sdk.listAttachments(resolvedId)
331
+ return {
332
+ content: [{
333
+ type: 'text' as const,
334
+ text: JSON.stringify(attachments, null, 2),
335
+ }],
336
+ }
337
+ }
338
+ )
339
+
340
+ server.tool(
341
+ 'add_attachment',
342
+ 'Add a file attachment to a kanban card. Copies the file to the card directory.',
343
+ {
344
+ cardId: z.string().describe('Card ID (or partial ID)'),
345
+ filePath: z.string().describe('Absolute path to the file to attach'),
346
+ },
347
+ async ({ cardId, filePath }) => {
348
+ let resolvedId = cardId
349
+ const card = await sdk.getCard(cardId)
350
+ if (!card) {
351
+ const all = await sdk.listCards()
352
+ const matches = all.filter(c => c.id.includes(cardId))
353
+ if (matches.length === 1) {
354
+ resolvedId = matches[0].id
355
+ } else if (matches.length > 1) {
356
+ return {
357
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
358
+ isError: true,
359
+ }
360
+ } else {
361
+ return {
362
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
363
+ isError: true,
364
+ }
365
+ }
366
+ }
367
+
368
+ const updated = await sdk.addAttachment(resolvedId, filePath)
369
+ return {
370
+ content: [{
371
+ type: 'text' as const,
372
+ text: JSON.stringify({ id: updated.id, attachments: updated.attachments }, null, 2),
373
+ }],
374
+ }
375
+ }
376
+ )
377
+
378
+ server.tool(
379
+ 'remove_attachment',
380
+ 'Remove an attachment from a kanban card. Only removes the reference, not the file.',
381
+ {
382
+ cardId: z.string().describe('Card ID (or partial ID)'),
383
+ attachment: z.string().describe('Attachment filename to remove'),
384
+ },
385
+ async ({ cardId, attachment }) => {
386
+ let resolvedId = cardId
387
+ const card = await sdk.getCard(cardId)
388
+ if (!card) {
389
+ const all = await sdk.listCards()
390
+ const matches = all.filter(c => c.id.includes(cardId))
391
+ if (matches.length === 1) {
392
+ resolvedId = matches[0].id
393
+ } else if (matches.length > 1) {
394
+ return {
395
+ content: [{ type: 'text' as const, text: `Multiple cards match "${cardId}": ${matches.map(m => m.id).join(', ')}` }],
396
+ isError: true,
397
+ }
398
+ } else {
399
+ return {
400
+ content: [{ type: 'text' as const, text: `Card not found: ${cardId}` }],
401
+ isError: true,
402
+ }
403
+ }
404
+ }
405
+
406
+ const updated = await sdk.removeAttachment(resolvedId, attachment)
407
+ return {
408
+ content: [{
409
+ type: 'text' as const,
410
+ text: JSON.stringify({ id: updated.id, attachments: updated.attachments }, null, 2),
411
+ }],
412
+ }
413
+ }
414
+ )
415
+
416
+ // --- Column Tools ---
417
+
418
+ server.tool(
419
+ 'list_columns',
420
+ 'List all kanban board columns.',
421
+ {},
422
+ async () => {
423
+ const columns = await sdk.listColumns()
424
+ return {
425
+ content: [{
426
+ type: 'text' as const,
427
+ text: JSON.stringify(columns, null, 2),
428
+ }],
429
+ }
430
+ }
431
+ )
432
+
433
+ server.tool(
434
+ 'add_column',
435
+ 'Add a new column to the kanban board.',
436
+ {
437
+ id: z.string().describe('Unique column ID (used in card status field)'),
438
+ name: z.string().describe('Display name for the column'),
439
+ color: z.string().describe('Column color (hex format, e.g. "#3b82f6")'),
440
+ },
441
+ async ({ id, name, color }) => {
442
+ const columns = await sdk.addColumn({ id, name, color })
443
+ return {
444
+ content: [{
445
+ type: 'text' as const,
446
+ text: JSON.stringify(columns, null, 2),
447
+ }],
448
+ }
449
+ }
450
+ )
451
+
452
+ server.tool(
453
+ 'update_column',
454
+ 'Update an existing kanban board column.',
455
+ {
456
+ columnId: z.string().describe('Column ID to update'),
457
+ name: z.string().optional().describe('New display name'),
458
+ color: z.string().optional().describe('New color (hex format)'),
459
+ },
460
+ async ({ columnId, name, color }) => {
461
+ const updates: Record<string, string> = {}
462
+ if (name) updates.name = name
463
+ if (color) updates.color = color
464
+ const columns = await sdk.updateColumn(columnId, updates)
465
+ return {
466
+ content: [{
467
+ type: 'text' as const,
468
+ text: JSON.stringify(columns, null, 2),
469
+ }],
470
+ }
471
+ }
472
+ )
473
+
474
+ server.tool(
475
+ 'remove_column',
476
+ 'Remove a column from the kanban board. Fails if any cards are in the column.',
477
+ {
478
+ columnId: z.string().describe('Column ID to remove'),
479
+ },
480
+ async ({ columnId }) => {
481
+ const columns = await sdk.removeColumn(columnId)
482
+ return {
483
+ content: [{
484
+ type: 'text' as const,
485
+ text: JSON.stringify(columns, null, 2),
486
+ }],
487
+ }
488
+ }
489
+ )
490
+
491
+ // --- Settings Tools ---
492
+
493
+ const workspaceRoot = path.dirname(featuresDir)
494
+
495
+ server.tool(
496
+ 'get_settings',
497
+ 'Get the current kanban board display settings.',
498
+ {},
499
+ async () => {
500
+ const config = readConfig(workspaceRoot)
501
+ const settings = configToSettings(config)
502
+ return {
503
+ content: [{
504
+ type: 'text' as const,
505
+ text: JSON.stringify(settings, null, 2),
506
+ }],
507
+ }
508
+ }
509
+ )
510
+
511
+ server.tool(
512
+ 'update_settings',
513
+ 'Update kanban board display settings. Only specified fields are changed.',
514
+ {
515
+ showPriorityBadges: z.boolean().optional().describe('Show priority badges on cards'),
516
+ showAssignee: z.boolean().optional().describe('Show assignee on cards'),
517
+ showDueDate: z.boolean().optional().describe('Show due date on cards'),
518
+ showLabels: z.boolean().optional().describe('Show labels on cards'),
519
+ showFileName: z.boolean().optional().describe('Show file name on cards'),
520
+ compactMode: z.boolean().optional().describe('Enable compact card display'),
521
+ defaultPriority: z.enum(['critical', 'high', 'medium', 'low']).optional().describe('Default priority for new cards'),
522
+ defaultStatus: z.enum(['backlog', 'todo', 'in-progress', 'review', 'done']).optional().describe('Default status for new cards'),
523
+ },
524
+ async (updates) => {
525
+ const config = readConfig(workspaceRoot)
526
+ const settings = configToSettings(config)
527
+ const merged = { ...settings }
528
+ for (const [key, value] of Object.entries(updates)) {
529
+ if (value !== undefined) {
530
+ (merged as unknown as Record<string, unknown>)[key] = value
531
+ }
532
+ }
533
+ writeConfig(workspaceRoot, settingsToConfig(config, merged))
534
+ const updated = configToSettings(readConfig(workspaceRoot))
535
+ return {
536
+ content: [{
537
+ type: 'text' as const,
538
+ text: JSON.stringify(updated, null, 2),
539
+ }],
540
+ }
541
+ }
542
+ )
543
+
544
+ // --- Webhook Tools ---
545
+
546
+ server.tool(
547
+ 'list_webhooks',
548
+ 'List all registered webhooks.',
549
+ {},
550
+ async () => {
551
+ const webhooks = loadWebhooks(workspaceRoot)
552
+ return {
553
+ content: [{
554
+ type: 'text' as const,
555
+ text: JSON.stringify(webhooks, null, 2),
556
+ }],
557
+ }
558
+ }
559
+ )
560
+
561
+ server.tool(
562
+ 'add_webhook',
563
+ 'Register a new webhook to receive event notifications.',
564
+ {
565
+ url: z.string().describe('Webhook target URL (HTTP/HTTPS)'),
566
+ events: z.array(z.string()).optional().describe('Events to subscribe to (e.g. ["task.created", "task.updated"]). Default: ["*"] for all.'),
567
+ secret: z.string().optional().describe('Optional HMAC-SHA256 signing secret'),
568
+ },
569
+ async ({ url, events, secret }) => {
570
+ const webhook = createWebhook(workspaceRoot, {
571
+ url,
572
+ events: events || ['*'],
573
+ secret,
574
+ })
575
+ return {
576
+ content: [{
577
+ type: 'text' as const,
578
+ text: JSON.stringify(webhook, null, 2),
579
+ }],
580
+ }
581
+ }
582
+ )
583
+
584
+ server.tool(
585
+ 'remove_webhook',
586
+ 'Remove a registered webhook by ID.',
587
+ {
588
+ webhookId: z.string().describe('Webhook ID (e.g. "wh_abc123")'),
589
+ },
590
+ async ({ webhookId }) => {
591
+ const removed = deleteWebhook(workspaceRoot, webhookId)
592
+ if (!removed) {
593
+ return {
594
+ content: [{ type: 'text' as const, text: `Webhook not found: ${webhookId}` }],
595
+ isError: true,
596
+ }
597
+ }
598
+ return {
599
+ content: [{
600
+ type: 'text' as const,
601
+ text: `Deleted webhook: ${webhookId}`,
602
+ }],
603
+ }
604
+ }
605
+ )
606
+
607
+ // --- Workspace Info Tool ---
608
+
609
+ server.tool(
610
+ 'get_workspace_info',
611
+ 'Get the workspace root path and features directory.',
612
+ {},
613
+ async () => {
614
+ return {
615
+ content: [{
616
+ type: 'text' as const,
617
+ text: JSON.stringify({ workspaceRoot, featuresDir }, null, 2),
618
+ }],
619
+ }
620
+ }
621
+ )
622
+
623
+ // --- Start server ---
624
+
625
+ const transport = new StdioServerTransport()
626
+ await server.connect(transport)
627
+ }
628
+
629
+ main().catch(err => {
630
+ console.error(`MCP Server error: ${err.message}`)
631
+ process.exit(1)
632
+ })