diff --git a/apps/nx-mcp-e2e/src/nx-project-details-compressed-targets.test.ts b/apps/nx-mcp-e2e/src/nx-project-details-compressed-targets.test.ts index b5a6280766..16b82d1f74 100644 --- a/apps/nx-mcp-e2e/src/nx-project-details-compressed-targets.test.ts +++ b/apps/nx-mcp-e2e/src/nx-project-details-compressed-targets.test.ts @@ -174,8 +174,10 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); - expect(targetsBlock).toContain( - 'target-nx-executor: @nx/webpack:webpack | cache: true', + expect(targetsBlock).toContain('target-nx-executor: @nx/webpack:webpack'); + // Cache is true by default, so it should NOT be shown (token efficiency) + expect(targetsBlock).not.toContain( + 'target-nx-executor: @nx/webpack:webpack | cache:', ); }); @@ -190,7 +192,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - 'target-custom-executor: @my-org/custom:build | cache: true', + 'target-custom-executor: @my-org/custom:build', ); }); @@ -205,7 +207,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - "target-run-cmd-options: nx:run-commands - 'echo test' | cache: true", + "target-run-cmd-options: nx:run-commands - 'echo test'", ); }); @@ -220,7 +222,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - "target-run-cmd-array-single-str: nx:run-commands - 'npm build' | cache: true", + "target-run-cmd-array-single-str: nx:run-commands - 'npm build'", ); }); @@ -235,7 +237,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - "target-run-cmd-array-single-obj: nx:run-commands - 'npm test' | cache: true", + "target-run-cmd-array-single-obj: nx:run-commands - 'npm test'", ); }); @@ -250,7 +252,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - 'target-run-cmd-array-multi: nx:run-commands - 3 commands | cache: true', + 'target-run-cmd-array-multi: nx:run-commands - 3 commands', ); }); @@ -264,9 +266,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); - expect(targetsBlock).toContain( - "deploy: nx:run-script - 'npm run deploy' | cache: true", - ); + expect(targetsBlock).toContain("deploy: nx:run-script - 'npm run deploy'"); }); it('should display nx:run-script target from package.json script - custom-hello', () => { @@ -280,7 +280,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - "custom-hello: nx:run-script - 'npm run custom-hello' | cache: true", + "custom-hello: nx:run-script - 'npm run custom-hello'", ); }); @@ -295,7 +295,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - 'target-with-deps-string: @nx/js:tsc | depends: [build, test] | cache: true', + 'target-with-deps-string: @nx/js:tsc | depends: [build, test]', ); }); @@ -310,7 +310,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - 'target-with-deps-object: @nx/js:tsc | depends: [build, ^build] | cache: true', + 'target-with-deps-object: @nx/js:tsc | depends: [build, ^build]', ); }); @@ -339,8 +339,10 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); - expect(targetsBlock).toContain( - 'target-with-cache: @nx/js:tsc | cache: true', + // When cache is true (default), it should NOT be displayed (token efficiency) + expect(targetsBlock).toContain('target-with-cache: @nx/js:tsc'); + expect(targetsBlock).not.toContain( + 'target-with-cache: @nx/js:tsc | cache:', ); }); @@ -405,7 +407,7 @@ describe('nx_project_details compressed targets', () => { const targetsBlock = getCompressedTargetsBlock(result); expect(targetsBlock).toBeDefined(); expect(targetsBlock).toContain( - 'target-empty-cmds: nx:run-commands - 0 commands | cache: true', + 'target-empty-cmds: nx:run-commands - 0 commands', ); }); diff --git a/apps/nx-mcp-e2e/src/nx-project-details-select.test.ts b/apps/nx-mcp-e2e/src/nx-project-details-select.test.ts index 3b47b41871..a4c67e0fa2 100644 --- a/apps/nx-mcp-e2e/src/nx-project-details-select.test.ts +++ b/apps/nx-mcp-e2e/src/nx-project-details-select.test.ts @@ -55,7 +55,9 @@ describe('nx_project_details select', () => { expect(result.content[1]?.text).toContain( 'To see full configuration for a specific target', ); - expect(result.content[1]?.text).toContain('cache:'); + // Cache should only appear when it's false (token efficiency) + // So we should see "cache: false" somewhere, but not "cache: true" + expect(result.content[1]?.text).not.toContain('cache: true'); // Third block should be External Dependencies expect(result.content[2]?.text).toContain('External Dependencies:'); diff --git a/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.test.ts b/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.test.ts index be509a667f..693f11b2ba 100644 --- a/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.test.ts +++ b/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.test.ts @@ -2,6 +2,7 @@ import { getTokenOptimizedToolResult, chunkContent, registerNxWorkspaceTools, + __testing__, } from './nx-workspace'; import { NxWorkspace, NxError } from '@nx-console/shared-types'; import { @@ -12,16 +13,15 @@ import { NX_PROJECT_DETAILS, } from '@nx-console/shared-llm-context'; -jest.mock('@nx-console/shared-llm-context', () => ({ - getNxJsonPrompt: jest.fn(), - getProjectGraphPrompt: jest.fn(), - getProjectGraphErrorsPrompt: jest.fn(), - NX_WORKSPACE: 'nx_workspace', - NX_PROJECT_DETAILS: 'nx_project_details', - NX_GENERATORS: 'nx_generators', - NX_GENERATOR_SCHEMA: 'nx_generator_schema', - NX_WORKSPACE_PATH: 'nx_workspace_path', -})); +jest.mock('@nx-console/shared-llm-context', () => { + const actual = jest.requireActual('@nx-console/shared-llm-context'); + return { + ...actual, + getNxJsonPrompt: jest.fn(), + getProjectGraphPrompt: jest.fn(), + getProjectGraphErrorsPrompt: jest.fn(), + }; +}); // Mock shared-npm module - don't import to avoid lazy-load conflict jest.mock('@nx-console/shared-npm', () => ({ @@ -512,3 +512,150 @@ describe('registerNxWorkspaceTools', () => { }); }); }); + +describe('compressTargetForDisplay', () => { + const { compressTargetForDisplay } = __testing__; + + it('should omit cache status when true', () => { + const config = { + executor: 'nx:run-commands', + command: 'echo test', + cache: true, + }; + + const result = compressTargetForDisplay('build', config); + + expect(result).toBe("build: nx:run-commands - 'echo test'"); + expect(result).not.toContain('cache'); + }); + + it('should show cache status when false', () => { + const config = { + executor: 'nx:run-commands', + command: 'echo test', + cache: false, + }; + + const result = compressTargetForDisplay('build', config); + + expect(result).toBe("build: nx:run-commands - 'echo test' | cache: false"); + }); + + it('should truncate long dependency lists', () => { + const config = { + executor: '@nx/gradle:gradle', + dependsOn: [ + 'dep1', + 'dep2', + 'dep3', + 'dep4', + 'dep5', + 'dep6', + 'dep7', + 'dep8', + 'dep9', + 'dep10', + 'dep11', + ], + cache: true, + }; + + const result = compressTargetForDisplay('build', config); + + expect(result).toBe( + 'build: @nx/gradle:gradle | depends: [dep1, dep2, dep3, +8 more]', + ); + }); + + it('should not truncate short dependency lists', () => { + const config = { + executor: '@nx/gradle:gradle', + dependsOn: ['dep1', 'dep2', 'dep3'], + cache: true, + }; + + const result = compressTargetForDisplay('build', config); + + expect(result).toBe( + 'build: @nx/gradle:gradle | depends: [dep1, dep2, dep3]', + ); + }); + + it('should show atomized targets with abbreviated names', () => { + const config = { + executor: 'nx:noop', + dependsOn: ['test-ci--Test1', 'test-ci--Test2', 'test-ci--Test3'], + cache: true, + }; + const atomizedTargets = [ + 'test-ci--Test1', + 'test-ci--Test2', + 'test-ci--Test3', + ]; + + const result = compressTargetForDisplay('test-ci', config, atomizedTargets); + + expect(result).toBe( + 'test-ci: nx:noop | depends: [3 atomized targets] | atomized: [Test1, Test2, Test3]', + ); + }); + + it('should truncate long atomized target lists', () => { + const config = { + executor: 'nx:noop', + cache: true, + }; + const atomizedTargets = [ + 'test-ci--Test1', + 'test-ci--Test2', + 'test-ci--Test3', + 'test-ci--Test4', + 'test-ci--Test5', + 'test-ci--Test6', + ]; + + const result = compressTargetForDisplay('test-ci', config, atomizedTargets); + + expect(result).toBe( + 'test-ci: nx:noop | atomized: [Test1, Test2, Test3, +3 more]', + ); + }); + + it('should handle target without executor', () => { + const config = { + cache: true, + }; + + const result = compressTargetForDisplay('build', config); + + expect(result).toBe('build: no executor'); + }); + + it('should handle nx:run-commands with multiple commands', () => { + const config = { + executor: 'nx:run-commands', + options: { + commands: ['echo test1', 'echo test2', 'echo test3'], + }, + cache: true, + }; + + const result = compressTargetForDisplay('build', config); + + expect(result).toBe('build: nx:run-commands - 3 commands'); + }); + + it('should handle nx:run-script executor', () => { + const config = { + executor: 'nx:run-script', + metadata: { + runCommand: 'npm run test', + }, + cache: true, + }; + + const result = compressTargetForDisplay('test', config); + + expect(result).toBe("test: nx:run-script - 'npm run test'"); + }); +}); diff --git a/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.ts b/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.ts index 105a0943cb..f63deb8b34 100644 --- a/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.ts +++ b/libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.ts @@ -1,6 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { + detectAtomizedTargets, getGeneratorNamesAndDescriptions, getGeneratorSchema, getGeneratorsPrompt, @@ -64,9 +65,14 @@ function getValueByPath(obj: any, path: string): any { * * @param name - The target name * @param config - The target configuration object + * @param atomizedTargets - Optional array of atomized target names for this root target * @returns Plain text description of the target */ -function compressTargetForDisplay(name: string, config: any): string { +function compressTargetForDisplay( + name: string, + config: any, + atomizedTargets?: string[], +): string { let description = `${name}: `; // Determine executor/command display @@ -114,14 +120,41 @@ function compressTargetForDisplay(name: string, config: any): string { const deps = config.dependsOn .map((dep: any) => (typeof dep === 'string' ? dep : dep.target)) .filter(Boolean); + if (deps.length > 0) { - description += ` | depends: [${deps.join(', ')}]`; + // If this is a root atomizer target, simplify the dependency display + if (atomizedTargets && atomizedTargets.length > 0) { + description += ` | depends: [${atomizedTargets.length} atomized targets]`; + } else if (deps.length > 10) { + // Truncate long dependency lists + const firstThree = deps.slice(0, 3).join(', '); + description += ` | depends: [${firstThree}, +${deps.length - 3} more]`; + } else { + description += ` | depends: [${deps.join(', ')}]`; + } } } - // Add cache status + // Only show cache status if it's false (assume true by default) const cacheEnabled = config.cache !== false; - description += ` | cache: ${cacheEnabled}`; + if (!cacheEnabled) { + description += ` | cache: false`; + } + + // Add atomized targets list if this is a root atomizer target + if (atomizedTargets && atomizedTargets.length > 0) { + // Strip the prefix from atomized target names for more compact display + const strippedNames = atomizedTargets.map((target) => + target.replace(`${name}--`, ''), + ); + + if (strippedNames.length <= 5) { + description += ` | atomized: [${strippedNames.join(', ')}]`; + } else { + const firstThree = strippedNames.slice(0, 3).join(', '); + description += ` | atomized: [${firstThree}, +${strippedNames.length - 3} more]`; + } + } return description; } @@ -426,16 +459,30 @@ export function registerNxWorkspaceTools( }; } } else { - // No select: compress targets into plain text, return rest as JSON - const { targets, ...projectDataWithoutTargets } = project.data; - detailsJson = projectDataWithoutTargets; + // No select: compress targets into plain text, collapse metadata, return rest as JSON + const { targets, metadata, ...projectDataWithoutTargets } = + project.data; + detailsJson = { + ...projectDataWithoutTargets, + ...(metadata && { + metadata: + '', + }), + }; if (targets && typeof targets === 'object') { + // Detect atomized targets + const targetGroups = project.data.metadata?.targetGroups ?? {}; + const { atomizedTargetsMap, targetsToExclude } = + detectAtomizedTargets(targetGroups); + + // Create compressed descriptions for visible targets only const targetDescriptions = Object.entries(targets) - .map( - ([name, config]) => - ` - ${compressTargetForDisplay(name, config)}`, - ) + .filter(([name]) => !targetsToExclude.includes(name)) + .map(([name, config]) => { + const atomizedTargets = atomizedTargetsMap.get(name); + return ` - ${compressTargetForDisplay(name, config, atomizedTargets)}`; + }) .join('\n'); // Pick a sample target name for the example @@ -700,3 +747,8 @@ export function getTokenOptimizedToolResult( return [nxJsonResult, projectGraphResult, errorsResult]; } + +// Export for testing +export const __testing__ = { + compressTargetForDisplay, +}; diff --git a/libs/shared/llm-context/src/lib/project-graph.spec.ts b/libs/shared/llm-context/src/lib/project-graph.spec.ts index a00977e279..14f20ed715 100644 --- a/libs/shared/llm-context/src/lib/project-graph.spec.ts +++ b/libs/shared/llm-context/src/lib/project-graph.spec.ts @@ -1,5 +1,5 @@ import type { ProjectGraph } from 'nx/src/devkit-exports'; -import { getProjectGraphPrompt } from './project-graph'; +import { detectAtomizedTargets, getProjectGraphPrompt } from './project-graph'; describe('project-graph', () => { describe('getProjectGraphPrompt', () => { @@ -385,4 +385,77 @@ describe('project-graph', () => { }); }); }); + + describe('detectAtomizedTargets', () => { + it('should detect atomized targets correctly', () => { + const targetGroups = { + 'test-ci': [ + 'test-ci', + 'test-ci--Test1', + 'test-ci--Test2', + 'test-ci--Test3', + ], + }; + + const result = detectAtomizedTargets(targetGroups); + + expect(result.rootTargets).toEqual(new Set(['test-ci'])); + expect(result.atomizedTargetsMap.get('test-ci')).toEqual([ + 'test-ci--Test1', + 'test-ci--Test2', + 'test-ci--Test3', + ]); + expect(result.targetsToExclude).toEqual([ + 'test-ci--Test1', + 'test-ci--Test2', + 'test-ci--Test3', + ]); + }); + + it('should handle multiple root targets', () => { + const targetGroups = { + 'test-ci': ['test-ci', 'test-ci--Test1', 'test-ci--Test2'], + 'e2e-ci': ['e2e-ci', 'e2e-ci--Test1', 'e2e-ci--Test2'], + }; + + const result = detectAtomizedTargets(targetGroups); + + expect(result.rootTargets).toEqual(new Set(['test-ci', 'e2e-ci'])); + expect(result.atomizedTargetsMap.get('test-ci')).toEqual([ + 'test-ci--Test1', + 'test-ci--Test2', + ]); + expect(result.atomizedTargetsMap.get('e2e-ci')).toEqual([ + 'e2e-ci--Test1', + 'e2e-ci--Test2', + ]); + expect(result.targetsToExclude).toEqual([ + 'test-ci--Test1', + 'test-ci--Test2', + 'e2e-ci--Test1', + 'e2e-ci--Test2', + ]); + }); + + it('should handle empty targetGroups', () => { + const result = detectAtomizedTargets({}); + + expect(result.rootTargets).toEqual(new Set()); + expect(result.atomizedTargetsMap.size).toBe(0); + expect(result.targetsToExclude).toEqual([]); + }); + + it('should handle groups without atomized targets', () => { + const targetGroups = { + build: ['build'], + test: ['test'], + }; + + const result = detectAtomizedTargets(targetGroups); + + expect(result.rootTargets).toEqual(new Set()); + expect(result.atomizedTargetsMap.size).toBe(0); + expect(result.targetsToExclude).toEqual([]); + }); + }); }); diff --git a/libs/shared/llm-context/src/lib/project-graph.ts b/libs/shared/llm-context/src/lib/project-graph.ts index c581456fee..db0802ae0a 100644 --- a/libs/shared/llm-context/src/lib/project-graph.ts +++ b/libs/shared/llm-context/src/lib/project-graph.ts @@ -10,6 +10,39 @@ export type ProjectGraphOptimizations = { truncateTargets?: boolean; }; +/** + * Detects atomized targets from targetGroups metadata. + * Atomized targets follow the pattern: rootTarget--identifier + * + * @param targetGroups - The targetGroups metadata from project data + * @returns Object with rootTargets set, atomizedTargetsMap, and targetsToExclude array + */ +export function detectAtomizedTargets(targetGroups: Record): { + rootTargets: Set; + atomizedTargetsMap: Map; + targetsToExclude: string[]; +} { + const rootTargets = new Set(); + const atomizedTargetsMap = new Map(); + const targetsToExclude: string[] = []; + + for (const groupName in targetGroups) { + const targets = targetGroups[groupName]; + + // The group name is the root target name (from metadata) + const rootTarget = groupName; + const atomizedTargets = targets.filter((t) => t !== rootTarget); + + if (atomizedTargets.length > 0) { + rootTargets.add(rootTarget); + atomizedTargetsMap.set(rootTarget, atomizedTargets); + targetsToExclude.push(...atomizedTargets); + } + } + + return { rootTargets, atomizedTargetsMap, targetsToExclude }; +} + export function getProjectGraphPrompt( projectGraph: ProjectGraph, optimizations?: ProjectGraphOptimizations, @@ -64,27 +97,8 @@ function getRobotReadableProjectGraph( // targets const targetGroups = node.data.metadata?.targetGroups ?? {}; - const targetsToExclude: string[] = []; - - // if there are many targets in a group where one is a root target (because of atomizer), ignore the rest - for (const group in targetGroups) { - const targets = targetGroups[group]; - const rootTargets = new Set(); - - for (const target of targets) { - if (targets.some((t) => t !== target && t.startsWith(target))) { - rootTargets.add(target); - } - } - - for (const rootTarget of rootTargets) { - targetsToExclude.push( - ...targets.filter( - (t) => t.startsWith(rootTarget) && t !== rootTarget, - ), - ); - } - } + const { targetsToExclude } = detectAtomizedTargets(targetGroups); + const targets = Object.keys(node.data.targets ?? {}).filter( (target) => !targetsToExclude.includes(target) &&