@elixium.ai/mcp-server 0.4.0 → 0.6.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/README.md +25 -0
- package/dist/constants/featureConfigFallback.js +30 -0
- package/dist/constants/workflowModelLabels.js +59 -0
- package/dist/index.js +308 -130
- package/dist/stakeholders.js +72 -0
- package/dist/toolSchemas.js +245 -0
- package/package.json +8 -6
- package/dist/__tests__/board-isolation.test.js +0 -83
- package/dist/__tests__/create-board.test.js +0 -97
- package/dist/__tests__/fixtures/boards.js +0 -117
- package/dist/__tests__/list-boards.test.js +0 -54
- package/dist/__tests__/select-board.test.js +0 -74
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stakeholder derivation — mcp-server local mirror of the logic in
|
|
3
|
+
* `lib/trust-audiences.ts` (story 72d27a2a / §7).
|
|
4
|
+
*
|
|
5
|
+
* Why duplicated: mcp-server is a separately-published npm package with
|
|
6
|
+
* its own `tsconfig.json` (rootDir = `./src`). It can't import from the
|
|
7
|
+
* parent repo's `lib/` without a workspace-level refactor. For now, the
|
|
8
|
+
* derivation logic lives independently here.
|
|
9
|
+
*
|
|
10
|
+
* **Drift guard:** `tests/stakeholder-detection-mcp-parity.spec.ts`
|
|
11
|
+
* imports both this module and `lib/trust-audiences.ts` and asserts they
|
|
12
|
+
* produce identical output on a fixture matrix. If either copy is
|
|
13
|
+
* updated without the other, that test fails.
|
|
14
|
+
*
|
|
15
|
+
* If/when a shared workspace package is introduced, consolidate.
|
|
16
|
+
*/
|
|
17
|
+
export const AUDIENCE_KEYS = [
|
|
18
|
+
"peers",
|
|
19
|
+
"institutional_memory",
|
|
20
|
+
"onboarders",
|
|
21
|
+
"risk_committee",
|
|
22
|
+
"governance",
|
|
23
|
+
"execs",
|
|
24
|
+
];
|
|
25
|
+
// Case-insensitive: accepts uppercase forms historically used in test
|
|
26
|
+
// fixtures AND the lowercase enum values from types.ts.
|
|
27
|
+
const REGULATED_FRAMEWORKS_NORMALIZED = new Set([
|
|
28
|
+
"hipaa",
|
|
29
|
+
"soc2",
|
|
30
|
+
"fedramp-moderate",
|
|
31
|
+
"fedramp-high",
|
|
32
|
+
"fedramp",
|
|
33
|
+
]);
|
|
34
|
+
function isRegulatedFramework(framework) {
|
|
35
|
+
return REGULATED_FRAMEWORKS_NORMALIZED.has(framework.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
function nonEmpty(value) {
|
|
38
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
39
|
+
}
|
|
40
|
+
export function deriveStakeholders(input) {
|
|
41
|
+
const result = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
function add(audience, reason) {
|
|
44
|
+
if (seen.has(audience))
|
|
45
|
+
return;
|
|
46
|
+
seen.add(audience);
|
|
47
|
+
result.push({ audience, reason });
|
|
48
|
+
}
|
|
49
|
+
add("peers", "always needed");
|
|
50
|
+
add("institutional_memory", "always needed");
|
|
51
|
+
add("onboarders", "always needed");
|
|
52
|
+
if (nonEmpty(input.story.risk_profile)) {
|
|
53
|
+
add("risk_committee", `risk_profile = "${input.story.risk_profile}"`);
|
|
54
|
+
}
|
|
55
|
+
if (input.story.risk_profile === "Compliance") {
|
|
56
|
+
add("governance", "risk_profile flags Compliance");
|
|
57
|
+
}
|
|
58
|
+
const regulated = (input.workspaceCompliance ?? []).find((f) => isRegulatedFramework(f));
|
|
59
|
+
if (regulated) {
|
|
60
|
+
add("governance", `workspace compliance: ${regulated}`);
|
|
61
|
+
}
|
|
62
|
+
const epic = input.epic;
|
|
63
|
+
if (epic) {
|
|
64
|
+
if (nonEmpty(epic.hypothesis)) {
|
|
65
|
+
add("execs", "epic has hypothesis");
|
|
66
|
+
}
|
|
67
|
+
else if (nonEmpty(epic.successMetrics)) {
|
|
68
|
+
add("execs", "epic has successMetrics");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool input schemas — extracted from `index.ts` so they can be imported
|
|
3
|
+
* directly by behavior tests instead of grepped from source (Tier 5.7).
|
|
4
|
+
*
|
|
5
|
+
* Pattern for new MCP tools in this package: define the inputSchema here as
|
|
6
|
+
* an exported `as const` literal, then reference it from the tool definition
|
|
7
|
+
* in `index.ts`. Story 50e536ca will eventually migrate the remaining inline
|
|
8
|
+
* schemas to this module.
|
|
9
|
+
*
|
|
10
|
+
* Keep enums and field shapes locked to the backend Zod validators
|
|
11
|
+
* (`backend/api/src/validators/storySchemas.ts`). Drift between the MCP
|
|
12
|
+
* schema and the backend validator means LLMs can send fields that get
|
|
13
|
+
* silently stripped (or be told to send valid fields the backend rejects).
|
|
14
|
+
* Cross-spec drift guards live in the test specs.
|
|
15
|
+
*/
|
|
16
|
+
const STORY_TYPE_ENUM = ["feature", "bug", "chore", "platform"];
|
|
17
|
+
const RISK_PROFILE_ENUM = [
|
|
18
|
+
"User",
|
|
19
|
+
"Tech",
|
|
20
|
+
"Market",
|
|
21
|
+
"Compliance",
|
|
22
|
+
"Model_Drift",
|
|
23
|
+
];
|
|
24
|
+
const LANE_ENUM = [
|
|
25
|
+
"Backlog",
|
|
26
|
+
"Icebox",
|
|
27
|
+
"Current",
|
|
28
|
+
"Done",
|
|
29
|
+
"BACKLOG",
|
|
30
|
+
"ICEBOX",
|
|
31
|
+
"CURRENT",
|
|
32
|
+
"DONE",
|
|
33
|
+
];
|
|
34
|
+
export const CREATE_STORY_INPUT_SCHEMA = {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
title: { type: "string", description: "Title of the story" },
|
|
38
|
+
description: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Description of the story",
|
|
41
|
+
},
|
|
42
|
+
acceptanceCriteria: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
45
|
+
},
|
|
46
|
+
lane: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Lane to add the story to (case-insensitive)",
|
|
49
|
+
enum: LANE_ENUM,
|
|
50
|
+
},
|
|
51
|
+
points: {
|
|
52
|
+
type: "number",
|
|
53
|
+
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
54
|
+
},
|
|
55
|
+
requester: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
58
|
+
},
|
|
59
|
+
epicId: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Epic ID to link this story to (optional). Use list_epics to find epic IDs.",
|
|
62
|
+
},
|
|
63
|
+
testPlan: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "Markdown test plan describing test strategy (optional). Sets the test_plan field without changing workflow_stage.",
|
|
66
|
+
},
|
|
67
|
+
storyType: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "Categorizes the story for filtering and Learning Loop signals (feature, bug, chore, platform).",
|
|
70
|
+
enum: STORY_TYPE_ENUM,
|
|
71
|
+
},
|
|
72
|
+
owners: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: { type: "string" },
|
|
75
|
+
description: "Story d9541094 — Firebase UIDs of the story's owners. If omitted, the backend auto-assigns the API key's owner UID (so MCP-created stories show up under the user's 'My Stories' filter). Pass an explicit array to assign teammates, or `[]` to create intentionally unowned.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ["title"],
|
|
79
|
+
};
|
|
80
|
+
// ─── Story 0ad8e319 (§2): Agent-proposal primitive ──────────────────────
|
|
81
|
+
const PROPOSABLE_FIELD_NAMES_FOR_MCP = [
|
|
82
|
+
"hypothesis",
|
|
83
|
+
"hidden_unknowns",
|
|
84
|
+
"confidence_score",
|
|
85
|
+
"risk_profile",
|
|
86
|
+
"outcome_summary",
|
|
87
|
+
];
|
|
88
|
+
export const PROPOSE_FIELD_DRAFT_INPUT_SCHEMA = {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
storyId: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "UUID of the story this proposal is for",
|
|
94
|
+
},
|
|
95
|
+
fieldName: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "Which Learning Loop field this proposal targets. Must be one of the proposable fields.",
|
|
98
|
+
enum: PROPOSABLE_FIELD_NAMES_FOR_MCP,
|
|
99
|
+
},
|
|
100
|
+
draftValue: {
|
|
101
|
+
type: ["string", "number", "null"],
|
|
102
|
+
description: "The proposed value for the field. Type matches the field: string for hypothesis/hidden_unknowns/risk_profile/outcome_summary, number for confidence_score, null to propose clearing the field.",
|
|
103
|
+
},
|
|
104
|
+
provider: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "Optional: the LLM provider+model that generated this draft (e.g., 'anthropic.claude-sonnet-4.5', 'google.gemini-2.0-flash'). Strongly encouraged — disclosed on the proposal badge so humans see which model drafted it.",
|
|
107
|
+
},
|
|
108
|
+
agent: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Optional: the agent client+version that submitted the proposal (e.g., 'claude-code/1.2.3', 'cursor/0.40.0'). Used for per-agent calibration signatures.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ["storyId", "fieldName", "draftValue"],
|
|
114
|
+
};
|
|
115
|
+
export const ENDORSE_PROPOSAL_INPUT_SCHEMA = {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
proposalId: {
|
|
119
|
+
type: "string",
|
|
120
|
+
description: "UUID of the pending proposal to endorse. Endorsement updates the underlying story field with the proposal's draft_value and emits a proposal.endorsed audit event.",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ["proposalId"],
|
|
124
|
+
};
|
|
125
|
+
export const REJECT_PROPOSAL_INPUT_SCHEMA = {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
proposalId: {
|
|
129
|
+
type: "string",
|
|
130
|
+
description: "UUID of the pending proposal to reject.",
|
|
131
|
+
},
|
|
132
|
+
reason: {
|
|
133
|
+
type: "string",
|
|
134
|
+
description: "Required reason the proposal was rejected. Captured for agent learning + audit.",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
required: ["proposalId", "reason"],
|
|
138
|
+
};
|
|
139
|
+
export const DRAFT_HYPOTHESIS_INPUT_SCHEMA = {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
storyId: {
|
|
143
|
+
type: "string",
|
|
144
|
+
description: "UUID of the story to draft a hypothesis for. Reference consumer of the proposal primitive — calls the configured AI_PROVIDER on the backend to generate a draft hypothesis, then submits it as a pending proposal. Pattern for §4 outcome-from-evidence and §6 v2 missing-field drafter to follow.",
|
|
145
|
+
},
|
|
146
|
+
context: {
|
|
147
|
+
type: "string",
|
|
148
|
+
description: "Optional additional context to seed the AI draft (e.g., domain notes, prior hypotheses).",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ["storyId"],
|
|
152
|
+
};
|
|
153
|
+
export const GET_STAKEHOLDERS_INPUT_SCHEMA = {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
storyId: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description: "UUID of the story to derive stakeholders for. Returns which audiences (peers, execs, governance, risk_committee, institutional_memory, onboarders) this card needs to earn trust with, based on story metadata + linked epic + workspace compliance frameworks.",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
required: ["storyId"],
|
|
162
|
+
};
|
|
163
|
+
export const GET_TRUST_LEAKAGE_INPUT_SCHEMA = {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
since: {
|
|
167
|
+
type: "string",
|
|
168
|
+
description: "Optional ISO-8601 timestamp. Return only commits authored on or after this instant. Defaults to 30 days ago.",
|
|
169
|
+
},
|
|
170
|
+
repo: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description: "Optional repo override in 'owner/repo' form. Defaults to the workspace's configured default_repo for the tenant's provider.",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
required: [],
|
|
176
|
+
};
|
|
177
|
+
export const UPDATE_STORY_INPUT_SCHEMA = {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
storyId: { type: "string", description: "ID of the story" },
|
|
181
|
+
title: { type: "string", description: "Updated title" },
|
|
182
|
+
description: { type: "string", description: "Updated description" },
|
|
183
|
+
lane: {
|
|
184
|
+
type: "string",
|
|
185
|
+
description: "Lane to move the story to (case-insensitive)",
|
|
186
|
+
enum: LANE_ENUM,
|
|
187
|
+
},
|
|
188
|
+
points: {
|
|
189
|
+
type: "number",
|
|
190
|
+
description: "Updated points (0, 1, 2, 3, 5, 8)",
|
|
191
|
+
},
|
|
192
|
+
state: {
|
|
193
|
+
type: "string",
|
|
194
|
+
description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
|
|
195
|
+
},
|
|
196
|
+
outcome_summary: {
|
|
197
|
+
type: "string",
|
|
198
|
+
description: "Learning outcome summary",
|
|
199
|
+
},
|
|
200
|
+
acceptanceCriteria: {
|
|
201
|
+
type: "string",
|
|
202
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
203
|
+
},
|
|
204
|
+
sortOrder: {
|
|
205
|
+
type: "number",
|
|
206
|
+
description: "Sort order within the lane (lower = higher priority)",
|
|
207
|
+
},
|
|
208
|
+
epicId: {
|
|
209
|
+
type: "string",
|
|
210
|
+
description: "Epic ID to link this story to. Set to empty string to unlink.",
|
|
211
|
+
},
|
|
212
|
+
testPlan: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "Markdown test plan. Updates test_plan field without changing workflow_stage.",
|
|
215
|
+
},
|
|
216
|
+
storyType: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "Categorizes the story for filtering and Learning Loop signals (feature, bug, chore, platform).",
|
|
219
|
+
enum: STORY_TYPE_ENUM,
|
|
220
|
+
},
|
|
221
|
+
hypothesis: {
|
|
222
|
+
type: "string",
|
|
223
|
+
description: "We believe that... — drives Learning Loop AI prompts and earns trust with execs/board.",
|
|
224
|
+
},
|
|
225
|
+
confidence_score: {
|
|
226
|
+
type: "number",
|
|
227
|
+
description: "How confident we are this delivers value (0-100). Earns trust with peers and calibration-minded leaders.",
|
|
228
|
+
},
|
|
229
|
+
hidden_unknowns: {
|
|
230
|
+
type: "string",
|
|
231
|
+
description: "Assumptions or open questions that could invalidate the hypothesis. Earns trust with risk committee / staff engineering.",
|
|
232
|
+
},
|
|
233
|
+
risk_profile: {
|
|
234
|
+
type: "string",
|
|
235
|
+
description: "Categorizes risk (User, Tech, Market, Compliance, Model_Drift). Earns trust with governance and compliance audiences.",
|
|
236
|
+
enum: RISK_PROFILE_ENUM,
|
|
237
|
+
},
|
|
238
|
+
owners: {
|
|
239
|
+
type: "array",
|
|
240
|
+
items: { type: "string" },
|
|
241
|
+
description: "Story d9541094 — Firebase UIDs of the story's owners. Replaces the existing array (PATCH semantics). Pass `[]` to clear owners. Use this to retroactively assign yourself or a teammate to a story whose owners were dropped before the auto-assign fix landed.",
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
required: ["storyId"],
|
|
245
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elixium.ai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP Server for Elixium.ai",
|
|
6
6
|
"mcpName": "io.github.IndirectTek/mcp-server",
|
|
@@ -19,20 +19,22 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "tsc",
|
|
22
|
-
"test": "vitest run",
|
|
23
|
-
"test:watch": "vitest",
|
|
24
22
|
"start": "node dist/index.js",
|
|
25
23
|
"dev": "ts-node src/index.ts"
|
|
26
24
|
},
|
|
27
25
|
"dependencies": {
|
|
28
|
-
"@modelcontextprotocol/sdk": "^
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
29
27
|
"axios": "^1.6.0",
|
|
30
28
|
"zod": "^3.22.0"
|
|
31
29
|
},
|
|
32
30
|
"devDependencies": {
|
|
33
31
|
"@types/node": "^20.0.0",
|
|
34
32
|
"ts-node": "^10.9.0",
|
|
35
|
-
"typescript": "^5.3.0"
|
|
36
|
-
|
|
33
|
+
"typescript": "^5.3.0"
|
|
34
|
+
},
|
|
35
|
+
"overrides": {
|
|
36
|
+
"ip-address": "^10.1.1",
|
|
37
|
+
"hono": "^4.12.18",
|
|
38
|
+
"fast-uri": "^3.1.2"
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { selectBoard, resetBoardContext, getRuntimeBoardId, } from "../board-context.js";
|
|
3
|
-
import { ALL_BOARDS, BOARD_ALPHA, BOARD_BETA, ALPHA_STORIES, BETA_STORIES, ALL_STORIES, ALPHA_EPICS, BETA_EPICS, } from "./fixtures/boards.js";
|
|
4
|
-
const mockClient = {
|
|
5
|
-
get: vi.fn(),
|
|
6
|
-
post: vi.fn(),
|
|
7
|
-
};
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
vi.clearAllMocks();
|
|
10
|
-
resetBoardContext();
|
|
11
|
-
});
|
|
12
|
-
/**
|
|
13
|
-
* Simulates how fetchStories filters by boardId in the MCP server.
|
|
14
|
-
* This mirrors the logic in index.ts that will use getRuntimeBoardId().
|
|
15
|
-
*/
|
|
16
|
-
const fetchStoriesForBoard = (boardId, allStories) => {
|
|
17
|
-
if (!boardId)
|
|
18
|
-
return allStories;
|
|
19
|
-
return allStories.filter((s) => s.boardId === boardId || !s.boardId);
|
|
20
|
-
};
|
|
21
|
-
const fetchEpicsForBoard = (boardId, allEpics) => {
|
|
22
|
-
if (!boardId)
|
|
23
|
-
return allEpics;
|
|
24
|
-
return allEpics.filter((e) => e.boardId === boardId || !e.boardId);
|
|
25
|
-
};
|
|
26
|
-
describe("board isolation", () => {
|
|
27
|
-
it("4.1 stories are scoped to selected board", async () => {
|
|
28
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
29
|
-
await selectBoard(mockClient, "alpha");
|
|
30
|
-
const boardId = getRuntimeBoardId();
|
|
31
|
-
const stories = fetchStoriesForBoard(boardId, ALL_STORIES);
|
|
32
|
-
expect(stories).toHaveLength(ALPHA_STORIES.length);
|
|
33
|
-
expect(stories.every((s) => s.boardId === BOARD_ALPHA.id)).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
it("4.1b beta board returns only beta stories", async () => {
|
|
36
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
37
|
-
await selectBoard(mockClient, "beta");
|
|
38
|
-
const boardId = getRuntimeBoardId();
|
|
39
|
-
const stories = fetchStoriesForBoard(boardId, ALL_STORIES);
|
|
40
|
-
expect(stories).toHaveLength(BETA_STORIES.length);
|
|
41
|
-
expect(stories.every((s) => s.boardId === BOARD_BETA.id)).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
it("4.2 creating a story assigns it to selected board", async () => {
|
|
44
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
45
|
-
await selectBoard(mockClient, "alpha");
|
|
46
|
-
const boardId = getRuntimeBoardId();
|
|
47
|
-
// Simulate what create_story handler does
|
|
48
|
-
const payload = {
|
|
49
|
-
title: "New Story",
|
|
50
|
-
lane: "Current",
|
|
51
|
-
...(boardId ? { boardId } : {}),
|
|
52
|
-
};
|
|
53
|
-
expect(payload.boardId).toBe(BOARD_ALPHA.id);
|
|
54
|
-
});
|
|
55
|
-
it("4.3 epics respect board context", async () => {
|
|
56
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
57
|
-
await selectBoard(mockClient, "alpha");
|
|
58
|
-
const boardId = getRuntimeBoardId();
|
|
59
|
-
const allEpics = [...ALPHA_EPICS, ...BETA_EPICS];
|
|
60
|
-
const epics = fetchEpicsForBoard(boardId, allEpics);
|
|
61
|
-
expect(epics).toHaveLength(1);
|
|
62
|
-
expect(epics[0].boardId).toBe(BOARD_ALPHA.id);
|
|
63
|
-
});
|
|
64
|
-
it("4.5 no board selected falls back to all stories", async () => {
|
|
65
|
-
// No select_board called, no env var
|
|
66
|
-
const boardId = getRuntimeBoardId();
|
|
67
|
-
expect(boardId).toBeNull();
|
|
68
|
-
const stories = fetchStoriesForBoard(boardId, ALL_STORIES);
|
|
69
|
-
expect(stories).toHaveLength(ALL_STORIES.length);
|
|
70
|
-
});
|
|
71
|
-
it("switching boards changes story scope", async () => {
|
|
72
|
-
// Select alpha
|
|
73
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
74
|
-
await selectBoard(mockClient, "alpha");
|
|
75
|
-
let stories = fetchStoriesForBoard(getRuntimeBoardId(), ALL_STORIES);
|
|
76
|
-
expect(stories).toHaveLength(ALPHA_STORIES.length);
|
|
77
|
-
// Switch to beta
|
|
78
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
79
|
-
await selectBoard(mockClient, "beta");
|
|
80
|
-
stories = fetchStoriesForBoard(getRuntimeBoardId(), ALL_STORIES);
|
|
81
|
-
expect(stories).toHaveLength(BETA_STORIES.length);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { createBoard, resetBoardContext } from "../board-context.js";
|
|
3
|
-
const mockClient = {
|
|
4
|
-
get: vi.fn(),
|
|
5
|
-
post: vi.fn(),
|
|
6
|
-
};
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
vi.clearAllMocks();
|
|
9
|
-
resetBoardContext();
|
|
10
|
-
});
|
|
11
|
-
describe("create_board", () => {
|
|
12
|
-
it("2.1 creates board with name and description", async () => {
|
|
13
|
-
const created = {
|
|
14
|
-
id: "new-board-id",
|
|
15
|
-
slug: "sprint-board",
|
|
16
|
-
name: "Sprint Board",
|
|
17
|
-
description: "For sprints",
|
|
18
|
-
is_archived: false,
|
|
19
|
-
created_at: "2026-03-23T00:00:00.000Z",
|
|
20
|
-
};
|
|
21
|
-
mockClient.post.mockResolvedValue({ data: created });
|
|
22
|
-
const result = await createBoard(mockClient, {
|
|
23
|
-
name: "Sprint Board",
|
|
24
|
-
description: "For sprints",
|
|
25
|
-
});
|
|
26
|
-
expect(mockClient.post).toHaveBeenCalledWith("/boards", {
|
|
27
|
-
name: "Sprint Board",
|
|
28
|
-
slug: "sprint-board",
|
|
29
|
-
description: "For sprints",
|
|
30
|
-
});
|
|
31
|
-
expect(result.id).toBe("new-board-id");
|
|
32
|
-
expect(result.slug).toBe("sprint-board");
|
|
33
|
-
expect(result.name).toBe("Sprint Board");
|
|
34
|
-
});
|
|
35
|
-
it("2.2 auto-generates slug from name", async () => {
|
|
36
|
-
mockClient.post.mockResolvedValue({
|
|
37
|
-
data: {
|
|
38
|
-
id: "gen-id",
|
|
39
|
-
slug: "my-sprint-board",
|
|
40
|
-
name: "My Sprint Board",
|
|
41
|
-
is_archived: false,
|
|
42
|
-
created_at: "2026-03-23T00:00:00.000Z",
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
await createBoard(mockClient, { name: "My Sprint Board" });
|
|
46
|
-
expect(mockClient.post).toHaveBeenCalledWith("/boards", {
|
|
47
|
-
name: "My Sprint Board",
|
|
48
|
-
slug: "my-sprint-board",
|
|
49
|
-
description: "",
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
it("2.3 creates board with only required name field", async () => {
|
|
53
|
-
mockClient.post.mockResolvedValue({
|
|
54
|
-
data: {
|
|
55
|
-
id: "min-id",
|
|
56
|
-
slug: "backlog",
|
|
57
|
-
name: "Backlog",
|
|
58
|
-
description: "",
|
|
59
|
-
is_archived: false,
|
|
60
|
-
created_at: "2026-03-23T00:00:00.000Z",
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
const result = await createBoard(mockClient, { name: "Backlog" });
|
|
64
|
-
expect(result.name).toBe("Backlog");
|
|
65
|
-
expect(result.description).toBe("");
|
|
66
|
-
});
|
|
67
|
-
it("2.4 rejects creation when board limit reached", async () => {
|
|
68
|
-
mockClient.post.mockRejectedValue({
|
|
69
|
-
response: {
|
|
70
|
-
status: 422,
|
|
71
|
-
data: { error: "Maximum 3 active boards per tenant" },
|
|
72
|
-
},
|
|
73
|
-
message: "Request failed with status code 422",
|
|
74
|
-
});
|
|
75
|
-
await expect(createBoard(mockClient, { name: "Fourth Board" })).rejects.toMatchObject({
|
|
76
|
-
response: { status: 422 },
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
it("2.5 rejects duplicate slug within tenant", async () => {
|
|
80
|
-
mockClient.post.mockRejectedValue({
|
|
81
|
-
response: {
|
|
82
|
-
status: 409,
|
|
83
|
-
data: { error: "Board slug already exists" },
|
|
84
|
-
},
|
|
85
|
-
message: "Request failed with status code 409",
|
|
86
|
-
});
|
|
87
|
-
await expect(createBoard(mockClient, { name: "Alpha Board" })).rejects.toMatchObject({
|
|
88
|
-
response: { status: 409 },
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
it("2.6 validates name is non-empty", async () => {
|
|
92
|
-
await expect(createBoard(mockClient, { name: "" })).rejects.toThrow("Board name cannot be empty");
|
|
93
|
-
await expect(createBoard(mockClient, { name: " " })).rejects.toThrow("Board name cannot be empty");
|
|
94
|
-
// Should not call API
|
|
95
|
-
expect(mockClient.post).not.toHaveBeenCalled();
|
|
96
|
-
});
|
|
97
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test fixtures for multi-board MCP tools.
|
|
3
|
-
*/
|
|
4
|
-
export const BOARD_ALPHA = {
|
|
5
|
-
id: "aaaaaaaa-1111-2222-3333-444444444444",
|
|
6
|
-
slug: "alpha",
|
|
7
|
-
name: "Alpha Board",
|
|
8
|
-
description: "First test board",
|
|
9
|
-
is_archived: false,
|
|
10
|
-
created_at: "2026-03-01T00:00:00.000Z",
|
|
11
|
-
settings: {},
|
|
12
|
-
};
|
|
13
|
-
export const BOARD_BETA = {
|
|
14
|
-
id: "bbbbbbbb-1111-2222-3333-444444444444",
|
|
15
|
-
slug: "beta",
|
|
16
|
-
name: "Beta Board",
|
|
17
|
-
description: "Second test board",
|
|
18
|
-
is_archived: false,
|
|
19
|
-
created_at: "2026-03-02T00:00:00.000Z",
|
|
20
|
-
settings: {},
|
|
21
|
-
};
|
|
22
|
-
export const BOARD_ARCHIVED = {
|
|
23
|
-
id: "cccccccc-1111-2222-3333-444444444444",
|
|
24
|
-
slug: "archived-board",
|
|
25
|
-
name: "Archived Board",
|
|
26
|
-
description: "An archived board",
|
|
27
|
-
is_archived: true,
|
|
28
|
-
created_at: "2026-02-15T00:00:00.000Z",
|
|
29
|
-
settings: {},
|
|
30
|
-
};
|
|
31
|
-
export const ALL_BOARDS = [BOARD_ALPHA, BOARD_BETA, BOARD_ARCHIVED];
|
|
32
|
-
export const ACTIVE_BOARDS = [BOARD_ALPHA, BOARD_BETA];
|
|
33
|
-
// Stories assigned to specific boards
|
|
34
|
-
export const ALPHA_STORIES = [
|
|
35
|
-
{
|
|
36
|
-
id: "s1111111-1111-1111-1111-111111111111",
|
|
37
|
-
title: "Alpha Story 1",
|
|
38
|
-
lane: "Current",
|
|
39
|
-
state: "started",
|
|
40
|
-
points: 3,
|
|
41
|
-
owners: ["user-1"],
|
|
42
|
-
labels: [],
|
|
43
|
-
epicId: null,
|
|
44
|
-
boardId: BOARD_ALPHA.id,
|
|
45
|
-
storyType: "feature",
|
|
46
|
-
createdAt: "2026-03-10T00:00:00.000Z",
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
id: "s2222222-2222-2222-2222-222222222222",
|
|
50
|
-
title: "Alpha Story 2",
|
|
51
|
-
lane: "Backlog",
|
|
52
|
-
state: "unstarted",
|
|
53
|
-
points: 2,
|
|
54
|
-
owners: [],
|
|
55
|
-
labels: [],
|
|
56
|
-
epicId: null,
|
|
57
|
-
boardId: BOARD_ALPHA.id,
|
|
58
|
-
storyType: "bug",
|
|
59
|
-
createdAt: "2026-03-11T00:00:00.000Z",
|
|
60
|
-
},
|
|
61
|
-
];
|
|
62
|
-
export const BETA_STORIES = [
|
|
63
|
-
{
|
|
64
|
-
id: "s3333333-3333-3333-3333-333333333333",
|
|
65
|
-
title: "Beta Story 1",
|
|
66
|
-
lane: "Current",
|
|
67
|
-
state: "unstarted",
|
|
68
|
-
points: 5,
|
|
69
|
-
owners: ["user-2"],
|
|
70
|
-
labels: [],
|
|
71
|
-
epicId: null,
|
|
72
|
-
boardId: BOARD_BETA.id,
|
|
73
|
-
storyType: "feature",
|
|
74
|
-
createdAt: "2026-03-12T00:00:00.000Z",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
id: "s4444444-4444-4444-4444-444444444444",
|
|
78
|
-
title: "Beta Story 2",
|
|
79
|
-
lane: "Current",
|
|
80
|
-
state: "started",
|
|
81
|
-
points: 1,
|
|
82
|
-
owners: [],
|
|
83
|
-
labels: [],
|
|
84
|
-
epicId: null,
|
|
85
|
-
boardId: BOARD_BETA.id,
|
|
86
|
-
storyType: "chore",
|
|
87
|
-
createdAt: "2026-03-13T00:00:00.000Z",
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
id: "s5555555-5555-5555-5555-555555555555",
|
|
91
|
-
title: "Beta Story 3",
|
|
92
|
-
lane: "Backlog",
|
|
93
|
-
state: "unstarted",
|
|
94
|
-
points: 8,
|
|
95
|
-
owners: [],
|
|
96
|
-
labels: [],
|
|
97
|
-
epicId: null,
|
|
98
|
-
boardId: BOARD_BETA.id,
|
|
99
|
-
storyType: "feature",
|
|
100
|
-
createdAt: "2026-03-14T00:00:00.000Z",
|
|
101
|
-
},
|
|
102
|
-
];
|
|
103
|
-
export const ALL_STORIES = [...ALPHA_STORIES, ...BETA_STORIES];
|
|
104
|
-
export const ALPHA_EPICS = [
|
|
105
|
-
{
|
|
106
|
-
id: "e1111111-1111-1111-1111-111111111111",
|
|
107
|
-
name: "Alpha Epic",
|
|
108
|
-
boardId: BOARD_ALPHA.id,
|
|
109
|
-
},
|
|
110
|
-
];
|
|
111
|
-
export const BETA_EPICS = [
|
|
112
|
-
{
|
|
113
|
-
id: "e2222222-2222-2222-2222-222222222222",
|
|
114
|
-
name: "Beta Epic",
|
|
115
|
-
boardId: BOARD_BETA.id,
|
|
116
|
-
},
|
|
117
|
-
];
|