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
|
@@ -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
|
+
}
|
package/src/mcp/stdio.ts
ADDED
|
@@ -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
|
+
}
|