From edc54c2116091801a6e9ced6f4ae05359012b01c Mon Sep 17 00:00:00 2001 From: oekazuma Date: Wed, 8 Jul 2026 16:27:33 +0900 Subject: [PATCH] feat(mcp): add categories input to the analyze tool Mirrors the CLI's --category flag: restrict analysis to rules in the given categories, intersecting with rules/ignore selection. Category values are accepted case-insensitively, matching the weights input's existing convention. --- .changeset/mcp-analyze-categories.md | 5 +++ docs/src/content/docs/guides/mcp.md | 1 + docs/src/content/docs/ja/guides/mcp.md | 1 + packages/mcp/src/tools/analyze.ts | 12 +++++- packages/mcp/test/analyze-tool.test.ts | 59 ++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-analyze-categories.md diff --git a/.changeset/mcp-analyze-categories.md b/.changeset/mcp-analyze-categories.md new file mode 100644 index 000000000..dd1690cc2 --- /dev/null +++ b/.changeset/mcp-analyze-categories.md @@ -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. diff --git a/docs/src/content/docs/guides/mcp.md b/docs/src/content/docs/guides/mcp.md index 683a3abf9..814470765 100644 --- a/docs/src/content/docs/guides/mcp.md +++ b/docs/src/content/docs/guides/mcp.md @@ -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?` | Per-category weights for the combined Health score, e.g. `{"seo": 2}` (category keys are case-insensitive; unlisted categories default to `1`) | diff --git a/docs/src/content/docs/ja/guides/mcp.md b/docs/src/content/docs/ja/guides/mcp.md index 1d3660482..f6e377f50 100644 --- a/docs/src/content/docs/ja/guides/mcp.md +++ b/docs/src/content/docs/ja/guides/mcp.md @@ -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?` | 組み合わせた Health スコアのカテゴリごとの重み(例:`{"seo": 2}`)。カテゴリ名は大文字小文字を区別せず、指定しなかったカテゴリはデフォルトの重み `1` になります | diff --git a/packages/mcp/src/tools/analyze.ts b/packages/mcp/src/tools/analyze.ts index a8dc0c39b..464e5fae0 100644 --- a/packages/mcp/src/tools/analyze.ts +++ b/packages/mcp/src/tools/analyze.ts @@ -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() @@ -81,7 +90,8 @@ export async function handleAnalyze(args: AnalyzeArgs): Promise { route: args.route, failOn: args.failOn, rules, - weights: args.weights + weights: args.weights, + categories: args.categories }); const report = buildJsonReport(results, config, { version }); return { diff --git a/packages/mcp/test/analyze-tool.test.ts b/packages/mcp/test/analyze-tool.test.ts index 151a3fa08..be4143074 100644 --- a/packages/mcp/test/analyze-tool.test.ts +++ b/packages/mcp/test/analyze-tool.test.ts @@ -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);