@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,90 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Shared Zod schemas and helpers for Sprintdock MCP tool plugins.
5
+ */
6
+ import { z } from "zod";
7
+
8
+ export const optionalPlanSlug = z.string().min(1).optional();
9
+
10
+ export const paginationSchema = {
11
+ limit: z.number().int().min(1).max(100).optional(),
12
+ offset: z.number().int().min(0).optional()
13
+ };
14
+
15
+ export const TASK_STATUS_SCHEMA = z.enum([
16
+ "todo",
17
+ "in_progress",
18
+ "blocked",
19
+ "done"
20
+ ]);
21
+
22
+ export const TASK_PRIORITY_SCHEMA = z.enum([
23
+ "low",
24
+ "medium",
25
+ "high",
26
+ "critical"
27
+ ]);
28
+
29
+ export function applyPagination<T>(
30
+ items: T[],
31
+ limit?: number,
32
+ offset?: number
33
+ ): { page: T[]; total: number } {
34
+ const total = items.length;
35
+ if (limit === undefined && offset === undefined) {
36
+ return { page: items, total };
37
+ }
38
+ const off = offset ?? 0;
39
+ const lim = limit ?? 50;
40
+ return { page: items.slice(off, off + lim), total };
41
+ }
42
+
43
+ export function paginationNote(
44
+ total: number,
45
+ limit?: number,
46
+ offset?: number
47
+ ): string {
48
+ if (limit === undefined && offset === undefined) {
49
+ return "";
50
+ }
51
+ const off = offset ?? 0;
52
+ const lim = limit ?? 50;
53
+ if (total === 0) {
54
+ return " (showing 0 of 0)";
55
+ }
56
+ const end = Math.min(off + lim, total);
57
+ return ` (showing ${off + 1}-${end} of ${total})`;
58
+ }
59
+
60
+ export function jsonStructured(data: unknown): Record<string, unknown> {
61
+ return JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
62
+ }
63
+
64
+ /** Toolkit passed to every plugin so custom plugins reuse the same schemas and helpers. */
65
+ export interface SprintdockMcpToolKit {
66
+ readonly jsonStructured: typeof jsonStructured;
67
+ readonly applyPagination: typeof applyPagination;
68
+ readonly paginationNote: typeof paginationNote;
69
+ readonly optionalPlanSlug: typeof optionalPlanSlug;
70
+ readonly paginationSchema: typeof paginationSchema;
71
+ readonly TASK_STATUS_SCHEMA: typeof TASK_STATUS_SCHEMA;
72
+ readonly TASK_PRIORITY_SCHEMA: typeof TASK_PRIORITY_SCHEMA;
73
+ }
74
+
75
+ const kitSingleton: SprintdockMcpToolKit = {
76
+ jsonStructured,
77
+ applyPagination,
78
+ paginationNote,
79
+ optionalPlanSlug,
80
+ paginationSchema,
81
+ TASK_STATUS_SCHEMA,
82
+ TASK_PRIORITY_SCHEMA
83
+ };
84
+
85
+ /**
86
+ * Returns the shared toolkit (immutable singleton) for MCP tool plugins.
87
+ */
88
+ export function createSprintdockMcpToolKit(): SprintdockMcpToolKit {
89
+ return kitSingleton;
90
+ }
@@ -0,0 +1,426 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: MCP plugin — plan list/create/active, summary, markdown, CRUD, and plan progress.
5
+ */
6
+ import { z } from "zod";
7
+ import { ValidationError } from "../../errors/backend-errors.js";
8
+ import {
9
+ countByTaskStatus,
10
+ resolvePlanOrActive
11
+ } from "../mcp-query-helpers.js";
12
+ import {
13
+ formatPlanList,
14
+ formatPlanProgress,
15
+ formatPlanSummary
16
+ } from "../mcp-text-formatters.js";
17
+ import { toMcpToolError } from "../mcp-tool-error.js";
18
+ import { guardToolExecution, writeToolAudit } from "../tool-guard.js";
19
+ import type { SprintdockMcpToolPlugin } from "./types.js";
20
+
21
+ export const planToolsPlugin: SprintdockMcpToolPlugin = {
22
+ id: "sprintdock/plan-tools",
23
+ register(server, ctx) {
24
+ const { deps, kit } = ctx;
25
+ const {
26
+ jsonStructured,
27
+ applyPagination,
28
+ paginationNote,
29
+ optionalPlanSlug,
30
+ paginationSchema
31
+ } = kit;
32
+ const { planService, sprintService, taskService } = deps.services;
33
+
34
+ server.registerTool(
35
+ "list_plans",
36
+ {
37
+ description:
38
+ "Lists every plan in the SQLite database with slug, title, status, and which plan is active. Use this first to discover planSlug values. Optional limit/offset paginate results; omit both to return the full list.",
39
+ inputSchema: z.object({ ...paginationSchema }).strict()
40
+ },
41
+ async (input) => {
42
+ try {
43
+ const plansAll = await planService.listPlans();
44
+ const { page, total } = applyPagination(
45
+ plansAll,
46
+ input.limit,
47
+ input.offset
48
+ );
49
+ let activePlanId: string | null = null;
50
+ try {
51
+ activePlanId = (await planService.getActivePlan()).id;
52
+ } catch {
53
+ activePlanId = null;
54
+ }
55
+ const text =
56
+ formatPlanList(page, activePlanId) +
57
+ paginationNote(total, input.limit, input.offset);
58
+ return {
59
+ content: [{ type: "text", text }],
60
+ structuredContent: jsonStructured({ plans: page, total })
61
+ };
62
+ } catch (e) {
63
+ return toMcpToolError(e);
64
+ }
65
+ }
66
+ );
67
+
68
+ server.registerTool(
69
+ "create_plan",
70
+ {
71
+ description:
72
+ "Creates a new plan with a kebab-case slug. The first plan in an empty database becomes the active plan automatically. Returns the new plan record.",
73
+ inputSchema: z
74
+ .object({
75
+ planSlug: z.string().min(1),
76
+ title: z.string().min(1).optional(),
77
+ initialBody: z.string().optional()
78
+ })
79
+ .strict()
80
+ },
81
+ async (input) => {
82
+ try {
83
+ const guard = await guardToolExecution(
84
+ deps.authContextResolver,
85
+ deps.requestCorrelation,
86
+ deps.rateLimiter,
87
+ "create_plan"
88
+ );
89
+ const plan = await planService.createPlan({
90
+ slug: input.planSlug,
91
+ title: input.title ?? input.planSlug,
92
+ markdownContent: input.initialBody ?? null
93
+ });
94
+ writeToolAudit(
95
+ deps.auditLog,
96
+ "create_plan",
97
+ guard.principalId,
98
+ guard.correlationId,
99
+ plan.id
100
+ );
101
+ return {
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text: `Created plan '${plan.slug}' (${plan.id}).`
106
+ }
107
+ ],
108
+ structuredContent: jsonStructured({ planSlug: plan.slug, plan })
109
+ };
110
+ } catch (e) {
111
+ return toMcpToolError(e);
112
+ }
113
+ }
114
+ );
115
+
116
+ server.registerTool(
117
+ "set_active_plan",
118
+ {
119
+ description:
120
+ "Switches the active plan used when tools omit planSlug. Pass the plan slug or UUID. Only one plan can be active at a time.",
121
+ inputSchema: z.object({ planSlug: z.string().min(1) }).strict()
122
+ },
123
+ async (input) => {
124
+ try {
125
+ const guard = await guardToolExecution(
126
+ deps.authContextResolver,
127
+ deps.requestCorrelation,
128
+ deps.rateLimiter,
129
+ "set_active_plan"
130
+ );
131
+ await planService.setActivePlan(input.planSlug);
132
+ writeToolAudit(
133
+ deps.auditLog,
134
+ "set_active_plan",
135
+ guard.principalId,
136
+ guard.correlationId,
137
+ input.planSlug
138
+ );
139
+ return {
140
+ content: [
141
+ { type: "text", text: `Active plan set to '${input.planSlug}'.` }
142
+ ],
143
+ structuredContent: jsonStructured({ planSlug: input.planSlug })
144
+ };
145
+ } catch (e) {
146
+ return toMcpToolError(e);
147
+ }
148
+ }
149
+ );
150
+
151
+ server.registerTool(
152
+ "get_active_plan",
153
+ {
154
+ description:
155
+ "Returns the active plan (or the plan matching optional planSlug) with title, id, and the on-disk plan markdown path.",
156
+ inputSchema: z.object({ planSlug: optionalPlanSlug }).strict()
157
+ },
158
+ async (input) => {
159
+ try {
160
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
161
+ return {
162
+ content: [
163
+ {
164
+ type: "text",
165
+ text: `Plan '${plan.slug}' (${plan.title}).`
166
+ }
167
+ ],
168
+ structuredContent: jsonStructured({
169
+ planSlug: plan.slug,
170
+ title: plan.title,
171
+ planId: plan.id,
172
+ planMarkdownPath: `.sprintdock/plans/${plan.slug}/plan.md`
173
+ })
174
+ };
175
+ } catch (e) {
176
+ return toMcpToolError(e);
177
+ }
178
+ }
179
+ );
180
+
181
+ server.registerTool(
182
+ "get_plan_summary",
183
+ {
184
+ description:
185
+ "Returns plan title, updated time, and every sprint with slug, status, id, and task counts by status (todo, in_progress, blocked, done). Use for dashboards before drilling into sprints.",
186
+ inputSchema: z.object({ planSlug: optionalPlanSlug }).strict()
187
+ },
188
+ async (input) => {
189
+ try {
190
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
191
+ const sprints = await sprintService.listSprints(plan.id);
192
+ const sprintsOut = [];
193
+ for (const sp of sprints) {
194
+ const tasks = await taskService.listTasks(sp.id);
195
+ sprintsOut.push({
196
+ sprintSlug: sp.slug,
197
+ sprintId: sp.id,
198
+ name: sp.name,
199
+ status: sp.status,
200
+ taskCounts: countByTaskStatus(tasks)
201
+ });
202
+ }
203
+ return {
204
+ content: [
205
+ {
206
+ type: "text",
207
+ text: formatPlanSummary(plan, sprintsOut)
208
+ }
209
+ ],
210
+ structuredContent: jsonStructured({
211
+ planSlug: plan.slug,
212
+ title: plan.title,
213
+ updatedAt: plan.updatedAt,
214
+ sprints: sprintsOut
215
+ })
216
+ };
217
+ } catch (e) {
218
+ return toMcpToolError(e);
219
+ }
220
+ }
221
+ );
222
+
223
+ server.registerTool(
224
+ "update_plan_markdown",
225
+ {
226
+ description:
227
+ "Overwrites the plan body markdown stored in SQLite for the resolved plan. Requires auth/rate-limit guard; use for syncing plan docs from agents.",
228
+ inputSchema: z
229
+ .object({
230
+ content: z.string(),
231
+ planSlug: optionalPlanSlug
232
+ })
233
+ .strict()
234
+ },
235
+ async (input) => {
236
+ try {
237
+ const guard = await guardToolExecution(
238
+ deps.authContextResolver,
239
+ deps.requestCorrelation,
240
+ deps.rateLimiter,
241
+ "update_plan_markdown"
242
+ );
243
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
244
+ const updated = await planService.updatePlan(plan.id, {
245
+ markdownContent: input.content
246
+ });
247
+ writeToolAudit(
248
+ deps.auditLog,
249
+ "update_plan_markdown",
250
+ guard.principalId,
251
+ guard.correlationId,
252
+ updated.id
253
+ );
254
+ return {
255
+ content: [{ type: "text", text: "Plan markdown updated." }],
256
+ structuredContent: jsonStructured({ ok: true, plan: updated })
257
+ };
258
+ } catch (e) {
259
+ return toMcpToolError(e);
260
+ }
261
+ }
262
+ );
263
+
264
+ server.registerTool(
265
+ "delete_plan",
266
+ {
267
+ description:
268
+ "Deletes a plan and cascades all sprints and tasks. Cannot delete the currently active plan; switch active plan first. Guarded mutation.",
269
+ inputSchema: z.object({ planSlug: z.string().min(1) }).strict()
270
+ },
271
+ async (input) => {
272
+ try {
273
+ const guard = await guardToolExecution(
274
+ deps.authContextResolver,
275
+ deps.requestCorrelation,
276
+ deps.rateLimiter,
277
+ "delete_plan"
278
+ );
279
+ const plan = await planService.getPlan(input.planSlug);
280
+ let active: { id: string };
281
+ try {
282
+ active = await planService.getActivePlan();
283
+ } catch {
284
+ active = { id: "" };
285
+ }
286
+ if (active.id === plan.id) {
287
+ throw new ValidationError(
288
+ "Cannot delete the active plan; set another plan active first."
289
+ );
290
+ }
291
+ await planService.deletePlan(plan.id);
292
+ writeToolAudit(
293
+ deps.auditLog,
294
+ "delete_plan",
295
+ guard.principalId,
296
+ guard.correlationId,
297
+ plan.id,
298
+ { slug: plan.slug }
299
+ );
300
+ return {
301
+ content: [
302
+ {
303
+ type: "text",
304
+ text: `Plan '${plan.slug}' ("${plan.title}") deleted.`
305
+ }
306
+ ],
307
+ structuredContent: jsonStructured({
308
+ deleted: true,
309
+ planSlug: plan.slug
310
+ })
311
+ };
312
+ } catch (e) {
313
+ return toMcpToolError(e);
314
+ }
315
+ }
316
+ );
317
+
318
+ server.registerTool(
319
+ "update_plan",
320
+ {
321
+ description:
322
+ "Updates plan title, description, or status with domain validation on status transitions. Resolves the plan from optional planSlug or the active plan; at least one field must be provided.",
323
+ inputSchema: z
324
+ .object({
325
+ planSlug: optionalPlanSlug,
326
+ title: z.string().min(1).optional(),
327
+ description: z.string().nullable().optional(),
328
+ status: z
329
+ .enum(["draft", "active", "completed", "archived"])
330
+ .optional()
331
+ })
332
+ .strict()
333
+ .refine(
334
+ (v) =>
335
+ v.title !== undefined ||
336
+ v.description !== undefined ||
337
+ v.status !== undefined,
338
+ {
339
+ message:
340
+ "Provide at least one of title, description, or status."
341
+ }
342
+ )
343
+ },
344
+ async (input) => {
345
+ try {
346
+ const guard = await guardToolExecution(
347
+ deps.authContextResolver,
348
+ deps.requestCorrelation,
349
+ deps.rateLimiter,
350
+ "update_plan"
351
+ );
352
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
353
+ const updated = await planService.updatePlan(plan.id, {
354
+ ...(input.title !== undefined ? { title: input.title } : {}),
355
+ ...(input.description !== undefined
356
+ ? { description: input.description }
357
+ : {}),
358
+ ...(input.status !== undefined ? { status: input.status } : {})
359
+ });
360
+ writeToolAudit(
361
+ deps.auditLog,
362
+ "update_plan",
363
+ guard.principalId,
364
+ guard.correlationId,
365
+ updated.id
366
+ );
367
+ return {
368
+ content: [
369
+ {
370
+ type: "text",
371
+ text: `Plan '${updated.slug}' ("${updated.title}") updated [${updated.status}].`
372
+ }
373
+ ],
374
+ structuredContent: jsonStructured({ plan: updated })
375
+ };
376
+ } catch (e) {
377
+ return toMcpToolError(e);
378
+ }
379
+ }
380
+ );
381
+
382
+ server.registerTool(
383
+ "get_plan_progress",
384
+ {
385
+ description:
386
+ "Computes done vs total tasks for the plan and each sprint, with completion percentages. Use for burndown-style snapshots.",
387
+ inputSchema: z.object({ planSlug: optionalPlanSlug }).strict()
388
+ },
389
+ async (input) => {
390
+ try {
391
+ const plan = await resolvePlanOrActive(planService, input.planSlug);
392
+ const sprints = await sprintService.listSprints(plan.id);
393
+ let doneAll = 0;
394
+ let totalAll = 0;
395
+ const rows = [];
396
+ for (const sp of sprints) {
397
+ const tasks = await taskService.listTasks(sp.id);
398
+ const done = tasks.filter((t) => t.status === "done").length;
399
+ const total = tasks.length;
400
+ doneAll += done;
401
+ totalAll += total;
402
+ rows.push({
403
+ slug: sp.slug,
404
+ name: sp.name,
405
+ status: sp.status,
406
+ done,
407
+ total
408
+ });
409
+ }
410
+ const text = formatPlanProgress(plan.slug, doneAll, totalAll, rows);
411
+ return {
412
+ content: [{ type: "text", text }],
413
+ structuredContent: jsonStructured({
414
+ planSlug: plan.slug,
415
+ done: doneAll,
416
+ total: totalAll,
417
+ sprints: rows
418
+ })
419
+ };
420
+ } catch (e) {
421
+ return toMcpToolError(e);
422
+ }
423
+ }
424
+ );
425
+ }
426
+ };