feat: MCP 서버 추가 — AI 에이전트용 웹 검사 도구

Node.js + TypeScript MCP 서버 구현:
- 5개 도구: inspect_page, inspect_site, get_inspection, get_issues, get_history
- 듀얼 트랜스포트: stdio (Claude Desktop) + Streamable HTTP (Docker/원격)
- i18n 지원 (영어/한국어)
- Docker 통합 (port 3100) + Nginx /mcp 프록시
- Smithery 레지스트리 배포 설정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-14 15:44:35 +09:00
parent bffce65aca
commit 69e0f80282
51 changed files with 4327 additions and 0 deletions

View File

@ -0,0 +1,46 @@
import { ApiClient } from "../api-client.js";
import { t } from "../i18n/index.js";
export async function getHistory(
client: ApiClient,
lang: "en" | "ko",
url?: string,
limit?: number,
): Promise<string> {
const result = await client.getInspections(url, limit || 10);
const lines: string[] = [];
lines.push(`# ${t("get_history.title", lang)}`);
lines.push(`**${t("get_history.total", lang)}**: ${result.total}`);
if (url) {
lines.push(`**Filter**: ${url}`);
}
lines.push("");
if (result.items.length === 0) {
lines.push("No inspection records found.");
return lines.join("\n");
}
lines.push("| # | URL | Score | Grade | Issues | Date |");
lines.push("|---|---|---|---|---|---|");
for (let i = 0; i < result.items.length; i++) {
const item = result.items[i];
const date = item.created_at.split("T")[0];
const urlShort =
item.url.length > 50 ? item.url.slice(0, 47) + "..." : item.url;
lines.push(
`| ${i + 1} | ${urlShort} | ${item.overall_score} | ${item.grade} | ${item.total_issues} | ${date} |`,
);
}
lines.push("");
// Include inspection IDs for follow-up
lines.push("## Inspection IDs");
for (const item of result.items) {
lines.push(`- ${item.url}: \`${item.inspection_id}\``);
}
return lines.join("\n");
}

View File

@ -0,0 +1,122 @@
import { ApiClient, type InspectionResult, type SiteInspectionResult } from "../api-client.js";
import { t } from "../i18n/index.js";
export async function getInspection(
client: ApiClient,
id: string,
lang: "en" | "ko",
): Promise<string> {
// Try single-page first, then site inspection
try {
const r = await client.getInspection(id);
return formatSingleResult(r, lang);
} catch {
// Might be a site inspection ID
}
try {
const r = await client.getSiteInspection(id);
return formatSiteResult(r, lang);
} catch {
return t("get_inspection.not_found", lang, { id });
}
}
function formatSingleResult(r: InspectionResult, lang: "en" | "ko"): string {
const lines: string[] = [];
const duration = r.duration_seconds
? `${r.duration_seconds.toFixed(1)}s`
: "—";
lines.push(`# ${t("result.title", lang)}`);
lines.push(`**URL**: ${r.url}`);
lines.push(`**Status**: ${r.status}`);
lines.push(`**Inspection ID**: ${r.inspection_id}`);
if (r.status !== "completed") {
return lines.join("\n");
}
lines.push(
`**${t("result.overall_score", lang)}**: ${r.overall_score}/100 (${r.grade})`,
);
lines.push(`**${t("result.duration", lang)}**: ${duration}`);
if (r.accessibility_standard) {
lines.push(`**${t("result.standard", lang)}**: ${r.accessibility_standard}`);
}
lines.push("");
// Category scores
lines.push(`## ${t("result.category_scores", lang)}`);
lines.push(
`| ${t("result.category", lang)} | ${t("result.score", lang)} | ${t("result.grade", lang)} | ${t("result.issues", lang)} |`,
);
lines.push("|---|---|---|---|");
for (const [key, label] of [
["html_css", "HTML/CSS"],
["accessibility", "Accessibility"],
["seo", "SEO"],
["performance_security", "Performance/Security"],
] as const) {
const cat = r.categories[key];
if (!cat) continue;
lines.push(
`| ${label} | ${cat.score} | ${cat.grade} | ${cat.total_issues} |`,
);
}
lines.push("");
lines.push(
`> ${t("result.more_issues_hint", lang, { inspectionId: r.inspection_id })}`,
);
return lines.join("\n");
}
function formatSiteResult(r: SiteInspectionResult, lang: "en" | "ko"): string {
const lines: string[] = [];
lines.push(`# Site ${t("result.title", lang)}`);
lines.push(`**Root URL**: ${r.root_url}`);
lines.push(`**Domain**: ${r.domain}`);
lines.push(`**Status**: ${r.status}`);
lines.push(`**Site Inspection ID**: ${r.site_inspection_id}`);
if (r.aggregate_scores) {
const a = r.aggregate_scores;
lines.push("");
lines.push(
`**${t("result.overall_score", lang)}**: ${a.overall_score}/100 (${a.grade})`,
);
lines.push(`**Pages**: ${a.pages_inspected}/${a.pages_total}`);
lines.push("");
lines.push(`## ${t("result.category_scores", lang)}`);
lines.push(
`| ${t("result.category", lang)} | ${t("result.score", lang)} |`,
);
lines.push("|---|---|");
lines.push(`| HTML/CSS | ${a.html_css} |`);
lines.push(`| Accessibility | ${a.accessibility} |`);
lines.push(`| SEO | ${a.seo} |`);
lines.push(`| Performance/Security | ${a.performance_security} |`);
lines.push("");
lines.push(`**Total Issues**: ${a.total_issues}`);
}
// Page list
if (r.discovered_pages.length > 0) {
lines.push("");
lines.push("## Pages");
lines.push("| URL | Score | Grade | Status |");
lines.push("|---|---|---|---|");
for (const p of r.discovered_pages) {
const score = p.overall_score !== undefined ? String(p.overall_score) : "—";
const grade = p.grade || "—";
lines.push(`| ${p.url} | ${score} | ${grade} | ${p.status} |`);
}
}
return lines.join("\n");
}

View File

@ -0,0 +1,71 @@
import { ApiClient } from "../api-client.js";
import { t } from "../i18n/index.js";
export async function getIssues(
client: ApiClient,
id: string,
lang: "en" | "ko",
category?: string,
severity?: string,
): Promise<string> {
const result = await client.getIssues(id, category, severity);
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}`);
// Show applied filters
const activeFilters = Object.entries(result.filters)
.filter(([, v]) => v)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
if (activeFilters) {
lines.push(`**${t("get_issues.filters", lang)}**: ${activeFilters}`);
}
lines.push("");
if (result.issues.length === 0) {
lines.push("No issues found.");
return lines.join("\n");
}
// Issue table
lines.push(
`| # | ${t("result.severity", lang)} | Code | ${t("result.category", lang)} | ${t("result.message", lang)} |`,
);
lines.push("|---|---|---|---|---|");
for (let i = 0; i < result.issues.length; i++) {
const issue = result.issues[i];
const msg =
issue.message.length > 80
? issue.message.slice(0, 77) + "..."
: issue.message;
lines.push(
`| ${i + 1} | ${issue.severity} | ${issue.code} | ${issue.category} | ${msg} |`,
);
}
lines.push("");
// Detailed view for first 10
const detailCount = Math.min(result.issues.length, 10);
lines.push("## Details");
for (let i = 0; i < detailCount; i++) {
const issue = result.issues[i];
lines.push(`### ${i + 1}. [${issue.severity.toUpperCase()}] ${issue.code}`);
lines.push(`- **${t("result.message", lang)}**: ${issue.message}`);
if (issue.element) {
lines.push(`- **${t("result.element", lang)}**: \`${issue.element}\``);
}
lines.push(`- **${t("result.suggestion", lang)}**: ${issue.suggestion}`);
if (issue.kwcag_criterion) {
lines.push(`- **KWCAG**: ${issue.kwcag_criterion} ${issue.kwcag_name || ""}`);
} else if (issue.wcag_criterion) {
lines.push(`- **WCAG**: ${issue.wcag_criterion}`);
}
lines.push("");
}
return lines.join("\n");
}

View File

@ -0,0 +1,119 @@
import { ApiClient, type InspectionResult } from "../api-client.js";
import { t } from "../i18n/index.js";
const POLL_INTERVAL = 2_000; // 2 seconds
const MAX_POLLS = 60; // 120 seconds total
export async function inspectPage(
client: ApiClient,
url: string,
lang: "en" | "ko",
standard?: string,
): Promise<string> {
// 1. Start inspection
const { inspection_id } = await client.startInspection(url, standard);
// 2. Poll until completion
let result: InspectionResult | null = null;
for (let i = 0; i < MAX_POLLS; i++) {
await sleep(POLL_INTERVAL);
const data = await client.getInspection(inspection_id);
if (data.status === "completed") {
result = data;
break;
}
if (data.status === "error") {
throw new Error(`Inspection failed for ${url}`);
}
}
// 3. Timeout — return partial info
if (!result) {
return t("inspect_page.timeout", lang, { url, inspectionId: inspection_id });
}
// 4. Format result as markdown
return formatResult(result, lang);
}
function formatResult(r: InspectionResult, lang: "en" | "ko"): string {
const lines: string[] = [];
const duration = r.duration_seconds
? `${r.duration_seconds.toFixed(1)}s`
: "—";
lines.push(`# ${t("result.title", lang)}`);
lines.push(`**URL**: ${r.url}`);
lines.push(
`**${t("result.overall_score", lang)}**: ${r.overall_score}/100 (${r.grade})`,
);
lines.push(`**${t("result.duration", lang)}**: ${duration}`);
if (r.accessibility_standard) {
lines.push(`**${t("result.standard", lang)}**: ${r.accessibility_standard}`);
}
lines.push("");
// Category scores table
lines.push(`## ${t("result.category_scores", lang)}`);
lines.push(
`| ${t("result.category", lang)} | ${t("result.score", lang)} | ${t("result.grade", lang)} | ${t("result.issues", lang)} |`,
);
lines.push("|---|---|---|---|");
const catNames: Array<[string, string]> = [
["html_css", "HTML/CSS"],
["accessibility", "Accessibility"],
["seo", "SEO"],
["performance_security", "Performance/Security"],
];
for (const [key, label] of catNames) {
const cat = r.categories[key as keyof typeof r.categories];
if (!cat) continue;
const issueStr = `${cat.total_issues} (C:${cat.critical} M:${cat.major} m:${cat.minor} i:${cat.info})`;
lines.push(`| ${label} | ${cat.score} | ${cat.grade} | ${issueStr} |`);
}
lines.push("");
// Issue summary
const s = r.summary;
lines.push(`## ${t("result.issue_summary", lang)}`);
lines.push(
`**${t("result.total", lang)}**: ${s.total_issues} (Critical: ${s.critical}, Major: ${s.major}, Minor: ${s.minor}, Info: ${s.info})`,
);
lines.push("");
// Top issues (critical + major, max 5)
const topIssues = collectTopIssues(r, 5);
if (topIssues.length > 0) {
lines.push(`## ${t("result.top_issues", lang)}`);
for (const issue of topIssues) {
const sevLabel = issue.severity.toUpperCase();
lines.push(`### [${sevLabel}] ${issue.code}`);
lines.push(`- **${t("result.message", lang)}**: ${issue.message}`);
if (issue.element) {
lines.push(`- **${t("result.element", lang)}**: \`${issue.element}\``);
}
lines.push(`- **${t("result.suggestion", lang)}**: ${issue.suggestion}`);
lines.push("");
}
}
// Hint for more issues
lines.push(
`> ${t("result.more_issues_hint", lang, { inspectionId: r.inspection_id })}`,
);
return lines.join("\n");
}
function collectTopIssues(r: InspectionResult, max: number) {
const all = Object.values(r.categories).flatMap((cat) => cat.issues);
return all
.filter((i) => i.severity === "critical" || i.severity === "major")
.slice(0, max);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -0,0 +1,25 @@
import { ApiClient } from "../api-client.js";
import { t } from "../i18n/index.js";
export async function inspectSite(
client: ApiClient,
url: string,
lang: "en" | "ko",
maxPages?: number,
maxDepth?: number,
standard?: string,
): Promise<string> {
const result = await client.startSiteInspection(url, maxPages, maxDepth, standard);
const lines: string[] = [];
lines.push(`# ${t("inspect_site.started", lang)}`);
lines.push(`**URL**: ${url}`);
lines.push(`**Site Inspection ID**: ${result.site_inspection_id}`);
lines.push(`**Status**: ${result.status}`);
lines.push("");
lines.push(
`> ${t("inspect_site.follow_up_hint", lang, { id: result.site_inspection_id })}`,
);
return lines.join("\n");
}