Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-analyze-categories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@svelte-vitals/mcp': minor
---

Add a `categories` input to the `analyze` tool, restricting analysis to rules in the given categories (intersects with `rules`/`ignore` selection, case-insensitive). Mirrors the CLI's `--category` flag.
1 change: 1 addition & 0 deletions docs/src/content/docs/guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Run static-mode analysis on a SvelteKit project.
| `treatDynamicAs` | `'pass' \| 'warn' \| 'fail'?` | How to handle dynamic metadata values |
| `rules` | `string[]?` | Rule IDs to enable (all others disabled) |
| `ignore` | `string[]?` | Rule IDs to disable |
| `categories` | `string[]?` | Restrict analysis to these categories (intersection with `rules`/`ignore`; case-insensitive) |
| `failOn` | `'critical' \| 'warning' \| 'info'?` | Severity threshold for the response's `failed` flag |
| `weights` | `Record<string, number>?` | Per-category weights for the combined Health score, e.g. `{"seo": 2}` (category keys are case-insensitive; unlisted categories default to `1`) |

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/ja/guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ SvelteKit プロジェクトの静的モード分析を実行します。
| `treatDynamicAs` | `'pass' \| 'warn' \| 'fail'?` | 動的メタデータ値の扱い方 |
| `rules` | `string[]?` | 有効にするルール ID(他はすべて無効) |
| `ignore` | `string[]?` | 無効にするルール ID |
| `categories` | `string[]?` | 分析対象をこれらのカテゴリに絞り込む(`rules`/`ignore` の選択との積集合。大文字小文字は区別しない) |
| `failOn` | `'critical' \| 'warning' \| 'info'?` | レスポンスの `failed` フラグの重大度閾値 |
| `weights` | `Record<string, number>?` | 組み合わせた Health スコアのカテゴリごとの重み(例:`{"seo": 2}`)。カテゴリ名は大文字小文字を区別せず、指定しなかったカテゴリはデフォルトの重み `1` になります |

Expand Down
12 changes: 11 additions & 1 deletion packages/mcp/src/tools/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ const analyzeInputSchema = z.object({
.describe('How dynamic ({data.title}) values are scored. Default: pass.'),
rules: z.array(z.string()).optional().describe('Enable only these rule ids (all others disabled).'),
ignore: z.array(z.string()).optional().describe('Disable these rule ids.'),
categories: z
.preprocess(
(v) => (Array.isArray(v) ? v.map((c) => String(c).toLowerCase()) : v),
z.array(z.enum(['seo', 'performance', 'correctness', 'security', 'architecture']))
)
.optional()
.describe(
'Restrict analysis to rules in these categories (intersection with rules/ignore selection). Case-insensitive. Mirrors the CLI --category flag.'
),
failOn: z
.enum(['critical', 'warning', 'info'])
.optional()
Expand Down Expand Up @@ -81,7 +90,8 @@ export async function handleAnalyze(args: AnalyzeArgs): Promise<McpToolResult> {
route: args.route,
failOn: args.failOn,
rules,
weights: args.weights
weights: args.weights,
categories: args.categories
});
const report = buildJsonReport(results, config, { version });
return {
Expand Down
59 changes: 59 additions & 0 deletions packages/mcp/test/analyze-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,65 @@ describe('analyze tool', () => {
for (const id of ids) expect(id).toBe('SEO001');
});

it('restricts findings to a single category via categories', async () => {
const res = await handleAnalyze({ path: fixtureDir, categories: ['seo'] });
expect(res.isError).toBeFalsy();
const report = res.structuredContent as { routes: Array<{ issues: Array<{ id: string }> }> };
const ids = new Set(report.routes.flatMap((r) => r.issues.map((i) => i.id)));
// Guard against a vacuous pass: the fixture must surface at least one SEO finding.
expect(ids.size).toBeGreaterThan(0);
for (const id of ids) expect(id).toMatch(/^SEO/);
});

it('returns findings across all categories when categories is not passed', async () => {
const res = await handleAnalyze({ path: fixtureDir });
expect(res.isError).toBeFalsy();
const report = res.structuredContent as { routes: Array<{ issues: Array<{ id: string }> }> };
const ids = new Set(report.routes.flatMap((r) => r.issues.map((i) => i.id)));
// The unfiltered fixture surfaces findings outside SEO too (unlike the categories: ['seo'] case above).
expect([...ids].some((id) => !id.startsWith('SEO'))).toBe(true);
});

it('accepts categories case-insensitively (via the real input schema, not handleAnalyze directly)', async () => {
// Case-insensitivity is implemented as a zod preprocess step on the input schema,
// so — like the weights case-insensitivity test below — this must go through a real
// client-server pair; calling handleAnalyze directly would bypass the schema entirely
// and the uppercase category would never get lowercased.
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createServer();
await server.connect(serverTransport);
const client = new Client({ name: 'test', version: '0.0.0' });
await client.connect(clientTransport);
try {
const lower = await handleAnalyze({ path: fixtureDir, categories: ['seo'] });
const res = await client.callTool({ name: 'analyze', arguments: { path: fixtureDir, categories: ['SEO'] } });
expect(res.isError).toBeFalsy();
expect(res.structuredContent).toEqual(lower.structuredContent);
} finally {
await client.close();
await server.close();
}
});

it('rejects an unknown category at the input-schema layer (isError, before analysis runs)', async () => {
// Go through a real client-server pair so the tool's zod inputSchema is applied
// (handleAnalyze alone would bypass it — validation lives in the MCP SDK layer).
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createServer();
await server.connect(serverTransport);
const client = new Client({ name: 'test', version: '0.0.0' });
await client.connect(clientTransport);
try {
const res = await client.callTool({ name: 'analyze', arguments: { path: fixtureDir, categories: ['a11y'] } });
expect(res.isError).toBe(true);
const text = (res.content as Array<{ type: string; text: string }>)[0]!.text;
expect(text).toContain('Input validation error');
} finally {
await client.close();
await server.close();
}
});

it('reports an error for an unknown rule id', async () => {
const res = await handleAnalyze({ path: fixtureDir, rules: ['NOPE999'] });
expect(res.isError).toBe(true);
Expand Down