@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.
- package/dist/__tests__/board-isolation.test.js +83 -0
- package/dist/__tests__/create-board.test.js +97 -0
- package/dist/__tests__/fixtures/boards.js +117 -0
- package/dist/__tests__/list-boards.test.js +54 -0
- package/dist/__tests__/select-board.test.js +74 -0
- package/dist/board-context.js +111 -0
- package/dist/index.js +236 -4
- package/package.json +5 -2
|
@@ -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
|
+
"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
|
}
|