@acid-cat/spec-ts 0.1.0 → 0.2.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.js +193 -153
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,174 +1,214 @@
|
|
|
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
|
+
const HTTP_STATUS = {
|
|
64
|
+
"unknown-capability": 404,
|
|
65
|
+
"invalid-input": 422,
|
|
66
|
+
unavailable: 503,
|
|
67
|
+
cancelled: 409,
|
|
68
|
+
internal: 500
|
|
36
69
|
};
|
|
37
|
-
|
|
70
|
+
const httpStatus = (outcome) => isCallFailure(outcome) ? HTTP_STATUS[outcome.kind] : 200;
|
|
71
|
+
/**
|
|
72
|
+
* The first rule that a request breaks, before its body is read: method, origin, media type, declared length.
|
|
73
|
+
* The body itself is checked as it arrives, with `readBounded`.
|
|
74
|
+
*/
|
|
38
75
|
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;
|
|
76
|
+
if (!rules.methods.includes(request.method)) return "method";
|
|
77
|
+
const origin = request.headers.get("Origin");
|
|
78
|
+
if (origin !== null && !rules.allowedOrigins.includes(origin)) return "origin";
|
|
79
|
+
if (request.method === "GET" || request.method === "HEAD") return void 0;
|
|
80
|
+
if ((request.headers.get("Content-Type") ?? "").split(";")[0].trim().toLowerCase() !== rules.mediaType) return "media-type";
|
|
81
|
+
const declared = Number(request.headers.get("Content-Length") ?? "");
|
|
82
|
+
if (Number.isFinite(declared) && declared > rules.maximumBytes) return "too-large";
|
|
48
83
|
}
|
|
84
|
+
/** Read a request body as UTF-8 text. Count the bytes as they arrive, and stop at the limit. */
|
|
49
85
|
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
|
-
|
|
86
|
+
if (request.body === null) return { text: "" };
|
|
87
|
+
const reader = request.body.getReader();
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let length = 0;
|
|
90
|
+
for (;;) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done) break;
|
|
93
|
+
length += value.byteLength;
|
|
94
|
+
if (length > maximumBytes) {
|
|
95
|
+
await reader.cancel().catch(() => void 0);
|
|
96
|
+
return { rejection: "too-large" };
|
|
97
|
+
}
|
|
98
|
+
chunks.push(value);
|
|
99
|
+
}
|
|
100
|
+
const bytes = new Uint8Array(length);
|
|
101
|
+
let offset = 0;
|
|
102
|
+
for (const chunk of chunks) {
|
|
103
|
+
bytes.set(chunk, offset);
|
|
104
|
+
offset += chunk.byteLength;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
108
|
+
} catch {
|
|
109
|
+
return { rejection: "not-utf8" };
|
|
110
|
+
}
|
|
75
111
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
113
|
+
const MCP_UNKNOWN_TOOL = -32602;
|
|
114
|
+
/** The HTTP status and JSON-RPC error code for each rejected MCP request. */
|
|
115
|
+
const MCP_REJECTIONS = {
|
|
116
|
+
method: { status: 405 },
|
|
117
|
+
origin: { status: 403 },
|
|
118
|
+
"media-type": { status: 415 },
|
|
119
|
+
"too-large": {
|
|
120
|
+
status: 413,
|
|
121
|
+
code: -32600
|
|
122
|
+
},
|
|
123
|
+
"not-utf8": {
|
|
124
|
+
status: 400,
|
|
125
|
+
code: -32700
|
|
126
|
+
}
|
|
84
127
|
};
|
|
128
|
+
/** The MCP tool result for an outcome that is not `unknown-capability`. */
|
|
85
129
|
function mcpToolResult(outcome, hasOutputSchema) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
130
|
+
const text = (value) => [{
|
|
131
|
+
type: "text",
|
|
132
|
+
text: JSON.stringify(value)
|
|
133
|
+
}];
|
|
134
|
+
if (isCallFailure(outcome)) return {
|
|
135
|
+
content: text({
|
|
136
|
+
kind: outcome.kind,
|
|
137
|
+
message: outcome.message
|
|
138
|
+
}),
|
|
139
|
+
isError: true
|
|
140
|
+
};
|
|
141
|
+
if (!outcome.ok) return {
|
|
142
|
+
content: text({
|
|
143
|
+
ok: false,
|
|
144
|
+
error: outcome.error
|
|
145
|
+
}),
|
|
146
|
+
isError: true
|
|
147
|
+
};
|
|
148
|
+
return hasOutputSchema ? {
|
|
149
|
+
content: text(outcome.value),
|
|
150
|
+
structuredContent: outcome.value
|
|
151
|
+
} : { content: text(outcome.value) };
|
|
90
152
|
}
|
|
91
|
-
|
|
153
|
+
const REFERENCE = /^#\/\$defs\/([A-Za-z][A-Za-z0-9_]*)$/;
|
|
154
|
+
/** The schemas of a capability, each with the member that holds it. */
|
|
92
155
|
function schemasOf(capability) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return members.filter((entry) => entry[1] !== void 0);
|
|
156
|
+
return [
|
|
157
|
+
["input", capability.input],
|
|
158
|
+
["output", capability.output],
|
|
159
|
+
["state", capability.state],
|
|
160
|
+
...Object.entries(capability.failures ?? {}).map(([tag, schema]) => [`failure ${tag}`, schema])
|
|
161
|
+
].filter((entry) => entry[1] !== void 0);
|
|
100
162
|
}
|
|
163
|
+
/** Every object in a JSON value, at all levels. */
|
|
101
164
|
function visitObjects(value, onObject) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
165
|
+
if (Array.isArray(value)) value.forEach((item) => visitObjects(item, onObject));
|
|
166
|
+
else if (value !== null && typeof value === "object") {
|
|
167
|
+
onObject(value);
|
|
168
|
+
Object.values(value).forEach((item) => visitObjects(item, onObject));
|
|
169
|
+
}
|
|
108
170
|
}
|
|
171
|
+
/** The types that a schema uses, directly or through other types. */
|
|
109
172
|
function typesUsedBy(schema, types) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
173
|
+
const used = /* @__PURE__ */ new Map();
|
|
174
|
+
const collect = (node) => visitObjects(node, (inner) => {
|
|
175
|
+
const name = typeof inner.$ref === "string" ? REFERENCE.exec(inner.$ref)?.[1] : void 0;
|
|
176
|
+
const type = name === void 0 ? void 0 : types[name];
|
|
177
|
+
if (name !== void 0 && type !== void 0 && !used.has(name)) {
|
|
178
|
+
used.set(name, type);
|
|
179
|
+
collect(type);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
collect(schema);
|
|
183
|
+
return used;
|
|
121
184
|
}
|
|
185
|
+
/** Bundle a schema (spec/catalog.md#bundling). */
|
|
122
186
|
function bundle(schema, types) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
187
|
+
const only = Object.keys(schema).length === 1 ? REFERENCE.exec(String(schema.$ref))?.[1] : void 0;
|
|
188
|
+
const base = only === void 0 ? schema : types[only] ?? schema;
|
|
189
|
+
const used = typesUsedBy(base, types);
|
|
190
|
+
return used.size === 0 ? base : {
|
|
191
|
+
...base,
|
|
192
|
+
$defs: Object.fromEntries(used)
|
|
193
|
+
};
|
|
127
194
|
}
|
|
195
|
+
/** The view of a target (spec/catalog.md#views). */
|
|
128
196
|
function viewOf(catalog, target) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
};
|
|
197
|
+
const types = catalog.$defs ?? {};
|
|
198
|
+
const capabilities = Object.fromEntries(Object.entries(catalog.capabilities).filter(([, capability]) => (capability.audience ?? []).includes(target)).map(([id, capability]) => [id, {
|
|
199
|
+
...capability,
|
|
200
|
+
audience: [target]
|
|
201
|
+
}]));
|
|
202
|
+
const used = /* @__PURE__ */ new Map();
|
|
203
|
+
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);
|
|
204
|
+
const { $defs: _, ...rest } = catalog;
|
|
205
|
+
const declared = catalog.targets[target];
|
|
206
|
+
return {
|
|
207
|
+
...rest,
|
|
208
|
+
targets: declared === void 0 ? {} : { [target]: declared },
|
|
209
|
+
...used.size > 0 ? { $defs: Object.fromEntries(used) } : {},
|
|
210
|
+
capabilities
|
|
211
|
+
};
|
|
145
212
|
}
|
|
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
|
-
};
|
|
213
|
+
//#endregion
|
|
214
|
+
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, 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.2.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",
|