internaltool-mcp 1.4.0 → 1.5.1

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/index.js +619 -54
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -264,14 +264,30 @@ function registerTaskTools(server, { isAdmin, scopedProjectId }) {
264
264
 
265
265
  server.tool(
266
266
  'move_task',
267
- 'Move a task to a different column. Requires approved README to move from planning to execution columns.',
267
+ 'Move a task to a different column. Requires approved README to move from planning to execution columns. Set confirmed=false first to preview.',
268
268
  {
269
- taskId: z.string().describe("Task's MongoDB ObjectId"),
270
- column: z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done']),
271
- toIndex: z.number().int().min(0).default(0).describe('Position in column (0 = top)'),
269
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
270
+ column: z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done']),
271
+ toIndex: z.number().int().min(0).default(0).describe('Position in column (0 = top)'),
272
+ confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
272
273
  },
273
- async ({ taskId, column, toIndex }) =>
274
- call(() => api.post(`/api/tasks/${taskId}/move`, { column, toIndex }))
274
+ async ({ taskId, column, toIndex, confirmed = false }) => {
275
+ if (!confirmed) {
276
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
277
+ const task = taskRes?.data?.task
278
+ return text({
279
+ preview: {
280
+ action: 'move_task',
281
+ task: task ? { key: task.key, title: task.title, currentColumn: task.column } : { taskId },
282
+ moveTo: column,
283
+ atPosition: toIndex,
284
+ },
285
+ requiresConfirmation: true,
286
+ message: `This will move the task to "${column}". Call move_task again with confirmed=true to proceed.`,
287
+ })
288
+ }
289
+ return call(() => api.post(`/api/tasks/${taskId}/move`, { column, toIndex }))
290
+ }
275
291
  )
276
292
 
277
293
  server.tool(
@@ -286,22 +302,56 @@ function registerTaskTools(server, { isAdmin, scopedProjectId }) {
286
302
  `Pause a task and save your current work context.
287
303
  IMPORTANT: Before calling this, run "git diff HEAD" and "git status" in the terminal to see what has changed.
288
304
  Use that output to write precise summary/remaining/blockers fields — not generic text.
289
- This is the developer's saved mental state; make it detailed enough to resume cold tomorrow.`,
305
+ This is the developer's saved mental state; make it detailed enough to resume cold tomorrow.
306
+
307
+ Set confirmed=false first to preview, then confirmed=true to actually save.`,
290
308
  {
291
309
  taskId: z.string().describe("Task's MongoDB ObjectId"),
292
310
  summary: z.string().optional().describe('What was built/changed — include file names and what was done'),
293
311
  remaining: z.string().optional().describe('Specific next steps to complete this task'),
294
312
  blockers: z.string().optional().describe('Anything blocking progress'),
313
+ confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
295
314
  },
296
- async ({ taskId, summary = '', remaining = '', blockers = '' }) =>
297
- call(() => api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers }))
315
+ async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false }) => {
316
+ if (!confirmed) {
317
+ return text({
318
+ preview: {
319
+ action: 'park_task',
320
+ taskId,
321
+ willSave: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
322
+ },
323
+ requiresConfirmation: true,
324
+ message: 'Review the park note above. Call park_task again with confirmed=true to save it.',
325
+ })
326
+ }
327
+ return call(() => api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers }))
328
+ }
298
329
  )
299
330
 
300
331
  server.tool(
301
332
  'unpark_task',
302
- 'Resume a parked task.',
303
- { taskId: z.string().describe("Task's MongoDB ObjectId") },
304
- async ({ taskId }) => call(() => api.patch(`/api/tasks/${taskId}/unpark`, {}))
333
+ 'Resume a parked task. Set confirmed=false first to preview, then confirmed=true to execute.',
334
+ {
335
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
336
+ confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
337
+ },
338
+ async ({ taskId, confirmed = false }) => {
339
+ if (!confirmed) {
340
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
341
+ const task = taskRes?.data?.task
342
+ return text({
343
+ preview: {
344
+ action: 'unpark_task',
345
+ taskId,
346
+ task: task ? { key: task.key, title: task.title, parkNote: task.parkNote } : null,
347
+ willDo: 'Clear park note and mark task as active again',
348
+ },
349
+ requiresConfirmation: true,
350
+ message: 'Call unpark_task again with confirmed=true to resume this task.',
351
+ })
352
+ }
353
+ return call(() => api.patch(`/api/tasks/${taskId}/unpark`, {}))
354
+ }
305
355
  )
306
356
  }
307
357
 
@@ -398,62 +448,100 @@ Use this when the developer says "wrap up", "end of day", or "I'm done for today
398
448
 
399
449
  server.tool(
400
450
  'kickoff_task',
401
- `Kick off a task — brief the developer and move it to in_progress.
402
- Fetches the full task details plus recent project commits for context.
403
- Returns everything needed to start: task plan, what recently changed in the repo, and the git branch command to run.
451
+ `Kick off a task — read the implementation plan (README) and brief the developer, then move the task to in_progress.
452
+
453
+ The README/implementation plan IS the brief. Always read it fully and present it to the developer
454
+ before they write a single line of code. If no README exists, warn the developer that the plan is
455
+ missing and suggest they write one before starting.
456
+
457
+ Workflow:
458
+ 1. confirmed=false → show the full README brief + subtask checklist so developer can read the plan
459
+ 2. confirmed=true → move task to in_progress, return branch name and recent repo commits
460
+
404
461
  Use this when a developer says "start task", "brief me on", or "what do I need to do for TASK-X".`,
405
462
  {
406
- taskId: z.string().describe("Task's MongoDB ObjectId"),
463
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
464
+ confirmed: z.boolean().optional().default(false).describe('Set true after reading the plan to move the task to in_progress'),
407
465
  },
408
- async ({ taskId }) => {
409
- // Get full task
466
+ async ({ taskId, confirmed = false }) => {
410
467
  const taskRes = await api.get(`/api/tasks/${taskId}`)
411
468
  if (!taskRes?.success) return errorText('Task not found')
412
469
  const task = taskRes.data.task
413
470
 
414
- // Get recent commits from the project
471
+ const suggestedBranch = `feature/${task.key?.toLowerCase()}-${task.title
472
+ .toLowerCase()
473
+ .replace(/[^a-z0-9]+/g, '-')
474
+ .slice(0, 40)}`
475
+
476
+ const hasReadme = !!(task.readmeMarkdown && task.readmeMarkdown.trim().length > 0)
477
+ const subtasks = (task.subtasks || []).map(s => ({
478
+ title: s.title,
479
+ done: s.done,
480
+ status: s.done ? '✅' : '⬜',
481
+ }))
482
+ const subtasksDone = subtasks.filter(s => s.done).length
483
+ const subtasksTotal = subtasks.length
484
+
485
+ // ── Preview: show the full plan before touching anything ──
486
+ if (!confirmed) {
487
+ return text({
488
+ brief: {
489
+ key: task.key,
490
+ title: task.title,
491
+ priority: task.priority,
492
+ description: task.description || '(no description)',
493
+ // The implementation plan — Claude must read and present this to the developer
494
+ implementationPlan: hasReadme
495
+ ? task.readmeMarkdown
496
+ : '⚠️ No implementation plan (README) found for this task. The developer should write one before starting work.',
497
+ subtasks: subtasksTotal > 0
498
+ ? { items: subtasks, progress: `${subtasksDone}/${subtasksTotal} done` }
499
+ : null,
500
+ approvalState: task.approval?.state || 'none',
501
+ },
502
+ meta: {
503
+ currentColumn: task.column,
504
+ willMoveTo: 'in_progress',
505
+ suggestedBranch,
506
+ },
507
+ requiresConfirmation: true,
508
+ message: hasReadme
509
+ ? `Read the implementation plan above carefully, then call kickoff_task again with confirmed=true to start the task.`
510
+ : `⚠️ This task has no implementation plan. Consider writing one first (use update_task with readmeMarkdown), then confirm to start anyway.`,
511
+ })
512
+ }
513
+
514
+ // ── Confirmed: move to in_progress and fetch recent commits ──
415
515
  let recentCommits = []
416
516
  try {
417
- const commitsRes = await api.get(
418
- `/api/projects/${task.project}/github/commits?per_page=10`
419
- )
517
+ const commitsRes = await api.get(`/api/projects/${task.project}/github/commits?per_page=10`)
420
518
  if (commitsRes?.success) recentCommits = commitsRes.data.commits || []
421
519
  } catch { /* GitHub may not be linked */ }
422
520
 
423
- // Move to in_progress
424
521
  let moved = false
425
522
  try {
426
- const moveRes = await api.post(`/api/tasks/${taskId}/move`, {
427
- column: 'in_progress',
428
- toIndex: 0,
429
- })
523
+ const moveRes = await api.post(`/api/tasks/${taskId}/move`, { column: 'in_progress', toIndex: 0 })
430
524
  moved = moveRes?.success ?? false
431
525
  } catch { /* might already be in_progress */ }
432
526
 
433
527
  return text({
434
- task: {
435
- id: task._id,
436
- key: task.key,
437
- title: task.title,
438
- description: task.description,
439
- priority: task.priority,
440
- column: moved ? 'in_progress' : task.column,
441
- readme: task.readmeMarkdown,
442
- subtasks: task.subtasks,
443
- parkNote: task.parkNote,
444
- approval: task.approval,
528
+ started: {
529
+ key: task.key,
530
+ title: task.title,
531
+ priority: task.priority,
532
+ column: moved ? 'in_progress' : task.column,
533
+ subtasks,
445
534
  },
446
- recentCommits: recentCommits.slice(0, 5).map((c) => ({
535
+ implementationPlan: hasReadme ? task.readmeMarkdown : null,
536
+ recentCommits: recentCommits.slice(0, 5).map(c => ({
447
537
  sha: c.sha?.slice(0, 7),
448
538
  message: c.commit?.message?.split('\n')[0],
449
539
  author: c.commit?.author?.name,
450
540
  date: c.commit?.author?.date,
451
541
  })),
452
542
  movedToInProgress: moved,
453
- suggestedBranch: `feature/${task.key?.toLowerCase()}-${task.title
454
- .toLowerCase()
455
- .replace(/[^a-z0-9]+/g, '-')
456
- .slice(0, 40)}`,
543
+ suggestedBranch,
544
+ nextStep: `Use create_branch to create "${suggestedBranch}" on GitHub, then run:\n git fetch origin\n git checkout ${suggestedBranch}`,
457
545
  })
458
546
  }
459
547
  )
@@ -509,14 +597,17 @@ Call this when the developer asks "what have I been neglecting" or "any stale ta
509
597
  'link_pr_to_task',
510
598
  `Link a pull request to a task, post a comment, and move the task to in_review.
511
599
  Use this when a developer pushes a branch and opens a PR.
512
- Keeps the board in sync with git automatically.`,
600
+ Keeps the board in sync with git automatically.
601
+
602
+ Set confirmed=false first to preview, then confirmed=true to execute.`,
513
603
  {
514
604
  taskId: z.string().describe("Task's MongoDB ObjectId"),
515
605
  prUrl: z.string().describe('Full GitHub PR URL'),
516
606
  prTitle: z.string().optional().describe('PR title'),
517
607
  branch: z.string().optional().describe('Branch name'),
608
+ confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
518
609
  },
519
- async ({ taskId, prUrl, prTitle, branch }) => {
610
+ async ({ taskId, prUrl, prTitle, branch, confirmed = false }) => {
520
611
  const commentBody = [
521
612
  `## Pull Request Opened`,
522
613
  `**PR:** [${prTitle || prUrl}](${prUrl})`,
@@ -524,16 +615,23 @@ Keeps the board in sync with git automatically.`,
524
615
  `**Status:** Ready for review`,
525
616
  ].filter(Boolean).join('\n')
526
617
 
527
- // Post comment
528
- const commentRes = await api.post(`/api/tasks/${taskId}/comments`, {
529
- body: commentBody,
530
- })
618
+ if (!confirmed) {
619
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
620
+ const task = taskRes?.data?.task
621
+ return text({
622
+ preview: {
623
+ action: 'link_pr_to_task',
624
+ task: task ? { key: task.key, title: task.title } : { taskId },
625
+ willPost: commentBody,
626
+ willMoveTo: 'in_review',
627
+ },
628
+ requiresConfirmation: true,
629
+ message: 'This will post the comment above and move the task to in_review. Call link_pr_to_task again with confirmed=true to proceed.',
630
+ })
631
+ }
531
632
 
532
- // Move to in_review
533
- const moveRes = await api.post(`/api/tasks/${taskId}/move`, {
534
- column: 'in_review',
535
- toIndex: 0,
536
- })
633
+ const commentRes = await api.post(`/api/tasks/${taskId}/comments`, { body: commentBody })
634
+ const moveRes = await api.post(`/api/tasks/${taskId}/move`, { column: 'in_review', toIndex: 0 })
537
635
 
538
636
  return text({
539
637
  commented: commentRes?.success ?? false,
@@ -677,6 +775,472 @@ function registerGithubTools(server, { scopedProjectId }) {
677
775
  )
678
776
  }
679
777
 
778
+ // ── Git Workflow Tools ─────────────────────────────────────────────────────────
779
+ // All write operations require confirmed=true after showing a preview.
780
+ // This gives developers full visibility before anything is executed.
781
+
782
+ function registerGitWorkflowTools(server, { scopedProjectId } = {}) {
783
+
784
+ // ── list_my_tasks ────────────────────────────────────────────────────────────
785
+ server.tool(
786
+ 'list_my_tasks',
787
+ `List your assigned tasks sorted by priority (critical → high → medium → low).
788
+ Each task includes a suggested next git action so Claude can guide you step by step.
789
+ Use this at the start of a session or when switching tasks.`,
790
+ {
791
+ includeColumns: z.array(
792
+ z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done'])
793
+ ).optional().default(['todo', 'in_progress', 'in_review'])
794
+ .describe('Which columns to include. Default excludes backlog and done.'),
795
+ },
796
+ async ({ includeColumns = ['todo', 'in_progress', 'in_review'] } = {}) => {
797
+ const res = await api.get('/api/users/me/tasks')
798
+ if (!res?.success) return errorText('Could not fetch tasks')
799
+
800
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 }
801
+ const tasks = (res.data.tasks || [])
802
+ .filter(t => includeColumns.includes(t.column))
803
+ .sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99))
804
+ .map(t => {
805
+ let suggestedAction = null
806
+ if (t.column === 'todo') {
807
+ suggestedAction = 'Use kickoff_task to start — it will brief you and move the task to in_progress'
808
+ } else if (t.column === 'in_progress' && !t.github?.headBranch) {
809
+ suggestedAction = 'No branch yet — use create_branch to create one before coding'
810
+ } else if (t.column === 'in_progress' && t.github?.headBranch) {
811
+ suggestedAction = `Branch exists (${t.github.headBranch}). Pushed commits? Use raise_pr to open a PR`
812
+ } else if (t.column === 'in_review') {
813
+ suggestedAction = `PR #${t.github?.prNumber || '?'} is open — waiting for reviewer feedback`
814
+ }
815
+ return {
816
+ id: t._id,
817
+ key: t.key,
818
+ title: t.title,
819
+ column: t.column,
820
+ priority: t.priority,
821
+ project: t.project?.name || t.project,
822
+ github: t.github ? { headBranch: t.github.headBranch, prNumber: t.github.prNumber, mergedAt: t.github.mergedAt } : null,
823
+ parked: !!t.parkNote?.parkedAt,
824
+ suggestedAction,
825
+ }
826
+ })
827
+
828
+ return text({ tasks, count: tasks.length })
829
+ }
830
+ )
831
+
832
+ // ── get_task_context ─────────────────────────────────────────────────────────
833
+ server.tool(
834
+ 'get_task_context',
835
+ `Get everything needed to resume a task: full details, park notes, recent activity, and git state.
836
+ Use this when returning to a task after a break, or when Claude needs the full picture before suggesting next steps.`,
837
+ {
838
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
839
+ },
840
+ async ({ taskId }) => {
841
+ const [taskRes, activityRes] = await Promise.all([
842
+ api.get(`/api/tasks/${taskId}`),
843
+ api.get(`/api/tasks/${taskId}/activity`).catch(() => null),
844
+ ])
845
+ if (!taskRes?.success) return errorText('Task not found')
846
+ const task = taskRes.data.task
847
+ const recentActivity = (activityRes?.data?.activity || []).slice(-10)
848
+
849
+ let coachingPrompt = null
850
+ if (task.column === 'in_progress' && !task.github?.headBranch) {
851
+ coachingPrompt = 'No branch created yet. Use create_branch before writing code.'
852
+ } else if (task.column === 'in_progress' && task.github?.headBranch) {
853
+ coachingPrompt = `Branch: ${task.github.headBranch}. When commits are pushed, use raise_pr to open a PR.`
854
+ } else if (task.column === 'in_review') {
855
+ coachingPrompt = `PR #${task.github?.prNumber} is open. Waiting for reviewer feedback.`
856
+ } else if (task.parkNote?.parkedAt) {
857
+ coachingPrompt = `Task was parked. Park note: "${task.parkNote.summary}". Use pop_stash if you stashed changes.`
858
+ }
859
+
860
+ return text({
861
+ task: {
862
+ id: task._id,
863
+ key: task.key,
864
+ title: task.title,
865
+ description: task.description,
866
+ priority: task.priority,
867
+ column: task.column,
868
+ assignees: (task.assignees || []).map(a => ({ id: a._id, name: a.name })),
869
+ reviewer: task.reviewer ? { id: task.reviewer._id, name: task.reviewer.name } : null,
870
+ parkNote: task.parkNote,
871
+ github: task.github,
872
+ subtasks: task.subtasks,
873
+ readmeMarkdown: task.readmeMarkdown,
874
+ },
875
+ recentActivity: recentActivity.map(a => ({ action: a.action, createdAt: a.createdAt, meta: a.meta })),
876
+ coachingPrompt,
877
+ })
878
+ }
879
+ )
880
+
881
+ // ── create_branch ────────────────────────────────────────────────────────────
882
+ server.tool(
883
+ 'create_branch',
884
+ `Create a Git branch on GitHub for a task using the naming convention:
885
+ feature/TASK-XXX-short-description (for new features)
886
+ fix/TASK-XXX-short-description (for bug fixes / patches)
887
+
888
+ WHEN TO USE: Immediately after a task moves to in_progress.
889
+ Claude will suggest this automatically after kickoff_task.
890
+
891
+ Set confirmed=false first to preview the branch name, then confirmed=true to create it.
892
+ After creation, run locally:
893
+ git fetch origin
894
+ git checkout <branchName>`,
895
+ {
896
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
897
+ projectId: z.string().describe("Project's MongoDB ObjectId"),
898
+ fromRef: z.string().optional().describe("Base ref to branch from (default: project's default branch)"),
899
+ confirmed: z.boolean().optional().default(false).describe('Set true to create the branch after reviewing the preview'),
900
+ },
901
+ async ({ taskId, projectId, fromRef, confirmed = false }) => {
902
+ if (scopedProjectId && projectId !== scopedProjectId) {
903
+ return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
904
+ }
905
+
906
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
907
+ if (!taskRes?.success) return errorText('Task not found')
908
+ const task = taskRes.data.task
909
+
910
+ const isFix = /\b(fix|bug|hotfix|patch)\b/i.test(task.title + ' ' + (task.description || ''))
911
+ const prefix = isFix ? 'fix' : 'feature'
912
+ const slug = task.title
913
+ .toLowerCase()
914
+ .replace(/[^a-z0-9]+/g, '-')
915
+ .replace(/^-|-$/g, '')
916
+ .slice(0, 35)
917
+ const branchName = `${prefix}/${task.key.toLowerCase()}-${slug}`
918
+
919
+ // Preview
920
+ if (!confirmed) {
921
+ return text({
922
+ preview: {
923
+ action: 'create_branch',
924
+ branchName,
925
+ fromRef: fromRef || '(project default branch)',
926
+ repo: '(resolved from project)',
927
+ task: { key: task.key, title: task.title },
928
+ },
929
+ requiresConfirmation: true,
930
+ message: `Will create branch "${branchName}" on GitHub. Call create_branch again with confirmed=true to proceed.`,
931
+ })
932
+ }
933
+
934
+ try {
935
+ const res = await api.post(`/api/projects/${projectId}/github/branches`, { branchName, fromRef })
936
+ if (!res?.success) return errorText(res?.message || 'Could not create branch')
937
+ return text({
938
+ branchName,
939
+ url: res.data.url,
940
+ message: 'Branch created on GitHub.',
941
+ gitSteps: [
942
+ 'git fetch origin',
943
+ `git checkout ${branchName}`,
944
+ ],
945
+ nextStep: 'Start coding! When your commits are ready to review, use raise_pr to open a pull request.',
946
+ })
947
+ } catch (e) {
948
+ return errorText(e.message)
949
+ }
950
+ }
951
+ )
952
+
953
+ // ── stash_changes ────────────────────────────────────────────────────────────
954
+ server.tool(
955
+ 'stash_changes',
956
+ `Save your current uncommitted work before switching to a higher-priority task.
957
+
958
+ WHEN TO USE: When an urgent task arrives while you are mid-work on another task.
959
+ Claude suggests this automatically when you say "I need to switch to TASK-XYZ".
960
+
961
+ This tool parks the current task in InternalTool (saves your context) and returns the
962
+ exact git stash command to run in your terminal — MCP cannot run git commands directly.
963
+
964
+ Set confirmed=false first to preview, then confirmed=true to park the task.`,
965
+ {
966
+ taskId: z.string().describe("Task's MongoDB ObjectId of the task you are pausing"),
967
+ summary: z.string().optional().describe('What you were doing — include file names and what changed'),
968
+ reason: z.string().optional().describe('Why you are switching (e.g. "urgent fix for TASK-042")'),
969
+ confirmed: z.boolean().optional().default(false).describe('Set true to park the task after reviewing the preview'),
970
+ },
971
+ async ({ taskId, summary = '', reason = '', confirmed = false }) => {
972
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
973
+ const task = taskRes?.data?.task
974
+
975
+ const stashMsg = summary
976
+ ? `wip: ${summary.slice(0, 60).replace(/"/g, "'")}`
977
+ : 'wip: pausing for priority switch'
978
+
979
+ const parkNote = {
980
+ summary: summary || 'Work in progress — stashing before switching tasks',
981
+ remaining: reason ? `Switched to: ${reason}` : 'Resume after priority task is done',
982
+ blockers: '',
983
+ }
984
+
985
+ if (!confirmed) {
986
+ return text({
987
+ preview: {
988
+ action: 'stash_changes',
989
+ task: task ? { key: task.key, title: task.title } : { taskId },
990
+ willPark: parkNote,
991
+ gitCommand: `git stash push -m "${stashMsg}"`,
992
+ },
993
+ requiresConfirmation: true,
994
+ message: 'This will park the task in InternalTool. Run the git stash command yourself in the terminal. Call stash_changes again with confirmed=true to save the park note.',
995
+ })
996
+ }
997
+
998
+ try {
999
+ await api.patch(`/api/tasks/${taskId}/park`, parkNote)
1000
+ } catch (e) {
1001
+ return errorText(`Failed to park task: ${e.message}`)
1002
+ }
1003
+
1004
+ return text({
1005
+ taskParked: true,
1006
+ gitCommand: `git stash push -m "${stashMsg}"`,
1007
+ message: 'Task parked. Run the git command above in your terminal to stash your changes.',
1008
+ nextStep: 'Use kickoff_task or create_branch to start the priority task. Use pop_stash to return here.',
1009
+ })
1010
+ }
1011
+ )
1012
+
1013
+ // ── pop_stash ────────────────────────────────────────────────────────────────
1014
+ server.tool(
1015
+ 'pop_stash',
1016
+ `Restore stashed work when returning to a previously paused task.
1017
+
1018
+ WHEN TO USE: When returning to a task you paused with stash_changes.
1019
+ Claude suggests this automatically when you resume a parked task.
1020
+
1021
+ Returns the git commands to run in your terminal and unparks the task in InternalTool.
1022
+
1023
+ Set confirmed=false first to review the park note, then confirmed=true to unpark.`,
1024
+ {
1025
+ taskId: z.string().describe("Task's MongoDB ObjectId of the task you are resuming"),
1026
+ stashIndex: z.number().int().min(0).optional().default(0)
1027
+ .describe("Stash index to pop (0 = most recent). Run 'git stash list' to see all."),
1028
+ confirmed: z.boolean().optional().default(false).describe('Set true to unpark the task after reviewing'),
1029
+ },
1030
+ async ({ taskId, stashIndex = 0, confirmed = false }) => {
1031
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
1032
+ if (!taskRes?.success) return errorText('Task not found')
1033
+ const task = taskRes.data.task
1034
+
1035
+ const stashCmd = stashIndex === 0 ? 'git stash pop' : `git stash pop stash@{${stashIndex}}`
1036
+ const gitSteps = ['git fetch origin', 'git rebase origin/main', stashCmd]
1037
+
1038
+ if (!confirmed) {
1039
+ return text({
1040
+ preview: {
1041
+ action: 'pop_stash',
1042
+ task: { key: task.key, title: task.title, column: task.column },
1043
+ parkNote: task.parkNote || null,
1044
+ gitCommands: gitSteps,
1045
+ willDo: 'Clear park note and mark task active in InternalTool',
1046
+ },
1047
+ requiresConfirmation: true,
1048
+ message: 'Review your park note above. Call pop_stash again with confirmed=true to unpark and get the git commands.',
1049
+ })
1050
+ }
1051
+
1052
+ try {
1053
+ await api.patch(`/api/tasks/${taskId}/unpark`, {})
1054
+ } catch (e) {
1055
+ return errorText(`Failed to unpark task: ${e.message}`)
1056
+ }
1057
+
1058
+ return text({
1059
+ taskUnparked: true,
1060
+ parkNote: task.parkNote || null,
1061
+ gitCommands: gitSteps,
1062
+ message: 'Task unparked. Run the git commands above in your terminal to restore your work.',
1063
+ nextStep: 'Review your park note for context, then continue where you left off.',
1064
+ })
1065
+ }
1066
+ )
1067
+
1068
+ // ── fix_pr_feedback ──────────────────────────────────────────────────────────
1069
+ server.tool(
1070
+ 'fix_pr_feedback',
1071
+ `Guide the developer through fixing a PR that has "changes requested" or was closed,
1072
+ while they are mid-work on a different task.
1073
+
1074
+ WHEN TO USE: When a notification says "PR needs changes" on Task A while working on Task B.
1075
+ Claude will suggest this automatically when it detects a task with github.changesRequestedAt set.
1076
+
1077
+ The tool shows exactly what to do — stash Task B, switch to Task A's branch, fix, push, return.
1078
+ All git commands are returned for the developer to run in their terminal.
1079
+
1080
+ Set confirmed=false first to preview the full plan, then confirmed=true to save the stash note.`,
1081
+ {
1082
+ taskAId: z.string().describe("Task's MongoDB ObjectId of the task whose PR needs fixing (Task A)"),
1083
+ taskBId: z.string().optional().describe("Task's MongoDB ObjectId of the task currently being worked on (Task B), if any"),
1084
+ confirmed: z.boolean().optional().default(false).describe('Set true to park Task B and get the full command sequence'),
1085
+ },
1086
+ async ({ taskAId, taskBId, confirmed = false }) => {
1087
+ const taskARes = await api.get(`/api/tasks/${taskAId}`)
1088
+ if (!taskARes?.success) return errorText('Task A not found')
1089
+ const taskA = taskARes.data.task
1090
+
1091
+ let taskB = null
1092
+ if (taskBId) {
1093
+ const taskBRes = await api.get(`/api/tasks/${taskBId}`)
1094
+ taskB = taskBRes?.data?.task || null
1095
+ }
1096
+
1097
+ const taskABranch = taskA.github?.headBranch || `feature/${taskA.key?.toLowerCase()}-${taskA.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30)}`
1098
+ const taskBBranch = taskB?.github?.headBranch || (taskB ? `feature/${taskB.key?.toLowerCase()}` : null)
1099
+
1100
+ const plan = {
1101
+ situation: taskB
1102
+ ? `PR for Task A (${taskA.key}) needs changes while you're working on Task B (${taskB.key}).`
1103
+ : `PR for Task A (${taskA.key}) needs changes.`,
1104
+ taskA: { key: taskA.key, title: taskA.title, branch: taskABranch, prNumber: taskA.github?.prNumber, prUrl: taskA.github?.prUrl },
1105
+ taskB: taskB ? { key: taskB.key, title: taskB.title, branch: taskBBranch } : null,
1106
+ steps: [
1107
+ taskB ? `1. Stash Task B work: git stash push -m "wip: ${taskBBranch} — switching to fix ${taskA.key} PR"` : null,
1108
+ `${taskB ? 2 : 1}. Switch to Task A branch: git checkout ${taskABranch}`,
1109
+ `${taskB ? 3 : 2}. Fetch and rebase: git fetch origin && git rebase origin/main`,
1110
+ `${taskB ? 4 : 3}. Read reviewer feedback on GitHub: ${taskA.github?.prUrl || 'open the PR'}`,
1111
+ `${taskB ? 5 : 4}. Fix issues, commit: git add . && git commit -m "fix(${taskA.key?.toLowerCase()}): address review feedback"`,
1112
+ `${taskB ? 6 : 5}. Push to update PR: git push origin ${taskABranch}`,
1113
+ taskB ? `7. Return to Task B: git checkout ${taskBBranch} && git stash pop` : null,
1114
+ ].filter(Boolean),
1115
+ }
1116
+
1117
+ if (!confirmed) {
1118
+ return text({
1119
+ preview: plan,
1120
+ requiresConfirmation: true,
1121
+ message: taskB
1122
+ ? `This will park Task B and guide you to fix the PR for Task A. Call fix_pr_feedback again with confirmed=true to proceed.`
1123
+ : `Here's the plan to fix the PR. Call fix_pr_feedback again with confirmed=true to proceed.`,
1124
+ })
1125
+ }
1126
+
1127
+ // Park Task B if provided
1128
+ if (taskB && taskBId) {
1129
+ try {
1130
+ await api.patch(`/api/tasks/${taskBId}/park`, {
1131
+ summary: `Working on fix for ${taskA.key} PR review`,
1132
+ remaining: `Resume ${taskB.key} after PR fix is pushed`,
1133
+ blockers: '',
1134
+ })
1135
+ } catch { /* non-fatal — developer can park manually */ }
1136
+ }
1137
+
1138
+ return text({
1139
+ plan,
1140
+ taskBParked: !!(taskB && taskBId),
1141
+ gitCommands: [
1142
+ taskBBranch ? `git stash push -m "wip: ${taskBBranch} — switching to fix ${taskA.key} PR"` : null,
1143
+ `git checkout ${taskABranch}`,
1144
+ 'git fetch origin',
1145
+ 'git rebase origin/main',
1146
+ `# Read feedback at: ${taskA.github?.prUrl || '(open the PR on GitHub)'}`,
1147
+ `# Make your fixes, then:`,
1148
+ `git add .`,
1149
+ `git commit -m "fix(${taskA.key?.toLowerCase()}): address review feedback — <describe what changed>"`,
1150
+ `git push origin ${taskABranch}`,
1151
+ taskBBranch ? `# When done — return to Task B:` : null,
1152
+ taskBBranch ? `git checkout ${taskBBranch}` : null,
1153
+ taskBBranch ? `git stash pop` : null,
1154
+ ].filter(Boolean),
1155
+ nextStep: `Fix the issues on Task A's branch, push, then return to Task B. The PR updates automatically when you push.`,
1156
+ })
1157
+ }
1158
+ )
1159
+
1160
+ // ── raise_pr ─────────────────────────────────────────────────────────────────
1161
+ server.tool(
1162
+ 'raise_pr',
1163
+ `Create a GitHub Pull Request for a task with auto-generated title and description.
1164
+
1165
+ WHEN TO USE: After pushing commits on a feature/fix branch and ready for review.
1166
+ Claude suggests this automatically when a task is in_progress and you indicate commits are pushed.
1167
+
1168
+ The PR title and body are built from the task data (title, key, description, park notes).
1169
+ After the PR is created, the GitHub webhook will automatically move the task to in_review
1170
+ and notify the assigned reviewer.
1171
+
1172
+ Set confirmed=false first to preview the full PR content, then confirmed=true to create it.`,
1173
+ {
1174
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
1175
+ projectId: z.string().describe("Project's MongoDB ObjectId"),
1176
+ headBranch: z.string().describe('Your feature/fix branch (e.g. feature/task-001-my-feature)'),
1177
+ additionalNotes: z.string().optional().describe('Extra context to add to the PR body'),
1178
+ draft: z.boolean().optional().default(false).describe('Open as a draft PR (not yet ready for review)'),
1179
+ confirmed: z.boolean().optional().default(false).describe('Set true to create the PR after reviewing the preview'),
1180
+ },
1181
+ async ({ taskId, projectId, headBranch, additionalNotes = '', draft = false, confirmed = false }) => {
1182
+ if (scopedProjectId && projectId !== scopedProjectId) {
1183
+ return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
1184
+ }
1185
+
1186
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
1187
+ if (!taskRes?.success) return errorText('Task not found')
1188
+ const task = taskRes.data.task
1189
+
1190
+ const prTitle = `[${task.key}] ${task.title}`
1191
+ const bodyParts = [
1192
+ `## ${task.key}: ${task.title}`,
1193
+ '',
1194
+ task.description ? `### Description\n${task.description}` : null,
1195
+ task.readmeMarkdown ? `### Implementation Notes\n${task.readmeMarkdown.slice(0, 600)}` : null,
1196
+ task.parkNote?.summary ? `### Work Summary\n${task.parkNote.summary}` : null,
1197
+ task.parkNote?.remaining ? `### What Remains\n${task.parkNote.remaining}` : null,
1198
+ additionalNotes ? `### Additional Notes\n${additionalNotes}` : null,
1199
+ '',
1200
+ '---',
1201
+ `*Auto-generated by InternalTool MCP — task: ${task.key}*`,
1202
+ ].filter(v => v !== null).join('\n')
1203
+
1204
+ if (!confirmed) {
1205
+ return text({
1206
+ preview: {
1207
+ action: 'raise_pr',
1208
+ prTitle,
1209
+ prBody: bodyParts,
1210
+ headBranch,
1211
+ draft,
1212
+ task: { key: task.key, title: task.title },
1213
+ },
1214
+ requiresConfirmation: true,
1215
+ message: `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
1216
+ })
1217
+ }
1218
+
1219
+ try {
1220
+ const res = await api.post(`/api/projects/${projectId}/github/pull-requests`, {
1221
+ title: prTitle,
1222
+ body: bodyParts,
1223
+ head: headBranch,
1224
+ draft,
1225
+ })
1226
+ if (!res?.success) return errorText(res?.message || 'Could not create PR')
1227
+ return text({
1228
+ prNumber: res.data.prNumber,
1229
+ prUrl: res.data.prUrl,
1230
+ title: prTitle,
1231
+ draft,
1232
+ message: `PR #${res.data.prNumber} created.`,
1233
+ nextStep: draft
1234
+ ? 'PR is a draft. Mark it ready for review on GitHub when you want reviewer notifications to fire.'
1235
+ : 'PR is live. The GitHub webhook will move the task to in_review and notify the reviewer within seconds.',
1236
+ })
1237
+ } catch (e) {
1238
+ return errorText(e.message)
1239
+ }
1240
+ }
1241
+ )
1242
+ }
1243
+
680
1244
  function registerAdminTools(server) {
681
1245
  server.tool(
682
1246
  'admin_list_users',
@@ -728,6 +1292,7 @@ async function main() {
728
1292
  registerCommentTools(server)
729
1293
  registerNotificationTools(server)
730
1294
  registerGithubTools(server, ctx)
1295
+ registerGitWorkflowTools(server, ctx)
731
1296
 
732
1297
  if (isAdmin) {
733
1298
  registerAdminTools(server)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "MCP server for InternalTool — connect AI assistants (Claude Code, Cursor) to your project and task management platform",
5
5
  "type": "module",
6
6
  "main": "index.js",