@andypai/agent-kanban 0.6.2 → 0.6.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.
@@ -5,6 +5,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
5
5
  import { addTask, initSchema, seedDefaultColumns } from '../db'
6
6
  import { createTrackerCore, createTrackerMcpServer, TrackerMcpError } from '../mcp/index'
7
7
  import { LocalProvider } from '../providers/local'
8
+ import { VERSION } from '../version'
8
9
 
9
10
  interface TestScope {
10
11
  actor: string
@@ -130,6 +131,22 @@ describe('createTrackerMcpServer', () => {
130
131
  }
131
132
  })
132
133
 
134
+ test('advertises the package version rather than a hard-coded one', async () => {
135
+ const runtime = startTrackerServer()
136
+ const transport = new StreamableHTTPClientTransport(runtime.url, {
137
+ requestInit: { headers: { Authorization: 'Bearer good-token' } },
138
+ })
139
+ const client = new Client({ name: 'test-client', version: '1.0.0' })
140
+
141
+ try {
142
+ await client.connect(transport)
143
+ expect(client.getServerVersion()?.version).toBe(VERSION)
144
+ } finally {
145
+ await client.close()
146
+ await runtime.close()
147
+ }
148
+ })
149
+
133
150
  test('round-trips updateComment end-to-end, handing the existing comment to the policy', async () => {
134
151
  const task = addTask(db, 'MCP task')
135
152
  const created = await provider.comment(task.id, 'original body')
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { assembleBoardMetrics, classifyColumnRoles } from '../metrics-spec'
3
+ import type { ActivityEntry } from '../types'
4
+
5
+ const columnCounts = [
6
+ { id: 'c1', name: 'Backlog', position: 0, count: 4 },
7
+ { id: 'c2', name: 'In Progress', position: 1, count: 2 },
8
+ { id: 'c3', name: 'Done', position: 2, count: 3 },
9
+ ]
10
+
11
+ describe('assembleBoardMetrics', () => {
12
+ test('normalizes priority order by severity regardless of input order', () => {
13
+ const metrics = assembleBoardMetrics({
14
+ columnCounts,
15
+ // Deliberately unsorted / alphabetical-ish to prove the spec re-orders.
16
+ priorityCounts: [
17
+ { priority: 'low', count: 1 },
18
+ { priority: 'urgent', count: 2 },
19
+ { priority: 'medium', count: 3 },
20
+ { priority: 'high', count: 4 },
21
+ ],
22
+ totalTasks: 9,
23
+ tasksCreatedThisWeek: 5,
24
+ avgCompletionHours: 12,
25
+ recentActivity: [],
26
+ assignees: ['amy'],
27
+ projects: ['Dispatch'],
28
+ })
29
+ expect(metrics.tasksByPriority.map((row) => row.priority)).toEqual([
30
+ 'urgent',
31
+ 'high',
32
+ 'medium',
33
+ 'low',
34
+ ])
35
+ })
36
+
37
+ test('derives completed / in-progress counts and completion percent from column roles', () => {
38
+ const metrics = assembleBoardMetrics({
39
+ columnCounts,
40
+ priorityCounts: [],
41
+ totalTasks: 9,
42
+ tasksCreatedThisWeek: 0,
43
+ avgCompletionHours: null,
44
+ recentActivity: [],
45
+ assignees: [],
46
+ projects: [],
47
+ })
48
+ expect(metrics.completedTasks).toBe(3) // Done column
49
+ expect(metrics.inProgressCount).toBe(2) // In Progress column
50
+ expect(metrics.completionPercent).toBe(33) // round(3/9 * 100)
51
+ expect(metrics.tasksByColumn).toEqual([
52
+ { column_name: 'Backlog', count: 4 },
53
+ { column_name: 'In Progress', count: 2 },
54
+ { column_name: 'Done', count: 3 },
55
+ ])
56
+ })
57
+
58
+ test('completion percent is zero when there are no tasks', () => {
59
+ const metrics = assembleBoardMetrics({
60
+ columnCounts: [{ id: 'c1', name: 'Backlog', position: 0, count: 0 }],
61
+ priorityCounts: [],
62
+ totalTasks: 0,
63
+ tasksCreatedThisWeek: 0,
64
+ avgCompletionHours: null,
65
+ recentActivity: [],
66
+ assignees: [],
67
+ projects: [],
68
+ })
69
+ expect(metrics.completionPercent).toBe(0)
70
+ expect(metrics.completedTasks).toBe(0)
71
+ })
72
+
73
+ test('passes through avg hours, activity, assignees, and projects unchanged', () => {
74
+ const activity: ActivityEntry[] = [
75
+ {
76
+ id: 'a1',
77
+ task_id: 't1',
78
+ action: 'created',
79
+ field_changed: null,
80
+ old_value: null,
81
+ new_value: 'Task',
82
+ timestamp: '2026-01-01T00:00:00Z',
83
+ },
84
+ ]
85
+ const metrics = assembleBoardMetrics({
86
+ columnCounts,
87
+ priorityCounts: [],
88
+ totalTasks: 9,
89
+ tasksCreatedThisWeek: 5,
90
+ avgCompletionHours: 7.5,
91
+ recentActivity: activity,
92
+ assignees: ['amy', 'bob'],
93
+ projects: ['Dispatch'],
94
+ })
95
+ expect(metrics.avgCompletionHours).toBe(7.5)
96
+ expect(metrics.recentActivity).toBe(activity)
97
+ expect(metrics.tasksCreatedThisWeek).toBe(5)
98
+ expect(metrics.assignees).toEqual(['amy', 'bob'])
99
+ expect(metrics.projects).toEqual(['Dispatch'])
100
+ })
101
+ })
102
+
103
+ describe('classifyColumnRoles', () => {
104
+ test('recognizes done/in-progress synonyms and falls back to the terminal column', () => {
105
+ const roles = classifyColumnRoles([
106
+ { id: 'c1', name: 'Backlog', position: 0 },
107
+ { id: 'c2', name: 'WIP', position: 1 },
108
+ { id: 'c3', name: 'Shipped', position: 2 },
109
+ ])
110
+ expect(roles.doneColumnIds).toEqual(['c3'])
111
+ expect(roles.inProgressColumnIds).toEqual(['c2'])
112
+ })
113
+
114
+ test('falls back to the last column when no name reads as done', () => {
115
+ const roles = classifyColumnRoles([
116
+ { id: 'c1', name: 'Backlog', position: 0 },
117
+ { id: 'c2', name: 'Later', position: 1 },
118
+ ])
119
+ expect(roles.doneColumnIds).toEqual(['c2'])
120
+ })
121
+ })
@@ -106,10 +106,14 @@ function linearFetchStub(calls: LinearStubCall[] = []): typeof fetch {
106
106
  return Response.json({ data: { team } })
107
107
  }
108
108
  if (query.includes('query Users')) {
109
- return Response.json({ data: { users } })
109
+ return Response.json({
110
+ data: { users: { ...users, pageInfo: { hasNextPage: false, endCursor: null } } },
111
+ })
110
112
  }
111
113
  if (query.includes('query Projects')) {
112
- return Response.json({ data: { projects } })
114
+ return Response.json({
115
+ data: { projects: { ...projects, pageInfo: { hasNextPage: false, endCursor: null } } },
116
+ })
113
117
  }
114
118
  if (query.includes('query IssueLabels')) {
115
119
  return Response.json({
@@ -121,6 +125,10 @@ function linearFetchStub(calls: LinearStubCall[] = []): typeof fetch {
121
125
  },
122
126
  })
123
127
  }
128
+ if (query.includes('query IssueById')) {
129
+ const issue = issues.find((candidate) => candidate.id === variables.id) ?? null
130
+ return Response.json({ data: { issue } })
131
+ }
124
132
  if (query.includes('query Issues')) {
125
133
  return Response.json({
126
134
  data: {
@@ -113,6 +113,146 @@ describe('postgres local provider', () => {
113
113
  expect(comments[0]!.body).toBe('Projection comment stored in Postgres')
114
114
  })
115
115
 
116
+ pgTest('logs activity for project/description/metadata updates and comment writes', async () => {
117
+ const runtime = await openKanbanRuntime()
118
+ try {
119
+ const provider = runtime.provider
120
+ const created = await provider.createTask({ title: 'Activity task' })
121
+ await provider.updateTask(created.id, {
122
+ project: 'Dispatch',
123
+ description: 'updated description',
124
+ metadata: '{"k":"v"}',
125
+ })
126
+ const comment = await provider.comment(created.id, 'first comment')
127
+ await provider.updateComment(created.id, comment.id, 'edited comment')
128
+
129
+ const activity = await provider.getActivity(50, created.id)
130
+ const fields = activity.map((entry) => entry.field_changed)
131
+ expect(fields).toContain('project')
132
+ expect(fields).toContain('description')
133
+ expect(fields).toContain('metadata')
134
+
135
+ const commentEntries = activity.filter((entry) => entry.field_changed === 'comment')
136
+ expect(commentEntries).toHaveLength(2)
137
+ expect(commentEntries.some((entry) => entry.new_value === 'first comment')).toBe(true)
138
+ expect(commentEntries.some((entry) => entry.new_value === 'edited comment')).toBe(true)
139
+ } finally {
140
+ await runtime.close()
141
+ }
142
+ })
143
+
144
+ pgTest('listTasks pushes filter, sort, and limit down to SQL', async () => {
145
+ const runtime = await openKanbanRuntime()
146
+ try {
147
+ const provider = runtime.provider
148
+ const alpha = await provider.createTask({
149
+ title: 'Alpha',
150
+ priority: 'low',
151
+ assignee: 'amy',
152
+ project: 'Dispatch',
153
+ })
154
+ await provider.createTask({
155
+ title: 'Beta',
156
+ priority: 'high',
157
+ assignee: 'amy',
158
+ project: 'Dispatch',
159
+ })
160
+ await provider.createTask({
161
+ title: 'Gamma',
162
+ priority: 'high',
163
+ assignee: 'bob',
164
+ project: 'Other',
165
+ })
166
+
167
+ // Filter pushdown: only amy's Dispatch tasks come back.
168
+ const byAssignee = await provider.listTasks({ assignee: 'amy', project: 'Dispatch' })
169
+ expect(byAssignee.map((task) => task.title).sort()).toEqual(['Alpha', 'Beta'])
170
+
171
+ // Filter + priority pushdown.
172
+ const highPriority = await provider.listTasks({ priority: 'high' })
173
+ expect(highPriority.map((task) => task.title).sort()).toEqual(['Beta', 'Gamma'])
174
+
175
+ // Sort + limit pushdown: title order, capped at one row.
176
+ const firstByTitle = await provider.listTasks({ sort: 'title', limit: 1 })
177
+ expect(firstByTitle).toHaveLength(1)
178
+ expect(firstByTitle[0]?.title).toBe('Alpha')
179
+
180
+ // Comment counts are scoped per returned row.
181
+ await provider.comment(alpha.id, 'a note')
182
+ const withCounts = await provider.listTasks({ assignee: 'amy', project: 'Dispatch' })
183
+ const alphaRow = withCounts.find((task) => task.id === alpha.id)
184
+ const betaRow = withCounts.find((task) => task.title === 'Beta')
185
+ expect(alphaRow?.comment_count).toBe(1)
186
+ expect(betaRow?.comment_count).toBe(0)
187
+ } finally {
188
+ await runtime.close()
189
+ }
190
+ })
191
+
192
+ pgTest('reports configEdit:false and refuses config edits', async () => {
193
+ const runtime = await openKanbanRuntime()
194
+ try {
195
+ const context = await runtime.provider.getContext()
196
+ expect(context.capabilities.configEdit).toBe(false)
197
+ // Board administration has no Postgres provider path.
198
+ expect(context.capabilities.columnCrud).toBe(false)
199
+ expect(context.capabilities.bulk).toBe(false)
200
+
201
+ await expect(runtime.provider.patchConfig({ projects: ['Anything'] })).rejects.toMatchObject({
202
+ code: 'UNSUPPORTED_OPERATION',
203
+ })
204
+ } finally {
205
+ await runtime.close()
206
+ }
207
+ })
208
+
209
+ pgTest('migrates a pre-existing tasks table missing project/labels/revision', async () => {
210
+ // Simulate an older Postgres-local database created before project, labels,
211
+ // and revision columns existed. CREATE TABLE IF NOT EXISTS would not add
212
+ // them, so the provider's migration must backfill before any insert.
213
+ await sql!`
214
+ CREATE TABLE columns (
215
+ id TEXT PRIMARY KEY,
216
+ name TEXT UNIQUE NOT NULL,
217
+ position INTEGER NOT NULL,
218
+ color TEXT,
219
+ created_at TEXT NOT NULL,
220
+ updated_at TEXT NOT NULL
221
+ )
222
+ `
223
+ await sql!`
224
+ CREATE TABLE tasks (
225
+ id TEXT PRIMARY KEY,
226
+ title TEXT NOT NULL,
227
+ description TEXT NOT NULL DEFAULT '',
228
+ column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE RESTRICT,
229
+ position INTEGER NOT NULL DEFAULT 0,
230
+ priority TEXT NOT NULL DEFAULT 'medium',
231
+ assignee TEXT NOT NULL DEFAULT '',
232
+ metadata TEXT NOT NULL DEFAULT '{}',
233
+ created_at TEXT NOT NULL,
234
+ updated_at TEXT NOT NULL
235
+ )
236
+ `
237
+
238
+ // First provider use runs ensureSchema/migrateTasksTable and must succeed.
239
+ const created = expectOk<TaskWithColumn>(
240
+ await run(['task', 'add', 'After migration', '--project', 'Dispatch', '--label', 'smoke']),
241
+ )
242
+ expect(created.title).toBe('After migration')
243
+ expect(created.project).toBe('Dispatch')
244
+ expect(created.labels).toEqual(['smoke'])
245
+ expect(created.version).toBe('0')
246
+
247
+ const columnRows = await sql!<{ column_name: string }[]>`
248
+ SELECT column_name FROM information_schema.columns WHERE table_name = 'tasks'
249
+ `
250
+ const columnNames = columnRows.map((row) => row.column_name)
251
+ expect(columnNames).toContain('project')
252
+ expect(columnNames).toContain('labels')
253
+ expect(columnNames).toContain('revision')
254
+ })
255
+
116
256
  pgTest('seeds custom default columns for Garage local compose', async () => {
117
257
  process.env['KANBAN_DEFAULT_COLUMNS'] = 'Todo,In Progress,Human Review,Merging,Done'
118
258
 
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { trackerConfigFromEnv } from '../tracker-config'
3
+ import { KanbanError } from '../errors'
4
+
5
+ describe('trackerConfigFromEnv default columns', () => {
6
+ test('parses comma-separated default columns for the local provider', () => {
7
+ const config = trackerConfigFromEnv({ KANBAN_DEFAULT_COLUMNS: 'Todo, Doing , Done' })
8
+ expect(config).toMatchObject({ provider: 'local', defaultColumns: ['Todo', 'Doing', 'Done'] })
9
+ })
10
+
11
+ test('rejects case-insensitive duplicate default columns', () => {
12
+ expect(() => trackerConfigFromEnv({ KANBAN_DEFAULT_COLUMNS: 'Done,done' })).toThrow(KanbanError)
13
+ expect(() => trackerConfigFromEnv({ KANBAN_DEFAULT_COLUMNS: 'Done,done' })).toThrow(
14
+ /duplicate column name/i,
15
+ )
16
+ })
17
+
18
+ test('leaves default columns unset when the variable is absent', () => {
19
+ const config = trackerConfigFromEnv({})
20
+ expect(config).toMatchObject({ provider: 'local' })
21
+ expect((config as { defaultColumns?: string[] }).defaultColumns).toBeUndefined()
22
+ })
23
+ })
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { parsePositiveInt } from '../transport-input'
3
+
4
+ describe('parsePositiveInt', () => {
5
+ test('returns undefined for absent values', () => {
6
+ expect(parsePositiveInt(null)).toBeUndefined()
7
+ expect(parsePositiveInt(undefined)).toBeUndefined()
8
+ expect(parsePositiveInt('')).toBeUndefined()
9
+ })
10
+
11
+ test('accepts positive integers', () => {
12
+ expect(parsePositiveInt('1')).toBe(1)
13
+ expect(parsePositiveInt('100')).toBe(100)
14
+ })
15
+
16
+ test.each([
17
+ '0',
18
+ '-5',
19
+ '3.9',
20
+ 'abc',
21
+ '5abc',
22
+ 'NaN',
23
+ 'Infinity',
24
+ '1e100',
25
+ '1e3',
26
+ '0x10',
27
+ '1000000000000000000000000',
28
+ ])('rejects invalid value %p', (value) => {
29
+ expect(() => parsePositiveInt(value)).toThrow(/positive integer/)
30
+ })
31
+
32
+ test('uses the provided field name in the message', () => {
33
+ expect(() => parsePositiveInt('-1', 'count')).toThrow(/count must be a positive integer/)
34
+ })
35
+ })
package/src/api.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { KanbanError, ErrorCode } from './errors'
2
2
  import type { BoardConfig, CliOutput, Task } from './types'
3
3
  import type { CreateTaskInput, UpdateTaskInput, KanbanProvider } from './providers/types'
4
+ import { parsePositiveInt } from './transport-input'
4
5
  import * as useCases from './use-cases'
5
6
 
6
7
  export type WsEvent =
@@ -21,15 +22,21 @@ function json(data: unknown, status = 200): Response {
21
22
  return Response.json(data, { status })
22
23
  }
23
24
 
24
- function parseOptionalInt(value: string | null): number | undefined {
25
- return value ? parseInt(value, 10) : undefined
25
+ function requireArgument(value: unknown, field: string): void {
26
+ if (!value) {
27
+ throw new KanbanError(ErrorCode.MISSING_ARGUMENT, `${field} is required`)
28
+ }
26
29
  }
27
30
 
28
- function missingArgument(field: string): Response {
29
- return json(
30
- { ok: false, error: { code: 'MISSING_ARGUMENT', message: `${field} is required` } },
31
- 400,
32
- )
31
+ // Parse a JSON request body inside a wrapHandler scope so malformed/empty/
32
+ // wrong-content-type bodies surface through the same { ok:false, error }
33
+ // envelope as every other validation failure instead of escaping as a raw 500.
34
+ async function parseJsonBody<T>(req: Request): Promise<T> {
35
+ try {
36
+ return (await req.json()) as T
37
+ } catch {
38
+ throw new KanbanError(ErrorCode.INVALID_REQUEST_BODY, 'Request body must be valid JSON')
39
+ }
33
40
  }
34
41
 
35
42
  function statusForCode(code: string): number {
@@ -131,7 +138,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
131
138
  const assignee = url.searchParams.get('assignee') ?? undefined
132
139
  const project = url.searchParams.get('project') ?? undefined
133
140
  const sort = url.searchParams.get('sort') ?? undefined
134
- const limit = parseOptionalInt(url.searchParams.get('limit'))
141
+ const limit = parsePositiveInt(url.searchParams.get('limit'))
135
142
  return {
136
143
  ok: true,
137
144
  data: await useCases.listTasks(provider, {
@@ -149,10 +156,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
149
156
  }
150
157
 
151
158
  if (path === '/api/tasks' && method === 'POST') {
152
- const body = (await req.json()) as Partial<CreateTaskInput>
153
- if (!body.title) return { response: missingArgument('title'), mutated: false }
154
159
  let created: Task | null = null
155
160
  const response = await wrapHandler(async () => {
161
+ const body = await parseJsonBody<Partial<CreateTaskInput>>(req)
162
+ requireArgument(body.title, 'title')
156
163
  created = await useCases.createTask(provider, {
157
164
  title: body.title!,
158
165
  description: body.description,
@@ -184,9 +191,9 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
184
191
  }
185
192
 
186
193
  if (method === 'PATCH') {
187
- const body = (await req.json()) as UpdateTaskInput
188
194
  let updated: Task | null = null
189
195
  const response = await wrapHandler(async () => {
196
+ const body = await parseJsonBody<UpdateTaskInput>(req)
190
197
  updated = await useCases.updateTask(provider, id, body)
191
198
  return { ok: true, data: updated }
192
199
  })
@@ -207,10 +214,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
207
214
  const moveMatch = path.match(/^\/api\/tasks\/([^/]+)\/move$/)
208
215
  if (moveMatch && method === 'PATCH') {
209
216
  const id = decodeURIComponent(moveMatch[1]!)
210
- const body = (await req.json()) as MoveTaskBody
211
- if (!body.column) return { response: missingArgument('column'), mutated: false }
212
217
  let moved: Task | null = null
213
218
  const response = await wrapHandler(async () => {
219
+ const body = await parseJsonBody<MoveTaskBody>(req)
220
+ requireArgument(body.column, 'column')
214
221
  moved = await useCases.moveTask(provider, id, body.column!)
215
222
  return { ok: true, data: moved }
216
223
  })
@@ -232,12 +239,11 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
232
239
  }
233
240
 
234
241
  if (method === 'POST') {
235
- const body = (await req.json()) as CommentBody
236
- if (!body.body) return { response: missingArgument('body'), mutated: false }
237
- const response = await wrapHandler(async () => ({
238
- ok: true,
239
- data: await useCases.addComment(provider, id, body.body!),
240
- }))
242
+ const response = await wrapHandler(async () => {
243
+ const body = await parseJsonBody<CommentBody>(req)
244
+ requireArgument(body.body, 'body')
245
+ return { ok: true, data: await useCases.addComment(provider, id, body.body!) }
246
+ })
241
247
  return { response, mutated: response.ok }
242
248
  }
243
249
  }
@@ -248,12 +254,11 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
248
254
  const commentId = decodeURIComponent(commentMatch[2]!)
249
255
 
250
256
  if (method === 'PATCH') {
251
- const body = (await req.json()) as CommentBody
252
- if (!body.body) return { response: missingArgument('body'), mutated: false }
253
- const response = await wrapHandler(async () => ({
254
- ok: true,
255
- data: await useCases.updateComment(provider, id, commentId, body.body!),
256
- }))
257
+ const response = await wrapHandler(async () => {
258
+ const body = await parseJsonBody<CommentBody>(req)
259
+ requireArgument(body.body, 'body')
260
+ return { ok: true, data: await useCases.updateComment(provider, id, commentId, body.body!) }
261
+ })
257
262
  return { response, mutated: response.ok }
258
263
  }
259
264
  }
@@ -262,7 +267,7 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
262
267
  return {
263
268
  response: await wrapHandler(async () => {
264
269
  const taskId = url.searchParams.get('taskId') ?? undefined
265
- const limit = parseOptionalInt(url.searchParams.get('limit'))
270
+ const limit = parsePositiveInt(url.searchParams.get('limit'))
266
271
  return { ok: true, data: await useCases.getActivity(provider, limit, taskId) }
267
272
  }),
268
273
  mutated: false,
@@ -290,11 +295,10 @@ export async function handleRequest(provider: KanbanProvider, req: Request): Pro
290
295
  }
291
296
 
292
297
  if (path === '/api/config' && method === 'PATCH') {
293
- const body = (await req.json()) as Partial<BoardConfig>
294
- const response = await wrapHandler(async () => ({
295
- ok: true,
296
- data: await useCases.patchConfig(provider, body),
297
- }))
298
+ const response = await wrapHandler(async () => {
299
+ const body = await parseJsonBody<Partial<BoardConfig>>(req)
300
+ return { ok: true, data: await useCases.patchConfig(provider, body) }
301
+ })
298
302
  return { response, mutated: response.ok }
299
303
  }
300
304
 
@@ -9,6 +9,7 @@ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv
9
9
  import { createTrackerCore, defaultTools, TrackerMcpError } from '../mcp/index'
10
10
  import type { TrackerMcpPolicy, TrackerMcpTool } from '../mcp/index'
11
11
  import type { KanbanProvider } from '../providers/types'
12
+ import { VERSION } from '../version'
12
13
 
13
14
  type LocalScope = Record<string, never>
14
15
 
@@ -38,7 +39,7 @@ export async function startStdioMcpServer(provider: KanbanProvider): Promise<voi
38
39
  )
39
40
 
40
41
  const server = new Server(
41
- { name: 'agent-kanban', version: '1.0.0' },
42
+ { name: 'agent-kanban', version: VERSION },
42
43
  { capabilities: { tools: {} } },
43
44
  )
44
45
 
package/src/db.ts CHANGED
@@ -697,7 +697,7 @@ export function bulkMoveAll(
697
697
  .all({ $col: fromCol.id }) as { id: string }[]
698
698
 
699
699
  const stmt = db.prepare(
700
- "UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now') WHERE id = $id",
700
+ "UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now'), revision = revision + 1 WHERE id = $id",
701
701
  )
702
702
  db.transaction(() => {
703
703
  tasks.forEach(({ id }, i) => {
package/src/errors.ts CHANGED
@@ -11,6 +11,8 @@ export const ErrorCode = {
11
11
  INVALID_METADATA: 'INVALID_METADATA',
12
12
  INVALID_POSITION: 'INVALID_POSITION',
13
13
  INVALID_CONFIG: 'INVALID_CONFIG',
14
+ INVALID_REQUEST_BODY: 'INVALID_REQUEST_BODY',
15
+ INVALID_ARGUMENT: 'INVALID_ARGUMENT',
14
16
  CONFLICT: 'CONFLICT',
15
17
  MISSING_ARGUMENT: 'MISSING_ARGUMENT',
16
18
  UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { boardInit, boardReset } from './commands/board'
9
9
  import { columnAdd, columnDelete, columnList, columnRename, columnReorder } from './commands/column'
10
10
  import { bulkClearDoneCmd, bulkMoveAllCmd } from './commands/bulk'
11
11
  import { getConfigPath, loadConfig, saveConfig } from './config'
12
+ import { parsePositiveInt } from './transport-input'
12
13
  import type { CliOutput, Priority } from './types'
13
14
  import { unsupportedOperation } from './providers/errors'
14
15
  import { openKanbanRuntime } from './provider-runtime'
@@ -83,7 +84,7 @@ async function routeTask(
83
84
  priority: values.p as string | undefined,
84
85
  assignee: values.a as string | undefined,
85
86
  project: values.project as string | undefined,
86
- limit: values.l ? parseInt(values.l as string, 10) : undefined,
87
+ limit: parsePositiveInt(values.l as string | undefined),
87
88
  sort: values.sort as string | undefined,
88
89
  }),
89
90
  )
@@ -374,6 +375,10 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
374
375
  output = routeBulk(sqliteDb, provider.type, action, positionals)
375
376
  break
376
377
  case 'config':
378
+ // routeConfig persists to the SQLite-side config file, so it only runs
379
+ // when a SQLite database is present. Postgres-local has no config
380
+ // repository (configEdit:false), so edits are refused here — matching the
381
+ // HTTP API, which fails through the provider's patchConfig.
377
382
  if (sqliteDb) {
378
383
  output = await routeConfig(provider, dbPath, action, positionals, values)
379
384
  } else {
package/src/mcp/core.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { KanbanProvider } from '../providers/types'
2
- import type { Task, TaskComment } from '../types'
2
+ import type { BoardView, Task, TaskComment } from '../types'
3
3
  import * as useCases from '../use-cases'
4
4
  import { TrackerMcpError, toTrackerMcpError } from './errors'
5
5
  import type { TrackerMcpHooks, TrackerMcpPolicy } from './types'
@@ -52,6 +52,21 @@ async function filterComments<TScope>(
52
52
  return comments.filter((_, index) => allowed[index])
53
53
  }
54
54
 
55
+ async function filterBoard<TScope>(
56
+ scope: TScope,
57
+ board: BoardView,
58
+ policy: TrackerMcpPolicy<TScope>,
59
+ ): Promise<BoardView> {
60
+ if (!policy.filterTask) return board
61
+ const columns = await Promise.all(
62
+ board.columns.map(async (column) => {
63
+ const allowed = await Promise.all(column.tasks.map((task) => policy.filterTask!(scope, task)))
64
+ return { ...column, tasks: column.tasks.filter((_, index) => allowed[index]) }
65
+ }),
66
+ )
67
+ return { ...board, columns }
68
+ }
69
+
55
70
  export function createTrackerCore<TScope>(input: {
56
71
  provider: KanbanProvider
57
72
  policy: TrackerMcpPolicy<TScope>
@@ -146,7 +161,14 @@ export function createTrackerCore<TScope>(input: {
146
161
  return runTool({
147
162
  scope,
148
163
  tool: 'getBoard',
149
- execute: async () => useCases.getBoard(provider),
164
+ execute: async () => {
165
+ await policy.canReadBoard?.(scope)
166
+ const board = await useCases.getBoard(provider)
167
+ return filterBoard(scope, board, policy)
168
+ },
169
+ resultMeta: (board) => ({
170
+ taskCount: board.columns.reduce((total, column) => total + column.tasks.length, 0),
171
+ }),
150
172
  })
151
173
  },
152
174
 
package/src/mcp/server.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  import type { TrackerCore } from './core'
17
17
  import { TrackerMcpError, toMcpError, toTrackerMcpError, trackerMcpJsonRpcCode } from './errors'
18
18
  import type { TrackerMcpAuthResolver, TrackerMcpServer, TrackerMcpTool } from './types'
19
+ import { VERSION } from '../version'
19
20
 
20
21
  const EMPTY_OBJECT_SCHEMA = {
21
22
  type: 'object',
@@ -169,7 +170,7 @@ function createSessionServer<TScope>(
169
170
  entry: SessionEntry<TScope>,
170
171
  ): Server {
171
172
  const server = new Server(
172
- { name: 'agent-kanban-tracker-mcp', version: '1.0.0' },
173
+ { name: 'agent-kanban-tracker-mcp', version: VERSION },
173
174
  { capabilities: { tools: {} } },
174
175
  )
175
176