diff --git a/mcp/src/i18n/en.ts b/mcp/src/i18n/en.ts index 7ed3dc3..1b13627 100644 --- a/mcp/src/i18n/en.ts +++ b/mcp/src/i18n/en.ts @@ -18,12 +18,15 @@ export const en: Record = { "get_inspection.param.id": "The inspection_id or site_inspection_id to retrieve", "get_issues.description": - "Get a filtered list of issues found during an inspection. Filter by category and/or severity.", - "get_issues.param.id": "The inspection_id to get issues for", + "Get a filtered list of issues found during an inspection. Works with both single-page inspection_id and site_inspection_id. For site inspections, aggregates issues from all pages.", + "get_issues.param.id": + "The inspection_id or site_inspection_id to get issues for", "get_issues.param.category": "Filter by category: html_css, accessibility, seo, or performance_security", "get_issues.param.severity": "Filter by severity: critical, major, minor, or info", + "get_issues.param.page_url": + "For site inspections: filter issues by page URL substring", "get_history.description": "List recent inspection history. Optionally filter by URL substring.", "get_history.param.url": "Optional URL substring to filter results", diff --git a/mcp/src/i18n/ko.ts b/mcp/src/i18n/ko.ts index d0727b8..fb451bb 100644 --- a/mcp/src/i18n/ko.ts +++ b/mcp/src/i18n/ko.ts @@ -18,12 +18,15 @@ export const ko: Record = { "get_inspection.param.id": "조회할 inspection_id 또는 site_inspection_id", "get_issues.description": - "검사에서 발견된 이슈 목록을 필터링하여 조회합니다. 카테고리 및 심각도로 필터링 가능.", - "get_issues.param.id": "이슈를 조회할 inspection_id", + "검사에서 발견된 이슈 목록을 필터링하여 조회합니다. 단일 페이지 inspection_id와 site_inspection_id 모두 지원. 사이트 검사 시 모든 페이지의 이슈를 집계합니다.", + "get_issues.param.id": + "이슈를 조회할 inspection_id 또는 site_inspection_id", "get_issues.param.category": "카테고리 필터: html_css, accessibility, seo, performance_security", "get_issues.param.severity": "심각도 필터: critical, major, minor, info", + "get_issues.param.page_url": + "사이트 검사 시: 페이지 URL 부분 문자열로 이슈 필터링", "get_history.description": "최근 검사 이력을 조회합니다. URL로 필터링 가능.", "get_history.param.url": "결과를 필터링할 URL (부분 문자열)", diff --git a/mcp/src/server.ts b/mcp/src/server.ts index 83f4caa..cab804e 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -134,6 +134,10 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server { enum: ["critical", "major", "minor", "info"], description: t("get_issues.param.severity", defaultLang), }, + page_url: { + type: "string", + description: t("get_issues.param.page_url", defaultLang), + }, language: { type: "string", enum: ["en", "ko"], @@ -209,6 +213,7 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server { lang, args.category as string | undefined, args.severity as string | undefined, + args.page_url as string | undefined, ); break; diff --git a/mcp/src/tools/get-issues.ts b/mcp/src/tools/get-issues.ts index 3124b4a..f5f8d71 100644 --- a/mcp/src/tools/get-issues.ts +++ b/mcp/src/tools/get-issues.ts @@ -1,4 +1,4 @@ -import { ApiClient } from "../api-client.js"; +import { ApiClient, type Issue } from "../api-client.js"; import { t } from "../i18n/index.js"; export async function getIssues( @@ -7,16 +7,155 @@ export async function getIssues( lang: "en" | "ko", category?: string, severity?: string, + pageUrl?: string, ): Promise { - const result = await client.getIssues(id, category, severity); + // Try single-page first + try { + const result = await client.getIssues(id, category, severity); + return formatSingleIssues(result.inspection_id, result.issues, result.total, result.filters, lang); + } catch { + // Might be a site inspection ID — aggregate issues from all pages + } + + try { + return await formatSiteIssues(client, id, lang, category, severity, pageUrl); + } catch { + return t("get_inspection.not_found", lang, { id }); + } +} + +async function formatSiteIssues( + client: ApiClient, + siteId: string, + lang: "en" | "ko", + category?: string, + severity?: string, + pageUrl?: string, +): Promise { + const site = await client.getSiteInspection(siteId); + + // Collect pages with inspection results + let pages = site.discovered_pages.filter( + (p) => p.inspection_id && p.status === "completed", + ); + + // Filter by page URL if specified + if (pageUrl) { + pages = pages.filter((p) => p.url.includes(pageUrl)); + } + + if (pages.length === 0) { + const lines = [ + `# ${t("get_issues.title", lang)} (Site)`, + `**Site**: ${site.root_url}`, + "", + "No completed page inspections found.", + ]; + return lines.join("\n"); + } + + // Fetch issues from each page (parallel, max 10 concurrent) + type PageIssues = { url: string; issues: Issue[] }; + const pageIssues: PageIssues[] = []; + + const chunks = chunkArray(pages, 10); + for (const chunk of chunks) { + const results = await Promise.allSettled( + chunk.map(async (p) => { + const r = await client.getIssues(p.inspection_id!, category, severity); + return { url: p.url, issues: r.issues } as PageIssues; + }), + ); + for (const r of results) { + if (r.status === "fulfilled" && r.value.issues.length > 0) { + pageIssues.push(r.value); + } + } + } + + // Aggregate all issues + const allIssues = pageIssues.flatMap((p) => + p.issues.map((i) => ({ ...i, _pageUrl: p.url })), + ); const lines: string[] = []; - lines.push(`# ${t("get_issues.title", lang)}`); - lines.push(`**Inspection ID**: ${result.inspection_id}`); - lines.push(`**${t("get_issues.total", lang)}**: ${result.total}`); + lines.push(`# ${t("get_issues.title", lang)} (Site)`); + lines.push(`**Site**: ${site.root_url} (${site.domain})`); + lines.push(`**Pages**: ${pages.length}`); + lines.push(`**${t("get_issues.total", lang)}**: ${allIssues.length}`); // Show applied filters - const activeFilters = Object.entries(result.filters) + const filters: string[] = []; + if (category) filters.push(`category: ${category}`); + if (severity) filters.push(`severity: ${severity}`); + if (pageUrl) filters.push(`page: ${pageUrl}`); + if (filters.length > 0) { + lines.push(`**${t("get_issues.filters", lang)}**: ${filters.join(", ")}`); + } + lines.push(""); + + if (allIssues.length === 0) { + lines.push("No issues found."); + return lines.join("\n"); + } + + // Summary by severity + const sevCounts = { critical: 0, major: 0, minor: 0, info: 0 }; + for (const i of allIssues) sevCounts[i.severity]++; + lines.push( + `**Summary**: Critical: ${sevCounts.critical}, Major: ${sevCounts.major}, Minor: ${sevCounts.minor}, Info: ${sevCounts.info}`, + ); + lines.push(""); + + // Per-page breakdown table + lines.push("## Pages"); + lines.push("| Page | Issues | Critical | Major |"); + lines.push("|---|---|---|---|"); + for (const pi of pageIssues) { + const crit = pi.issues.filter((i) => i.severity === "critical").length; + const maj = pi.issues.filter((i) => i.severity === "major").length; + const shortUrl = pi.url.length > 60 ? pi.url.slice(0, 57) + "..." : pi.url; + lines.push(`| ${shortUrl} | ${pi.issues.length} | ${crit} | ${maj} |`); + } + lines.push(""); + + // Top issues across all pages (critical + major first, max 15) + const sorted = allIssues + .sort((a, b) => sevOrder(a.severity) - sevOrder(b.severity)) + .slice(0, 15); + + lines.push("## Top Issues"); + for (let i = 0; i < sorted.length; i++) { + const issue = sorted[i]; + const shortUrl = issue._pageUrl.replace(site.root_url, "") || "/"; + lines.push( + `### ${i + 1}. [${issue.severity.toUpperCase()}] ${issue.code} — ${shortUrl}`, + ); + lines.push(`- **${t("result.message", lang)}**: ${issue.message}`); + if (issue.element) { + const el = issue.element.length > 120 ? issue.element.slice(0, 117) + "..." : issue.element; + lines.push(`- **${t("result.element", lang)}**: \`${el}\``); + } + lines.push(`- **${t("result.suggestion", lang)}**: ${issue.suggestion}`); + lines.push(""); + } + + return lines.join("\n"); +} + +function formatSingleIssues( + inspectionId: string, + issues: Issue[], + total: number, + filters: Record, + lang: "en" | "ko", +): string { + const lines: string[] = []; + lines.push(`# ${t("get_issues.title", lang)}`); + lines.push(`**Inspection ID**: ${inspectionId}`); + lines.push(`**${t("get_issues.total", lang)}**: ${total}`); + + const activeFilters = Object.entries(filters) .filter(([, v]) => v) .map(([k, v]) => `${k}: ${v}`) .join(", "); @@ -25,7 +164,7 @@ export async function getIssues( } lines.push(""); - if (result.issues.length === 0) { + if (issues.length === 0) { lines.push("No issues found."); return lines.join("\n"); } @@ -36,8 +175,8 @@ export async function getIssues( ); lines.push("|---|---|---|---|---|"); - for (let i = 0; i < result.issues.length; i++) { - const issue = result.issues[i]; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; const msg = issue.message.length > 80 ? issue.message.slice(0, 77) + "..." @@ -49,10 +188,10 @@ export async function getIssues( lines.push(""); // Detailed view for first 10 - const detailCount = Math.min(result.issues.length, 10); + const detailCount = Math.min(issues.length, 10); lines.push("## Details"); for (let i = 0; i < detailCount; i++) { - const issue = result.issues[i]; + const issue = issues[i]; lines.push(`### ${i + 1}. [${issue.severity.toUpperCase()}] ${issue.code}`); lines.push(`- **${t("result.message", lang)}**: ${issue.message}`); if (issue.element) { @@ -69,3 +208,15 @@ export async function getIssues( return lines.join("\n"); } + +function sevOrder(s: string): number { + return { critical: 0, major: 1, minor: 2, info: 3 }[s] ?? 4; +} + +function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +}