Skip to content

Commit 173043d

Browse files
committed
feat(mcp): add get_rule_impact_report MCP tool (#1133)
Add a new MCP tool that generates a formatted markdown impact report from rule-stats.json, showing top rules by usage, unused rules, domain coverage, estimated time saved, and trend analysis. - Leverages existing RuleInsightsService for computation logic - Handles missing/empty stats gracefully with user-friendly message - Time saved heuristic: 15 min per violation caught, 5 min per check - Registered in MCP module for standard tool discovery
1 parent c5072d8 commit 173043d

4 files changed

Lines changed: 492 additions & 0 deletions

File tree

apps/mcp-server/src/mcp/handlers/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ export { BriefingHandler } from './briefing.handler';
156156
*/
157157
export { ResumeHandler } from './resume.handler';
158158

159+
/**
160+
* Handler for rule impact report tools (get_rule_impact_report)
161+
* @see {@link RuleImpactHandler}
162+
*/
163+
export { RuleImpactHandler } from './rule-impact.handler';
164+
159165
/**
160166
* Injection token for the array of all tool handlers.
161167
*
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { RuleImpactHandler } from './rule-impact.handler';
2+
import { RuleInsightsService } from '../../rules/rule-insights.service';
3+
import type { RuleInsight } from '../../rules/rule-insights.service';
4+
import { RuleTracker } from '../../rules/rule-tracker';
5+
import { RulesService } from '../../rules/rules.service';
6+
7+
vi.mock('../../rules/rule-tracker');
8+
9+
describe('RuleImpactHandler', () => {
10+
let handler: RuleImpactHandler;
11+
let mockInsightsService: RuleInsightsService;
12+
let mockRulesService: RulesService;
13+
14+
const now = 1700000000000;
15+
16+
const mockInsight: RuleInsight = {
17+
generatedAt: now,
18+
summary: {
19+
totalRulesTracked: 5,
20+
totalUsageCount: 42,
21+
activeRules: 3,
22+
staleRules: 1,
23+
},
24+
topRules: [
25+
{ name: 'core', count: 15, lastUsed: now, classification: 'high' },
26+
{ name: 'project', count: 12, lastUsed: now, classification: 'high' },
27+
{ name: 'augmented-coding', count: 8, lastUsed: now - 86400000, classification: 'medium' },
28+
{ name: 'security', count: 5, lastUsed: now - 172800000, classification: 'low' },
29+
{ name: 'testing', count: 2, lastUsed: now - 2592000000, classification: 'low' },
30+
],
31+
unusedRules: ['old-deprecated-rule', 'legacy-rule'],
32+
trends: {
33+
recentlyActive: ['core', 'project', 'augmented-coding'],
34+
declining: ['testing'],
35+
emerging: ['security'],
36+
},
37+
suggestions: ['Some suggestion about high-frequency rules'],
38+
};
39+
40+
beforeEach(() => {
41+
vi.mocked(RuleTracker.fromFile).mockResolvedValue({
42+
getStats: vi.fn().mockReturnValue({
43+
core: { count: 15, lastUsed: now },
44+
project: { count: 12, lastUsed: now },
45+
'augmented-coding': { count: 8, lastUsed: now - 86400000 },
46+
security: { count: 5, lastUsed: now - 172800000 },
47+
testing: { count: 2, lastUsed: now - 2592000000 },
48+
}),
49+
} as unknown as RuleTracker);
50+
51+
mockInsightsService = {
52+
generateInsights: vi.fn().mockReturnValue(mockInsight),
53+
} as unknown as RuleInsightsService;
54+
55+
mockRulesService = {
56+
searchRules: vi.fn().mockResolvedValue([
57+
{ file: 'rules/core.md', matches: [], score: 1 },
58+
{ file: 'rules/project.md', matches: [], score: 1 },
59+
]),
60+
} as unknown as RulesService;
61+
62+
handler = new RuleImpactHandler(mockInsightsService, mockRulesService);
63+
});
64+
65+
afterEach(() => {
66+
vi.restoreAllMocks();
67+
});
68+
69+
it('should return null for unhandled tools', async () => {
70+
const result = await handler.handle('unknown_tool', {});
71+
expect(result).toBeNull();
72+
});
73+
74+
describe('get_rule_impact_report', () => {
75+
it('should return a formatted markdown report', async () => {
76+
const result = await handler.handle('get_rule_impact_report', {});
77+
78+
expect(result).not.toBeNull();
79+
expect(result?.isError).toBeFalsy();
80+
81+
const text = result!.content[0].text;
82+
// Should contain markdown headings for each section
83+
expect(text).toContain('# Rule Impact Report');
84+
expect(text).toContain('## Summary');
85+
expect(text).toContain('## Top Rules');
86+
expect(text).toContain('## Domain Coverage');
87+
expect(text).toContain('## Unused Rules');
88+
});
89+
90+
it('should include summary statistics in the report', async () => {
91+
const result = await handler.handle('get_rule_impact_report', {});
92+
const text = result!.content[0].text;
93+
94+
expect(text).toContain('42'); // total usage count
95+
expect(text).toContain('5'); // total rules tracked
96+
});
97+
98+
it('should include time saved estimation', async () => {
99+
const result = await handler.handle('get_rule_impact_report', {});
100+
const text = result!.content[0].text;
101+
102+
// Each violation caught saves ~15 min, each checklist check saves ~5 min
103+
expect(text).toContain('Estimated Time Saved');
104+
});
105+
106+
it('should include top rules as a table', async () => {
107+
const result = await handler.handle('get_rule_impact_report', {});
108+
const text = result!.content[0].text;
109+
110+
// Markdown table header
111+
expect(text).toContain('| Rule | Applications | Classification |');
112+
expect(text).toContain('core');
113+
expect(text).toContain('project');
114+
});
115+
116+
it('should list unused rules', async () => {
117+
const result = await handler.handle('get_rule_impact_report', {});
118+
const text = result!.content[0].text;
119+
120+
expect(text).toContain('old-deprecated-rule');
121+
expect(text).toContain('legacy-rule');
122+
});
123+
124+
it('should include trend information', async () => {
125+
const result = await handler.handle('get_rule_impact_report', {});
126+
const text = result!.content[0].text;
127+
128+
expect(text).toContain('Trend');
129+
});
130+
131+
it('should handle empty/missing stats gracefully', async () => {
132+
vi.mocked(RuleTracker.fromFile).mockRejectedValue(new Error('ENOENT'));
133+
134+
const emptyInsight: RuleInsight = {
135+
generatedAt: now,
136+
summary: { totalRulesTracked: 0, totalUsageCount: 0, activeRules: 0, staleRules: 0 },
137+
topRules: [],
138+
unusedRules: [],
139+
trends: { recentlyActive: [], declining: [], emerging: [] },
140+
suggestions: [
141+
'No tracking data available yet — use parse_mode to start collecting rule usage data',
142+
],
143+
};
144+
(mockInsightsService.generateInsights as ReturnType<typeof vi.fn>).mockReturnValue(
145+
emptyInsight,
146+
);
147+
148+
const result = await handler.handle('get_rule_impact_report', {});
149+
150+
expect(result).not.toBeNull();
151+
expect(result?.isError).toBeFalsy();
152+
const text = result!.content[0].text;
153+
expect(text).toContain('No data collected yet');
154+
});
155+
156+
it('should accept optional period parameter', async () => {
157+
const result = await handler.handle('get_rule_impact_report', { period: 'week' });
158+
159+
expect(result).not.toBeNull();
160+
expect(result?.isError).toBeFalsy();
161+
});
162+
163+
it('should pass statsPath to RuleTracker.fromFile', async () => {
164+
await handler.handle('get_rule_impact_report', { statsPath: '/custom/path.json' });
165+
166+
expect(RuleTracker.fromFile).toHaveBeenCalledWith('/custom/path.json');
167+
});
168+
169+
it('should use default statsPath when not provided', async () => {
170+
await handler.handle('get_rule_impact_report', {});
171+
172+
expect(RuleTracker.fromFile).toHaveBeenCalledWith(expect.stringContaining('rule-stats.json'));
173+
});
174+
});
175+
176+
describe('getToolDefinitions', () => {
177+
it('should return get_rule_impact_report definition', () => {
178+
const definitions = handler.getToolDefinitions();
179+
180+
expect(definitions).toHaveLength(1);
181+
expect(definitions[0].name).toBe('get_rule_impact_report');
182+
expect(definitions[0].inputSchema.properties).toHaveProperty('period');
183+
expect(definitions[0].inputSchema.properties).toHaveProperty('statsPath');
184+
});
185+
186+
it('should have a descriptive description', () => {
187+
const definitions = handler.getToolDefinitions();
188+
expect(definitions[0].description).toContain('impact');
189+
});
190+
});
191+
});

0 commit comments

Comments
 (0)