New:Socket for Asana Is Now Available.Learn more
Get Started

@wcagc/mcp

Package Overview
Dependencies
Maintainers
1
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@wcagc/mcp - npm Package Compare versions

Comparing version
0.3.1
to
0.4.0
+1
-1
dist/server.js

@@ -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

@@ -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 @@ }

@@ -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 {

@@ -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 {

@@ -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 };
}
}
{
"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",

@@ -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