@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.2.8"; | ||
| export const VERSION = "0.3.0"; | ||
| /** | ||
@@ -12,0 +12,0 @@ * One server, two transports (hosted Streamable HTTP + local stdio) share this — the tool |
@@ -59,3 +59,3 @@ import { z } from "zod"; | ||
| title: "Get a full-site scan run by id", | ||
| description: "Polls a run started by scan_site (or a registered-site scan_url). Pro+.", | ||
| 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() }, | ||
@@ -62,0 +62,0 @@ }, async ({ runId }, extra) => { |
+58
-28
| import { z } from "zod"; | ||
| import { apiJson } from "../api-client.js"; | ||
| import { McpApiError, apiJson } from "../api-client.js"; | ||
| import { resolveBearer } from "../bearer.js"; | ||
@@ -10,26 +10,50 @@ import { toolError } from "../tool-error.js"; | ||
| function registeredScanText(scan) { | ||
| return summarizeScanLike(scan, `Scan for ${scan.requestedUrl}`, "Still in progress — call get_run to poll."); | ||
| 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."; | ||
| } | ||
| /** | ||
| * Falling back is not a failure, so say what happened and what it costs. Without this the caller | ||
| * cannot tell a recorded scan from an ad-hoc one, and never learns that registering the site is | ||
| * what unlocks history, trends and full-site scans. | ||
| */ | ||
| function fallbackNote(reason) { | ||
| return reason === "unregistered" | ||
| ? "\nThis URL is not a registered site in the account, so it ran as a one-off scan " + | ||
| "(counted against the daily quota). Add the site under Sites to record future scans " + | ||
| "against it and unlock history, trends and full-site scans." | ||
| : "\nRan as a one-off scan (counted against the daily quota). Recording scans against a " + | ||
| "registered site — with history, trends and full-site scans — needs a Pro plan or higher."; | ||
| } | ||
| /** 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) { | ||
| server.registerTool("scan_url", { | ||
| title: "Scan a URL", | ||
| description: "Runs a deterministic axe-core accessibility scan of one http(s) URL. The url argument " + | ||
| "is ALWAYS required — siteHost never replaces it, it only changes which pipeline the " + | ||
| "scan runs through. Omit siteHost for a quick ad-hoc scan of any public URL (free tier, " + | ||
| "counted against the daily quota). Add siteHost when that URL belongs to a site " + | ||
| "registered in the account, to record the scan against it (Pro+, requires the " + | ||
| "sites:read and scans:write scopes) — that unlocks get_run/get_run_findings, full-site " + | ||
| "scans, and trends. Either way this queues the scan and returns immediately with an id " + | ||
| "to poll; never returns a compliance score — automated testing finds only a portion of " + | ||
| "accessibility barriers, see coverageDisclaimer in the result.", | ||
| description: "Scans one http(s) URL for accessibility problems with axe-core — deterministic checks, " + | ||
| "not a language model's opinion. Pass the URL and nothing else. If it belongs to a site " + | ||
| "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.", | ||
| inputSchema: { | ||
| url: z.string().url().max(2048).describe("The http(s) URL to scan."), | ||
| siteHost: z.string().optional().describe("Optional. A registered site's normalized host (Pro+), e.g. \"example.com\" — host only, " + | ||
| "no scheme or path. Supplied ALONGSIDE url, never instead of it. Omit for the free " + | ||
| "anonymous path."), | ||
| }, | ||
| }, async ({ url, siteHost }, extra) => { | ||
| }, async ({ url }, extra) => { | ||
| try { | ||
| const bearer = resolveBearer(extra); | ||
| if (siteHost) { | ||
| // Try the registered-site path first: it is strictly the better outcome (the scan is kept, | ||
| // and feeds trends). Anything that means "not available for this URL/account" falls back to | ||
| // the one-off scan rather than surfacing as an error the caller has to decode and retry — | ||
| // deciding that is this server's job, not the assistant's. | ||
| try { | ||
| const scan = await apiJson(bearer, "/api/v1/scans", { | ||
@@ -39,3 +63,3 @@ method: "POST", | ||
| }); | ||
| const response = withDisclaimer({ scan }); | ||
| const response = withDisclaimer({ scan, recordedAgainstSite: true, pollWith: "get_run" }); | ||
| return { | ||
@@ -46,10 +70,16 @@ content: [{ type: "text", text: registeredScanText(scan) }], | ||
| } | ||
| const response = await apiJson(bearer, "/api/v1/mcp/scan-url", { | ||
| method: "POST", | ||
| body: JSON.stringify({ url }), | ||
| }); | ||
| return { | ||
| content: [{ type: "text", text: freeScanText(response) }], | ||
| structuredContent: response, | ||
| }; | ||
| catch (err) { | ||
| const reason = isNotRegisteredPath(err); | ||
| if (reason === null) | ||
| throw err; | ||
| const free = await apiJson(bearer, "/api/v1/mcp/scan-url", { | ||
| method: "POST", | ||
| body: JSON.stringify({ url }), | ||
| }); | ||
| const response = { ...free, recordedAgainstSite: false, pollWith: "get_scan" }; | ||
| return { | ||
| content: [{ type: "text", text: freeScanText(free) + fallbackNote(reason) }], | ||
| structuredContent: response, | ||
| }; | ||
| } | ||
| } | ||
@@ -62,3 +92,3 @@ catch (err) { | ||
| title: "Get a free-tier scan by id", | ||
| description: "Polls a scan started by scan_url without siteHost. Returns severity counts, a " + | ||
| 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 " + | ||
@@ -82,4 +112,4 @@ "scan did not complete.", | ||
| title: "Get a free-tier scan's findings", | ||
| description: "The top-5 finding sample for a scan_url (no siteHost) scan (rule id, severity, " + | ||
| "help URL, target selector). Full findings for a registered-site scan need get_run_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() }, | ||
@@ -86,0 +116,0 @@ }, async ({ scanId }, extra) => { |
+1
-1
| { | ||
| "name": "@wcagc/mcp", | ||
| "version": "0.2.8", | ||
| "version": "0.3.0", | ||
| "mcpName": "io.github.WCAG-Compliance/mcp", | ||
@@ -5,0 +5,0 @@ "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.", |
48365
3.59%851
3.65%