ultra11y 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- package/src/wcag.ts +128 -0
package/src/mcp/http.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { createServer as createMcpServer, type JsonRpcMessage, type ServerOptions } from "./server.js";
|
|
3
|
+
import { ASSUMED_HTTP_PROTOCOL, isOriginAllowed, isProtocolVersion, type ProtocolVersion } from "./protocol.js";
|
|
4
|
+
|
|
5
|
+
// The Streamable HTTP transport, in its stateless form: one endpoint, POST,
|
|
6
|
+
// JSON in and JSON out.
|
|
7
|
+
//
|
|
8
|
+
// No Mcp-Session-Id is issued, and that is a decision rather than an omission.
|
|
9
|
+
// Every ultra11y tool call is self-contained — it carries its own `repo` and
|
|
10
|
+
// arguments — so there is nothing for a session to hold. Issuing one would buy
|
|
11
|
+
// a class of interop bugs (echo-back, expiry, 404-then-reinitialize, DELETE
|
|
12
|
+
// semantics) for no capability. Revisit only when something genuinely spans
|
|
13
|
+
// calls.
|
|
14
|
+
//
|
|
15
|
+
// No SSE either: nothing here sends server-initiated messages, and the spec's
|
|
16
|
+
// answer for a server with no stream to offer is 405 on GET, which is what this
|
|
17
|
+
// does. server.ts already routes replies through a callback, so adding a stream
|
|
18
|
+
// later is a change to this file alone.
|
|
19
|
+
|
|
20
|
+
const MCP_PATH = "/mcp";
|
|
21
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const CORS_HEADERS = "content-type, accept, mcp-protocol-version, mcp-session-id, authorization, last-event-id";
|
|
23
|
+
|
|
24
|
+
export interface HttpOptions extends ServerOptions {
|
|
25
|
+
port?: number;
|
|
26
|
+
bind?: string;
|
|
27
|
+
allowOrigin?: string[];
|
|
28
|
+
// Bind somewhere other than loopback. Off by default and loud when used:
|
|
29
|
+
// this server clones arbitrary git URLs and reads files off disk, so an
|
|
30
|
+
// exposed port is a fetch-anything primitive for whoever finds it.
|
|
31
|
+
allowRemote?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunningHttpServer {
|
|
35
|
+
server: Server;
|
|
36
|
+
port: number;
|
|
37
|
+
url: string;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const LOOPBACK_BIND = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
42
|
+
|
|
43
|
+
export function startHttpServer(opts: HttpOptions = {}): Promise<RunningHttpServer> {
|
|
44
|
+
const bind = opts.bind ?? "127.0.0.1";
|
|
45
|
+
if (!LOOPBACK_BIND.has(bind) && !opts.allowRemote) {
|
|
46
|
+
return Promise.reject(
|
|
47
|
+
new Error(
|
|
48
|
+
`refusing to bind ${bind}: ultra11y's MCP server clones arbitrary git URLs and reads local files. Pass --allow-remote if that is really what you want.`,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const server = createHttpServer((req, res) => {
|
|
54
|
+
void route(req, res, opts).catch((e) => {
|
|
55
|
+
// Only if nothing was written yet: a throw after the response started
|
|
56
|
+
// (a client that hung up mid-write) must not become a second writeHead.
|
|
57
|
+
if (res.headersSent) {
|
|
58
|
+
res.destroy();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
sendJson(res, 500, { jsonrpc: "2.0", id: null, error: { code: -32603, message: e instanceof Error ? e.message : String(e) } });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A cold `ultra11y_doc` runs for minutes. Node's default 300s request timeout
|
|
66
|
+
// would cut the socket with no JSON-RPC error, which the client reports as a
|
|
67
|
+
// crashed server rather than a slow tool.
|
|
68
|
+
server.requestTimeout = 0;
|
|
69
|
+
server.headersTimeout = 60_000;
|
|
70
|
+
server.keepAliveTimeout = 120_000;
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
server.once("error", reject);
|
|
74
|
+
server.listen(opts.port ?? 0, bind, () => {
|
|
75
|
+
server.removeListener("error", reject);
|
|
76
|
+
const addr = server.address();
|
|
77
|
+
const port = typeof addr === "object" && addr ? addr.port : (opts.port ?? 0);
|
|
78
|
+
const host = bind.includes(":") ? `[${bind}]` : bind;
|
|
79
|
+
resolve({
|
|
80
|
+
server,
|
|
81
|
+
port,
|
|
82
|
+
url: `http://${host}:${port}${MCP_PATH}`,
|
|
83
|
+
close: () =>
|
|
84
|
+
new Promise<void>((done) => {
|
|
85
|
+
server.closeAllConnections?.();
|
|
86
|
+
server.close(() => done());
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function route(req: IncomingMessage, res: ServerResponse, opts: HttpOptions): Promise<void> {
|
|
94
|
+
const path = (req.url ?? "").split("?")[0];
|
|
95
|
+
const origin = header(req, "origin");
|
|
96
|
+
|
|
97
|
+
// DNS-rebinding defense first, before anything reads the body: a page the
|
|
98
|
+
// user happens to have open must not be able to drive this server.
|
|
99
|
+
if (!isOriginAllowed(origin, opts.allowOrigin)) {
|
|
100
|
+
sendJson(res, 403, { error: "origin not allowed", origin });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (req.method === "OPTIONS") {
|
|
105
|
+
res.writeHead(204, {
|
|
106
|
+
...corsHeaders(origin),
|
|
107
|
+
"access-control-allow-methods": "POST, GET, DELETE, OPTIONS",
|
|
108
|
+
"access-control-allow-headers": CORS_HEADERS,
|
|
109
|
+
"access-control-max-age": "86400",
|
|
110
|
+
});
|
|
111
|
+
res.end();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (path !== MCP_PATH) {
|
|
116
|
+
sendJson(res, 404, { error: `not found: ${path} (the MCP endpoint is ${MCP_PATH})` }, origin);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// GET would open the server→client SSE stream; there isn't one, and 405 is
|
|
121
|
+
// the spec's way of saying so. DELETE terminates a session; there are none.
|
|
122
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
123
|
+
res.writeHead(405, { allow: "POST, OPTIONS", ...corsHeaders(origin) });
|
|
124
|
+
res.end(JSON.stringify({ error: `${req.method} is not supported: this server is stateless and offers no server-initiated stream` }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (req.method !== "POST") {
|
|
129
|
+
res.writeHead(405, { allow: "POST, OPTIONS", ...corsHeaders(origin) });
|
|
130
|
+
res.end(JSON.stringify({ error: `${req.method} is not supported` }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const contentType = (header(req, "content-type") ?? "").split(";")[0]!.trim().toLowerCase();
|
|
135
|
+
if (contentType && contentType !== "application/json") {
|
|
136
|
+
sendJson(res, 415, { error: `unsupported content-type "${contentType}" — send application/json` }, origin);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Lenient on Accept: the spec asks clients for
|
|
141
|
+
// "application/json, text/event-stream", but rejecting the ones that send
|
|
142
|
+
// something narrower breaks real integrations and protects nothing.
|
|
143
|
+
const accept = (header(req, "accept") ?? "").toLowerCase();
|
|
144
|
+
if (accept && !/application\/json|text\/event-stream|\*\/\*/.test(accept)) {
|
|
145
|
+
sendJson(res, 406, { error: "this endpoint replies with application/json" }, origin);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const declared = header(req, "mcp-protocol-version");
|
|
150
|
+
if (declared !== undefined && !isProtocolVersion(declared)) {
|
|
151
|
+
sendJson(res, 400, { error: `unsupported MCP-Protocol-Version: ${declared}` }, origin);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Absent means a client written against the previous revision, which never
|
|
155
|
+
// sent the header. The spec says assume, not reject.
|
|
156
|
+
const protocol: ProtocolVersion = (declared as ProtocolVersion | undefined) ?? ASSUMED_HTTP_PROTOCOL;
|
|
157
|
+
|
|
158
|
+
let raw: string;
|
|
159
|
+
try {
|
|
160
|
+
raw = await readBody(req);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if ((e as Error).message === "too large") {
|
|
163
|
+
sendJson(res, 413, { error: `request body exceeds ${MAX_BODY_BYTES} bytes` }, origin);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
sendJson(res, 400, { error: `could not read request body: ${(e as Error).message}` }, origin);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let parsed: unknown;
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(raw);
|
|
173
|
+
} catch {
|
|
174
|
+
sendJson(res, 200, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }, origin);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A server instance per request. Stateless means the negotiated version
|
|
179
|
+
// cannot live on the server object: two overlapping requests on different
|
|
180
|
+
// protocol versions would otherwise read each other's.
|
|
181
|
+
const mcp = createMcpServer(opts);
|
|
182
|
+
mcp.setProtocolVersion(protocol);
|
|
183
|
+
|
|
184
|
+
const out: JsonRpcMessage[] = [];
|
|
185
|
+
const collect = (m: JsonRpcMessage) => void out.push(m);
|
|
186
|
+
const messages: JsonRpcMessage[] = Array.isArray(parsed) ? (parsed as JsonRpcMessage[]) : [parsed as JsonRpcMessage];
|
|
187
|
+
for (const m of messages) await mcp.handle(m, collect);
|
|
188
|
+
|
|
189
|
+
// Nothing to answer means the body held only notifications or responses —
|
|
190
|
+
// `notifications/initialized` arrives exactly this way, and a 200 with a body
|
|
191
|
+
// here is what trips strict clients.
|
|
192
|
+
if (out.length === 0) {
|
|
193
|
+
res.writeHead(202, corsHeaders(origin));
|
|
194
|
+
res.end();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
sendJson(res, 200, Array.isArray(parsed) ? out : out[0]!, origin);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function header(req: IncomingMessage, name: string): string | undefined {
|
|
202
|
+
const v = req.headers[name];
|
|
203
|
+
return Array.isArray(v) ? v[0] : v;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function corsHeaders(origin: string | undefined): Record<string, string> {
|
|
207
|
+
return origin ? { "access-control-allow-origin": origin, vary: "origin" } : {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sendJson(res: ServerResponse, status: number, body: unknown, origin?: string, extra: Record<string, string> = {}): void {
|
|
211
|
+
const text = JSON.stringify(body);
|
|
212
|
+
res.writeHead(status, {
|
|
213
|
+
"content-type": "application/json",
|
|
214
|
+
"content-length": String(Buffer.byteLength(text, "utf8")),
|
|
215
|
+
...corsHeaders(origin),
|
|
216
|
+
...extra,
|
|
217
|
+
});
|
|
218
|
+
res.end(text);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// How much we will read past the cap purely to reach the end of an oversized
|
|
222
|
+
// request. Answering a 413 while the client is still writing reaches it as a
|
|
223
|
+
// connection reset, not as our explanation — so we stop buffering, keep
|
|
224
|
+
// reading, and reply once the request is actually over. Past this ceiling the
|
|
225
|
+
// body is not a mistake and we stop humouring it.
|
|
226
|
+
const DRAIN_LIMIT = MAX_BODY_BYTES * 8;
|
|
227
|
+
|
|
228
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const chunks: Buffer[] = [];
|
|
231
|
+
let size = 0;
|
|
232
|
+
let over = false;
|
|
233
|
+
|
|
234
|
+
const declared = Number(req.headers["content-length"]);
|
|
235
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) over = true;
|
|
236
|
+
|
|
237
|
+
req.on("data", (c: Buffer) => {
|
|
238
|
+
size += c.length;
|
|
239
|
+
if (over) {
|
|
240
|
+
if (size > DRAIN_LIMIT) {
|
|
241
|
+
req.destroy();
|
|
242
|
+
reject(new Error("too large"));
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (size > MAX_BODY_BYTES) {
|
|
247
|
+
over = true;
|
|
248
|
+
chunks.length = 0;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
chunks.push(c);
|
|
252
|
+
});
|
|
253
|
+
req.on("end", () => {
|
|
254
|
+
if (over) reject(new Error("too large"));
|
|
255
|
+
else resolve(Buffer.concat(chunks).toString("utf8"));
|
|
256
|
+
});
|
|
257
|
+
req.on("error", reject);
|
|
258
|
+
req.on("aborted", () => reject(new Error("client aborted the request")));
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { TOOLS, WRITE_TOOLS } from "./tools.js";
|
|
2
|
+
|
|
3
|
+
// The workflows, as MCP prompts.
|
|
4
|
+
//
|
|
5
|
+
// Tools are the half of this skill a client can discover on its own. The other
|
|
6
|
+
// half is the coverage arithmetic: of the 55 WCAG 2.2 AA success criteria, the
|
|
7
|
+
// static engine decides a handful, a real browser decides fourteen, and the
|
|
8
|
+
// remaining thirty-eight are judgment calls only the model can make. A client
|
|
9
|
+
// handed the audit tool and no protocol runs it, sees no errors, and reports
|
|
10
|
+
// the page as accessible — which is a false conformance claim, the one output
|
|
11
|
+
// an accessibility tool must never produce.
|
|
12
|
+
//
|
|
13
|
+
// Each prompt says three things, in this order: the contract, the exact tool
|
|
14
|
+
// sequence, and what the gate does on failure.
|
|
15
|
+
|
|
16
|
+
export interface PromptArgument {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PromptDecl {
|
|
23
|
+
name: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
description: string;
|
|
26
|
+
arguments: PromptArgument[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PromptMessage {
|
|
30
|
+
role: "user" | "assistant";
|
|
31
|
+
content: { type: "text"; text: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromptResult {
|
|
35
|
+
description: string;
|
|
36
|
+
messages: PromptMessage[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class PromptError extends Error {}
|
|
40
|
+
|
|
41
|
+
const cwdArg: PromptArgument = { name: "cwd", description: "Absolute path to the project root.", required: true };
|
|
42
|
+
|
|
43
|
+
export const PROMPTS: PromptDecl[] = [
|
|
44
|
+
{
|
|
45
|
+
name: "audit_wcag",
|
|
46
|
+
title: "Audit a project against WCAG 2.2 AA",
|
|
47
|
+
description:
|
|
48
|
+
"The full conformance workflow: run the static engine, adjudicate the criteria it cannot decide, name the ones only a browser can settle, and produce " +
|
|
49
|
+
"a dated report whose coverage is stated rather than implied.",
|
|
50
|
+
arguments: [
|
|
51
|
+
cwdArg,
|
|
52
|
+
{ name: "globs", description: "What to audit, comma-separated (default: the project's markup).", required: false },
|
|
53
|
+
{ name: "standard", description: "wcag (default) or rgaa.", required: false },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "adjudicate_criteria",
|
|
58
|
+
title: "Decide the criteria the engine cannot",
|
|
59
|
+
description:
|
|
60
|
+
"The judgment workflow: work the adjudication queue — alt-text relevance, link purpose in context, heading structure, reading order — by reading the " +
|
|
61
|
+
"real markup, not by pattern-matching the element.",
|
|
62
|
+
arguments: [cwdArg, { name: "standard", description: "wcag (default) or rgaa.", required: false }],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "review_diff_a11y",
|
|
66
|
+
title: "Review a diff for accessibility",
|
|
67
|
+
description:
|
|
68
|
+
"The review workflow: audit exactly the code under change, refute the false positives, and report what this diff introduces — not the project's whole " +
|
|
69
|
+
"accessibility backlog.",
|
|
70
|
+
arguments: [cwdArg, { name: "since", description: "The git ref to diff against (default: the staged changes).", required: false }],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export function getPrompt(name: string, args: Record<string, unknown> = {}): PromptResult {
|
|
75
|
+
const decl = PROMPTS.find((p) => p.name === name);
|
|
76
|
+
if (!decl) throw new PromptError(`unknown prompt: ${name || "(none given)"}`);
|
|
77
|
+
|
|
78
|
+
for (const arg of decl.arguments) {
|
|
79
|
+
if (arg.required && !str(args[arg.name])) throw new PromptError(`\`${arg.name}\` is required for prompt "${name}"`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = name === "audit_wcag" ? auditWcag(args) : name === "adjudicate_criteria" ? adjudicateCriteria(args) : reviewDiff(args);
|
|
83
|
+
return { description: decl.description, messages: [{ role: "user", content: { type: "text", text } }] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The rule every workflow here rests on. Stated once, quoted into each prompt,
|
|
87
|
+
// so the two can never drift apart.
|
|
88
|
+
const CORE_RULE = `A criterion nobody tested is UNTESTED, never conformant. The static engine decides only a handful of the 55 WCAG 2.2 AA criteria; fourteen need a real browser and thirty-eight need your judgment. Never write that a criterion passes because the tool did not flag it, and never invent a non-conformity the evidence does not show.`;
|
|
89
|
+
|
|
90
|
+
const GATE = `\`ultra11y_check\` returning \`ok: false\` is a VERDICT, not a tool failure. It means the report claims more than the evidence carries — usually a criterion asserted conformant that was never actually tested. Fix the claim, not the gate.`;
|
|
91
|
+
|
|
92
|
+
function auditWcag(args: Record<string, unknown>): string {
|
|
93
|
+
const cwd = str(args.cwd)!;
|
|
94
|
+
const globs = str(args.globs);
|
|
95
|
+
const standard = str(args.standard);
|
|
96
|
+
|
|
97
|
+
return `Audit \`${cwd}\` against ${standard === "rgaa" ? "RGAA" : "WCAG 2.2 AA"}${globs ? `, over \`${globs}\`` : ""}.
|
|
98
|
+
|
|
99
|
+
${CORE_RULE}
|
|
100
|
+
|
|
101
|
+
**Sequence:**
|
|
102
|
+
|
|
103
|
+
1. \`ultra11y_audit\`${globs ? ` with \`globs: ["${globs}"]\`` : ""} — the static pass. Add \`graph: true\` for a component codebase, where a label is routinely defined in a different file from the input it names.
|
|
104
|
+
2. \`ultra11y_adjudicate\` — the queue of criteria the engine cannot decide. This is most of WCAG, and skipping it is what turns an audit into a lint run.
|
|
105
|
+
3. For each item: \`ultra11y_read\` the real markup around it and rule on it. \`ultra11y_criteria\` when you need the criterion's exact wording — look it up rather than recalling it, which is how invented non-conformities get written.
|
|
106
|
+
4. \`ultra11y_report\`${standard ? ` with \`standard: "${standard}"\`` : ""} for the dated conformance report.
|
|
107
|
+
5. \`ultra11y_check\` on what you wrote.
|
|
108
|
+
|
|
109
|
+
**Say what you did not test.** The rendering criteria — contrast, focus visibility, zoom, reflow, hover, target size — cannot be decided from source. They need \`ultra11y scan\` in a real browser, from the CLI. A report that omits them silently reads as a clean bill of health for the very criteria most likely to fail.
|
|
110
|
+
|
|
111
|
+
**Then \`ultra11y_prd\`** to turn the non-conformities into work a team can pick up, with the fix and the effort per unit.
|
|
112
|
+
|
|
113
|
+
${GATE}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function adjudicateCriteria(args: Record<string, unknown>): string {
|
|
117
|
+
const cwd = str(args.cwd)!;
|
|
118
|
+
const standard = str(args.standard);
|
|
119
|
+
|
|
120
|
+
return `Work the adjudication queue for \`${cwd}\`${standard ? ` against ${standard}` : ""}.
|
|
121
|
+
|
|
122
|
+
${CORE_RULE}
|
|
123
|
+
|
|
124
|
+
**Sequence:**
|
|
125
|
+
|
|
126
|
+
1. \`ultra11y_adjudicate\` — the items, each naming the criterion and the element it turns on.
|
|
127
|
+
2. For each: \`ultra11y_read\` the file around that element. You are deciding what the markup MEANS in context, and context is exactly what an excerpt drops.
|
|
128
|
+
3. \`ultra11y_criteria\` for the criterion's exact test, whenever you are about to rule from memory.
|
|
129
|
+
4. Rule on each item, then \`ultra11y_check\` your write-up.
|
|
130
|
+
|
|
131
|
+
**What these criteria actually ask.**
|
|
132
|
+
|
|
133
|
+
- **Alt text** — does it convey what the image contributes HERE? A correct-looking alt on a decorative image is a failure; so is "chart" on a chart carrying the paragraph's only data.
|
|
134
|
+
- **Link purpose in context** — could a user reading links out of context tell where this one goes? "Read more" fails unless its context is programmatically associated.
|
|
135
|
+
- **Heading structure** — do the headings describe the real outline of the page, or the visual size someone wanted?
|
|
136
|
+
- **Reading order** — does the DOM order match the meaning, independent of CSS placement?
|
|
137
|
+
- **Label in name** — does the accessible name start with the visible label, so a speech user can say what they see?
|
|
138
|
+
|
|
139
|
+
**When the evidence does not settle it, say so.** "Needs a human with the design context" is a legitimate verdict and a useful one. A confident wrong ruling is neither.`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reviewDiff(args: Record<string, unknown>): string {
|
|
143
|
+
const cwd = str(args.cwd)!;
|
|
144
|
+
const since = str(args.since);
|
|
145
|
+
|
|
146
|
+
return `Review the ${since ? `changes since \`${since}\`` : "staged changes"} in \`${cwd}\` for accessibility.
|
|
147
|
+
|
|
148
|
+
${CORE_RULE}
|
|
149
|
+
|
|
150
|
+
**Sequence:**
|
|
151
|
+
|
|
152
|
+
1. \`ultra11y_audit\` with ${since ? `\`since: "${since}"\`` : "`staged: true`"} and \`graph: true\` — scoped to exactly the code under change, but able to see across files.
|
|
153
|
+
2. \`ultra11y_read\` each flagged element in its real context before writing anything about it.
|
|
154
|
+
3. \`ultra11y_criteria\` for any criterion you are about to cite.
|
|
155
|
+
4. Report, ranked: blocking → major → minor.
|
|
156
|
+
|
|
157
|
+
**Review the diff, not the project.** A pre-existing non-conformity in a file this branch merely touched is not this branch's finding — mention it once as context, never as a blocker.
|
|
158
|
+
|
|
159
|
+
**Refute the false positives yourself.** Findings inside library source, generated output, or a single-file component the engine could not fully resolve are PRELIMINARY. Check each against the real markup and drop the ones that do not survive; a review that forwards the engine's output unfiltered wastes the author's time and teaches them to ignore the next one.
|
|
160
|
+
|
|
161
|
+
**Name the residual risks.** Contrast, focus visibility and zoom cannot be judged from source — say that they were not assessed rather than letting silence imply they passed.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function str(v: unknown): string | undefined {
|
|
165
|
+
return typeof v === "string" && v.trim() !== "" ? v : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Every tool a prompt tells the model to call must actually be declared —
|
|
169
|
+
// otherwise a prompt survives a tool rename as a set of instructions that
|
|
170
|
+
// cannot be followed. Exported so the test can assert it.
|
|
171
|
+
const DECLARED = new Set([...TOOLS, ...WRITE_TOOLS].map((t) => t.name));
|
|
172
|
+
|
|
173
|
+
export function toolNamesReferencedBy(text: string): string[] {
|
|
174
|
+
const found = new Set<string>();
|
|
175
|
+
for (const m of text.matchAll(/ultra11y_[a-z_]+/g)) if (DECLARED.has(m[0])) found.add(m[0]);
|
|
176
|
+
return [...found].sort();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function unknownToolNamesIn(text: string): string[] {
|
|
180
|
+
const bad = new Set<string>();
|
|
181
|
+
for (const m of text.matchAll(/ultra11y_[a-z_]+/g)) if (!DECLARED.has(m[0])) bad.add(m[0]);
|
|
182
|
+
return [...bad].sort();
|
|
183
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The MCP wire rules, with no knowledge of what the tools do and no I/O. Kept
|
|
2
|
+
// separate from tools.ts and server.ts so the parts that are pure functions of
|
|
3
|
+
// their input stay directly testable.
|
|
4
|
+
//
|
|
5
|
+
// Written by hand rather than pulled from @modelcontextprotocol/sdk: the shipped
|
|
6
|
+
// bundle has zero runtime dependencies (design principle 2), and the surface an
|
|
7
|
+
// ultra11y server needs — initialize, ping, tools/list, tools/call — is a few
|
|
8
|
+
// hundred lines. The vendored codeindex engine ships the same shape at
|
|
9
|
+
// src/vendor/codeindex-engine.mjs:10468; this is that shape, retargeted.
|
|
10
|
+
|
|
11
|
+
// Oldest first. We answer on whichever of these the client asks for, and fall
|
|
12
|
+
// back to the newest when it asks for something we don't know — the spec's
|
|
13
|
+
// rule is that the server replies with a version it supports and the client
|
|
14
|
+
// then decides whether it can live with it.
|
|
15
|
+
export const PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const;
|
|
16
|
+
export type ProtocolVersion = (typeof PROTOCOL_VERSIONS)[number];
|
|
17
|
+
|
|
18
|
+
export const LATEST_PROTOCOL: ProtocolVersion = PROTOCOL_VERSIONS[PROTOCOL_VERSIONS.length - 1]!;
|
|
19
|
+
|
|
20
|
+
// What an HTTP request without an `MCP-Protocol-Version` header means. The spec
|
|
21
|
+
// (2025-06-18) says to assume this version for backwards compatibility rather
|
|
22
|
+
// than reject, because clients written against the previous revision never send
|
|
23
|
+
// the header at all.
|
|
24
|
+
export const ASSUMED_HTTP_PROTOCOL: ProtocolVersion = "2025-03-26";
|
|
25
|
+
|
|
26
|
+
// Tool `annotations` arrived in 2025-03-26; `title`, `outputSchema` and
|
|
27
|
+
// `structuredContent` in 2025-06-18. Sending them to an older client is not
|
|
28
|
+
// fatal but is noise it never asked for, so they are gated.
|
|
29
|
+
export const ANNOTATIONS_SINCE = "2025-03-26";
|
|
30
|
+
export const RICH_TOOLS_SINCE = "2025-06-18";
|
|
31
|
+
|
|
32
|
+
// A tool result larger than this is withheld rather than sent as a truncated
|
|
33
|
+
// payload — see capResponse.
|
|
34
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000;
|
|
35
|
+
|
|
36
|
+
export function isProtocolVersion(v: unknown): v is ProtocolVersion {
|
|
37
|
+
return typeof v === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Echo the client's version when we speak it, otherwise offer the newest we do.
|
|
41
|
+
export function negotiateProtocol(requested: unknown): ProtocolVersion {
|
|
42
|
+
return isProtocolVersion(requested) ? requested : LATEST_PROTOCOL;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --------------------------------------------------------------------------
|
|
46
|
+
// Argument validation
|
|
47
|
+
// --------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface JsonSchemaProp {
|
|
50
|
+
type?: "string" | "number" | "boolean" | "array" | "object";
|
|
51
|
+
items?: { type?: string };
|
|
52
|
+
enum?: readonly string[];
|
|
53
|
+
description?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface JsonSchema {
|
|
57
|
+
type: "object";
|
|
58
|
+
properties: Record<string, JsonSchemaProp>;
|
|
59
|
+
required: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check a tool call's arguments against its declared schema. Returns a message
|
|
63
|
+
// naming the offending property, or undefined when the args are usable.
|
|
64
|
+
//
|
|
65
|
+
// Deliberately shallow: it enforces required-ness, scalar types, string-array
|
|
66
|
+
// shape and enum membership, and it accepts a numeric string for a number
|
|
67
|
+
// (several clients stringify every argument). Anything subtler is the handler's
|
|
68
|
+
// job, where the error message can be about the domain rather than the schema.
|
|
69
|
+
export function validateArgs(schema: JsonSchema, args: Record<string, unknown>): string | undefined {
|
|
70
|
+
for (const key of schema.required) {
|
|
71
|
+
const v = args[key];
|
|
72
|
+
if (v === undefined || v === null || v === "") return `\`${key}\` is required`;
|
|
73
|
+
}
|
|
74
|
+
for (const [key, value] of Object.entries(args)) {
|
|
75
|
+
if (value === undefined || value === null) continue;
|
|
76
|
+
const spec = schema.properties[key];
|
|
77
|
+
// Unknown keys are ignored, not rejected: a client that sends an extra
|
|
78
|
+
// field should not have its call fail.
|
|
79
|
+
if (!spec?.type) continue;
|
|
80
|
+
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
81
|
+
|
|
82
|
+
if (spec.type === "number") {
|
|
83
|
+
if (actual === "number") continue;
|
|
84
|
+
if (actual === "string" && (value as string).trim() !== "" && Number.isFinite(Number(value))) continue;
|
|
85
|
+
return `\`${key}\` must be a number, got ${actual === "string" ? JSON.stringify(value) : actual}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (spec.type === "array") {
|
|
89
|
+
if (actual !== "array") return `\`${key}\` must be an array, got ${actual}`;
|
|
90
|
+
const arr = value as unknown[];
|
|
91
|
+
if (spec.items?.type === "string" && !arr.every((x) => typeof x === "string")) {
|
|
92
|
+
return `\`${key}\` must be an array of strings`;
|
|
93
|
+
}
|
|
94
|
+
if (spec.enum) {
|
|
95
|
+
const bad = arr.find((x) => typeof x === "string" && !spec.enum!.includes(x));
|
|
96
|
+
if (bad !== undefined) return `\`${key}\` contains "${String(bad)}" — allowed: ${spec.enum.join(", ")}`;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (actual !== spec.type) return `\`${key}\` must be a ${spec.type}, got ${actual}`;
|
|
102
|
+
|
|
103
|
+
if (spec.enum && typeof value === "string" && !spec.enum.includes(value)) {
|
|
104
|
+
return `\`${key}\` must be one of: ${spec.enum.join(", ")}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --------------------------------------------------------------------------
|
|
111
|
+
// Response size
|
|
112
|
+
// --------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// How to ask for less, per tool. A cap that only says "too big" makes the model
|
|
115
|
+
// retry the same call; one that names the narrowing argument gets a smaller
|
|
116
|
+
// second call.
|
|
117
|
+
const NARROWER: Record<string, string> = {
|
|
118
|
+
ultra11y_audit: "narrow the globs, or pass `max_files`; `changed: true` audits only what the diff touched",
|
|
119
|
+
ultra11y_report: "report one standard at a time, or read the file at the returned path",
|
|
120
|
+
ultra11y_prd: 'split per criterion with `split: "criterion"`',
|
|
121
|
+
ultra11y_criteria: "ask for one success criterion instead of the whole reference",
|
|
122
|
+
ultra11y_verify: "lower `max_verify`",
|
|
123
|
+
ultra11y_scan: "scan one page instead of a crawl",
|
|
124
|
+
ultra11y_read: "pass `start_line`/`end_line` to read a window instead of the whole file",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Withhold an oversized payload instead of sending a partial one. A truncated
|
|
128
|
+
// JSON body is worse than no body: the model cannot tell a clipped result from
|
|
129
|
+
// a complete one, so it reasons over half the evidence and says nothing about
|
|
130
|
+
// it. The replacement is small, structured, and says exactly how to ask again.
|
|
131
|
+
export function capResponse(text: string, tool: string, maxBytes: number, artifact?: string): string {
|
|
132
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
133
|
+
if (bytes <= maxBytes) return text;
|
|
134
|
+
return (
|
|
135
|
+
JSON.stringify(
|
|
136
|
+
{
|
|
137
|
+
truncated: true,
|
|
138
|
+
tool,
|
|
139
|
+
bytes,
|
|
140
|
+
maxBytes,
|
|
141
|
+
reason: "This response exceeds the configured limit and was withheld rather than sent as an unusable partial payload.",
|
|
142
|
+
narrower: NARROWER[tool] ?? "narrow the request and call again",
|
|
143
|
+
...(artifact ? { artifact, artifactNote: "The full result is on disk here — read it directly if you need all of it." } : {}),
|
|
144
|
+
},
|
|
145
|
+
null,
|
|
146
|
+
2,
|
|
147
|
+
) + "\n"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// `structuredContent` mirrors the text body as a typed object. Only sent when
|
|
152
|
+
// the client is new enough, the tool declares an outputSchema, nothing was
|
|
153
|
+
// capped, and the body really is a JSON object — a mismatch here is worse than
|
|
154
|
+
// the field's absence, because clients validate it.
|
|
155
|
+
export function structuredContentFor(text: string, capped: boolean, hasSchema: boolean): Record<string, unknown> | undefined {
|
|
156
|
+
if (capped || !hasSchema) return undefined;
|
|
157
|
+
let parsed: unknown;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(text);
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
164
|
+
return parsed as Record<string, unknown>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --------------------------------------------------------------------------
|
|
168
|
+
// Origin checking (HTTP transport)
|
|
169
|
+
// --------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const LOOPBACK_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
172
|
+
|
|
173
|
+
// DNS-rebinding defense, required by the spec's security section for a local
|
|
174
|
+
// HTTP server. Without it any web page the user visits can POST to
|
|
175
|
+
// 127.0.0.1:<port> and drive this server — which clones arbitrary git URLs and
|
|
176
|
+
// reads files off disk.
|
|
177
|
+
//
|
|
178
|
+
// An absent Origin is allowed: non-browser clients (Claude Code, Cursor, curl)
|
|
179
|
+
// don't send one, and a browser always does, so absence is not something an
|
|
180
|
+
// attacker can use.
|
|
181
|
+
export function isOriginAllowed(origin: string | undefined, allowed: string[] = []): boolean {
|
|
182
|
+
if (origin === undefined) return true;
|
|
183
|
+
const o = origin.trim();
|
|
184
|
+
if (o === "" || o === "null") return true;
|
|
185
|
+
if (LOOPBACK_ORIGIN.test(o)) return true;
|
|
186
|
+
return allowed.some((a) => a === "*" || a.toLowerCase() === o.toLowerCase());
|
|
187
|
+
}
|