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.
- package/index.js +207 -55
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
895
|
-
1. confirmed=false
|
|
896
|
-
2.
|
|
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
|
-
|
|
900
|
-
|
|
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:
|
|
908
|
-
projectId:
|
|
909
|
-
fromRef:
|
|
910
|
-
confirmed:
|
|
911
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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 —
|
|
960
|
-
if (
|
|
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:
|
|
964
|
-
|
|
965
|
-
|
|
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:
|
|
983
|
-
stashed: 'You stashed changes
|
|
984
|
-
committed: 'You
|
|
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