internaltool-mcp 1.6.40 → 1.6.41

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 +231 -44
  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, readdirSync, statSync } from 'fs'
21
+ import { mkdirSync, writeFileSync, readFileSync, 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'
@@ -83,6 +83,180 @@ async function assertAdmin() {
83
83
  }
84
84
  }
85
85
 
86
+ // ── Codebase analysis helpers (used by plan_task_from_codebase) ───────────────
87
+
88
+ /**
89
+ * Reads manifest files in cwd and returns detected stack info.
90
+ * Works for any language — JS/TS, Python, Go, Ruby, Java, Rust, PHP.
91
+ */
92
+ function detectStack(cwd) {
93
+ const stack = { language: null, framework: null, testRunner: null, packageManager: null, extra: [] }
94
+ try {
95
+ // Node.js / JS / TS
96
+ const pkgPath = join(cwd, 'package.json')
97
+ if (existsSync(pkgPath)) {
98
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
99
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
100
+ stack.language = pkg.scripts?.build?.includes('tsc') || existsSync(join(cwd, 'tsconfig.json')) ? 'typescript' : 'javascript'
101
+ stack.packageManager = existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(cwd, 'yarn.lock')) ? 'yarn' : 'npm'
102
+ // Framework detection
103
+ if (deps.next) stack.framework = 'nextjs'
104
+ else if (deps.nuxt) stack.framework = 'nuxt'
105
+ else if (deps.react) stack.framework = 'react'
106
+ else if (deps.vue) stack.framework = 'vue'
107
+ else if (deps.svelte) stack.framework = 'svelte'
108
+ else if (deps.express) stack.framework = 'express'
109
+ else if (deps.fastify) stack.framework = 'fastify'
110
+ else if (deps.nestjs || deps['@nestjs/core']) stack.framework = 'nestjs'
111
+ else if (deps.hono) stack.framework = 'hono'
112
+ // Test runner
113
+ if (deps.jest || deps['@jest/core']) stack.testRunner = 'jest'
114
+ else if (deps.vitest) stack.testRunner = 'vitest'
115
+ else if (deps.mocha) stack.testRunner = 'mocha'
116
+ else if (deps.playwright || deps['@playwright/test']) stack.testRunner = 'playwright'
117
+ // ORM / DB
118
+ if (deps.mongoose) stack.extra.push('mongoose')
119
+ if (deps.prisma) stack.extra.push('prisma')
120
+ if (deps.drizzle) stack.extra.push('drizzle')
121
+ if (deps.typeorm) stack.extra.push('typeorm')
122
+ if (deps.sequelize) stack.extra.push('sequelize')
123
+ if (deps['@supabase/supabase-js']) stack.extra.push('supabase')
124
+ if (deps.trpc || deps['@trpc/server']) stack.extra.push('trpc')
125
+ return stack
126
+ }
127
+ // Python
128
+ const reqPath = join(cwd, 'requirements.txt')
129
+ const pyprojPath = join(cwd, 'pyproject.toml')
130
+ if (existsSync(reqPath) || existsSync(pyprojPath)) {
131
+ stack.language = 'python'
132
+ const content = existsSync(reqPath) ? readFileSync(reqPath, 'utf8') : readFileSync(pyprojPath, 'utf8')
133
+ if (content.includes('django')) stack.framework = 'django'
134
+ else if (content.includes('fastapi')) stack.framework = 'fastapi'
135
+ else if (content.includes('flask')) stack.framework = 'flask'
136
+ else if (content.includes('starlette')) stack.framework = 'starlette'
137
+ if (content.includes('pytest')) stack.testRunner = 'pytest'
138
+ if (content.includes('sqlalchemy')) stack.extra.push('sqlalchemy')
139
+ if (content.includes('alembic')) stack.extra.push('alembic')
140
+ return stack
141
+ }
142
+ // Go
143
+ if (existsSync(join(cwd, 'go.mod'))) {
144
+ stack.language = 'go'
145
+ const gomod = readFileSync(join(cwd, 'go.mod'), 'utf8')
146
+ if (gomod.includes('gin-gonic/gin')) stack.framework = 'gin'
147
+ else if (gomod.includes('go-chi/chi')) stack.framework = 'chi'
148
+ else if (gomod.includes('labstack/echo')) stack.framework = 'echo'
149
+ else if (gomod.includes('gofiber/fiber')) stack.framework = 'fiber'
150
+ stack.testRunner = 'go test'
151
+ return stack
152
+ }
153
+ // Ruby
154
+ if (existsSync(join(cwd, 'Gemfile'))) {
155
+ stack.language = 'ruby'
156
+ const gemfile = readFileSync(join(cwd, 'Gemfile'), 'utf8')
157
+ if (gemfile.includes('rails')) stack.framework = 'rails'
158
+ else if (gemfile.includes('sinatra')) stack.framework = 'sinatra'
159
+ if (gemfile.includes('rspec')) stack.testRunner = 'rspec'
160
+ return stack
161
+ }
162
+ // Rust
163
+ if (existsSync(join(cwd, 'Cargo.toml'))) {
164
+ stack.language = 'rust'
165
+ const cargo = readFileSync(join(cwd, 'Cargo.toml'), 'utf8')
166
+ if (cargo.includes('actix-web')) stack.framework = 'actix-web'
167
+ else if (cargo.includes('axum')) stack.framework = 'axum'
168
+ else if (cargo.includes('warp')) stack.framework = 'warp'
169
+ stack.testRunner = 'cargo test'
170
+ return stack
171
+ }
172
+ // Java / Kotlin
173
+ if (existsSync(join(cwd, 'pom.xml')) || existsSync(join(cwd, 'build.gradle'))) {
174
+ stack.language = existsSync(join(cwd, 'build.gradle.kts')) ? 'kotlin' : 'java'
175
+ stack.framework = 'spring-boot'
176
+ stack.testRunner = 'junit'
177
+ return stack
178
+ }
179
+ // PHP
180
+ if (existsSync(join(cwd, 'composer.json'))) {
181
+ stack.language = 'php'
182
+ const composer = JSON.parse(readFileSync(join(cwd, 'composer.json'), 'utf8'))
183
+ const req = { ...composer.require, ...composer['require-dev'] }
184
+ if (req['laravel/framework']) stack.framework = 'laravel'
185
+ else if (req['symfony/symfony'] || req['symfony/framework-bundle']) stack.framework = 'symfony'
186
+ return stack
187
+ }
188
+ } catch { /* non-fatal */ }
189
+ return stack
190
+ }
191
+
192
+ /**
193
+ * Returns a shallow directory tree (depth 2) so the agent knows the folder layout.
194
+ * Ignores node_modules, .git, vendor, __pycache__, dist, build, .next
195
+ */
196
+ function getDirTree(cwd, maxDepth = 2) {
197
+ const IGNORE = new Set(['node_modules', '.git', 'vendor', '__pycache__', 'dist', 'build', '.next', '.turbo', 'coverage', '.cache', 'target', 'venv', '.venv'])
198
+ function walk(dir, depth) {
199
+ if (depth > maxDepth) return []
200
+ let entries
201
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] }
202
+ return entries
203
+ .filter(e => !IGNORE.has(e.name) && !e.name.startsWith('.'))
204
+ .map(e => {
205
+ if (e.isDirectory()) {
206
+ const children = walk(join(dir, e.name), depth + 1)
207
+ return { name: e.name + '/', children: children.length ? children : undefined }
208
+ }
209
+ return { name: e.name }
210
+ })
211
+ }
212
+ return walk(cwd, 0)
213
+ }
214
+
215
+ /**
216
+ * Based on detected stack, grep for the most likely entry-point files.
217
+ * Returns a list of { pattern, matches } — actual file paths found locally.
218
+ */
219
+ function findEntryPoints(cwd, stack) {
220
+ const results = {}
221
+ const greps = {
222
+ javascript: { typescript: true,
223
+ routes: ['router\\.', 'app\\.get\\|app\\.post\\|app\\.put\\|fastify\\.', 'Route path='],
224
+ models: ['mongoose\\.Schema\\|new Schema\\|@Entity\\|@Table'],
225
+ components: ['export default function\\|export const.*=.*=>\\|React\\.FC'],
226
+ },
227
+ python: {
228
+ routes: ['@app\\.route\\|@router\\.\\|path(\\|urlpatterns\\|@api_view'],
229
+ models: ['class.*Model\\|db\\.Model\\|Base\\)\\|models\\.Model'],
230
+ schemas: ['class.*Schema\\|serializers\\.\\|Pydantic\\|BaseModel'],
231
+ },
232
+ go: {
233
+ routes: ['router\\.\\.\\|http\\.HandleFunc\\|r\\.GET\\|r\\.POST\\|e\\.GET'],
234
+ models: ['type.*struct\\|gorm\\.Model'],
235
+ },
236
+ ruby: {
237
+ routes: ['resources \\|get \'\\|post \'\\|Rails\\.application\\.routes'],
238
+ models: ['ApplicationRecord\\|ActiveRecord::Base\\|belongs_to\\|has_many'],
239
+ },
240
+ }
241
+
242
+ const lang = stack.language === 'typescript' ? 'javascript' : stack.language
243
+ const patterns = greps[lang] || {}
244
+
245
+ for (const [type, patternList] of Object.entries(patterns)) {
246
+ if (type === 'typescript') continue
247
+ try {
248
+ const pat = Array.isArray(patternList) ? patternList.join('\\|') : patternList
249
+ const out = execSync(
250
+ `grep -rl "${pat}" . --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx" --include="*.py" --include="*.go" --include="*.rb" --include="*.java" --include="*.kt" --include="*.rs" --include="*.php" 2>/dev/null | grep -v node_modules | grep -v ".git" | head -8`,
251
+ { cwd, encoding: 'utf8', timeout: 4000 }
252
+ )
253
+ const files = out.trim().split('\n').filter(Boolean)
254
+ if (files.length) results[type] = files
255
+ } catch { /* non-fatal */ }
256
+ }
257
+ return results
258
+ }
259
+
86
260
  // ── Tool registration functions ───────────────────────────────────────────────
87
261
 
88
262
  function registerAuthTools(server) {
@@ -370,7 +544,13 @@ Do NOT ask the developer to describe the codebase — read it yourself.`,
370
544
  return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
371
545
  }
372
546
 
373
- // Fetch project context so the agent knows the project name and any existing conventions
547
+ // ── 1. Run codebase analysis using local filesystem access ────────────────
548
+ const cwd = process.cwd()
549
+ const stack = detectStack(cwd)
550
+ const dirTree = getDirTree(cwd, 2)
551
+ const entryPoints = findEntryPoints(cwd, stack)
552
+
553
+ // ── 2. Fetch project context ──────────────────────────────────────────────
374
554
  let projectContext = null
375
555
  try {
376
556
  const projRes = await api.get(`/api/projects/${projectId}`)
@@ -384,63 +564,58 @@ Do NOT ask the developer to describe the codebase — read it yourself.`,
384
564
  }
385
565
  } catch { /* non-fatal */ }
386
566
 
387
- // Check for duplicate tasks first
388
- let duplicateCheck = null
567
+ // ── 3. Duplicate check ────────────────────────────────────────────────────
568
+ let similarTasks = null
389
569
  try {
390
570
  const keywords = request.split(' ').filter(w => w.length > 4).slice(0, 3).join(' ')
391
571
  const searchRes = await api.get(
392
572
  `/api/projects/${projectId}/tasks/search?q=${encodeURIComponent(keywords)}&limit=3`
393
573
  )
394
574
  if (searchRes?.success && searchRes.data?.tasks?.length > 0) {
395
- duplicateCheck = searchRes.data.tasks.map(t => ({
396
- taskId: t._id,
397
- key: t.key,
398
- title: t.title,
399
- column: t.column,
400
- taskType: t.taskType,
575
+ similarTasks = searchRes.data.tasks.map(t => ({
576
+ taskId: t._id, key: t.key, title: t.title, column: t.column, taskType: t.taskType,
401
577
  }))
402
578
  }
403
579
  } catch { /* non-fatal */ }
404
580
 
405
- if (duplicateCheck?.length > 0) {
406
- return text({
407
- duplicateWarning: true,
408
- message: `Found ${duplicateCheck.length} potentially similar task(s). Review before creating a duplicate.`,
409
- similarTasks: duplicateCheck,
410
- instruction: [
411
- 'If one of these IS the same task: call kickoff_task on it instead of creating a new one.',
412
- 'If none match: proceed with the analysis protocol below.',
413
- ],
414
- analysisProtocol: {
415
- step1: 'Read package.json / go.mod / requirements.txt to identify the stack',
416
- step2: 'Grep for existing patterns relevant to the request',
417
- step3: 'Identify files to create and modify',
418
- step4: `Call create_task(projectId="${projectId}", title="...", readmeMarkdown="...", taskType="...", priority="${priority}", column="todo", subtasks=[...], suggestedFiles=[...])`,
419
- step5: 'Call kickoff_task(taskId=<id>, confirmed=true, agentRole="builder", files=[...suggestedFiles])',
420
- },
421
- projectContext,
422
- request,
423
- projectId,
424
- priority,
425
- })
426
- }
581
+ // ── 4. Return codebase intelligence + tight instructions ──────────────────
582
+ const hasEntryPoints = Object.keys(entryPoints).length > 0
583
+ const stackSummary = [
584
+ stack.language, stack.framework, stack.testRunner, ...(stack.extra || [])
585
+ ].filter(Boolean).join(', ') || 'unknown (no manifest found at cwd)'
427
586
 
428
- // No duplicate — return the analysis brief
429
587
  return text({
430
- projectId,
588
+ // Real codebase data — use these directly, do NOT re-read what's already here
589
+ codebaseIntelligence: {
590
+ cwd,
591
+ stack: { ...stack, summary: stackSummary },
592
+ dirTree,
593
+ entryPoints: hasEntryPoints
594
+ ? entryPoints
595
+ : { note: 'No entry points found at cwd — check that MCP server is running from the project root' },
596
+ },
597
+
598
+ // Duplicate guard
599
+ ...(similarTasks?.length > 0 && {
600
+ duplicateWarning: true,
601
+ similarTasks,
602
+ duplicateInstruction: 'Check the list above. If one matches → call kickoff_task on it. If none match → proceed.',
603
+ }),
604
+
605
+ // What to do now — use codebaseIntelligence above to skip re-reading the filesystem
606
+ nextSteps: [
607
+ 'Use dirTree + entryPoints above to identify which files need changing — you already have the map.',
608
+ `Read 2-3 files from entryPoints.routes / entryPoints.models / entryPoints.components to extract naming conventions and wiring patterns.`,
609
+ 'Draft readmeMarkdown with: ## Goal, ## Stack, ## Technical approach (name real files), ## Files to create, ## Files to modify, ## Subtasks (ordered), ## Acceptance criteria.',
610
+ `Call create_task(projectId="${projectId}", title="<verb + noun>", readmeMarkdown="<plan>", taskType="<feature|bugfix|...>", priority="${priority}", column="todo", subtasks=[...], suggestedFiles=[...])`,
611
+ 'Immediately after: call kickoff_task(taskId=<returned id>, confirmed=true, agentRole="builder", files=[...suggestedFiles])',
612
+ ],
613
+
614
+ // Context
431
615
  request,
616
+ projectId,
432
617
  priority,
433
618
  projectContext,
434
- analysisProtocol: {
435
- overview: 'Follow these steps IN ORDER before calling create_task',
436
- step1_stack: 'Read package.json / go.mod / requirements.txt / Cargo.toml — detect language, framework, test runner',
437
- step2_entrypoint: 'Grep for the relevant area: routes, controllers, components, models — find where similar features live',
438
- step3_patterns: 'Read 2-3 existing similar files — note naming conventions, folder structure, wiring patterns',
439
- step4_impact: 'List every file to CREATE and every file to MODIFY — be exhaustive',
440
- step5_create: `Call create_task(projectId="${projectId}", title="<action-verb noun>", readmeMarkdown="<full plan>", taskType="<type>", priority="${priority}", column="todo", subtasks=[{title: "step1"}, ...], suggestedFiles=["path/to/file1", ...])`,
441
- step6_kickoff: 'Call kickoff_task(taskId=<returned id>, confirmed=true, agentRole="builder", files=[...suggestedFiles])',
442
- },
443
- reminder: 'Do NOT call create_task before completing steps 1-4. The readmeMarkdown must reference real file paths from your analysis.',
444
619
  })
445
620
  }
446
621
  )
@@ -680,6 +855,9 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
680
855
  agentWorkspace: { clearedAt: new Date().toISOString() }
681
856
  }).catch(() => {/* non-fatal */})
682
857
 
858
+ // Remove .internaltool-active-task so the Claude Code hook blocks edits until next kickoff
859
+ try { unlinkSync(join(process.cwd(), '.internaltool-active-task')) } catch { /* non-fatal */ }
860
+
683
861
  // ── #9 Capture last commit for handoff metadata ───────────────────────
684
862
  const lastCommit = getLastCommitMeta(repoRoot)
685
863
 
@@ -3913,6 +4091,15 @@ After \`request_human_input\`: STOP, show the question in chat, wait for reply,
3913
4091
  }
3914
4092
  api.patch(`/api/tasks/${taskId}`, workspacePatch).catch(() => {/* non-fatal */})
3915
4093
 
4094
+ // Write .internaltool-active-task so the Claude Code hook knows a task is active
4095
+ try {
4096
+ writeFileSync(
4097
+ join(process.cwd(), '.internaltool-active-task'),
4098
+ JSON.stringify({ taskId, taskKey: task.key, title: task.title, agentRole: agentRole || null, kickedOffAt: new Date().toISOString() }, null, 2),
4099
+ 'utf8'
4100
+ )
4101
+ } catch { /* non-fatal — hook gracefully degrades if file can't be written */ }
4102
+
3916
4103
  // Auto-scan workspace so the Workspace tab in the UI is fresh immediately after kickoff.
3917
4104
  if (kickoffProject) {
3918
4105
  runWorkspaceScan(taskId, task, kickoffProject, repoPath).catch(() => {/* non-fatal */})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.6.40",
3
+ "version": "1.6.41",
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",