@elixium.ai/mcp-server 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/constants/workflowModelLabels.js +59 -0
- package/dist/index.js +236 -105
- package/dist/stakeholders.js +72 -0
- package/dist/toolSchemas.js +235 -0
- package/package.json +3 -6
- package/dist/__tests__/board-isolation.test.js +0 -83
- package/dist/__tests__/create-board.test.js +0 -97
- package/dist/__tests__/fixtures/boards.js +0 -117
- package/dist/__tests__/list-boards.test.js +0 -54
- package/dist/__tests__/select-board.test.js +0 -74
package/README.md
CHANGED
|
@@ -125,6 +125,30 @@ If you're using multiple MCP servers, combine them in the same config:
|
|
|
125
125
|
> If you set `ELIXIUM_BOARD_SLUG`, the MCP server will only read/write stories for that board.
|
|
126
126
|
> The server resolves the board slug to a boardId on startup, so the slug must match an existing board.
|
|
127
127
|
|
|
128
|
+
## Required Scopes (for Scoped API Keys)
|
|
129
|
+
|
|
130
|
+
If you generate a **scoped** API key (via the Command Center → Integrations → *Advanced: Key Scopes*), the key must include the scopes below to use the corresponding MCP tools. An **unscoped** key (all scopes picker collapsed when generating) has full access and works with every tool.
|
|
131
|
+
|
|
132
|
+
| Tool category | Tools | Required scopes |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| **Read stories** | `list_stories`, `get_story`, `get_iteration_context`, `prepare_implementation`, `estimate_cost` | `stories:read` |
|
|
135
|
+
| **Write stories** | `create_story`, `update_story`, `start_story`, `propose_test_plan`, `submit_for_review`, `record_learning`, `create_hypothesis` | `stories:read` + `stories:write` |
|
|
136
|
+
| **Read epics** | `list_epics`, `get_epic_cost_rollup` | `epics:read` |
|
|
137
|
+
| **Write epics** | `create_epic`, `update_epic`, `prioritize_epic` | `epics:read` + `epics:write` |
|
|
138
|
+
| **Read boards** | `list_boards`, `select_board` | `boards:read` |
|
|
139
|
+
| **Write boards** | `create_board` | `boards:read` + `boards:write` |
|
|
140
|
+
| **Read workspace config** | `get_feature_config`, `get_infrastructure_profile` | `workspace:read` |
|
|
141
|
+
| **Team decisions** | `list_decisions`, `search_decisions` | `stories:read` |
|
|
142
|
+
| **Team decisions (write)** | `record_decision` | `stories:read` + `stories:write` |
|
|
143
|
+
| **Objectives** | `list_objectives` | `stories:read` |
|
|
144
|
+
|
|
145
|
+
**Recommended baseline for full MCP functionality:**
|
|
146
|
+
```
|
|
147
|
+
stories:read, stories:write, epics:read, epics:write, boards:read, workspace:read
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
If a scoped key is missing a scope, the MCP server surfaces an actionable error message naming the missing scope and directing you to regenerate the key with the required scope. You do NOT need to restart the MCP server after regenerating — just update `ELIXIUM_API_KEY` in your config and reload the server.
|
|
151
|
+
|
|
128
152
|
## Usage
|
|
129
153
|
Once configured, your AI agent will have access to tools like:
|
|
130
154
|
- `list_stories` - View all stories on the board
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical workflow model labels for MCP tool responses.
|
|
3
|
+
*
|
|
4
|
+
* Story 69c3a615 — surfaces the active workflow model in `start_story` and
|
|
5
|
+
* `submit_for_review` responses so agents (and through them, users) know
|
|
6
|
+
* what state transitions to expect. Story 79f641d5 (Team Profile UI) will
|
|
7
|
+
* import the same constants to keep terminology aligned across layers.
|
|
8
|
+
*
|
|
9
|
+
* AC: "model name matches the Team Profile UI's label exactly (no
|
|
10
|
+
* terminology drift between layers)" — this module is the single source.
|
|
11
|
+
*/
|
|
12
|
+
export const WORKFLOW_MODEL_NAMES = {
|
|
13
|
+
/** Pivotal-style 4-state model with explicit `delivered` step. */
|
|
14
|
+
pivotal4State: "4-state (Pivotal)",
|
|
15
|
+
/** Trunk-based 3-state model — no delivered step, finished → accepted. */
|
|
16
|
+
trunk3State: "3-state (simple)",
|
|
17
|
+
};
|
|
18
|
+
/** Per-state parenthetical explanations under each model. */
|
|
19
|
+
export const WORKFLOW_MODEL_STATE_EXPLANATION = {
|
|
20
|
+
pivotal4State: {
|
|
21
|
+
delivered: "work is on main, awaiting acceptance",
|
|
22
|
+
finished: "PR pending merge, advances to delivered when merged",
|
|
23
|
+
},
|
|
24
|
+
trunk3State: {
|
|
25
|
+
finished: "story moves directly to accepted on human review",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
/** At-start (no state yet) explanations — used by `start_story`. */
|
|
29
|
+
export const WORKFLOW_MODEL_START_EXPLANATION = {
|
|
30
|
+
pivotal4State: "submit will set delivered (auto-merge) or finished (PR pending)",
|
|
31
|
+
trunk3State: "submit advances to finished, accept moves to accepted",
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Formats the canonical "Workflow Model:" line for MCP tool responses.
|
|
35
|
+
*
|
|
36
|
+
* Examples:
|
|
37
|
+
* { useDeliveredState: true, state: "delivered" }
|
|
38
|
+
* → "4-state (Pivotal — work is on main, awaiting acceptance)"
|
|
39
|
+
* { useDeliveredState: false, state: "finished" }
|
|
40
|
+
* → "3-state (simple — story moves directly to accepted on human review)"
|
|
41
|
+
* { useDeliveredState: true } (no state — start_story case)
|
|
42
|
+
* → "4-state (Pivotal — submit will set delivered (auto-merge) or finished (PR pending))"
|
|
43
|
+
*
|
|
44
|
+
* Single-line output guarantee: the returned string never contains a newline
|
|
45
|
+
* — callers can safely interpolate it as one row in a markdown response.
|
|
46
|
+
*/
|
|
47
|
+
export function formatWorkflowModelLine(args) {
|
|
48
|
+
const modelKey = args.useDeliveredState ? "pivotal4State" : "trunk3State";
|
|
49
|
+
const baseName = args.useDeliveredState ? "4-state" : "3-state";
|
|
50
|
+
const typeLabel = args.useDeliveredState ? "Pivotal" : "simple";
|
|
51
|
+
let explanation = WORKFLOW_MODEL_START_EXPLANATION[modelKey];
|
|
52
|
+
if (args.state) {
|
|
53
|
+
const stateExplanations = WORKFLOW_MODEL_STATE_EXPLANATION[modelKey];
|
|
54
|
+
const found = stateExplanations[args.state];
|
|
55
|
+
if (found)
|
|
56
|
+
explanation = found;
|
|
57
|
+
}
|
|
58
|
+
return `${baseName} (${typeLabel} — ${explanation})`;
|
|
59
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,9 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
6
6
|
import axios from "axios";
|
|
7
7
|
import * as http from "node:http";
|
|
8
|
+
import { CREATE_STORY_INPUT_SCHEMA, UPDATE_STORY_INPUT_SCHEMA, GET_STAKEHOLDERS_INPUT_SCHEMA, PROPOSE_FIELD_DRAFT_INPUT_SCHEMA, ENDORSE_PROPOSAL_INPUT_SCHEMA, REJECT_PROPOSAL_INPUT_SCHEMA, DRAFT_HYPOTHESIS_INPUT_SCHEMA, GET_TRUST_LEAKAGE_INPUT_SCHEMA, } from "./toolSchemas.js";
|
|
9
|
+
import { deriveStakeholders } from "./stakeholders.js";
|
|
10
|
+
import { formatWorkflowModelLine } from "./constants/workflowModelLabels.js";
|
|
8
11
|
const API_KEY = process.env.ELIXIUM_API_KEY;
|
|
9
12
|
const API_URL = process.env.ELIXIUM_API_URL || "https://elixium.ai/api";
|
|
10
13
|
const BOARD_SLUG = process.env.ELIXIUM_BOARD_SLUG;
|
|
@@ -320,6 +323,7 @@ const fetchFeatureConfig = async () => {
|
|
|
320
323
|
aiTools: true,
|
|
321
324
|
teamDecisions: false,
|
|
322
325
|
ragKnowledgeBase: false,
|
|
326
|
+
useDeliveredState: true,
|
|
323
327
|
},
|
|
324
328
|
source: {
|
|
325
329
|
balancedTeam: "error-fallback",
|
|
@@ -328,6 +332,7 @@ const fetchFeatureConfig = async () => {
|
|
|
328
332
|
aiTools: "error-fallback",
|
|
329
333
|
teamDecisions: "error-fallback",
|
|
330
334
|
ragKnowledgeBase: "error-fallback",
|
|
335
|
+
useDeliveredState: "error-fallback",
|
|
331
336
|
},
|
|
332
337
|
};
|
|
333
338
|
}
|
|
@@ -643,52 +648,8 @@ const createServer = () => {
|
|
|
643
648
|
},
|
|
644
649
|
{
|
|
645
650
|
name: "create_story",
|
|
646
|
-
description: "Create a new story on the Elixium board",
|
|
647
|
-
inputSchema:
|
|
648
|
-
type: "object",
|
|
649
|
-
properties: {
|
|
650
|
-
title: { type: "string", description: "Title of the story" },
|
|
651
|
-
description: {
|
|
652
|
-
type: "string",
|
|
653
|
-
description: "Description of the story",
|
|
654
|
-
},
|
|
655
|
-
acceptanceCriteria: {
|
|
656
|
-
type: "string",
|
|
657
|
-
description: "Acceptance criteria in Given/When/Then format",
|
|
658
|
-
},
|
|
659
|
-
lane: {
|
|
660
|
-
type: "string",
|
|
661
|
-
description: "Lane to add the story to (case-insensitive)",
|
|
662
|
-
enum: [
|
|
663
|
-
"Backlog",
|
|
664
|
-
"Icebox",
|
|
665
|
-
"Current",
|
|
666
|
-
"Done",
|
|
667
|
-
"BACKLOG",
|
|
668
|
-
"ICEBOX",
|
|
669
|
-
"CURRENT",
|
|
670
|
-
"DONE",
|
|
671
|
-
],
|
|
672
|
-
},
|
|
673
|
-
points: {
|
|
674
|
-
type: "number",
|
|
675
|
-
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
676
|
-
},
|
|
677
|
-
requester: {
|
|
678
|
-
type: "string",
|
|
679
|
-
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
680
|
-
},
|
|
681
|
-
epicId: {
|
|
682
|
-
type: "string",
|
|
683
|
-
description: "Epic ID to link this story to (optional). Use list_epics to find epic IDs.",
|
|
684
|
-
},
|
|
685
|
-
testPlan: {
|
|
686
|
-
type: "string",
|
|
687
|
-
description: "Markdown test plan describing test strategy (optional). Sets the test_plan field without changing workflow_stage.",
|
|
688
|
-
},
|
|
689
|
-
},
|
|
690
|
-
required: ["title"],
|
|
691
|
-
},
|
|
651
|
+
description: "Create a new story on the Elixium board. Accepts storyType (feature/bug/chore/platform) at create time.",
|
|
652
|
+
inputSchema: CREATE_STORY_INPUT_SCHEMA,
|
|
692
653
|
},
|
|
693
654
|
{
|
|
694
655
|
name: "get_iteration_context",
|
|
@@ -767,58 +728,38 @@ const createServer = () => {
|
|
|
767
728
|
},
|
|
768
729
|
{
|
|
769
730
|
name: "update_story",
|
|
770
|
-
description: "Update fields on an existing story",
|
|
771
|
-
inputSchema:
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
},
|
|
803
|
-
acceptanceCriteria: {
|
|
804
|
-
type: "string",
|
|
805
|
-
description: "Acceptance criteria in Given/When/Then format",
|
|
806
|
-
},
|
|
807
|
-
sortOrder: {
|
|
808
|
-
type: "number",
|
|
809
|
-
description: "Sort order within the lane (lower = higher priority)",
|
|
810
|
-
},
|
|
811
|
-
epicId: {
|
|
812
|
-
type: "string",
|
|
813
|
-
description: "Epic ID to link this story to. Set to empty string to unlink.",
|
|
814
|
-
},
|
|
815
|
-
testPlan: {
|
|
816
|
-
type: "string",
|
|
817
|
-
description: "Markdown test plan. Updates test_plan field without changing workflow_stage.",
|
|
818
|
-
},
|
|
819
|
-
},
|
|
820
|
-
required: ["storyId"],
|
|
821
|
-
},
|
|
731
|
+
description: "Update fields on an existing story. Accepts Learning Loop fields (hypothesis, confidence_score, hidden_unknowns, risk_profile) and storyType in addition to the basics.",
|
|
732
|
+
inputSchema: UPDATE_STORY_INPUT_SCHEMA,
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: "get_stakeholders",
|
|
736
|
+
description: "Derive the set of trust audiences this story needs to earn trust with. Returns an array of {audience, reason} entries based on the story's risk_profile, its linked epic's hypothesis/successMetrics, and the workspace's compliance frameworks. Pure derivation — no mutations, no AI calls.",
|
|
737
|
+
inputSchema: GET_STAKEHOLDERS_INPUT_SCHEMA,
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
name: "propose_field_draft",
|
|
741
|
+
description: "Submit an AI-drafted value for a Learning Loop field as a pending proposal. The draft does NOT change the story until a human endorses via endorse_proposal. Caller should disclose `provider` (LLM model) and `agent` (client+version) for trust attribution.",
|
|
742
|
+
inputSchema: PROPOSE_FIELD_DRAFT_INPUT_SCHEMA,
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
name: "endorse_proposal",
|
|
746
|
+
description: "Endorse a pending field proposal. Updates the underlying story field with the proposal's draft_value and emits a proposal.endorsed audit event. Only humans should endorse — the agent should never call this on its own draft.",
|
|
747
|
+
inputSchema: ENDORSE_PROPOSAL_INPUT_SCHEMA,
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
name: "reject_proposal",
|
|
751
|
+
description: "Reject a pending field proposal with a required reason. Story field is unchanged; reason is captured for agent learning + audit.",
|
|
752
|
+
inputSchema: REJECT_PROPOSAL_INPUT_SCHEMA,
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
name: "draft_hypothesis",
|
|
756
|
+
description: "Reference consumer of the proposal primitive: backend calls AI_PROVIDER to generate a hypothesis draft for the given story, then submits it as a pending proposal. Pattern for §4 outcome-from-evidence and §6 v2 missing-field drafter to follow.",
|
|
757
|
+
inputSchema: DRAFT_HYPOTHESIS_INPUT_SCHEMA,
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "get_trust_leakage",
|
|
761
|
+
description: "Return the tenant's off-system decision report: commits authored since `since` on the workspace's configured Git provider (GitHub, GitHub Enterprise, GitLab, or Gitea) whose messages do NOT reference any story on the board. Nudge signal, not a gate. Requires workspace_git_provider to be configured via POST /workspace/git-provider first.",
|
|
762
|
+
inputSchema: GET_TRUST_LEAKAGE_INPUT_SCHEMA,
|
|
822
763
|
},
|
|
823
764
|
{
|
|
824
765
|
name: "prepare_implementation",
|
|
@@ -959,6 +900,20 @@ const createServer = () => {
|
|
|
959
900
|
required: ["storyId", "testPlan"],
|
|
960
901
|
},
|
|
961
902
|
},
|
|
903
|
+
{
|
|
904
|
+
name: "draft_test_plan",
|
|
905
|
+
description: "Return a starter test plan scaffold seeded from the story's hidden_unknowns and hypothesis. The agent edits this scaffold and calls propose_test_plan with the final version. Makes Icebox premortem assumptions load-bearing at test authoring time.",
|
|
906
|
+
inputSchema: {
|
|
907
|
+
type: "object",
|
|
908
|
+
properties: {
|
|
909
|
+
storyId: {
|
|
910
|
+
type: "string",
|
|
911
|
+
description: "ID of the story to draft a test plan for",
|
|
912
|
+
},
|
|
913
|
+
},
|
|
914
|
+
required: ["storyId"],
|
|
915
|
+
},
|
|
916
|
+
},
|
|
962
917
|
{
|
|
963
918
|
name: "approve_tests",
|
|
964
919
|
description: "Approve a proposed test plan so implementation can proceed. Transitions workflow from tests_proposed to tests_approved. This is the human approval gate in the TDD workflow.",
|
|
@@ -1149,7 +1104,7 @@ const createServer = () => {
|
|
|
1149
1104
|
try {
|
|
1150
1105
|
const toolName = request.params.name;
|
|
1151
1106
|
// Check TDD workflow tools
|
|
1152
|
-
const tddWorkflowTools = ["start_story", "propose_test_plan", "get_test_plan", "submit_for_review", "review_pr"];
|
|
1107
|
+
const tddWorkflowTools = ["start_story", "propose_test_plan", "draft_test_plan", "get_test_plan", "submit_for_review", "review_pr"];
|
|
1153
1108
|
if (tddWorkflowTools.includes(toolName)) {
|
|
1154
1109
|
const enabled = await isTddWorkflowEnabled();
|
|
1155
1110
|
if (!enabled) {
|
|
@@ -1540,6 +1495,134 @@ ${config.infrastructureProfile?.provider ? `- Provider: ${config.infrastructureP
|
|
|
1540
1495
|
],
|
|
1541
1496
|
};
|
|
1542
1497
|
}
|
|
1498
|
+
case "get_stakeholders": {
|
|
1499
|
+
const args = request.params.arguments;
|
|
1500
|
+
const { storyId } = args;
|
|
1501
|
+
assertUUID(storyId, "storyId");
|
|
1502
|
+
// Fetch story + feature config in parallel; the story tells us
|
|
1503
|
+
// whether to fetch a linked epic.
|
|
1504
|
+
const [storyResponse, featureConfig] = await Promise.all([
|
|
1505
|
+
client.get(`/stories/${storyId}`),
|
|
1506
|
+
fetchFeatureConfig(),
|
|
1507
|
+
]);
|
|
1508
|
+
const story = storyResponse.data;
|
|
1509
|
+
// If the story is linked to an epic, fetch the epic too.
|
|
1510
|
+
let epic = null;
|
|
1511
|
+
if (story.epic_id) {
|
|
1512
|
+
try {
|
|
1513
|
+
const epicResponse = await client.get(`/epics/${story.epic_id}`);
|
|
1514
|
+
epic = epicResponse.data;
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
// Epic fetch failure is non-fatal — derivation simply won't
|
|
1518
|
+
// add the `execs` audience in this case.
|
|
1519
|
+
epic = null;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
// Read from canonical field name `complianceFrameworks` (per
|
|
1523
|
+
// types.ts InfrastructureProfile); fall back to `compliance` for
|
|
1524
|
+
// any shipped callers that use the older name. Values are
|
|
1525
|
+
// normalized case-insensitively in deriveStakeholders.
|
|
1526
|
+
const infra = featureConfig.infrastructureProfile ?? {};
|
|
1527
|
+
const workspaceCompliance = Array.isArray(infra.complianceFrameworks)
|
|
1528
|
+
? infra.complianceFrameworks
|
|
1529
|
+
: Array.isArray(infra.compliance)
|
|
1530
|
+
? infra.compliance
|
|
1531
|
+
: [];
|
|
1532
|
+
const stakeholders = deriveStakeholders({
|
|
1533
|
+
story: {
|
|
1534
|
+
story_type: story.story_type ?? null,
|
|
1535
|
+
title: story.title ?? null,
|
|
1536
|
+
risk_profile: story.risk_profile ?? null,
|
|
1537
|
+
epic_id: story.epic_id ?? null,
|
|
1538
|
+
},
|
|
1539
|
+
epic: epic
|
|
1540
|
+
? {
|
|
1541
|
+
hypothesis: epic.hypothesis ?? null,
|
|
1542
|
+
successMetrics: epic.successMetrics ?? null,
|
|
1543
|
+
}
|
|
1544
|
+
: null,
|
|
1545
|
+
workspaceCompliance,
|
|
1546
|
+
});
|
|
1547
|
+
const payload = {
|
|
1548
|
+
storyId: story.id,
|
|
1549
|
+
stakeholders,
|
|
1550
|
+
derived_from: {
|
|
1551
|
+
story_type: story.story_type ?? null,
|
|
1552
|
+
risk_profile: story.risk_profile ?? null,
|
|
1553
|
+
epic_id: story.epic_id ?? null,
|
|
1554
|
+
compliance_frameworks: workspaceCompliance,
|
|
1555
|
+
},
|
|
1556
|
+
};
|
|
1557
|
+
return {
|
|
1558
|
+
content: [
|
|
1559
|
+
{ type: "text", text: JSON.stringify(payload, null, 2) },
|
|
1560
|
+
],
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
case "propose_field_draft": {
|
|
1564
|
+
const args = request.params.arguments;
|
|
1565
|
+
const { storyId, fieldName, draftValue, provider, agent } = args;
|
|
1566
|
+
assertUUID(storyId, "storyId");
|
|
1567
|
+
const response = await client.post(`/stories/${storyId}/proposals`, {
|
|
1568
|
+
field_name: fieldName,
|
|
1569
|
+
draft_value: draftValue,
|
|
1570
|
+
...(provider ? { provider } : {}),
|
|
1571
|
+
...(agent ? { agent } : {}),
|
|
1572
|
+
});
|
|
1573
|
+
return {
|
|
1574
|
+
content: [
|
|
1575
|
+
{ type: "text", text: JSON.stringify(response.data, null, 2) },
|
|
1576
|
+
],
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
case "endorse_proposal": {
|
|
1580
|
+
const args = request.params.arguments;
|
|
1581
|
+
const { proposalId } = args;
|
|
1582
|
+
assertUUID(proposalId, "proposalId");
|
|
1583
|
+
const response = await client.post(`/proposals/${proposalId}/endorse`, {});
|
|
1584
|
+
return {
|
|
1585
|
+
content: [
|
|
1586
|
+
{ type: "text", text: JSON.stringify(response.data, null, 2) },
|
|
1587
|
+
],
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
case "reject_proposal": {
|
|
1591
|
+
const args = request.params.arguments;
|
|
1592
|
+
const { proposalId, reason } = args;
|
|
1593
|
+
assertUUID(proposalId, "proposalId");
|
|
1594
|
+
const response = await client.post(`/proposals/${proposalId}/reject`, { reason });
|
|
1595
|
+
return {
|
|
1596
|
+
content: [
|
|
1597
|
+
{ type: "text", text: JSON.stringify(response.data, null, 2) },
|
|
1598
|
+
],
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
case "draft_hypothesis": {
|
|
1602
|
+
const args = request.params.arguments;
|
|
1603
|
+
const { storyId, context } = args;
|
|
1604
|
+
assertUUID(storyId, "storyId");
|
|
1605
|
+
const response = await client.post(`/stories/${storyId}/proposals/draft`, { field_name: "hypothesis", ...(context ? { context } : {}) });
|
|
1606
|
+
return {
|
|
1607
|
+
content: [
|
|
1608
|
+
{ type: "text", text: JSON.stringify(response.data, null, 2) },
|
|
1609
|
+
],
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
case "get_trust_leakage": {
|
|
1613
|
+
const args = (request.params.arguments ?? {});
|
|
1614
|
+
const query = {};
|
|
1615
|
+
if (args.since)
|
|
1616
|
+
query.since = String(args.since);
|
|
1617
|
+
if (args.repo)
|
|
1618
|
+
query.repo = String(args.repo);
|
|
1619
|
+
const response = await client.get(`/trust-leakage`, { params: query });
|
|
1620
|
+
return {
|
|
1621
|
+
content: [
|
|
1622
|
+
{ type: "text", text: JSON.stringify(response.data, null, 2) },
|
|
1623
|
+
],
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1543
1626
|
case "prepare_implementation": {
|
|
1544
1627
|
const args = request.params.arguments;
|
|
1545
1628
|
const { storyId } = args;
|
|
@@ -1680,6 +1763,9 @@ Here’s the smallest change that will validate it:
|
|
|
1680
1763
|
const dorWarnings = formatDorWarnings(boardSettings, result.dorChecklist);
|
|
1681
1764
|
const isTrunk = result.trunkBased;
|
|
1682
1765
|
const isAutoMerge = result.autoMerge;
|
|
1766
|
+
const workflowModelLine = formatWorkflowModelLine({
|
|
1767
|
+
useDeliveredState: teamConfig.features.useDeliveredState,
|
|
1768
|
+
});
|
|
1683
1769
|
let formattedResult;
|
|
1684
1770
|
if (isTrunk && !isAutoMerge) {
|
|
1685
1771
|
formattedResult = `
|
|
@@ -1688,6 +1774,7 @@ Here’s the smallest change that will validate it:
|
|
|
1688
1774
|
**Mode:** Direct to main (trunk-based development)
|
|
1689
1775
|
**Feature Flag:** \`${result.featureFlagName || "none"}\`
|
|
1690
1776
|
**Workflow Stage:** ${result.workflow_stage}
|
|
1777
|
+
**Workflow Model:** ${workflowModelLine}
|
|
1691
1778
|
|
|
1692
1779
|
## Acceptance Criteria
|
|
1693
1780
|
${result.acceptance_criteria || "No specific AC provided."}
|
|
@@ -1714,6 +1801,7 @@ ${dorWarnings}`;
|
|
|
1714
1801
|
**Branch:** \`${result.branch}\`
|
|
1715
1802
|
**Feature Flag:** \`${result.featureFlagName || "none"}\`
|
|
1716
1803
|
**Workflow Stage:** ${result.workflow_stage}
|
|
1804
|
+
**Workflow Model:** ${workflowModelLine}
|
|
1717
1805
|
|
|
1718
1806
|
## Acceptance Criteria
|
|
1719
1807
|
${result.acceptance_criteria || "No specific AC provided."}
|
|
@@ -1738,6 +1826,7 @@ ${dorWarnings}`;
|
|
|
1738
1826
|
|
|
1739
1827
|
**Branch:** \`${result.branch}\`
|
|
1740
1828
|
**Workflow Stage:** ${result.workflow_stage}
|
|
1829
|
+
**Workflow Model:** ${workflowModelLine}
|
|
1741
1830
|
|
|
1742
1831
|
## Acceptance Criteria
|
|
1743
1832
|
${result.acceptance_criteria || "No specific AC provided."}
|
|
@@ -1844,6 +1933,30 @@ ${(result.test_file_paths || []).map((p) => `- \`${p}\``).join("\n") || "No test
|
|
|
1844
1933
|
${result.message}
|
|
1845
1934
|
|
|
1846
1935
|
> 🛑 **BLOCKED:** Implementation cannot proceed until a human approves this test plan.
|
|
1936
|
+
`;
|
|
1937
|
+
return {
|
|
1938
|
+
content: [{ type: "text", text: formattedResult.trim() }],
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
case "draft_test_plan": {
|
|
1942
|
+
const args = request.params.arguments;
|
|
1943
|
+
const { storyId } = args;
|
|
1944
|
+
if (!storyId) {
|
|
1945
|
+
throw new Error("storyId is required");
|
|
1946
|
+
}
|
|
1947
|
+
assertUUID(storyId, "storyId");
|
|
1948
|
+
const response = await client.get(`/stories/${storyId}/draft-test-plan`);
|
|
1949
|
+
const result = response.data;
|
|
1950
|
+
const formattedResult = `
|
|
1951
|
+
# Drafted Test Plan
|
|
1952
|
+
|
|
1953
|
+
**Story ID:** ${storyId}
|
|
1954
|
+
|
|
1955
|
+
> This is a scaffold seeded from the story's \`hidden_unknowns\` and \`hypothesis\`. Edit freely, then call \`propose_test_plan\` with the final version.
|
|
1956
|
+
|
|
1957
|
+
---
|
|
1958
|
+
|
|
1959
|
+
${result.testPlan}
|
|
1847
1960
|
`;
|
|
1848
1961
|
return {
|
|
1849
1962
|
content: [{ type: "text", text: formattedResult.trim() }],
|
|
@@ -1880,13 +1993,20 @@ ${result.message}
|
|
|
1880
1993
|
throw new Error("storyId is required");
|
|
1881
1994
|
}
|
|
1882
1995
|
assertUUID(storyId, "storyId");
|
|
1883
|
-
const response = await
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1996
|
+
const [response, submitFeatureConfig] = await Promise.all([
|
|
1997
|
+
client.post(`/stories/${storyId}/submit-review`, {
|
|
1998
|
+
commitHash,
|
|
1999
|
+
testResults,
|
|
2000
|
+
implementationNotes,
|
|
2001
|
+
}),
|
|
2002
|
+
fetchFeatureConfig(),
|
|
2003
|
+
]);
|
|
1888
2004
|
const result = response.data;
|
|
1889
2005
|
const isAutoMerge = result.autoMerge;
|
|
2006
|
+
const submitWorkflowModelLine = formatWorkflowModelLine({
|
|
2007
|
+
useDeliveredState: submitFeatureConfig.features.useDeliveredState,
|
|
2008
|
+
state: result.state,
|
|
2009
|
+
});
|
|
1890
2010
|
let autoMergeSection = "";
|
|
1891
2011
|
if (isAutoMerge && result.autoMergeInstructions) {
|
|
1892
2012
|
const instr = result.autoMergeInstructions;
|
|
@@ -1920,6 +2040,7 @@ git branch -d ${instr.sourceBranch}
|
|
|
1920
2040
|
**Status:** ${result.status}
|
|
1921
2041
|
**Workflow Stage:** ${result.workflow_stage}
|
|
1922
2042
|
**State:** ${result.state}
|
|
2043
|
+
**Workflow Model:** ${submitWorkflowModelLine}
|
|
1923
2044
|
${result.trunkBased ? `**Mode:** Trunk-based${isAutoMerge ? " + Auto-merge" : ""}` : ""}
|
|
1924
2045
|
${result.featureFlagName ? `**Feature Flag:** \`${result.featureFlagName}\`` : ""}
|
|
1925
2046
|
|
|
@@ -2335,7 +2456,17 @@ ${depList || "No dependencies detected."}
|
|
|
2335
2456
|
if (error.response?.status) {
|
|
2336
2457
|
errorText += ` (HTTP ${error.response.status})`;
|
|
2337
2458
|
}
|
|
2338
|
-
|
|
2459
|
+
// Enhanced 403 handling for RBAC and API key scope errors
|
|
2460
|
+
if (error.response?.status === 403 && error.response?.data) {
|
|
2461
|
+
const data = error.response.data;
|
|
2462
|
+
if (data.required && data.currentScopes) {
|
|
2463
|
+
errorText = `🔑 API key missing scope: ${data.required}\n\nYour key has scopes: [${data.currentScopes.join(", ")}]\nRegenerate your API key with the required scope to use this tool.\n\nIn Elixium → Integrations → Generate API Key (with scopes)`;
|
|
2464
|
+
}
|
|
2465
|
+
else if (data.required && data.currentLevel) {
|
|
2466
|
+
errorText = `🔒 Insufficient permissions: ${data.required}\n\nYour access level is "${data.currentLevel}". This operation requires a higher access level.\nContact your workspace owner to update your role.`;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
else if (error.response?.data) {
|
|
2339
2470
|
const data = error.response.data;
|
|
2340
2471
|
const detail = typeof data === "string" ? data : JSON.stringify(data);
|
|
2341
2472
|
errorText += `\nDetails: ${detail}`;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stakeholder derivation — mcp-server local mirror of the logic in
|
|
3
|
+
* `lib/trust-audiences.ts` (story 72d27a2a / §7).
|
|
4
|
+
*
|
|
5
|
+
* Why duplicated: mcp-server is a separately-published npm package with
|
|
6
|
+
* its own `tsconfig.json` (rootDir = `./src`). It can't import from the
|
|
7
|
+
* parent repo's `lib/` without a workspace-level refactor. For now, the
|
|
8
|
+
* derivation logic lives independently here.
|
|
9
|
+
*
|
|
10
|
+
* **Drift guard:** `tests/stakeholder-detection-mcp-parity.spec.ts`
|
|
11
|
+
* imports both this module and `lib/trust-audiences.ts` and asserts they
|
|
12
|
+
* produce identical output on a fixture matrix. If either copy is
|
|
13
|
+
* updated without the other, that test fails.
|
|
14
|
+
*
|
|
15
|
+
* If/when a shared workspace package is introduced, consolidate.
|
|
16
|
+
*/
|
|
17
|
+
export const AUDIENCE_KEYS = [
|
|
18
|
+
"peers",
|
|
19
|
+
"institutional_memory",
|
|
20
|
+
"onboarders",
|
|
21
|
+
"risk_committee",
|
|
22
|
+
"governance",
|
|
23
|
+
"execs",
|
|
24
|
+
];
|
|
25
|
+
// Case-insensitive: accepts uppercase forms historically used in test
|
|
26
|
+
// fixtures AND the lowercase enum values from types.ts.
|
|
27
|
+
const REGULATED_FRAMEWORKS_NORMALIZED = new Set([
|
|
28
|
+
"hipaa",
|
|
29
|
+
"soc2",
|
|
30
|
+
"fedramp-moderate",
|
|
31
|
+
"fedramp-high",
|
|
32
|
+
"fedramp",
|
|
33
|
+
]);
|
|
34
|
+
function isRegulatedFramework(framework) {
|
|
35
|
+
return REGULATED_FRAMEWORKS_NORMALIZED.has(framework.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
function nonEmpty(value) {
|
|
38
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
39
|
+
}
|
|
40
|
+
export function deriveStakeholders(input) {
|
|
41
|
+
const result = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
function add(audience, reason) {
|
|
44
|
+
if (seen.has(audience))
|
|
45
|
+
return;
|
|
46
|
+
seen.add(audience);
|
|
47
|
+
result.push({ audience, reason });
|
|
48
|
+
}
|
|
49
|
+
add("peers", "always needed");
|
|
50
|
+
add("institutional_memory", "always needed");
|
|
51
|
+
add("onboarders", "always needed");
|
|
52
|
+
if (nonEmpty(input.story.risk_profile)) {
|
|
53
|
+
add("risk_committee", `risk_profile = "${input.story.risk_profile}"`);
|
|
54
|
+
}
|
|
55
|
+
if (input.story.risk_profile === "Compliance") {
|
|
56
|
+
add("governance", "risk_profile flags Compliance");
|
|
57
|
+
}
|
|
58
|
+
const regulated = (input.workspaceCompliance ?? []).find((f) => isRegulatedFramework(f));
|
|
59
|
+
if (regulated) {
|
|
60
|
+
add("governance", `workspace compliance: ${regulated}`);
|
|
61
|
+
}
|
|
62
|
+
const epic = input.epic;
|
|
63
|
+
if (epic) {
|
|
64
|
+
if (nonEmpty(epic.hypothesis)) {
|
|
65
|
+
add("execs", "epic has hypothesis");
|
|
66
|
+
}
|
|
67
|
+
else if (nonEmpty(epic.successMetrics)) {
|
|
68
|
+
add("execs", "epic has successMetrics");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool input schemas — extracted from `index.ts` so they can be imported
|
|
3
|
+
* directly by behavior tests instead of grepped from source (Tier 5.7).
|
|
4
|
+
*
|
|
5
|
+
* Pattern for new MCP tools in this package: define the inputSchema here as
|
|
6
|
+
* an exported `as const` literal, then reference it from the tool definition
|
|
7
|
+
* in `index.ts`. Story 50e536ca will eventually migrate the remaining inline
|
|
8
|
+
* schemas to this module.
|
|
9
|
+
*
|
|
10
|
+
* Keep enums and field shapes locked to the backend Zod validators
|
|
11
|
+
* (`backend/api/src/validators/storySchemas.ts`). Drift between the MCP
|
|
12
|
+
* schema and the backend validator means LLMs can send fields that get
|
|
13
|
+
* silently stripped (or be told to send valid fields the backend rejects).
|
|
14
|
+
* Cross-spec drift guards live in the test specs.
|
|
15
|
+
*/
|
|
16
|
+
const STORY_TYPE_ENUM = ["feature", "bug", "chore", "platform"];
|
|
17
|
+
const RISK_PROFILE_ENUM = [
|
|
18
|
+
"User",
|
|
19
|
+
"Tech",
|
|
20
|
+
"Market",
|
|
21
|
+
"Compliance",
|
|
22
|
+
"Model_Drift",
|
|
23
|
+
];
|
|
24
|
+
const LANE_ENUM = [
|
|
25
|
+
"Backlog",
|
|
26
|
+
"Icebox",
|
|
27
|
+
"Current",
|
|
28
|
+
"Done",
|
|
29
|
+
"BACKLOG",
|
|
30
|
+
"ICEBOX",
|
|
31
|
+
"CURRENT",
|
|
32
|
+
"DONE",
|
|
33
|
+
];
|
|
34
|
+
export const CREATE_STORY_INPUT_SCHEMA = {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
title: { type: "string", description: "Title of the story" },
|
|
38
|
+
description: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Description of the story",
|
|
41
|
+
},
|
|
42
|
+
acceptanceCriteria: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
45
|
+
},
|
|
46
|
+
lane: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Lane to add the story to (case-insensitive)",
|
|
49
|
+
enum: LANE_ENUM,
|
|
50
|
+
},
|
|
51
|
+
points: {
|
|
52
|
+
type: "number",
|
|
53
|
+
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
54
|
+
},
|
|
55
|
+
requester: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
58
|
+
},
|
|
59
|
+
epicId: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Epic ID to link this story to (optional). Use list_epics to find epic IDs.",
|
|
62
|
+
},
|
|
63
|
+
testPlan: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "Markdown test plan describing test strategy (optional). Sets the test_plan field without changing workflow_stage.",
|
|
66
|
+
},
|
|
67
|
+
storyType: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "Categorizes the story for filtering and Learning Loop signals (feature, bug, chore, platform).",
|
|
70
|
+
enum: STORY_TYPE_ENUM,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ["title"],
|
|
74
|
+
};
|
|
75
|
+
// ─── Story 0ad8e319 (§2): Agent-proposal primitive ──────────────────────
|
|
76
|
+
const PROPOSABLE_FIELD_NAMES_FOR_MCP = [
|
|
77
|
+
"hypothesis",
|
|
78
|
+
"hidden_unknowns",
|
|
79
|
+
"confidence_score",
|
|
80
|
+
"risk_profile",
|
|
81
|
+
"outcome_summary",
|
|
82
|
+
];
|
|
83
|
+
export const PROPOSE_FIELD_DRAFT_INPUT_SCHEMA = {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
storyId: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "UUID of the story this proposal is for",
|
|
89
|
+
},
|
|
90
|
+
fieldName: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Which Learning Loop field this proposal targets. Must be one of the proposable fields.",
|
|
93
|
+
enum: PROPOSABLE_FIELD_NAMES_FOR_MCP,
|
|
94
|
+
},
|
|
95
|
+
draftValue: {
|
|
96
|
+
type: ["string", "number", "null"],
|
|
97
|
+
description: "The proposed value for the field. Type matches the field: string for hypothesis/hidden_unknowns/risk_profile/outcome_summary, number for confidence_score, null to propose clearing the field.",
|
|
98
|
+
},
|
|
99
|
+
provider: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "Optional: the LLM provider+model that generated this draft (e.g., 'anthropic.claude-sonnet-4.5', 'google.gemini-2.0-flash'). Strongly encouraged — disclosed on the proposal badge so humans see which model drafted it.",
|
|
102
|
+
},
|
|
103
|
+
agent: {
|
|
104
|
+
type: "string",
|
|
105
|
+
description: "Optional: the agent client+version that submitted the proposal (e.g., 'claude-code/1.2.3', 'cursor/0.40.0'). Used for per-agent calibration signatures.",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
required: ["storyId", "fieldName", "draftValue"],
|
|
109
|
+
};
|
|
110
|
+
export const ENDORSE_PROPOSAL_INPUT_SCHEMA = {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
proposalId: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "UUID of the pending proposal to endorse. Endorsement updates the underlying story field with the proposal's draft_value and emits a proposal.endorsed audit event.",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: ["proposalId"],
|
|
119
|
+
};
|
|
120
|
+
export const REJECT_PROPOSAL_INPUT_SCHEMA = {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
proposalId: {
|
|
124
|
+
type: "string",
|
|
125
|
+
description: "UUID of the pending proposal to reject.",
|
|
126
|
+
},
|
|
127
|
+
reason: {
|
|
128
|
+
type: "string",
|
|
129
|
+
description: "Required reason the proposal was rejected. Captured for agent learning + audit.",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
required: ["proposalId", "reason"],
|
|
133
|
+
};
|
|
134
|
+
export const DRAFT_HYPOTHESIS_INPUT_SCHEMA = {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
storyId: {
|
|
138
|
+
type: "string",
|
|
139
|
+
description: "UUID of the story to draft a hypothesis for. Reference consumer of the proposal primitive — calls the configured AI_PROVIDER on the backend to generate a draft hypothesis, then submits it as a pending proposal. Pattern for §4 outcome-from-evidence and §6 v2 missing-field drafter to follow.",
|
|
140
|
+
},
|
|
141
|
+
context: {
|
|
142
|
+
type: "string",
|
|
143
|
+
description: "Optional additional context to seed the AI draft (e.g., domain notes, prior hypotheses).",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
required: ["storyId"],
|
|
147
|
+
};
|
|
148
|
+
export const GET_STAKEHOLDERS_INPUT_SCHEMA = {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
storyId: {
|
|
152
|
+
type: "string",
|
|
153
|
+
description: "UUID of the story to derive stakeholders for. Returns which audiences (peers, execs, governance, risk_committee, institutional_memory, onboarders) this card needs to earn trust with, based on story metadata + linked epic + workspace compliance frameworks.",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
required: ["storyId"],
|
|
157
|
+
};
|
|
158
|
+
export const GET_TRUST_LEAKAGE_INPUT_SCHEMA = {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
since: {
|
|
162
|
+
type: "string",
|
|
163
|
+
description: "Optional ISO-8601 timestamp. Return only commits authored on or after this instant. Defaults to 30 days ago.",
|
|
164
|
+
},
|
|
165
|
+
repo: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description: "Optional repo override in 'owner/repo' form. Defaults to the workspace's configured default_repo for the tenant's provider.",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
required: [],
|
|
171
|
+
};
|
|
172
|
+
export const UPDATE_STORY_INPUT_SCHEMA = {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
storyId: { type: "string", description: "ID of the story" },
|
|
176
|
+
title: { type: "string", description: "Updated title" },
|
|
177
|
+
description: { type: "string", description: "Updated description" },
|
|
178
|
+
lane: {
|
|
179
|
+
type: "string",
|
|
180
|
+
description: "Lane to move the story to (case-insensitive)",
|
|
181
|
+
enum: LANE_ENUM,
|
|
182
|
+
},
|
|
183
|
+
points: {
|
|
184
|
+
type: "number",
|
|
185
|
+
description: "Updated points (0, 1, 2, 3, 5, 8)",
|
|
186
|
+
},
|
|
187
|
+
state: {
|
|
188
|
+
type: "string",
|
|
189
|
+
description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
|
|
190
|
+
},
|
|
191
|
+
outcome_summary: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description: "Learning outcome summary",
|
|
194
|
+
},
|
|
195
|
+
acceptanceCriteria: {
|
|
196
|
+
type: "string",
|
|
197
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
198
|
+
},
|
|
199
|
+
sortOrder: {
|
|
200
|
+
type: "number",
|
|
201
|
+
description: "Sort order within the lane (lower = higher priority)",
|
|
202
|
+
},
|
|
203
|
+
epicId: {
|
|
204
|
+
type: "string",
|
|
205
|
+
description: "Epic ID to link this story to. Set to empty string to unlink.",
|
|
206
|
+
},
|
|
207
|
+
testPlan: {
|
|
208
|
+
type: "string",
|
|
209
|
+
description: "Markdown test plan. Updates test_plan field without changing workflow_stage.",
|
|
210
|
+
},
|
|
211
|
+
storyType: {
|
|
212
|
+
type: "string",
|
|
213
|
+
description: "Categorizes the story for filtering and Learning Loop signals (feature, bug, chore, platform).",
|
|
214
|
+
enum: STORY_TYPE_ENUM,
|
|
215
|
+
},
|
|
216
|
+
hypothesis: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "We believe that... — drives Learning Loop AI prompts and earns trust with execs/board.",
|
|
219
|
+
},
|
|
220
|
+
confidence_score: {
|
|
221
|
+
type: "number",
|
|
222
|
+
description: "How confident we are this delivers value (0-100). Earns trust with peers and calibration-minded leaders.",
|
|
223
|
+
},
|
|
224
|
+
hidden_unknowns: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description: "Assumptions or open questions that could invalidate the hypothesis. Earns trust with risk committee / staff engineering.",
|
|
227
|
+
},
|
|
228
|
+
risk_profile: {
|
|
229
|
+
type: "string",
|
|
230
|
+
description: "Categorizes risk (User, Tech, Market, Compliance, Model_Drift). Earns trust with governance and compliance audiences.",
|
|
231
|
+
enum: RISK_PROFILE_ENUM,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
required: ["storyId"],
|
|
235
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elixium.ai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP Server for Elixium.ai",
|
|
6
6
|
"mcpName": "io.github.IndirectTek/mcp-server",
|
|
@@ -19,20 +19,17 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "tsc",
|
|
22
|
-
"test": "vitest run",
|
|
23
|
-
"test:watch": "vitest",
|
|
24
22
|
"start": "node dist/index.js",
|
|
25
23
|
"dev": "ts-node src/index.ts"
|
|
26
24
|
},
|
|
27
25
|
"dependencies": {
|
|
28
|
-
"@modelcontextprotocol/sdk": "^
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
29
27
|
"axios": "^1.6.0",
|
|
30
28
|
"zod": "^3.22.0"
|
|
31
29
|
},
|
|
32
30
|
"devDependencies": {
|
|
33
31
|
"@types/node": "^20.0.0",
|
|
34
32
|
"ts-node": "^10.9.0",
|
|
35
|
-
"typescript": "^5.3.0"
|
|
36
|
-
"vitest": "^4.1.1"
|
|
33
|
+
"typescript": "^5.3.0"
|
|
37
34
|
}
|
|
38
35
|
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { selectBoard, resetBoardContext, getRuntimeBoardId, } from "../board-context.js";
|
|
3
|
-
import { ALL_BOARDS, BOARD_ALPHA, BOARD_BETA, ALPHA_STORIES, BETA_STORIES, ALL_STORIES, ALPHA_EPICS, BETA_EPICS, } from "./fixtures/boards.js";
|
|
4
|
-
const mockClient = {
|
|
5
|
-
get: vi.fn(),
|
|
6
|
-
post: vi.fn(),
|
|
7
|
-
};
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
vi.clearAllMocks();
|
|
10
|
-
resetBoardContext();
|
|
11
|
-
});
|
|
12
|
-
/**
|
|
13
|
-
* Simulates how fetchStories filters by boardId in the MCP server.
|
|
14
|
-
* This mirrors the logic in index.ts that will use getRuntimeBoardId().
|
|
15
|
-
*/
|
|
16
|
-
const fetchStoriesForBoard = (boardId, allStories) => {
|
|
17
|
-
if (!boardId)
|
|
18
|
-
return allStories;
|
|
19
|
-
return allStories.filter((s) => s.boardId === boardId || !s.boardId);
|
|
20
|
-
};
|
|
21
|
-
const fetchEpicsForBoard = (boardId, allEpics) => {
|
|
22
|
-
if (!boardId)
|
|
23
|
-
return allEpics;
|
|
24
|
-
return allEpics.filter((e) => e.boardId === boardId || !e.boardId);
|
|
25
|
-
};
|
|
26
|
-
describe("board isolation", () => {
|
|
27
|
-
it("4.1 stories are scoped to selected board", async () => {
|
|
28
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
29
|
-
await selectBoard(mockClient, "alpha");
|
|
30
|
-
const boardId = getRuntimeBoardId();
|
|
31
|
-
const stories = fetchStoriesForBoard(boardId, ALL_STORIES);
|
|
32
|
-
expect(stories).toHaveLength(ALPHA_STORIES.length);
|
|
33
|
-
expect(stories.every((s) => s.boardId === BOARD_ALPHA.id)).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
it("4.1b beta board returns only beta stories", async () => {
|
|
36
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
37
|
-
await selectBoard(mockClient, "beta");
|
|
38
|
-
const boardId = getRuntimeBoardId();
|
|
39
|
-
const stories = fetchStoriesForBoard(boardId, ALL_STORIES);
|
|
40
|
-
expect(stories).toHaveLength(BETA_STORIES.length);
|
|
41
|
-
expect(stories.every((s) => s.boardId === BOARD_BETA.id)).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
it("4.2 creating a story assigns it to selected board", async () => {
|
|
44
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
45
|
-
await selectBoard(mockClient, "alpha");
|
|
46
|
-
const boardId = getRuntimeBoardId();
|
|
47
|
-
// Simulate what create_story handler does
|
|
48
|
-
const payload = {
|
|
49
|
-
title: "New Story",
|
|
50
|
-
lane: "Current",
|
|
51
|
-
...(boardId ? { boardId } : {}),
|
|
52
|
-
};
|
|
53
|
-
expect(payload.boardId).toBe(BOARD_ALPHA.id);
|
|
54
|
-
});
|
|
55
|
-
it("4.3 epics respect board context", async () => {
|
|
56
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
57
|
-
await selectBoard(mockClient, "alpha");
|
|
58
|
-
const boardId = getRuntimeBoardId();
|
|
59
|
-
const allEpics = [...ALPHA_EPICS, ...BETA_EPICS];
|
|
60
|
-
const epics = fetchEpicsForBoard(boardId, allEpics);
|
|
61
|
-
expect(epics).toHaveLength(1);
|
|
62
|
-
expect(epics[0].boardId).toBe(BOARD_ALPHA.id);
|
|
63
|
-
});
|
|
64
|
-
it("4.5 no board selected falls back to all stories", async () => {
|
|
65
|
-
// No select_board called, no env var
|
|
66
|
-
const boardId = getRuntimeBoardId();
|
|
67
|
-
expect(boardId).toBeNull();
|
|
68
|
-
const stories = fetchStoriesForBoard(boardId, ALL_STORIES);
|
|
69
|
-
expect(stories).toHaveLength(ALL_STORIES.length);
|
|
70
|
-
});
|
|
71
|
-
it("switching boards changes story scope", async () => {
|
|
72
|
-
// Select alpha
|
|
73
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
74
|
-
await selectBoard(mockClient, "alpha");
|
|
75
|
-
let stories = fetchStoriesForBoard(getRuntimeBoardId(), ALL_STORIES);
|
|
76
|
-
expect(stories).toHaveLength(ALPHA_STORIES.length);
|
|
77
|
-
// Switch to beta
|
|
78
|
-
mockClient.get.mockResolvedValue({ data: ALL_BOARDS });
|
|
79
|
-
await selectBoard(mockClient, "beta");
|
|
80
|
-
stories = fetchStoriesForBoard(getRuntimeBoardId(), ALL_STORIES);
|
|
81
|
-
expect(stories).toHaveLength(BETA_STORIES.length);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { createBoard, resetBoardContext } from "../board-context.js";
|
|
3
|
-
const mockClient = {
|
|
4
|
-
get: vi.fn(),
|
|
5
|
-
post: vi.fn(),
|
|
6
|
-
};
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
vi.clearAllMocks();
|
|
9
|
-
resetBoardContext();
|
|
10
|
-
});
|
|
11
|
-
describe("create_board", () => {
|
|
12
|
-
it("2.1 creates board with name and description", async () => {
|
|
13
|
-
const created = {
|
|
14
|
-
id: "new-board-id",
|
|
15
|
-
slug: "sprint-board",
|
|
16
|
-
name: "Sprint Board",
|
|
17
|
-
description: "For sprints",
|
|
18
|
-
is_archived: false,
|
|
19
|
-
created_at: "2026-03-23T00:00:00.000Z",
|
|
20
|
-
};
|
|
21
|
-
mockClient.post.mockResolvedValue({ data: created });
|
|
22
|
-
const result = await createBoard(mockClient, {
|
|
23
|
-
name: "Sprint Board",
|
|
24
|
-
description: "For sprints",
|
|
25
|
-
});
|
|
26
|
-
expect(mockClient.post).toHaveBeenCalledWith("/boards", {
|
|
27
|
-
name: "Sprint Board",
|
|
28
|
-
slug: "sprint-board",
|
|
29
|
-
description: "For sprints",
|
|
30
|
-
});
|
|
31
|
-
expect(result.id).toBe("new-board-id");
|
|
32
|
-
expect(result.slug).toBe("sprint-board");
|
|
33
|
-
expect(result.name).toBe("Sprint Board");
|
|
34
|
-
});
|
|
35
|
-
it("2.2 auto-generates slug from name", async () => {
|
|
36
|
-
mockClient.post.mockResolvedValue({
|
|
37
|
-
data: {
|
|
38
|
-
id: "gen-id",
|
|
39
|
-
slug: "my-sprint-board",
|
|
40
|
-
name: "My Sprint Board",
|
|
41
|
-
is_archived: false,
|
|
42
|
-
created_at: "2026-03-23T00:00:00.000Z",
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
await createBoard(mockClient, { name: "My Sprint Board" });
|
|
46
|
-
expect(mockClient.post).toHaveBeenCalledWith("/boards", {
|
|
47
|
-
name: "My Sprint Board",
|
|
48
|
-
slug: "my-sprint-board",
|
|
49
|
-
description: "",
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
it("2.3 creates board with only required name field", async () => {
|
|
53
|
-
mockClient.post.mockResolvedValue({
|
|
54
|
-
data: {
|
|
55
|
-
id: "min-id",
|
|
56
|
-
slug: "backlog",
|
|
57
|
-
name: "Backlog",
|
|
58
|
-
description: "",
|
|
59
|
-
is_archived: false,
|
|
60
|
-
created_at: "2026-03-23T00:00:00.000Z",
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
const result = await createBoard(mockClient, { name: "Backlog" });
|
|
64
|
-
expect(result.name).toBe("Backlog");
|
|
65
|
-
expect(result.description).toBe("");
|
|
66
|
-
});
|
|
67
|
-
it("2.4 rejects creation when board limit reached", async () => {
|
|
68
|
-
mockClient.post.mockRejectedValue({
|
|
69
|
-
response: {
|
|
70
|
-
status: 422,
|
|
71
|
-
data: { error: "Maximum 3 active boards per tenant" },
|
|
72
|
-
},
|
|
73
|
-
message: "Request failed with status code 422",
|
|
74
|
-
});
|
|
75
|
-
await expect(createBoard(mockClient, { name: "Fourth Board" })).rejects.toMatchObject({
|
|
76
|
-
response: { status: 422 },
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
it("2.5 rejects duplicate slug within tenant", async () => {
|
|
80
|
-
mockClient.post.mockRejectedValue({
|
|
81
|
-
response: {
|
|
82
|
-
status: 409,
|
|
83
|
-
data: { error: "Board slug already exists" },
|
|
84
|
-
},
|
|
85
|
-
message: "Request failed with status code 409",
|
|
86
|
-
});
|
|
87
|
-
await expect(createBoard(mockClient, { name: "Alpha Board" })).rejects.toMatchObject({
|
|
88
|
-
response: { status: 409 },
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
it("2.6 validates name is non-empty", async () => {
|
|
92
|
-
await expect(createBoard(mockClient, { name: "" })).rejects.toThrow("Board name cannot be empty");
|
|
93
|
-
await expect(createBoard(mockClient, { name: " " })).rejects.toThrow("Board name cannot be empty");
|
|
94
|
-
// Should not call API
|
|
95
|
-
expect(mockClient.post).not.toHaveBeenCalled();
|
|
96
|
-
});
|
|
97
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test fixtures for multi-board MCP tools.
|
|
3
|
-
*/
|
|
4
|
-
export const BOARD_ALPHA = {
|
|
5
|
-
id: "aaaaaaaa-1111-2222-3333-444444444444",
|
|
6
|
-
slug: "alpha",
|
|
7
|
-
name: "Alpha Board",
|
|
8
|
-
description: "First test board",
|
|
9
|
-
is_archived: false,
|
|
10
|
-
created_at: "2026-03-01T00:00:00.000Z",
|
|
11
|
-
settings: {},
|
|
12
|
-
};
|
|
13
|
-
export const BOARD_BETA = {
|
|
14
|
-
id: "bbbbbbbb-1111-2222-3333-444444444444",
|
|
15
|
-
slug: "beta",
|
|
16
|
-
name: "Beta Board",
|
|
17
|
-
description: "Second test board",
|
|
18
|
-
is_archived: false,
|
|
19
|
-
created_at: "2026-03-02T00:00:00.000Z",
|
|
20
|
-
settings: {},
|
|
21
|
-
};
|
|
22
|
-
export const BOARD_ARCHIVED = {
|
|
23
|
-
id: "cccccccc-1111-2222-3333-444444444444",
|
|
24
|
-
slug: "archived-board",
|
|
25
|
-
name: "Archived Board",
|
|
26
|
-
description: "An archived board",
|
|
27
|
-
is_archived: true,
|
|
28
|
-
created_at: "2026-02-15T00:00:00.000Z",
|
|
29
|
-
settings: {},
|
|
30
|
-
};
|
|
31
|
-
export const ALL_BOARDS = [BOARD_ALPHA, BOARD_BETA, BOARD_ARCHIVED];
|
|
32
|
-
export const ACTIVE_BOARDS = [BOARD_ALPHA, BOARD_BETA];
|
|
33
|
-
// Stories assigned to specific boards
|
|
34
|
-
export const ALPHA_STORIES = [
|
|
35
|
-
{
|
|
36
|
-
id: "s1111111-1111-1111-1111-111111111111",
|
|
37
|
-
title: "Alpha Story 1",
|
|
38
|
-
lane: "Current",
|
|
39
|
-
state: "started",
|
|
40
|
-
points: 3,
|
|
41
|
-
owners: ["user-1"],
|
|
42
|
-
labels: [],
|
|
43
|
-
epicId: null,
|
|
44
|
-
boardId: BOARD_ALPHA.id,
|
|
45
|
-
storyType: "feature",
|
|
46
|
-
createdAt: "2026-03-10T00:00:00.000Z",
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
id: "s2222222-2222-2222-2222-222222222222",
|
|
50
|
-
title: "Alpha Story 2",
|
|
51
|
-
lane: "Backlog",
|
|
52
|
-
state: "unstarted",
|
|
53
|
-
points: 2,
|
|
54
|
-
owners: [],
|
|
55
|
-
labels: [],
|
|
56
|
-
epicId: null,
|
|
57
|
-
boardId: BOARD_ALPHA.id,
|
|
58
|
-
storyType: "bug",
|
|
59
|
-
createdAt: "2026-03-11T00:00:00.000Z",
|
|
60
|
-
},
|
|
61
|
-
];
|
|
62
|
-
export const BETA_STORIES = [
|
|
63
|
-
{
|
|
64
|
-
id: "s3333333-3333-3333-3333-333333333333",
|
|
65
|
-
title: "Beta Story 1",
|
|
66
|
-
lane: "Current",
|
|
67
|
-
state: "unstarted",
|
|
68
|
-
points: 5,
|
|
69
|
-
owners: ["user-2"],
|
|
70
|
-
labels: [],
|
|
71
|
-
epicId: null,
|
|
72
|
-
boardId: BOARD_BETA.id,
|
|
73
|
-
storyType: "feature",
|
|
74
|
-
createdAt: "2026-03-12T00:00:00.000Z",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
id: "s4444444-4444-4444-4444-444444444444",
|
|
78
|
-
title: "Beta Story 2",
|
|
79
|
-
lane: "Current",
|
|
80
|
-
state: "started",
|
|
81
|
-
points: 1,
|
|
82
|
-
owners: [],
|
|
83
|
-
labels: [],
|
|
84
|
-
epicId: null,
|
|
85
|
-
boardId: BOARD_BETA.id,
|
|
86
|
-
storyType: "chore",
|
|
87
|
-
createdAt: "2026-03-13T00:00:00.000Z",
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
id: "s5555555-5555-5555-5555-555555555555",
|
|
91
|
-
title: "Beta Story 3",
|
|
92
|
-
lane: "Backlog",
|
|
93
|
-
state: "unstarted",
|
|
94
|
-
points: 8,
|
|
95
|
-
owners: [],
|
|
96
|
-
labels: [],
|
|
97
|
-
epicId: null,
|
|
98
|
-
boardId: BOARD_BETA.id,
|
|
99
|
-
storyType: "feature",
|
|
100
|
-
createdAt: "2026-03-14T00:00:00.000Z",
|
|
101
|
-
},
|
|
102
|
-
];
|
|
103
|
-
export const ALL_STORIES = [...ALPHA_STORIES, ...BETA_STORIES];
|
|
104
|
-
export const ALPHA_EPICS = [
|
|
105
|
-
{
|
|
106
|
-
id: "e1111111-1111-1111-1111-111111111111",
|
|
107
|
-
name: "Alpha Epic",
|
|
108
|
-
boardId: BOARD_ALPHA.id,
|
|
109
|
-
},
|
|
110
|
-
];
|
|
111
|
-
export const BETA_EPICS = [
|
|
112
|
-
{
|
|
113
|
-
id: "e2222222-2222-2222-2222-222222222222",
|
|
114
|
-
name: "Beta Epic",
|
|
115
|
-
boardId: BOARD_BETA.id,
|
|
116
|
-
},
|
|
117
|
-
];
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,74 +0,0 @@
|
|
|
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
|
-
});
|