internaltool-mcp 1.6.40 → 1.6.42

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 +285 -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) {
@@ -289,6 +463,60 @@ Returns tasks with key, title, column, assignees, priority, taskType, and branch
289
463
  }
290
464
  )
291
465
 
466
+ // ── suggest_skill ─────────────────────────────────────────────────────────────
467
+ server.tool(
468
+ 'suggest_skill',
469
+ `Propose a reusable skill, rule, prompt, subagent, or hook to the project's AgentKit — pending human approval.
470
+
471
+ ## When to call this
472
+
473
+ Call this when you discover something during a coding session that would help future sessions in this codebase:
474
+
475
+ - **skill**: A step-by-step methodology you used (e.g. "how to add a new API endpoint in this Express app", "how to run and interpret the test suite")
476
+ - **rule**: A constraint that should always apply (e.g. "always validate with zod before touching the DB", "never modify migrations directly")
477
+ - **prompt**: A prompt template that reliably produces good results for this project (e.g. "security sweep prompt", "PR review checklist")
478
+ - **subagent**: A specialized agent persona (e.g. "database migration specialist with access to prisma tools only")
479
+ - **hook**: A shell command that should run automatically (e.g. "check .internaltool-active-task before any Edit", "run lint after Write")
480
+
481
+ ## When NOT to call this
482
+
483
+ - Do not suggest things that are obvious from the README or existing docs
484
+ - Do not suggest generic skills that apply to any codebase (e.g. "how to write clean code")
485
+ - Do not suggest more than 1-2 skills per session — be selective and high-signal
486
+
487
+ ## The human reviews and decides
488
+
489
+ Your suggestion goes into a "Pending" queue visible in the project's AgentKit UI.
490
+ The developer can edit the body, approve (which promotes it to active config), or reject.
491
+ After approval, the skill/rule is injected at every future kickoff_task.`,
492
+ {
493
+ projectId: z.string().describe("Project's MongoDB ObjectId"),
494
+ type: z.enum(['skill', 'rule', 'prompt', 'subagent', 'hook']).describe('What kind of suggestion this is'),
495
+ name: z.string().describe('Slug name — lowercase, hyphens, no spaces (e.g. "add-express-route", "require-zod-validation")'),
496
+ description: z.string().describe('One-line description of what this does — shown in the AgentKit UI'),
497
+ body: z.string().describe('Full markdown content — for skills: step-by-step instructions; for rules: constraint language; for hooks: shell command + explanation'),
498
+ rationale: z.string().describe('Why this would help future sessions — what problem it solves, what mistake it prevents, or what pattern it captures'),
499
+ sourceTaskId: z.string().optional().describe('Task ID this was discovered during (for traceability)'),
500
+ sourceTaskKey: z.string().optional().describe('Task key (e.g. PROJ-42) for display'),
501
+ // Hook-specific
502
+ hookTrigger: z.string().optional().describe('For type=hook: when it fires (PreToolUse | PostToolUse | Stop)'),
503
+ hookMatcher: z.string().optional().describe('For type=hook: tool name regex matcher (e.g. "Edit|Write")'),
504
+ hookCommand: z.string().optional().describe('For type=hook: the shell command to execute'),
505
+ },
506
+ async ({ projectId, type, name, description, body, rationale, sourceTaskId, sourceTaskKey, hookTrigger, hookMatcher, hookCommand }) => {
507
+ try { assertProjectScope(projectId) } catch (e) { return errorText(e.message) }
508
+ return call(() => api.post(`/api/projects/${projectId}/skill-suggestions`, {
509
+ type, name, description, body, rationale,
510
+ sourceTaskId: sourceTaskId || null,
511
+ sourceTaskKey: sourceTaskKey || '',
512
+ suggestedBy: 'Claude Code',
513
+ hookTrigger: hookTrigger || '',
514
+ hookMatcher: hookMatcher || '',
515
+ hookCommand: hookCommand || '',
516
+ }))
517
+ }
518
+ )
519
+
292
520
  // ── plan_task_from_codebase ───────────────────────────────────────────────────
293
521
  server.tool(
294
522
  'plan_task_from_codebase',
@@ -370,7 +598,13 @@ Do NOT ask the developer to describe the codebase — read it yourself.`,
370
598
  return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
371
599
  }
372
600
 
373
- // Fetch project context so the agent knows the project name and any existing conventions
601
+ // ── 1. Run codebase analysis using local filesystem access ────────────────
602
+ const cwd = process.cwd()
603
+ const stack = detectStack(cwd)
604
+ const dirTree = getDirTree(cwd, 2)
605
+ const entryPoints = findEntryPoints(cwd, stack)
606
+
607
+ // ── 2. Fetch project context ──────────────────────────────────────────────
374
608
  let projectContext = null
375
609
  try {
376
610
  const projRes = await api.get(`/api/projects/${projectId}`)
@@ -384,63 +618,58 @@ Do NOT ask the developer to describe the codebase — read it yourself.`,
384
618
  }
385
619
  } catch { /* non-fatal */ }
386
620
 
387
- // Check for duplicate tasks first
388
- let duplicateCheck = null
621
+ // ── 3. Duplicate check ────────────────────────────────────────────────────
622
+ let similarTasks = null
389
623
  try {
390
624
  const keywords = request.split(' ').filter(w => w.length > 4).slice(0, 3).join(' ')
391
625
  const searchRes = await api.get(
392
626
  `/api/projects/${projectId}/tasks/search?q=${encodeURIComponent(keywords)}&limit=3`
393
627
  )
394
628
  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,
629
+ similarTasks = searchRes.data.tasks.map(t => ({
630
+ taskId: t._id, key: t.key, title: t.title, column: t.column, taskType: t.taskType,
401
631
  }))
402
632
  }
403
633
  } catch { /* non-fatal */ }
404
634
 
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
- }
635
+ // ── 4. Return codebase intelligence + tight instructions ──────────────────
636
+ const hasEntryPoints = Object.keys(entryPoints).length > 0
637
+ const stackSummary = [
638
+ stack.language, stack.framework, stack.testRunner, ...(stack.extra || [])
639
+ ].filter(Boolean).join(', ') || 'unknown (no manifest found at cwd)'
427
640
 
428
- // No duplicate — return the analysis brief
429
641
  return text({
430
- projectId,
642
+ // Real codebase data — use these directly, do NOT re-read what's already here
643
+ codebaseIntelligence: {
644
+ cwd,
645
+ stack: { ...stack, summary: stackSummary },
646
+ dirTree,
647
+ entryPoints: hasEntryPoints
648
+ ? entryPoints
649
+ : { note: 'No entry points found at cwd — check that MCP server is running from the project root' },
650
+ },
651
+
652
+ // Duplicate guard
653
+ ...(similarTasks?.length > 0 && {
654
+ duplicateWarning: true,
655
+ similarTasks,
656
+ duplicateInstruction: 'Check the list above. If one matches → call kickoff_task on it. If none match → proceed.',
657
+ }),
658
+
659
+ // What to do now — use codebaseIntelligence above to skip re-reading the filesystem
660
+ nextSteps: [
661
+ 'Use dirTree + entryPoints above to identify which files need changing — you already have the map.',
662
+ `Read 2-3 files from entryPoints.routes / entryPoints.models / entryPoints.components to extract naming conventions and wiring patterns.`,
663
+ 'Draft readmeMarkdown with: ## Goal, ## Stack, ## Technical approach (name real files), ## Files to create, ## Files to modify, ## Subtasks (ordered), ## Acceptance criteria.',
664
+ `Call create_task(projectId="${projectId}", title="<verb + noun>", readmeMarkdown="<plan>", taskType="<feature|bugfix|...>", priority="${priority}", column="todo", subtasks=[...], suggestedFiles=[...])`,
665
+ 'Immediately after: call kickoff_task(taskId=<returned id>, confirmed=true, agentRole="builder", files=[...suggestedFiles])',
666
+ ],
667
+
668
+ // Context
431
669
  request,
670
+ projectId,
432
671
  priority,
433
672
  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
673
  })
445
674
  }
446
675
  )
@@ -680,6 +909,9 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
680
909
  agentWorkspace: { clearedAt: new Date().toISOString() }
681
910
  }).catch(() => {/* non-fatal */})
682
911
 
912
+ // Remove .internaltool-active-task so the Claude Code hook blocks edits until next kickoff
913
+ try { unlinkSync(join(process.cwd(), '.internaltool-active-task')) } catch { /* non-fatal */ }
914
+
683
915
  // ── #9 Capture last commit for handoff metadata ───────────────────────
684
916
  const lastCommit = getLastCommitMeta(repoRoot)
685
917
 
@@ -3913,6 +4145,15 @@ After \`request_human_input\`: STOP, show the question in chat, wait for reply,
3913
4145
  }
3914
4146
  api.patch(`/api/tasks/${taskId}`, workspacePatch).catch(() => {/* non-fatal */})
3915
4147
 
4148
+ // Write .internaltool-active-task so the Claude Code hook knows a task is active
4149
+ try {
4150
+ writeFileSync(
4151
+ join(process.cwd(), '.internaltool-active-task'),
4152
+ JSON.stringify({ taskId, taskKey: task.key, title: task.title, agentRole: agentRole || null, kickedOffAt: new Date().toISOString() }, null, 2),
4153
+ 'utf8'
4154
+ )
4155
+ } catch { /* non-fatal — hook gracefully degrades if file can't be written */ }
4156
+
3916
4157
  // Auto-scan workspace so the Workspace tab in the UI is fresh immediately after kickoff.
3917
4158
  if (kickoffProject) {
3918
4159
  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.42",
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",