@elixium.ai/mcp-server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,10 @@ 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";
11
+ import { MCP_FEATURE_CONFIG_ERROR_FALLBACK } from "./constants/featureConfigFallback.js";
8
12
  const API_KEY = process.env.ELIXIUM_API_KEY;
9
13
  const API_URL = process.env.ELIXIUM_API_URL || "https://elixium.ai/api";
10
14
  const BOARD_SLUG = process.env.ELIXIUM_BOARD_SLUG;
@@ -311,25 +315,8 @@ const fetchFeatureConfig = async () => {
311
315
  return response.data;
312
316
  }
313
317
  catch (error) {
314
- console.error("Failed to fetch feature config, defaulting to all enabled:", error);
315
- return {
316
- features: {
317
- balancedTeam: true,
318
- learningLoop: true,
319
- tddWorkflow: true,
320
- aiTools: true,
321
- teamDecisions: false,
322
- ragKnowledgeBase: false,
323
- },
324
- source: {
325
- balancedTeam: "error-fallback",
326
- learningLoop: "error-fallback",
327
- tddWorkflow: "error-fallback",
328
- aiTools: "error-fallback",
329
- teamDecisions: "error-fallback",
330
- ragKnowledgeBase: "error-fallback",
331
- },
332
- };
318
+ console.error("Failed to fetch feature config, using error fallback:", error);
319
+ return MCP_FEATURE_CONFIG_ERROR_FALLBACK;
333
320
  }
334
321
  };
335
322
  // Helper functions to check individual features
@@ -643,52 +630,8 @@ const createServer = () => {
643
630
  },
644
631
  {
645
632
  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
- },
633
+ description: "Create a story on the active board. New stories enter the Backlog lane and are attributed to the user bound to your API key. Stories you create without current board context risk duplicating in-flight work or contradicting recent decisions. Ensure you are grounded before writing — if you haven't grounded this session, call `get_board_context` (optionally with `intent` set to the user's request for similar-story matching). This is the normal path when a user asks you to draft a story; it is not a bypass of human review, which happens at downstream workflow gates. Accepts storyType (feature/bug/chore/platform) at create time. Use `propose_field_draft` for asynchronous suggestions on fields of an existing story.",
634
+ inputSchema: CREATE_STORY_INPUT_SCHEMA,
692
635
  },
693
636
  {
694
637
  name: "get_iteration_context",
@@ -698,6 +641,19 @@ const createServer = () => {
698
641
  properties: {},
699
642
  },
700
643
  },
644
+ {
645
+ name: "get_board_context",
646
+ description: "Get a snapshot of the workspace's board state for grounding before drafting or updating content. Returns time-balanced sections: inFlight (Current lane + in_progress epics), queued (Backlog + next/soon epics), icebox (Icebox + someday epics), recentlyCompleted (Done + archived epics), decisions, objectives, learnings, plus configuration (feature flags, workflow stages). Pass `intent` (free-form text of what you're about to do; the user's verbatim request is fine) to additionally receive `similar` — existing stories ranked by relevance. Stories you create or update without current board context risk duplicating in-flight work or contradicting recent decisions; ensure you are grounded before writing.",
647
+ inputSchema: {
648
+ type: "object",
649
+ properties: {
650
+ intent: {
651
+ type: "string",
652
+ description: "Optional free-form text describing what you're about to do (e.g., the user's verbatim request, or a short topic phrase). When provided, the response additionally includes a `similar` section ranked by relevance.",
653
+ },
654
+ },
655
+ },
656
+ },
701
657
  {
702
658
  name: "list_objectives",
703
659
  description: "List objectives for the current workspace",
@@ -716,7 +672,7 @@ const createServer = () => {
716
672
  },
717
673
  {
718
674
  name: "create_epic",
719
- description: "Create a new epic for the current board",
675
+ description: "Create an epic for the active board. Epics group related stories around an outcome hypothesis. Before creating, ensure you are grounded — call `get_board_context` to surface existing epics, workspace objectives, and recent decisions; duplicate or overlapping epics are easy to introduce without that context. Epics are attributed to the user bound to your API key. Use `update_epic` to revise an existing epic rather than duplicating.",
720
676
  inputSchema: {
721
677
  type: "object",
722
678
  properties: {
@@ -741,7 +697,7 @@ const createServer = () => {
741
697
  },
742
698
  {
743
699
  name: "update_epic",
744
- description: "Update an epic (title, description, stage, or outcome fields)",
700
+ description: "Update an epic's title, description, stage, or outcome fields (hypothesis, success metrics, outcome status, target date). Before updating, ensure you are grounded — call `get_board_context` for current board state and recent decisions that may affect this epic. Updates are attributed to the user bound to your API key.",
745
701
  inputSchema: {
746
702
  type: "object",
747
703
  properties: {
@@ -767,58 +723,38 @@ const createServer = () => {
767
723
  },
768
724
  {
769
725
  name: "update_story",
770
- description: "Update fields on an existing story",
771
- inputSchema: {
772
- type: "object",
773
- properties: {
774
- storyId: { type: "string", description: "ID of the story" },
775
- title: { type: "string", description: "Updated title" },
776
- description: { type: "string", description: "Updated description" },
777
- lane: {
778
- type: "string",
779
- description: "Lane to move the story to (case-insensitive)",
780
- enum: [
781
- "Backlog",
782
- "Icebox",
783
- "Current",
784
- "Done",
785
- "BACKLOG",
786
- "ICEBOX",
787
- "CURRENT",
788
- "DONE",
789
- ],
790
- },
791
- points: {
792
- type: "number",
793
- description: "Updated points (0, 1, 2, 3, 5, 8)",
794
- },
795
- state: {
796
- type: "string",
797
- description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
798
- },
799
- outcome_summary: {
800
- type: "string",
801
- description: "Learning outcome summary",
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
- },
726
+ 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. Before updating, call `get_story` to inspect current state, and `get_board_context` if your update relates to a topic that may be governed by a recent team decision. Updates are attributed to the user bound to your API key.",
727
+ inputSchema: UPDATE_STORY_INPUT_SCHEMA,
728
+ },
729
+ {
730
+ name: "get_stakeholders",
731
+ 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.",
732
+ inputSchema: GET_STAKEHOLDERS_INPUT_SCHEMA,
733
+ },
734
+ {
735
+ name: "propose_field_draft",
736
+ 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. Stories whose fields you propose without current board context risk contradicting recent decisions in this area. Ensure you are grounded before drafting — call `get_board_context` if you haven't grounded this session.",
737
+ inputSchema: PROPOSE_FIELD_DRAFT_INPUT_SCHEMA,
738
+ },
739
+ {
740
+ name: "endorse_proposal",
741
+ 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.",
742
+ inputSchema: ENDORSE_PROPOSAL_INPUT_SCHEMA,
743
+ },
744
+ {
745
+ name: "reject_proposal",
746
+ description: "Reject a pending field proposal with a required reason. Story field is unchanged; reason is captured for agent learning + audit.",
747
+ inputSchema: REJECT_PROPOSAL_INPUT_SCHEMA,
748
+ },
749
+ {
750
+ name: "draft_hypothesis",
751
+ description: "Generate an AI-drafted hypothesis for the given story and submit it as a pending proposal. The backend calls the configured AI_PROVIDER, drafts the hypothesis text, and creates a proposal that a human must endorse via `endorse_proposal` before the story's hypothesis field is updated. The draft does not change the story until endorsed.",
752
+ inputSchema: DRAFT_HYPOTHESIS_INPUT_SCHEMA,
753
+ },
754
+ {
755
+ name: "get_trust_leakage",
756
+ 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.",
757
+ inputSchema: GET_TRUST_LEAKAGE_INPUT_SCHEMA,
822
758
  },
823
759
  {
824
760
  name: "prepare_implementation",
@@ -938,7 +874,7 @@ const createServer = () => {
938
874
  },
939
875
  {
940
876
  name: "propose_test_plan",
941
- description: "Submit a test plan for human review. Sets workflow_stage to tests_proposed. Implementation is BLOCKED until human approves.",
877
+ description: "Submit a test plan for human review. Sets workflow_stage to tests_proposed. Implementation is BLOCKED until human approves. Before proposing, call `get_story` to inspect the story's hypothesis, hidden_unknowns, and risk_profile (the test plan should ground in those fields). Use `draft_test_plan` first for a scaffold seeded from the story's premortem assumptions.",
942
878
  inputSchema: {
943
879
  type: "object",
944
880
  properties: {
@@ -959,6 +895,20 @@ const createServer = () => {
959
895
  required: ["storyId", "testPlan"],
960
896
  },
961
897
  },
898
+ {
899
+ name: "draft_test_plan",
900
+ 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.",
901
+ inputSchema: {
902
+ type: "object",
903
+ properties: {
904
+ storyId: {
905
+ type: "string",
906
+ description: "ID of the story to draft a test plan for",
907
+ },
908
+ },
909
+ required: ["storyId"],
910
+ },
911
+ },
962
912
  {
963
913
  name: "approve_tests",
964
914
  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.",
@@ -1032,7 +982,7 @@ const createServer = () => {
1032
982
  const learningLoopTools = learningLoopEnabled ? [
1033
983
  {
1034
984
  name: "create_hypothesis",
1035
- description: "Create a new assumption/hypothesis in the Icebox for validation",
985
+ description: "Create a new assumption/hypothesis in the Icebox for validation. Before creating, ensure you are grounded — call `get_board_context` to check for existing related hypotheses and recent decisions in this area. The hypothesis is attributed to the user bound to your API key.",
1036
986
  inputSchema: {
1037
987
  type: "object",
1038
988
  properties: {
@@ -1050,7 +1000,7 @@ const createServer = () => {
1050
1000
  },
1051
1001
  {
1052
1002
  name: "record_learning",
1053
- description: "Record a learning outcome for a completed story",
1003
+ description: "Record a learning outcome for a completed story (sets the story's outcome_summary field). The learning is visible in the workspace's recent-learnings surface. Before recording, call `get_story` to inspect the story's current state, and `get_board_context` to surface recently-recorded learnings on related stories that may already capture the same insight.",
1054
1004
  inputSchema: {
1055
1005
  type: "object",
1056
1006
  properties: {
@@ -1068,7 +1018,7 @@ const createServer = () => {
1068
1018
  const teamDecisionsTools = teamDecisionsEnabled ? [
1069
1019
  {
1070
1020
  name: "record_decision",
1071
- description: "Record a team decision, meeting outcome, or architectural choice. These are shared across all team members' AI sessions.",
1021
+ description: "Record a team decision, meeting outcome, or architectural choice. These are shared across all team members' AI sessions. Before recording, ensure you are grounded — call `get_board_context` to check whether a recent decision already covers this area; duplicate or contradictory decisions are exactly what this tool exists to prevent.",
1072
1022
  inputSchema: {
1073
1023
  type: "object",
1074
1024
  properties: {
@@ -1149,7 +1099,7 @@ const createServer = () => {
1149
1099
  try {
1150
1100
  const toolName = request.params.name;
1151
1101
  // Check TDD workflow tools
1152
- const tddWorkflowTools = ["start_story", "propose_test_plan", "get_test_plan", "submit_for_review", "review_pr"];
1102
+ const tddWorkflowTools = ["start_story", "propose_test_plan", "draft_test_plan", "get_test_plan", "submit_for_review", "review_pr"];
1153
1103
  if (tddWorkflowTools.includes(toolName)) {
1154
1104
  const enabled = await isTddWorkflowEnabled();
1155
1105
  if (!enabled) {
@@ -1411,6 +1361,58 @@ ${config.infrastructureProfile?.provider ? `- Provider: ${config.infrastructureP
1411
1361
  ],
1412
1362
  };
1413
1363
  }
1364
+ case "get_board_context": {
1365
+ // Thin wrapper around GET /api/board-context. The backend composes
1366
+ // the response; this handler forwards the optional `intent` query
1367
+ // param and wraps the response in MCP content format.
1368
+ //
1369
+ // Note: the backend also supports a `since` parameter for conditional
1370
+ // return (sentinel when workspace state hasn't shifted), but it is
1371
+ // intentionally NOT exposed at the MCP layer until the
1372
+ // context_version invariant ships (separate follow-on story:
1373
+ // "Wire context_version invariant with chosen atomicity pattern").
1374
+ // Without per-write version bumps, the sentinel would be unreliable
1375
+ // (stale `unchanged: true` for state that did change).
1376
+ //
1377
+ // Graceful 404 fallback: if the backend is older than 2026-05-17
1378
+ // (or a self-hosted release that doesn't include this endpoint),
1379
+ // return a structured message pointing the agent at the existing
1380
+ // multi-tool orchestration path rather than throwing an opaque
1381
+ // error. Version skew between mcp-server and backend is the most
1382
+ // common failure mode for new MCP tools; surfacing it well here
1383
+ // is cheaper than every customer filing the same bug report.
1384
+ const args = request.params.arguments ?? {};
1385
+ const params = {};
1386
+ if (typeof args.intent === "string" && args.intent.trim()) {
1387
+ params.intent = args.intent.trim();
1388
+ }
1389
+ try {
1390
+ const response = await client.get("/board-context", { params });
1391
+ return {
1392
+ content: [
1393
+ { type: "text", text: JSON.stringify(response.data, null, 2) },
1394
+ ],
1395
+ };
1396
+ }
1397
+ catch (err) {
1398
+ if (err?.response?.status === 404) {
1399
+ return {
1400
+ content: [
1401
+ {
1402
+ type: "text",
1403
+ text: JSON.stringify({
1404
+ error: "get_board_context is not available on this backend",
1405
+ reason: "The backend does not expose GET /api/board-context. This is likely a version-skew issue: the tool was added in mcp-server 0.6.0 (2026-05-17) and requires a backend release that includes the matching endpoint.",
1406
+ fallback: "Until the backend is updated, orchestrate grounding manually: call `get_iteration_context` (in-flight work), `list_epics` (queued and in-progress epics), `list_decisions` (recent team decisions in the workspace), and `get_feature_config` (workspace configuration). The trade-offs are more tool calls and no time-balanced sections or similar-story matching, but the agent still gets the underlying grounding data.",
1407
+ action: "Self-hosted: update the backend to a release that includes GET /api/board-context. SaaS (elixium.ai-hosted): contact support — the endpoint should already be live on hosted backends.",
1408
+ }, null, 2),
1409
+ },
1410
+ ],
1411
+ };
1412
+ }
1413
+ throw err;
1414
+ }
1415
+ }
1414
1416
  case "create_hypothesis": {
1415
1417
  const args = request.params.arguments;
1416
1418
  const normalizedLane = await normalizeLane("Icebox");
@@ -1540,6 +1542,134 @@ ${config.infrastructureProfile?.provider ? `- Provider: ${config.infrastructureP
1540
1542
  ],
1541
1543
  };
1542
1544
  }
1545
+ case "get_stakeholders": {
1546
+ const args = request.params.arguments;
1547
+ const { storyId } = args;
1548
+ assertUUID(storyId, "storyId");
1549
+ // Fetch story + feature config in parallel; the story tells us
1550
+ // whether to fetch a linked epic.
1551
+ const [storyResponse, featureConfig] = await Promise.all([
1552
+ client.get(`/stories/${storyId}`),
1553
+ fetchFeatureConfig(),
1554
+ ]);
1555
+ const story = storyResponse.data;
1556
+ // If the story is linked to an epic, fetch the epic too.
1557
+ let epic = null;
1558
+ if (story.epic_id) {
1559
+ try {
1560
+ const epicResponse = await client.get(`/epics/${story.epic_id}`);
1561
+ epic = epicResponse.data;
1562
+ }
1563
+ catch {
1564
+ // Epic fetch failure is non-fatal — derivation simply won't
1565
+ // add the `execs` audience in this case.
1566
+ epic = null;
1567
+ }
1568
+ }
1569
+ // Read from canonical field name `complianceFrameworks` (per
1570
+ // types.ts InfrastructureProfile); fall back to `compliance` for
1571
+ // any shipped callers that use the older name. Values are
1572
+ // normalized case-insensitively in deriveStakeholders.
1573
+ const infra = featureConfig.infrastructureProfile ?? {};
1574
+ const workspaceCompliance = Array.isArray(infra.complianceFrameworks)
1575
+ ? infra.complianceFrameworks
1576
+ : Array.isArray(infra.compliance)
1577
+ ? infra.compliance
1578
+ : [];
1579
+ const stakeholders = deriveStakeholders({
1580
+ story: {
1581
+ story_type: story.story_type ?? null,
1582
+ title: story.title ?? null,
1583
+ risk_profile: story.risk_profile ?? null,
1584
+ epic_id: story.epic_id ?? null,
1585
+ },
1586
+ epic: epic
1587
+ ? {
1588
+ hypothesis: epic.hypothesis ?? null,
1589
+ successMetrics: epic.successMetrics ?? null,
1590
+ }
1591
+ : null,
1592
+ workspaceCompliance,
1593
+ });
1594
+ const payload = {
1595
+ storyId: story.id,
1596
+ stakeholders,
1597
+ derived_from: {
1598
+ story_type: story.story_type ?? null,
1599
+ risk_profile: story.risk_profile ?? null,
1600
+ epic_id: story.epic_id ?? null,
1601
+ compliance_frameworks: workspaceCompliance,
1602
+ },
1603
+ };
1604
+ return {
1605
+ content: [
1606
+ { type: "text", text: JSON.stringify(payload, null, 2) },
1607
+ ],
1608
+ };
1609
+ }
1610
+ case "propose_field_draft": {
1611
+ const args = request.params.arguments;
1612
+ const { storyId, fieldName, draftValue, provider, agent } = args;
1613
+ assertUUID(storyId, "storyId");
1614
+ const response = await client.post(`/stories/${storyId}/proposals`, {
1615
+ field_name: fieldName,
1616
+ draft_value: draftValue,
1617
+ ...(provider ? { provider } : {}),
1618
+ ...(agent ? { agent } : {}),
1619
+ });
1620
+ return {
1621
+ content: [
1622
+ { type: "text", text: JSON.stringify(response.data, null, 2) },
1623
+ ],
1624
+ };
1625
+ }
1626
+ case "endorse_proposal": {
1627
+ const args = request.params.arguments;
1628
+ const { proposalId } = args;
1629
+ assertUUID(proposalId, "proposalId");
1630
+ const response = await client.post(`/proposals/${proposalId}/endorse`, {});
1631
+ return {
1632
+ content: [
1633
+ { type: "text", text: JSON.stringify(response.data, null, 2) },
1634
+ ],
1635
+ };
1636
+ }
1637
+ case "reject_proposal": {
1638
+ const args = request.params.arguments;
1639
+ const { proposalId, reason } = args;
1640
+ assertUUID(proposalId, "proposalId");
1641
+ const response = await client.post(`/proposals/${proposalId}/reject`, { reason });
1642
+ return {
1643
+ content: [
1644
+ { type: "text", text: JSON.stringify(response.data, null, 2) },
1645
+ ],
1646
+ };
1647
+ }
1648
+ case "draft_hypothesis": {
1649
+ const args = request.params.arguments;
1650
+ const { storyId, context } = args;
1651
+ assertUUID(storyId, "storyId");
1652
+ const response = await client.post(`/stories/${storyId}/proposals/draft`, { field_name: "hypothesis", ...(context ? { context } : {}) });
1653
+ return {
1654
+ content: [
1655
+ { type: "text", text: JSON.stringify(response.data, null, 2) },
1656
+ ],
1657
+ };
1658
+ }
1659
+ case "get_trust_leakage": {
1660
+ const args = (request.params.arguments ?? {});
1661
+ const query = {};
1662
+ if (args.since)
1663
+ query.since = String(args.since);
1664
+ if (args.repo)
1665
+ query.repo = String(args.repo);
1666
+ const response = await client.get(`/trust-leakage`, { params: query });
1667
+ return {
1668
+ content: [
1669
+ { type: "text", text: JSON.stringify(response.data, null, 2) },
1670
+ ],
1671
+ };
1672
+ }
1543
1673
  case "prepare_implementation": {
1544
1674
  const args = request.params.arguments;
1545
1675
  const { storyId } = args;
@@ -1680,6 +1810,9 @@ Here’s the smallest change that will validate it:
1680
1810
  const dorWarnings = formatDorWarnings(boardSettings, result.dorChecklist);
1681
1811
  const isTrunk = result.trunkBased;
1682
1812
  const isAutoMerge = result.autoMerge;
1813
+ const workflowModelLine = formatWorkflowModelLine({
1814
+ useDeliveredState: teamConfig.features.useDeliveredState,
1815
+ });
1683
1816
  let formattedResult;
1684
1817
  if (isTrunk && !isAutoMerge) {
1685
1818
  formattedResult = `
@@ -1688,6 +1821,7 @@ Here’s the smallest change that will validate it:
1688
1821
  **Mode:** Direct to main (trunk-based development)
1689
1822
  **Feature Flag:** \`${result.featureFlagName || "none"}\`
1690
1823
  **Workflow Stage:** ${result.workflow_stage}
1824
+ **Workflow Model:** ${workflowModelLine}
1691
1825
 
1692
1826
  ## Acceptance Criteria
1693
1827
  ${result.acceptance_criteria || "No specific AC provided."}
@@ -1714,6 +1848,7 @@ ${dorWarnings}`;
1714
1848
  **Branch:** \`${result.branch}\`
1715
1849
  **Feature Flag:** \`${result.featureFlagName || "none"}\`
1716
1850
  **Workflow Stage:** ${result.workflow_stage}
1851
+ **Workflow Model:** ${workflowModelLine}
1717
1852
 
1718
1853
  ## Acceptance Criteria
1719
1854
  ${result.acceptance_criteria || "No specific AC provided."}
@@ -1738,6 +1873,7 @@ ${dorWarnings}`;
1738
1873
 
1739
1874
  **Branch:** \`${result.branch}\`
1740
1875
  **Workflow Stage:** ${result.workflow_stage}
1876
+ **Workflow Model:** ${workflowModelLine}
1741
1877
 
1742
1878
  ## Acceptance Criteria
1743
1879
  ${result.acceptance_criteria || "No specific AC provided."}
@@ -1844,6 +1980,30 @@ ${(result.test_file_paths || []).map((p) => `- \`${p}\``).join("\n") || "No test
1844
1980
  ${result.message}
1845
1981
 
1846
1982
  > 🛑 **BLOCKED:** Implementation cannot proceed until a human approves this test plan.
1983
+ `;
1984
+ return {
1985
+ content: [{ type: "text", text: formattedResult.trim() }],
1986
+ };
1987
+ }
1988
+ case "draft_test_plan": {
1989
+ const args = request.params.arguments;
1990
+ const { storyId } = args;
1991
+ if (!storyId) {
1992
+ throw new Error("storyId is required");
1993
+ }
1994
+ assertUUID(storyId, "storyId");
1995
+ const response = await client.get(`/stories/${storyId}/draft-test-plan`);
1996
+ const result = response.data;
1997
+ const formattedResult = `
1998
+ # Drafted Test Plan
1999
+
2000
+ **Story ID:** ${storyId}
2001
+
2002
+ > This is a scaffold seeded from the story's \`hidden_unknowns\` and \`hypothesis\`. Edit freely, then call \`propose_test_plan\` with the final version.
2003
+
2004
+ ---
2005
+
2006
+ ${result.testPlan}
1847
2007
  `;
1848
2008
  return {
1849
2009
  content: [{ type: "text", text: formattedResult.trim() }],
@@ -1880,13 +2040,20 @@ ${result.message}
1880
2040
  throw new Error("storyId is required");
1881
2041
  }
1882
2042
  assertUUID(storyId, "storyId");
1883
- const response = await client.post(`/stories/${storyId}/submit-review`, {
1884
- commitHash,
1885
- testResults,
1886
- implementationNotes,
1887
- });
2043
+ const [response, submitFeatureConfig] = await Promise.all([
2044
+ client.post(`/stories/${storyId}/submit-review`, {
2045
+ commitHash,
2046
+ testResults,
2047
+ implementationNotes,
2048
+ }),
2049
+ fetchFeatureConfig(),
2050
+ ]);
1888
2051
  const result = response.data;
1889
2052
  const isAutoMerge = result.autoMerge;
2053
+ const submitWorkflowModelLine = formatWorkflowModelLine({
2054
+ useDeliveredState: submitFeatureConfig.features.useDeliveredState,
2055
+ state: result.state,
2056
+ });
1890
2057
  let autoMergeSection = "";
1891
2058
  if (isAutoMerge && result.autoMergeInstructions) {
1892
2059
  const instr = result.autoMergeInstructions;
@@ -1920,6 +2087,7 @@ git branch -d ${instr.sourceBranch}
1920
2087
  **Status:** ${result.status}
1921
2088
  **Workflow Stage:** ${result.workflow_stage}
1922
2089
  **State:** ${result.state}
2090
+ **Workflow Model:** ${submitWorkflowModelLine}
1923
2091
  ${result.trunkBased ? `**Mode:** Trunk-based${isAutoMerge ? " + Auto-merge" : ""}` : ""}
1924
2092
  ${result.featureFlagName ? `**Feature Flag:** \`${result.featureFlagName}\`` : ""}
1925
2093
 
@@ -2335,7 +2503,17 @@ ${depList || "No dependencies detected."}
2335
2503
  if (error.response?.status) {
2336
2504
  errorText += ` (HTTP ${error.response.status})`;
2337
2505
  }
2338
- if (error.response?.data) {
2506
+ // Enhanced 403 handling for RBAC and API key scope errors
2507
+ if (error.response?.status === 403 && error.response?.data) {
2508
+ const data = error.response.data;
2509
+ if (data.required && data.currentScopes) {
2510
+ 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)`;
2511
+ }
2512
+ else if (data.required && data.currentLevel) {
2513
+ 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.`;
2514
+ }
2515
+ }
2516
+ else if (error.response?.data) {
2339
2517
  const data = error.response.data;
2340
2518
  const detail = typeof data === "string" ? data : JSON.stringify(data);
2341
2519
  errorText += `\nDetails: ${detail}`;