@sprintdock/backend 0.4.2

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 (107) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +252 -0
  3. package/SERVER.md +25 -0
  4. package/dist/index.d.ts +1536 -0
  5. package/dist/index.js +4103 -0
  6. package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
  7. package/drizzle/0001_sprint_markdown_content.sql +1 -0
  8. package/drizzle/0002_task_touched_files.sql +8 -0
  9. package/drizzle/meta/0000_snapshot.json +372 -0
  10. package/drizzle/meta/0001_snapshot.json +379 -0
  11. package/drizzle/meta/_journal.json +27 -0
  12. package/drizzle.config.ts +14 -0
  13. package/package.json +40 -0
  14. package/src/application/container.ts +44 -0
  15. package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
  16. package/src/application/plan.service.ts +123 -0
  17. package/src/application/sprint.service.ts +118 -0
  18. package/src/application/task.service.ts +389 -0
  19. package/src/db/connection.ts +25 -0
  20. package/src/db/migrator.ts +46 -0
  21. package/src/db/schema/index.ts +14 -0
  22. package/src/db/schema/plans.ts +18 -0
  23. package/src/db/schema/relations.ts +36 -0
  24. package/src/db/schema/sprints.ts +33 -0
  25. package/src/db/schema/tasks.ts +62 -0
  26. package/src/domain/entities/index.ts +30 -0
  27. package/src/domain/entities/plan.entity.ts +33 -0
  28. package/src/domain/entities/sprint.entity.ts +44 -0
  29. package/src/domain/entities/task.entity.ts +80 -0
  30. package/src/domain/repositories/index.ts +9 -0
  31. package/src/domain/repositories/plan.repository.ts +21 -0
  32. package/src/domain/repositories/sprint.repository.ts +19 -0
  33. package/src/domain/repositories/task.repository.ts +35 -0
  34. package/src/domain/services/index.ts +9 -0
  35. package/src/domain/services/plan-domain.service.ts +44 -0
  36. package/src/domain/services/sprint-domain.service.ts +44 -0
  37. package/src/domain/services/task-domain.service.ts +136 -0
  38. package/src/errors/backend-errors.ts +75 -0
  39. package/src/http/app-factory.ts +55 -0
  40. package/src/http/controllers/health.controller.ts +33 -0
  41. package/src/http/controllers/plan.controller.ts +153 -0
  42. package/src/http/controllers/sprint.controller.ts +111 -0
  43. package/src/http/controllers/task.controller.ts +158 -0
  44. package/src/http/express-augmentation.d.ts +20 -0
  45. package/src/http/middleware/cors.ts +41 -0
  46. package/src/http/middleware/error-handler.ts +50 -0
  47. package/src/http/middleware/request-id.ts +28 -0
  48. package/src/http/middleware/validate.ts +54 -0
  49. package/src/http/routes/v1/index.ts +39 -0
  50. package/src/http/routes/v1/plan.routes.ts +51 -0
  51. package/src/http/routes/v1/schemas.ts +175 -0
  52. package/src/http/routes/v1/sprint.routes.ts +49 -0
  53. package/src/http/routes/v1/task.routes.ts +64 -0
  54. package/src/index.ts +34 -0
  55. package/src/infrastructure/observability/audit-log.ts +34 -0
  56. package/src/infrastructure/observability/request-correlation.ts +20 -0
  57. package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
  58. package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
  59. package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
  60. package/src/infrastructure/repositories/drizzle/index.ts +16 -0
  61. package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
  62. package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
  63. package/src/infrastructure/repositories/repository-factory.ts +54 -0
  64. package/src/infrastructure/security/auth-context.ts +35 -0
  65. package/src/infrastructure/security/input-guard.ts +21 -0
  66. package/src/infrastructure/security/rate-limiter.ts +65 -0
  67. package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
  68. package/src/mcp/mcp-query-helpers.ts +89 -0
  69. package/src/mcp/mcp-text-formatters.ts +204 -0
  70. package/src/mcp/mcp-tool-error.ts +24 -0
  71. package/src/mcp/plugins/context-tools.plugin.ts +107 -0
  72. package/src/mcp/plugins/default-plugins.ts +23 -0
  73. package/src/mcp/plugins/index.ts +21 -0
  74. package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
  75. package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
  76. package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
  77. package/src/mcp/plugins/task-tools.plugin.ts +528 -0
  78. package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
  79. package/src/mcp/plugins/types.ts +45 -0
  80. package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
  81. package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
  82. package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
  83. package/src/mcp/tool-guard.ts +58 -0
  84. package/src/mcp/transports/http-app-factory.ts +31 -0
  85. package/src/mcp/transports/http-entry.ts +27 -0
  86. package/src/mcp/transports/stdio-entry.ts +17 -0
  87. package/tests/application/container.test.ts +36 -0
  88. package/tests/application/plan.service.test.ts +114 -0
  89. package/tests/application/sprint.service.test.ts +138 -0
  90. package/tests/application/task.service.test.ts +325 -0
  91. package/tests/db/test-db.test.ts +112 -0
  92. package/tests/domain/plan-domain.service.test.ts +44 -0
  93. package/tests/domain/sprint-domain.service.test.ts +38 -0
  94. package/tests/domain/task-domain.service.test.ts +105 -0
  95. package/tests/errors/backend-errors.test.ts +44 -0
  96. package/tests/helpers/test-db.ts +43 -0
  97. package/tests/http/error-handler.test.ts +37 -0
  98. package/tests/http/plan.routes.test.ts +128 -0
  99. package/tests/http/sprint.routes.test.ts +72 -0
  100. package/tests/http/task.routes.test.ts +130 -0
  101. package/tests/http/test-app.ts +17 -0
  102. package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
  103. package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
  104. package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
  105. package/tests/mcp/mcp-text-formatters.test.ts +246 -0
  106. package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
  107. package/tsconfig.json +9 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: MCP plugin — core task CRUD, list, assign, move, and field updates.
5
+ */
6
+ import { z } from "zod";
7
+ import {
8
+ ensureSprintInPlan,
9
+ ensureTaskInPlan,
10
+ getSprintForPlan,
11
+ resolvePlanOrActive
12
+ } from "../mcp-query-helpers.js";
13
+ import { formatTaskList, shortId } from "../mcp-text-formatters.js";
14
+ import { toMcpToolError } from "../mcp-tool-error.js";
15
+ import { guardToolExecution, writeToolAudit } from "../tool-guard.js";
16
+ import type { SprintdockMcpToolPlugin } from "./types.js";
17
+
18
+ const TOUCHED_FILE_TYPE = z.enum([
19
+ "test",
20
+ "implementation",
21
+ "doc",
22
+ "config",
23
+ "other"
24
+ ]);
25
+
26
+ const touchedFilesSchema = z
27
+ .array(
28
+ z.object({
29
+ path: z.string().min(1).max(2048),
30
+ fileType: TOUCHED_FILE_TYPE
31
+ })
32
+ )
33
+ .optional();
34
+
35
+ export const taskToolsPlugin: SprintdockMcpToolPlugin = {
36
+ id: "sprintdock/task-tools",
37
+ register(server, ctx) {
38
+ const { deps, kit } = ctx;
39
+ const {
40
+ jsonStructured,
41
+ applyPagination,
42
+ paginationNote,
43
+ optionalPlanSlug,
44
+ paginationSchema,
45
+ TASK_STATUS_SCHEMA,
46
+ TASK_PRIORITY_SCHEMA
47
+ } = kit;
48
+ const { planService, sprintService, taskService } = deps.services;
49
+
50
+ server.registerTool(
51
+ "create_task",
52
+ {
53
+ description:
54
+ "Creates a task in a sprint. Pass sprintIdOrSlug: full sprint UUID or sprint slug under the resolved plan (same pattern as get_sprint). Do not read SQLite directly. Order defaults to the end of the sprint. Optional dependsOnTaskIds must reference tasks in the same plan. Optional touchedFiles lists repo paths with file types (test, implementation, doc, config, other). Guarded mutation.",
55
+ inputSchema: z
56
+ .object({
57
+ title: z.string().min(1),
58
+ description: z.string().default(""),
59
+ priority: TASK_PRIORITY_SCHEMA.default("medium"),
60
+ sprintIdOrSlug: z.string().min(1),
61
+ assignee: z.string().nullable().optional(),
62
+ tags: z.array(z.string()).default([]),
63
+ order: z.number().int().optional(),
64
+ dependsOnTaskIds: z.array(z.string()).optional(),
65
+ touchedFiles: touchedFilesSchema,
66
+ planSlug: optionalPlanSlug
67
+ })
68
+ .strict()
69
+ },
70
+ async (input) => {
71
+ try {
72
+ const guard = await guardToolExecution(
73
+ deps.authContextResolver,
74
+ deps.requestCorrelation,
75
+ deps.rateLimiter,
76
+ "create_task"
77
+ );
78
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
79
+ const sprint = await getSprintForPlan(
80
+ sprintService,
81
+ plan,
82
+ input.sprintIdOrSlug
83
+ );
84
+ const task = await taskService.createTask(
85
+ {
86
+ title: input.title,
87
+ description: input.description || null,
88
+ priority: input.priority ?? "medium",
89
+ sprintId: sprint.id,
90
+ assignee: input.assignee ?? null,
91
+ tags: input.tags ?? [],
92
+ order: input.order,
93
+ dependsOnTaskIds: input.dependsOnTaskIds,
94
+ touchedFiles: input.touchedFiles
95
+ },
96
+ input.planSlug
97
+ );
98
+ writeToolAudit(
99
+ deps.auditLog,
100
+ "create_task",
101
+ guard.principalId,
102
+ guard.correlationId,
103
+ task.id
104
+ );
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `Created task '${task.title}' (id: ${shortId(task.id)}).`
110
+ }
111
+ ],
112
+ structuredContent: jsonStructured(task)
113
+ };
114
+ } catch (e) {
115
+ return toMcpToolError(e);
116
+ }
117
+ }
118
+ );
119
+
120
+ server.registerTool(
121
+ "list_tasks",
122
+ {
123
+ description:
124
+ "Lists tasks for an optional sprint UUID or across the whole plan when sprintId is omitted, with optional filters (status, priority, assignee). Optional limit/offset paginate; omit both for the full filtered list.",
125
+ inputSchema: z
126
+ .object({
127
+ sprintId: z.string().optional(),
128
+ status: TASK_STATUS_SCHEMA.optional(),
129
+ priority: TASK_PRIORITY_SCHEMA.optional(),
130
+ assignee: z.string().optional(),
131
+ planSlug: optionalPlanSlug,
132
+ ...paginationSchema
133
+ })
134
+ .strict()
135
+ },
136
+ async (input) => {
137
+ try {
138
+ const filter =
139
+ input.status || input.priority || input.assignee !== undefined
140
+ ? {
141
+ ...(input.status ? { status: input.status } : {}),
142
+ ...(input.priority ? { priority: input.priority } : {}),
143
+ ...(input.assignee !== undefined
144
+ ? { assignee: input.assignee }
145
+ : {})
146
+ }
147
+ : undefined;
148
+ const tasksAll = await taskService.listTasks(
149
+ input.sprintId,
150
+ input.planSlug,
151
+ filter
152
+ );
153
+ const { page, total } = applyPagination(
154
+ tasksAll,
155
+ input.limit,
156
+ input.offset
157
+ );
158
+ const text =
159
+ formatTaskList(page) +
160
+ paginationNote(total, input.limit, input.offset);
161
+ return {
162
+ content: [{ type: "text", text }],
163
+ structuredContent: jsonStructured({ tasks: page, total })
164
+ };
165
+ } catch (e) {
166
+ return toMcpToolError(e);
167
+ }
168
+ }
169
+ );
170
+
171
+ server.registerTool(
172
+ "get_task",
173
+ {
174
+ description:
175
+ "Returns one task by UUID with title, status, priority, assignee, tags, order, and sprint. When planSlug is set, verifies the task belongs to that plan.",
176
+ inputSchema: z
177
+ .object({
178
+ taskId: z.string().min(1),
179
+ planSlug: optionalPlanSlug
180
+ })
181
+ .strict()
182
+ },
183
+ async (input) => {
184
+ try {
185
+ if (input.planSlug !== undefined && input.planSlug !== "") {
186
+ const plan = await planService.resolvePlan(input.planSlug);
187
+ await ensureTaskInPlan(
188
+ taskService,
189
+ sprintService,
190
+ plan,
191
+ input.taskId
192
+ );
193
+ }
194
+ const task = await taskService.getTask(input.taskId);
195
+ return {
196
+ content: [
197
+ {
198
+ type: "text",
199
+ text: `Task '${task.title}' currently has status '${task.status}'.`
200
+ }
201
+ ],
202
+ structuredContent: jsonStructured(task)
203
+ };
204
+ } catch (e) {
205
+ return toMcpToolError(e);
206
+ }
207
+ }
208
+ );
209
+
210
+ server.registerTool(
211
+ "update_task_status",
212
+ {
213
+ description:
214
+ "Updates a task status with domain transition rules (e.g. done cannot jump back arbitrarily). Guarded mutation; returns the updated task.",
215
+ inputSchema: z
216
+ .object({
217
+ taskId: z.string().min(1),
218
+ status: TASK_STATUS_SCHEMA,
219
+ planSlug: optionalPlanSlug
220
+ })
221
+ .strict()
222
+ },
223
+ async (input) => {
224
+ try {
225
+ const guard = await guardToolExecution(
226
+ deps.authContextResolver,
227
+ deps.requestCorrelation,
228
+ deps.rateLimiter,
229
+ "update_task_status"
230
+ );
231
+ if (input.planSlug !== undefined && input.planSlug !== "") {
232
+ const plan = await planService.resolvePlan(input.planSlug);
233
+ await ensureTaskInPlan(
234
+ taskService,
235
+ sprintService,
236
+ plan,
237
+ input.taskId
238
+ );
239
+ }
240
+ const task = await taskService.updateTaskStatus(
241
+ input.taskId,
242
+ input.status
243
+ );
244
+ writeToolAudit(
245
+ deps.auditLog,
246
+ "update_task_status",
247
+ guard.principalId,
248
+ guard.correlationId,
249
+ task.id,
250
+ { status: task.status }
251
+ );
252
+ return {
253
+ content: [
254
+ {
255
+ type: "text",
256
+ text: `Task '${task.title}' updated to '${task.status}'. (id: ${shortId(task.id)})`
257
+ }
258
+ ],
259
+ structuredContent: jsonStructured(task)
260
+ };
261
+ } catch (e) {
262
+ return toMcpToolError(e);
263
+ }
264
+ }
265
+ );
266
+
267
+ server.registerTool(
268
+ "assign_task",
269
+ {
270
+ description:
271
+ "Sets or clears the assignee string on a task (null clears). Guarded mutation; scoped with planSlug when provided.",
272
+ inputSchema: z
273
+ .object({
274
+ taskId: z.string().min(1),
275
+ assignee: z.string().nullable(),
276
+ planSlug: optionalPlanSlug
277
+ })
278
+ .strict()
279
+ },
280
+ async (input) => {
281
+ try {
282
+ const guard = await guardToolExecution(
283
+ deps.authContextResolver,
284
+ deps.requestCorrelation,
285
+ deps.rateLimiter,
286
+ "assign_task"
287
+ );
288
+ if (input.planSlug !== undefined && input.planSlug !== "") {
289
+ const plan = await planService.resolvePlan(input.planSlug);
290
+ await ensureTaskInPlan(
291
+ taskService,
292
+ sprintService,
293
+ plan,
294
+ input.taskId
295
+ );
296
+ }
297
+ const task = await taskService.assignTask(
298
+ input.taskId,
299
+ input.assignee
300
+ );
301
+ writeToolAudit(
302
+ deps.auditLog,
303
+ "assign_task",
304
+ guard.principalId,
305
+ guard.correlationId,
306
+ task.id,
307
+ { assignee: task.assignee }
308
+ );
309
+ const who = task.assignee ?? "unassigned";
310
+ return {
311
+ content: [
312
+ {
313
+ type: "text",
314
+ text: `Task '${task.title}' assigned to '${who}'. (id: ${shortId(task.id)})`
315
+ }
316
+ ],
317
+ structuredContent: jsonStructured(task)
318
+ };
319
+ } catch (e) {
320
+ return toMcpToolError(e);
321
+ }
322
+ }
323
+ );
324
+
325
+ server.registerTool(
326
+ "move_task",
327
+ {
328
+ description:
329
+ "Moves a task to another sprint in the same plan (target sprint UUID). Guarded mutation; confirms the task and target sprint share a plan when planSlug is provided.",
330
+ inputSchema: z
331
+ .object({
332
+ taskId: z.string().min(1),
333
+ targetSprintId: z.string().min(1),
334
+ planSlug: optionalPlanSlug
335
+ })
336
+ .strict()
337
+ },
338
+ async (input) => {
339
+ try {
340
+ const guard = await guardToolExecution(
341
+ deps.authContextResolver,
342
+ deps.requestCorrelation,
343
+ deps.rateLimiter,
344
+ "move_task"
345
+ );
346
+ if (input.planSlug !== undefined && input.planSlug !== "") {
347
+ const plan = await planService.resolvePlan(input.planSlug);
348
+ await ensureTaskInPlan(
349
+ taskService,
350
+ sprintService,
351
+ plan,
352
+ input.taskId
353
+ );
354
+ await ensureSprintInPlan(
355
+ sprintService,
356
+ plan,
357
+ input.targetSprintId
358
+ );
359
+ }
360
+ const targetSprint = await sprintService.getSprint(
361
+ input.targetSprintId
362
+ );
363
+ const task = await taskService.moveTask(
364
+ input.taskId,
365
+ input.targetSprintId
366
+ );
367
+ writeToolAudit(
368
+ deps.auditLog,
369
+ "move_task",
370
+ guard.principalId,
371
+ guard.correlationId,
372
+ task.id,
373
+ { targetSprintId: task.sprintId }
374
+ );
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: `Task '${task.title}' moved to sprint '${targetSprint.name}' (${targetSprint.slug}). (id: ${shortId(task.id)})`
380
+ }
381
+ ],
382
+ structuredContent: jsonStructured(task)
383
+ };
384
+ } catch (e) {
385
+ return toMcpToolError(e);
386
+ }
387
+ }
388
+ );
389
+
390
+ server.registerTool(
391
+ "delete_task",
392
+ {
393
+ description:
394
+ "Permanently deletes a task by UUID and removes its dependency edges. Guarded mutation. Use delete_sprint to remove a whole sprint. Returns confirmation with the task title and short id.",
395
+ inputSchema: z
396
+ .object({
397
+ taskId: z.string().min(1),
398
+ planSlug: optionalPlanSlug
399
+ })
400
+ .strict()
401
+ },
402
+ async (input) => {
403
+ try {
404
+ const guard = await guardToolExecution(
405
+ deps.authContextResolver,
406
+ deps.requestCorrelation,
407
+ deps.rateLimiter,
408
+ "delete_task"
409
+ );
410
+ if (input.planSlug !== undefined && input.planSlug !== "") {
411
+ const plan = await planService.resolvePlan(input.planSlug);
412
+ await ensureTaskInPlan(
413
+ taskService,
414
+ sprintService,
415
+ plan,
416
+ input.taskId
417
+ );
418
+ }
419
+ const before = await taskService.getTask(input.taskId);
420
+ await taskService.deleteTask(input.taskId);
421
+ writeToolAudit(
422
+ deps.auditLog,
423
+ "delete_task",
424
+ guard.principalId,
425
+ guard.correlationId,
426
+ input.taskId,
427
+ { title: before.title }
428
+ );
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: `Task '${before.title}' (${shortId(before.id)}) deleted.`
434
+ }
435
+ ],
436
+ structuredContent: jsonStructured({
437
+ deleted: true,
438
+ taskId: input.taskId
439
+ })
440
+ };
441
+ } catch (e) {
442
+ return toMcpToolError(e);
443
+ }
444
+ }
445
+ );
446
+
447
+ server.registerTool(
448
+ "update_task",
449
+ {
450
+ description:
451
+ "Updates task title, description, priority, tags, sort order, or touchedFiles (replaces all file rows when set). Does not change status (use update_task_status). Guarded mutation.",
452
+ inputSchema: z
453
+ .object({
454
+ taskId: z.string().min(1),
455
+ title: z.string().min(1).optional(),
456
+ description: z.string().nullable().optional(),
457
+ priority: TASK_PRIORITY_SCHEMA.optional(),
458
+ tags: z.array(z.string()).optional(),
459
+ order: z.number().int().optional(),
460
+ touchedFiles: touchedFilesSchema,
461
+ planSlug: optionalPlanSlug
462
+ })
463
+ .strict()
464
+ .refine(
465
+ (v) =>
466
+ v.title !== undefined ||
467
+ v.description !== undefined ||
468
+ v.priority !== undefined ||
469
+ v.tags !== undefined ||
470
+ v.order !== undefined ||
471
+ v.touchedFiles !== undefined,
472
+ { message: "Provide at least one field to update." }
473
+ )
474
+ },
475
+ async (input) => {
476
+ try {
477
+ const guard = await guardToolExecution(
478
+ deps.authContextResolver,
479
+ deps.requestCorrelation,
480
+ deps.rateLimiter,
481
+ "update_task"
482
+ );
483
+ if (input.planSlug !== undefined && input.planSlug !== "") {
484
+ const plan = await planService.resolvePlan(input.planSlug);
485
+ await ensureTaskInPlan(
486
+ taskService,
487
+ sprintService,
488
+ plan,
489
+ input.taskId
490
+ );
491
+ }
492
+ const updated = await taskService.updateTask(input.taskId, {
493
+ ...(input.title !== undefined ? { title: input.title } : {}),
494
+ ...(input.description !== undefined
495
+ ? { description: input.description }
496
+ : {}),
497
+ ...(input.priority !== undefined
498
+ ? { priority: input.priority }
499
+ : {}),
500
+ ...(input.tags !== undefined ? { tags: input.tags } : {}),
501
+ ...(input.order !== undefined ? { order: input.order } : {}),
502
+ ...(input.touchedFiles !== undefined
503
+ ? { touchedFiles: input.touchedFiles }
504
+ : {})
505
+ });
506
+ writeToolAudit(
507
+ deps.auditLog,
508
+ "update_task",
509
+ guard.principalId,
510
+ guard.correlationId,
511
+ updated.id
512
+ );
513
+ return {
514
+ content: [
515
+ {
516
+ type: "text",
517
+ text: `Task '${updated.title}' updated. (id: ${shortId(updated.id)})`
518
+ }
519
+ ],
520
+ structuredContent: jsonStructured(updated)
521
+ };
522
+ } catch (e) {
523
+ return toMcpToolError(e);
524
+ }
525
+ }
526
+ );
527
+ }
528
+ };