@idea-base/mcp-server 2.0.0 → 2.1.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +249 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idea-base/mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "MCP Server for IDEA Base - AI-powered project management. Integrates with Claude Code, Cursor, and Windsurf.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -433,12 +433,34 @@ const tools = [
433
433
  },
434
434
  assignee_user_id: {
435
435
  type: 'string',
436
- description: 'User ID to assign the task to. Must be a member of the same account. Omit to leave the task unassigned.',
436
+ description: 'User ID to assign the task to. Must be a member of the same account. Omit to leave the task unassigned. For a SECOND (or third, ...) assignee, do not call this again — use add_assignee after creation, since this field only ever sets a single owner at create time.',
437
437
  },
438
438
  parent_task_id: {
439
439
  type: 'number',
440
440
  description: 'Make this a SUBTASK of the given task. The parent must be in the same project, and a subtask cannot itself have subtasks (one level only). A parent with subtasks takes its status from them: any subtask in progress makes the parent in progress, and all subtasks done makes it ready to complete.',
441
441
  },
442
+ verification_mode: {
443
+ type: 'string',
444
+ enum: ['manual', 'ai_review', 'ci_required', 'all'],
445
+ description: 'Gate this task\'s own completion. "manual" (default) allows a plain status change to done. "ai_review" or "all" BLOCK marking the task done (403 VERIFICATION_REQUIRED) until ai_completion_score reaches 0.8 — run "Verify with AI" first. "ci_required" or "all" BLOCK it until a PR is linked (github_pr_url) with passing CI. Setting this arms a real check against your own future attempt to close the task.',
446
+ },
447
+ github_pr_url: {
448
+ type: 'string',
449
+ description: 'URL of the GitHub Pull Request implementing this task. Used to fetch the PR diff for AI verification and to match incoming CI status (check_run/check_suite/workflow_run webhooks) back to this task.',
450
+ },
451
+ sort_order: {
452
+ type: 'number',
453
+ description: 'Position among the project\'s tasks (lower sorts first). NOT wired on the REST create path — POST /api/projects/:id/tasks always assigns MAX(sort_order)+1 and ignores this field on create. Set it after creation with update_task instead.',
454
+ },
455
+ is_milestone: {
456
+ type: 'boolean',
457
+ description: 'Mark this task as a milestone, drawn differently on the Gantt view. NOT wired on the REST create path — POST /api/projects/:id/tasks does not accept this field; the column is left at its default (0). Set it after creation with update_task instead.',
458
+ },
459
+ required_ci_checks: {
460
+ type: 'array',
461
+ items: { type: 'string' },
462
+ description: 'Which named CI checks (matched case-insensitively as a substring of the check name) must pass for "ci_required"/"all" verification_mode to consider CI satisfied — e.g. ["build","test"]. Read by GET /api/tasks/:id/ci-status\'s resolveRequiredChecks (app/functions/api/tasks/[id]/ci-status.js:13-32), NOT by the GitHub webhook handler, which sets ci_status unconditionally from whichever single check/workflow event just arrived. Omit to leave unset (falls back to the project\'s own required_ci_checks, then to requiring every check). NOT wired on the REST create path — POST /api/projects/:id/tasks does not accept this field. Set it after creation with update_task instead.',
463
+ },
442
464
  },
443
465
  required: ['project_id', 'title'],
444
466
  },
@@ -483,7 +505,29 @@ const tools = [
483
505
  },
484
506
  assignee_user_id: {
485
507
  type: 'string',
486
- description: 'User ID to assign the task to, replacing any existing assignee. Must be a member of the same account. Pass an empty string to unassign.',
508
+ description: 'User ID to assign the task to, REPLACING THE WHOLE assignee set (every non-active assignment row is deleted, active workers spared) — use this only when you want exactly one owner. Must be a member of the same account. Pass an empty string to unassign everyone (active workers still spared). To ADD a second assignee without touching the first, use add_assignee instead; to remove just one, use remove_assignee.',
509
+ },
510
+ verification_mode: {
511
+ type: 'string',
512
+ enum: ['manual', 'ai_review', 'ci_required', 'all'],
513
+ description: 'Gate this task\'s own completion. "manual" (default) allows a plain status change to done. "ai_review" or "all" BLOCK marking the task done (403 VERIFICATION_REQUIRED) until ai_completion_score reaches 0.8 — run "Verify with AI" first. "ci_required" or "all" BLOCK it until a PR is linked (github_pr_url) with passing CI. Setting this arms a real check against your own future attempt to close the task.',
514
+ },
515
+ github_pr_url: {
516
+ type: 'string',
517
+ description: 'URL of the GitHub Pull Request implementing this task. Used to fetch the PR diff for AI verification and to match incoming CI status back to this task. Pass an empty string to clear it.',
518
+ },
519
+ sort_order: {
520
+ type: 'number',
521
+ description: 'Position among the project\'s tasks (lower sorts first). Drives the order returned by list_tasks and the board/list views.',
522
+ },
523
+ is_milestone: {
524
+ type: 'boolean',
525
+ description: 'Mark this task as a milestone, drawn differently on the Gantt view. Pass false to unmark it.',
526
+ },
527
+ required_ci_checks: {
528
+ type: 'array',
529
+ items: { type: 'string' },
530
+ description: 'Which named CI checks (matched case-insensitively as a substring of the check name) must pass for "ci_required"/"all" verification_mode to consider CI satisfied — e.g. ["build","test"]. Read by GET /api/tasks/:id/ci-status\'s resolveRequiredChecks (app/functions/api/tasks/[id]/ci-status.js:13-32), NOT by the GitHub webhook handler, which sets ci_status unconditionally from whichever single check/workflow event just arrived regardless of this field. Stored as a JSON array. Pass an empty array to require every check; this tool has no way to clear the column back to "inherit from project" (the REST API reserves that for an empty string, which this array-typed field cannot send).',
487
531
  },
488
532
  blocked_by: {
489
533
  type: 'array',
@@ -521,6 +565,90 @@ const tools = [
521
565
  required: ['task_id', 'status'],
522
566
  },
523
567
  },
568
+ {
569
+ name: 'add_assignee',
570
+ description: 'Assign a user to a task WITHOUT disturbing anyone already assigned. task_assignments holds one row per (task_id, user_id) — POST /api/tasks/:id/assignments upserts exactly that one row and leaves every other assignee alone, unlike update_task\'s assignee_user_id (which REPLACES the whole set — use that only when you actually want a single owner). This is the fix for the defect where a second MCP assignment silently dropped the first.\n\nIdempotent and is_active-safe: this checks the task\'s current assignees first, and if the user already holds a row (assigned OR actively working) it does nothing and says so, rather than re-POSTing. That matters because the REST endpoint\'s upsert OVERWRITES is_active on conflict — a blind re-assign of someone with a running timer (is_active=1, set by start_working) would silently stop their clock. Skipping the no-op re-POST is what keeps assignment and active-work presence independent.\n\nRejects a user who is not a member of this account (404, surfaced as an error) rather than creating a dangling assignment.',
571
+ inputSchema: {
572
+ type: 'object',
573
+ properties: {
574
+ task_id: {
575
+ type: 'number',
576
+ description: 'The ID of the task to assign the user to.',
577
+ },
578
+ user_id: {
579
+ type: 'string',
580
+ description: 'User ID to add as an assignee. Must be a member of the same account.',
581
+ },
582
+ },
583
+ required: ['task_id', 'user_id'],
584
+ },
585
+ },
586
+ {
587
+ name: 'remove_assignee',
588
+ description: 'Remove exactly one user\'s assignment from a task, leaving every other assignee untouched. DELETE /api/tasks/:id/assignments?user_id=... removes that one task_assignments row outright (both the assignment and any is_active flag on it) — this is a full unassign, not stop_working\'s "still assigned, no longer active" (use stop_working to end a work session without unassigning).',
589
+ inputSchema: {
590
+ type: 'object',
591
+ properties: {
592
+ task_id: {
593
+ type: 'number',
594
+ description: 'The ID of the task to remove the assignment from.',
595
+ },
596
+ user_id: {
597
+ type: 'string',
598
+ description: 'User ID to remove from the task\'s assignees.',
599
+ },
600
+ },
601
+ required: ['task_id', 'user_id'],
602
+ },
603
+ },
604
+ {
605
+ name: 'verify_task',
606
+ description: 'Run AI verification: grades this task\'s acceptance_criteria against the diff of its linked PR (a Haiku relevance pre-screen, then a Sonnet deep review) and PERSISTS the result to ai_completion_score / ai_completion_notes. This is exactly what update_task_status checks when the task\'s verification_mode is "ai_review" or "all" — call this to satisfy that gate yourself rather than repeatedly hitting 403 VERIFICATION_REQUIRED with no way through it.\n\nCOSTS MONEY — DO NOT LOOP THIS. Every call is metered via recordAIUsage and recorded to ai_generations (Sonnet, large PR-diff context) for audit. It runs FREE while your account is within its plan\'s included monthly verification cap; once over that cap each call is billed as AI credit overage — 2 credits per call on this account. Calling this repeatedly hoping for a higher score spends real credits for no guaranteed gain; if a genuine score lands below the 0.8 gate, that is a finding about the work, not a reason to retry.\n\nGRADES THE DIFF, NOT THE RUNNING SYSTEM. A high score means the PR\'s code changes look like they satisfy the criteria on paper — it is NOT proof the deployed/running behavior actually works. Never report this score as end-to-end verification; that is a human\'s job or an independent task-verify sub-agent\'s, not this tool\'s.\n\nRequires the task to already have non-empty acceptance_criteria (set via update_task) and a way to see the work: either the task\'s own github_pr_url (set via update_task) or a pr_url/work_description passed here. Fails with a clear message naming what is missing rather than grading an empty contract — refusal is free, no credits spent.',
607
+ inputSchema: {
608
+ type: 'object',
609
+ properties: {
610
+ task_id: {
611
+ type: 'number',
612
+ description: 'The ID of the task to verify. Must already have non-empty acceptance_criteria.',
613
+ },
614
+ pr_url: {
615
+ type: 'string',
616
+ description: 'GitHub PR URL to grade against, for this call only — does not overwrite the task\'s stored github_pr_url. Omit to use the task\'s own linked PR.',
617
+ },
618
+ work_description: {
619
+ type: 'string',
620
+ description: 'Free-text description of the completed work to grade against. Used ONLY as a fallback when no PR diff is available (no pr_url given here and the task has no github_pr_url); ignored whenever a PR diff can be fetched.',
621
+ },
622
+ },
623
+ required: ['task_id'],
624
+ },
625
+ },
626
+ {
627
+ name: 'record_verification_feedback',
628
+ description: 'Record whether the most recent verify_task verdict on this task was accurate — calibration data, not a correction. Attaches to the latest ai_generations row of type completion_review for this task, so verify_task must have run at least once first. Does NOT change the stored ai_completion_score or re-run any model — it costs nothing (no Anthropic call, just an audit-log row) and is safe to call as often as useful.',
629
+ inputSchema: {
630
+ type: 'object',
631
+ properties: {
632
+ task_id: {
633
+ type: 'number',
634
+ description: 'The ID of the task whose most recent verification you are giving feedback on.',
635
+ },
636
+ agreed: {
637
+ type: 'boolean',
638
+ description: 'Whether you agree the AI verdict (score + summary) was an accurate read of the work.',
639
+ },
640
+ actual_score: {
641
+ type: 'number',
642
+ description: 'Optional: the score (0.0-1.0) you believe was actually correct, if you disagree with the AI\'s score.',
643
+ },
644
+ notes: {
645
+ type: 'string',
646
+ description: 'Optional: why you agreed or disagreed.',
647
+ },
648
+ },
649
+ required: ['task_id', 'agreed'],
650
+ },
651
+ },
524
652
  {
525
653
  name: 'log_time',
526
654
  description: 'Log time spent on a task. This triggers a real-time notification showing time was logged.',
@@ -897,7 +1025,12 @@ const toolHandlers = {
897
1025
  };
898
1026
  },
899
1027
 
900
- async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, parent_task_id }) {
1028
+ async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, parent_task_id, verification_mode, github_pr_url, sort_order, is_milestone, required_ci_checks }) {
1029
+ // sort_order, is_milestone and required_ci_checks are forwarded for parity
1030
+ // with update_task, but POST /api/projects/:id/tasks does not destructure
1031
+ // any of the three — it always assigns MAX(sort_order)+1 and leaves
1032
+ // is_milestone/required_ci_checks at their column defaults. Sending them
1033
+ // here is a silent no-op on create; use update_task right after to set them.
901
1034
  const task = await apiRequest(`/projects/${project_id}/tasks`, {
902
1035
  method: 'POST',
903
1036
  body: JSON.stringify({
@@ -910,6 +1043,11 @@ const toolHandlers = {
910
1043
  due_date,
911
1044
  assignee_user_id,
912
1045
  parent_task_id,
1046
+ verification_mode,
1047
+ github_pr_url,
1048
+ sort_order,
1049
+ is_milestone,
1050
+ required_ci_checks,
913
1051
  }),
914
1052
  });
915
1053
  return {
@@ -922,11 +1060,13 @@ const toolHandlers = {
922
1060
  };
923
1061
  },
924
1062
 
925
- async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, blocked_by, blocked_reason }) {
1063
+ async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, blocked_by, blocked_reason, verification_mode, github_pr_url, sort_order, is_milestone, required_ci_checks }) {
926
1064
  // Only forward what the caller actually named. An absent key leaves the
927
- // field alone; an explicitly empty string is how a date or the assignee is
928
- // cleared, and blocked_by is a REPLACE so an explicit [] must reach the
929
- // API — which is why these are `!== undefined` rather than truthiness.
1065
+ // field alone; an explicitly empty string is how a date, the assignee or
1066
+ // the PR link is cleared, and blocked_by is a REPLACE so an explicit []
1067
+ // must reach the API — which is why these are `!== undefined` rather than
1068
+ // truthiness. is_milestone follows the same rule: `false` is a real value,
1069
+ // not an absence, and must still reach the API.
930
1070
  const updates = {};
931
1071
  if (title !== undefined) updates.title = title;
932
1072
  if (description !== undefined) updates.description = description;
@@ -938,6 +1078,11 @@ const toolHandlers = {
938
1078
  if (assignee_user_id !== undefined) updates.assignee_user_id = assignee_user_id;
939
1079
  if (blocked_by !== undefined) updates.blocked_by = blocked_by;
940
1080
  if (blocked_reason !== undefined) updates.blocked_reason = blocked_reason;
1081
+ if (verification_mode !== undefined) updates.verification_mode = verification_mode;
1082
+ if (github_pr_url !== undefined) updates.github_pr_url = github_pr_url;
1083
+ if (sort_order !== undefined) updates.sort_order = sort_order;
1084
+ if (is_milestone !== undefined) updates.is_milestone = is_milestone;
1085
+ if (required_ci_checks !== undefined) updates.required_ci_checks = required_ci_checks;
941
1086
 
942
1087
  const task = await apiRequest(`/tasks/${task_id}`, {
943
1088
  method: 'PUT',
@@ -968,6 +1113,103 @@ const toolHandlers = {
968
1113
  };
969
1114
  },
970
1115
 
1116
+ async add_assignee({ task_id, user_id }) {
1117
+ // Check first so an already-assigned (or already-active) user is never
1118
+ // re-POSTed: the REST upsert overwrites is_active on conflict, and a
1119
+ // blind re-assign would silently stop that person's running timer. See
1120
+ // POST /api/tasks/:id/assignments' ON CONFLICT clause.
1121
+ const current = await apiRequest(`/tasks/${task_id}/assignments`);
1122
+ const assigned = current.assigned || [];
1123
+ const active = current.active_workers || [];
1124
+ const existing = [...assigned, ...active].find(a => a.user_id === user_id);
1125
+
1126
+ if (existing) {
1127
+ const state = active.some(a => a.user_id === user_id) ? 'actively working' : 'assigned';
1128
+ return {
1129
+ content: [
1130
+ {
1131
+ type: 'text',
1132
+ text: `${user_id} is already ${state} on task ${task_id} — no change made. Other assignees are untouched.`,
1133
+ },
1134
+ ],
1135
+ };
1136
+ }
1137
+
1138
+ await apiRequest(`/tasks/${task_id}/assignments`, {
1139
+ method: 'POST',
1140
+ body: JSON.stringify({ user_id }),
1141
+ });
1142
+
1143
+ return {
1144
+ content: [
1145
+ {
1146
+ type: 'text',
1147
+ text: `Assigned ${user_id} to task ${task_id}. Existing assignees, if any, were left in place.`,
1148
+ },
1149
+ ],
1150
+ };
1151
+ },
1152
+
1153
+ async remove_assignee({ task_id, user_id }) {
1154
+ await apiRequest(`/tasks/${task_id}/assignments?user_id=${encodeURIComponent(user_id)}`, {
1155
+ method: 'DELETE',
1156
+ });
1157
+
1158
+ return {
1159
+ content: [
1160
+ {
1161
+ type: 'text',
1162
+ text: `Removed ${user_id} from task ${task_id}'s assignees. Other assignees are untouched.`,
1163
+ },
1164
+ ],
1165
+ };
1166
+ },
1167
+
1168
+ async verify_task({ task_id, pr_url, work_description }) {
1169
+ const body = {};
1170
+ if (pr_url !== undefined) body.pr_url = pr_url;
1171
+ if (work_description !== undefined) body.work_description = work_description;
1172
+
1173
+ const result = await apiRequest(`/tasks/${task_id}/verify`, {
1174
+ method: 'POST',
1175
+ body: JSON.stringify(body),
1176
+ });
1177
+
1178
+ const billingNote = result.billing?.type === 'ai_credit_overage'
1179
+ ? `Billed ${result.billing.credits_charged} AI credit(s) as overage (over your plan's included verification cap).`
1180
+ : 'Free platform feature — within your plan\'s included verification cap.';
1181
+
1182
+ return {
1183
+ content: [
1184
+ {
1185
+ type: 'text',
1186
+ text: `Verification score for task ${task_id}: ${result.score} (confidence: ${result.confidence}). ${billingNote} ` +
1187
+ `This grades the PR diff against the acceptance criteria, not the running system.\n\n${JSON.stringify(result, null, 2)}`,
1188
+ },
1189
+ ],
1190
+ };
1191
+ },
1192
+
1193
+ async record_verification_feedback({ task_id, agreed, actual_score, notes }) {
1194
+ const body = { agreed };
1195
+ if (actual_score !== undefined) body.actual_score = actual_score;
1196
+ if (notes !== undefined) body.notes = notes;
1197
+
1198
+ await apiRequest(`/tasks/${task_id}/verify-feedback`, {
1199
+ method: 'POST',
1200
+ body: JSON.stringify(body),
1201
+ });
1202
+
1203
+ return {
1204
+ content: [
1205
+ {
1206
+ type: 'text',
1207
+ text: `Feedback recorded for task ${task_id}'s most recent AI verification (agreed: ${agreed}).`,
1208
+ },
1209
+ ],
1210
+ };
1211
+ },
1212
+
971
1213
  async log_time({ task_id, minutes, notes, date }) {
972
1214
  const entry = await apiRequest(`/tasks/${task_id}/time`, {
973
1215
  method: 'POST',