@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 Ajv, {} from "ajv";
2
- // Tool schemas come from remote catalogs and are therefore untrusted: allErrors lets a crafted
3
- // schema amplify validation work, and Ajv keeps every compiled schema for the life of the isolate.
4
- const MaxCompiledSchemas = 200;
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 ajv = new Ajv({ allErrors: false, strict: false });
7
- const compiled = new Map();
39
+ const prepared = new Map();
8
40
  return (schema, value) => {
9
- try {
10
- const key = JSON.stringify(schema);
11
- let validate = compiled.get(key);
12
- if (!validate) {
13
- if (compiled.size >= MaxCompiledSchemas) {
14
- compiled.clear();
15
- ajv.removeSchema();
16
- }
17
- validate = ajv.compile(schema);
18
- compiled.set(key, validate);
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 (validate(value))
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
- return { valid: false, detail: "The cached tool schema is invalid" };
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
+ }
@@ -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
- if (config.defaultLanguage !== "en" && !(config.defaultLanguage in catalogs)) {
104
- throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is not listed in ui.languages`);
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: [...] }` or from a JSON text block. What
19
- * makes a row usable is not that it parsed but that its identifier is confirmed against the live
20
- * tool list below.
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 is prose, not a directory. Skip it rather than fail: the
46
- // portal may well add a human sentence beside the payload.
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 null;
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.9.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.7.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",