@acid-cat/spec-ts 0.1.0 → 0.3.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/dist/index.d.ts +10 -0
- package/dist/index.js +228 -153
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -58,6 +58,16 @@ export declare const CLI_EXIT_CODES: {
|
|
|
58
58
|
readonly cancelled: 130;
|
|
59
59
|
};
|
|
60
60
|
export declare const cliExitCode: (outcome: Outcome<unknown, unknown>) => number;
|
|
61
|
+
/**
|
|
62
|
+
* The flags of a command, as `usage` writes them (spec/bindings/cli.md#commands): required flags first, each group in
|
|
63
|
+
* code point order of the names. `input` is the bundled input schema.
|
|
64
|
+
*/
|
|
65
|
+
export declare function cliFlags(input: JsonObject): string[];
|
|
66
|
+
/**
|
|
67
|
+
* The `usage` of a capability in `list` (spec/bindings/cli.md#commands). `input` is the bundled input schema, or
|
|
68
|
+
* `undefined` when the capability has no input.
|
|
69
|
+
*/
|
|
70
|
+
export declare function cliUsage(program: string, id: string, input: JsonObject | undefined): string;
|
|
61
71
|
export declare const HTTP_STATUS: {
|
|
62
72
|
readonly "unknown-capability": 404;
|
|
63
73
|
readonly "invalid-input": 422;
|
package/dist/index.js
CHANGED
|
@@ -1,174 +1,249 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* The fixed details of the AcidCat specification, as TypeScript data and pure functions, for the TypeScript reference
|
|
4
|
+
* implementation. This package has no dependencies.
|
|
5
|
+
*
|
|
6
|
+
* - Outcomes and call failures: spec/invocation.md
|
|
7
|
+
* - Names, views and bundling: spec/catalog.md
|
|
8
|
+
* - Exit codes, status codes and MCP results: spec/bindings/
|
|
9
|
+
*/
|
|
10
|
+
/** The kinds of call failure, in order of precedence: when more than one applies, the first one is used. */
|
|
11
|
+
const CALL_FAILURE_KINDS = [
|
|
12
|
+
"cancelled",
|
|
13
|
+
"unknown-capability",
|
|
14
|
+
"unavailable",
|
|
15
|
+
"invalid-input",
|
|
16
|
+
"internal"
|
|
17
|
+
];
|
|
18
|
+
const ok = (value) => ({
|
|
19
|
+
ok: true,
|
|
20
|
+
value
|
|
21
|
+
});
|
|
22
|
+
/** A failure with its tag, as a result. */
|
|
23
|
+
const fail = (tag, data) => ({
|
|
24
|
+
ok: false,
|
|
25
|
+
error: {
|
|
26
|
+
_tag: tag,
|
|
27
|
+
...data ?? {}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const callFailure = (kind, message) => ({
|
|
31
|
+
kind,
|
|
32
|
+
message
|
|
33
|
+
});
|
|
34
|
+
const isCallFailure = (outcome) => !("ok" in outcome);
|
|
35
|
+
/** The JavaScript error for a call failure (spec/bindings/typescript.md#handles). */
|
|
7
36
|
var CallFailureError = class extends Error {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
37
|
+
kind;
|
|
38
|
+
constructor(failure) {
|
|
39
|
+
super(failure.message);
|
|
40
|
+
this.kind = failure.kind;
|
|
41
|
+
this.name = failure.kind === "cancelled" ? "AbortError" : failure.kind === "invalid-input" ? "TypeError" : "Error";
|
|
42
|
+
}
|
|
14
43
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
const AVAILABLE = { available: true };
|
|
45
|
+
const unavailable = (reason) => ({
|
|
46
|
+
available: false,
|
|
47
|
+
reason
|
|
48
|
+
});
|
|
49
|
+
const ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$/;
|
|
50
|
+
const isId = (id) => id.length <= 128 && ID_PATTERN.test(id);
|
|
51
|
+
/** The path of the CLI command for an id: `aircon.power.set` → `["aircon", "power", "set"]`. */
|
|
52
|
+
const commandPath = (id) => id.split(".");
|
|
53
|
+
const CLI_EXIT_CODES = {
|
|
54
|
+
value: 0,
|
|
55
|
+
failure: 1,
|
|
56
|
+
"unknown-capability": 2,
|
|
57
|
+
"invalid-input": 3,
|
|
58
|
+
unavailable: 4,
|
|
59
|
+
internal: 5,
|
|
60
|
+
cancelled: 130
|
|
28
61
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
const cliExitCode = (outcome) => isCallFailure(outcome) ? CLI_EXIT_CODES[outcome.kind] : outcome.ok ? CLI_EXIT_CODES.value : CLI_EXIT_CODES.failure;
|
|
63
|
+
/**
|
|
64
|
+
* The flags of a command, as `usage` writes them (spec/bindings/cli.md#commands): required flags first, each group in
|
|
65
|
+
* code point order of the names. `input` is the bundled input schema.
|
|
66
|
+
*/
|
|
67
|
+
function cliFlags(input) {
|
|
68
|
+
const properties = input.properties ?? {};
|
|
69
|
+
const definitions = input.$defs ?? {};
|
|
70
|
+
const required = new Set(input.required ?? []);
|
|
71
|
+
const byName = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
72
|
+
const names = Object.keys(properties).sort(byName);
|
|
73
|
+
return [...names.filter((name) => required.has(name)), ...names.filter((name) => !required.has(name))].map((name) => {
|
|
74
|
+
const reference = properties[name].$ref;
|
|
75
|
+
const target = typeof reference === "string" ? REFERENCE.exec(reference)?.[1] : void 0;
|
|
76
|
+
const property = (target === void 0 ? void 0 : definitions[target]) ?? properties[name];
|
|
77
|
+
const flag = property.type === "boolean" ? `--${name}` : `--${name} <${flagValue(property)}>`;
|
|
78
|
+
return required.has(name) ? flag : `[${flag}]`;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function flagValue(property) {
|
|
82
|
+
if (Array.isArray(property.enum) && property.enum.every((value) => typeof value === "string")) return property.enum.join("|");
|
|
83
|
+
if (property.type === "integer" || property.type === "number" || property.type === "string") return property.type;
|
|
84
|
+
return "json";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The `usage` of a capability in `list` (spec/bindings/cli.md#commands). `input` is the bundled input schema, or
|
|
88
|
+
* `undefined` when the capability has no input.
|
|
89
|
+
*/
|
|
90
|
+
function cliUsage(program, id, input) {
|
|
91
|
+
if (input !== void 0 && input.properties === void 0) return `${program} call ${id} '<json>'`;
|
|
92
|
+
return [
|
|
93
|
+
program,
|
|
94
|
+
...commandPath(id),
|
|
95
|
+
...input === void 0 ? [] : cliFlags(input)
|
|
96
|
+
].join(" ");
|
|
97
|
+
}
|
|
98
|
+
const HTTP_STATUS = {
|
|
99
|
+
"unknown-capability": 404,
|
|
100
|
+
"invalid-input": 422,
|
|
101
|
+
unavailable: 503,
|
|
102
|
+
cancelled: 409,
|
|
103
|
+
internal: 500
|
|
36
104
|
};
|
|
37
|
-
|
|
105
|
+
const httpStatus = (outcome) => isCallFailure(outcome) ? HTTP_STATUS[outcome.kind] : 200;
|
|
106
|
+
/**
|
|
107
|
+
* The first rule that a request breaks, before its body is read: method, origin, media type, declared length.
|
|
108
|
+
* The body itself is checked as it arrives, with `readBounded`.
|
|
109
|
+
*/
|
|
38
110
|
function screenRequest(request, rules) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (Number.isFinite(declared) && declared > rules.maximumBytes) return "too-large";
|
|
47
|
-
return void 0;
|
|
111
|
+
if (!rules.methods.includes(request.method)) return "method";
|
|
112
|
+
const origin = request.headers.get("Origin");
|
|
113
|
+
if (origin !== null && !rules.allowedOrigins.includes(origin)) return "origin";
|
|
114
|
+
if (request.method === "GET" || request.method === "HEAD") return void 0;
|
|
115
|
+
if ((request.headers.get("Content-Type") ?? "").split(";")[0].trim().toLowerCase() !== rules.mediaType) return "media-type";
|
|
116
|
+
const declared = Number(request.headers.get("Content-Length") ?? "");
|
|
117
|
+
if (Number.isFinite(declared) && declared > rules.maximumBytes) return "too-large";
|
|
48
118
|
}
|
|
119
|
+
/** Read a request body as UTF-8 text. Count the bytes as they arrive, and stop at the limit. */
|
|
49
120
|
async function readBounded(request, maximumBytes) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
121
|
+
if (request.body === null) return { text: "" };
|
|
122
|
+
const reader = request.body.getReader();
|
|
123
|
+
const chunks = [];
|
|
124
|
+
let length = 0;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done) break;
|
|
128
|
+
length += value.byteLength;
|
|
129
|
+
if (length > maximumBytes) {
|
|
130
|
+
await reader.cancel().catch(() => void 0);
|
|
131
|
+
return { rejection: "too-large" };
|
|
132
|
+
}
|
|
133
|
+
chunks.push(value);
|
|
134
|
+
}
|
|
135
|
+
const bytes = new Uint8Array(length);
|
|
136
|
+
let offset = 0;
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
bytes.set(chunk, offset);
|
|
139
|
+
offset += chunk.byteLength;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
143
|
+
} catch {
|
|
144
|
+
return { rejection: "not-utf8" };
|
|
145
|
+
}
|
|
75
146
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
147
|
+
const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
148
|
+
const MCP_UNKNOWN_TOOL = -32602;
|
|
149
|
+
/** The HTTP status and JSON-RPC error code for each rejected MCP request. */
|
|
150
|
+
const MCP_REJECTIONS = {
|
|
151
|
+
method: { status: 405 },
|
|
152
|
+
origin: { status: 403 },
|
|
153
|
+
"media-type": { status: 415 },
|
|
154
|
+
"too-large": {
|
|
155
|
+
status: 413,
|
|
156
|
+
code: -32600
|
|
157
|
+
},
|
|
158
|
+
"not-utf8": {
|
|
159
|
+
status: 400,
|
|
160
|
+
code: -32700
|
|
161
|
+
}
|
|
84
162
|
};
|
|
163
|
+
/** The MCP tool result for an outcome that is not `unknown-capability`. */
|
|
85
164
|
function mcpToolResult(outcome, hasOutputSchema) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
165
|
+
const text = (value) => [{
|
|
166
|
+
type: "text",
|
|
167
|
+
text: JSON.stringify(value)
|
|
168
|
+
}];
|
|
169
|
+
if (isCallFailure(outcome)) return {
|
|
170
|
+
content: text({
|
|
171
|
+
kind: outcome.kind,
|
|
172
|
+
message: outcome.message
|
|
173
|
+
}),
|
|
174
|
+
isError: true
|
|
175
|
+
};
|
|
176
|
+
if (!outcome.ok) return {
|
|
177
|
+
content: text({
|
|
178
|
+
ok: false,
|
|
179
|
+
error: outcome.error
|
|
180
|
+
}),
|
|
181
|
+
isError: true
|
|
182
|
+
};
|
|
183
|
+
return hasOutputSchema ? {
|
|
184
|
+
content: text(outcome.value),
|
|
185
|
+
structuredContent: outcome.value
|
|
186
|
+
} : { content: text(outcome.value) };
|
|
90
187
|
}
|
|
91
|
-
|
|
188
|
+
const REFERENCE = /^#\/\$defs\/([A-Za-z][A-Za-z0-9_]*)$/;
|
|
189
|
+
/** The schemas of a capability, each with the member that holds it. */
|
|
92
190
|
function schemasOf(capability) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return members.filter((entry) => entry[1] !== void 0);
|
|
191
|
+
return [
|
|
192
|
+
["input", capability.input],
|
|
193
|
+
["output", capability.output],
|
|
194
|
+
["state", capability.state],
|
|
195
|
+
...Object.entries(capability.failures ?? {}).map(([tag, schema]) => [`failure ${tag}`, schema])
|
|
196
|
+
].filter((entry) => entry[1] !== void 0);
|
|
100
197
|
}
|
|
198
|
+
/** Every object in a JSON value, at all levels. */
|
|
101
199
|
function visitObjects(value, onObject) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
200
|
+
if (Array.isArray(value)) value.forEach((item) => visitObjects(item, onObject));
|
|
201
|
+
else if (value !== null && typeof value === "object") {
|
|
202
|
+
onObject(value);
|
|
203
|
+
Object.values(value).forEach((item) => visitObjects(item, onObject));
|
|
204
|
+
}
|
|
108
205
|
}
|
|
206
|
+
/** The types that a schema uses, directly or through other types. */
|
|
109
207
|
function typesUsedBy(schema, types) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
208
|
+
const used = /* @__PURE__ */ new Map();
|
|
209
|
+
const collect = (node) => visitObjects(node, (inner) => {
|
|
210
|
+
const name = typeof inner.$ref === "string" ? REFERENCE.exec(inner.$ref)?.[1] : void 0;
|
|
211
|
+
const type = name === void 0 ? void 0 : types[name];
|
|
212
|
+
if (name !== void 0 && type !== void 0 && !used.has(name)) {
|
|
213
|
+
used.set(name, type);
|
|
214
|
+
collect(type);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
collect(schema);
|
|
218
|
+
return used;
|
|
121
219
|
}
|
|
220
|
+
/** Bundle a schema (spec/catalog.md#bundling). */
|
|
122
221
|
function bundle(schema, types) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
222
|
+
const only = Object.keys(schema).length === 1 ? REFERENCE.exec(String(schema.$ref))?.[1] : void 0;
|
|
223
|
+
const base = only === void 0 ? schema : types[only] ?? schema;
|
|
224
|
+
const used = typesUsedBy(base, types);
|
|
225
|
+
return used.size === 0 ? base : {
|
|
226
|
+
...base,
|
|
227
|
+
$defs: Object.fromEntries(used)
|
|
228
|
+
};
|
|
127
229
|
}
|
|
230
|
+
/** The view of a target (spec/catalog.md#views). */
|
|
128
231
|
function viewOf(catalog, target) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
232
|
+
const types = catalog.$defs ?? {};
|
|
233
|
+
const capabilities = Object.fromEntries(Object.entries(catalog.capabilities).filter(([, capability]) => (capability.audience ?? []).includes(target)).map(([id, capability]) => [id, {
|
|
234
|
+
...capability,
|
|
235
|
+
audience: [target]
|
|
236
|
+
}]));
|
|
237
|
+
const used = /* @__PURE__ */ new Map();
|
|
238
|
+
for (const capability of Object.values(capabilities)) for (const [, schema] of schemasOf(capability)) for (const [name, type] of typesUsedBy(schema, types)) used.set(name, type);
|
|
239
|
+
const { $defs: _, ...rest } = catalog;
|
|
240
|
+
const declared = catalog.targets[target];
|
|
241
|
+
return {
|
|
242
|
+
...rest,
|
|
243
|
+
targets: declared === void 0 ? {} : { [target]: declared },
|
|
244
|
+
...used.size > 0 ? { $defs: Object.fromEntries(used) } : {},
|
|
245
|
+
capabilities
|
|
246
|
+
};
|
|
145
247
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
CALL_FAILURE_KINDS,
|
|
149
|
-
CLI_EXIT_CODES,
|
|
150
|
-
CallFailureError,
|
|
151
|
-
HTTP_STATUS,
|
|
152
|
-
ID_PATTERN,
|
|
153
|
-
MCP_PROTOCOL_VERSION,
|
|
154
|
-
MCP_REJECTIONS,
|
|
155
|
-
MCP_UNKNOWN_TOOL,
|
|
156
|
-
REFERENCE,
|
|
157
|
-
bundle,
|
|
158
|
-
callFailure,
|
|
159
|
-
cliExitCode,
|
|
160
|
-
commandPath,
|
|
161
|
-
fail,
|
|
162
|
-
httpStatus,
|
|
163
|
-
isCallFailure,
|
|
164
|
-
isId,
|
|
165
|
-
mcpToolResult,
|
|
166
|
-
ok,
|
|
167
|
-
readBounded,
|
|
168
|
-
schemasOf,
|
|
169
|
-
screenRequest,
|
|
170
|
-
typesUsedBy,
|
|
171
|
-
unavailable,
|
|
172
|
-
viewOf,
|
|
173
|
-
visitObjects
|
|
174
|
-
};
|
|
248
|
+
//#endregion
|
|
249
|
+
export { AVAILABLE, CALL_FAILURE_KINDS, CLI_EXIT_CODES, CallFailureError, HTTP_STATUS, ID_PATTERN, MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, REFERENCE, bundle, callFailure, cliExitCode, cliFlags, cliUsage, commandPath, fail, httpStatus, isCallFailure, isId, mcpToolResult, ok, readBounded, schemasOf, screenRequest, typesUsedBy, unavailable, viewOf, visitObjects };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/spec-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The fixed details of the AcidCat specification as TypeScript data and pure functions, with no dependencies. Part of the TypeScript reference implementation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"acidcat",
|