@wcagc/mcp
Advanced tools
+1
-1
@@ -9,3 +9,3 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| // test/version.test.ts fails the build if the two ever drift. | ||
| export const VERSION = "0.3.1"; | ||
| export const VERSION = "0.4.0"; | ||
| /** | ||
@@ -12,0 +12,0 @@ * One server, two transports (hosted Streamable HTTP + local stdio) share this — the tool |
@@ -28,4 +28,4 @@ import { McpApiError } from "./api-client.js"; | ||
| " Call list_sites to see the registered hosts and use one of them verbatim as siteHost" + | ||
| " (host only — no https:// and no trailing path). To scan a URL that is not a registered" + | ||
| " site, omit siteHost entirely and the free path is used instead." + hint; | ||
| " (host only — no https:// and no trailing path). To scan a URL on a site that is not" + | ||
| " registered, use scan_url: it takes the URL alone and falls back to a one-off scan." + hint; | ||
| } | ||
@@ -32,0 +32,0 @@ // Without this the caller just gets "the key does not grant the required scope" and has no |
+27
-3
@@ -0,3 +1,26 @@ | ||
| import { z } from "zod"; | ||
| import { COVERAGE_DISCLAIMER, STANDARD_WCAG21_AA } from "../disclaimer.js"; | ||
| /** | ||
| * Output schemas describe what a caller actually receives, so they are deliberately permissive | ||
| * about which fields are present: one tool can be answered by two different wcagc-api endpoints | ||
| * (an accepted-but-queued scan carries only an id and a status; a finished one carries counts), | ||
| * and a schema that demanded the richer shape would make the SDK reject the honest, narrower | ||
| * result. Anything a caller can rely on unconditionally is required; everything else is nullish. | ||
| */ | ||
| export const severityCountsSchema = z.object({ | ||
| critical: z.number(), | ||
| serious: z.number(), | ||
| moderate: z.number(), | ||
| minor: z.number(), | ||
| }); | ||
| export const failureReasonSchema = z.object({ | ||
| code: z.string(), | ||
| reason: z.string().nullish(), | ||
| }); | ||
| /** Both fields are on every scan-shaped result — the honesty contract, never omitted. */ | ||
| export const disclaimerShape = { | ||
| coverageDisclaimer: z.string().describe("Why this result is not a compliance verdict. Always relay it; never present a scan as proof of conformance."), | ||
| standard: z.string().describe("The standard the checks were run against, e.g. \"WCAG 2.1 AA\"."), | ||
| }; | ||
| /** | ||
| * Every Pro+ tool wrapping a bare v1 REST response (which carries no disclaimer of its own — | ||
@@ -11,7 +34,8 @@ * that's a wcagc-mcp-only honesty requirement, ROADMAP 2.6.3) must inject one client-side before | ||
| } | ||
| const TERMINAL_OK = new Set(["DONE", "COMPLETED", "PARTIAL"]); | ||
| export function summarizeScanLike(scan, label, pollHint) { | ||
| const parts = [`${label} ${scan.id} — status ${scan.status}.`]; | ||
| if (scan.status === "DONE" || scan.status === "COMPLETED" || scan.status === "PARTIAL") { | ||
| const c = scan.counts; | ||
| parts.push(`${scan.totalViolations} issue(s) found (critical ${c.critical}, serious ${c.serious}, ` + | ||
| const c = scan.counts; | ||
| if (TERMINAL_OK.has(scan.status) && c) { | ||
| parts.push(`${scan.totalViolations ?? 0} issue(s) found (critical ${c.critical}, serious ${c.serious}, ` + | ||
| `moderate ${c.moderate}, minor ${c.minor}).`); | ||
@@ -18,0 +42,0 @@ } |
+36
-2
@@ -6,2 +6,3 @@ import { z } from "zod"; | ||
| import { toolError } from "../tool-error.js"; | ||
| import { disclaimerShape } from "./common.js"; | ||
| function summarizeText(response) { | ||
@@ -23,2 +24,17 @@ const { pdfCheck } = response; | ||
| } | ||
| const pdfCheckOutputShape = { | ||
| pdfCheck: z.object({ | ||
| id: z.string(), | ||
| status: z.string(), | ||
| profile: z.string(), | ||
| summary: z.object({ | ||
| totalAssertions: z.number().nullish(), | ||
| failedRuleCount: z.number().nullish(), | ||
| failedCheckCount: z.number().nullish(), | ||
| reportTruncated: z.boolean().nullish(), | ||
| }).nullish(), | ||
| failureReason: z.object({ code: z.string(), reason: z.string().nullish() }).nullish(), | ||
| }), | ||
| ...disclaimerShape, | ||
| }; | ||
| export function registerPdfTools(server) { | ||
@@ -34,2 +50,11 @@ server.registerTool("check_pdf", { | ||
| }, | ||
| outputSchema: pdfCheckOutputShape, | ||
| annotations: { | ||
| title: "Check a PDF for PDF/UA-1 conformance", | ||
| // Downloads the URL and spends a daily quota slot; adds a check record, removes nothing. | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }, | ||
| }, async ({ url }, extra) => { | ||
@@ -63,4 +88,13 @@ try { | ||
| title: "Get a PDF check by id", | ||
| description: "Polls a PDF check started by check_pdf.", | ||
| inputSchema: { checkId: z.string().uuid() }, | ||
| description: "Polls a PDF check started by check_pdf — status, failed-rule and failed-check " + | ||
| "counts, and a failure reason if it did not complete.", | ||
| inputSchema: { checkId: z.string().uuid().describe("The id returned by check_pdf.") }, | ||
| outputSchema: pdfCheckOutputShape, | ||
| annotations: { | ||
| title: "Get a PDF check by id", | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| }, async ({ checkId }, extra) => { | ||
@@ -67,0 +101,0 @@ try { |
+108
-12
@@ -6,3 +6,76 @@ import { z } from "zod"; | ||
| import { toolError } from "../tool-error.js"; | ||
| import { summarizeScanLike, withDisclaimer } from "./common.js"; | ||
| import { disclaimerShape, failureReasonSchema, severityCountsSchema, summarizeScanLike, withDisclaimer, } from "./common.js"; | ||
| const siteSchema = z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| rootUrl: z.string(), | ||
| normalizedHost: z.string(), | ||
| verified: z.boolean(), | ||
| }); | ||
| const runSchema = z.object({ | ||
| id: z.string(), | ||
| status: z.string(), | ||
| siteId: z.string().nullish(), | ||
| pagesTotal: z.number().nullish(), | ||
| pagesDone: z.number().nullish(), | ||
| counts: severityCountsSchema.nullish(), | ||
| totalViolations: z.number().nullish(), | ||
| failureReason: failureReasonSchema.nullish(), | ||
| }); | ||
| const runViolationSchema = z.object({ | ||
| ruleId: z.string(), | ||
| impact: z.string(), | ||
| wcagSc: z.array(z.string()), | ||
| url: z.string().nullish(), | ||
| helpUrl: z.string(), | ||
| target: z.string(), | ||
| htmlSnippet: z.string(), | ||
| failureSummary: z.string(), | ||
| }); | ||
| const journeyRunSchema = z.object({ | ||
| id: z.string(), | ||
| journeyId: z.string(), | ||
| siteId: z.string(), | ||
| status: z.string(), | ||
| failedStepIndex: z.number().nullish(), | ||
| failureReasonCode: z.string().nullish(), | ||
| failureReasonText: z.string().nullish(), | ||
| checkpoints: z.array(z.object({ | ||
| scanId: z.string(), | ||
| label: z.string(), | ||
| url: z.string(), | ||
| status: z.string(), | ||
| })), | ||
| startedAt: z.string().nullish(), | ||
| finishedAt: z.string().nullish(), | ||
| createdAt: z.string(), | ||
| }); | ||
| const trendSchema = z.object({ | ||
| siteId: z.string(), | ||
| standard: z.string().nullish(), | ||
| points: z.array(z.object({ | ||
| scanRunId: z.string(), | ||
| finishedAt: z.string(), | ||
| status: z.string(), | ||
| pagesScanned: z.number(), | ||
| truncated: z.boolean(), | ||
| totalViolations: z.number(), | ||
| bySeverity: severityCountsSchema, | ||
| addedCount: z.number().nullish(), | ||
| resolvedCount: z.number().nullish(), | ||
| })), | ||
| }); | ||
| /** Every Pro+ tool reaches wcagc-api over the network and none of them deletes anything. */ | ||
| const READ_ONLY = { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }; | ||
| const QUEUES_WORK = { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }; | ||
| /** Resolves a human-given host to the registered Site the Pro+ tools operate on. */ | ||
@@ -20,4 +93,7 @@ async function resolveSite(bearer, siteHost) { | ||
| title: "List registered sites", | ||
| description: "Lists this organization's registered sites. Pro+ (requires sites:read + API_ACCESS).", | ||
| description: "Lists this organization's registered sites — their normalized hosts are what " + | ||
| "every other Pro+ tool takes as siteHost. Pro+ (requires sites:read + API_ACCESS).", | ||
| inputSchema: {}, | ||
| outputSchema: { sites: z.array(siteSchema) }, | ||
| annotations: { title: "List registered sites", ...READ_ONLY }, | ||
| }, async (_args, extra) => { | ||
@@ -39,4 +115,9 @@ try { | ||
| description: "Crawls and scans every reachable page of a registered site. Pro+ " + | ||
| "(SCAN_FULL_SITE). Queues the run and returns immediately with a runId — call get_run to poll.", | ||
| inputSchema: { siteHost: z.string().describe("The registered site's normalized host.") }, | ||
| "(SCAN_FULL_SITE). Queues the run and returns immediately with a runId — call get_run to poll. " + | ||
| "Call list_sites first if you do not already know the exact registered host.", | ||
| inputSchema: { | ||
| siteHost: z.string().describe("The registered site's normalized host, exactly as list_sites reports it — host only, no scheme and no path."), | ||
| }, | ||
| outputSchema: { run: runSchema, ...disclaimerShape }, | ||
| annotations: { title: "Start a full-site scan", ...QUEUES_WORK }, | ||
| }, async ({ siteHost }, extra) => { | ||
@@ -46,6 +127,7 @@ try { | ||
| const site = await resolveSite(bearer, siteHost); | ||
| const run = await apiJson(bearer, "/api/v1/scan-runs", { | ||
| const accepted = await apiJson(bearer, "/api/v1/scan-runs", { | ||
| method: "POST", | ||
| body: JSON.stringify({ siteId: site.id }), | ||
| }); | ||
| const run = { ...accepted, siteId: site.id }; | ||
| const response = withDisclaimer({ run }); | ||
@@ -63,4 +145,8 @@ return { | ||
| title: "Get a full-site scan run by id", | ||
| description: "Polls a full-site run from scan_site, or a scan_url result whose pollWith says get_run. Pro+.", | ||
| inputSchema: { runId: z.string().uuid() }, | ||
| description: "Polls a full-site run started by scan_site — page progress, severity counts " + | ||
| "and, once terminal, a failure reason. Pro+. For a single-page scan from scan_url, use " + | ||
| "get_scan instead.", | ||
| inputSchema: { runId: z.string().uuid().describe("The id returned by scan_site.") }, | ||
| outputSchema: { run: runSchema, ...disclaimerShape }, | ||
| annotations: { title: "Get a full-site scan run by id", ...READ_ONLY }, | ||
| }, async ({ runId }, extra) => { | ||
@@ -82,4 +168,8 @@ try { | ||
| title: "Get a full-site scan run's findings", | ||
| description: "Run-level, rule-deduplicated findings for a scan_site run. Pro+.", | ||
| inputSchema: { runId: z.string().uuid() }, | ||
| description: "Run-level, rule-deduplicated findings for a scan_site run — one entry per " + | ||
| "rule, with the WCAG success criteria it maps to. Pro+. For a single-page scan from " + | ||
| "scan_url, use get_findings instead.", | ||
| inputSchema: { runId: z.string().uuid().describe("The id returned by scan_site.") }, | ||
| outputSchema: { violations: z.array(runViolationSchema) }, | ||
| annotations: { title: "Get a full-site scan run's findings", ...READ_ONLY }, | ||
| }, async ({ runId }, extra) => { | ||
@@ -109,5 +199,7 @@ try { | ||
| inputSchema: { | ||
| siteHost: z.string().describe("The registered site's normalized host."), | ||
| siteHost: z.string().describe("The registered site's normalized host, as list_sites reports it."), | ||
| journeyName: z.string().describe("The saved journey's name, as configured in the app."), | ||
| }, | ||
| outputSchema: { run: journeyRunSchema, ...disclaimerShape }, | ||
| annotations: { title: "Run a saved user journey", ...QUEUES_WORK }, | ||
| }, async ({ siteHost, journeyName }, extra) => { | ||
@@ -136,3 +228,5 @@ try { | ||
| "a failure reason if a step failed. Pro+.", | ||
| inputSchema: { runId: z.string().uuid() }, | ||
| inputSchema: { runId: z.string().uuid().describe("The id returned by run_journey.") }, | ||
| outputSchema: { run: journeyRunSchema, ...disclaimerShape }, | ||
| annotations: { title: "Get a journey run by id", ...READ_ONLY }, | ||
| }, async ({ runId }, extra) => { | ||
@@ -157,5 +251,7 @@ try { | ||
| inputSchema: { | ||
| siteHost: z.string().describe("The registered site's normalized host."), | ||
| siteHost: z.string().describe("The registered site's normalized host, as list_sites reports it."), | ||
| limit: z.number().int().min(1).max(100).optional().describe("Most recent N runs (default 30, max 100)."), | ||
| }, | ||
| outputSchema: { trend: trendSchema, ...disclaimerShape }, | ||
| annotations: { title: "Get a site's violation-count trend", ...READ_ONLY }, | ||
| }, async ({ siteHost, limit }, extra) => { | ||
@@ -162,0 +258,0 @@ try { |
+151
-40
@@ -5,11 +5,47 @@ import { z } from "zod"; | ||
| import { toolError } from "../tool-error.js"; | ||
| import { summarizeScanLike, withDisclaimer } from "./common.js"; | ||
| function freeScanText(response) { | ||
| return summarizeScanLike(response.scan, `Scan for ${response.scan.host}`, "Still in progress — call get_scan to poll."); | ||
| import { disclaimerShape, failureReasonSchema, severityCountsSchema, summarizeScanLike, withDisclaimer, } from "./common.js"; | ||
| const violationSchema = z.object({ | ||
| ruleId: z.string(), | ||
| impact: z.string(), | ||
| helpUrl: z.string(), | ||
| targetSelector: z.string(), | ||
| wcagSc: z.array(z.string()).nullish(), | ||
| url: z.string().nullish(), | ||
| htmlSnippet: z.string().nullish(), | ||
| failureSummary: z.string().nullish(), | ||
| }); | ||
| /** One schema for both paths a scan can come back through — see common.ts on why it is loose. */ | ||
| const scanSchema = z.object({ | ||
| id: z.string(), | ||
| status: z.string().describe("QUEUED, RUNNING, DONE, PARTIAL or FAILED."), | ||
| requestedUrl: z.string().nullish(), | ||
| host: z.string().nullish(), | ||
| siteId: z.string().nullish(), | ||
| counts: severityCountsSchema.nullish(), | ||
| totalViolations: z.number().nullish(), | ||
| topViolations: z.array(violationSchema).nullish(), | ||
| incompleteCount: z.number().nullish(), | ||
| passesCount: z.number().nullish(), | ||
| failureReason: failureReasonSchema.nullish(), | ||
| }); | ||
| const scanOutputShape = { | ||
| scan: scanSchema, | ||
| recordedAgainstSite: z.boolean().describe("True when the URL belongs to a registered site and the scan was kept against it (so it feeds history and trends); false for a one-off scan."), | ||
| pollWith: z.literal("get_scan").describe("The tool that reads this scan's progress and result."), | ||
| ...disclaimerShape, | ||
| }; | ||
| const findingsOutputShape = { | ||
| violations: z.array(violationSchema), | ||
| recordedAgainstSite: z.boolean(), | ||
| }; | ||
| /** Codes that mean "this URL/id is not on the registered-site path", not "the request was wrong". */ | ||
| function isNotRegisteredPath(err) { | ||
| if (!(err instanceof McpApiError)) | ||
| return null; | ||
| if (err.code === "SITE_NOT_FOUND") | ||
| return "unregistered"; | ||
| if (err.code === "FEATURE_NOT_IN_PLAN" || err.code === "API_KEY_SCOPE_MISSING") | ||
| return "plan"; | ||
| return null; | ||
| } | ||
| function registeredScanText(scan) { | ||
| return `${summarizeScanLike(scan, `Scan for ${scan.requestedUrl}`, "Still in progress — call get_run to poll.")}\n` + | ||
| "This URL belongs to a site registered in the account, so the scan is recorded against it — " + | ||
| "its findings feed history and trends. Poll it with get_run."; | ||
| } | ||
| /** | ||
@@ -28,12 +64,2 @@ * Falling back is not a failure, so say what happened and what it costs. Without this the caller | ||
| } | ||
| /** Codes that mean "this URL cannot use the registered-site path", not "the request was wrong". */ | ||
| function isNotRegisteredPath(err) { | ||
| if (!(err instanceof McpApiError)) | ||
| return null; | ||
| if (err.code === "SITE_NOT_FOUND") | ||
| return "unregistered"; | ||
| if (err.code === "FEATURE_NOT_IN_PLAN" || err.code === "API_KEY_SCOPE_MISSING") | ||
| return "plan"; | ||
| return null; | ||
| } | ||
| export function registerScanTools(server) { | ||
@@ -45,9 +71,19 @@ server.registerTool("scan_url", { | ||
| "registered in the caller's account (and their plan allows), the scan is recorded against " + | ||
| "that site so it feeds history and trends; otherwise it runs as a one-off. The result says " + | ||
| "which happened and which tool to poll with. Returns immediately with an id — never a " + | ||
| "compliance score, because automated testing finds only a portion of accessibility " + | ||
| "barriers; see coverageDisclaimer in the result.", | ||
| "that site so it feeds history and trends; otherwise it runs as a one-off. Either way, " + | ||
| "poll it with get_scan. Returns immediately with an id — never a compliance score, " + | ||
| "because automated testing finds only a portion of accessibility barriers; see " + | ||
| "coverageDisclaimer in the result.", | ||
| inputSchema: { | ||
| url: z.string().url().max(2048).describe("The http(s) URL to scan."), | ||
| }, | ||
| outputSchema: scanOutputShape, | ||
| annotations: { | ||
| title: "Scan a URL", | ||
| // Queues real work and spends the account's daily scan quota, so not read-only — but it | ||
| // only ever adds a scan record, and re-running one is always safe. | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }, | ||
| }, async ({ url }, extra) => { | ||
@@ -61,9 +97,19 @@ try { | ||
| try { | ||
| const scan = await apiJson(bearer, "/api/v1/scans", { | ||
| const accepted = await apiJson(bearer, "/api/v1/scans", { | ||
| method: "POST", | ||
| body: JSON.stringify({ url }), | ||
| }); | ||
| const response = withDisclaimer({ scan, recordedAgainstSite: true, pollWith: "get_run" }); | ||
| const scan = { ...accepted, requestedUrl: url }; | ||
| const response = withDisclaimer({ | ||
| scan, | ||
| recordedAgainstSite: true, | ||
| pollWith: "get_scan", | ||
| }); | ||
| return { | ||
| content: [{ type: "text", text: registeredScanText(scan) }], | ||
| content: [{ | ||
| type: "text", | ||
| text: `${summarizeScanLike(scan, `Scan for ${url}`, "Still in progress — call get_scan to poll.")}\n` + | ||
| "This URL belongs to a site registered in the account, so the scan is recorded " + | ||
| "against it — its findings feed history and trends.", | ||
| }], | ||
| structuredContent: response, | ||
@@ -82,3 +128,6 @@ }; | ||
| return { | ||
| content: [{ type: "text", text: freeScanText(free) + fallbackNote(reason) }], | ||
| content: [{ | ||
| type: "text", | ||
| text: summarizeScanLike(free.scan, `Scan for ${free.scan.host}`, "Still in progress — call get_scan to poll.") + fallbackNote(reason), | ||
| }], | ||
| structuredContent: response, | ||
@@ -93,13 +142,31 @@ }; | ||
| server.registerTool("get_scan", { | ||
| title: "Get a free-tier scan by id", | ||
| description: "Polls a one-off scan — use it when scan_url's result says pollWith get_scan. Returns severity counts, a " + | ||
| "top-5 finding sample, the coverage disclaimer, and (once terminal) a failure reason if the " + | ||
| "scan did not complete.", | ||
| inputSchema: { scanId: z.string().uuid() }, | ||
| title: "Get a scan by id", | ||
| description: "Polls a scan started by scan_url — either kind, recorded against a registered site or " + | ||
| "one-off; this tool finds it either way. Returns severity counts, a finding sample, the " + | ||
| "coverage disclaimer, and (once terminal) a failure reason if the scan did not complete. " + | ||
| "For a full-site run from scan_site, use get_run instead.", | ||
| inputSchema: { scanId: z.string().uuid().describe("The id returned by scan_url.") }, | ||
| outputSchema: scanOutputShape, | ||
| annotations: { | ||
| title: "Get a scan by id", | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| }, async ({ scanId }, extra) => { | ||
| try { | ||
| const bearer = resolveBearer(extra); | ||
| const response = await apiJson(bearer, `/api/v1/mcp/scans/${scanId}`); | ||
| const { scan, recordedAgainstSite } = await readScan(bearer, scanId); | ||
| const target = scan.requestedUrl ?? scan.host; | ||
| const response = withDisclaimer({ | ||
| scan, | ||
| recordedAgainstSite, | ||
| pollWith: "get_scan", | ||
| }); | ||
| return { | ||
| content: [{ type: "text", text: freeScanText(response) }], | ||
| content: [{ | ||
| type: "text", | ||
| text: summarizeScanLike(scan, target ? `Scan for ${target}` : "Scan", "Still in progress — call get_scan again to poll."), | ||
| }], | ||
| structuredContent: response, | ||
@@ -113,16 +180,26 @@ }; | ||
| server.registerTool("get_findings", { | ||
| title: "Get a free-tier scan's findings", | ||
| description: "The top-5 finding sample for a one-off scan (rule id, severity, help URL, target " + | ||
| "selector). For a scan recorded against a registered site, use get_run_findings instead.", | ||
| inputSchema: { scanId: z.string().uuid() }, | ||
| title: "Get a scan's findings", | ||
| description: "The findings for a scan started by scan_url — rule id, severity, help URL and the " + | ||
| "element selector. Works for both kinds of scan_url scan. A one-off scan returns the " + | ||
| "top-5 sample; a scan recorded against a registered site returns its full findings. " + | ||
| "For a full-site run from scan_site, use get_run_findings instead.", | ||
| inputSchema: { scanId: z.string().uuid().describe("The id returned by scan_url.") }, | ||
| outputSchema: findingsOutputShape, | ||
| annotations: { | ||
| title: "Get a scan's findings", | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| }, async ({ scanId }, extra) => { | ||
| try { | ||
| const bearer = resolveBearer(extra); | ||
| const violations = await apiJson(bearer, `/api/v1/mcp/scans/${scanId}/violations`); | ||
| const { violations, recordedAgainstSite } = await readFindings(bearer, scanId); | ||
| const text = violations.length === 0 | ||
| ? `No findings recorded for scan ${scanId} yet (still running, or none in the top-5 sample).` | ||
| ? `No findings recorded for scan ${scanId} yet (still running, or none found).` | ||
| : violations.map((v) => `[${v.impact}] ${v.ruleId} — ${v.targetSelector} (${v.helpUrl})`).join("\n"); | ||
| return { | ||
| content: [{ type: "text", text }], | ||
| structuredContent: { violations }, | ||
| structuredContent: { violations, recordedAgainstSite }, | ||
| }; | ||
@@ -135,1 +212,35 @@ } | ||
| } | ||
| /** | ||
| * scan_url routes a URL to whichever path the account can use, so polling has to route the id | ||
| * back the same way — otherwise every scan recorded against a registered site is a dead end | ||
| * (the id is real, but the free-tier endpoint has never heard of it and answers SCAN_NOT_FOUND). | ||
| * The two id spaces do not overlap: one-off scans live in their own non-tenant table. The free | ||
| * path is tried first because it is the common case and reads cost no quota either way. | ||
| */ | ||
| async function readScan(bearer, scanId) { | ||
| try { | ||
| const free = await apiJson(bearer, `/api/v1/mcp/scans/${scanId}`); | ||
| return { scan: free.scan, recordedAgainstSite: false }; | ||
| } | ||
| catch (err) { | ||
| if (!(err instanceof McpApiError) || err.code !== "SCAN_NOT_FOUND") | ||
| throw err; | ||
| const scan = await apiJson(bearer, `/api/v1/scans/${scanId}`); | ||
| return { scan, recordedAgainstSite: true }; | ||
| } | ||
| } | ||
| async function readFindings(bearer, scanId) { | ||
| try { | ||
| const violations = await apiJson(bearer, `/api/v1/mcp/scans/${scanId}/violations`); | ||
| return { violations, recordedAgainstSite: false }; | ||
| } | ||
| catch (err) { | ||
| if (!(err instanceof McpApiError) || err.code !== "SCAN_NOT_FOUND") | ||
| throw err; | ||
| const rich = await apiJson(bearer, `/api/v1/scans/${scanId}/violations`); | ||
| // The registered-site endpoint names the element `target`; keep the one field name the | ||
| // free-tier path already publishes so a caller never has to branch on which scan it polled. | ||
| const violations = rich.map((v) => ({ ...v, targetSelector: v.target })); | ||
| return { violations, recordedAgainstSite: true }; | ||
| } | ||
| } |
+2
-2
| { | ||
| "name": "@wcagc/mcp", | ||
| "version": "0.3.1", | ||
| "version": "0.4.0", | ||
| "mcpName": "io.github.WCAG-Compliance/mcp", | ||
@@ -49,3 +49,3 @@ "description": "wcagc MCP server \u2014 a thin, stateless adapter that translates MCP tool calls into wcagc-api HTTP calls. No database, no secrets beyond WCAGC_API_BASE_URL (+ WCAGC_MCP_KEY for local stdio mode). Open-source: this package holds no business logic or credentials of its own.", | ||
| "typecheck": "tsc --noEmit", | ||
| "verify": "node --import tsx --test test/version.test.ts test/tools.test.ts test/auth.test.ts test/quota.test.ts test/pro.test.ts test/action-manifest.test.ts", | ||
| "verify": "node --import tsx --test test/version.test.ts test/tools.test.ts test/auth.test.ts test/quota.test.ts test/pro.test.ts test/annotations.test.ts test/action-manifest.test.ts", | ||
| "verify:tools": "node --import tsx --test test/tools.test.ts", | ||
@@ -52,0 +52,0 @@ "verify:auth": "node --import tsx --test test/auth.test.ts", |
+5
-2
@@ -65,9 +65,12 @@ # wcagc-mcp | ||
| | `check_pdf` | all | Run a PDF/UA-1 structure check on a public PDF. | | ||
| | `get_scan` · `get_findings` | all | Read a scan's status, severity counts, and findings. | | ||
| | `get_scan` · `get_findings` | all | Read a `scan_url` scan's status, severity counts, and findings — either kind, recorded or one-off. | | ||
| | `list_sites` | Pro+ | List the account's registered sites. | | ||
| | `scan_site` | Pro+ | Crawl and scan every reachable page of a registered site. | | ||
| | `get_run` · `get_run_findings` | Pro+ | Read a full-site run. | | ||
| | `get_run` · `get_run_findings` | Pro+ | Read a full-site run from `scan_site`. | | ||
| | `run_journey` | Pro+ | Replay a saved multi-step journey and check each step. | | ||
| | `get_trends` | Pro+ | Read a site's violation-count history over time. | | ||
| One id, one poll tool: whatever `scan_url` did with a URL, `get_scan` and `get_findings` read it | ||
| back. `get_run` and `get_run_findings` are only for full-site runs from `scan_site`. | ||
| Every scan-producing tool returns the coverage disclaimer in both the text content and the | ||
@@ -74,0 +77,0 @@ structured content. There is no score, grade, or conformance verdict — automated testing finds |
65727
21.68%1251
26.88%106
2.91%