Compare commits

...

6 Commits

Author SHA1 Message Date
dc5ca95008 feat: 모든 도구에 outputSchema 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:58:10 +09:00
3227091c58 feat: MCP 서버에 SVG 아이콘 엔드포인트 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:55:35 +09:00
c8f3d3a6ea feat: MCP best practices 적용 - tool annotations, server instructions, error codes
- 모든 도구에 annotations 추가 (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
- Server instructions 추가 (워크플로우 안내)
- McpError + ErrorCode로 파라미터 검증 및 에러 처리 개선
- server-card.json에 annotations, capabilities, 상세 설명 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:43:01 +09:00
e939f0d2a1 feat: MCP server-card.json 엔드포인트 추가
Smithery 품질 점수 개선을 위한 메타데이터 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:34:50 +09:00
39cf58df01 feat: get_issues에 사이트 검사 이슈 집계 지원
- site_inspection_id를 받으면 모든 페이지의 이슈를 자동 수집/집계
- page_url 파라미터로 특정 페이지 필터링 가능
- 페이지별 이슈 수 테이블 + 심각도순 상위 이슈 15개 표시
- i18n 설명 업데이트 (en/ko)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 09:12:18 +09:00
39ffe879f0 fix: inspect_page 폴링에서 404 응답을 재시도하도록 수정
검사 시작 직후 결과가 아직 DB에 저장되기 전 404가 반환되면
폴링을 계속하도록 에러 핸들링 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 08:45:36 +09:00
6 changed files with 388 additions and 28 deletions

View File

@ -18,12 +18,15 @@ export const en: Record<string, string> = {
"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",

View File

@ -18,12 +18,15 @@ export const ko: Record<string, string> = {
"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 (부분 문자열)",

View File

@ -51,6 +51,118 @@ async function startHttp(server: ReturnType<typeof createServer>, port: number)
res.writeHead(405).end(JSON.stringify({ error: "Session management not supported" }));
});
// MCP Server Card (Smithery metadata)
app.get("/.well-known/mcp/server-card.json", (_req, res) => {
res.json({
serverInfo: { name: "web-inspector-mcp", version: "1.0.0" },
authentication: { required: false },
capabilities: {
tools: true,
resources: false,
prompts: false,
},
tools: [
{
name: "inspect_page",
description:
"Inspect a single web page for HTML/CSS quality, accessibility (WCAG/KWCAG), SEO, and performance/security. Returns scores, grades, and top issues. Takes 10-30 seconds.",
annotations: { title: "Inspect Page", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "The URL of the web page to inspect (must start with http:// or https://)" },
accessibility_standard: {
type: "string",
enum: ["wcag_2.0_a", "wcag_2.0_aa", "wcag_2.1_aa", "wcag_2.2_aa", "kwcag_2.1", "kwcag_2.2"],
description: "Accessibility standard to check against (default: wcag_2.1_aa)",
},
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
},
required: ["url"],
},
},
{
name: "inspect_site",
description:
"Start a site-wide crawl and inspection. Crawls links from the root URL and inspects each page. Returns a site_inspection_id for tracking. Use get_inspection to check results later.",
annotations: { title: "Inspect Site", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Root URL of the site to crawl and inspect" },
max_pages: { type: "number", description: "Maximum number of pages to crawl (default: 20, max: 500, 0: unlimited)" },
max_depth: { type: "number", description: "Maximum link depth to crawl (default: 2, max: 3)" },
accessibility_standard: {
type: "string",
enum: ["wcag_2.0_a", "wcag_2.0_aa", "wcag_2.1_aa", "wcag_2.2_aa", "kwcag_2.1", "kwcag_2.2"],
description: "Accessibility standard to check against (default: wcag_2.1_aa)",
},
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
},
required: ["url"],
},
},
{
name: "get_inspection",
description: "Get detailed inspection results by inspection ID. Works for both single-page and site inspections.",
annotations: { title: "Get Inspection", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "The inspection_id or site_inspection_id to retrieve" },
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
},
required: ["id"],
},
},
{
name: "get_issues",
description:
"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.",
annotations: { title: "Get Issues", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "The inspection_id or site_inspection_id to get issues for" },
category: { type: "string", enum: ["html_css", "accessibility", "seo", "performance_security"], description: "Filter by category" },
severity: { type: "string", enum: ["critical", "major", "minor", "info"], description: "Filter by severity" },
page_url: { type: "string", description: "For site inspections: filter issues by page URL substring" },
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
},
required: ["id"],
},
},
{
name: "get_history",
description: "List recent inspection history. Optionally filter by URL substring.",
annotations: { title: "Get History", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Optional URL substring to filter results" },
limit: { type: "number", description: "Number of results to return (default: 10)" },
language: { type: "string", enum: ["en", "ko"], description: "Response language: 'en' for English, 'ko' for Korean" },
},
},
},
],
resources: [],
prompts: [],
});
});
// Server icon
app.get("/icon.svg", (_req, res) => {
res.type("image/svg+xml").send(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#1a1a2e"/>
<circle cx="32" cy="28" r="14" stroke="#00d2ff" stroke-width="2.5" fill="none"/>
<path d="M26 28l4 4 8-8" stroke="#00d2ff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="14" y="48" width="36" height="3" rx="1.5" fill="#00d2ff" opacity="0.3"/>
<rect x="14" y="44" width="28" height="3" rx="1.5" fill="#00d2ff" opacity="0.5"/>
<rect x="14" y="52" width="20" height="3" rx="1.5" fill="#00d2ff" opacity="0.2"/>
</svg>`);
});
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", transport: "http", api_url: API_URL });

View File

@ -1,7 +1,9 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import { ApiClient } from "./api-client.js";
import { t } from "./i18n/index.js";
@ -13,6 +15,25 @@ import { getHistory } from "./tools/get-history.js";
type Lang = "en" | "ko";
// Common output schema for all tools (markdown text content)
const TEXT_OUTPUT = {
type: "object" as const,
properties: {
content: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string", const: "text" },
text: { type: "string", description: "Markdown-formatted inspection result" },
},
required: ["type", "text"],
},
},
},
required: ["content"],
};
const STANDARDS = [
"wcag_2.0_a",
"wcag_2.0_aa",
@ -27,7 +48,18 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
const server = new Server(
{ name: "web-inspector-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } },
{
capabilities: { tools: {} },
instructions:
"Web Inspector MCP server analyzes web pages and websites for HTML/CSS quality, accessibility (WCAG 2.x / KWCAG 2.x), SEO, and performance/security.\n\n" +
"Typical workflow:\n" +
"1. Use `inspect_page` to audit a single URL (takes 10-30s, returns scores and top issues).\n" +
"2. Use `inspect_site` to start a site-wide crawl (returns an ID immediately; the crawl runs in background).\n" +
"3. Use `get_inspection` to retrieve detailed results by ID.\n" +
"4. Use `get_issues` to filter and drill into specific issues by category or severity.\n" +
"5. Use `get_history` to review past inspections.\n\n" +
"All tools accept a `language` parameter ('en' or 'ko') to control the response language.",
},
);
// ── List tools ──────────────────────────────────────────
@ -37,6 +69,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
{
name: "inspect_page",
description: t("inspect_page.description", defaultLang),
annotations: {
title: "Inspect Page",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
outputSchema: TEXT_OUTPUT,
inputSchema: {
type: "object" as const,
properties: {
@ -61,6 +101,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
{
name: "inspect_site",
description: t("inspect_site.description", defaultLang),
annotations: {
title: "Inspect Site",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
outputSchema: TEXT_OUTPUT,
inputSchema: {
type: "object" as const,
properties: {
@ -93,6 +141,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
{
name: "get_inspection",
description: t("get_inspection.description", defaultLang),
annotations: {
title: "Get Inspection",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
outputSchema: TEXT_OUTPUT,
inputSchema: {
type: "object" as const,
properties: {
@ -112,6 +168,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
{
name: "get_issues",
description: t("get_issues.description", defaultLang),
annotations: {
title: "Get Issues",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
outputSchema: TEXT_OUTPUT,
inputSchema: {
type: "object" as const,
properties: {
@ -134,6 +198,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"],
@ -146,6 +214,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
{
name: "get_history",
description: t("get_history.description", defaultLang),
annotations: {
title: "Get History",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
outputSchema: TEXT_OUTPUT,
inputSchema: {
type: "object" as const,
properties: {
@ -174,6 +250,14 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
const { name, arguments: args = {} } = request.params;
const lang: Lang = (args.language as Lang) || defaultLang;
// Validate required params
if ((name === "inspect_page" || name === "inspect_site") && !args.url) {
throw new McpError(ErrorCode.InvalidParams, "Missing required parameter: url");
}
if ((name === "get_inspection" || name === "get_issues") && !args.id) {
throw new McpError(ErrorCode.InvalidParams, "Missing required parameter: id");
}
try {
let text: string;
@ -209,6 +293,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;
@ -222,14 +307,12 @@ export function createServer(apiUrl: string, defaultLang: Lang): Server {
break;
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
return { content: [{ type: "text", text }] };
} catch (error) {
if (error instanceof McpError) throw error;
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${message}` }],

View File

@ -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<string> {
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<string> {
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<string, string | null>,
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<T>(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;
}

View File

@ -13,17 +13,25 @@ export async function inspectPage(
// 1. Start inspection
const { inspection_id } = await client.startInspection(url, standard);
// 2. Poll until completion
// 2. Poll until completion (404 = not yet saved, keep polling)
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}`);
try {
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}`);
}
} catch (err) {
// 404 or transient error — keep polling
if (err instanceof Error && err.message.includes("failed")) {
continue;
}
throw err;
}
}