@konneal/engine 0.2.136 → 0.2.138

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.
@@ -4,15 +4,16 @@ import {
4
4
  handleMemories,
5
5
  scoreFaithfulness,
6
6
  scoreJudge
7
- } from "../../chunk-YLBPABDQ.js";
7
+ } from "../../chunk-4GNKOTIK.js";
8
+ import "../../chunk-GBGRHXRU.js";
8
9
  import {
9
10
  TOOLS_REGISTRY,
10
11
  bindModelNode,
11
12
  modelGroundingBlock
12
- } from "../../chunk-J6LBR6GI.js";
13
+ } from "../../chunk-RKNN3WAV.js";
13
14
  import {
14
15
  handleSearch
15
- } from "../../chunk-MWOY24B6.js";
16
+ } from "../../chunk-5WITTJQD.js";
16
17
  import {
17
18
  buildMessages,
18
19
  citations,
@@ -36,13 +37,11 @@ import {
36
37
  sessionFrom,
37
38
  telemetry,
38
39
  understandQuery
39
- } from "../../chunk-7AXXR7GD.js";
40
+ } from "../../chunk-XH5QGX2O.js";
40
41
  import {
41
- cfBlobs,
42
42
  embed,
43
- generateOnce,
44
- portModelRunner
45
- } from "../../chunk-TH5XGPD5.js";
43
+ generateOnce
44
+ } from "../../chunk-NI4CIK6W.js";
46
45
  import {
47
46
  authenticate,
48
47
  corsHeaders,
@@ -70,11 +69,14 @@ import {
70
69
  sha256Hex,
71
70
  today
72
71
  } from "../../chunk-VT7DR6NQ.js";
73
- import "../../chunk-GBGRHXRU.js";
74
72
  import {
75
73
  P,
76
74
  setProfile
77
75
  } from "../../chunk-3FYJM7LH.js";
76
+ import {
77
+ cfBlobs,
78
+ portModelRunner
79
+ } from "../../chunk-LSZTJI7V.js";
78
80
 
79
81
  // workers/worker_public/src/conversations.ts
80
82
  var ID_RE = /^[a-zA-Z0-9_-]{8,64}$/;
@@ -959,9 +961,30 @@ async function deleteAttachment(deps, sub, id) {
959
961
 
960
962
  // workers/worker_public/src/mcp-proto.ts
961
963
  var PROTOCOL_VERSION = "2025-06-18";
964
+ var mcpPrefix = () => `${P().publisher.id}_`;
965
+ var MCP_NAME_OVERRIDES = {
966
+ retrieve: `${P().publisher.id}_search`,
967
+ // the published retrieval contract
968
+ "documents.family": `${P().publisher.id}_documents`
969
+ // the published registry contract
970
+ };
971
+ function mcpName(registryName2) {
972
+ return MCP_NAME_OVERRIDES[registryName2] ?? `${mcpPrefix()}${registryName2.replace(/\./g, "_")}`;
973
+ }
974
+ function registryName(presented) {
975
+ const pid = P().publisher.id;
976
+ if (presented === `${pid}_ask`) return "ask";
977
+ for (const [reg, mc] of Object.entries(MCP_NAME_OVERRIDES)) if (mc === presented) return reg;
978
+ if (presented.startsWith(`${pid}_`)) {
979
+ const rest = presented.slice(pid.length + 1);
980
+ const hit = TOOLS_REGISTRY.find((t) => t.audiences.includes("mcp") && t.name.replace(/\./g, "_") === rest);
981
+ if (hit) return hit.name;
982
+ }
983
+ return presented;
984
+ }
962
985
  var TOOLS = [
963
986
  {
964
- name: "ask",
987
+ name: mcpName("ask"),
965
988
  description: "Ask the corpus a question; returns a citation-grounded answer with the passages it rests on.",
966
989
  inputSchema: {
967
990
  type: "object",
@@ -973,7 +996,7 @@ var TOOLS = [
973
996
  }
974
997
  },
975
998
  {
976
- name: "retrieve",
999
+ name: mcpName("retrieve"),
977
1000
  description: "Retrieve the top passages for a query (hybrid dense + metadata steering, reranked).",
978
1001
  inputSchema: {
979
1002
  type: "object",
@@ -985,7 +1008,7 @@ var TOOLS = [
985
1008
  }
986
1009
  },
987
1010
  ...TOOLS_REGISTRY.filter((t) => t.audiences.includes("mcp")).map((t) => ({
988
- name: t.name,
1011
+ name: mcpName(t.name),
989
1012
  description: t.description,
990
1013
  inputSchema: {
991
1014
  type: "object",
@@ -1008,7 +1031,7 @@ function dispatch(method, params, callTool) {
1008
1031
  if (!TOOLS.some((t) => t.name === name)) {
1009
1032
  return Promise.resolve({ ok: false, code: -32602, message: `unknown tool: ${name}` });
1010
1033
  }
1011
- return callTool(name, params?.arguments ?? {}).then(
1034
+ return callTool(registryName(name), params?.arguments ?? {}).then(
1012
1035
  (payload) => ({ ok: true, result: { content: [{ type: "text", text: JSON.stringify(payload) }] } })
1013
1036
  );
1014
1037
  }
@@ -1046,13 +1069,13 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
1046
1069
  // stream:false forces the JSON lane (anon defaults to SSE)
1047
1070
  body: JSON.stringify({ ...args, stream: false })
1048
1071
  });
1049
- const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-O4JYGVSR.js");
1072
+ const { runTool, TOOLS_REGISTRY: TOOLS_REGISTRY2 } = await import("../../tools-EZRLYLTM.js");
1050
1073
  let payload;
1051
1074
  if (TOOLS_REGISTRY2.some((t) => t.name === name && t.audiences.includes("mcp"))) {
1052
1075
  const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
1053
1076
  payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
1054
1077
  } else {
1055
- const res = name === "ask" ? await (await import("../../ask-N5I35VCK.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-LFS5HHMZ.js")).handleSearch(env, ctx, inner, tier, key);
1078
+ const res = name === "ask" ? await (await import("../../ask-UL5NL5O7.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-VEGQBEFC.js")).handleSearch(env, ctx, inner, tier, key);
1056
1079
  payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1057
1080
  }
1058
1081
  telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.136",
30
+ "version": "0.2.138",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -1426,28 +1426,52 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1426
1426
  // the tool call — one reader, one tool, no parallel extraction racing a
1427
1427
  // second vision pass that drifts hour to hour. The old probe dropped the
1428
1428
  // image-bearing message, which is why the private bridge had to exist.
1429
- if (multiTurn && /certificate|certified|certification/i.test(q.query) && env.DB) {
1429
+ if (multiTurn && env.DB) {
1430
1430
  try {
1431
1431
  const imageTurn = typeof withImage === "string";
1432
1432
  if (imageTurn) mark("Reading the photograph to identify the instrument");
1433
- const probeMessages: any[] = [...messages.slice(0, -1), { role: "user", content: `${TOOL_DECLARATION}\n\n${imageTurn ? "A photograph of the instrument is attached after this message. Transcribe the maker, model and any printed certificate number you can read FROM THE PHOTOGRAPH ITSELF, then emit the TOOL line with those tokens as the query — never invented, and never tokens taken from any register content elsewhere in the context (a register row's holder is not the plate's maker; observed live 2026-10-04)." : ""}\n\n${q.query}`.replace("\n\n\n\n", "\n\n") }];
1434
- if (imageTurn) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
1435
- const probe = await (env as any).AI.run(model, {
1436
- messages: probeMessages,
1437
- max_tokens: 600,
1438
- reasoning_effort: imageTurn ? "high" : "low",
1439
- });
1440
- const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
1441
- probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
1442
- const call = parseToolCall(probeText);
1443
- if (call) {
1444
- mark(`Searching the certificate register for “${String(call.args?.query ?? "")}”`);
1433
+ // the loop v2 (the owner's 2026-10-12 direction): the MODEL decides
1434
+ // whether and what to query — no intent regex arms it. Three
1435
+ // bounded rounds; each result joins the messages ATTRIBUTED and the
1436
+ // next probe re-reads them (the model composes db.find predicates
1437
+ // over the published schema and corrects from schema-named
1438
+ // errors). Cross-turn memory: the conversation history above
1439
+ // already carries the prior answers (which quote their tool
1440
+ // results) — a follow-up reuses them instead of re-querying.
1441
+ // Native function-calling rides alongside the line protocol: when
1442
+ // the binding returns tool_calls, they dispatch identically.
1443
+ for (let round = 0; round < 3; round++) {
1444
+ const imageNote = imageTurn && round === 0
1445
+ ? "A photograph of the instrument is attached after this message. Transcribe the maker, model and any printed certificate number you can read FROM THE PHOTOGRAPH ITSELF, then emit the TOOL line with those tokens as the query — never invented, and never tokens taken from any register content elsewhere in the context (a register row's holder is not the plate's maker; observed live 2026-10-04)."
1446
+ : "";
1447
+ const continueNote = round > 0
1448
+ ? "The tool results above are in hand. Answer now without a TOOL line, or issue exactly one more TOOL line if one more query is genuinely needed."
1449
+ : "";
1450
+ const probeMessages: any[] = [...messages.slice(0, -1), { role: "user", content: [TOOL_DECLARATION, imageNote, continueNote, q.query].filter(Boolean).join("\n\n") }];
1451
+ if (imageTurn && round === 0) probeMessages.push({ role: "user", content: [{ type: "image_url", image_url: { url: withImage } }] });
1452
+ const probe = await (env as any).AI.run(model, {
1453
+ messages: probeMessages,
1454
+ max_tokens: 700,
1455
+ reasoning_effort: imageTurn && round === 0 ? "high" : "low",
1456
+ });
1457
+ const probeText = typeof probe?.response === "string" ? probe?.response : probe?.choices?.[0]?.message?.content ?? "";
1458
+ probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
1459
+ const nativeCall = Array.isArray((probe as any)?.tool_calls) ? (probe as any).tool_calls[0]?.function : null;
1460
+ const call = nativeCall
1461
+ ? {
1462
+ name: String(nativeCall.name ?? ""),
1463
+ args: (() => {
1464
+ try { return typeof nativeCall.arguments === "string" ? JSON.parse(nativeCall.arguments) : (nativeCall.arguments ?? {}); } catch { return {}; }
1465
+ })(),
1466
+ }
1467
+ : parseToolCall(probeText);
1468
+ if (!call || !call.name) break; // the model chose to answer
1445
1469
  const result = await runTool(env, call);
1446
- if (result) {
1447
- messages.push({ role: "system", content: toolNote(result) });
1448
- pendingTools.push(result);
1449
- console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
1450
- }
1470
+ if (!result) break;
1471
+ mark(`${result.name} — ${result.query}`.slice(0, 140));
1472
+ messages.push({ role: "system", content: toolNote(result) });
1473
+ pendingTools.push(result);
1474
+ console.log("agent-tool:", result.name, JSON.stringify(result.query), "->", result.output.slice(0, 80));
1451
1475
  }
1452
1476
  } catch {
1453
1477
  /* the loop is optional — the answer proceeds without the tool */
@@ -0,0 +1,343 @@
1
+ // The data-plane grammar (the owner's 2026-10-12 direction, "the model
2
+ // decides what and how to look for data"): instead of pre-enumerated
3
+ // operation menus, the agent gets the PUBLISHED SCHEMA and composes
4
+ // typed predicates over it. db.schema answers "what is queryable";
5
+ // db.find answers the question. The engine compiles predicates to
6
+ // parameterized SQL — the model never writes SQL, identifiers never
7
+ // interpolate from model input, values always bind. BIRD-INTERACT
8
+ // (2026) grounding: interactive data questions collapse without schema
9
+ // linkage, so every validation error NAMES the valid columns.
10
+
11
+ export interface CatalogColumn {
12
+ name: string;
13
+ type: "text" | "number";
14
+ description: string;
15
+ /** ordering/comparison compiles CAST(col AS INTEGER) — textual columns
16
+ * carrying numeric content (certificates.year is TEXT in D1) */
17
+ numeric_cast?: boolean;
18
+ /** excluded from the default select (heavy bodies); still selectable by name */
19
+ wide?: boolean;
20
+ }
21
+
22
+ export interface CatalogTable {
23
+ name: string;
24
+ description: string;
25
+ columns: CatalogColumn[];
26
+ default_select: string[];
27
+ }
28
+
29
+ // PUBLISHED tables only — public corpus data. Everything holding user
30
+ // or operational state (api_keys, queries, spend, feedback, memories,
31
+ // projects, project_files, conversations, messages, shared_conversations,
32
+ // conversation_entities, attachments, model_plane_meta, ask retries and
33
+ // usage caches) is deliberately absent: db.find cannot reach it.
34
+ export const DB_CATALOG: CatalogTable[] = [
35
+ {
36
+ name: "certificates",
37
+ description:
38
+ "The certificate register (a snapshot, not live certification status): one row per issued certificate across the recommendation families.",
39
+ columns: [
40
+ { name: "num", type: "text", description: "the printed certificate number, e.g. R60/2021-A-NL1-26.12 (family/prefix per recommendation)" },
41
+ { name: "family", type: "text", description: "the recommendation family number, e.g. '60' for R 60" },
42
+ { name: "holder", type: "text", description: "the certificate holder (manufacturer)" },
43
+ { name: "model", type: "text", description: "the instrument model designation" },
44
+ { name: "year", type: "number", description: "the issue year", numeric_cast: true },
45
+ { name: "status", type: "text", description: "Valid | Suspended | Withdrawn" },
46
+ { name: "pdf_key", type: "text", description: "the /cert-pdf path suffix when the PDF is on file, else empty" },
47
+ ],
48
+ default_select: ["num", "family", "holder", "model", "year", "status", "pdf_key"],
49
+ },
50
+ {
51
+ name: "documents",
52
+ description:
53
+ "The document identity registry: every publication edition with its family, part, and derived edition status (an edition with a successor is superseded regardless of any copied status field).",
54
+ columns: [
55
+ { name: "canonical_id", type: "text", description: "doc:<family>-<edition> slug" },
56
+ { name: "docidentifier", type: "text", description: "the full identifier with edition" },
57
+ { name: "family", type: "text", description: "the family key, e.g. R-60" },
58
+ { name: "part", type: "text", description: "1 | 2 | 3 | A | annexes | sup | empty" },
59
+ { name: "edition", type: "number", description: "the edition year", numeric_cast: true },
60
+ { name: "status", type: "text", description: "in-force | superseded | withdrawn (as recorded)" },
61
+ { name: "derived_status", type: "text", description: "the successor-edge derivation result" },
62
+ { name: "active", type: "number", description: "1 = the active edition of its family+part" },
63
+ { name: "superseded_by", type: "text", description: "the successor's canonical_id, else empty" },
64
+ { name: "title", type: "text", description: "the publication title" },
65
+ ],
66
+ default_select: ["canonical_id", "docidentifier", "family", "part", "edition", "derived_status", "active", "superseded_by", "title"],
67
+ },
68
+ {
69
+ name: "graph_nodes",
70
+ description: "The public citation-graph nodes: documents, families, and vocabulary concepts.",
71
+ columns: [
72
+ { name: "id", type: "text", description: "doc:<identifier-slug> | family:<number> | concept:<id>" },
73
+ { name: "kind", type: "text", description: "doc | family | concept" },
74
+ { name: "label", type: "text", description: "the display label" },
75
+ ],
76
+ default_select: ["id", "kind", "label"],
77
+ },
78
+ {
79
+ name: "graph_edges",
80
+ description: "The public citation-graph edges between nodes.",
81
+ columns: [
82
+ { name: "src", type: "text", description: "the source node id" },
83
+ { name: "dst", type: "text", description: "the destination node id" },
84
+ { name: "kind", type: "text", description: "part_of | variant_of | successor | amends | defines" },
85
+ ],
86
+ default_select: ["src", "kind", "dst"],
87
+ },
88
+ {
89
+ name: "model_nodes",
90
+ description:
91
+ "The Recommendation model plane (per-standard requirement/conformance graphs): the machine-readable nodes behind the verdict engine.",
92
+ columns: [
93
+ { name: "standard", type: "text", description: "the standard key, e.g. r60" },
94
+ { name: "node_id", type: "text", description: "/req/metrological/mpe" },
95
+ { name: "kind", type: "text", description: "requirement | conformance_test | term | constraint | characteristic | state_machine | dimension" },
96
+ { name: "name", type: "text", description: "the node's display name" },
97
+ { name: "clause_doc", type: "text", description: "the provenance document urn" },
98
+ { name: "clause_ref", type: "text", description: "the clause inside it, e.g. 5.3.2" },
99
+ { name: "content", type: "text", description: "the node's JSON projection (the checks, thresholds, parameters)", wide: true },
100
+ { name: "content_hash", type: "text", description: "sha256 of content" },
101
+ ],
102
+ default_select: ["standard", "node_id", "kind", "name", "clause_doc", "clause_ref"],
103
+ },
104
+ {
105
+ name: "unit_payloads",
106
+ description: "Typed retrievable units (tables, formulas, figures, terms) with their producer payloads.",
107
+ columns: [
108
+ { name: "unit_id", type: "text", description: "u:table-1" },
109
+ { name: "doc_id", type: "text", description: "the owning document id" },
110
+ { name: "docidentifier", type: "text", description: "the publication identifier with part" },
111
+ { name: "edition", type: "text", description: "the edition year" },
112
+ { name: "clause_anchor", type: "text", description: "the clause the unit lives in" },
113
+ { name: "type", type: "text", description: "table | formula | figure | term | requirement" },
114
+ { name: "payload", type: "text", description: "the producer payload JSON (the table's rows, the formula's math)", wide: true },
115
+ ],
116
+ default_select: ["unit_id", "doc_id", "docidentifier", "edition", "clause_anchor", "type"],
117
+ },
118
+ {
119
+ name: "chunks",
120
+ description:
121
+ "The indexed corpus chunks (one row per clause-level passage) — use for exact metadata questions; semantic search is NOT this tool's job.",
122
+ columns: [
123
+ { name: "id", type: "text", description: "the chunk id" },
124
+ { name: "doc_id", type: "text", description: "the source document slug" },
125
+ { name: "docidentifier", type: "text", description: "the publication identifier with part" },
126
+ { name: "doctype", type: "text", description: "R | D | B | G | E" },
127
+ { name: "doc_number", type: "text", description: "60" },
128
+ { name: "edition", type: "text", description: "the edition year" },
129
+ { name: "language", type: "text", description: "en | fr | …" },
130
+ { name: "clause_anchor", type: "text", description: "the clause anchor" },
131
+ { name: "clause_title", type: "text", description: "the clause title" },
132
+ { name: "status", type: "text", description: "in-force | superseded" },
133
+ { name: "superseded_by", type: "text", description: "the superseding docidentifier, else empty" },
134
+ { name: "corpus", type: "text", description: "clean | dirty" },
135
+ { name: "tier", type: "text", description: "the quality tier" },
136
+ { name: "text", type: "text", description: "the passage body (wide — select only when needed)", wide: true },
137
+ { name: "fts_text", type: "text", description: "context preamble + body (wide)", wide: true },
138
+ ],
139
+ default_select: ["id", "doc_id", "docidentifier", "doctype", "doc_number", "edition", "language", "clause_anchor", "clause_title", "status", "corpus"],
140
+ },
141
+ ];
142
+
143
+ export function publishedTable(name: string): CatalogTable | undefined {
144
+ return DB_CATALOG.find((t) => t.name === name);
145
+ }
146
+
147
+ // ── the predicate compiler (pure; unit-tested) ────────────────────────
148
+
149
+ export const FIND_OPS = ["eq", "ne", "lt", "le", "gt", "ge", "in", "like", "between"] as const;
150
+ export type FindOp = (typeof FIND_OPS)[number];
151
+
152
+ export interface FindPredicate {
153
+ col: string;
154
+ op: FindOp;
155
+ value: unknown;
156
+ }
157
+
158
+ export interface FindArgs {
159
+ table: string;
160
+ where?: FindPredicate[];
161
+ select?: string[];
162
+ order_by?: { col: string; dir?: "asc" | "desc" };
163
+ limit?: number;
164
+ group_by?: string[];
165
+ agg?: { fn: "count" | "sum" | "avg" | "min" | "max"; col?: string };
166
+ }
167
+
168
+ export type Compiled =
169
+ | { ok: true; sql: string; params: unknown[]; echo: string }
170
+ | { ok: false; error: string };
171
+
172
+ const MAX_LIMIT = 25;
173
+ const DEFAULT_LIMIT = 10;
174
+
175
+ const NUMERIC_OPS: FindOp[] = ["lt", "le", "gt", "ge", "between"];
176
+
177
+ /** Compile validated identifiers + bound parameters. Returns a
178
+ * schema-linked error (naming the valid columns) on any mismatch —
179
+ * the model corrects from the error, the loop continues. */
180
+ export function compileFind(args: FindArgs): Compiled {
181
+ const table = publishedTable(String(args?.table ?? ""));
182
+ if (!table) {
183
+ return {
184
+ ok: false,
185
+ error: `unknown table "${String(args?.table ?? "")}". Published tables: ${DB_CATALOG.map((t) => t.name).join(", ")}.`,
186
+ };
187
+ }
188
+ const colNames = table.columns.map((c) => c.name);
189
+ const colByName = new Map(table.columns.map((c) => [c.name, c]));
190
+ const params: unknown[] = [];
191
+ const phrases: string[] = [];
192
+ const p = (n: number) => `?${n}`;
193
+
194
+ const colRef = (col: string): string => {
195
+ const c = colByName.get(col)!;
196
+ return c.numeric_cast ? `CAST(${col} AS INTEGER)` : col;
197
+ };
198
+
199
+ for (const w of Array.isArray(args?.where) ? args.where : []) {
200
+ const col = String(w?.col ?? "");
201
+ const colDef = colByName.get(col);
202
+ if (!colDef) {
203
+ return {
204
+ ok: false,
205
+ error: `table "${table.name}" has no column "${col}". Valid columns: ${colNames.join(", ")}.`,
206
+ };
207
+ }
208
+ const op = String(w?.op ?? "") as FindOp;
209
+ if (!FIND_OPS.includes(op)) {
210
+ return { ok: false, error: `unknown op "${op}". Valid ops: ${FIND_OPS.join(", ")}.` };
211
+ }
212
+ const v = w?.value;
213
+ if (NUMERIC_OPS.includes(op) && !colDef.numeric_cast && colDef.type !== "number") {
214
+ return { ok: false, error: `op "${op}" needs a numeric column; "${col}" is ${colDef.type}.` };
215
+ }
216
+ const ref = colRef(col);
217
+ if (op === "in") {
218
+ const list = Array.isArray(v) ? v.map((x) => String(x)) : [String(v)];
219
+ if (!list.length) return { ok: false, error: `op "in" needs a non-empty value list.` };
220
+ const holders = list.map((x) => p(params.push(x))).join(", ");
221
+ phrases.push(`${ref} IN (${holders})`);
222
+ } else if (op === "between") {
223
+ const pair = Array.isArray(v) ? v : [undefined, undefined];
224
+ if (pair.length !== 2) return { ok: false, error: `op "between" needs a two-element value [lo, hi].` };
225
+ phrases.push(`${ref} BETWEEN ${p(params.push(pair[0]))} AND ${p(params.push(pair[1]))}`);
226
+ } else if (op === "like") {
227
+ phrases.push(`${ref} LIKE ${p(params.push(String(v)))}`);
228
+ } else {
229
+ const sqlOp = { eq: "=", ne: "!=", lt: "<", le: "<=", gt: ">", ge: ">=" }[op as "eq" | "ne" | "lt" | "le" | "gt" | "ge"];
230
+ phrases.push(`${ref} ${sqlOp} ${p(params.push(v))}`);
231
+ }
232
+ }
233
+
234
+ const select = (Array.isArray(args?.select) && args.select.length ? args.select : table.default_select).map(String);
235
+ for (const s of select) {
236
+ if (!colNames.includes(s)) {
237
+ return { ok: false, error: `table "${table.name}" has no column "${s}". Valid columns: ${colNames.join(", ")}.` };
238
+ }
239
+ }
240
+
241
+ let head: string;
242
+ if (args?.agg?.fn) {
243
+ const fn = String(args.agg.fn) as "count" | "sum" | "avg" | "min" | "max";
244
+ if (!["count", "sum", "avg", "min", "max"].includes(fn)) {
245
+ return { ok: false, error: `unknown agg fn "${fn}". Valid: count, sum, avg, min, max.` };
246
+ }
247
+ let aggExpr = "COUNT(*)";
248
+ if (fn !== "count") {
249
+ const acol = String(args.agg.col ?? "");
250
+ const adef = colByName.get(acol);
251
+ if (!adef) return { ok: false, error: `agg column "${acol}" is not a column of "${table.name}". Valid columns: ${colNames.join(", ")}.` };
252
+ aggExpr = `${fn.toUpperCase()}(${colRef(acol)})`;
253
+ }
254
+ head = `SELECT ${args?.group_by?.length ? `${(args.group_by as string[]).map(String).join(", ")}, ` : ""}${aggExpr} AS agg FROM ${table.name}`;
255
+ for (const g of args?.group_by ?? []) {
256
+ if (!colNames.includes(String(g))) {
257
+ return { ok: false, error: `table "${table.name}" has no column "${String(g)}". Valid columns: ${colNames.join(", ")}.` };
258
+ }
259
+ }
260
+ } else {
261
+ head = `SELECT ${select.join(", ")} FROM ${table.name}`;
262
+ }
263
+
264
+ const where = phrases.length ? ` WHERE ${phrases.join(" AND ")}` : "";
265
+ let tail = "";
266
+ if (args?.group_by?.length) tail += ` GROUP BY ${(args.group_by as string[]).map(String).join(", ")}`;
267
+ if (args?.order_by?.col) {
268
+ const ocol = String(args.order_by.col);
269
+ if (!colNames.includes(ocol)) {
270
+ return { ok: false, error: `table "${table.name}" has no column "${ocol}". Valid columns: ${colNames.join(", ")}.` };
271
+ }
272
+ const dir = String(args.order_by.dir ?? "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
273
+ tail += ` ORDER BY ${colRef(ocol)} ${dir}`;
274
+ }
275
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Number(args?.limit ?? DEFAULT_LIMIT) || DEFAULT_LIMIT));
276
+ tail += ` LIMIT ${limit}`;
277
+
278
+ const echo = [
279
+ `db.find ${table.name}`,
280
+ args?.where?.length ? `where ${args.where.map((w) => `${w.col} ${w.op} ${JSON.stringify(w.value ?? null)}`).join(" AND ")}` : "all rows",
281
+ args?.agg?.fn ? `${args.agg.fn}${args.agg.col ? ` ${args.agg.col}` : ""}` : "",
282
+ ]
283
+ .filter(Boolean)
284
+ .join(" · ");
285
+
286
+ return {
287
+ ok: true,
288
+ sql: `${head}${where}${tail}`,
289
+ params,
290
+ echo,
291
+ };
292
+ }
293
+
294
+ /** The db.find handler: compile, run on D1, format deterministically.
295
+ * Every output carries the predicate echo — the answer attributes the
296
+ * rows to the query that produced them. */
297
+ export async function runFind(env: any, args: FindArgs): Promise<{ name: string; query: string; output: string } | null> {
298
+ const compiled = compileFind(args);
299
+ if (!compiled.ok) {
300
+ return {
301
+ name: "db.find",
302
+ query: String(args?.table ?? ""),
303
+ output: `The query was rejected: ${compiled.error} Correct the predicates and call again.`,
304
+ };
305
+ }
306
+ try {
307
+ const { portStore } = await import("./env.ts");
308
+ const res = await portStore(env).prepare(compiled.sql).bind(...compiled.params).all();
309
+ const rows = (res?.results ?? []) as Record<string, unknown>[];
310
+ const body = rows.length
311
+ ? rows.map((r) => JSON.stringify(r)).join("\n")
312
+ : "No rows matched. Either the predicates are too narrow or the data is absent — state which, and consider widening.";
313
+ const aggNote = args?.agg?.fn ? " (aggregate)" : "";
314
+ return { name: "db.find", query: compiled.echo, output: `${compiled.echo}${aggNote} → ${rows.length} row(s):\n${body}` };
315
+ } catch (e: any) {
316
+ return { name: "db.find", query: compiled.echo, output: `The query failed to run: ${String(e?.message ?? e).slice(0, 200)}` };
317
+ }
318
+ }
319
+
320
+ /** The db.schema handler: the published map, one table or the catalog. */
321
+ export function runSchema(table?: string): { name: string; query: string; output: string } {
322
+ const tables = table ? [publishedTable(String(table))].filter(Boolean) : DB_CATALOG;
323
+ if (!tables.length) {
324
+ return {
325
+ name: "db.schema",
326
+ query: String(table),
327
+ output: `Unknown table "${table}". Published tables: ${DB_CATALOG.map((t) => t.name).join(", ")}.`,
328
+ };
329
+ }
330
+ const output = (tables as CatalogTable[])
331
+ .map((t) => {
332
+ const cols = t.columns
333
+ .map((c) => ` ${c.name} (${c.type}${c.numeric_cast ? ", numeric-cast" : ""}${c.wide ? ", wide" : ""}) — ${c.description}`)
334
+ .join("\n");
335
+ return `${t.name} — ${t.description}\n${cols}\n default select: ${t.default_select.join(", ")}`;
336
+ })
337
+ .join("\n\n");
338
+ return {
339
+ name: "db.schema",
340
+ query: table ? String(table) : "catalog",
341
+ output: `Published tables (compose TOOL db.find with where: [{col, op, value}]; ops: ${FIND_OPS.join(" ")}; values bind safely; like accepts % and _ wildcards):\n\n${output}`,
342
+ };
343
+ }
@@ -2,8 +2,40 @@
2
2
  // dependency-free so plain node can load it for unit tests — the
3
3
  // route adapter (mcp.ts) owns the handler wiring.
4
4
  import { TOOLS_REGISTRY } from "./tools.ts";
5
+ import { P } from "./profile.ts";
5
6
  export const PROTOCOL_VERSION = "2025-06-18";
6
7
 
8
+ // The MCP naming convention (2026-10-12): every tool the publisher
9
+ // exposes over MCP carries the publisher prefix — MCP clients render
10
+ // tool names in a SHARED namespace, so generic names ("ask") collide
11
+ // across servers. The three names docs/MCP.md already publishes keep
12
+ // their exact spelling; the mechanical transform covers the rest.
13
+ const mcpPrefix = () => `${P().publisher.id}_`;
14
+ const MCP_NAME_OVERRIDES: Record<string, string> = {
15
+ retrieve: `${P().publisher.id}_search`, // the published retrieval contract
16
+ "documents.family": `${P().publisher.id}_documents`, // the published registry contract
17
+ };
18
+
19
+ /** Registry tool name → the MCP-presented name. */
20
+ export function mcpName(registryName: string): string {
21
+ return MCP_NAME_OVERRIDES[registryName] ?? `${mcpPrefix()}${registryName.replace(/\./g, "_")}`;
22
+ }
23
+
24
+ /** MCP-presented name → the name the handlers dispatch on. Raw
25
+ * registry names pass through (transition tolerance for early
26
+ * consumers of the unprefixed surface). */
27
+ export function registryName(presented: string): string {
28
+ const pid = P().publisher.id;
29
+ if (presented === `${pid}_ask`) return "ask";
30
+ for (const [reg, mc] of Object.entries(MCP_NAME_OVERRIDES)) if (mc === presented) return reg;
31
+ if (presented.startsWith(`${pid}_`)) {
32
+ const rest = presented.slice(pid.length + 1);
33
+ const hit = TOOLS_REGISTRY.find((t) => t.audiences.includes("mcp") && t.name.replace(/\./g, "_") === rest);
34
+ if (hit) return hit.name;
35
+ }
36
+ return presented;
37
+ }
38
+
7
39
  export interface McpTool {
8
40
  name: string;
9
41
  description: string;
@@ -14,7 +46,7 @@ export interface McpTool {
14
46
  // tool registers it; this list is never hand-extended for them)
15
47
  export const TOOLS: McpTool[] = [
16
48
  {
17
- name: "ask",
49
+ name: mcpName("ask"),
18
50
  description: "Ask the corpus a question; returns a citation-grounded answer with the passages it rests on.",
19
51
  inputSchema: {
20
52
  type: "object",
@@ -26,7 +58,7 @@ export const TOOLS: McpTool[] = [
26
58
  },
27
59
  },
28
60
  {
29
- name: "retrieve",
61
+ name: mcpName("retrieve"),
30
62
  description: "Retrieve the top passages for a query (hybrid dense + metadata steering, reranked).",
31
63
  inputSchema: {
32
64
  type: "object",
@@ -40,7 +72,7 @@ export const TOOLS: McpTool[] = [
40
72
  ...TOOLS_REGISTRY
41
73
  .filter((t) => t.audiences.includes("mcp"))
42
74
  .map((t): McpTool => ({
43
- name: t.name,
75
+ name: mcpName(t.name),
44
76
  description: t.description,
45
77
  inputSchema: {
46
78
  type: "object",
@@ -75,7 +107,7 @@ export function dispatch(
75
107
  if (!TOOLS.some((t) => t.name === name)) {
76
108
  return Promise.resolve({ ok: false, code: -32602, message: `unknown tool: ${name}` });
77
109
  }
78
- return callTool(name, params?.arguments ?? {}).then(
110
+ return callTool(registryName(name), params?.arguments ?? {}).then(
79
111
  (payload) => ({ ok: true, result: { content: [{ type: "text", text: JSON.stringify(payload) }] } }) as McpResult,
80
112
  );
81
113
  }