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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,136 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // The skill's own documentation, served over MCP.
6
+ //
7
+ // A tool list hands a client the ENGINE. It does not hand it the method: the
8
+ // rule that you answer only from retrieved evidence, the citation format, the
9
+ // rubric a good answer is held to. Inside Claude Code that method arrives as
10
+ // SKILL.md. Everywhere else — Cursor, Zed, Claude Desktop — nothing carries it,
11
+ // and a model driving these tools from cold reinvents a worse protocol.
12
+ //
13
+ // So SKILL.md and references/*.md are exposed as resources. Nothing here is
14
+ // generated: they are the same files the skill ships, read off disk at request
15
+ // time, which is why a documentation fix never needs a rebuild to reach a
16
+ // client.
17
+
18
+ export const SKILL_NAME = "ultra11y";
19
+
20
+ const URI_SCHEME = "skill://";
21
+
22
+ export interface ResourceDecl {
23
+ uri: string;
24
+ name: string;
25
+ title?: string;
26
+ description?: string;
27
+ mimeType: string;
28
+ }
29
+
30
+ export interface ResourceContents {
31
+ uri: string;
32
+ mimeType: string;
33
+ text: string;
34
+ }
35
+
36
+ // Where SKILL.md lives, relative to whatever directory this module was loaded
37
+ // from. Three layouts, tried in order, because the same code runs from all of
38
+ // them:
39
+ //
40
+ // <payload>/scripts/ultra11y.mjs → <payload>/SKILL.md (installed skill)
41
+ // <repo>/scripts/ultra11y.mjs → <repo>/skills/ultra11y/SKILL.md (repo-root bundle)
42
+ // <repo>/src/mcp/resources.ts → <repo>/skills/ultra11y/SKILL.md (source tree, tests)
43
+ //
44
+ // `moduleDir` is injectable for the same reason the vendored engine makes it
45
+ // injectable (codeindex-engine.mjs:5560): a test must be able to point this at
46
+ // a fixture without staging a fake bundle on disk.
47
+ export function resolveSkillRoot(moduleDir?: string): string | undefined {
48
+ const here = moduleDir ?? dirname(fileURLToPath(import.meta.url));
49
+ const candidates = [resolve(here, ".."), resolve(here, "..", "skills", SKILL_NAME), resolve(here, "..", "..", "skills", SKILL_NAME)];
50
+ return candidates.find((dir) => existsSync(join(dir, "SKILL.md")));
51
+ }
52
+
53
+ // The catalogue. An unresolvable root is not an error: a skill installed
54
+ // without its payload should still serve every tool it has, with an empty
55
+ // resource list, rather than refuse to start over missing documentation.
56
+ export function listResources(moduleDir?: string): ResourceDecl[] {
57
+ const root = resolveSkillRoot(moduleDir);
58
+ if (!root) return [];
59
+
60
+ const out: ResourceDecl[] = [describe(root, "SKILL.md", `${SKILL_NAME}: the skill`)];
61
+
62
+ const refDir = join(root, "references");
63
+ if (!existsSync(refDir)) return out;
64
+ for (const file of readdirSync(refDir).sort()) {
65
+ if (!file.endsWith(".md")) continue;
66
+ out.push(describe(root, join("references", file), `${SKILL_NAME} reference: ${basename(file, ".md")}`));
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export function readResource(uri: string, moduleDir?: string): ResourceContents {
72
+ if (!uri.startsWith(URI_SCHEME)) {
73
+ throw new ResourceError(`unknown resource scheme in "${uri}" (expected ${URI_SCHEME}…)`);
74
+ }
75
+ const root = resolveSkillRoot(moduleDir);
76
+ if (!root) throw new ResourceError("no skill payload found next to this build — nothing to read");
77
+
78
+ const rel = uri.slice(URI_SCHEME.length);
79
+ if (!rel) throw new ResourceError("empty resource path");
80
+
81
+ // Containment is checked on the REALPATH, not on the joined string. A
82
+ // `skill://../../.ssh/id_rsa` normalises away, but a symlink inside
83
+ // references/ pointing out of the tree does not — and this server may be
84
+ // reachable over HTTP.
85
+ const target = resolve(root, rel);
86
+ const rootReal = realpathSync(root);
87
+ let targetReal: string;
88
+ try {
89
+ targetReal = realpathSync(target);
90
+ } catch {
91
+ throw new ResourceError(`no such resource: ${uri}`);
92
+ }
93
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
94
+ throw new ResourceError(`resource path escapes the skill root: ${uri}`);
95
+ }
96
+ if (!statSync(targetReal).isFile()) throw new ResourceError(`not a file: ${uri}`);
97
+
98
+ return { uri, mimeType: "text/markdown", text: readFileSync(targetReal, "utf8") };
99
+ }
100
+
101
+ // Thrown for a resource the caller asked for wrongly. The server turns it into
102
+ // a JSON-RPC error, the way an unknown tool is one: the client named something
103
+ // that does not exist, which is a client bug, not a failed read.
104
+ export class ResourceError extends Error {}
105
+
106
+ function describe(root: string, rel: string, fallbackTitle: string): ResourceDecl {
107
+ const decl: ResourceDecl = {
108
+ uri: `${URI_SCHEME}${rel.split(sep).join("/")}`,
109
+ name: rel.split(sep).join("/"),
110
+ title: fallbackTitle,
111
+ mimeType: "text/markdown",
112
+ };
113
+ const summary = firstProse(join(root, rel));
114
+ if (summary) decl.description = summary;
115
+ return decl;
116
+ }
117
+
118
+ // The first real sentence of a markdown file, for the resource description.
119
+ // Skips YAML frontmatter, headings and blockquote callouts, so what a client
120
+ // shows is the document's claim rather than its title repeated.
121
+ function firstProse(file: string): string | undefined {
122
+ let text: string;
123
+ try {
124
+ text = readFileSync(file, "utf8");
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ const body = text.startsWith("---\n") ? text.slice(text.indexOf("\n---", 3) + 4) : text;
129
+ for (const block of body.split(/\n\s*\n/)) {
130
+ const line = block.trim();
131
+ if (!line || line.startsWith("#") || line.startsWith(">") || line.startsWith("|") || line.startsWith("```")) continue;
132
+ const flat = line.replace(/\s+/g, " ").replace(/[*`]/g, "");
133
+ return flat.length > 300 ? `${flat.slice(0, 297)}…` : flat;
134
+ }
135
+ return undefined;
136
+ }
@@ -0,0 +1,228 @@
1
+ import { VERSION } from "../types.js";
2
+ import { callTool, ToolError, type HandlerDefaults } from "./handlers.js";
3
+ import { getPrompt, PROMPTS, PromptError } from "./prompts.js";
4
+ import { listResources, readResource, ResourceError } from "./resources.js";
5
+ import { toolsFor, type ToolDecl } from "./tools.js";
6
+ import {
7
+ DEFAULT_MAX_RESPONSE_BYTES,
8
+ LATEST_PROTOCOL,
9
+ RICH_TOOLS_SINCE,
10
+ capResponse,
11
+ negotiateProtocol,
12
+ structuredContentFor,
13
+ validateArgs,
14
+ type ProtocolVersion,
15
+ } from "./protocol.js";
16
+
17
+ // The JSON-RPC layer, with no idea how the bytes arrive. stdio.ts frames it in
18
+ // newline-delimited JSON, http.ts in request bodies; both call `handle`.
19
+ //
20
+ // Responses go out through a `send` callback rather than a return value. That
21
+ // is not decoration: it is what lets a later revision stream progress
22
+ // notifications over SSE without touching this file.
23
+
24
+ export interface JsonRpcMessage {
25
+ jsonrpc?: string;
26
+ id?: string | number | null;
27
+ method?: string;
28
+ params?: Record<string, unknown>;
29
+ [k: string]: unknown;
30
+ }
31
+
32
+ export interface ServerOptions extends HandlerDefaults {
33
+ maxResponseBytes?: number;
34
+ serverName?: string;
35
+ // Where to look for the skill payload (SKILL.md + references/). Injectable
36
+ // for tests; in production resources.ts finds it from its own module path.
37
+ skillDir?: string;
38
+ }
39
+
40
+ export const ERR_INVALID_REQUEST = -32600;
41
+ export const ERR_METHOD_NOT_FOUND = -32601;
42
+ export const ERR_INVALID_PARAMS = -32602;
43
+ export const ERR_INTERNAL = -32603;
44
+
45
+ export interface McpServer {
46
+ // Handle one message. `send` is called zero times for a notification, once
47
+ // for a request. Never throws.
48
+ handle(msg: JsonRpcMessage, send: (out: JsonRpcMessage) => void): Promise<void>;
49
+ // The version agreed during `initialize`. The HTTP transport overrides it per
50
+ // request from the MCP-Protocol-Version header, since it has no session.
51
+ protocolVersion(): ProtocolVersion;
52
+ setProtocolVersion(v: ProtocolVersion): void;
53
+ tools(): ToolDecl[];
54
+ }
55
+
56
+ export function createServer(opts: ServerOptions = {}): McpServer {
57
+ const serverInfo = { name: opts.serverName ?? "ultra11y", version: VERSION };
58
+ const maxBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
59
+ // Until a client says otherwise, assume the newest. `initialize` replaces it.
60
+ let protocol: ProtocolVersion = LATEST_PROTOCOL;
61
+ // Requests the client withdrew. Per spec a cancelled request gets NO response
62
+ // at all, so the id has to survive until the in-flight work finishes. An id
63
+ // that never had a request in flight is never claimed, so the set is bounded:
64
+ // a client that cancels ids it never sent cannot grow it without limit in a
65
+ // process that may run for days.
66
+ const cancelled = new Set<string>();
67
+ const CANCELLED_MAX = 1024;
68
+
69
+ const listTools = () => toolsFor(protocol, { defaultCwd: opts.defaultCwd, allowWrite: opts.allowWrite });
70
+
71
+ async function handle(msg: JsonRpcMessage, send: (out: JsonRpcMessage) => void): Promise<void> {
72
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
73
+ send({ jsonrpc: "2.0", id: null, error: { code: ERR_INVALID_REQUEST, message: "invalid request: expected a JSON-RPC object" } });
74
+ return;
75
+ }
76
+
77
+ // A message with no id is a notification: act on it, answer nothing.
78
+ if (msg.id === undefined || msg.id === null) {
79
+ if (msg.method === "notifications/cancelled") {
80
+ const target = msg.params?.requestId;
81
+ if (typeof target === "string" || typeof target === "number") {
82
+ if (cancelled.size >= CANCELLED_MAX) cancelled.delete(cancelled.values().next().value!);
83
+ cancelled.add(String(target));
84
+ }
85
+ }
86
+ return;
87
+ }
88
+ const id = msg.id;
89
+
90
+ const reply = (out: Omit<JsonRpcMessage, "jsonrpc" | "id">) => {
91
+ // A cancelled request is dropped on the floor — answering it after the
92
+ // client moved on is exactly what the notification asks us not to do.
93
+ if (cancelled.delete(String(id))) return;
94
+ send({ jsonrpc: "2.0", id, ...out });
95
+ };
96
+
97
+ try {
98
+ switch (msg.method) {
99
+ case "initialize": {
100
+ protocol = negotiateProtocol(msg.params?.protocolVersion);
101
+ reply({
102
+ result: {
103
+ protocolVersion: protocol,
104
+ // Three primitives, because a skill is three things: the engine
105
+ // (tools), the method (prompts) and the documentation the method
106
+ // refers to (resources). A client given only the first has to
107
+ // invent the other two.
108
+ capabilities: {
109
+ tools: { listChanged: false },
110
+ resources: { subscribe: false, listChanged: false },
111
+ prompts: { listChanged: false },
112
+ },
113
+ serverInfo,
114
+ },
115
+ });
116
+ return;
117
+ }
118
+ case "ping":
119
+ reply({ result: {} });
120
+ return;
121
+ case "tools/list":
122
+ reply({ result: { tools: listTools() } });
123
+ return;
124
+ case "tools/call":
125
+ await handleToolCall(msg, reply);
126
+ return;
127
+ case "resources/list":
128
+ reply({ result: { resources: listResources(opts.skillDir) } });
129
+ return;
130
+ case "resources/read": {
131
+ const uri = typeof msg.params?.uri === "string" ? msg.params.uri : "";
132
+ if (!uri) {
133
+ reply({ error: { code: ERR_INVALID_PARAMS, message: "`uri` is required" } });
134
+ return;
135
+ }
136
+ try {
137
+ reply({ result: { contents: [readResource(uri, opts.skillDir)] } });
138
+ } catch (e) {
139
+ // A resource the client named wrongly is a client bug, the same as
140
+ // an unknown tool — not a read that failed on its own terms.
141
+ if (e instanceof ResourceError) reply({ error: { code: ERR_INVALID_PARAMS, message: e.message } });
142
+ else reply({ error: { code: ERR_INTERNAL, message: errMessage(e) } });
143
+ }
144
+ return;
145
+ }
146
+ case "prompts/list":
147
+ reply({ result: { prompts: PROMPTS } });
148
+ return;
149
+ case "prompts/get": {
150
+ const name = typeof msg.params?.name === "string" ? msg.params.name : "";
151
+ const args = (msg.params?.arguments ?? {}) as Record<string, unknown>;
152
+ try {
153
+ reply({ result: getPrompt(name, args) });
154
+ } catch (e) {
155
+ if (e instanceof PromptError) reply({ error: { code: ERR_INVALID_PARAMS, message: e.message } });
156
+ else reply({ error: { code: ERR_INTERNAL, message: errMessage(e) } });
157
+ }
158
+ return;
159
+ }
160
+ default:
161
+ reply({ error: { code: ERR_METHOD_NOT_FOUND, message: `method not found: ${String(msg.method)}` } });
162
+ return;
163
+ }
164
+ } catch (e) {
165
+ // Nothing above is supposed to throw. Reaching here is a bug in the
166
+ // server, not a bad request — report it as such rather than as a tool
167
+ // failure the model might try to work around.
168
+ reply({ error: { code: ERR_INTERNAL, message: errMessage(e) } });
169
+ }
170
+ }
171
+
172
+ async function handleToolCall(msg: JsonRpcMessage, reply: (out: Omit<JsonRpcMessage, "jsonrpc" | "id">) => void): Promise<void> {
173
+ const params = msg.params ?? {};
174
+ const name = typeof params.name === "string" ? params.name : "";
175
+ const args = (params.arguments ?? {}) as Record<string, unknown>;
176
+
177
+ // An unknown tool and malformed arguments are PROTOCOL errors: the client
178
+ // asked for something that doesn't exist or sent something the declared
179
+ // schema forbids. They are not tool failures, and conflating the two (as
180
+ // the vendored blueprint does) hides a client bug inside a model-readable
181
+ // result the model then tries to reason around.
182
+ const decl = listTools().find((t) => t.name === name);
183
+ if (!decl) {
184
+ reply({ error: { code: ERR_INVALID_PARAMS, message: `unknown tool: ${name || "(none given)"}` } });
185
+ return;
186
+ }
187
+ const invalid = validateArgs(decl.inputSchema, args);
188
+ if (invalid) {
189
+ reply({ error: { code: ERR_INVALID_PARAMS, message: invalid } });
190
+ return;
191
+ }
192
+
193
+ try {
194
+ const { text: raw, artifact } = await callTool(name, args, { defaultCwd: opts.defaultCwd, allowWrite: opts.allowWrite });
195
+ const text = capResponse(raw, name, maxBytes, artifact);
196
+ const capped = text !== raw;
197
+ const structured = protocol >= RICH_TOOLS_SINCE ? structuredContentFor(text, capped, decl.outputSchema !== undefined) : undefined;
198
+ reply({ result: { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) } });
199
+ } catch (e) {
200
+ // The tool ran and could not finish: a repo that won't clone, a path
201
+ // outside the tree, a dossier that isn't there. The caller can act on all
202
+ // of these, so they come back as a readable result, not a protocol error.
203
+ //
204
+ // What never lands here: a source that degraded. Those are `notes` inside
205
+ // a successful result — an unreachable issues API is information, not a
206
+ // failure, and reporting it as one would make the model retry work that
207
+ // already told it everything it is going to.
208
+ if (e instanceof ToolError) {
209
+ reply({ result: { content: [{ type: "text", text: e.message }], isError: true } });
210
+ return;
211
+ }
212
+ reply({ error: { code: ERR_INTERNAL, message: errMessage(e) } });
213
+ }
214
+ }
215
+
216
+ return {
217
+ handle,
218
+ protocolVersion: () => protocol,
219
+ setProtocolVersion: (v: ProtocolVersion) => {
220
+ protocol = v;
221
+ },
222
+ tools: listTools,
223
+ };
224
+ }
225
+
226
+ function errMessage(e: unknown): string {
227
+ return e instanceof Error ? e.message : String(e);
228
+ }
@@ -0,0 +1,119 @@
1
+ import { createInterface } from "node:readline";
2
+ import type { Readable, Writable } from "node:stream";
3
+ import { createServer, ERR_INVALID_REQUEST, type JsonRpcMessage, type ServerOptions } from "./server.js";
4
+
5
+ // The stdio transport: one JSON-RPC message per line, in on stdin, out on
6
+ // stdout. This is what `claude mcp add --transport stdio` and Claude Desktop
7
+ // speak, and it is the default.
8
+ //
9
+ // Two properties this file exists to guarantee:
10
+ //
11
+ // 1. stdout carries frames and nothing else. Today no module outside cli.ts
12
+ // writes to stdout, but "nobody does it yet" is an invariant maintained by
13
+ // discipline. Reassigning process.stdout.write makes it one maintained by
14
+ // construction: a console.log added to a source module in a year lands on
15
+ // stderr instead of corrupting the stream mid-session.
16
+ //
17
+ // 2. A slow tool never blocks a fast one. `ultra11y_ask` on a cold repo takes
18
+ // tens of seconds; serializing the read loop behind it would make `ping` and
19
+ // `tools/list` time out. JSON-RPC explicitly permits out-of-order responses.
20
+
21
+ // How many tool calls may be in flight at once. Above this, ultra11y's own
22
+ // per-source concurrency and the git subprocesses stop paying for themselves.
23
+ const MAX_IN_FLIGHT = 4;
24
+
25
+ export interface StdioOptions extends ServerOptions {
26
+ input?: Readable;
27
+ output?: Writable;
28
+ // Skip the stdout guard. Only for tests, which need to read what the server
29
+ // wrote through a stream they control.
30
+ captureStdout?: boolean;
31
+ }
32
+
33
+ export async function runStdioServer(opts: StdioOptions = {}): Promise<void> {
34
+ const input = opts.input ?? process.stdin;
35
+ const output = opts.output ?? process.stdout;
36
+
37
+ // Capture the real writer BEFORE the guard goes up, so frames still reach the
38
+ // client afterwards.
39
+ const emit = output.write.bind(output);
40
+ let restore: (() => void) | undefined;
41
+ if (!opts.captureStdout && output === process.stdout) {
42
+ const original = process.stdout.write;
43
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) =>
44
+ (process.stderr.write as (...a: unknown[]) => boolean)(chunk, ...rest)) as typeof process.stdout.write;
45
+ restore = () => {
46
+ process.stdout.write = original;
47
+ };
48
+ }
49
+
50
+ const server = createServer(opts);
51
+ const send = (msg: JsonRpcMessage) => {
52
+ emit(JSON.stringify(msg) + "\n");
53
+ };
54
+
55
+ const inFlight = new Set<Promise<void>>();
56
+ const track = (p: Promise<void>) => {
57
+ inFlight.add(p);
58
+ void p.finally(() => inFlight.delete(p));
59
+ return p;
60
+ };
61
+ const drainToLimit = async () => {
62
+ while (inFlight.size >= MAX_IN_FLIGHT) await Promise.race(inFlight);
63
+ };
64
+
65
+ const rl = createInterface({ input, terminal: false });
66
+ try {
67
+ for await (const line of rl) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed) continue;
70
+
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(trimmed);
74
+ } catch {
75
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
76
+ continue;
77
+ }
78
+
79
+ await drainToLimit();
80
+
81
+ if (Array.isArray(parsed)) {
82
+ // A batch answers as one array, so the client can match it to what it
83
+ // sent. Notifications inside it contribute nothing, and a batch of only
84
+ // notifications produces no frame at all.
85
+ track(
86
+ (async () => {
87
+ const out: JsonRpcMessage[] = [];
88
+ await Promise.all(parsed.map((m) => server.handle(m as JsonRpcMessage, (r) => void out.push(r))));
89
+ if (out.length) emit(JSON.stringify(out) + "\n");
90
+ })().catch(reportInternal(send)),
91
+ );
92
+ continue;
93
+ }
94
+
95
+ if (parsed === null || typeof parsed !== "object") {
96
+ send({ jsonrpc: "2.0", id: null, error: { code: ERR_INVALID_REQUEST, message: "invalid request: expected a JSON-RPC object" } });
97
+ continue;
98
+ }
99
+
100
+ // Deliberately not awaited: the loop goes back for the next frame while
101
+ // this one works.
102
+ track(server.handle(parsed as JsonRpcMessage, send).catch(reportInternal(send)));
103
+ }
104
+
105
+ // stdin closed. Let whatever is still running finish and answer — calling
106
+ // process.exit() here would drop these frames, because stdout on a pipe is
107
+ // asynchronous and exit() does not flush it.
108
+ await Promise.all(inFlight);
109
+ } finally {
110
+ rl.close();
111
+ restore?.();
112
+ }
113
+ }
114
+
115
+ function reportInternal(send: (msg: JsonRpcMessage) => void) {
116
+ return (e: unknown) => {
117
+ send({ jsonrpc: "2.0", id: null, error: { code: -32603, message: e instanceof Error ? e.message : String(e) } });
118
+ };
119
+ }