Skip to content

Commit 0ba631b

Browse files
committed
feat(nx-mcp): make nx_project_details more token efficient by default
1 parent 93ba07e commit 0ba631b

File tree

4 files changed

+330
-42
lines changed

4 files changed

+330
-42
lines changed

libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.test.ts

Lines changed: 157 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getTokenOptimizedToolResult,
33
chunkContent,
44
registerNxWorkspaceTools,
5+
__testing__,
56
} from './nx-workspace';
67
import { NxWorkspace, NxError } from '@nx-console/shared-types';
78
import {
@@ -12,16 +13,15 @@ import {
1213
NX_PROJECT_DETAILS,
1314
} from '@nx-console/shared-llm-context';
1415

15-
jest.mock('@nx-console/shared-llm-context', () => ({
16-
getNxJsonPrompt: jest.fn(),
17-
getProjectGraphPrompt: jest.fn(),
18-
getProjectGraphErrorsPrompt: jest.fn(),
19-
NX_WORKSPACE: 'nx_workspace',
20-
NX_PROJECT_DETAILS: 'nx_project_details',
21-
NX_GENERATORS: 'nx_generators',
22-
NX_GENERATOR_SCHEMA: 'nx_generator_schema',
23-
NX_WORKSPACE_PATH: 'nx_workspace_path',
24-
}));
16+
jest.mock('@nx-console/shared-llm-context', () => {
17+
const actual = jest.requireActual('@nx-console/shared-llm-context');
18+
return {
19+
...actual,
20+
getNxJsonPrompt: jest.fn(),
21+
getProjectGraphPrompt: jest.fn(),
22+
getProjectGraphErrorsPrompt: jest.fn(),
23+
};
24+
});
2525

2626
// Mock shared-npm module - don't import to avoid lazy-load conflict
2727
jest.mock('@nx-console/shared-npm', () => ({
@@ -512,3 +512,150 @@ describe('registerNxWorkspaceTools', () => {
512512
});
513513
});
514514
});
515+
516+
describe('compressTargetForDisplay', () => {
517+
const { compressTargetForDisplay } = __testing__;
518+
519+
it('should omit cache status when true', () => {
520+
const config = {
521+
executor: 'nx:run-commands',
522+
command: 'echo test',
523+
cache: true,
524+
};
525+
526+
const result = compressTargetForDisplay('build', config);
527+
528+
expect(result).toBe("build: nx:run-commands - 'echo test'");
529+
expect(result).not.toContain('cache');
530+
});
531+
532+
it('should show cache status when false', () => {
533+
const config = {
534+
executor: 'nx:run-commands',
535+
command: 'echo test',
536+
cache: false,
537+
};
538+
539+
const result = compressTargetForDisplay('build', config);
540+
541+
expect(result).toBe("build: nx:run-commands - 'echo test' | cache: false");
542+
});
543+
544+
it('should truncate long dependency lists', () => {
545+
const config = {
546+
executor: '@nx/gradle:gradle',
547+
dependsOn: [
548+
'dep1',
549+
'dep2',
550+
'dep3',
551+
'dep4',
552+
'dep5',
553+
'dep6',
554+
'dep7',
555+
'dep8',
556+
'dep9',
557+
'dep10',
558+
'dep11',
559+
],
560+
cache: true,
561+
};
562+
563+
const result = compressTargetForDisplay('build', config);
564+
565+
expect(result).toBe(
566+
'build: @nx/gradle:gradle | depends: [dep1, dep2, dep3, +8 more]',
567+
);
568+
});
569+
570+
it('should not truncate short dependency lists', () => {
571+
const config = {
572+
executor: '@nx/gradle:gradle',
573+
dependsOn: ['dep1', 'dep2', 'dep3'],
574+
cache: true,
575+
};
576+
577+
const result = compressTargetForDisplay('build', config);
578+
579+
expect(result).toBe(
580+
'build: @nx/gradle:gradle | depends: [dep1, dep2, dep3]',
581+
);
582+
});
583+
584+
it('should show atomized targets with abbreviated names', () => {
585+
const config = {
586+
executor: 'nx:noop',
587+
dependsOn: ['test-ci--Test1', 'test-ci--Test2', 'test-ci--Test3'],
588+
cache: true,
589+
};
590+
const atomizedTargets = [
591+
'test-ci--Test1',
592+
'test-ci--Test2',
593+
'test-ci--Test3',
594+
];
595+
596+
const result = compressTargetForDisplay('test-ci', config, atomizedTargets);
597+
598+
expect(result).toBe(
599+
'test-ci: nx:noop | depends: [3 atomized targets] | atomized: [Test1, Test2, Test3]',
600+
);
601+
});
602+
603+
it('should truncate long atomized target lists', () => {
604+
const config = {
605+
executor: 'nx:noop',
606+
cache: true,
607+
};
608+
const atomizedTargets = [
609+
'test-ci--Test1',
610+
'test-ci--Test2',
611+
'test-ci--Test3',
612+
'test-ci--Test4',
613+
'test-ci--Test5',
614+
'test-ci--Test6',
615+
];
616+
617+
const result = compressTargetForDisplay('test-ci', config, atomizedTargets);
618+
619+
expect(result).toBe(
620+
'test-ci: nx:noop | atomized: [Test1, Test2, Test3, +3 more]',
621+
);
622+
});
623+
624+
it('should handle target without executor', () => {
625+
const config = {
626+
cache: true,
627+
};
628+
629+
const result = compressTargetForDisplay('build', config);
630+
631+
expect(result).toBe('build: no executor');
632+
});
633+
634+
it('should handle nx:run-commands with multiple commands', () => {
635+
const config = {
636+
executor: 'nx:run-commands',
637+
options: {
638+
commands: ['echo test1', 'echo test2', 'echo test3'],
639+
},
640+
cache: true,
641+
};
642+
643+
const result = compressTargetForDisplay('build', config);
644+
645+
expect(result).toBe('build: nx:run-commands - 3 commands');
646+
});
647+
648+
it('should handle nx:run-script executor', () => {
649+
const config = {
650+
executor: 'nx:run-script',
651+
metadata: {
652+
runCommand: 'npm run test',
653+
},
654+
cache: true,
655+
};
656+
657+
const result = compressTargetForDisplay('test', config);
658+
659+
expect(result).toBe("test: nx:run-script - 'npm run test'");
660+
});
661+
});

libs/nx-mcp/nx-mcp-server/src/lib/tools/nx-workspace.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
33
import {
4+
detectAtomizedTargets,
45
getGeneratorNamesAndDescriptions,
56
getGeneratorSchema,
67
getGeneratorsPrompt,
@@ -64,9 +65,14 @@ function getValueByPath(obj: any, path: string): any {
6465
*
6566
* @param name - The target name
6667
* @param config - The target configuration object
68+
* @param atomizedTargets - Optional array of atomized target names for this root target
6769
* @returns Plain text description of the target
6870
*/
69-
function compressTargetForDisplay(name: string, config: any): string {
71+
function compressTargetForDisplay(
72+
name: string,
73+
config: any,
74+
atomizedTargets?: string[],
75+
): string {
7076
let description = `${name}: `;
7177

7278
// Determine executor/command display
@@ -114,14 +120,41 @@ function compressTargetForDisplay(name: string, config: any): string {
114120
const deps = config.dependsOn
115121
.map((dep: any) => (typeof dep === 'string' ? dep : dep.target))
116122
.filter(Boolean);
123+
117124
if (deps.length > 0) {
118-
description += ` | depends: [${deps.join(', ')}]`;
125+
// If this is a root atomizer target, simplify the dependency display
126+
if (atomizedTargets && atomizedTargets.length > 0) {
127+
description += ` | depends: [${atomizedTargets.length} atomized targets]`;
128+
} else if (deps.length > 10) {
129+
// Truncate long dependency lists
130+
const firstThree = deps.slice(0, 3).join(', ');
131+
description += ` | depends: [${firstThree}, +${deps.length - 3} more]`;
132+
} else {
133+
description += ` | depends: [${deps.join(', ')}]`;
134+
}
119135
}
120136
}
121137

122-
// Add cache status
138+
// Only show cache status if it's false (assume true by default)
123139
const cacheEnabled = config.cache !== false;
124-
description += ` | cache: ${cacheEnabled}`;
140+
if (!cacheEnabled) {
141+
description += ` | cache: false`;
142+
}
143+
144+
// Add atomized targets list if this is a root atomizer target
145+
if (atomizedTargets && atomizedTargets.length > 0) {
146+
// Strip the prefix from atomized target names for more compact display
147+
const strippedNames = atomizedTargets.map((target) =>
148+
target.replace(`${name}--`, ''),
149+
);
150+
151+
if (strippedNames.length <= 5) {
152+
description += ` | atomized: [${strippedNames.join(', ')}]`;
153+
} else {
154+
const firstThree = strippedNames.slice(0, 3).join(', ');
155+
description += ` | atomized: [${firstThree}, +${strippedNames.length - 3} more]`;
156+
}
157+
}
125158

126159
return description;
127160
}
@@ -426,16 +459,30 @@ export function registerNxWorkspaceTools(
426459
};
427460
}
428461
} else {
429-
// No select: compress targets into plain text, return rest as JSON
430-
const { targets, ...projectDataWithoutTargets } = project.data;
431-
detailsJson = projectDataWithoutTargets;
462+
// No select: compress targets into plain text, collapse metadata, return rest as JSON
463+
const { targets, metadata, ...projectDataWithoutTargets } =
464+
project.data;
465+
detailsJson = {
466+
...projectDataWithoutTargets,
467+
...(metadata && {
468+
metadata:
469+
'<!-- COLLAPSED - use filter parameter to see details -->',
470+
}),
471+
};
432472

433473
if (targets && typeof targets === 'object') {
474+
// Detect atomized targets
475+
const targetGroups = project.data.metadata?.targetGroups ?? {};
476+
const { atomizedTargetsMap, targetsToExclude } =
477+
detectAtomizedTargets(targetGroups);
478+
479+
// Create compressed descriptions for visible targets only
434480
const targetDescriptions = Object.entries(targets)
435-
.map(
436-
([name, config]) =>
437-
` - ${compressTargetForDisplay(name, config)}`,
438-
)
481+
.filter(([name]) => !targetsToExclude.includes(name))
482+
.map(([name, config]) => {
483+
const atomizedTargets = atomizedTargetsMap.get(name);
484+
return ` - ${compressTargetForDisplay(name, config, atomizedTargets)}`;
485+
})
439486
.join('\n');
440487

441488
// Pick a sample target name for the example
@@ -700,3 +747,8 @@ export function getTokenOptimizedToolResult(
700747

701748
return [nxJsonResult, projectGraphResult, errorsResult];
702749
}
750+
751+
// Export for testing
752+
export const __testing__ = {
753+
compressTargetForDisplay,
754+
};

libs/shared/llm-context/src/lib/project-graph.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ProjectGraph } from 'nx/src/devkit-exports';
2-
import { getProjectGraphPrompt } from './project-graph';
2+
import { detectAtomizedTargets, getProjectGraphPrompt } from './project-graph';
33

44
describe('project-graph', () => {
55
describe('getProjectGraphPrompt', () => {
@@ -385,4 +385,77 @@ describe('project-graph', () => {
385385
});
386386
});
387387
});
388+
389+
describe('detectAtomizedTargets', () => {
390+
it('should detect atomized targets correctly', () => {
391+
const targetGroups = {
392+
'test-ci': [
393+
'test-ci',
394+
'test-ci--Test1',
395+
'test-ci--Test2',
396+
'test-ci--Test3',
397+
],
398+
};
399+
400+
const result = detectAtomizedTargets(targetGroups);
401+
402+
expect(result.rootTargets).toEqual(new Set(['test-ci']));
403+
expect(result.atomizedTargetsMap.get('test-ci')).toEqual([
404+
'test-ci--Test1',
405+
'test-ci--Test2',
406+
'test-ci--Test3',
407+
]);
408+
expect(result.targetsToExclude).toEqual([
409+
'test-ci--Test1',
410+
'test-ci--Test2',
411+
'test-ci--Test3',
412+
]);
413+
});
414+
415+
it('should handle multiple root targets', () => {
416+
const targetGroups = {
417+
'test-ci': ['test-ci', 'test-ci--Test1', 'test-ci--Test2'],
418+
'e2e-ci': ['e2e-ci', 'e2e-ci--Test1', 'e2e-ci--Test2'],
419+
};
420+
421+
const result = detectAtomizedTargets(targetGroups);
422+
423+
expect(result.rootTargets).toEqual(new Set(['test-ci', 'e2e-ci']));
424+
expect(result.atomizedTargetsMap.get('test-ci')).toEqual([
425+
'test-ci--Test1',
426+
'test-ci--Test2',
427+
]);
428+
expect(result.atomizedTargetsMap.get('e2e-ci')).toEqual([
429+
'e2e-ci--Test1',
430+
'e2e-ci--Test2',
431+
]);
432+
expect(result.targetsToExclude).toEqual([
433+
'test-ci--Test1',
434+
'test-ci--Test2',
435+
'e2e-ci--Test1',
436+
'e2e-ci--Test2',
437+
]);
438+
});
439+
440+
it('should handle empty targetGroups', () => {
441+
const result = detectAtomizedTargets({});
442+
443+
expect(result.rootTargets).toEqual(new Set());
444+
expect(result.atomizedTargetsMap.size).toBe(0);
445+
expect(result.targetsToExclude).toEqual([]);
446+
});
447+
448+
it('should handle groups without atomized targets', () => {
449+
const targetGroups = {
450+
build: ['build'],
451+
test: ['test'],
452+
};
453+
454+
const result = detectAtomizedTargets(targetGroups);
455+
456+
expect(result.rootTargets).toEqual(new Set());
457+
expect(result.atomizedTargetsMap.size).toBe(0);
458+
expect(result.targetsToExclude).toEqual([]);
459+
});
460+
});
388461
});

0 commit comments

Comments
 (0)