Compare commits
8 Commits
bffce65aca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc5ca95008 | |||
| 3227091c58 | |||
| c8f3d3a6ea | |||
| e939f0d2a1 | |||
| 39cf58df01 | |||
| 39ffe879f0 | |||
| 6eaef94a78 | |||
| 69e0f80282 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
node_modules/
|
node_modules/
|
||||||
|
dist/
|
||||||
.next/
|
.next/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
backups/
|
backups/
|
||||||
|
|||||||
@ -78,6 +78,27 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# MCP Server
|
||||||
|
# ===================
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ./mcp
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: web-inspector-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MCP_PORT:-3100}:3100"
|
||||||
|
environment:
|
||||||
|
- TRANSPORT=http
|
||||||
|
- API_URL=http://backend:8000
|
||||||
|
- PORT=3100
|
||||||
|
- LANGUAGE=${MCP_LANGUAGE:-en}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongodb_data:
|
mongodb_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
16
mcp/Dockerfile
Normal file
16
mcp/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
EXPOSE 3100
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
2200
mcp/package-lock.json
generated
Normal file
2200
mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
mcp/package.json
Normal file
40
mcp/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "web-inspector-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP server for Web Inspector — inspect web pages for HTML/CSS, accessibility (WCAG/KWCAG), SEO, and performance/security",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"web-inspector-mcp": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"mcp-server",
|
||||||
|
"web-inspector",
|
||||||
|
"accessibility",
|
||||||
|
"wcag",
|
||||||
|
"kwcag",
|
||||||
|
"seo",
|
||||||
|
"html"
|
||||||
|
],
|
||||||
|
"author": "yakenator",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
|
"express": "^4.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
mcp/smithery.yaml
Normal file
24
mcp/smithery.yaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
startCommand:
|
||||||
|
type: stdio
|
||||||
|
configSchema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
apiUrl:
|
||||||
|
type: string
|
||||||
|
description: "Web Inspector API base URL"
|
||||||
|
default: "https://web-inspector.yakenator.io"
|
||||||
|
language:
|
||||||
|
type: string
|
||||||
|
description: "Default language (en or ko)"
|
||||||
|
enum: ["en", "ko"]
|
||||||
|
default: "en"
|
||||||
|
commandFunction: |-
|
||||||
|
(config) => ({
|
||||||
|
command: "node",
|
||||||
|
args: ["dist/index.js"],
|
||||||
|
env: {
|
||||||
|
API_URL: config.apiUrl || "https://web-inspector.yakenator.io",
|
||||||
|
LANGUAGE: config.language || "en",
|
||||||
|
TRANSPORT: "stdio"
|
||||||
|
}
|
||||||
|
})
|
||||||
234
mcp/src/api-client.ts
Normal file
234
mcp/src/api-client.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* REST API client for Web Inspector backend.
|
||||||
|
* Uses native fetch (Node 20+) with AbortController timeout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 120_000; // 120 seconds
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
constructor(private baseUrl: string) {
|
||||||
|
// Strip trailing slash
|
||||||
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-page inspection ──────────────────────────────
|
||||||
|
|
||||||
|
async startInspection(
|
||||||
|
url: string,
|
||||||
|
accessibilityStandard?: string,
|
||||||
|
): Promise<{ inspection_id: string; status: string }> {
|
||||||
|
const body: Record<string, unknown> = { url };
|
||||||
|
if (accessibilityStandard) body.accessibility_standard = accessibilityStandard;
|
||||||
|
return this.post("/api/inspections", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInspection(inspectionId: string): Promise<InspectionResult> {
|
||||||
|
return this.get(`/api/inspections/${inspectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIssues(
|
||||||
|
inspectionId: string,
|
||||||
|
category?: string,
|
||||||
|
severity?: string,
|
||||||
|
): Promise<IssuesResult> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (category) params.set("category", category);
|
||||||
|
if (severity) params.set("severity", severity);
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.get(`/api/inspections/${inspectionId}/issues${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInspections(
|
||||||
|
url?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<InspectionsListResult> {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (url) params.set("url", url);
|
||||||
|
return this.get(`/api/inspections?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Site-wide inspection ────────────────────────────────
|
||||||
|
|
||||||
|
async startSiteInspection(
|
||||||
|
url: string,
|
||||||
|
maxPages?: number,
|
||||||
|
maxDepth?: number,
|
||||||
|
accessibilityStandard?: string,
|
||||||
|
): Promise<{ site_inspection_id: string; status: string }> {
|
||||||
|
const body: Record<string, unknown> = { url };
|
||||||
|
if (maxPages !== undefined) body.max_pages = maxPages;
|
||||||
|
if (maxDepth !== undefined) body.max_depth = maxDepth;
|
||||||
|
if (accessibilityStandard) body.accessibility_standard = accessibilityStandard;
|
||||||
|
return this.post("/api/site-inspections", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSiteInspection(siteInspectionId: string): Promise<SiteInspectionResult> {
|
||||||
|
return this.get(`/api/site-inspections/${siteInspectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSiteInspections(limit = 10): Promise<SiteInspectionsListResult> {
|
||||||
|
return this.get(`/api/site-inspections?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
private async get<T>(path: string): Promise<T> {
|
||||||
|
return this.request("GET", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request("POST", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`API ${method} ${path} failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response types ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Issue {
|
||||||
|
code: string;
|
||||||
|
category: string;
|
||||||
|
severity: "critical" | "major" | "minor" | "info";
|
||||||
|
message: string;
|
||||||
|
element?: string;
|
||||||
|
line?: number;
|
||||||
|
suggestion: string;
|
||||||
|
wcag_criterion?: string;
|
||||||
|
kwcag_criterion?: string;
|
||||||
|
kwcag_name?: string;
|
||||||
|
kwcag_principle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryResult {
|
||||||
|
score: number;
|
||||||
|
grade: string;
|
||||||
|
total_issues: number;
|
||||||
|
critical: number;
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
info: number;
|
||||||
|
issues: Issue[];
|
||||||
|
wcag_level?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionResult {
|
||||||
|
inspection_id: string;
|
||||||
|
url: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
duration_seconds?: number;
|
||||||
|
overall_score: number;
|
||||||
|
grade: string;
|
||||||
|
accessibility_standard?: string;
|
||||||
|
categories: {
|
||||||
|
html_css: CategoryResult;
|
||||||
|
accessibility: CategoryResult;
|
||||||
|
seo: CategoryResult;
|
||||||
|
performance_security: CategoryResult;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
total_issues: number;
|
||||||
|
critical: number;
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
info: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuesResult {
|
||||||
|
inspection_id: string;
|
||||||
|
total: number;
|
||||||
|
filters: Record<string, string | null>;
|
||||||
|
issues: Issue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionsListResult {
|
||||||
|
items: Array<{
|
||||||
|
inspection_id: string;
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
overall_score: number;
|
||||||
|
grade: string;
|
||||||
|
total_issues: number;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteInspectionResult {
|
||||||
|
site_inspection_id: string;
|
||||||
|
root_url: string;
|
||||||
|
domain: string;
|
||||||
|
status: "crawling" | "inspecting" | "completed" | "error";
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
config: {
|
||||||
|
max_pages: number;
|
||||||
|
max_depth: number;
|
||||||
|
concurrency: number;
|
||||||
|
accessibility_standard: string;
|
||||||
|
};
|
||||||
|
discovered_pages: Array<{
|
||||||
|
url: string;
|
||||||
|
depth: number;
|
||||||
|
parent_url?: string;
|
||||||
|
inspection_id?: string;
|
||||||
|
status: string;
|
||||||
|
title?: string;
|
||||||
|
overall_score?: number;
|
||||||
|
grade?: string;
|
||||||
|
}>;
|
||||||
|
aggregate_scores?: {
|
||||||
|
overall_score: number;
|
||||||
|
grade: string;
|
||||||
|
html_css: number;
|
||||||
|
accessibility: number;
|
||||||
|
seo: number;
|
||||||
|
performance_security: number;
|
||||||
|
total_issues: number;
|
||||||
|
pages_inspected: number;
|
||||||
|
pages_total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteInspectionsListResult {
|
||||||
|
items: Array<{
|
||||||
|
site_inspection_id: string;
|
||||||
|
root_url: string;
|
||||||
|
domain: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
pages_total: number;
|
||||||
|
pages_inspected: number;
|
||||||
|
overall_score?: number;
|
||||||
|
grade?: string;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
68
mcp/src/i18n/en.ts
Normal file
68
mcp/src/i18n/en.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export const en: Record<string, string> = {
|
||||||
|
// --- Tool descriptions ---
|
||||||
|
"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.",
|
||||||
|
"inspect_page.param.url":
|
||||||
|
"The URL of the web page to inspect (must start with http:// or https://)",
|
||||||
|
"inspect_page.param.standard":
|
||||||
|
"Accessibility standard to check against (default: wcag_2.1_aa)",
|
||||||
|
"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.",
|
||||||
|
"inspect_site.param.url": "Root URL of the site to crawl and inspect",
|
||||||
|
"inspect_site.param.max_pages":
|
||||||
|
"Maximum number of pages to crawl (default: 20, max: 500, 0: unlimited)",
|
||||||
|
"inspect_site.param.max_depth":
|
||||||
|
"Maximum link depth to crawl (default: 2, max: 3)",
|
||||||
|
"get_inspection.description":
|
||||||
|
"Get detailed inspection results by inspection ID. Works for both single-page and site inspections.",
|
||||||
|
"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. 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",
|
||||||
|
"get_history.param.limit": "Number of results to return (default: 10)",
|
||||||
|
"common.param.language": "Response language: 'en' for English, 'ko' for Korean",
|
||||||
|
|
||||||
|
// --- Result formatting ---
|
||||||
|
"result.title": "Web Inspection Result",
|
||||||
|
"result.overall_score": "Overall Score",
|
||||||
|
"result.duration": "Duration",
|
||||||
|
"result.standard": "Standard",
|
||||||
|
"result.category_scores": "Category Scores",
|
||||||
|
"result.category": "Category",
|
||||||
|
"result.score": "Score",
|
||||||
|
"result.grade": "Grade",
|
||||||
|
"result.issues": "Issues",
|
||||||
|
"result.issue_summary": "Issue Summary",
|
||||||
|
"result.total": "Total",
|
||||||
|
"result.top_issues": "Top Issues (Critical/Major)",
|
||||||
|
"result.message": "Message",
|
||||||
|
"result.element": "Element",
|
||||||
|
"result.suggestion": "Suggestion",
|
||||||
|
"result.severity": "Severity",
|
||||||
|
"result.more_issues_hint":
|
||||||
|
"Use `get_issues` with inspection_id '{inspectionId}' to see all issues with filtering.",
|
||||||
|
|
||||||
|
// --- Tool responses ---
|
||||||
|
"inspect_page.timeout":
|
||||||
|
"Inspection timed out for {url}. Inspection ID: {inspectionId}. Try get_inspection later.",
|
||||||
|
"inspect_site.started": "Site inspection started successfully.",
|
||||||
|
"inspect_site.follow_up_hint":
|
||||||
|
"The crawl is running in the background. Use `get_inspection` with ID '{id}' to check the result after a few minutes.",
|
||||||
|
"get_inspection.not_found": "Inspection '{id}' not found.",
|
||||||
|
"get_issues.title": "Issue List",
|
||||||
|
"get_issues.total": "Total Issues",
|
||||||
|
"get_issues.filters": "Filters",
|
||||||
|
"get_history.title": "Inspection History",
|
||||||
|
"get_history.total": "Total Records",
|
||||||
|
};
|
||||||
23
mcp/src/i18n/index.ts
Normal file
23
mcp/src/i18n/index.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { en } from "./en.js";
|
||||||
|
import { ko } from "./ko.js";
|
||||||
|
|
||||||
|
const messages: Record<string, Record<string, string>> = { en, ko };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a key with optional interpolation.
|
||||||
|
* Usage: t("inspect_page.description", "ko")
|
||||||
|
* t("result.title", "en", { url: "..." })
|
||||||
|
*/
|
||||||
|
export function t(
|
||||||
|
key: string,
|
||||||
|
lang: "en" | "ko",
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
let text = messages[lang]?.[key] || messages["en"]?.[key] || key;
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
text = text.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
68
mcp/src/i18n/ko.ts
Normal file
68
mcp/src/i18n/ko.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export const ko: Record<string, string> = {
|
||||||
|
// --- 도구 설명 ---
|
||||||
|
"inspect_page.description":
|
||||||
|
"단일 웹 페이지의 HTML/CSS 품질, 접근성(WCAG/KWCAG), SEO, 성능/보안을 검사합니다. 점수, 등급, 주요 이슈를 반환합니다. 10-30초 소요.",
|
||||||
|
"inspect_page.param.url":
|
||||||
|
"검사할 웹 페이지 URL (http:// 또는 https://로 시작)",
|
||||||
|
"inspect_page.param.standard":
|
||||||
|
"접근성 검사 표준 (기본값: wcag_2.1_aa)",
|
||||||
|
"inspect_site.description":
|
||||||
|
"사이트 전체 크롤링 및 검사를 시작합니다. 루트 URL에서 링크를 따라가며 각 페이지를 검사합니다. site_inspection_id를 반환하며, get_inspection으로 결과를 확인할 수 있습니다.",
|
||||||
|
"inspect_site.param.url": "크롤링 및 검사할 사이트의 루트 URL",
|
||||||
|
"inspect_site.param.max_pages":
|
||||||
|
"최대 크롤링 페이지 수 (기본값: 20, 최대: 500, 0: 무제한)",
|
||||||
|
"inspect_site.param.max_depth":
|
||||||
|
"최대 크롤링 깊이 (기본값: 2, 최대: 3)",
|
||||||
|
"get_inspection.description":
|
||||||
|
"검사 ID로 상세 결과를 조회합니다. 단일 페이지 및 사이트 검사 모두 지원.",
|
||||||
|
"get_inspection.param.id":
|
||||||
|
"조회할 inspection_id 또는 site_inspection_id",
|
||||||
|
"get_issues.description":
|
||||||
|
"검사에서 발견된 이슈 목록을 필터링하여 조회합니다. 단일 페이지 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 (부분 문자열)",
|
||||||
|
"get_history.param.limit": "반환할 결과 수 (기본값: 10)",
|
||||||
|
"common.param.language": "응답 언어: 'en' 영어, 'ko' 한국어",
|
||||||
|
|
||||||
|
// --- 결과 포맷 ---
|
||||||
|
"result.title": "웹 검사 결과",
|
||||||
|
"result.overall_score": "종합 점수",
|
||||||
|
"result.duration": "소요 시간",
|
||||||
|
"result.standard": "검사 표준",
|
||||||
|
"result.category_scores": "카테고리별 점수",
|
||||||
|
"result.category": "카테고리",
|
||||||
|
"result.score": "점수",
|
||||||
|
"result.grade": "등급",
|
||||||
|
"result.issues": "이슈",
|
||||||
|
"result.issue_summary": "이슈 요약",
|
||||||
|
"result.total": "전체",
|
||||||
|
"result.top_issues": "주요 이슈 (Critical/Major)",
|
||||||
|
"result.message": "메시지",
|
||||||
|
"result.element": "요소",
|
||||||
|
"result.suggestion": "개선 제안",
|
||||||
|
"result.severity": "심각도",
|
||||||
|
"result.more_issues_hint":
|
||||||
|
"`get_issues` 도구에 inspection_id '{inspectionId}'를 사용하여 모든 이슈를 필터링해서 확인할 수 있습니다.",
|
||||||
|
|
||||||
|
// --- 도구 응답 ---
|
||||||
|
"inspect_page.timeout":
|
||||||
|
"{url} 검사가 시간 초과되었습니다. Inspection ID: {inspectionId}. 나중에 get_inspection으로 확인해 보세요.",
|
||||||
|
"inspect_site.started": "사이트 검사가 성공적으로 시작되었습니다.",
|
||||||
|
"inspect_site.follow_up_hint":
|
||||||
|
"크롤링이 백그라운드에서 실행 중입니다. 몇 분 후 `get_inspection`에 ID '{id}'를 사용하여 결과를 확인하세요.",
|
||||||
|
"get_inspection.not_found": "검사 '{id}'를 찾을 수 없습니다.",
|
||||||
|
"get_issues.title": "이슈 목록",
|
||||||
|
"get_issues.total": "전체 이슈 수",
|
||||||
|
"get_issues.filters": "필터",
|
||||||
|
"get_history.title": "검사 이력",
|
||||||
|
"get_history.total": "전체 레코드",
|
||||||
|
};
|
||||||
181
mcp/src/index.ts
Normal file
181
mcp/src/index.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
|
import express from "express";
|
||||||
|
import { createServer } from "./server.js";
|
||||||
|
|
||||||
|
type Lang = "en" | "ko";
|
||||||
|
|
||||||
|
const TRANSPORT = process.env.TRANSPORT || "stdio";
|
||||||
|
const API_URL = process.env.API_URL || "http://localhost:8011";
|
||||||
|
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||||
|
const LANGUAGE = (process.env.LANGUAGE || "en") as Lang;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const server = createServer(API_URL, LANGUAGE);
|
||||||
|
|
||||||
|
if (TRANSPORT === "http") {
|
||||||
|
await startHttp(server, PORT);
|
||||||
|
} else {
|
||||||
|
await startStdio(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startStdio(server: ReturnType<typeof createServer>) {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
console.error(`[web-inspector-mcp] stdio mode, API: ${API_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startHttp(server: ReturnType<typeof createServer>, port: number) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.post("/mcp", async (req, res) => {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
});
|
||||||
|
res.on("close", () => {
|
||||||
|
transport.close();
|
||||||
|
});
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/mcp", async (req, res) => {
|
||||||
|
res.writeHead(405).end(JSON.stringify({ error: "Use POST for MCP requests" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/mcp", async (req, res) => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.error(
|
||||||
|
`[web-inspector-mcp] HTTP mode on port ${port}, API: ${API_URL}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
325
mcp/src/server.ts
Normal file
325
mcp/src/server.ts
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
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";
|
||||||
|
import { inspectPage } from "./tools/inspect-page.js";
|
||||||
|
import { inspectSite } from "./tools/inspect-site.js";
|
||||||
|
import { getInspection } from "./tools/get-inspection.js";
|
||||||
|
import { getIssues } from "./tools/get-issues.js";
|
||||||
|
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",
|
||||||
|
"wcag_2.1_aa",
|
||||||
|
"wcag_2.2_aa",
|
||||||
|
"kwcag_2.1",
|
||||||
|
"kwcag_2.2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function createServer(apiUrl: string, defaultLang: Lang): Server {
|
||||||
|
const client = new ApiClient(apiUrl);
|
||||||
|
|
||||||
|
const server = new Server(
|
||||||
|
{ name: "web-inspector-mcp", version: "1.0.0" },
|
||||||
|
{
|
||||||
|
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 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
url: {
|
||||||
|
type: "string",
|
||||||
|
description: t("inspect_page.param.url", defaultLang),
|
||||||
|
},
|
||||||
|
accessibility_standard: {
|
||||||
|
type: "string",
|
||||||
|
enum: STANDARDS,
|
||||||
|
description: t("inspect_page.param.standard", defaultLang),
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["en", "ko"],
|
||||||
|
description: t("common.param.language", defaultLang),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
url: {
|
||||||
|
type: "string",
|
||||||
|
description: t("inspect_site.param.url", defaultLang),
|
||||||
|
},
|
||||||
|
max_pages: {
|
||||||
|
type: "number",
|
||||||
|
description: t("inspect_site.param.max_pages", defaultLang),
|
||||||
|
},
|
||||||
|
max_depth: {
|
||||||
|
type: "number",
|
||||||
|
description: t("inspect_site.param.max_depth", defaultLang),
|
||||||
|
},
|
||||||
|
accessibility_standard: {
|
||||||
|
type: "string",
|
||||||
|
enum: STANDARDS,
|
||||||
|
description: t("inspect_page.param.standard", defaultLang),
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["en", "ko"],
|
||||||
|
description: t("common.param.language", defaultLang),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
description: t("get_inspection.param.id", defaultLang),
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["en", "ko"],
|
||||||
|
description: t("common.param.language", defaultLang),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
description: t("get_issues.param.id", defaultLang),
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: "string",
|
||||||
|
enum: [
|
||||||
|
"html_css",
|
||||||
|
"accessibility",
|
||||||
|
"seo",
|
||||||
|
"performance_security",
|
||||||
|
],
|
||||||
|
description: t("get_issues.param.category", defaultLang),
|
||||||
|
},
|
||||||
|
severity: {
|
||||||
|
type: "string",
|
||||||
|
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"],
|
||||||
|
description: t("common.param.language", defaultLang),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
||||||
|
url: {
|
||||||
|
type: "string",
|
||||||
|
description: t("get_history.param.url", defaultLang),
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: t("get_history.param.limit", defaultLang),
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["en", "ko"],
|
||||||
|
description: t("common.param.language", defaultLang),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Call tool ────────────────────────────────────────────
|
||||||
|
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "inspect_page":
|
||||||
|
text = await inspectPage(
|
||||||
|
client,
|
||||||
|
args.url as string,
|
||||||
|
lang,
|
||||||
|
args.accessibility_standard as string | undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "inspect_site":
|
||||||
|
text = await inspectSite(
|
||||||
|
client,
|
||||||
|
args.url as string,
|
||||||
|
lang,
|
||||||
|
args.max_pages as number | undefined,
|
||||||
|
args.max_depth as number | undefined,
|
||||||
|
args.accessibility_standard as string | undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_inspection":
|
||||||
|
text = await getInspection(client, args.id as string, lang);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_issues":
|
||||||
|
text = await getIssues(
|
||||||
|
client,
|
||||||
|
args.id as string,
|
||||||
|
lang,
|
||||||
|
args.category as string | undefined,
|
||||||
|
args.severity as string | undefined,
|
||||||
|
args.page_url as string | undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_history":
|
||||||
|
text = await getHistory(
|
||||||
|
client,
|
||||||
|
lang,
|
||||||
|
args.url as string | undefined,
|
||||||
|
args.limit as number | undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
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}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
46
mcp/src/tools/get-history.ts
Normal file
46
mcp/src/tools/get-history.ts
Normal 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");
|
||||||
|
}
|
||||||
122
mcp/src/tools/get-inspection.ts
Normal file
122
mcp/src/tools/get-inspection.ts
Normal 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");
|
||||||
|
}
|
||||||
222
mcp/src/tools/get-issues.ts
Normal file
222
mcp/src/tools/get-issues.ts
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { ApiClient, type Issue } 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,
|
||||||
|
pageUrl?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// 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)} (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 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(", ");
|
||||||
|
if (activeFilters) {
|
||||||
|
lines.push(`**${t("get_issues.filters", lang)}**: ${activeFilters}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (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 < issues.length; i++) {
|
||||||
|
const issue = 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(issues.length, 10);
|
||||||
|
lines.push("## Details");
|
||||||
|
for (let i = 0; i < detailCount; 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) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
127
mcp/src/tools/inspect-page.ts
Normal file
127
mcp/src/tools/inspect-page.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
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 (404 = not yet saved, keep polling)
|
||||||
|
let result: InspectionResult | null = null;
|
||||||
|
for (let i = 0; i < MAX_POLLS; i++) {
|
||||||
|
await sleep(POLL_INTERVAL);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
25
mcp/src/tools/inspect-site.ts
Normal file
25
mcp/src/tools/inspect-site.ts
Normal 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");
|
||||||
|
}
|
||||||
17
mcp/tsconfig.json
Normal file
17
mcp/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@ -6,12 +6,29 @@ upstream backend {
|
|||||||
server backend:8000;
|
server backend:8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream mcp {
|
||||||
|
server mcp:3100;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name web-inspector.yakenator.io;
|
server_name web-inspector.yakenator.io;
|
||||||
|
|
||||||
client_max_body_size 10M;
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
# MCP 엔드포인트 (Streamable HTTP)
|
||||||
|
location /mcp {
|
||||||
|
proxy_pass http://mcp;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
add_header X-Accel-Buffering no;
|
||||||
|
}
|
||||||
|
|
||||||
# API 요청 → Backend
|
# API 요청 → Backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
|
|||||||
Reference in New Issue
Block a user