internaltool-mcp 1.5.4 → 1.5.7

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 +238 -56
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
19
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
20
+ import { execSync } from 'child_process'
20
21
  import { z } from 'zod'
21
22
  import { api, login, configure } from './api-client.js'
22
23
 
@@ -541,12 +542,7 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
541
542
  })),
542
543
  movedToInProgress: moved,
543
544
  suggestedBranch,
544
- preflightBeforeBranch: {
545
- message: 'Before creating the branch, check your local git state first.',
546
- commands: ['git status', 'git stash list'],
547
- instruction: 'If you have uncommitted changes, stash or commit them before switching branches. Then call create_branch with your localState.',
548
- },
549
- nextStep: `Use create_branch to create "${suggestedBranch}" on GitHub — it will ask for your local git state first.`,
545
+ nextStep: `Use create_branch to create "${suggestedBranch}" on GitHub — it will automatically check your local git state before creating.`,
550
546
  })
551
547
  }
552
548
  )
@@ -780,12 +776,103 @@ function registerGithubTools(server, { scopedProjectId }) {
780
776
  )
781
777
  }
782
778
 
779
+ // ── Local git helper ──────────────────────────────────────────────────────────
780
+ // MCP runs as a local process — it can shell out to git directly.
781
+
782
+ function runGit(args, cwd) {
783
+ return execSync(`git ${args}`, {
784
+ cwd: cwd || process.cwd(),
785
+ encoding: 'utf8',
786
+ timeout: 8000,
787
+ stdio: ['pipe', 'pipe', 'pipe'],
788
+ }).trim()
789
+ }
790
+
791
+ function parseGitStatus(porcelain) {
792
+ const lines = porcelain.split('\n').filter(Boolean)
793
+ const staged = lines.filter(l => !' ?!'.includes(l[0])).map(l => ({ xy: l.slice(0, 2), file: l.slice(3) }))
794
+ const unstaged = lines.filter(l => !' ?!'.includes(l[1])).map(l => ({ xy: l.slice(0, 2), file: l.slice(3) }))
795
+ const untracked = lines.filter(l => l.startsWith('??')).map(l => l.slice(3))
796
+ const modified = [...new Set([...staged, ...unstaged].map(f => f.file))]
797
+
798
+ let localState
799
+ if (modified.length > 0) localState = 'modified' // tracked changes — must stash or commit
800
+ else if (untracked.length > 0) localState = 'untracked' // only untracked — safe to switch
801
+ else localState = 'clean' // nothing at all
802
+
803
+ return { staged, unstaged, untracked, modified, localState }
804
+ }
805
+
783
806
  // ── Git Workflow Tools ─────────────────────────────────────────────────────────
784
807
  // All write operations require confirmed=true after showing a preview.
785
808
  // This gives developers full visibility before anything is executed.
786
809
 
787
810
  function registerGitWorkflowTools(server, { scopedProjectId } = {}) {
788
811
 
812
+ // ── check_local_git_state ────────────────────────────────────────────────────
813
+ server.tool(
814
+ 'check_local_git_state',
815
+ `Check the local git repository state on the developer's machine by running git commands directly.
816
+
817
+ Returns: current branch, whether the working tree is clean, modified/staged/untracked files,
818
+ stash list, and a recommendation for what to do before creating or switching branches.
819
+
820
+ ALWAYS call this before create_branch, stash_changes, or pop_stash.
821
+ The developer does not need to run any commands manually — this tool reads local state automatically.`,
822
+ {
823
+ repoPath: z.string().optional().describe('Absolute path to the local git repo. Defaults to the MCP process working directory.'),
824
+ },
825
+ async ({ repoPath } = {}) => {
826
+ const cwd = repoPath || process.cwd()
827
+ try {
828
+ const porcelain = runGit('status --porcelain=v1', cwd)
829
+ const branch = runGit('branch --show-current', cwd)
830
+ const stashListRaw = runGit('stash list', cwd)
831
+ const remoteStatus = runGit('status --short --branch', cwd).split('\n')[0] // ## main...origin/main [ahead 1]
832
+
833
+ const { staged, unstaged, untracked, modified, localState } = parseGitStatus(porcelain)
834
+ const stashes = stashListRaw ? stashListRaw.split('\n').filter(Boolean) : []
835
+
836
+ const recommendation =
837
+ localState === 'modified'
838
+ ? `You have ${modified.length} modified tracked file(s). Before switching branches you must either:\n a) git stash push -m "wip: <message>" (reversible, stays local)\n b) git add . && git commit -m "chore: wip" (safer, survives crashes)`
839
+ : localState === 'untracked'
840
+ ? `Only untracked files present (${untracked.length} file(s)). Safe to create or switch branches — untracked files stay in place and do NOT follow you to another branch.`
841
+ : 'Working tree is clean. Safe to create or switch branches.'
842
+
843
+ return text({
844
+ currentBranch: branch || '(detached HEAD)',
845
+ remoteStatus: remoteStatus.replace(/^## /, ''),
846
+ localState,
847
+ clean: localState === 'clean',
848
+ summary: {
849
+ stagedFiles: staged.map(f => f.file),
850
+ unstagedFiles: unstaged.map(f => f.file),
851
+ untrackedFiles: untracked,
852
+ modifiedCount: modified.length,
853
+ untrackedCount: untracked.length,
854
+ },
855
+ stashes: {
856
+ count: stashes.length,
857
+ list: stashes.slice(0, 5),
858
+ },
859
+ recommendation,
860
+ nextStep: localState === 'modified'
861
+ ? 'Stash or commit your changes, then call create_branch with localState="stashed" or "committed".'
862
+ : 'Call create_branch with localState="' + localState + '" — you are ready to proceed.',
863
+ })
864
+ } catch (e) {
865
+ if (e.message.includes('not a git repository')) {
866
+ return errorText(`"${cwd}" is not a git repository. Pass the correct repoPath or cd into your project.`)
867
+ }
868
+ if (e.message.includes('not found') || e.message.includes('ENOENT')) {
869
+ return errorText('git is not installed or not on PATH.')
870
+ }
871
+ return errorText(`Could not check git state: ${e.message.split('\n')[0]}`)
872
+ }
873
+ }
874
+ )
875
+
789
876
  // ── list_my_tasks ────────────────────────────────────────────────────────────
790
877
  server.tool(
791
878
  'list_my_tasks',
@@ -836,12 +923,14 @@ Use this at the start of a session or when switching tasks.`,
836
923
  // ── get_task_context ─────────────────────────────────────────────────────────
837
924
  server.tool(
838
925
  'get_task_context',
839
- `Get everything needed to resume a task: full details, park notes, recent activity, and git state.
926
+ `Get everything needed to resume a task: full details, park notes, recent activity, and local git state.
927
+ Auto-detects the developer's current branch and git state — no manual commands needed.
840
928
  Use this when returning to a task after a break, or when Claude needs the full picture before suggesting next steps.`,
841
929
  {
842
- taskId: z.string().describe("Task's MongoDB ObjectId"),
930
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
931
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
843
932
  },
844
- async ({ taskId }) => {
933
+ async ({ taskId, repoPath }) => {
845
934
  const [taskRes, activityRes] = await Promise.all([
846
935
  api.get(`/api/tasks/${taskId}`),
847
936
  api.get(`/api/tasks/${taskId}/activity`).catch(() => null),
@@ -850,11 +939,66 @@ Use this when returning to a task after a break, or when Claude needs the full p
850
939
  const task = taskRes.data.task
851
940
  const recentActivity = (activityRes?.data?.activity || []).slice(-10)
852
941
 
942
+ // Auto-detect local git state
943
+ let localGitState = null
944
+ try {
945
+ const cwd = repoPath || process.cwd()
946
+ const porcelain = runGit('status --porcelain=v1', cwd)
947
+ const branch = runGit('branch --show-current', cwd)
948
+ const stashListRaw = runGit('stash list', cwd)
949
+ const { modified, untracked, localState } = parseGitStatus(porcelain)
950
+ const stashes = stashListRaw ? stashListRaw.split('\n').filter(Boolean) : []
951
+ localGitState = {
952
+ currentBranch: branch || '(detached HEAD)',
953
+ state: localState,
954
+ modifiedFiles: modified,
955
+ untrackedFiles: untracked,
956
+ stashes: stashes.slice(0, 3),
957
+ onTaskBranch: task.github?.headBranch ? branch === task.github.headBranch : null,
958
+ }
959
+ } catch {
960
+ // non-fatal — git may not be available or path may differ
961
+ }
962
+
853
963
  let coachingPrompt = null
854
964
  if (task.column === 'in_progress' && !task.github?.headBranch) {
855
- coachingPrompt = 'No branch yet. Before create_branch: run "git status" in your terminal first — stash or commit any local changes, then call create_branch with your localState.'
965
+ let stateHint
966
+ if (localGitState?.state === 'modified') {
967
+ const files = localGitState.modifiedFiles.join(', ')
968
+ stateHint = [
969
+ `You have ${localGitState.modifiedFiles.length} modified file(s): ${files}`,
970
+ `Before creating the task branch, handle these changes — pick one:`,
971
+ ` Option A — Stash (recommended, keeps changes safe):`,
972
+ ` git stash push -m "wip: before starting ${task.key}"`,
973
+ ` Option B — Commit as WIP:`,
974
+ ` git add . && git commit -m "chore: wip before ${task.key}"`,
975
+ `Then call create_branch — it will detect the clean state automatically.`,
976
+ ].join('\n')
977
+ } else {
978
+ stateHint = `Your working tree is ${localGitState?.state || 'unknown'} — ready to call create_branch.`
979
+ }
980
+ coachingPrompt = `No branch yet.\n${stateHint}`
856
981
  } else if (task.column === 'in_progress' && task.github?.headBranch) {
857
- coachingPrompt = `Branch: ${task.github.headBranch}. When commits are pushed, use raise_pr to open a PR.`
982
+ const onBranch = localGitState?.onTaskBranch
983
+ if (onBranch === false) {
984
+ const lines = [
985
+ `Branch exists: ${task.github.headBranch}`,
986
+ `You are currently on "${localGitState?.currentBranch}".`,
987
+ ]
988
+ if (localGitState?.state === 'modified') {
989
+ lines.push(`You have ${localGitState.modifiedFiles.length} modified file(s): ${localGitState.modifiedFiles.join(', ')}`)
990
+ lines.push(`Handle them before switching:`)
991
+ lines.push(` git stash push -m "wip: switching to ${task.github.headBranch}"`)
992
+ lines.push(` — or — git add . && git commit -m "chore: wip"`)
993
+ lines.push(`Then switch: git fetch origin && git checkout ${task.github.headBranch}`)
994
+ } else {
995
+ lines.push(`Switch to the task branch: git fetch origin && git checkout ${task.github.headBranch}`)
996
+ }
997
+ lines.push(`When commits are pushed, use raise_pr.`)
998
+ coachingPrompt = lines.join('\n')
999
+ } else {
1000
+ coachingPrompt = `Branch: ${task.github.headBranch}${onBranch === true ? ' (you are already on it)' : ''}. When commits are pushed, use raise_pr to open a PR.`
1001
+ }
858
1002
  } else if (task.column === 'in_review') {
859
1003
  coachingPrompt = `PR #${task.github?.prNumber} is open. Waiting for reviewer feedback.`
860
1004
  } else if (task.parkNote?.parkedAt) {
@@ -876,7 +1020,8 @@ Use this when returning to a task after a break, or when Claude needs the full p
876
1020
  subtasks: task.subtasks,
877
1021
  readmeMarkdown: task.readmeMarkdown,
878
1022
  },
879
- recentActivity: recentActivity.map(a => ({ action: a.action, createdAt: a.createdAt, meta: a.meta })),
1023
+ recentActivity: recentActivity.map(a => ({ action: a.action, createdAt: a.createdAt, meta: a.meta })),
1024
+ localGitState,
880
1025
  coachingPrompt,
881
1026
  })
882
1027
  }
@@ -891,27 +1036,20 @@ Use this when returning to a task after a break, or when Claude needs the full p
891
1036
 
892
1037
  WHEN TO USE: Immediately after a task moves to in_progress.
893
1038
 
894
- THREE-STEP FLOW — do not skip steps:
895
- 1. confirmed=false preview branch name + get pre-flight git commands to run locally
896
- 2. Run the pre-flight commands → check local state, stash if needed, report back with localState
897
- 3. confirmed=true + localState → branch is created on GitHub
898
-
899
- localState must be one of:
900
- "clean" — git status shows nothing to commit, working tree clean
901
- "stashed" — had changes, ran git stash, now clean
902
- "committed" — had changes, did a WIP commit, now clean
1039
+ TWO-STEP FLOW:
1040
+ 1. confirmed=false auto-detects your local git state + shows the branch name preview
1041
+ 2. confirmed=true → creates the branch on GitHub (blocks automatically if tracked changes are present)
903
1042
 
904
- Never skip the localState checkswitching branches with uncommitted changes
905
- can cause conflicts or silently carry changes onto the new branch.`,
1043
+ You do NOT need to run git status manually this tool checks it for you.
1044
+ If you have uncommitted tracked changes, it will tell you exactly what to do before retrying.`,
906
1045
  {
907
- taskId: z.string().describe("Task's MongoDB ObjectId"),
908
- projectId: z.string().describe("Project's MongoDB ObjectId"),
909
- fromRef: z.string().optional().describe("Base ref to branch from (default: project's default branch)"),
910
- confirmed: z.boolean().optional().default(false).describe('Set true only after running pre-flight commands and setting localState'),
911
- localState: z.enum(['clean', 'untracked', 'stashed', 'committed']).optional()
912
- .describe('Your local git state after running git status. Required when confirmed=true. Use "untracked" if git status shows only untracked files with no modified tracked files.'),
1046
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
1047
+ projectId: z.string().describe("Project's MongoDB ObjectId"),
1048
+ fromRef: z.string().optional().describe("Base ref to branch from (default: project's default branch)"),
1049
+ confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the preview to create the branch'),
1050
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
913
1051
  },
914
- async ({ taskId, projectId, fromRef, confirmed = false, localState }) => {
1052
+ async ({ taskId, projectId, fromRef, confirmed = false, repoPath }) => {
915
1053
  if (scopedProjectId && projectId !== scopedProjectId) {
916
1054
  return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
917
1055
  }
@@ -929,8 +1067,42 @@ can cause conflicts or silently carry changes onto the new branch.`,
929
1067
  .slice(0, 35)
930
1068
  const branchName = `${prefix}/${task.key.toLowerCase()}-${slug}`
931
1069
 
932
- // Step 1 preview + pre-flight commands
1070
+ // Auto-detect local git state
1071
+ let gitState = null
1072
+ let gitStateError = null
1073
+ const cwd = repoPath || process.cwd()
1074
+ try {
1075
+ const porcelain = runGit('status --porcelain=v1', cwd)
1076
+ const branch = runGit('branch --show-current', cwd)
1077
+ const stashListRaw = runGit('stash list', cwd)
1078
+ const { staged, unstaged, untracked, modified, localState } = parseGitStatus(porcelain)
1079
+ const stashes = stashListRaw ? stashListRaw.split('\n').filter(Boolean) : []
1080
+ gitState = { currentBranch: branch, localState, modified, untracked, stashes }
1081
+ } catch (e) {
1082
+ gitStateError = e.message.includes('not a git repository')
1083
+ ? `"${cwd}" is not a git repository. Pass repoPath to the correct local project directory.`
1084
+ : `Could not read local git state: ${e.message.split('\n')[0]}`
1085
+ }
1086
+
1087
+ // Step 1 — preview with auto-detected local state
933
1088
  if (!confirmed) {
1089
+ const stateMsg = gitStateError
1090
+ ? `⚠️ Could not auto-detect git state: ${gitStateError}`
1091
+ : gitState.localState === 'modified'
1092
+ ? `⚠️ You have ${gitState.modified.length} modified tracked file(s) on branch "${gitState.currentBranch}". You must stash or commit them before switching branches.`
1093
+ : gitState.localState === 'untracked'
1094
+ ? `✅ Only untracked files present (${gitState.untracked.length} file(s)) on branch "${gitState.currentBranch}". Safe to switch — untracked files stay in place.`
1095
+ : `✅ Working tree is clean on branch "${gitState.currentBranch}". Safe to create the branch.`
1096
+
1097
+ const requiredAction = gitState?.localState === 'modified'
1098
+ ? {
1099
+ mustDo: 'Stash or commit your tracked changes before confirming.',
1100
+ option_a: `git stash push -m "wip: switching to ${branchName}"`,
1101
+ option_b: 'git add . && git commit -m "chore: wip"',
1102
+ thenDo: 'After doing one of the above, call create_branch again with confirmed=true.',
1103
+ }
1104
+ : null
1105
+
934
1106
  return text({
935
1107
  preview: {
936
1108
  action: 'create_branch',
@@ -938,50 +1110,60 @@ can cause conflicts or silently carry changes onto the new branch.`,
938
1110
  basedOn: fromRef || '(project default branch)',
939
1111
  task: { key: task.key, title: task.title },
940
1112
  },
941
- preflightRequired: true,
942
- preflightCommands: [
943
- 'git status',
944
- 'git stash list',
945
- 'git diff --stat',
946
- ],
947
- preflightInstructions: [
948
- 'Run the three commands above in your terminal right now.',
949
- 'Read your git status output and pick the matching localState:',
950
- ' "clean" — "nothing to commit, working tree clean"',
951
- ' "untracked" — only untracked files listed, no modified tracked files. Safe to proceed — untracked files stay in place when you switch branches, they do NOT follow you.',
952
- ' "stashed" — you had modified tracked files; run: git stash push -m "wip: switching to ' + branchName + '" — then set this.',
953
- ' "committed" — you had changes; run: git add . && git commit -m "chore: wip" — then set this.',
954
- ],
955
- message: `Run the pre-flight commands above, read the output, then call create_branch again with confirmed=true and localState set to one of: clean | untracked | stashed | committed.`,
1113
+ localGitState: gitState
1114
+ ? {
1115
+ currentBranch: gitState.currentBranch,
1116
+ state: gitState.localState,
1117
+ modifiedFiles: gitState.modified,
1118
+ untrackedFiles: gitState.untracked,
1119
+ stashes: gitState.stashes.slice(0, 3),
1120
+ }
1121
+ : { error: gitStateError },
1122
+ statusMessage: stateMsg,
1123
+ requiredAction,
1124
+ requiresConfirmation: true,
1125
+ message: gitState?.localState === 'modified'
1126
+ ? `Cannot proceed — stash or commit your changes first (see requiredAction above), then call create_branch with confirmed=true.`
1127
+ : `Local state is clear. Call create_branch again with confirmed=true to create the branch on GitHub.`,
956
1128
  })
957
1129
  }
958
1130
 
959
- // Step 2 — localState gate: block if developer skipped the check
960
- if (!localState) {
1131
+ // Step 2 — block automatically if there are tracked changes
1132
+ if (gitState?.localState === 'modified') {
961
1133
  return text({
962
1134
  blocked: true,
963
- reason: 'localState is required when confirmed=true.',
964
- message: 'You must run "git status" first and tell me your local state. Set localState to "clean", "stashed", or "committed" depending on what you found.',
965
- preflightCommands: ['git status', 'git stash list'],
1135
+ reason: `You have ${gitState.modified.length} modified tracked file(s). Creating a branch while tracked changes are present can carry them onto the new branch.`,
1136
+ modifiedFiles: gitState.modified,
1137
+ fixOptions: {
1138
+ stash: `git stash push -m "wip: switching to ${branchName}"`,
1139
+ commit: 'git add . && git commit -m "chore: wip"',
1140
+ },
1141
+ message: 'Stash or commit your changes, then call create_branch again with confirmed=true.',
966
1142
  })
967
1143
  }
968
1144
 
969
- // Step 3 — create branch on GitHub
1145
+ // Step 3 — create branch on GitHub, then link it to the task
1146
+ const localState = gitState?.localState || 'unknown'
970
1147
  try {
971
1148
  const res = await api.post(`/api/projects/${projectId}/github/branches`, { branchName, fromRef })
972
1149
  if (!res?.success) return errorText(res?.message || 'Could not create branch')
973
1150
 
1151
+ // Link the branch name back to the task so get_task_context / list_my_tasks reflect it immediately
1152
+ try {
1153
+ await api.patch(`/api/tasks/${taskId}/github/branch`, { headBranch: branchName })
1154
+ } catch { /* non-fatal — branch was still created on GitHub */ }
1155
+
974
1156
  const checkoutSteps = [
975
1157
  'git fetch origin',
976
1158
  `git checkout ${branchName}`,
977
1159
  ]
978
1160
 
979
- // State-specific notes
980
1161
  const localStateNote = {
981
1162
  clean: null,
982
- untracked: 'Your untracked files (.cursor/, docs, etc.) will stay in your working directory after checkout — they are not tracked by git so they do not follow branches. They will not appear in commits on the new branch unless you explicitly git add them.',
983
- stashed: 'You stashed changes before switching. After checkout, decide: does your stash belong on this new branch? If yes: git stash pop. If it belongs to a different branch, leave it and pop it there later.',
984
- committed: 'You made a WIP commit before switching. That commit stays on the branch you were on — it will not appear on the new branch.',
1163
+ untracked: `Your untracked files (${gitState?.untracked?.length || 0} file(s)) will stay in your working directory after checkout — they are not tracked by git so they do not follow branches.`,
1164
+ stashed: 'You have stashed changes. After checkout, decide: does your stash belong on this new branch? If yes: git stash pop.',
1165
+ committed: 'You have a WIP commit on the previous branch — it will not appear on the new branch.',
1166
+ unknown: null,
985
1167
  }[localState] || null
986
1168
 
987
1169
  return text({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.5.4",
3
+ "version": "1.5.7",
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",