internaltool-mcp 1.6.9 → 1.6.14

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 +227 -28
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
19
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
20
20
  import { execSync } from 'child_process'
21
- import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'fs'
21
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, readdirSync, statSync } from 'fs'
22
22
  import { join } from 'path'
23
23
  import { z } from 'zod'
24
24
  import { api, login, configure } from './api-client.js'
@@ -302,10 +302,11 @@ function registerTaskTools(server, { isAdmin, scopedProjectId }) {
302
302
 
303
303
  server.tool(
304
304
  'park_task',
305
- `Pause a task and save your current work context.
306
- IMPORTANT: Before calling this, run "git diff HEAD" and "git status" in the terminal to see what has changed.
307
- Use that output to write precise summary/remaining/blockers fields not generic text.
308
- This is the developer's saved mental state; make it detailed enough to resume cold tomorrow.
305
+ `Pause a task and save your current work context so another developer can safely pick it up.
306
+
307
+ SAFETY REQUIREMENT: All local changes must be committed and pushed before parking.
308
+ If there are uncommitted or unpushed changes, this tool will block and tell you to push first.
309
+ This ensures no work is lost when someone else picks up the task.
309
310
 
310
311
  Set confirmed=false first to preview, then confirmed=true to actually save.`,
311
312
  {
@@ -314,46 +315,117 @@ Set confirmed=false first to preview, then confirmed=true to actually save.`,
314
315
  remaining: z.string().optional().describe('Specific next steps to complete this task'),
315
316
  blockers: z.string().optional().describe('Anything blocking progress'),
316
317
  confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
318
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
317
319
  },
318
- async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false }) => {
320
+ async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false, repoPath }) => {
321
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
322
+ const task = taskRes?.data?.task
323
+
324
+ // ── Safety check: block if there are uncommitted or unpushed changes ──
325
+ const cwd = repoPath || process.cwd()
326
+ const repoRoot = findRepoRoot(cwd)
327
+ let gitBlockers = []
328
+ if (repoRoot) {
329
+ try {
330
+ const porcelain = runGit('status --porcelain=v1', repoRoot)
331
+ const { localState } = parseGitStatus(porcelain)
332
+ if (localState === 'modified') {
333
+ gitBlockers.push('You have uncommitted local changes. Commit them first so your work is not lost:\n git add . && git commit -m "<your message>" && git push')
334
+ }
335
+ } catch { /* non-fatal */ }
336
+ try {
337
+ const currentBranch = runGit('branch --show-current', repoRoot).trim()
338
+ if (currentBranch) {
339
+ runGit('fetch origin', repoRoot)
340
+ const unpushed = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, repoRoot).trim(), 10) || 0
341
+ if (unpushed > 0) {
342
+ gitBlockers.push(`You have ${unpushed} unpushed commit(s) on "${currentBranch}". Push first so another developer can access them:\n git push origin ${currentBranch}`)
343
+ }
344
+ }
345
+ } catch { /* remote branch may not exist — treat as unpushed */ }
346
+ }
347
+
348
+ if (gitBlockers.length > 0) {
349
+ return text({
350
+ blocked: true,
351
+ reason: 'Cannot park task — changes are not fully committed and pushed.',
352
+ gitBlockers,
353
+ message: 'Fix the issues above, then call park_task again.',
354
+ })
355
+ }
356
+
319
357
  if (!confirmed) {
320
358
  return text({
321
359
  preview: {
322
360
  action: 'park_task',
323
361
  taskId,
362
+ task: task ? { key: task.key, title: task.title, branch: task.github?.headBranch || null } : null,
324
363
  willSave: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
364
+ cursorRulesNote: task?.cursorRules?.trim() ? `Cursor rules file (.cursor/rules/${task.key?.toLowerCase()}.mdc) will be removed while parked.` : null,
365
+ handoffNote: task?.github?.headBranch
366
+ ? `All work is pushed. Another developer can pick this up by running: git fetch origin && git checkout ${task.github.headBranch}`
367
+ : null,
325
368
  },
326
369
  requiresConfirmation: true,
327
- message: 'Review the park note above. Call park_task again with confirmed=true to save it.',
370
+ message: 'All changes are pushed. Review the park note above then call park_task again with confirmed=true.',
328
371
  })
329
372
  }
330
- return call(() => api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers }))
373
+
374
+ const res = await api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers })
375
+ // Remove cursor rules file while task is parked — another task may be active
376
+ let cursorRulesCleared = null
377
+ if (task?.cursorRules?.trim()) {
378
+ cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
379
+ }
380
+ return text({
381
+ ...(res?.data || {}),
382
+ cursorRulesCleared,
383
+ handoff: task?.github?.headBranch
384
+ ? { branch: task.github.headBranch, checkoutSteps: ['git fetch origin', `git checkout ${task.github.headBranch}`] }
385
+ : null,
386
+ })
331
387
  }
332
388
  )
333
389
 
334
390
  server.tool(
335
391
  'unpark_task',
336
- 'Resume a parked task. Set confirmed=false first to preview, then confirmed=true to execute.',
392
+ 'Resume a parked task shows exactly what the previous developer left behind and what to do next.',
337
393
  {
338
394
  taskId: z.string().describe("Task's MongoDB ObjectId"),
339
395
  confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
396
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory). Used to restore cursor rules file.'),
340
397
  },
341
- async ({ taskId, confirmed = false }) => {
398
+ async ({ taskId, confirmed = false, repoPath }) => {
399
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
400
+ const task = taskRes?.data?.task
401
+ const branch = task?.github?.headBranch || null
342
402
  if (!confirmed) {
343
- const taskRes = await api.get(`/api/tasks/${taskId}`)
344
- const task = taskRes?.data?.task
345
403
  return text({
346
404
  preview: {
347
405
  action: 'unpark_task',
348
406
  taskId,
349
- task: task ? { key: task.key, title: task.title, parkNote: task.parkNote } : null,
350
- willDo: 'Clear park note and mark task as active again',
407
+ task: task ? { key: task.key, title: task.title, column: task.column } : null,
408
+ parkNote: task?.parkNote || null,
409
+ branch,
410
+ handoffSteps: branch ? [
411
+ 'git fetch origin',
412
+ `git checkout ${branch}`,
413
+ 'git pull origin ' + branch + ' # get any commits the previous developer pushed',
414
+ ] : ['No branch linked yet — create one with create_branch'],
415
+ cursorRulesNote: task?.cursorRules?.trim() ? `Cursor rules will be restored to .cursor/rules/${task.key?.toLowerCase()}.mdc` : null,
416
+ willDo: 'Clear park note and mark task as active again',
351
417
  },
352
418
  requiresConfirmation: true,
353
- message: 'Call unpark_task again with confirmed=true to resume this task.',
419
+ message: 'Read the park note and handoff steps above, then call unpark_task again with confirmed=true to take ownership.',
354
420
  })
355
421
  }
356
- return call(() => api.patch(`/api/tasks/${taskId}/unpark`, {}))
422
+ const res = await api.patch(`/api/tasks/${taskId}/unpark`, {})
423
+ // Restore cursor rules file now that this task is active again
424
+ let cursorRulesFile = null
425
+ if (task?.cursorRules?.trim()) {
426
+ cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
427
+ }
428
+ return text({ ...(res?.data || {}), cursorRulesFile })
357
429
  }
358
430
  )
359
431
  }
@@ -848,8 +920,9 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
848
920
  {
849
921
  taskId: z.string().describe("Task's MongoDB ObjectId"),
850
922
  confirmed: z.boolean().optional().default(false).describe('Set true after reading the plan to move the task to in_progress'),
923
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory). Used to write cursor rules file.'),
851
924
  },
852
- async ({ taskId, confirmed = false }) => {
925
+ async ({ taskId, confirmed = false, repoPath }) => {
853
926
  const taskRes = await api.get(`/api/tasks/${taskId}`)
854
927
  if (!taskRes?.success) return errorText('Task not found')
855
928
  const task = taskRes.data.task
@@ -903,6 +976,39 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
903
976
  ]
904
977
  }
905
978
 
979
+ // ── Simultaneous work lock ──────────────────────────────────────────────────
980
+ // If the task is already in_progress with a branch, warn before proceeding.
981
+ // This prevents two developers from unknowingly working on the same task.
982
+ const alreadyActive = task.column === 'in_progress' && !!task.github?.headBranch
983
+ const activeAssignees = (task.assignees || []).map(a => a.name || a.email || a.toString())
984
+
985
+ if (!confirmed && alreadyActive) {
986
+ return text({
987
+ warning: {
988
+ type: 'simultaneous_work_detected',
989
+ message: `⚠️ This task is already in progress on branch "${task.github.headBranch}".`,
990
+ assignees: activeAssignees.length ? activeAssignees : ['(unassigned)'],
991
+ branch: task.github.headBranch,
992
+ parkNote: task.parkNote || null,
993
+ advice: activeAssignees.length
994
+ ? `Check with ${activeAssignees.join(', ')} before taking over. If they have parked this task their changes are pushed — checkout their branch instead of starting fresh.`
995
+ : `Someone may have been working on this. Check the branch before starting fresh.`,
996
+ checkoutSteps: [
997
+ 'git fetch origin',
998
+ `git checkout ${task.github.headBranch}`,
999
+ `git pull origin ${task.github.headBranch}`,
1000
+ ],
1001
+ },
1002
+ brief: {
1003
+ key: task.key,
1004
+ title: task.title,
1005
+ priority: task.priority,
1006
+ },
1007
+ requiresConfirmation: true,
1008
+ message: `Task is already in progress. Read the warning above. If you still want to take over, call kickoff_task again with confirmed=true.`,
1009
+ })
1010
+ }
1011
+
906
1012
  if (!confirmed) {
907
1013
  return text({
908
1014
  CURSOR_RULES: hasCursorRules
@@ -963,7 +1069,7 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
963
1069
  // Write cursor rules file to local repo immediately on kickoff
964
1070
  let cursorRulesFile = null
965
1071
  if (hasCursorRules) {
966
- cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules)
1072
+ cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
967
1073
  }
968
1074
 
969
1075
  return text({
@@ -1238,10 +1344,33 @@ function runGit(args, cwd) {
1238
1344
  }).trim()
1239
1345
  }
1240
1346
 
1347
+ /**
1348
+ * Find the root of the git repo starting from `startPath`.
1349
+ * If `startPath` itself is not a git repo, scan one level of subdirectories.
1350
+ * Returns the repo root string or null.
1351
+ */
1352
+ function findRepoRoot(startPath) {
1353
+ const base = startPath || process.cwd()
1354
+ try {
1355
+ return runGit('rev-parse --show-toplevel', base)
1356
+ } catch { /* not a git repo — try subdirectories */ }
1357
+ try {
1358
+ const entries = readdirSync(base, { withFileTypes: true })
1359
+ for (const entry of entries) {
1360
+ if (!entry.isDirectory()) continue
1361
+ try {
1362
+ return runGit('rev-parse --show-toplevel', join(base, entry.name))
1363
+ } catch { /* not a git repo */ }
1364
+ }
1365
+ } catch { /* can't read dir */ }
1366
+ return null
1367
+ }
1368
+
1241
1369
  /** Write task-specific cursor rules to .cursor/rules/<taskKey>.mdc in the local repo root. */
1242
- function writeCursorRulesFile(taskKey, rulesMarkdown) {
1370
+ function writeCursorRulesFile(taskKey, rulesMarkdown, startPath) {
1243
1371
  try {
1244
- const repoRoot = runGit('rev-parse --show-toplevel', process.cwd())
1372
+ const repoRoot = findRepoRoot(startPath)
1373
+ if (!repoRoot) return null
1245
1374
  const rulesDir = join(repoRoot, '.cursor', 'rules')
1246
1375
  mkdirSync(rulesDir, { recursive: true })
1247
1376
  const filePath = join(rulesDir, `${taskKey.toLowerCase()}.mdc`)
@@ -1254,9 +1383,10 @@ function writeCursorRulesFile(taskKey, rulesMarkdown) {
1254
1383
  }
1255
1384
 
1256
1385
  /** Delete the task-specific cursor rules file when work is complete. */
1257
- function deleteCursorRulesFile(taskKey) {
1386
+ function deleteCursorRulesFile(taskKey, startPath) {
1258
1387
  try {
1259
- const repoRoot = runGit('rev-parse --show-toplevel', process.cwd())
1388
+ const repoRoot = findRepoRoot(startPath)
1389
+ if (!repoRoot) return null
1260
1390
  const filePath = join(repoRoot, '.cursor', 'rules', `${taskKey.toLowerCase()}.mdc`)
1261
1391
  if (existsSync(filePath)) {
1262
1392
  unlinkSync(filePath)
@@ -1283,6 +1413,32 @@ function parseGitStatus(porcelain) {
1283
1413
  return { staged, unstaged, untracked, modified, localState }
1284
1414
  }
1285
1415
 
1416
+ /**
1417
+ * Returns how many commits the current branch is behind/ahead of its base branch,
1418
+ * and whether there are uncommitted changes. Fetches from origin first.
1419
+ * Returns null on any git error (e.g. no remote configured).
1420
+ */
1421
+ function getBranchSyncStatus(cwd, baseBranch = 'main') {
1422
+ try {
1423
+ runGit('fetch origin', cwd)
1424
+ } catch { /* no remote or no network — continue with local data */ }
1425
+ try {
1426
+ const behind = parseInt(runGit(`rev-list HEAD..origin/${baseBranch} --count`, cwd).trim(), 10) || 0
1427
+ const unpushed = parseInt(runGit(`rev-list origin/${baseBranch}..HEAD --count`, cwd).trim(), 10) || 0
1428
+ // Check if the remote tracking branch for the feature branch exists and has unpushed commits
1429
+ let unpushedOnBranch = 0
1430
+ try {
1431
+ const currentBranch = runGit('branch --show-current', cwd).trim()
1432
+ unpushedOnBranch = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, cwd).trim(), 10) || 0
1433
+ } catch { /* remote branch may not exist yet — all commits are unpushed */ }
1434
+ const porcelain = runGit('status --porcelain=v1', cwd)
1435
+ const { localState } = parseGitStatus(porcelain)
1436
+ return { behind, unpushed, unpushedOnBranch, localState, baseBranch }
1437
+ } catch {
1438
+ return null
1439
+ }
1440
+ }
1441
+
1286
1442
  // ── Git Workflow Tools ─────────────────────────────────────────────────────────
1287
1443
  // All write operations require confirmed=true after showing a preview.
1288
1444
  // This gives developers full visibility before anything is executed.
@@ -1690,7 +1846,7 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
1690
1846
  const freshTaskForRules = await api.get(`/api/tasks/${taskId}`).catch(() => null)
1691
1847
  const cursorRulesContent = freshTaskForRules?.data?.task?.cursorRules
1692
1848
  if (cursorRulesContent?.trim()) {
1693
- cursorRulesFile = writeCursorRulesFile(freshTaskForRules.data.task.key, cursorRulesContent)
1849
+ cursorRulesFile = writeCursorRulesFile(freshTaskForRules.data.task.key, cursorRulesContent, cwd)
1694
1850
  }
1695
1851
 
1696
1852
  const checkoutSteps = [
@@ -1977,8 +2133,9 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
1977
2133
  additionalNotes: z.string().optional().describe('Extra context to add to the PR body'),
1978
2134
  draft: z.boolean().optional().default(false).describe('Open as a draft PR (not yet ready for review)'),
1979
2135
  confirmed: z.boolean().optional().default(false).describe('Set true to create the PR after reviewing the preview'),
2136
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory). Used to delete cursor rules file.'),
1980
2137
  },
1981
- async ({ taskId, projectId, headBranch, additionalNotes = '', draft = false, confirmed = false }) => {
2138
+ async ({ taskId, projectId, headBranch, additionalNotes = '', draft = false, confirmed = false, repoPath }) => {
1982
2139
  if (scopedProjectId && projectId !== scopedProjectId) {
1983
2140
  return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
1984
2141
  }
@@ -2002,8 +2159,33 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2002
2159
  ].filter(v => v !== null).join('\n')
2003
2160
 
2004
2161
  if (!confirmed) {
2162
+ // ── Pre-flight: check branch sync status before allowing PR ──
2163
+ const cwd = repoPath || process.cwd()
2164
+ const repoRoot = findRepoRoot(cwd)
2165
+ const sync = repoRoot ? getBranchSyncStatus(repoRoot) : null
2166
+
2167
+ const preflight = { status: 'ok', warnings: [], blockers: [] }
2168
+ if (sync) {
2169
+ if (sync.behind > 0) {
2170
+ preflight.blockers.push(
2171
+ `Branch is ${sync.behind} commit(s) behind origin/${sync.baseBranch}. Rebase before raising PR to avoid merge conflicts: git fetch origin && git rebase origin/${sync.baseBranch}`
2172
+ )
2173
+ }
2174
+ if (sync.unpushedOnBranch > 0) {
2175
+ preflight.blockers.push(
2176
+ `You have ${sync.unpushedOnBranch} local commit(s) not pushed to remote. Push first: git push origin ${headBranch}`
2177
+ )
2178
+ }
2179
+ if (sync.localState === 'modified') {
2180
+ preflight.warnings.push('You have uncommitted local changes. Commit or stash them before the PR is merged.')
2181
+ }
2182
+ if (preflight.blockers.length > 0) preflight.status = 'blocked'
2183
+ else if (preflight.warnings.length > 0) preflight.status = 'warning'
2184
+ }
2185
+
2005
2186
  return text({
2006
- preview: {
2187
+ preflight,
2188
+ preview: preflight.status === 'blocked' ? null : {
2007
2189
  action: 'raise_pr',
2008
2190
  prTitle,
2009
2191
  prBody: bodyParts,
@@ -2011,8 +2193,10 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2011
2193
  draft,
2012
2194
  task: { key: task.key, title: task.title },
2013
2195
  },
2014
- requiresConfirmation: true,
2015
- message: `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
2196
+ requiresConfirmation: preflight.status !== 'blocked',
2197
+ message: preflight.status === 'blocked'
2198
+ ? `⛔ Cannot raise PR — fix the blockers above first.`
2199
+ : `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
2016
2200
  })
2017
2201
  }
2018
2202
 
@@ -2026,7 +2210,7 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2026
2210
  if (!res?.success) return errorText(res?.message || 'Could not create PR')
2027
2211
 
2028
2212
  // Delete the task-specific cursor rules file — coding is done
2029
- const deletedRulesFile = deleteCursorRulesFile(task.key)
2213
+ const deletedRulesFile = deleteCursorRulesFile(task.key, repoPath)
2030
2214
 
2031
2215
  return text({
2032
2216
  prNumber: res.data.prNumber,
@@ -2220,6 +2404,20 @@ branchAction values (only needed when current branch ≠ task branch):
2220
2404
 
2221
2405
  // Case 2: on correct branch — standard preview
2222
2406
  const pushCmd = `git push origin ${currentBranch}`
2407
+
2408
+ // Check if branch is behind base — warn developer to rebase before more commits pile up
2409
+ let divergenceWarning = null
2410
+ try {
2411
+ runGit('fetch origin', cwd)
2412
+ const repoRoot = findRepoRoot(cwd)
2413
+ if (repoRoot) {
2414
+ const behind = parseInt(runGit(`rev-list HEAD..origin/main --count`, repoRoot).trim(), 10) || 0
2415
+ if (behind > 0) {
2416
+ divergenceWarning = `⚠️ Your branch is ${behind} commit(s) behind origin/main. Rebase now to prevent merge conflicts later:\n git fetch origin && git rebase origin/main\nThen re-run your commits.`
2417
+ }
2418
+ }
2419
+ } catch { /* non-fatal */ }
2420
+
2223
2421
  return text({
2224
2422
  preview: {
2225
2423
  suggestedMessage: commitMsg,
@@ -2230,6 +2428,7 @@ branchAction values (only needed when current branch ≠ task branch):
2230
2428
  onCorrectBranch: !branchMismatch,
2231
2429
  changedFiles: changedFilesList,
2232
2430
  },
2431
+ divergenceWarning,
2233
2432
  unsafeUntrackedWarning: unsafeUntracked.length
2234
2433
  ? `These paths should NOT be committed — add them to .gitignore first: ${unsafeUntracked.join(', ')}`
2235
2434
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.6.9",
3
+ "version": "1.6.14",
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",