@elixium.ai/mcp-server 0.3.6 → 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
+ });
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ const SSE_PATH = ensurePath(getArgValue("--sse-path") ?? process.env.ELIXIUM_MCP
44
44
  const MESSAGE_PATH = ensurePath(getArgValue("--message-path") ??
45
45
  process.env.ELIXIUM_MCP_MESSAGE_PATH ??
46
46
  "/message", "/message");
47
+ import { listBoards, createBoard, selectBoard, getActiveBoardSlug, getRuntimeBoardId, } from "./board-context.js";
47
48
  import * as fs from "fs";
48
49
  import * as path from "path";
49
50
  import { fileURLToPath } from "url";
@@ -98,6 +99,119 @@ const client = axios.create({
98
99
  ...(BOARD_SLUG ? { "x-board-slug": BOARD_SLUG } : {}),
99
100
  },
100
101
  });
102
+ // ── Contract Scanning for prepare_implementation ──
103
+ const CONTRACT_STOP_WORDS = new Set([
104
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
105
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
106
+ "should", "may", "might", "shall", "can", "for", "and", "nor", "but",
107
+ "or", "yet", "so", "in", "on", "at", "to", "of", "by", "with", "from",
108
+ "up", "about", "into", "through", "during", "before", "after", "above",
109
+ "below", "between", "out", "off", "over", "under", "again", "further",
110
+ "then", "once", "here", "there", "when", "where", "why", "how", "all",
111
+ "each", "every", "both", "few", "more", "most", "other", "some", "such",
112
+ "no", "not", "only", "own", "same", "than", "too", "very", "just",
113
+ "because", "as", "until", "while", "that", "this", "these", "those",
114
+ "it", "its", "we", "they", "add", "update", "fix", "implement", "create",
115
+ "new", "remove", "change", "make", "use", "set", "get", "check", "ensure",
116
+ "story", "feature", "bug", "chore", "task",
117
+ ]);
118
+ function extractKeywords(title, description = "") {
119
+ const text = `${title} ${description}`.toLowerCase();
120
+ const words = text
121
+ .replace(/[^a-z0-9\s-]/g, " ")
122
+ .split(/\s+/)
123
+ .filter((w) => w.length > 2 && !CONTRACT_STOP_WORDS.has(w));
124
+ return [...new Set(words)];
125
+ }
126
+ function categorizeMatch(content) {
127
+ if (/\b(create|generate|produce|emit|publish|write|return|send|build|insert)\b/i.test(content))
128
+ return "producer";
129
+ if (/\b(consume|read|validate|verify|import|parse|receive|fetch|load|decode|check)\b/i.test(content))
130
+ return "consumer";
131
+ return "reference";
132
+ }
133
+ function scanCodebaseForContracts(keywords, cwd) {
134
+ if (keywords.length === 0)
135
+ return [];
136
+ const matches = [];
137
+ const searchDirs = ["app", "backend/api/src", "mcp-server/src", "components"].map(d => path.join(cwd, d));
138
+ const extensions = new Set([".ts", ".tsx", ".js", ".jsx"]);
139
+ const maxMatches = 50;
140
+ function searchDir(dir) {
141
+ if (matches.length >= maxMatches)
142
+ return;
143
+ try {
144
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
145
+ for (const entry of entries) {
146
+ if (matches.length >= maxMatches)
147
+ return;
148
+ const fullPath = path.join(dir, entry.name);
149
+ if (entry.isDirectory()) {
150
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist")
151
+ continue;
152
+ searchDir(fullPath);
153
+ }
154
+ else if (extensions.has(path.extname(entry.name))) {
155
+ try {
156
+ const content = fs.readFileSync(fullPath, "utf-8");
157
+ const lines = content.split("\n");
158
+ for (let i = 0; i < lines.length && matches.length < maxMatches; i++) {
159
+ const line = lines[i];
160
+ const lineLower = line.toLowerCase();
161
+ if (keywords.some((kw) => lineLower.includes(kw))) {
162
+ const trimmed = line.trim();
163
+ if (trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("*")) {
164
+ matches.push({
165
+ file: path.relative(cwd, fullPath),
166
+ line: i + 1,
167
+ content: trimmed.substring(0, 200),
168
+ role: categorizeMatch(trimmed),
169
+ });
170
+ }
171
+ }
172
+ }
173
+ }
174
+ catch { /* skip unreadable files */ }
175
+ }
176
+ }
177
+ }
178
+ catch { /* skip inaccessible dirs */ }
179
+ }
180
+ for (const dir of searchDirs) {
181
+ if (fs.existsSync(dir))
182
+ searchDir(dir);
183
+ }
184
+ return matches;
185
+ }
186
+ function formatContractsSection(matches) {
187
+ if (matches.length === 0) {
188
+ return "\n## Integration Contracts\nNo downstream consumers detected.\n";
189
+ }
190
+ const producers = matches.filter((m) => m.role === "producer");
191
+ const consumers = matches.filter((m) => m.role === "consumer");
192
+ const references = matches.filter((m) => m.role === "reference");
193
+ let section = "\n## Integration Contracts\n\nThis story touches systems with upstream/downstream dependencies:\n";
194
+ if (producers.length > 0) {
195
+ section += "\n**Produces (writes/creates/returns):**\n";
196
+ for (const p of producers.slice(0, 10)) {
197
+ section += `- \`${p.file}:${p.line}\` — ${p.content}\n`;
198
+ }
199
+ }
200
+ if (consumers.length > 0) {
201
+ section += "\n**Consumed by (reads/validates/imports):**\n";
202
+ for (const c of consumers.slice(0, 10)) {
203
+ section += `- \`${c.file}:${c.line}\` — ${c.content}\n`;
204
+ }
205
+ }
206
+ if (references.length > 0 && producers.length + consumers.length < 5) {
207
+ section += "\n**Other references:**\n";
208
+ for (const r of references.slice(0, 5)) {
209
+ section += `- \`${r.file}:${r.line}\` — ${r.content}\n`;
210
+ }
211
+ }
212
+ section += "\n> **Note:** This section is informational. Review these contracts before writing your test plan.\n";
213
+ return section;
214
+ }
101
215
  const LANE_TITLE = {
102
216
  backlog: "Backlog",
103
217
  icebox: "Icebox",
@@ -153,6 +267,10 @@ const normalizeBoardSlug = (value) => {
153
267
  let cachedBoardId = null;
154
268
  let cachedBoardSlug = null;
155
269
  const resolveBoardId = async () => {
270
+ // Prefer runtime selection from select_board tool
271
+ const runtimeId = getRuntimeBoardId();
272
+ if (runtimeId)
273
+ return runtimeId;
156
274
  const slug = normalizeBoardSlug(BOARD_SLUG);
157
275
  if (!slug)
158
276
  return null;
@@ -336,7 +454,7 @@ const formatDorWarnings = (boardSettings, dorChecklist) => {
336
454
  };
337
455
  const fetchStories = async () => {
338
456
  const boardId = await resolveBoardId();
339
- const slug = normalizeBoardSlug(BOARD_SLUG);
457
+ const slug = getActiveBoardSlug(BOARD_SLUG) ?? normalizeBoardSlug(BOARD_SLUG);
340
458
  const isMainBoard = slug === "main";
341
459
  // For the "main" board, fetch ALL stories (no boardId filter) then filter
342
460
  // client-side. This matches the frontend behavior: legacy stories have
@@ -354,7 +472,7 @@ const fetchStories = async () => {
354
472
  };
355
473
  const fetchEpics = async () => {
356
474
  const boardId = await resolveBoardId();
357
- const slug = normalizeBoardSlug(BOARD_SLUG);
475
+ const slug = getActiveBoardSlug(BOARD_SLUG) ?? normalizeBoardSlug(BOARD_SLUG);
358
476
  const isMainBoard = slug === "main";
359
477
  const response = await client.get("/epics", {
360
478
  params: boardId && !isMainBoard ? { boardId } : undefined,
@@ -434,6 +552,55 @@ const createServer = () => {
434
552
  const teamDecisionsEnabled = featureConfig.features.teamDecisions;
435
553
  const ragKnowledgeBaseEnabled = featureConfig.features.ragKnowledgeBase;
436
554
  const baseTools = [
555
+ {
556
+ name: "list_boards",
557
+ description: "List all boards in the workspace. Returns id, name, slug, and story count for each board. Marks the currently selected board.",
558
+ inputSchema: {
559
+ type: "object",
560
+ properties: {
561
+ includeArchived: {
562
+ type: "boolean",
563
+ description: "Include archived boards (default false)",
564
+ },
565
+ },
566
+ },
567
+ },
568
+ {
569
+ name: "create_board",
570
+ description: "Create a new board in the workspace. Auto-generates slug from name if not provided. Max 3 active boards per tenant.",
571
+ inputSchema: {
572
+ type: "object",
573
+ properties: {
574
+ name: {
575
+ type: "string",
576
+ description: "Name for the new board",
577
+ },
578
+ description: {
579
+ type: "string",
580
+ description: "Optional description",
581
+ },
582
+ slug: {
583
+ type: "string",
584
+ description: "Optional custom slug (auto-generated from name if omitted)",
585
+ },
586
+ },
587
+ required: ["name"],
588
+ },
589
+ },
590
+ {
591
+ name: "select_board",
592
+ description: "Switch the active board context. All subsequent tool calls (list_stories, create_story, list_epics, etc.) will operate against the selected board. Overrides ELIXIUM_BOARD_SLUG env var for this session.",
593
+ inputSchema: {
594
+ type: "object",
595
+ properties: {
596
+ slug: {
597
+ type: "string",
598
+ description: "Slug of the board to select (case-insensitive)",
599
+ },
600
+ },
601
+ required: ["slug"],
602
+ },
603
+ },
437
604
  {
438
605
  name: "get_feature_config",
439
606
  description: "Get the feature configuration for this board/workspace. Returns enabled features, team profile, and smart defaults context.",
@@ -757,6 +924,14 @@ const createServer = () => {
757
924
  type: "boolean",
758
925
  description: "If true with trunkBased, creates a short-lived branch that auto-merges to main on submit_for_review. Branch is deleted after merge.",
759
926
  },
927
+ skipGates: {
928
+ type: "boolean",
929
+ description: "If true, bypasses the test plan approval gate. For hotfixes/emergencies only. Bypass is recorded in the story audit trail.",
930
+ },
931
+ skipReason: {
932
+ type: "string",
933
+ description: "Reason for bypassing gates (e.g., 'hotfix for production outage'). Recorded in the audit trail.",
934
+ },
760
935
  },
761
936
  required: ["storyId"],
762
937
  },
@@ -1046,6 +1221,50 @@ const createServer = () => {
1046
1221
  }
1047
1222
  }
1048
1223
  switch (toolName) {
1224
+ case "list_boards": {
1225
+ const args = request.params.arguments;
1226
+ const result = await listBoards(client, {
1227
+ includeArchived: args?.includeArchived,
1228
+ });
1229
+ return {
1230
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1231
+ };
1232
+ }
1233
+ case "create_board": {
1234
+ const args = request.params.arguments;
1235
+ const board = await createBoard(client, {
1236
+ name: args.name,
1237
+ description: args.description,
1238
+ slug: args.slug,
1239
+ });
1240
+ // Invalidate feature config cache since board list changed
1241
+ cachedFeatureConfig = null;
1242
+ return {
1243
+ content: [
1244
+ {
1245
+ type: "text",
1246
+ text: `# Board Created\n\n- **Name:** ${board.name}\n- **Slug:** ${board.slug}\n- **ID:** ${board.id}\n\n> Use \`select_board\` with slug "${board.slug}" to switch to this board.`,
1247
+ },
1248
+ ],
1249
+ };
1250
+ }
1251
+ case "select_board": {
1252
+ const args = request.params.arguments;
1253
+ const board = await selectBoard(client, args.slug);
1254
+ // Invalidate caches so subsequent calls use new board context
1255
+ cachedFeatureConfig = null;
1256
+ cachedBoardId = null;
1257
+ cachedBoardSlug = null;
1258
+ cachedLaneStyle = null;
1259
+ return {
1260
+ content: [
1261
+ {
1262
+ type: "text",
1263
+ text: `# Board Selected\n\n- **Name:** ${board.name}\n- **Slug:** ${board.slug}\n- **ID:** ${board.id}\n\nAll subsequent tool calls will operate against this board.`,
1264
+ },
1265
+ ],
1266
+ };
1267
+ }
1049
1268
  case "get_feature_config": {
1050
1269
  const config = await fetchFeatureConfig();
1051
1270
  const formattedConfig = `
@@ -1402,6 +1621,17 @@ ${config.infrastructureProfile?.provider ? `- Provider: ${config.infrastructureP
1402
1621
  }).join("\n");
1403
1622
  knowledgeSection = `\n## Related Knowledge\n${formatted}\n`;
1404
1623
  }
1624
+ // Scan codebase for integration contracts (non-blocking)
1625
+ let contractsSection = "";
1626
+ try {
1627
+ const keywords = extractKeywords(story.title, story.description || "");
1628
+ const cwd = process.cwd();
1629
+ const contractMatches = scanCodebaseForContracts(keywords, cwd);
1630
+ contractsSection = formatContractsSection(contractMatches);
1631
+ }
1632
+ catch {
1633
+ contractsSection = "\n## Integration Contracts\nContract scanning unavailable.\n";
1634
+ }
1405
1635
  const formattedBrief = `
1406
1636
  # Implementation Brief: ${story.title}
1407
1637
 
@@ -1414,7 +1644,7 @@ ${acceptanceCriteria}
1414
1644
  ## Assumptions
1415
1645
  Here are the assumptions I think we’re testing:
1416
1646
  ${assumptions}
1417
- ${decisionsSection}${knowledgeSection}
1647
+ ${decisionsSection}${knowledgeSection}${contractsSection}
1418
1648
  ${formatTeamContext(teamConfig)}
1419
1649
  ${formatDorDodSection(boardSettings, story.dorChecklist, story.dodChecklist)}
1420
1650
  ## Proposal
@@ -1428,7 +1658,7 @@ Here’s the smallest change that will validate it:
1428
1658
  // TDD Workflow Handlers
1429
1659
  case "start_story": {
1430
1660
  const args = request.params.arguments;
1431
- const { storyId, branchPrefix, trunkBased, autoMerge } = args;
1661
+ const { storyId, branchPrefix, trunkBased, autoMerge, skipGates, skipReason } = args;
1432
1662
  if (!storyId) {
1433
1663
  throw new Error("storyId is required");
1434
1664
  }
@@ -1439,6 +1669,8 @@ Here’s the smallest change that will validate it:
1439
1669
  branchPrefix: branchPrefix || "feat",
1440
1670
  trunkBased,
1441
1671
  autoMerge,
1672
+ ...(skipGates && { skipGates: true }),
1673
+ ...(skipReason && { skipReason }),
1442
1674
  }),
1443
1675
  fetchFeatureConfig(),
1444
1676
  fetchBoardSettings(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elixium.ai/mcp-server",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "MCP Server for Elixium.ai",
6
6
  "mcpName": "io.github.IndirectTek/mcp-server",
@@ -19,6 +19,8 @@
19
19
  },
20
20
  "scripts": {
21
21
  "build": "tsc",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
22
24
  "start": "node dist/index.js",
23
25
  "dev": "ts-node src/index.ts"
24
26
  },
@@ -30,6 +32,7 @@
30
32
  "devDependencies": {
31
33
  "@types/node": "^20.0.0",
32
34
  "ts-node": "^10.9.0",
33
- "typescript": "^5.3.0"
35
+ "typescript": "^5.3.0",
36
+ "vitest": "^4.1.1"
34
37
  }
35
38
  }