@elixium.ai/mcp-server 0.3.3 → 0.4.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.
@@ -0,0 +1,83 @@
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
+ });
@@ -0,0 +1,97 @@
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
+ });
@@ -0,0 +1,117 @@
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
+ ];
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { listBoards, resetBoardContext, selectBoard } from "../board-context.js";
3
+ import { ALL_BOARDS, ACTIVE_BOARDS, } 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
+ describe("list_boards", () => {
13
+ it("1.1 returns all boards with required fields", async () => {
14
+ mockClient.get.mockResolvedValue({ data: ACTIVE_BOARDS });
15
+ const result = await listBoards(mockClient);
16
+ expect(result.boards).toHaveLength(2);
17
+ for (const board of result.boards) {
18
+ expect(board).toHaveProperty("id");
19
+ expect(board).toHaveProperty("name");
20
+ expect(board).toHaveProperty("slug");
21
+ expect(board).toHaveProperty("createdAt");
22
+ }
23
+ });
24
+ it("1.2 returns empty array when no boards exist", async () => {
25
+ mockClient.get.mockResolvedValue({ data: [] });
26
+ const result = await listBoards(mockClient);
27
+ expect(result.boards).toEqual([]);
28
+ });
29
+ it("1.3 excludes archived boards by default", async () => {
30
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
31
+ const result = await listBoards(mockClient);
32
+ expect(result.boards).toHaveLength(2);
33
+ expect(result.boards.every((b) => !b.isArchived)).toBe(true);
34
+ });
35
+ it("1.4 includes archived boards when requested", async () => {
36
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
37
+ const result = await listBoards(mockClient, { includeArchived: true });
38
+ expect(result.boards).toHaveLength(3);
39
+ const archived = result.boards.find((b) => b.slug === "archived-board");
40
+ expect(archived?.isArchived).toBe(true);
41
+ });
42
+ it("1.5 indicates currently selected board", async () => {
43
+ // First select alpha
44
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
45
+ await selectBoard(mockClient, "alpha");
46
+ // Then list
47
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
48
+ const result = await listBoards(mockClient);
49
+ const alpha = result.boards.find((b) => b.slug === "alpha");
50
+ const beta = result.boards.find((b) => b.slug === "beta");
51
+ expect(alpha?.selected).toBe(true);
52
+ expect(beta?.selected).toBeUndefined();
53
+ });
54
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { selectBoard, resetBoardContext, getActiveBoardSlug, getRuntimeBoardId, } from "../board-context.js";
3
+ import { ALL_BOARDS, BOARD_ALPHA, BOARD_BETA } 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
+ describe("select_board", () => {
13
+ it("3.1 selects board by slug and confirms selection", async () => {
14
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
15
+ const result = await selectBoard(mockClient, "alpha");
16
+ expect(result.id).toBe(BOARD_ALPHA.id);
17
+ expect(result.name).toBe("Alpha Board");
18
+ expect(result.slug).toBe("alpha");
19
+ expect(getActiveBoardSlug()).toBe("alpha");
20
+ expect(getRuntimeBoardId()).toBe(BOARD_ALPHA.id);
21
+ });
22
+ it("3.2 case-insensitive slug matching", async () => {
23
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
24
+ const result1 = await selectBoard(mockClient, "Alpha");
25
+ expect(result1.id).toBe(BOARD_ALPHA.id);
26
+ resetBoardContext();
27
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
28
+ const result2 = await selectBoard(mockClient, "ALPHA");
29
+ expect(result2.id).toBe(BOARD_ALPHA.id);
30
+ });
31
+ it("3.3 returns error for invalid slug", async () => {
32
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
33
+ await expect(selectBoard(mockClient, "nonexistent")).rejects.toThrow('Board "nonexistent" not found');
34
+ // Board context should remain unset
35
+ expect(getActiveBoardSlug()).toBeNull();
36
+ expect(getRuntimeBoardId()).toBeNull();
37
+ });
38
+ it("3.3b preserves previous selection on error", async () => {
39
+ // Select alpha first
40
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
41
+ await selectBoard(mockClient, "alpha");
42
+ expect(getActiveBoardSlug()).toBe("alpha");
43
+ // Try to select nonexistent — should fail
44
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
45
+ await expect(selectBoard(mockClient, "nonexistent")).rejects.toThrow();
46
+ // Alpha should still be selected
47
+ expect(getActiveBoardSlug()).toBe("alpha");
48
+ expect(getRuntimeBoardId()).toBe(BOARD_ALPHA.id);
49
+ });
50
+ it("3.4 subsequent calls use selected board", async () => {
51
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
52
+ await selectBoard(mockClient, "alpha");
53
+ // getActiveBoardSlug should return "alpha" for downstream tools
54
+ expect(getActiveBoardSlug()).toBe("alpha");
55
+ expect(getRuntimeBoardId()).toBe(BOARD_ALPHA.id);
56
+ });
57
+ it("3.5 switching boards updates context", async () => {
58
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
59
+ await selectBoard(mockClient, "alpha");
60
+ expect(getActiveBoardSlug()).toBe("alpha");
61
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
62
+ await selectBoard(mockClient, "beta");
63
+ expect(getActiveBoardSlug()).toBe("beta");
64
+ expect(getRuntimeBoardId()).toBe(BOARD_BETA.id);
65
+ });
66
+ it("3.6 overrides ELIXIUM_BOARD_SLUG env var", async () => {
67
+ // Before runtime selection, env var is used
68
+ expect(getActiveBoardSlug("alpha")).toBe("alpha");
69
+ // After runtime selection, runtime wins
70
+ mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
71
+ await selectBoard(mockClient, "beta");
72
+ expect(getActiveBoardSlug("alpha")).toBe("beta");
73
+ });
74
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Board context management for multi-board MCP support.
3
+ *
4
+ * Provides runtime board selection that overrides the static
5
+ * ELIXIUM_BOARD_SLUG env var. All board-aware tools (list_stories,
6
+ * create_story, list_epics, etc.) use getActiveBoardSlug() to
7
+ * determine which board to operate on.
8
+ */
9
+ // Runtime-selected board slug (overrides env var when set)
10
+ let runtimeBoardSlug = null;
11
+ let runtimeBoardId = null;
12
+ /**
13
+ * Get the active board slug, preferring runtime selection over env var.
14
+ */
15
+ export const getActiveBoardSlug = (envSlug) => {
16
+ if (runtimeBoardSlug)
17
+ return runtimeBoardSlug;
18
+ if (!envSlug)
19
+ return null;
20
+ const normalized = envSlug.trim().toLowerCase();
21
+ return normalized.length > 0 ? normalized : null;
22
+ };
23
+ /**
24
+ * Get the runtime-selected board ID (if select_board was called).
25
+ */
26
+ export const getRuntimeBoardId = () => runtimeBoardId;
27
+ /**
28
+ * Select a board by slug. Validates that the board exists via API.
29
+ * Returns the matched board or throws if not found.
30
+ */
31
+ export const selectBoard = async (client, slug) => {
32
+ const normalized = slug.trim().toLowerCase();
33
+ if (!normalized) {
34
+ throw new Error("Board slug cannot be empty");
35
+ }
36
+ const response = await client.get("/boards");
37
+ const boards = Array.isArray(response.data) ? response.data : [];
38
+ const match = boards.find((b) => {
39
+ if (typeof b?.slug !== "string")
40
+ return false;
41
+ return b.slug.trim().toLowerCase() === normalized;
42
+ });
43
+ if (!match?.id) {
44
+ const available = boards
45
+ .filter((b) => !b.is_archived)
46
+ .map((b) => b.slug)
47
+ .join(", ");
48
+ throw new Error(`Board "${slug}" not found. Available boards: ${available || "none"}`);
49
+ }
50
+ runtimeBoardSlug = normalized;
51
+ runtimeBoardId = match.id;
52
+ return normalizeBoardResponse(match);
53
+ };
54
+ /**
55
+ * List all boards for the current workspace.
56
+ */
57
+ export const listBoards = async (client, opts) => {
58
+ const response = await client.get("/boards");
59
+ let boards = Array.isArray(response.data) ? response.data : [];
60
+ if (!opts?.includeArchived) {
61
+ boards = boards.filter((b) => !b.is_archived);
62
+ }
63
+ const summaries = boards.map((b) => normalizeBoardResponse(b));
64
+ // Mark the currently selected board
65
+ const activeSlug = getActiveBoardSlug();
66
+ for (const board of summaries) {
67
+ if (activeSlug && board.slug === activeSlug) {
68
+ board.selected = true;
69
+ }
70
+ }
71
+ return { boards: summaries };
72
+ };
73
+ /**
74
+ * Create a new board via API.
75
+ */
76
+ export const createBoard = async (client, args) => {
77
+ const name = args.name?.trim();
78
+ if (!name) {
79
+ throw new Error("Board name cannot be empty");
80
+ }
81
+ const slug = args.slug?.trim().toLowerCase() ||
82
+ name
83
+ .toLowerCase()
84
+ .replace(/[^a-z0-9]+/g, "-")
85
+ .replace(/^-|-$/g, "");
86
+ const response = await client.post("/boards", {
87
+ name,
88
+ slug,
89
+ description: args.description || "",
90
+ });
91
+ return normalizeBoardResponse(response.data);
92
+ };
93
+ /**
94
+ * Reset runtime board selection (for testing).
95
+ */
96
+ export const resetBoardContext = () => {
97
+ runtimeBoardSlug = null;
98
+ runtimeBoardId = null;
99
+ };
100
+ /**
101
+ * Normalize API board response to BoardSummary.
102
+ */
103
+ const normalizeBoardResponse = (raw) => ({
104
+ id: raw.id,
105
+ slug: raw.slug,
106
+ name: raw.name,
107
+ description: raw.description || "",
108
+ isArchived: raw.is_archived ?? raw.isArchived ?? false,
109
+ createdAt: raw.created_at ?? raw.createdAt ?? "",
110
+ ...(raw.storyCount != null ? { storyCount: raw.storyCount } : {}),
111
+ });