@andypai/agent-kanban 0.2.0 → 0.3.1

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 (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. package/ui/dist/assets/index-zWp-rB7b.js +0 -40
package/src/index.ts CHANGED
@@ -2,22 +2,16 @@
2
2
 
3
3
  import { parseArgs } from 'node:util'
4
4
  import { Database } from 'bun:sqlite'
5
- import { KanbanError, ErrorCode } from './errors.ts'
6
- import { formatOutput, error, success } from './output.ts'
7
- import { openDb, getDbPath, initSchema, migrateSchema, seedDefaultColumns } from './db.ts'
8
- import { boardInit, boardReset } from './commands/board.ts'
9
- import {
10
- columnAdd,
11
- columnDelete,
12
- columnList,
13
- columnRename,
14
- columnReorder,
15
- } from './commands/column.ts'
16
- import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk.ts'
17
- import { getConfigPath, loadConfig, saveConfig } from './config.ts'
18
- import type { CliOutput, Priority } from './types.ts'
19
- import { createProvider } from './providers/index.ts'
20
- import { unsupportedOperation } from './providers/errors.ts'
5
+ import { KanbanError, ErrorCode } from './errors'
6
+ import { formatOutput, error, success } from './output'
7
+ import { openDb, getDbPath, initSchema, migrateSchema, seedDefaultColumns } from './db'
8
+ import { boardInit, boardReset } from './commands/board'
9
+ import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from './commands/column'
10
+ import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
11
+ import { getConfigPath, loadConfig, saveConfig } from './config'
12
+ import type { CliOutput, Priority } from './types'
13
+ import { createProvider } from './providers/index'
14
+ import { unsupportedOperation } from './providers/errors'
21
15
 
22
16
  interface ParsedArgs {
23
17
  values: Record<string, unknown>
@@ -50,7 +44,7 @@ function parseCliArgs(argv: string[]): ParsedArgs {
50
44
  }
51
45
 
52
46
  function requireLocalProvider(providerType: string, feature: string): void {
53
- if (providerType === 'linear') unsupportedOperation(`${feature} is only available in local mode`)
47
+ if (providerType !== 'local') unsupportedOperation(`${feature} is only available in local mode`)
54
48
  }
55
49
 
56
50
  async function routeTask(
@@ -197,7 +191,7 @@ async function routeConfig(
197
191
  positionals: string[],
198
192
  values: Record<string, unknown>,
199
193
  ): Promise<CliOutput> {
200
- if (provider.type === 'linear') {
194
+ if (provider.type !== 'local') {
201
195
  if (action === 'show' || action === undefined) {
202
196
  return success(await provider.getConfig())
203
197
  }
@@ -357,6 +351,7 @@ Commands:
357
351
  config remove-project <name> Remove project
358
352
 
359
353
  serve Start web dashboard [--port 3000]
354
+ mcp Run as an MCP server over stdio (for Claude Desktop, etc.)
360
355
 
361
356
  Options:
362
357
  --pretty Human-readable output (default: JSON)
@@ -364,29 +359,82 @@ Options:
364
359
  --project <n> Filter/set project
365
360
  -h, --help Show this help`
366
361
 
362
+ export interface ServeOptions {
363
+ db?: string
364
+ port: number
365
+ tunnel: boolean
366
+ }
367
+
368
+ export function parseServeArgs(argv: string[]): ServeOptions {
369
+ const { values } = parseArgs({
370
+ args: argv,
371
+ options: {
372
+ db: { type: 'string' },
373
+ port: { type: 'string' },
374
+ tunnel: { type: 'boolean', default: false },
375
+ },
376
+ strict: false,
377
+ allowPositionals: true,
378
+ })
379
+ const port = values.port
380
+ ? parseInt(values.port as string, 10)
381
+ : parseInt(process.env['PORT'] || '3000', 10)
382
+ return {
383
+ db: values.db as string | undefined,
384
+ port,
385
+ tunnel: Boolean(values.tunnel),
386
+ }
387
+ }
388
+
389
+ export interface McpOptions {
390
+ db?: string
391
+ }
392
+
393
+ export function parseMcpArgs(argv: string[]): McpOptions {
394
+ const { values } = parseArgs({
395
+ args: argv,
396
+ options: { db: { type: 'string' } },
397
+ strict: false,
398
+ allowPositionals: true,
399
+ })
400
+ return { db: values.db as string | undefined }
401
+ }
402
+
367
403
  if (import.meta.main) {
368
404
  const argv = process.argv.slice(2)
369
405
 
370
- if (argv[0] === 'serve') {
371
- const portIdx = argv.indexOf('--port')
372
- const port =
373
- portIdx !== -1
374
- ? parseInt(argv[portIdx + 1]!, 10)
375
- : parseInt(process.env['PORT'] || '3000', 10)
376
-
377
- const { values } = parseArgs({
378
- args: argv,
379
- options: { db: { type: 'string' }, port: { type: 'string' } },
380
- strict: false,
381
- allowPositionals: true,
382
- })
383
-
384
- const dbPath = (values.db as string | undefined) ?? getDbPath()
406
+ if (argv[0] === 'mcp') {
407
+ const opts = parseMcpArgs(argv)
408
+ const dbPath = opts.db ?? getDbPath()
385
409
  const db = openDb(dbPath)
386
410
  migrateSchema(db)
387
411
  const provider = createProvider(db, dbPath)
388
- const { startServer } = await import('./server.ts')
389
- startServer(provider, port)
412
+ const { startStdioMcpServer } = await import('./commands/mcp')
413
+ await startStdioMcpServer(provider)
414
+ } else if (argv[0] === 'serve') {
415
+ const opts = parseServeArgs(argv)
416
+
417
+ const dbPath = opts.db ?? getDbPath()
418
+ const db = openDb(dbPath)
419
+ migrateSchema(db)
420
+ const provider = createProvider(db, dbPath)
421
+ const { startServer } = await import('./server')
422
+ startServer(provider, opts.port)
423
+
424
+ if (opts.tunnel) {
425
+ const { startCloudflareTunnel } = await import('./tunnel')
426
+ try {
427
+ const handle = startCloudflareTunnel(opts.port)
428
+ const shutdown = (): void => {
429
+ handle.stop()
430
+ process.exit(0)
431
+ }
432
+ process.on('SIGINT', shutdown)
433
+ process.on('SIGTERM', shutdown)
434
+ } catch {
435
+ // startCloudflareTunnel already logged a friendly message
436
+ }
437
+ }
390
438
  } else {
391
439
  let exitCode = 0
392
440
  const pretty = argv.includes('--pretty')
@@ -0,0 +1,193 @@
1
+ import type { KanbanProvider } from '../providers/types'
2
+ import type { Task, TaskComment } from '../types'
3
+ import { TrackerMcpError, toTrackerMcpError } from './errors'
4
+ import type { TrackerMcpHooks, TrackerMcpPolicy } from './types'
5
+
6
+ export interface TrackerCore<TScope> {
7
+ notifyAuthFailure(input: {
8
+ request: Request
9
+ durationMs: number
10
+ error: TrackerMcpError
11
+ }): Promise<void>
12
+ notifyToolError(input: {
13
+ scope: TScope | null
14
+ tool: string
15
+ ticketId?: string
16
+ durationMs: number
17
+ error: TrackerMcpError
18
+ }): Promise<void>
19
+ handlers: {
20
+ getTicket(input: { scope: TScope; ticketId: string }): Promise<Task>
21
+ listComments(input: { scope: TScope; ticketId: string }): Promise<TaskComment[]>
22
+ getBoard(input: { scope: TScope }): Promise<Awaited<ReturnType<KanbanProvider['getBoard']>>>
23
+ postComment(input: { scope: TScope; ticketId: string; body: string }): Promise<TaskComment>
24
+ updateComment(input: {
25
+ scope: TScope
26
+ ticketId: string
27
+ commentId: string
28
+ body: string
29
+ }): Promise<TaskComment>
30
+ moveTicket(input: { scope: TScope; ticketId: string; column: string }): Promise<void>
31
+ }
32
+ }
33
+
34
+ interface RunToolInput<TScope, TResult> {
35
+ scope: TScope
36
+ tool: string
37
+ ticketId?: string
38
+ execute(): Promise<TResult>
39
+ resultMeta?: Record<string, unknown> | ((result: TResult) => Record<string, unknown> | undefined)
40
+ }
41
+
42
+ async function filterComments<TScope>(
43
+ scope: TScope,
44
+ comments: TaskComment[],
45
+ policy: TrackerMcpPolicy<TScope>,
46
+ ): Promise<TaskComment[]> {
47
+ if (!policy.filterComment) return comments
48
+ const allowed = await Promise.all(
49
+ comments.map((comment) => policy.filterComment!(scope, comment)),
50
+ )
51
+ return comments.filter((_, index) => allowed[index])
52
+ }
53
+
54
+ export function createTrackerCore<TScope>(input: {
55
+ provider: KanbanProvider
56
+ policy: TrackerMcpPolicy<TScope>
57
+ hooks?: TrackerMcpHooks<TScope>
58
+ }): TrackerCore<TScope> {
59
+ const { provider, policy } = input
60
+ const hooks = input.hooks ?? {}
61
+
62
+ async function runTool<TResult>({
63
+ scope,
64
+ tool,
65
+ ticketId,
66
+ execute,
67
+ resultMeta,
68
+ }: RunToolInput<TScope, TResult>): Promise<TResult> {
69
+ const startedAt = Date.now()
70
+ await hooks.onToolStart?.({ scope, tool, ticketId })
71
+ try {
72
+ const result = await execute()
73
+ const hookResult = typeof resultMeta === 'function' ? resultMeta(result) : resultMeta
74
+ await hooks.onToolResult?.({
75
+ scope,
76
+ tool,
77
+ ticketId,
78
+ durationMs: Date.now() - startedAt,
79
+ result: hookResult,
80
+ })
81
+ return result
82
+ } catch (error) {
83
+ const trackerError = toTrackerMcpError(error)
84
+ await hooks.onToolError?.({
85
+ scope,
86
+ tool,
87
+ ticketId,
88
+ durationMs: Date.now() - startedAt,
89
+ errorCode: trackerError.code,
90
+ error: trackerError,
91
+ })
92
+ throw trackerError
93
+ }
94
+ }
95
+
96
+ return {
97
+ async notifyAuthFailure({ request, durationMs, error }) {
98
+ await hooks.onAuthFailure?.({
99
+ request,
100
+ durationMs,
101
+ errorCode: 'auth_failed',
102
+ error,
103
+ })
104
+ },
105
+
106
+ async notifyToolError({ scope, tool, ticketId, durationMs, error }) {
107
+ await hooks.onToolError?.({
108
+ scope,
109
+ tool,
110
+ ticketId,
111
+ durationMs,
112
+ errorCode: error.code,
113
+ error,
114
+ })
115
+ },
116
+
117
+ handlers: {
118
+ getTicket({ scope, ticketId }) {
119
+ return runTool({
120
+ scope,
121
+ tool: 'getTicket',
122
+ ticketId,
123
+ execute: async () => {
124
+ await policy.canReadTicket(scope, ticketId)
125
+ return provider.getTask(ticketId)
126
+ },
127
+ })
128
+ },
129
+
130
+ listComments({ scope, ticketId }) {
131
+ return runTool({
132
+ scope,
133
+ tool: 'listComments',
134
+ ticketId,
135
+ execute: async () => {
136
+ await policy.canReadTicket(scope, ticketId)
137
+ const comments = await provider.listComments(ticketId)
138
+ return filterComments(scope, comments, policy)
139
+ },
140
+ resultMeta: (comments) => ({ commentCount: comments.length }),
141
+ })
142
+ },
143
+
144
+ getBoard({ scope }) {
145
+ return runTool({
146
+ scope,
147
+ tool: 'getBoard',
148
+ execute: async () => provider.getBoard(),
149
+ })
150
+ },
151
+
152
+ postComment({ scope, ticketId, body }) {
153
+ return runTool({
154
+ scope,
155
+ tool: 'postComment',
156
+ ticketId,
157
+ execute: async () => {
158
+ await policy.canPostComment(scope, ticketId, body)
159
+ return provider.comment(ticketId, body)
160
+ },
161
+ resultMeta: (comment) => ({ commentId: comment.id }),
162
+ })
163
+ },
164
+
165
+ updateComment({ scope, ticketId, commentId, body }) {
166
+ return runTool({
167
+ scope,
168
+ tool: 'updateComment',
169
+ ticketId,
170
+ execute: async () => {
171
+ const existing = await provider.getComment(ticketId, commentId)
172
+ await policy.canUpdateComment(scope, ticketId, existing, body)
173
+ return provider.updateComment(ticketId, commentId, body)
174
+ },
175
+ resultMeta: (comment) => ({ commentId: comment.id }),
176
+ })
177
+ },
178
+
179
+ moveTicket({ scope, ticketId, column }) {
180
+ return runTool({
181
+ scope,
182
+ tool: 'moveTicket',
183
+ ticketId,
184
+ execute: async () => {
185
+ await policy.canMoveTicket(scope, ticketId, column)
186
+ await provider.moveTask(ticketId, column)
187
+ },
188
+ resultMeta: { movedTo: column },
189
+ })
190
+ },
191
+ },
192
+ }
193
+ }
@@ -0,0 +1,109 @@
1
+ import { McpError, ErrorCode as JsonRpcErrorCode } from '@modelcontextprotocol/sdk/types.js'
2
+ import { ErrorCode, type ErrorCodeValue, KanbanError } from '../errors'
3
+
4
+ export type TrackerMcpErrorCode =
5
+ | 'auth_failed'
6
+ | 'policy_denied'
7
+ | 'ticket_not_found'
8
+ | 'comment_not_found'
9
+ | 'validation_failed'
10
+ | 'provider_unavailable'
11
+ | 'internal_error'
12
+
13
+ export class TrackerMcpError extends Error {
14
+ override readonly name = 'TrackerMcpError'
15
+ readonly code: TrackerMcpErrorCode
16
+ override readonly cause?: unknown
17
+ readonly publicMessage?: string
18
+
19
+ constructor(input: {
20
+ code: TrackerMcpErrorCode
21
+ message?: string
22
+ publicMessage?: string
23
+ cause?: unknown
24
+ }) {
25
+ super(input.message ?? input.publicMessage ?? input.code)
26
+ this.code = input.code
27
+ this.cause = input.cause
28
+ this.publicMessage = input.publicMessage
29
+ }
30
+ }
31
+
32
+ function providerError(code: ErrorCodeValue): TrackerMcpErrorCode {
33
+ switch (code) {
34
+ case ErrorCode.TASK_NOT_FOUND:
35
+ return 'ticket_not_found'
36
+ case ErrorCode.COMMENT_NOT_FOUND:
37
+ return 'comment_not_found'
38
+ case ErrorCode.COLUMN_NOT_FOUND:
39
+ case ErrorCode.INVALID_METADATA:
40
+ case ErrorCode.INVALID_POSITION:
41
+ case ErrorCode.INVALID_PRIORITY:
42
+ case ErrorCode.MISSING_ARGUMENT:
43
+ case ErrorCode.UNSUPPORTED_OPERATION:
44
+ case ErrorCode.CONFLICT:
45
+ return 'validation_failed'
46
+ case ErrorCode.PROVIDER_AUTH_FAILED:
47
+ case ErrorCode.PROVIDER_RATE_LIMITED:
48
+ case ErrorCode.PROVIDER_UPSTREAM_ERROR:
49
+ case ErrorCode.PROVIDER_SYNC_REQUIRED:
50
+ case ErrorCode.PROVIDER_NOT_CONFIGURED:
51
+ return 'provider_unavailable'
52
+ default:
53
+ return 'internal_error'
54
+ }
55
+ }
56
+
57
+ export function toTrackerMcpError(error: unknown): TrackerMcpError {
58
+ if (error instanceof TrackerMcpError) return error
59
+ if (error instanceof KanbanError) {
60
+ return new TrackerMcpError({
61
+ code: providerError(error.code),
62
+ message: error.message,
63
+ publicMessage: error.message,
64
+ cause: error,
65
+ })
66
+ }
67
+ if (error instanceof Error) {
68
+ return new TrackerMcpError({
69
+ code: 'internal_error',
70
+ message: error.message,
71
+ publicMessage: error.message,
72
+ cause: error,
73
+ })
74
+ }
75
+ return new TrackerMcpError({
76
+ code: 'internal_error',
77
+ message: String(error),
78
+ publicMessage: String(error),
79
+ cause: error,
80
+ })
81
+ }
82
+
83
+ export function trackerMcpJsonRpcCode(code: TrackerMcpErrorCode): number {
84
+ switch (code) {
85
+ case 'auth_failed':
86
+ return -32001
87
+ case 'policy_denied':
88
+ return -32002
89
+ case 'ticket_not_found':
90
+ case 'comment_not_found':
91
+ return -32003
92
+ case 'validation_failed':
93
+ return JsonRpcErrorCode.InvalidParams
94
+ case 'provider_unavailable':
95
+ return -32010
96
+ case 'internal_error':
97
+ default:
98
+ return JsonRpcErrorCode.InternalError
99
+ }
100
+ }
101
+
102
+ export function toMcpError(error: unknown): McpError {
103
+ const trackerError = toTrackerMcpError(error)
104
+ return new McpError(
105
+ trackerMcpJsonRpcCode(trackerError.code),
106
+ trackerError.publicMessage ?? trackerError.message,
107
+ { trackerMcpCode: trackerError.code },
108
+ )
109
+ }
@@ -0,0 +1,13 @@
1
+ export { createTrackerCore } from './core'
2
+ export { createTrackerMcpServer } from './server'
3
+ export { TrackerMcpError, type TrackerMcpErrorCode } from './errors'
4
+ export type { TrackerCore } from './core'
5
+ export type {
6
+ TrackerMcpAuthResolver,
7
+ TrackerMcpHooks,
8
+ TrackerMcpPolicy,
9
+ TrackerMcpServer,
10
+ TrackerMcpTool,
11
+ TrackerMcpToolHandlerContext,
12
+ } from './types'
13
+ export { defaultTools } from './server'