internaltool-mcp 1.5.4 → 1.5.5

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 +207 -55
  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,41 @@ 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
+ const stateHint = localGitState?.state === 'modified'
966
+ ? ` Your working tree has ${localGitState.modifiedFiles.length} modified file(s) — stash or commit them first, then call create_branch.`
967
+ : ` Your working tree is ${localGitState?.state || 'unknown'} — ready to call create_branch.`
968
+ coachingPrompt = `No branch yet.${stateHint}`
856
969
  } 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.`
970
+ const onBranch = localGitState?.onTaskBranch
971
+ const branchHint = onBranch === true
972
+ ? ` (you are already on it)`
973
+ : onBranch === false
974
+ ? ` (you are on "${localGitState?.currentBranch}" — run: git checkout ${task.github.headBranch})`
975
+ : ''
976
+ coachingPrompt = `Branch: ${task.github.headBranch}${branchHint}. When commits are pushed, use raise_pr to open a PR.`
858
977
  } else if (task.column === 'in_review') {
859
978
  coachingPrompt = `PR #${task.github?.prNumber} is open. Waiting for reviewer feedback.`
860
979
  } else if (task.parkNote?.parkedAt) {
@@ -876,7 +995,8 @@ Use this when returning to a task after a break, or when Claude needs the full p
876
995
  subtasks: task.subtasks,
877
996
  readmeMarkdown: task.readmeMarkdown,
878
997
  },
879
- recentActivity: recentActivity.map(a => ({ action: a.action, createdAt: a.createdAt, meta: a.meta })),
998
+ recentActivity: recentActivity.map(a => ({ action: a.action, createdAt: a.createdAt, meta: a.meta })),
999
+ localGitState,
880
1000
  coachingPrompt,
881
1001
  })
882
1002
  }
@@ -891,27 +1011,20 @@ Use this when returning to a task after a break, or when Claude needs the full p
891
1011
 
892
1012
  WHEN TO USE: Immediately after a task moves to in_progress.
893
1013
 
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
1014
+ TWO-STEP FLOW:
1015
+ 1. confirmed=false auto-detects your local git state + shows the branch name preview
1016
+ 2. confirmed=true → creates the branch on GitHub (blocks automatically if tracked changes are present)
898
1017
 
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
903
-
904
- Never skip the localState check — switching branches with uncommitted changes
905
- can cause conflicts or silently carry changes onto the new branch.`,
1018
+ You do NOT need to run git status manually — this tool checks it for you.
1019
+ If you have uncommitted tracked changes, it will tell you exactly what to do before retrying.`,
906
1020
  {
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.'),
1021
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
1022
+ projectId: z.string().describe("Project's MongoDB ObjectId"),
1023
+ fromRef: z.string().optional().describe("Base ref to branch from (default: project's default branch)"),
1024
+ confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the preview to create the branch'),
1025
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
913
1026
  },
914
- async ({ taskId, projectId, fromRef, confirmed = false, localState }) => {
1027
+ async ({ taskId, projectId, fromRef, confirmed = false, repoPath }) => {
915
1028
  if (scopedProjectId && projectId !== scopedProjectId) {
916
1029
  return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
917
1030
  }
@@ -929,8 +1042,42 @@ can cause conflicts or silently carry changes onto the new branch.`,
929
1042
  .slice(0, 35)
930
1043
  const branchName = `${prefix}/${task.key.toLowerCase()}-${slug}`
931
1044
 
932
- // Step 1 preview + pre-flight commands
1045
+ // Auto-detect local git state
1046
+ let gitState = null
1047
+ let gitStateError = null
1048
+ const cwd = repoPath || process.cwd()
1049
+ try {
1050
+ const porcelain = runGit('status --porcelain=v1', cwd)
1051
+ const branch = runGit('branch --show-current', cwd)
1052
+ const stashListRaw = runGit('stash list', cwd)
1053
+ const { staged, unstaged, untracked, modified, localState } = parseGitStatus(porcelain)
1054
+ const stashes = stashListRaw ? stashListRaw.split('\n').filter(Boolean) : []
1055
+ gitState = { currentBranch: branch, localState, modified, untracked, stashes }
1056
+ } catch (e) {
1057
+ gitStateError = e.message.includes('not a git repository')
1058
+ ? `"${cwd}" is not a git repository. Pass repoPath to the correct local project directory.`
1059
+ : `Could not read local git state: ${e.message.split('\n')[0]}`
1060
+ }
1061
+
1062
+ // Step 1 — preview with auto-detected local state
933
1063
  if (!confirmed) {
1064
+ const stateMsg = gitStateError
1065
+ ? `⚠️ Could not auto-detect git state: ${gitStateError}`
1066
+ : gitState.localState === 'modified'
1067
+ ? `⚠️ You have ${gitState.modified.length} modified tracked file(s) on branch "${gitState.currentBranch}". You must stash or commit them before switching branches.`
1068
+ : gitState.localState === 'untracked'
1069
+ ? `✅ Only untracked files present (${gitState.untracked.length} file(s)) on branch "${gitState.currentBranch}". Safe to switch — untracked files stay in place.`
1070
+ : `✅ Working tree is clean on branch "${gitState.currentBranch}". Safe to create the branch.`
1071
+
1072
+ const requiredAction = gitState?.localState === 'modified'
1073
+ ? {
1074
+ mustDo: 'Stash or commit your tracked changes before confirming.',
1075
+ option_a: `git stash push -m "wip: switching to ${branchName}"`,
1076
+ option_b: 'git add . && git commit -m "chore: wip"',
1077
+ thenDo: 'After doing one of the above, call create_branch again with confirmed=true.',
1078
+ }
1079
+ : null
1080
+
934
1081
  return text({
935
1082
  preview: {
936
1083
  action: 'create_branch',
@@ -938,35 +1085,40 @@ can cause conflicts or silently carry changes onto the new branch.`,
938
1085
  basedOn: fromRef || '(project default branch)',
939
1086
  task: { key: task.key, title: task.title },
940
1087
  },
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.`,
1088
+ localGitState: gitState
1089
+ ? {
1090
+ currentBranch: gitState.currentBranch,
1091
+ state: gitState.localState,
1092
+ modifiedFiles: gitState.modified,
1093
+ untrackedFiles: gitState.untracked,
1094
+ stashes: gitState.stashes.slice(0, 3),
1095
+ }
1096
+ : { error: gitStateError },
1097
+ statusMessage: stateMsg,
1098
+ requiredAction,
1099
+ requiresConfirmation: true,
1100
+ message: gitState?.localState === 'modified'
1101
+ ? `Cannot proceed — stash or commit your changes first (see requiredAction above), then call create_branch with confirmed=true.`
1102
+ : `Local state is clear. Call create_branch again with confirmed=true to create the branch on GitHub.`,
956
1103
  })
957
1104
  }
958
1105
 
959
- // Step 2 — localState gate: block if developer skipped the check
960
- if (!localState) {
1106
+ // Step 2 — block automatically if there are tracked changes
1107
+ if (gitState?.localState === 'modified') {
961
1108
  return text({
962
1109
  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'],
1110
+ 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.`,
1111
+ modifiedFiles: gitState.modified,
1112
+ fixOptions: {
1113
+ stash: `git stash push -m "wip: switching to ${branchName}"`,
1114
+ commit: 'git add . && git commit -m "chore: wip"',
1115
+ },
1116
+ message: 'Stash or commit your changes, then call create_branch again with confirmed=true.',
966
1117
  })
967
1118
  }
968
1119
 
969
1120
  // Step 3 — create branch on GitHub
1121
+ const localState = gitState?.localState || 'unknown'
970
1122
  try {
971
1123
  const res = await api.post(`/api/projects/${projectId}/github/branches`, { branchName, fromRef })
972
1124
  if (!res?.success) return errorText(res?.message || 'Could not create branch')
@@ -976,12 +1128,12 @@ can cause conflicts or silently carry changes onto the new branch.`,
976
1128
  `git checkout ${branchName}`,
977
1129
  ]
978
1130
 
979
- // State-specific notes
980
1131
  const localStateNote = {
981
1132
  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.',
1133
+ 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.`,
1134
+ stashed: 'You have stashed changes. After checkout, decide: does your stash belong on this new branch? If yes: git stash pop.',
1135
+ committed: 'You have a WIP commit on the previous branch — it will not appear on the new branch.',
1136
+ unknown: null,
985
1137
  }[localState] || null
986
1138
 
987
1139
  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.5",
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",