Skip to content

Commit bb17622

Browse files
committed
feat(nx-mcp): make nx_project_details more token efficient by default
1 parent 8433d19 commit bb17622

File tree

4 files changed

+334
-38
lines changed

4 files changed

+334
-38
lines changed

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

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1-
import { getTokenOptimizedToolResult, chunkContent } from './nx-workspace';
1+
import {
2+
getTokenOptimizedToolResult,
3+
chunkContent,
4+
__testing__,
5+
} from './nx-workspace';
26
import { NxWorkspace, NxError } from '@nx-console/shared-types';
37
import {
48
getNxJsonPrompt,
59
getProjectGraphPrompt,
610
getProjectGraphErrorsPrompt,
711
} from '@nx-console/shared-llm-context';
812

9-
jest.mock('@nx-console/shared-llm-context', () => ({
10-
getNxJsonPrompt: jest.fn(),
11-
getProjectGraphPrompt: jest.fn(),
12-
getProjectGraphErrorsPrompt: jest.fn(),
13-
}));
13+
jest.mock('@nx-console/shared-llm-context', () => {
14+
const actual = jest.requireActual('@nx-console/shared-llm-context');
15+
return {
16+
...actual,
17+
getNxJsonPrompt: jest.fn(),
18+
getProjectGraphPrompt: jest.fn(),
19+
getProjectGraphErrorsPrompt: jest.fn(),
20+
};
21+
});
1422

1523
const mockGetNxJsonPrompt = getNxJsonPrompt as jest.MockedFunction<
1624
typeof getNxJsonPrompt
@@ -297,3 +305,150 @@ describe('chunkContent', () => {
297305
expect(page10.hasMore).toBe(false);
298306
});
299307
});
308+
309+
describe('compressTargetForDisplay', () => {
310+
const { compressTargetForDisplay } = __testing__;
311+
312+
it('should omit cache status when true', () => {
313+
const config = {
314+
executor: 'nx:run-commands',
315+
command: 'echo test',
316+
cache: true,
317+
};
318+
319+
const result = compressTargetForDisplay('build', config);
320+
321+
expect(result).toBe("build: nx:run-commands - 'echo test'");
322+
expect(result).not.toContain('cache');
323+
});
324+
325+
it('should show cache status when false', () => {
326+
const config = {
327+
executor: 'nx:run-commands',
328+
command: 'echo test',
329+
cache: false,
330+
};
331+
332+
const result = compressTargetForDisplay('build', config);
333+
334+
expect(result).toBe("build: nx:run-commands - 'echo test' | cache: false");
335+
});
336+
337+
it('should truncate long dependency lists', () => {
338+
const config = {
339+
executor: '@nx/gradle:gradle',
340+
dependsOn: [
341+
'dep1',
342+
'dep2',
343+
'dep3',
344+
'dep4',
345+
'dep5',
346+
'dep6',
347+
'dep7',
348+
'dep8',
349+
'dep9',
350+
'dep10',
351+
'dep11',
352+
],
353+
cache: true,
354+
};
355+
356+
const result = compressTargetForDisplay('build', config);
357+
358+
expect(result).toBe(
359+
'build: @nx/gradle:gradle | depends: [dep1, dep2, dep3, +8 more]',
360+
);
361+
});
362+
363+
it('should not truncate short dependency lists', () => {
364+
const config = {
365+
executor: '@nx/gradle:gradle',
366+
dependsOn: ['dep1', 'dep2', 'dep3'],
367+
cache: true,
368+
};
369+
370+
const result = compressTargetForDisplay('build', config);
371+
372+
expect(result).toBe(
373+
'build: @nx/gradle:gradle | depends: [dep1, dep2, dep3]',
374+
);
375+
});
376+
377+
it('should show atomized targets with abbreviated names', () => {
378+
const config = {
379+
executor: 'nx:noop',
380+
dependsOn: ['test-ci--Test1', 'test-ci--Test2', 'test-ci--Test3'],
381+
cache: true,
382+
};
383+
const atomizedTargets = [
384+
'test-ci--Test1',
385+
'test-ci--Test2',
386+
'test-ci--Test3',
387+
];
388+
389+
const result = compressTargetForDisplay('test-ci', config, atomizedTargets);
390+
391+
expect(result).toBe(
392+
'test-ci: nx:noop | depends: [3 atomized targets] | atomized: [Test1, Test2, Test3]',
393+
);
394+
});
395+
396+
it('should truncate long atomized target lists', () => {
397+
const config = {
398+
executor: 'nx:noop',
399+
cache: true,
400+
};
401+
const atomizedTargets = [
402+
'test-ci--Test1',
403+
'test-ci--Test2',
404+
'test-ci--Test3',
405+
'test-ci--Test4',
406+
'test-ci--Test5',
407+
'test-ci--Test6',
408+
];
409+
410+
const result = compressTargetForDisplay('test-ci', config, atomizedTargets);
411+
412+
expect(result).toBe(
413+
'test-ci: nx:noop | atomized: [Test1, Test2, Test3, +3 more]',
414+
);
415+
});
416+
417+
it('should handle target without executor', () => {
418+
const config = {
419+
cache: true,
420+
};
421+
422+
const result = compressTargetForDisplay('build', config);
423+
424+
expect(result).toBe('build: no executor');
425+
});
426+
427+
it('should handle nx:run-commands with multiple commands', () => {
428+
const config = {
429+
executor: 'nx:run-commands',
430+
options: {
431+
commands: ['echo test1', 'echo test2', 'echo test3'],
432+
},
433+
cache: true,
434+
};
435+
436+
const result = compressTargetForDisplay('build', config);
437+
438+
expect(result).toBe('build: nx:run-commands - 3 commands');
439+
});
440+
441+
it('should handle nx:run-script executor', () => {
442+
const config = {
443+
executor: 'nx:run-script',
444+
metadata: {
445+
runCommand: 'npm run test',
446+
},
447+
cache: true,
448+
};
449+
450+
const result = compressTargetForDisplay('test', config);
451+
452+
expect(result).toBe("test: nx:run-script - 'npm run test'");
453+
});
454+
});

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
}
@@ -377,16 +410,30 @@ export function registerNxWorkspaceTools(
377410
};
378411
}
379412
} else {
380-
// No filter: compress targets into plain text, return rest as JSON
381-
const { targets, ...projectDataWithoutTargets } = project.data;
382-
detailsJson = projectDataWithoutTargets;
413+
// No filter: compress targets into plain text, collapse metadata, return rest as JSON
414+
const { targets, metadata, ...projectDataWithoutTargets } =
415+
project.data;
416+
detailsJson = {
417+
...projectDataWithoutTargets,
418+
...(metadata && {
419+
metadata:
420+
'<!-- COLLAPSED - use filter parameter to see details -->',
421+
}),
422+
};
383423

384424
if (targets && typeof targets === 'object') {
425+
// Detect atomized targets
426+
const targetGroups = project.data.metadata?.targetGroups ?? {};
427+
const { atomizedTargetsMap, targetsToExclude } =
428+
detectAtomizedTargets(targetGroups);
429+
430+
// Create compressed descriptions for visible targets only
385431
const targetDescriptions = Object.entries(targets)
386-
.map(
387-
([name, config]) =>
388-
` - ${compressTargetForDisplay(name, config)}`,
389-
)
432+
.filter(([name]) => !targetsToExclude.includes(name))
433+
.map(([name, config]) => {
434+
const atomizedTargets = atomizedTargetsMap.get(name);
435+
return ` - ${compressTargetForDisplay(name, config, atomizedTargets)}`;
436+
})
390437
.join('\n');
391438

392439
// Pick a sample target name for the example
@@ -651,3 +698,8 @@ export function getTokenOptimizedToolResult(
651698

652699
return [nxJsonResult, projectGraphResult, errorsResult];
653700
}
701+
702+
// Export for testing
703+
export const __testing__ = {
704+
compressTargetForDisplay,
705+
};

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)