@elixium.ai/mcp-server 0.3.6 → 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 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,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
+ });
@@ -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;
@@ -44,6 +47,7 @@ const SSE_PATH = ensurePath(getArgValue("--sse-path") ?? process.env.ELIXIUM_MCP
44
47
  const MESSAGE_PATH = ensurePath(getArgValue("--message-path") ??
45
48
  process.env.ELIXIUM_MCP_MESSAGE_PATH ??
46
49
  "/message", "/message");
50
+ import { listBoards, createBoard, selectBoard, getActiveBoardSlug, getRuntimeBoardId, } from "./board-context.js";
47
51
  import * as fs from "fs";
48
52
  import * as path from "path";
49
53
  import { fileURLToPath } from "url";
@@ -98,6 +102,119 @@ const client = axios.create({
98
102
  ...(BOARD_SLUG ? { "x-board-slug": BOARD_SLUG } : {}),
99
103
  },
100
104
  });
105
+ // ── Contract Scanning for prepare_implementation ──
106
+ const CONTRACT_STOP_WORDS = new Set([
107
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
108
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
109
+ "should", "may", "might", "shall", "can", "for", "and", "nor", "but",
110
+ "or", "yet", "so", "in", "on", "at", "to", "of", "by", "with", "from",
111
+ "up", "about", "into", "through", "during", "before", "after", "above",
112
+ "below", "between", "out", "off", "over", "under", "again", "further",
113
+ "then", "once", "here", "there", "when", "where", "why", "how", "all",
114
+ "each", "every", "both", "few", "more", "most", "other", "some", "such",
115
+ "no", "not", "only", "own", "same", "than", "too", "very", "just",
116
+ "because", "as", "until", "while", "that", "this", "these", "those",
117
+ "it", "its", "we", "they", "add", "update", "fix", "implement", "create",
118
+ "new", "remove", "change", "make", "use", "set", "get", "check", "ensure",
119
+ "story", "feature", "bug", "chore", "task",
120
+ ]);
121
+ function extractKeywords(title, description = "") {
122
+ const text = `${title} ${description}`.toLowerCase();
123
+ const words = text
124
+ .replace(/[^a-z0-9\s-]/g, " ")
125
+ .split(/\s+/)
126
+ .filter((w) => w.length > 2 && !CONTRACT_STOP_WORDS.has(w));
127
+ return [...new Set(words)];
128
+ }
129
+ function categorizeMatch(content) {
130
+ if (/\b(create|generate|produce|emit|publish|write|return|send|build|insert)\b/i.test(content))
131
+ return "producer";
132
+ if (/\b(consume|read|validate|verify|import|parse|receive|fetch|load|decode|check)\b/i.test(content))
133
+ return "consumer";
134
+ return "reference";
135
+ }
136
+ function scanCodebaseForContracts(keywords, cwd) {
137
+ if (keywords.length === 0)
138
+ return [];
139
+ const matches = [];
140
+ const searchDirs = ["app", "backend/api/src", "mcp-server/src", "components"].map(d => path.join(cwd, d));
141
+ const extensions = new Set([".ts", ".tsx", ".js", ".jsx"]);
142
+ const maxMatches = 50;
143
+ function searchDir(dir) {
144
+ if (matches.length >= maxMatches)
145
+ return;
146
+ try {
147
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
148
+ for (const entry of entries) {
149
+ if (matches.length >= maxMatches)
150
+ return;
151
+ const fullPath = path.join(dir, entry.name);
152
+ if (entry.isDirectory()) {
153
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist")
154
+ continue;
155
+ searchDir(fullPath);
156
+ }
157
+ else if (extensions.has(path.extname(entry.name))) {
158
+ try {
159
+ const content = fs.readFileSync(fullPath, "utf-8");
160
+ const lines = content.split("\n");
161
+ for (let i = 0; i < lines.length && matches.length < maxMatches; i++) {
162
+ const line = lines[i];
163
+ const lineLower = line.toLowerCase();
164
+ if (keywords.some((kw) => lineLower.includes(kw))) {
165
+ const trimmed = line.trim();
166
+ if (trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("*")) {
167
+ matches.push({
168
+ file: path.relative(cwd, fullPath),
169
+ line: i + 1,
170
+ content: trimmed.substring(0, 200),
171
+ role: categorizeMatch(trimmed),
172
+ });
173
+ }
174
+ }
175
+ }
176
+ }
177
+ catch { /* skip unreadable files */ }
178
+ }
179
+ }
180
+ }
181
+ catch { /* skip inaccessible dirs */ }
182
+ }
183
+ for (const dir of searchDirs) {
184
+ if (fs.existsSync(dir))
185
+ searchDir(dir);
186
+ }
187
+ return matches;
188
+ }
189
+ function formatContractsSection(matches) {
190
+ if (matches.length === 0) {
191
+ return "\n## Integration Contracts\nNo downstream consumers detected.\n";
192
+ }
193
+ const producers = matches.filter((m) => m.role === "producer");
194
+ const consumers = matches.filter((m) => m.role === "consumer");
195
+ const references = matches.filter((m) => m.role === "reference");
196
+ let section = "\n## Integration Contracts\n\nThis story touches systems with upstream/downstream dependencies:\n";
197
+ if (producers.length > 0) {
198
+ section += "\n**Produces (writes/creates/returns):**\n";
199
+ for (const p of producers.slice(0, 10)) {
200
+ section += `- \`${p.file}:${p.line}\` — ${p.content}\n`;
201
+ }
202
+ }
203
+ if (consumers.length > 0) {
204
+ section += "\n**Consumed by (reads/validates/imports):**\n";
205
+ for (const c of consumers.slice(0, 10)) {
206
+ section += `- \`${c.file}:${c.line}\` — ${c.content}\n`;
207
+ }
208
+ }
209
+ if (references.length > 0 && producers.length + consumers.length < 5) {
210
+ section += "\n**Other references:**\n";
211
+ for (const r of references.slice(0, 5)) {
212
+ section += `- \`${r.file}:${r.line}\` — ${r.content}\n`;
213
+ }
214
+ }
215
+ section += "\n> **Note:** This section is informational. Review these contracts before writing your test plan.\n";
216
+ return section;
217
+ }
101
218
  const LANE_TITLE = {
102
219
  backlog: "Backlog",
103
220
  icebox: "Icebox",
@@ -153,6 +270,10 @@ const normalizeBoardSlug = (value) => {
153
270
  let cachedBoardId = null;
154
271
  let cachedBoardSlug = null;
155
272
  const resolveBoardId = async () => {
273
+ // Prefer runtime selection from select_board tool
274
+ const runtimeId = getRuntimeBoardId();
275
+ if (runtimeId)
276
+ return runtimeId;
156
277
  const slug = normalizeBoardSlug(BOARD_SLUG);
157
278
  if (!slug)
158
279
  return null;
@@ -202,6 +323,7 @@ const fetchFeatureConfig = async () => {
202
323
  aiTools: true,
203
324
  teamDecisions: false,
204
325
  ragKnowledgeBase: false,
326
+ useDeliveredState: true,
205
327
  },
206
328
  source: {
207
329
  balancedTeam: "error-fallback",
@@ -210,6 +332,7 @@ const fetchFeatureConfig = async () => {
210
332
  aiTools: "error-fallback",
211
333
  teamDecisions: "error-fallback",
212
334
  ragKnowledgeBase: "error-fallback",
335
+ useDeliveredState: "error-fallback",
213
336
  },
214
337
  };
215
338
  }
@@ -336,7 +459,7 @@ const formatDorWarnings = (boardSettings, dorChecklist) => {
336
459
  };
337
460
  const fetchStories = async () => {
338
461
  const boardId = await resolveBoardId();
339
- const slug = normalizeBoardSlug(BOARD_SLUG);
462
+ const slug = getActiveBoardSlug(BOARD_SLUG) ?? normalizeBoardSlug(BOARD_SLUG);
340
463
  const isMainBoard = slug === "main";
341
464
  // For the "main" board, fetch ALL stories (no boardId filter) then filter
342
465
  // client-side. This matches the frontend behavior: legacy stories have
@@ -354,7 +477,7 @@ const fetchStories = async () => {
354
477
  };
355
478
  const fetchEpics = async () => {
356
479
  const boardId = await resolveBoardId();
357
- const slug = normalizeBoardSlug(BOARD_SLUG);
480
+ const slug = getActiveBoardSlug(BOARD_SLUG) ?? normalizeBoardSlug(BOARD_SLUG);
358
481
  const isMainBoard = slug === "main";
359
482
  const response = await client.get("/epics", {
360
483
  params: boardId && !isMainBoard ? { boardId } : undefined,
@@ -434,6 +557,55 @@ const createServer = () => {
434
557
  const teamDecisionsEnabled = featureConfig.features.teamDecisions;
435
558
  const ragKnowledgeBaseEnabled = featureConfig.features.ragKnowledgeBase;
436
559
  const baseTools = [
560
+ {
561
+ name: "list_boards",
562
+ description: "List all boards in the workspace. Returns id, name, slug, and story count for each board. Marks the currently selected board.",
563
+ inputSchema: {
564
+ type: "object",
565
+ properties: {
566
+ includeArchived: {
567
+ type: "boolean",
568
+ description: "Include archived boards (default false)",
569
+ },
570
+ },
571
+ },
572
+ },
573
+ {
574
+ name: "create_board",
575
+ description: "Create a new board in the workspace. Auto-generates slug from name if not provided. Max 3 active boards per tenant.",
576
+ inputSchema: {
577
+ type: "object",
578
+ properties: {
579
+ name: {
580
+ type: "string",
581
+ description: "Name for the new board",
582
+ },
583
+ description: {
584
+ type: "string",
585
+ description: "Optional description",
586
+ },
587
+ slug: {
588
+ type: "string",
589
+ description: "Optional custom slug (auto-generated from name if omitted)",
590
+ },
591
+ },
592
+ required: ["name"],
593
+ },
594
+ },
595
+ {
596
+ name: "select_board",
597
+ 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.",
598
+ inputSchema: {
599
+ type: "object",
600
+ properties: {
601
+ slug: {
602
+ type: "string",
603
+ description: "Slug of the board to select (case-insensitive)",
604
+ },
605
+ },
606
+ required: ["slug"],
607
+ },
608
+ },
437
609
  {
438
610
  name: "get_feature_config",
439
611
  description: "Get the feature configuration for this board/workspace. Returns enabled features, team profile, and smart defaults context.",
@@ -476,52 +648,8 @@ const createServer = () => {
476
648
  },
477
649
  {
478
650
  name: "create_story",
479
- description: "Create a new story on the Elixium board",
480
- inputSchema: {
481
- type: "object",
482
- properties: {
483
- title: { type: "string", description: "Title of the story" },
484
- description: {
485
- type: "string",
486
- description: "Description of the story",
487
- },
488
- acceptanceCriteria: {
489
- type: "string",
490
- description: "Acceptance criteria in Given/When/Then format",
491
- },
492
- lane: {
493
- type: "string",
494
- description: "Lane to add the story to (case-insensitive)",
495
- enum: [
496
- "Backlog",
497
- "Icebox",
498
- "Current",
499
- "Done",
500
- "BACKLOG",
501
- "ICEBOX",
502
- "CURRENT",
503
- "DONE",
504
- ],
505
- },
506
- points: {
507
- type: "number",
508
- description: "Points (0, 1, 2, 3, 5, 8)",
509
- },
510
- requester: {
511
- type: "string",
512
- description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
513
- },
514
- epicId: {
515
- type: "string",
516
- description: "Epic ID to link this story to (optional). Use list_epics to find epic IDs.",
517
- },
518
- testPlan: {
519
- type: "string",
520
- description: "Markdown test plan describing test strategy (optional). Sets the test_plan field without changing workflow_stage.",
521
- },
522
- },
523
- required: ["title"],
524
- },
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,
525
653
  },
526
654
  {
527
655
  name: "get_iteration_context",
@@ -600,58 +728,38 @@ const createServer = () => {
600
728
  },
601
729
  {
602
730
  name: "update_story",
603
- description: "Update fields on an existing story",
604
- inputSchema: {
605
- type: "object",
606
- properties: {
607
- storyId: { type: "string", description: "ID of the story" },
608
- title: { type: "string", description: "Updated title" },
609
- description: { type: "string", description: "Updated description" },
610
- lane: {
611
- type: "string",
612
- description: "Lane to move the story to (case-insensitive)",
613
- enum: [
614
- "Backlog",
615
- "Icebox",
616
- "Current",
617
- "Done",
618
- "BACKLOG",
619
- "ICEBOX",
620
- "CURRENT",
621
- "DONE",
622
- ],
623
- },
624
- points: {
625
- type: "number",
626
- description: "Updated points (0, 1, 2, 3, 5, 8)",
627
- },
628
- state: {
629
- type: "string",
630
- description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
631
- },
632
- outcome_summary: {
633
- type: "string",
634
- description: "Learning outcome summary",
635
- },
636
- acceptanceCriteria: {
637
- type: "string",
638
- description: "Acceptance criteria in Given/When/Then format",
639
- },
640
- sortOrder: {
641
- type: "number",
642
- description: "Sort order within the lane (lower = higher priority)",
643
- },
644
- epicId: {
645
- type: "string",
646
- description: "Epic ID to link this story to. Set to empty string to unlink.",
647
- },
648
- testPlan: {
649
- type: "string",
650
- description: "Markdown test plan. Updates test_plan field without changing workflow_stage.",
651
- },
652
- },
653
- required: ["storyId"],
654
- },
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,
655
763
  },
656
764
  {
657
765
  name: "prepare_implementation",
@@ -757,6 +865,14 @@ const createServer = () => {
757
865
  type: "boolean",
758
866
  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
867
  },
868
+ skipGates: {
869
+ type: "boolean",
870
+ description: "If true, bypasses the test plan approval gate. For hotfixes/emergencies only. Bypass is recorded in the story audit trail.",
871
+ },
872
+ skipReason: {
873
+ type: "string",
874
+ description: "Reason for bypassing gates (e.g., 'hotfix for production outage'). Recorded in the audit trail.",
875
+ },
760
876
  },
761
877
  required: ["storyId"],
762
878
  },
@@ -784,6 +900,20 @@ const createServer = () => {
784
900
  required: ["storyId", "testPlan"],
785
901
  },
786
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
+ },
787
917
  {
788
918
  name: "approve_tests",
789
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.",
@@ -974,7 +1104,7 @@ const createServer = () => {
974
1104
  try {
975
1105
  const toolName = request.params.name;
976
1106
  // Check TDD workflow tools
977
- 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"];
978
1108
  if (tddWorkflowTools.includes(toolName)) {
979
1109
  const enabled = await isTddWorkflowEnabled();
980
1110
  if (!enabled) {
@@ -1046,6 +1176,50 @@ const createServer = () => {
1046
1176
  }
1047
1177
  }
1048
1178
  switch (toolName) {
1179
+ case "list_boards": {
1180
+ const args = request.params.arguments;
1181
+ const result = await listBoards(client, {
1182
+ includeArchived: args?.includeArchived,
1183
+ });
1184
+ return {
1185
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1186
+ };
1187
+ }
1188
+ case "create_board": {
1189
+ const args = request.params.arguments;
1190
+ const board = await createBoard(client, {
1191
+ name: args.name,
1192
+ description: args.description,
1193
+ slug: args.slug,
1194
+ });
1195
+ // Invalidate feature config cache since board list changed
1196
+ cachedFeatureConfig = null;
1197
+ return {
1198
+ content: [
1199
+ {
1200
+ type: "text",
1201
+ 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.`,
1202
+ },
1203
+ ],
1204
+ };
1205
+ }
1206
+ case "select_board": {
1207
+ const args = request.params.arguments;
1208
+ const board = await selectBoard(client, args.slug);
1209
+ // Invalidate caches so subsequent calls use new board context
1210
+ cachedFeatureConfig = null;
1211
+ cachedBoardId = null;
1212
+ cachedBoardSlug = null;
1213
+ cachedLaneStyle = null;
1214
+ return {
1215
+ content: [
1216
+ {
1217
+ type: "text",
1218
+ 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.`,
1219
+ },
1220
+ ],
1221
+ };
1222
+ }
1049
1223
  case "get_feature_config": {
1050
1224
  const config = await fetchFeatureConfig();
1051
1225
  const formattedConfig = `
@@ -1321,6 +1495,134 @@ ${config.infrastructureProfile?.provider ? `- Provider: ${config.infrastructureP
1321
1495
  ],
1322
1496
  };
1323
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
+ }
1324
1626
  case "prepare_implementation": {
1325
1627
  const args = request.params.arguments;
1326
1628
  const { storyId } = args;
@@ -1402,6 +1704,17 @@ ${config.infrastructureProfile?.provider ? `- Provider: ${config.infrastructureP
1402
1704
  }).join("\n");
1403
1705
  knowledgeSection = `\n## Related Knowledge\n${formatted}\n`;
1404
1706
  }
1707
+ // Scan codebase for integration contracts (non-blocking)
1708
+ let contractsSection = "";
1709
+ try {
1710
+ const keywords = extractKeywords(story.title, story.description || "");
1711
+ const cwd = process.cwd();
1712
+ const contractMatches = scanCodebaseForContracts(keywords, cwd);
1713
+ contractsSection = formatContractsSection(contractMatches);
1714
+ }
1715
+ catch {
1716
+ contractsSection = "\n## Integration Contracts\nContract scanning unavailable.\n";
1717
+ }
1405
1718
  const formattedBrief = `
1406
1719
  # Implementation Brief: ${story.title}
1407
1720
 
@@ -1414,7 +1727,7 @@ ${acceptanceCriteria}
1414
1727
  ## Assumptions
1415
1728
  Here are the assumptions I think we’re testing:
1416
1729
  ${assumptions}
1417
- ${decisionsSection}${knowledgeSection}
1730
+ ${decisionsSection}${knowledgeSection}${contractsSection}
1418
1731
  ${formatTeamContext(teamConfig)}
1419
1732
  ${formatDorDodSection(boardSettings, story.dorChecklist, story.dodChecklist)}
1420
1733
  ## Proposal
@@ -1428,7 +1741,7 @@ Here’s the smallest change that will validate it:
1428
1741
  // TDD Workflow Handlers
1429
1742
  case "start_story": {
1430
1743
  const args = request.params.arguments;
1431
- const { storyId, branchPrefix, trunkBased, autoMerge } = args;
1744
+ const { storyId, branchPrefix, trunkBased, autoMerge, skipGates, skipReason } = args;
1432
1745
  if (!storyId) {
1433
1746
  throw new Error("storyId is required");
1434
1747
  }
@@ -1439,6 +1752,8 @@ Here’s the smallest change that will validate it:
1439
1752
  branchPrefix: branchPrefix || "feat",
1440
1753
  trunkBased,
1441
1754
  autoMerge,
1755
+ ...(skipGates && { skipGates: true }),
1756
+ ...(skipReason && { skipReason }),
1442
1757
  }),
1443
1758
  fetchFeatureConfig(),
1444
1759
  fetchBoardSettings(),
@@ -1448,6 +1763,9 @@ Here’s the smallest change that will validate it:
1448
1763
  const dorWarnings = formatDorWarnings(boardSettings, result.dorChecklist);
1449
1764
  const isTrunk = result.trunkBased;
1450
1765
  const isAutoMerge = result.autoMerge;
1766
+ const workflowModelLine = formatWorkflowModelLine({
1767
+ useDeliveredState: teamConfig.features.useDeliveredState,
1768
+ });
1451
1769
  let formattedResult;
1452
1770
  if (isTrunk && !isAutoMerge) {
1453
1771
  formattedResult = `
@@ -1456,6 +1774,7 @@ Here’s the smallest change that will validate it:
1456
1774
  **Mode:** Direct to main (trunk-based development)
1457
1775
  **Feature Flag:** \`${result.featureFlagName || "none"}\`
1458
1776
  **Workflow Stage:** ${result.workflow_stage}
1777
+ **Workflow Model:** ${workflowModelLine}
1459
1778
 
1460
1779
  ## Acceptance Criteria
1461
1780
  ${result.acceptance_criteria || "No specific AC provided."}
@@ -1482,6 +1801,7 @@ ${dorWarnings}`;
1482
1801
  **Branch:** \`${result.branch}\`
1483
1802
  **Feature Flag:** \`${result.featureFlagName || "none"}\`
1484
1803
  **Workflow Stage:** ${result.workflow_stage}
1804
+ **Workflow Model:** ${workflowModelLine}
1485
1805
 
1486
1806
  ## Acceptance Criteria
1487
1807
  ${result.acceptance_criteria || "No specific AC provided."}
@@ -1506,6 +1826,7 @@ ${dorWarnings}`;
1506
1826
 
1507
1827
  **Branch:** \`${result.branch}\`
1508
1828
  **Workflow Stage:** ${result.workflow_stage}
1829
+ **Workflow Model:** ${workflowModelLine}
1509
1830
 
1510
1831
  ## Acceptance Criteria
1511
1832
  ${result.acceptance_criteria || "No specific AC provided."}
@@ -1612,6 +1933,30 @@ ${(result.test_file_paths || []).map((p) => `- \`${p}\``).join("\n") || "No test
1612
1933
  ${result.message}
1613
1934
 
1614
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}
1615
1960
  `;
1616
1961
  return {
1617
1962
  content: [{ type: "text", text: formattedResult.trim() }],
@@ -1648,13 +1993,20 @@ ${result.message}
1648
1993
  throw new Error("storyId is required");
1649
1994
  }
1650
1995
  assertUUID(storyId, "storyId");
1651
- const response = await client.post(`/stories/${storyId}/submit-review`, {
1652
- commitHash,
1653
- testResults,
1654
- implementationNotes,
1655
- });
1996
+ const [response, submitFeatureConfig] = await Promise.all([
1997
+ client.post(`/stories/${storyId}/submit-review`, {
1998
+ commitHash,
1999
+ testResults,
2000
+ implementationNotes,
2001
+ }),
2002
+ fetchFeatureConfig(),
2003
+ ]);
1656
2004
  const result = response.data;
1657
2005
  const isAutoMerge = result.autoMerge;
2006
+ const submitWorkflowModelLine = formatWorkflowModelLine({
2007
+ useDeliveredState: submitFeatureConfig.features.useDeliveredState,
2008
+ state: result.state,
2009
+ });
1658
2010
  let autoMergeSection = "";
1659
2011
  if (isAutoMerge && result.autoMergeInstructions) {
1660
2012
  const instr = result.autoMergeInstructions;
@@ -1688,6 +2040,7 @@ git branch -d ${instr.sourceBranch}
1688
2040
  **Status:** ${result.status}
1689
2041
  **Workflow Stage:** ${result.workflow_stage}
1690
2042
  **State:** ${result.state}
2043
+ **Workflow Model:** ${submitWorkflowModelLine}
1691
2044
  ${result.trunkBased ? `**Mode:** Trunk-based${isAutoMerge ? " + Auto-merge" : ""}` : ""}
1692
2045
  ${result.featureFlagName ? `**Feature Flag:** \`${result.featureFlagName}\`` : ""}
1693
2046
 
@@ -2103,7 +2456,17 @@ ${depList || "No dependencies detected."}
2103
2456
  if (error.response?.status) {
2104
2457
  errorText += ` (HTTP ${error.response.status})`;
2105
2458
  }
2106
- if (error.response?.data) {
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) {
2107
2470
  const data = error.response.data;
2108
2471
  const detail = typeof data === "string" ? data : JSON.stringify(data);
2109
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.6",
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",
@@ -23,7 +23,7 @@
23
23
  "dev": "ts-node src/index.ts"
24
24
  },
25
25
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^0.6.0",
26
+ "@modelcontextprotocol/sdk": "^1.25.2",
27
27
  "axios": "^1.6.0",
28
28
  "zod": "^3.22.0"
29
29
  },