@anchrd/intel-api 0.9.0 → 0.11.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.
|
@@ -1,31 +1,77 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
import { Validator } from "@cfworker/json-schema";
|
|
2
|
+
import { reportUnexpectedError } from "../../shared/report-unexpected-error/report-unexpected-error.js";
|
|
3
|
+
// ⚠️ This validator must not generate code, and Ajv must never come back. Ajv turns a schema into
|
|
4
|
+
// JavaScript with `new Function`, and workerd answers that with `EvalError: Code generation from
|
|
5
|
+
// strings disallowed for this context` — so `compile` threw for EVERY schema in production, the
|
|
6
|
+
// catch called it a broken schema, and each tool call died with 400 "The cached tool schema is
|
|
7
|
+
// invalid" while nothing was wrong with any schema (#229). Ajv is usable only with schemas
|
|
8
|
+
// precompiled at build time; these arrive from a remote catalog and are unknown until the call.
|
|
9
|
+
// `@cfworker/json-schema` interprets the schema instead, which is what it was written for.
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ The `workerd` test pool does not prove this on its own: it replaces `globalThis.Function` with
|
|
12
|
+
// a proxy onto an unsafe-eval binding, so Ajv compiled happily in every test while failing in every
|
|
13
|
+
// deployment. `json-schema.int.ts` therefore takes that patch away for the length of the test — it
|
|
14
|
+
// is the only thing that turns red when a code-generating validator returns.
|
|
15
|
+
// Tool schemas come from remote catalogs and are therefore untrusted: `ShortCircuit` stops at the
|
|
16
|
+
// first error so a crafted schema cannot amplify the work of one call, and the prepared validators
|
|
17
|
+
// are bounded because each one holds its dereferenced schema for the life of the isolate.
|
|
18
|
+
const MaxPreparedSchemas = 200;
|
|
19
|
+
const ShortCircuit = true;
|
|
20
|
+
const MaxDetailLength = 2_000;
|
|
21
|
+
// A tool schema that names its draft gets that draft: draft-07 ignores keywords sitting beside a
|
|
22
|
+
// `$ref` and the later drafts apply them, so guessing here would validate a foreign catalog against
|
|
23
|
+
// rules its author never wrote. Unnamed means 2020-12, the draft the MCP generators emit when they
|
|
24
|
+
// stay silent.
|
|
25
|
+
const DefaultDraft = "2020-12";
|
|
26
|
+
const DraftsByMetaSchema = [
|
|
27
|
+
["draft-04", "4"],
|
|
28
|
+
["draft-07", "7"],
|
|
29
|
+
["2019-09", "2019-09"],
|
|
30
|
+
["2020-12", "2020-12"],
|
|
31
|
+
];
|
|
32
|
+
function draftOf(schema) {
|
|
33
|
+
const declared = schema.$schema;
|
|
34
|
+
if (typeof declared !== "string")
|
|
35
|
+
return DefaultDraft;
|
|
36
|
+
return DraftsByMetaSchema.find(([marker]) => declared.includes(marker))?.[1] ?? DefaultDraft;
|
|
37
|
+
}
|
|
5
38
|
export function createJsonSchemaValidator() {
|
|
6
|
-
const
|
|
7
|
-
const compiled = new Map();
|
|
39
|
+
const prepared = new Map();
|
|
8
40
|
return (schema, value) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
const key = JSON.stringify(schema);
|
|
42
|
+
let validator = prepared.get(key);
|
|
43
|
+
if (!validator) {
|
|
44
|
+
try {
|
|
45
|
+
validator = new Validator(schema, draftOf(schema), ShortCircuit);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// A schema this validator cannot even read is still refused by name — but the caller hears
|
|
49
|
+
// that it was the schema, not their arguments, and the reason itself goes to the log. The
|
|
50
|
+
// old wording claimed a broken schema for every failure, which is what hid #229 for weeks.
|
|
51
|
+
reportUnexpectedError(error);
|
|
52
|
+
return { valid: false, detail: "The tool schema could not be read; the reason was logged" };
|
|
19
53
|
}
|
|
20
|
-
if (
|
|
54
|
+
if (prepared.size >= MaxPreparedSchemas)
|
|
55
|
+
prepared.clear();
|
|
56
|
+
prepared.set(key, validator);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = validator.validate(value);
|
|
60
|
+
if (result.valid)
|
|
21
61
|
return { valid: true };
|
|
22
|
-
return {
|
|
23
|
-
valid: false,
|
|
24
|
-
detail: ajv.errorsText(validate.errors, { separator: "; " }).slice(0, 2_000),
|
|
25
|
-
};
|
|
62
|
+
return { valid: false, detail: describe(result) };
|
|
26
63
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
64
|
+
catch (error) {
|
|
65
|
+
// An unresolvable `$ref` only shows up while walking the instance, so this is the same
|
|
66
|
+
// unreadable schema arriving one step later — and never a statement about the arguments.
|
|
67
|
+
reportUnexpectedError(error);
|
|
68
|
+
return { valid: false, detail: "The tool schema could not be read; the reason was logged" };
|
|
29
69
|
}
|
|
30
70
|
};
|
|
31
71
|
}
|
|
72
|
+
function describe(result) {
|
|
73
|
+
return result.errors
|
|
74
|
+
.map((unit) => `${unit.instanceLocation}: ${unit.error}`)
|
|
75
|
+
.join("; ")
|
|
76
|
+
.slice(0, MaxDetailLength);
|
|
77
|
+
}
|
package/dist/build/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UI_LANGUAGES } from "@anchrd/intel-contract";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
const UiConfig = z.strictObject({
|
|
3
4
|
theme: z.string().min(1).optional(),
|
|
@@ -100,8 +101,12 @@ export function createBuild(deps) {
|
|
|
100
101
|
}
|
|
101
102
|
catalogs[language] = candidateParsed.data;
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// A built-in language needs no entry in ui.languages — its catalog is already in the UI.
|
|
105
|
+
// Without this exception, `ui.defaultLanguage: "de"` would force the customer to list a copy
|
|
106
|
+
// of de.json that rots at every UI update, and nobody maintains that copy.
|
|
107
|
+
const builtIn = UI_LANGUAGES;
|
|
108
|
+
if (!builtIn.includes(config.defaultLanguage) && !(config.defaultLanguage in catalogs)) {
|
|
109
|
+
throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is neither built in (${UI_LANGUAGES.join(", ")}) nor listed in ui.languages`);
|
|
105
110
|
}
|
|
106
111
|
const theme = config.theme
|
|
107
112
|
? `/* Generated by \`intel build\` from ${asComment(config.theme)}. */\n${await readRequired(config.theme, "ui.theme")}\n`
|
|
@@ -15,9 +15,10 @@ export const ServerDirectoryTool = "portal_list_servers";
|
|
|
15
15
|
*
|
|
16
16
|
* ⚠️ Only the shape is assumed, never the vocabulary. The portal is somebody else's product and its
|
|
17
17
|
* payload is not part of any contract Intel owns, so every field is optional and the answer is
|
|
18
|
-
* accepted from `structuredContent`, from `{ servers: [...] }
|
|
19
|
-
*
|
|
20
|
-
* tool list
|
|
18
|
+
* accepted from `structuredContent`, from `{ servers: [...] }`, from a JSON text block, or — the
|
|
19
|
+
* form the live portal actually uses — from the prose block `proseRows` reads. What makes a row
|
|
20
|
+
* usable is not that it parsed but that its identifier is confirmed against the live tool list
|
|
21
|
+
* below.
|
|
21
22
|
*/
|
|
22
23
|
const DirectoryRow = z.looseObject({
|
|
23
24
|
id: z.string().optional(),
|
|
@@ -26,10 +27,71 @@ const DirectoryRow = z.looseObject({
|
|
|
26
27
|
});
|
|
27
28
|
const DirectoryRows = z.array(DirectoryRow);
|
|
28
29
|
const DirectoryEnvelope = z.looseObject({ servers: DirectoryRows });
|
|
30
|
+
/** A line the portal meant as a directory entry: `- <display name> (<handle>): <state>`. */
|
|
31
|
+
const ProseRow = /^\s*[-*•]\s+(.+?)\s*\(([^()\s]+)\)\s*:\s*(.*)$/;
|
|
32
|
+
/** What claims to be an entry at all. Everything else in the block is a heading or a hint. */
|
|
33
|
+
const ProseBullet = /^\s*[-*•]\s/;
|
|
34
|
+
/**
|
|
35
|
+
* ⚠️ Never the ✓. A tick is decoration: a different locale, a plain-text client or a later version
|
|
36
|
+
* of the portal writes the same fact another way, and a reading that hangs on one code point would
|
|
37
|
+
* silently switch every server off. What is read is the word, and the word alone.
|
|
38
|
+
*
|
|
39
|
+
* It has to be the WHOLE state, not a word inside it, because "disabled" contains "enabled" and
|
|
40
|
+
* "not enabled" is built from it — a substring test would read both as switched on, and that is the
|
|
41
|
+
* one direction this must never get wrong. Anything else the portal might write ("aktiviert",
|
|
42
|
+
* "enabled (3 tools)") is therefore not offerable, and stays declared.
|
|
43
|
+
*/
|
|
44
|
+
function readsAsEnabled(state) {
|
|
45
|
+
const words = state.toLowerCase().match(/\p{L}+/gu);
|
|
46
|
+
return words !== null && words.length === 1 && words[0] === "enabled";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The portal's prose form: a heading, one bullet per server, a closing hint. It is what the live
|
|
50
|
+
* portal actually answers (#233), and it is read only after every JSON reading in `rows` has failed.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ This reading hangs on somebody else's wording and can break without warning. The portal owes
|
|
53
|
+
* Intel no format at all, so a reworded state, a translated page or a changed layout is a normal
|
|
54
|
+
* event here rather than a defect. Two things keep that from becoming a quiet widening:
|
|
55
|
+
*
|
|
56
|
+
* - **A bullet that does not read as a row refuses the whole answer.** Skipping it would hand back
|
|
57
|
+
* a directory with one server missing, and a missing row is not a smaller list — it lets that
|
|
58
|
+
* server's tools fall back onto the shorter handle that encloses them, which is exactly the
|
|
59
|
+
* widening the split between `declared` and `servers` exists to prevent. The price is that a
|
|
60
|
+
* portal which one day bullets its closing hint fails loudly with the same closed
|
|
61
|
+
* `tool_servers_unavailable` that #233 found; that is the intended direction.
|
|
62
|
+
* - **Nothing read here can invent a server.** A handle no live tool confirms is never offered, so
|
|
63
|
+
* a misread line costs an offer, never a delegation.
|
|
64
|
+
*/
|
|
65
|
+
function proseRows(answer) {
|
|
66
|
+
const parsed = [];
|
|
67
|
+
for (const block of answer.content) {
|
|
68
|
+
if (typeof block !== "object" || block === null || !("text" in block))
|
|
69
|
+
continue;
|
|
70
|
+
const text = block.text;
|
|
71
|
+
if (typeof text !== "string")
|
|
72
|
+
continue;
|
|
73
|
+
for (const line of text.split("\n")) {
|
|
74
|
+
if (!ProseBullet.test(line))
|
|
75
|
+
continue;
|
|
76
|
+
const [, name, handle, state] = ProseRow.exec(line) ?? [];
|
|
77
|
+
if (name === undefined || handle === undefined)
|
|
78
|
+
return null;
|
|
79
|
+
// `id` carries the handle and `name` the display name, but neither is believed on its own:
|
|
80
|
+
// both stay candidates in `toolServersFrom`, and the live tool list decides which one
|
|
81
|
+
// actually namespaces.
|
|
82
|
+
parsed.push({ id: handle, name, enabled: readsAsEnabled(state ?? "") });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parsed.length > 0 ? parsed : null;
|
|
86
|
+
}
|
|
29
87
|
/**
|
|
30
88
|
* Every place the portal could reasonably have put its list, tried in order. An MCP tool result is
|
|
31
89
|
* either structured or text, and a server that answers with `{ servers: [...] }` is as likely as
|
|
32
90
|
* one that answers with a bare array.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ The prose reading comes last and is ADDITIONAL, never replacing. The portal is somebody else's
|
|
93
|
+
* product: it answers in prose today and may answer structured tomorrow, and a structured answer
|
|
94
|
+
* must keep winning on the day it arrives beside a human sentence.
|
|
33
95
|
*/
|
|
34
96
|
function rows(answer) {
|
|
35
97
|
const candidates = [answer.structuredContent];
|
|
@@ -42,8 +104,9 @@ function rows(answer) {
|
|
|
42
104
|
candidates.push(JSON.parse(text));
|
|
43
105
|
}
|
|
44
106
|
catch {
|
|
45
|
-
// A text block that is not JSON
|
|
46
|
-
//
|
|
107
|
+
// A text block that is not JSON may still be the directory — see `proseRows` above. It is
|
|
108
|
+
// not fed to the JSON readings here, and it does not make them fail either: the portal may
|
|
109
|
+
// well add a human sentence beside the payload.
|
|
47
110
|
}
|
|
48
111
|
}
|
|
49
112
|
}
|
|
@@ -55,7 +118,7 @@ function rows(answer) {
|
|
|
55
118
|
if (wrapped.success)
|
|
56
119
|
return wrapped.data.servers;
|
|
57
120
|
}
|
|
58
|
-
return
|
|
121
|
+
return proseRows(answer);
|
|
59
122
|
}
|
|
60
123
|
/**
|
|
61
124
|
* ⚠️ The rule itself — longest match against declared handles, never a cut at an underscore — is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.8.0",
|
|
47
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
47
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
48
|
-
"ajv": "^8.20.0",
|
|
49
49
|
"fflate": "^0.8.3",
|
|
50
50
|
"hono": "^4.12.32",
|
|
51
51
|
"openid-client": "^6.8.4",
|