@elixium.ai/mcp-server 0.1.8 → 0.2.0
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.
- package/dist/index.js +685 -299
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -113,16 +113,9 @@ const parseLaneStyle = (value) => {
|
|
|
113
113
|
return null;
|
|
114
114
|
};
|
|
115
115
|
const inferLaneStyleFromUrl = (apiUrl) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return "title";
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
// Fall through to default.
|
|
124
|
-
}
|
|
125
|
-
return "upper";
|
|
116
|
+
// Default to title case as that's what the frontend LaneType enum expects
|
|
117
|
+
// (e.g., "Backlog", "Current", "Done", "Icebox")
|
|
118
|
+
return "title";
|
|
126
119
|
};
|
|
127
120
|
const normalizeLaneForComparison = (lane) => {
|
|
128
121
|
if (typeof lane !== "string")
|
|
@@ -171,19 +164,91 @@ const resolveBoardId = async () => {
|
|
|
171
164
|
cachedBoardId = match.id;
|
|
172
165
|
return cachedBoardId;
|
|
173
166
|
};
|
|
167
|
+
// Feature flag caching
|
|
168
|
+
let cachedFeatureConfig = null;
|
|
169
|
+
const FEATURE_FLAG_CACHE_TTL = 60000; // 1 minute
|
|
170
|
+
const fetchFeatureConfig = async () => {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
if (cachedFeatureConfig && now - cachedFeatureConfig.fetchedAt < FEATURE_FLAG_CACHE_TTL) {
|
|
173
|
+
const { fetchedAt, ...config } = cachedFeatureConfig;
|
|
174
|
+
return config;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const boardId = await resolveBoardId();
|
|
178
|
+
const response = await client.get("/config/features", {
|
|
179
|
+
params: boardId ? { boardId } : undefined,
|
|
180
|
+
});
|
|
181
|
+
cachedFeatureConfig = {
|
|
182
|
+
...response.data,
|
|
183
|
+
fetchedAt: now,
|
|
184
|
+
};
|
|
185
|
+
return response.data;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error("Failed to fetch feature config, defaulting to all enabled:", error);
|
|
189
|
+
return {
|
|
190
|
+
features: {
|
|
191
|
+
balancedTeam: true,
|
|
192
|
+
learningLoop: true,
|
|
193
|
+
tddWorkflow: true,
|
|
194
|
+
aiTools: true,
|
|
195
|
+
},
|
|
196
|
+
source: {
|
|
197
|
+
balancedTeam: "error-fallback",
|
|
198
|
+
learningLoop: "error-fallback",
|
|
199
|
+
tddWorkflow: "error-fallback",
|
|
200
|
+
aiTools: "error-fallback",
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
// Helper functions to check individual features
|
|
206
|
+
const isTddWorkflowEnabled = async () => {
|
|
207
|
+
const config = await fetchFeatureConfig();
|
|
208
|
+
return config.features.tddWorkflow;
|
|
209
|
+
};
|
|
210
|
+
const isLearningLoopEnabled = async () => {
|
|
211
|
+
const config = await fetchFeatureConfig();
|
|
212
|
+
return config.features.learningLoop;
|
|
213
|
+
};
|
|
214
|
+
const isAiToolsEnabled = async () => {
|
|
215
|
+
const config = await fetchFeatureConfig();
|
|
216
|
+
return config.features.aiTools;
|
|
217
|
+
};
|
|
218
|
+
const getTeamProfile = async () => {
|
|
219
|
+
const config = await fetchFeatureConfig();
|
|
220
|
+
return config.teamProfile;
|
|
221
|
+
};
|
|
174
222
|
const fetchStories = async () => {
|
|
175
223
|
const boardId = await resolveBoardId();
|
|
224
|
+
const slug = normalizeBoardSlug(BOARD_SLUG);
|
|
225
|
+
const isMainBoard = slug === "main";
|
|
226
|
+
// For the "main" board, fetch ALL stories (no boardId filter) then filter
|
|
227
|
+
// client-side. This matches the frontend behavior: legacy stories have
|
|
228
|
+
// boardId: null and must be included on the main board.
|
|
176
229
|
const response = await client.get("/stories", {
|
|
177
|
-
params: boardId ? { boardId } : undefined,
|
|
230
|
+
params: boardId && !isMainBoard ? { boardId } : undefined,
|
|
178
231
|
});
|
|
179
|
-
|
|
232
|
+
let stories = extractStories(response.data);
|
|
233
|
+
// Client-side filter for main board: include stories matching boardId OR
|
|
234
|
+
// with no boardId (legacy stories created before multi-board support)
|
|
235
|
+
if (boardId && isMainBoard) {
|
|
236
|
+
stories = stories.filter((s) => s.boardId === boardId || !s.boardId);
|
|
237
|
+
}
|
|
238
|
+
return stories;
|
|
180
239
|
};
|
|
181
240
|
const fetchEpics = async () => {
|
|
182
241
|
const boardId = await resolveBoardId();
|
|
242
|
+
const slug = normalizeBoardSlug(BOARD_SLUG);
|
|
243
|
+
const isMainBoard = slug === "main";
|
|
183
244
|
const response = await client.get("/epics", {
|
|
184
|
-
params: boardId ? { boardId } : undefined,
|
|
245
|
+
params: boardId && !isMainBoard ? { boardId } : undefined,
|
|
185
246
|
});
|
|
186
|
-
|
|
247
|
+
let epics = extractEpics(response.data);
|
|
248
|
+
if (boardId && isMainBoard) {
|
|
249
|
+
epics = epics.filter((e) => e.boardId === boardId || !e.boardId);
|
|
250
|
+
}
|
|
251
|
+
return epics;
|
|
187
252
|
};
|
|
188
253
|
let cachedLaneStyle = null;
|
|
189
254
|
const detectLaneStyle = async () => {
|
|
@@ -221,7 +286,15 @@ const normalizeLane = async (lane) => {
|
|
|
221
286
|
const buildIterationContext = (stories, user = null) => {
|
|
222
287
|
const currentIteration = stories.filter((story) => normalizeLaneForComparison(story?.lane) === "current");
|
|
223
288
|
const backlog = stories.filter((story) => normalizeLaneForComparison(story?.lane) === "backlog");
|
|
224
|
-
|
|
289
|
+
// Cost summary across current iteration
|
|
290
|
+
const iterationStories = [...currentIteration, ...backlog];
|
|
291
|
+
const withEstimates = iterationStories.filter((s) => s.costEstimate);
|
|
292
|
+
const costSummary = {
|
|
293
|
+
totalMonthlyCost: withEstimates.reduce((sum, s) => sum + (s.costEstimate?.totalMonthlyCost || 0), 0),
|
|
294
|
+
estimatedCount: withEstimates.length,
|
|
295
|
+
unestimatedCount: iterationStories.length - withEstimates.length,
|
|
296
|
+
};
|
|
297
|
+
return { currentIteration, backlog, user, costSummary };
|
|
225
298
|
};
|
|
226
299
|
const createServer = () => {
|
|
227
300
|
const server = new Server({
|
|
@@ -234,302 +307,432 @@ const createServer = () => {
|
|
|
234
307
|
});
|
|
235
308
|
// Define Tools
|
|
236
309
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
310
|
+
const featureConfig = await fetchFeatureConfig();
|
|
311
|
+
const tddEnabled = featureConfig.features.tddWorkflow;
|
|
312
|
+
const learningLoopEnabled = featureConfig.features.learningLoop;
|
|
313
|
+
const baseTools = [
|
|
314
|
+
{
|
|
315
|
+
name: "get_feature_config",
|
|
316
|
+
description: "Get the feature configuration for this board/workspace. Returns enabled features, team profile, and smart defaults context.",
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: "object",
|
|
319
|
+
properties: {},
|
|
246
320
|
},
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
description: {
|
|
255
|
-
type: "string",
|
|
256
|
-
description: "Description of the story",
|
|
257
|
-
},
|
|
258
|
-
acceptanceCriteria: {
|
|
259
|
-
type: "string",
|
|
260
|
-
description: "Acceptance criteria in Given/When/Then format",
|
|
261
|
-
},
|
|
262
|
-
lane: {
|
|
263
|
-
type: "string",
|
|
264
|
-
description: "Lane to add the story to (case-insensitive)",
|
|
265
|
-
enum: [
|
|
266
|
-
"Backlog",
|
|
267
|
-
"Icebox",
|
|
268
|
-
"Current",
|
|
269
|
-
"Done",
|
|
270
|
-
"BACKLOG",
|
|
271
|
-
"ICEBOX",
|
|
272
|
-
"CURRENT",
|
|
273
|
-
"DONE",
|
|
274
|
-
],
|
|
275
|
-
},
|
|
276
|
-
points: {
|
|
277
|
-
type: "number",
|
|
278
|
-
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
279
|
-
},
|
|
280
|
-
requester: {
|
|
281
|
-
type: "string",
|
|
282
|
-
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
283
|
-
},
|
|
284
|
-
},
|
|
285
|
-
required: ["title"],
|
|
286
|
-
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "list_stories",
|
|
324
|
+
description: "List all stories on the Elixium board",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {},
|
|
287
328
|
},
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "create_story",
|
|
332
|
+
description: "Create a new story on the Elixium board",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
title: { type: "string", description: "Title of the story" },
|
|
337
|
+
description: {
|
|
338
|
+
type: "string",
|
|
339
|
+
description: "Description of the story",
|
|
340
|
+
},
|
|
341
|
+
acceptanceCriteria: {
|
|
342
|
+
type: "string",
|
|
343
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
344
|
+
},
|
|
345
|
+
lane: {
|
|
346
|
+
type: "string",
|
|
347
|
+
description: "Lane to add the story to (case-insensitive)",
|
|
348
|
+
enum: [
|
|
349
|
+
"Backlog",
|
|
350
|
+
"Icebox",
|
|
351
|
+
"Current",
|
|
352
|
+
"Done",
|
|
353
|
+
"BACKLOG",
|
|
354
|
+
"ICEBOX",
|
|
355
|
+
"CURRENT",
|
|
356
|
+
"DONE",
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
points: {
|
|
360
|
+
type: "number",
|
|
361
|
+
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
362
|
+
},
|
|
363
|
+
requester: {
|
|
364
|
+
type: "string",
|
|
365
|
+
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
366
|
+
},
|
|
294
367
|
},
|
|
368
|
+
required: ["title"],
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: "get_iteration_context",
|
|
373
|
+
description: "Get the current iteration context (Current + Backlog) for AI planning",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "list_objectives",
|
|
381
|
+
description: "List objectives for the current workspace",
|
|
382
|
+
inputSchema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "list_epics",
|
|
389
|
+
description: "List epics for the current board",
|
|
390
|
+
inputSchema: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {},
|
|
295
393
|
},
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
},
|
|
311
|
-
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "create_epic",
|
|
397
|
+
description: "Create a new epic for the current board",
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: "object",
|
|
400
|
+
properties: {
|
|
401
|
+
title: { type: "string", description: "Epic title" },
|
|
402
|
+
description: { type: "string", description: "Epic description" },
|
|
403
|
+
stage: {
|
|
404
|
+
type: "string",
|
|
405
|
+
description: "Roadmap stage",
|
|
406
|
+
enum: ["in_progress", "next", "soon", "someday", "archived"],
|
|
407
|
+
},
|
|
408
|
+
hypothesis: { type: "string", description: "We believe that... (outcome hypothesis)" },
|
|
409
|
+
successMetrics: { type: "string", description: "We'll know it works when... (success criteria)" },
|
|
410
|
+
targetDate: { type: "number", description: "Unix timestamp - when to evaluate outcome (not ship date)" },
|
|
411
|
+
outcomeStatus: {
|
|
412
|
+
type: "string",
|
|
413
|
+
description: "Outcome validation status",
|
|
414
|
+
enum: ["exploring", "validating", "achieved", "abandoned"],
|
|
415
|
+
},
|
|
312
416
|
},
|
|
417
|
+
required: ["title"],
|
|
313
418
|
},
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: "update_epic",
|
|
422
|
+
description: "Update an epic (title, description, stage, or outcome fields)",
|
|
423
|
+
inputSchema: {
|
|
424
|
+
type: "object",
|
|
425
|
+
properties: {
|
|
426
|
+
epicId: { type: "string", description: "ID of the epic" },
|
|
427
|
+
title: { type: "string", description: "Epic title" },
|
|
428
|
+
description: { type: "string", description: "Epic description" },
|
|
429
|
+
stage: {
|
|
430
|
+
type: "string",
|
|
431
|
+
description: "Roadmap stage",
|
|
432
|
+
enum: ["in_progress", "next", "soon", "someday", "archived"],
|
|
433
|
+
},
|
|
434
|
+
hypothesis: { type: "string", description: "We believe that... (outcome hypothesis)" },
|
|
435
|
+
successMetrics: { type: "string", description: "We'll know it works when... (success criteria)" },
|
|
436
|
+
targetDate: { type: "number", description: "Unix timestamp - when to evaluate outcome (not ship date)" },
|
|
437
|
+
outcomeStatus: {
|
|
438
|
+
type: "string",
|
|
439
|
+
description: "Outcome validation status",
|
|
440
|
+
enum: ["exploring", "validating", "achieved", "abandoned"],
|
|
441
|
+
},
|
|
320
442
|
},
|
|
443
|
+
required: ["epicId"],
|
|
321
444
|
},
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "update_story",
|
|
448
|
+
description: "Update fields on an existing story",
|
|
449
|
+
inputSchema: {
|
|
450
|
+
type: "object",
|
|
451
|
+
properties: {
|
|
452
|
+
storyId: { type: "string", description: "ID of the story" },
|
|
453
|
+
title: { type: "string", description: "Updated title" },
|
|
454
|
+
description: { type: "string", description: "Updated description" },
|
|
455
|
+
lane: {
|
|
456
|
+
type: "string",
|
|
457
|
+
description: "Lane to move the story to (case-insensitive)",
|
|
458
|
+
enum: [
|
|
459
|
+
"Backlog",
|
|
460
|
+
"Icebox",
|
|
461
|
+
"Current",
|
|
462
|
+
"Done",
|
|
463
|
+
"BACKLOG",
|
|
464
|
+
"ICEBOX",
|
|
465
|
+
"CURRENT",
|
|
466
|
+
"DONE",
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
points: {
|
|
470
|
+
type: "number",
|
|
471
|
+
description: "Updated points (0, 1, 2, 3, 5, 8)",
|
|
472
|
+
},
|
|
473
|
+
state: {
|
|
474
|
+
type: "string",
|
|
475
|
+
description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
|
|
476
|
+
},
|
|
477
|
+
outcome_summary: {
|
|
478
|
+
type: "string",
|
|
479
|
+
description: "Learning outcome summary",
|
|
480
|
+
},
|
|
481
|
+
acceptanceCriteria: {
|
|
482
|
+
type: "string",
|
|
483
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
484
|
+
},
|
|
335
485
|
},
|
|
486
|
+
required: ["storyId"],
|
|
336
487
|
},
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: "prepare_implementation",
|
|
491
|
+
description: "Fetch all context for a story and format an implementation brief",
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
storyId: {
|
|
496
|
+
type: "string",
|
|
497
|
+
description: "ID of the story to prepare",
|
|
498
|
+
},
|
|
343
499
|
},
|
|
500
|
+
required: ["storyId"],
|
|
344
501
|
},
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
},
|
|
366
|
-
},
|
|
367
|
-
required: ["title"],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "estimate_cost",
|
|
505
|
+
description: "Generate AI-assisted infrastructure cost estimate for a story. Uses live cloud pricing data when available.",
|
|
506
|
+
inputSchema: {
|
|
507
|
+
type: "object",
|
|
508
|
+
properties: {
|
|
509
|
+
storyId: {
|
|
510
|
+
type: "string",
|
|
511
|
+
description: "ID of the story to estimate cost for",
|
|
512
|
+
},
|
|
513
|
+
provider: {
|
|
514
|
+
type: "string",
|
|
515
|
+
description: "Cloud provider for the estimate",
|
|
516
|
+
enum: ["gcp", "aws", "azure", "self-hosted"],
|
|
517
|
+
},
|
|
518
|
+
constraints: {
|
|
519
|
+
type: "string",
|
|
520
|
+
description: "Optional constraints (e.g., budget limit, region, instance types)",
|
|
521
|
+
},
|
|
368
522
|
},
|
|
523
|
+
required: ["storyId", "provider"],
|
|
369
524
|
},
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
description: "Roadmap stage",
|
|
382
|
-
enum: ["in_progress", "next", "soon", "someday", "archived"],
|
|
383
|
-
},
|
|
384
|
-
hypothesis: { type: "string", description: "We believe that... (outcome hypothesis)" },
|
|
385
|
-
successMetrics: { type: "string", description: "We'll know it works when... (success criteria)" },
|
|
386
|
-
targetDate: { type: "number", description: "Unix timestamp - when to evaluate outcome (not ship date)" },
|
|
387
|
-
outcomeStatus: {
|
|
388
|
-
type: "string",
|
|
389
|
-
description: "Outcome validation status",
|
|
390
|
-
enum: ["exploring", "validating", "achieved", "abandoned"],
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
required: ["epicId"],
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "get_epic_cost_rollup",
|
|
528
|
+
description: "Get aggregated infrastructure cost for all stories in an epic. Shows total monthly cost, category breakdown, and estimation coverage.",
|
|
529
|
+
inputSchema: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
epicId: {
|
|
533
|
+
type: "string",
|
|
534
|
+
description: "ID of the epic",
|
|
535
|
+
},
|
|
394
536
|
},
|
|
537
|
+
required: ["epicId"],
|
|
395
538
|
},
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
state: {
|
|
424
|
-
type: "string",
|
|
425
|
-
description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
|
|
426
|
-
},
|
|
427
|
-
outcome_summary: {
|
|
428
|
-
type: "string",
|
|
429
|
-
description: "Learning outcome summary",
|
|
430
|
-
},
|
|
431
|
-
acceptanceCriteria: {
|
|
432
|
-
type: "string",
|
|
433
|
-
description: "Acceptance criteria in Given/When/Then format",
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
required: ["storyId"],
|
|
539
|
+
},
|
|
540
|
+
];
|
|
541
|
+
const tddTools = tddEnabled ? [
|
|
542
|
+
// TDD Workflow Tools
|
|
543
|
+
{
|
|
544
|
+
name: "start_story",
|
|
545
|
+
description: "Start TDD workflow for a story. By default creates a feature branch. Set trunkBased=true to work directly on main (recommended for small changes). Set autoMerge=true with trunkBased for short-lived branches that auto-merge on submit.",
|
|
546
|
+
inputSchema: {
|
|
547
|
+
type: "object",
|
|
548
|
+
properties: {
|
|
549
|
+
storyId: {
|
|
550
|
+
type: "string",
|
|
551
|
+
description: "ID of the story to start",
|
|
552
|
+
},
|
|
553
|
+
branchPrefix: {
|
|
554
|
+
type: "string",
|
|
555
|
+
description: "Branch prefix (feat, fix, chore) - ignored if trunkBased=true without autoMerge",
|
|
556
|
+
enum: ["feat", "fix", "chore"],
|
|
557
|
+
},
|
|
558
|
+
trunkBased: {
|
|
559
|
+
type: "boolean",
|
|
560
|
+
description: "If true, use trunk-based development. Without autoMerge, commits directly to main with feature flag. With autoMerge, creates a short-lived branch. Board/workspace defaults apply if not set.",
|
|
561
|
+
},
|
|
562
|
+
autoMerge: {
|
|
563
|
+
type: "boolean",
|
|
564
|
+
description: "If true with trunkBased, creates a short-lived branch that auto-merges to main on submit_for_review. Branch is deleted after merge.",
|
|
565
|
+
},
|
|
437
566
|
},
|
|
567
|
+
required: ["storyId"],
|
|
438
568
|
},
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
},
|
|
450
|
-
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "propose_test_plan",
|
|
572
|
+
description: "Submit a test plan for human review. Sets workflow_stage to tests_proposed. Implementation is BLOCKED until human approves.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
storyId: {
|
|
577
|
+
type: "string",
|
|
578
|
+
description: "ID of the story",
|
|
579
|
+
},
|
|
580
|
+
testPlan: {
|
|
581
|
+
type: "string",
|
|
582
|
+
description: "Markdown test plan describing test strategy",
|
|
583
|
+
},
|
|
584
|
+
testFilePaths: {
|
|
585
|
+
type: "array",
|
|
586
|
+
items: { type: "string" },
|
|
587
|
+
description: "Paths to created test files",
|
|
588
|
+
},
|
|
451
589
|
},
|
|
590
|
+
required: ["storyId", "testPlan"],
|
|
452
591
|
},
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "submit_for_review",
|
|
595
|
+
description: "Submit implementation for human review. Only works if tests are approved. Sets state to finished.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
type: "object",
|
|
598
|
+
properties: {
|
|
599
|
+
storyId: {
|
|
600
|
+
type: "string",
|
|
601
|
+
description: "ID of the story",
|
|
602
|
+
},
|
|
603
|
+
commitHash: {
|
|
604
|
+
type: "string",
|
|
605
|
+
description: "Latest commit SHA",
|
|
606
|
+
},
|
|
607
|
+
testResults: {
|
|
608
|
+
type: "string",
|
|
609
|
+
description: "Test output summary",
|
|
610
|
+
},
|
|
611
|
+
implementationNotes: {
|
|
612
|
+
type: "string",
|
|
613
|
+
description: "What was implemented",
|
|
614
|
+
},
|
|
475
615
|
},
|
|
616
|
+
required: ["storyId"],
|
|
476
617
|
},
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
},
|
|
496
|
-
},
|
|
497
|
-
required: ["storyId", "testPlan"],
|
|
618
|
+
},
|
|
619
|
+
] : [];
|
|
620
|
+
// Learning Loop tools (conditional on feature flag)
|
|
621
|
+
const learningLoopTools = learningLoopEnabled ? [
|
|
622
|
+
{
|
|
623
|
+
name: "create_hypothesis",
|
|
624
|
+
description: "Create a new assumption/hypothesis in the Icebox for validation",
|
|
625
|
+
inputSchema: {
|
|
626
|
+
type: "object",
|
|
627
|
+
properties: {
|
|
628
|
+
title: { type: "string", description: "The assumption statement" },
|
|
629
|
+
hypothesis: { type: "string", description: "Detailed hypothesis" },
|
|
630
|
+
confidence_score: {
|
|
631
|
+
type: "number",
|
|
632
|
+
description: "Initial confidence (1-5)",
|
|
633
|
+
minimum: 1,
|
|
634
|
+
maximum: 5,
|
|
635
|
+
},
|
|
498
636
|
},
|
|
637
|
+
required: ["title", "hypothesis", "confidence_score"],
|
|
499
638
|
},
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
description: "Latest commit SHA",
|
|
513
|
-
},
|
|
514
|
-
testResults: {
|
|
515
|
-
type: "string",
|
|
516
|
-
description: "Test output summary",
|
|
517
|
-
},
|
|
518
|
-
implementationNotes: {
|
|
519
|
-
type: "string",
|
|
520
|
-
description: "What was implemented",
|
|
521
|
-
},
|
|
522
|
-
},
|
|
523
|
-
required: ["storyId"],
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "record_learning",
|
|
642
|
+
description: "Record a learning outcome for a completed story",
|
|
643
|
+
inputSchema: {
|
|
644
|
+
type: "object",
|
|
645
|
+
properties: {
|
|
646
|
+
storyId: { type: "string", description: "ID of the story" },
|
|
647
|
+
outcome_summary: {
|
|
648
|
+
type: "string",
|
|
649
|
+
description: "What was learned?",
|
|
650
|
+
},
|
|
524
651
|
},
|
|
652
|
+
required: ["storyId", "outcome_summary"],
|
|
525
653
|
},
|
|
526
|
-
|
|
527
|
-
|
|
654
|
+
},
|
|
655
|
+
] : [];
|
|
656
|
+
return { tools: [...baseTools, ...tddTools, ...learningLoopTools] };
|
|
528
657
|
});
|
|
529
658
|
// Handle Requests
|
|
530
659
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
531
660
|
try {
|
|
532
|
-
|
|
661
|
+
const toolName = request.params.name;
|
|
662
|
+
// Check TDD workflow tools
|
|
663
|
+
const tddWorkflowTools = ["start_story", "propose_test_plan", "submit_for_review"];
|
|
664
|
+
if (tddWorkflowTools.includes(toolName)) {
|
|
665
|
+
const enabled = await isTddWorkflowEnabled();
|
|
666
|
+
if (!enabled) {
|
|
667
|
+
return {
|
|
668
|
+
content: [{
|
|
669
|
+
type: "text",
|
|
670
|
+
text: JSON.stringify({
|
|
671
|
+
error: "TDD workflow is disabled",
|
|
672
|
+
message: "The TDD workflow feature is currently disabled for this board. Contact your workspace administrator to enable it.",
|
|
673
|
+
help: "TDD workflow can be enabled in workspace settings or per-board settings."
|
|
674
|
+
}, null, 2)
|
|
675
|
+
}],
|
|
676
|
+
isError: true,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Check Learning Loop tools
|
|
681
|
+
const learningLoopTools = ["create_hypothesis", "record_learning"];
|
|
682
|
+
if (learningLoopTools.includes(toolName)) {
|
|
683
|
+
const enabled = await isLearningLoopEnabled();
|
|
684
|
+
if (!enabled) {
|
|
685
|
+
return {
|
|
686
|
+
content: [{
|
|
687
|
+
type: "text",
|
|
688
|
+
text: JSON.stringify({
|
|
689
|
+
error: "Learning Loop is disabled",
|
|
690
|
+
message: "The Learning Loop feature is currently disabled for this board. This feature includes hypothesis tracking and confidence scoring.",
|
|
691
|
+
help: "Learning Loop can be enabled in workspace settings or per-board settings."
|
|
692
|
+
}, null, 2)
|
|
693
|
+
}],
|
|
694
|
+
isError: true,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
switch (toolName) {
|
|
699
|
+
case "get_feature_config": {
|
|
700
|
+
const config = await fetchFeatureConfig();
|
|
701
|
+
const formattedConfig = `
|
|
702
|
+
# Workspace Feature Configuration
|
|
703
|
+
|
|
704
|
+
## Enabled Features
|
|
705
|
+
${config.features.balancedTeam ? "✅" : "❌"} **Balanced Team** - Design URLs, Three Amigos checks, OKR linking
|
|
706
|
+
${config.features.learningLoop ? "✅" : "❌"} **Learning Loop** - Hypothesis tracking, risk profiles, confidence scores
|
|
707
|
+
${config.features.tddWorkflow ? "✅" : "❌"} **TDD Workflow** - Test-driven development enforcement
|
|
708
|
+
${config.features.aiTools ? "✅" : "❌"} **AI Tools** - AI-powered suggestions and analysis
|
|
709
|
+
|
|
710
|
+
## Team Profile
|
|
711
|
+
${config.teamProfile ? `
|
|
712
|
+
- Team Size: ${config.teamProfile.teamSize || "Not set"}
|
|
713
|
+
- Has Designer: ${config.teamProfile.hasDesigner ? "Yes" : "No"}
|
|
714
|
+
- Has Product Manager: ${config.teamProfile.hasProductManager ? "Yes" : "No"}
|
|
715
|
+
- Has QA: ${config.teamProfile.hasQA ? "Yes" : "No"}
|
|
716
|
+
- Development Approach: ${config.teamProfile.developmentApproach || "Not set"}
|
|
717
|
+
` : "Team profile not configured"}
|
|
718
|
+
|
|
719
|
+
## Feature Sources
|
|
720
|
+
- Balanced Team: ${config.source.balancedTeam}
|
|
721
|
+
- Learning Loop: ${config.source.learningLoop}
|
|
722
|
+
- TDD Workflow: ${config.source.tddWorkflow}
|
|
723
|
+
- AI Tools: ${config.source.aiTools}
|
|
724
|
+
|
|
725
|
+
## Branching Strategy Defaults
|
|
726
|
+
${config.branchingDefaults ? `- Trunk-Based: ${config.branchingDefaults.trunkBased ? "Yes" : "No"}
|
|
727
|
+
- Auto-Merge: ${config.branchingDefaults.autoMerge ? "Yes" : "No"}
|
|
728
|
+
- Source: ${config.branchingDefaults.source}` : "Not configured (branch-based default)"}
|
|
729
|
+
|
|
730
|
+
> **Tip:** Features can be configured at workspace or board level. Board settings override workspace defaults.
|
|
731
|
+
`;
|
|
732
|
+
return {
|
|
733
|
+
content: [{ type: "text", text: formattedConfig.trim() }],
|
|
734
|
+
};
|
|
735
|
+
}
|
|
533
736
|
case "list_stories": {
|
|
534
737
|
const stories = await fetchStories();
|
|
535
738
|
return {
|
|
@@ -542,6 +745,12 @@ const createServer = () => {
|
|
|
542
745
|
const args = request.params.arguments;
|
|
543
746
|
const normalizedLane = await normalizeLane(args.lane);
|
|
544
747
|
const boardId = await resolveBoardId();
|
|
748
|
+
// Warn if no boardId - stories may not appear on board UI
|
|
749
|
+
if (!boardId) {
|
|
750
|
+
console.warn("Warning: ELIXIUM_BOARD_SLUG is not set or board not found. " +
|
|
751
|
+
"Stories created without boardId may not appear on the board UI. " +
|
|
752
|
+
"Set ELIXIUM_BOARD_SLUG in your MCP configuration.");
|
|
753
|
+
}
|
|
545
754
|
// Use requester from args, fall back to env var, then let backend use API key owner
|
|
546
755
|
const requester = args.requester || USER_EMAIL;
|
|
547
756
|
const response = await client.post("/stories", {
|
|
@@ -724,35 +933,68 @@ Here’s the smallest change that will validate it:
|
|
|
724
933
|
// TDD Workflow Handlers
|
|
725
934
|
case "start_story": {
|
|
726
935
|
const args = request.params.arguments;
|
|
727
|
-
const { storyId, branchPrefix, trunkBased } = args;
|
|
936
|
+
const { storyId, branchPrefix, trunkBased, autoMerge } = args;
|
|
728
937
|
if (!storyId) {
|
|
729
938
|
throw new Error("storyId is required");
|
|
730
939
|
}
|
|
731
940
|
const response = await client.post(`/stories/${storyId}/start`, {
|
|
732
941
|
branchPrefix: branchPrefix || "feat",
|
|
733
|
-
trunkBased
|
|
942
|
+
trunkBased,
|
|
943
|
+
autoMerge,
|
|
734
944
|
});
|
|
735
945
|
const result = response.data;
|
|
736
|
-
const isTrunk =
|
|
737
|
-
const
|
|
946
|
+
const isTrunk = result.trunkBased;
|
|
947
|
+
const isAutoMerge = result.autoMerge;
|
|
948
|
+
let formattedResult;
|
|
949
|
+
if (isTrunk && !isAutoMerge) {
|
|
950
|
+
formattedResult = `
|
|
738
951
|
# Story Started (Trunk-Based): ${storyId}
|
|
739
952
|
|
|
740
|
-
**Mode:**
|
|
953
|
+
**Mode:** Direct to main (trunk-based development)
|
|
954
|
+
**Feature Flag:** \`${result.featureFlagName || "none"}\`
|
|
741
955
|
**Workflow Stage:** ${result.workflow_stage}
|
|
742
956
|
|
|
743
957
|
## Acceptance Criteria
|
|
744
958
|
${result.acceptance_criteria || "No specific AC provided."}
|
|
745
959
|
|
|
746
960
|
## Trunk-Based Workflow
|
|
747
|
-
1.
|
|
748
|
-
2.
|
|
749
|
-
3.
|
|
750
|
-
4.
|
|
751
|
-
5.
|
|
752
|
-
6.
|
|
753
|
-
|
|
754
|
-
>
|
|
755
|
-
|
|
961
|
+
1. Write tests first
|
|
962
|
+
2. Call \`propose_test_plan\` and wait for approval
|
|
963
|
+
3. Implement (make tests pass)
|
|
964
|
+
4. Run \`npm run build\` to verify
|
|
965
|
+
5. Commit directly to main behind feature flag \`${result.featureFlagName}\`
|
|
966
|
+
6. Auto-deploy on green CI
|
|
967
|
+
|
|
968
|
+
> **Feature Flag:** Wrap new behavior with \`${result.featureFlagName}\` for safe isolation.
|
|
969
|
+
> **TDD Workflow:** Write tests first, then call \`propose_test_plan\` before implementing.
|
|
970
|
+
`;
|
|
971
|
+
}
|
|
972
|
+
else if (isTrunk && isAutoMerge) {
|
|
973
|
+
formattedResult = `
|
|
974
|
+
# Story Started (Trunk-Based + Auto-Merge): ${storyId}
|
|
975
|
+
|
|
976
|
+
**Mode:** Short-lived branch with auto-merge to main
|
|
977
|
+
**Branch:** \`${result.branch}\`
|
|
978
|
+
**Feature Flag:** \`${result.featureFlagName || "none"}\`
|
|
979
|
+
**Workflow Stage:** ${result.workflow_stage}
|
|
980
|
+
|
|
981
|
+
## Acceptance Criteria
|
|
982
|
+
${result.acceptance_criteria || "No specific AC provided."}
|
|
983
|
+
|
|
984
|
+
## Auto-Merge Workflow
|
|
985
|
+
1. Write tests first on branch \`${result.branch}\`
|
|
986
|
+
2. Call \`propose_test_plan\` and wait for approval
|
|
987
|
+
3. Implement (make tests pass)
|
|
988
|
+
4. Run \`npm run build\` to verify
|
|
989
|
+
5. Call \`submit_for_review\` — branch auto-merges to main if tests pass
|
|
990
|
+
6. Branch is deleted after merge
|
|
991
|
+
|
|
992
|
+
> **Feature Flag:** Wrap new behavior with \`${result.featureFlagName}\` for safe isolation.
|
|
993
|
+
> **TDD Workflow:** Write tests first, then call \`propose_test_plan\` before implementing.
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
formattedResult = `
|
|
756
998
|
# Story Started: ${storyId}
|
|
757
999
|
|
|
758
1000
|
**Branch:** \`${result.branch}\`
|
|
@@ -764,8 +1006,9 @@ ${result.acceptance_criteria || "No specific AC provided."}
|
|
|
764
1006
|
## Next Steps
|
|
765
1007
|
${result.workflow_reminder}
|
|
766
1008
|
|
|
767
|
-
>
|
|
1009
|
+
> **TDD Workflow:** Write tests first, then call \`propose_test_plan\` before implementing.
|
|
768
1010
|
`;
|
|
1011
|
+
}
|
|
769
1012
|
return {
|
|
770
1013
|
content: [{ type: "text", text: formattedResult.trim() }],
|
|
771
1014
|
};
|
|
@@ -814,6 +1057,25 @@ ${result.message}
|
|
|
814
1057
|
implementationNotes,
|
|
815
1058
|
});
|
|
816
1059
|
const result = response.data;
|
|
1060
|
+
const isAutoMerge = result.autoMerge;
|
|
1061
|
+
let autoMergeSection = "";
|
|
1062
|
+
if (isAutoMerge && result.autoMergeInstructions) {
|
|
1063
|
+
const instr = result.autoMergeInstructions;
|
|
1064
|
+
autoMergeSection = `
|
|
1065
|
+
## Auto-Merge Instructions
|
|
1066
|
+
**Action:** Merge \`${instr.sourceBranch}\` into \`${instr.targetBranch}\`
|
|
1067
|
+
**Delete branch after merge:** Yes
|
|
1068
|
+
|
|
1069
|
+
Run these git commands:
|
|
1070
|
+
\`\`\`bash
|
|
1071
|
+
git checkout main && git pull
|
|
1072
|
+
git merge ${instr.sourceBranch}
|
|
1073
|
+
git push origin main
|
|
1074
|
+
git push origin --delete ${instr.sourceBranch}
|
|
1075
|
+
git branch -d ${instr.sourceBranch}
|
|
1076
|
+
\`\`\`
|
|
1077
|
+
`;
|
|
1078
|
+
}
|
|
817
1079
|
const formattedResult = `
|
|
818
1080
|
# Implementation Submitted for Review
|
|
819
1081
|
|
|
@@ -821,17 +1083,141 @@ ${result.message}
|
|
|
821
1083
|
**Status:** ${result.status}
|
|
822
1084
|
**Workflow Stage:** ${result.workflow_stage}
|
|
823
1085
|
**State:** ${result.state}
|
|
1086
|
+
${result.trunkBased ? `**Mode:** Trunk-based${isAutoMerge ? " + Auto-merge" : ""}` : ""}
|
|
1087
|
+
${result.featureFlagName ? `**Feature Flag:** \`${result.featureFlagName}\`` : ""}
|
|
824
1088
|
|
|
825
1089
|
## Branch
|
|
826
|
-
\`${result.branch || "
|
|
1090
|
+
\`${result.branch || "main (trunk-based)"}\`
|
|
827
1091
|
|
|
828
1092
|
## Commits
|
|
829
1093
|
${(result.commit_hashes || []).map((h) => `- \`${h}\``).join("\n") || "No commits recorded"}
|
|
830
|
-
|
|
1094
|
+
${autoMergeSection}
|
|
831
1095
|
## Next Step
|
|
832
1096
|
${result.next_step}
|
|
833
1097
|
|
|
834
|
-
>
|
|
1098
|
+
> ${isAutoMerge
|
|
1099
|
+
? "**Auto-Merge:** Execute the git commands above to merge and clean up the branch."
|
|
1100
|
+
: "**Ready for Review:** Human should review diff and move story to Done lane."}
|
|
1101
|
+
`;
|
|
1102
|
+
return {
|
|
1103
|
+
content: [{ type: "text", text: formattedResult.trim() }],
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
case "estimate_cost": {
|
|
1107
|
+
const args = request.params.arguments;
|
|
1108
|
+
const { storyId, provider, constraints } = args;
|
|
1109
|
+
if (!storyId)
|
|
1110
|
+
throw new Error("storyId is required");
|
|
1111
|
+
if (!provider)
|
|
1112
|
+
throw new Error("provider is required");
|
|
1113
|
+
if (!["gcp", "aws", "azure", "self-hosted"].includes(provider)) {
|
|
1114
|
+
throw new Error("provider must be one of: gcp, aws, azure, self-hosted");
|
|
1115
|
+
}
|
|
1116
|
+
// Fetch the story to get title and description
|
|
1117
|
+
const storyRes = await client.get(`/stories/${storyId}`);
|
|
1118
|
+
const storyData = storyRes.data;
|
|
1119
|
+
if (!storyData.description) {
|
|
1120
|
+
throw new Error("Story needs a description before cost estimation");
|
|
1121
|
+
}
|
|
1122
|
+
// Call the AI estimate-cost endpoint
|
|
1123
|
+
const estimateRes = await client.post("/ai/estimate-cost", {
|
|
1124
|
+
storyTitle: storyData.title,
|
|
1125
|
+
description: storyData.description,
|
|
1126
|
+
provider,
|
|
1127
|
+
...(constraints ? { constraints } : {}),
|
|
1128
|
+
});
|
|
1129
|
+
const estimate = estimateRes.data;
|
|
1130
|
+
// Save the estimate to the story
|
|
1131
|
+
await client.patch(`/stories/${storyId}`, {
|
|
1132
|
+
costEstimate: estimate,
|
|
1133
|
+
});
|
|
1134
|
+
// Format result as markdown
|
|
1135
|
+
const componentTable = estimate.components
|
|
1136
|
+
.map((c) => provider === "self-hosted"
|
|
1137
|
+
? `| ${c.name} | ${c.category} | ${c.resourceUnits || "-"} |`
|
|
1138
|
+
: `| ${c.name} | ${c.category} | $${c.monthlyCost?.toFixed(2)} | ${c.resourceUnits || "-"} |`)
|
|
1139
|
+
.join("\n");
|
|
1140
|
+
const tableHeader = provider === "self-hosted"
|
|
1141
|
+
? "| Component | Category | Resources |\n|-----------|----------|-----------|\n"
|
|
1142
|
+
: "| Component | Category | Monthly Cost | Resources |\n|-----------|----------|-------------|------------|\n";
|
|
1143
|
+
const formattedResult = `
|
|
1144
|
+
# Cost Estimate: ${storyData.title}
|
|
1145
|
+
|
|
1146
|
+
**Provider:** ${provider.toUpperCase()}
|
|
1147
|
+
**Confidence:** ${estimate.confidence}
|
|
1148
|
+
**Pricing Source:** ${estimate.pricingSource === "live" ? "Live API data" : "AI-estimated"}
|
|
1149
|
+
${provider !== "self-hosted" ? `**Total Monthly Cost:** $${estimate.totalMonthlyCost?.toLocaleString()}/mo` : "**Mode:** Self-hosted (resource units only)"}
|
|
1150
|
+
|
|
1151
|
+
## Components
|
|
1152
|
+
${tableHeader}${componentTable}
|
|
1153
|
+
|
|
1154
|
+
## Assumptions
|
|
1155
|
+
${(estimate.assumptions || []).map((a) => `- ${a}`).join("\n")}
|
|
1156
|
+
|
|
1157
|
+
> Estimate saved to story ${storyId}.
|
|
1158
|
+
`;
|
|
1159
|
+
return {
|
|
1160
|
+
content: [{ type: "text", text: formattedResult.trim() }],
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
case "get_epic_cost_rollup": {
|
|
1164
|
+
const args = request.params.arguments;
|
|
1165
|
+
const { epicId } = args;
|
|
1166
|
+
if (!epicId)
|
|
1167
|
+
throw new Error("epicId is required");
|
|
1168
|
+
// Fetch all stories and filter by epicId
|
|
1169
|
+
const allStories = await fetchStories();
|
|
1170
|
+
const epicStories = allStories.filter((s) => s.epicId === epicId);
|
|
1171
|
+
// Fetch epic info
|
|
1172
|
+
const epicsRes = await client.get("/epics");
|
|
1173
|
+
const epicsData = Array.isArray(epicsRes.data) ? epicsRes.data : [];
|
|
1174
|
+
const epic = epicsData.find((e) => e.id === epicId);
|
|
1175
|
+
const epicTitle = epic?.title || epicId;
|
|
1176
|
+
const estimated = epicStories.filter((s) => s.costEstimate);
|
|
1177
|
+
const unestimated = epicStories.filter((s) => !s.costEstimate);
|
|
1178
|
+
if (estimated.length === 0) {
|
|
1179
|
+
return {
|
|
1180
|
+
content: [{
|
|
1181
|
+
type: "text",
|
|
1182
|
+
text: `# Cost Rollup: ${epicTitle}\n\nNo stories have cost estimates yet. Use \`estimate_cost\` to estimate individual stories first.\n\n**Stories without estimates:** ${unestimated.length}`,
|
|
1183
|
+
}],
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
let totalMonthlyCost = 0;
|
|
1187
|
+
const byCategory = {};
|
|
1188
|
+
const providerCounts = {};
|
|
1189
|
+
estimated.forEach((s) => {
|
|
1190
|
+
const est = s.costEstimate;
|
|
1191
|
+
totalMonthlyCost += est.totalMonthlyCost || 0;
|
|
1192
|
+
providerCounts[est.provider] = (providerCounts[est.provider] || 0) + 1;
|
|
1193
|
+
(est.components || []).forEach((comp) => {
|
|
1194
|
+
byCategory[comp.category] = (byCategory[comp.category] || 0) + (comp.monthlyCost || 0);
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
const providers = Object.keys(providerCounts);
|
|
1198
|
+
const providerSummary = providers.length === 1
|
|
1199
|
+
? providers[0].toUpperCase()
|
|
1200
|
+
: providers.map((p) => `${p.toUpperCase()} (${providerCounts[p]})`).join(", ");
|
|
1201
|
+
const categoryBreakdown = Object.entries(byCategory)
|
|
1202
|
+
.sort(([, a], [, b]) => b - a)
|
|
1203
|
+
.map(([cat, cost]) => `| ${cat} | $${cost.toFixed(2)} |`)
|
|
1204
|
+
.join("\n");
|
|
1205
|
+
const formattedResult = `
|
|
1206
|
+
# Cost Rollup: ${epicTitle}
|
|
1207
|
+
|
|
1208
|
+
**Total Monthly Cost:** $${totalMonthlyCost.toLocaleString(undefined, { minimumFractionDigits: 2 })}/mo
|
|
1209
|
+
**Coverage:** ${estimated.length} of ${epicStories.length} stories estimated
|
|
1210
|
+
**Provider(s):** ${providerSummary}
|
|
1211
|
+
|
|
1212
|
+
## Cost by Category
|
|
1213
|
+
| Category | Monthly Cost |
|
|
1214
|
+
|----------|-------------|
|
|
1215
|
+
${categoryBreakdown}
|
|
1216
|
+
|
|
1217
|
+
## Stories Without Estimates
|
|
1218
|
+
${unestimated.length === 0
|
|
1219
|
+
? "All stories have been estimated!"
|
|
1220
|
+
: unestimated.map((s) => `- ${s.title} (${s.id})`).join("\n")}
|
|
835
1221
|
`;
|
|
836
1222
|
return {
|
|
837
1223
|
content: [{ type: "text", text: formattedResult.trim() }],
|