@malloy-publisher/server 0.0.231 → 0.0.233

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.
Files changed (101) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +135 -13
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
  14. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  15. package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
  16. package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
  17. package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
  18. package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +22627 -1260
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/controller/query.controller.ts +26 -21
  32. package/src/errors.ts +19 -0
  33. package/src/json_utils.spec.ts +51 -0
  34. package/src/json_utils.ts +33 -0
  35. package/src/logger.spec.ts +18 -1
  36. package/src/logger.ts +3 -1
  37. package/src/materialization_metrics.spec.ts +89 -4
  38. package/src/materialization_metrics.ts +155 -5
  39. package/src/mcp/query_envelope.spec.ts +229 -0
  40. package/src/mcp/query_envelope.ts +230 -0
  41. package/src/mcp/server.protocol.spec.ts +128 -16
  42. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  43. package/src/mcp/skills/skills_bundle.json +1 -1
  44. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  45. package/src/mcp/tool_response.spec.ts +108 -0
  46. package/src/mcp/tool_response.ts +138 -0
  47. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  48. package/src/mcp/tools/compile_tool.ts +61 -30
  49. package/src/mcp/tools/docs_search_tool.ts +6 -16
  50. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  51. package/src/mcp/tools/embedding_index.ts +808 -0
  52. package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
  53. package/src/mcp/tools/execute_query_tool.ts +90 -151
  54. package/src/mcp/tools/get_context_eval.ts +194 -45
  55. package/src/mcp/tools/get_context_tool.spec.ts +358 -5
  56. package/src/mcp/tools/get_context_tool.ts +202 -56
  57. package/src/mcp/tools/reload_package_tool.ts +3 -29
  58. package/src/pg_helpers.spec.ts +201 -0
  59. package/src/pg_helpers.ts +44 -5
  60. package/src/server.ts +24 -0
  61. package/src/service/build_plan.spec.ts +128 -2
  62. package/src/service/build_plan.ts +239 -17
  63. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  64. package/src/service/connection.spec.ts +371 -1
  65. package/src/service/connection.ts +339 -20
  66. package/src/service/connection_config.spec.ts +108 -0
  67. package/src/service/connection_config.ts +47 -8
  68. package/src/service/connection_federation.spec.ts +184 -0
  69. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  70. package/src/service/embedding_provider.spec.ts +329 -0
  71. package/src/service/embedding_provider.ts +236 -0
  72. package/src/service/environment.ts +274 -12
  73. package/src/service/environment_store.spec.ts +678 -3
  74. package/src/service/environment_store.ts +449 -33
  75. package/src/service/environment_store_clone.spec.ts +350 -0
  76. package/src/service/manifest_loader.spec.ts +68 -13
  77. package/src/service/manifest_loader.ts +67 -19
  78. package/src/service/materialization_build_session.spec.ts +435 -0
  79. package/src/service/materialization_build_session.ts +681 -0
  80. package/src/service/materialization_eligibility.spec.ts +158 -0
  81. package/src/service/materialization_eligibility.ts +305 -0
  82. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  83. package/src/service/materialization_serve_transform.ts +779 -0
  84. package/src/service/materialization_service.spec.ts +774 -7
  85. package/src/service/materialization_service.ts +1107 -42
  86. package/src/service/materialization_test_fixtures.ts +7 -0
  87. package/src/service/model.spec.ts +207 -0
  88. package/src/service/model.ts +569 -59
  89. package/src/service/model_limits.spec.ts +28 -0
  90. package/src/service/model_limits.ts +21 -0
  91. package/src/service/model_storage_serve.spec.ts +193 -0
  92. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  93. package/src/service/package.spec.ts +196 -0
  94. package/src/service/package.ts +385 -17
  95. package/src/service/persistence_policy.spec.ts +109 -0
  96. package/src/storage/duckdb/schema.ts +37 -0
  97. package/tests/fixtures/xlsx/database.xlsx +0 -0
  98. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  99. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  100. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
  101. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -3,8 +3,15 @@ import { z } from "zod";
3
3
  import lunr from "lunr";
4
4
  import { EnvironmentStore } from "../../service/environment_store";
5
5
  import { Package } from "../../service/package";
6
- import { buildMalloyUri } from "../handler_utils";
6
+ import {
7
+ EmbeddingProvider,
8
+ embeddingConfigured,
9
+ getEmbeddingProvider,
10
+ } from "../../service/embedding_provider";
11
+ import { buildMalloyUri, classifyToolError } from "../handler_utils";
12
+ import { jsonResource, jsonToolError } from "../tool_response";
7
13
  import { logger } from "../../logger";
14
+ import { entityRowKey, trySemanticSearch } from "./embedding_index";
8
15
 
9
16
  /**
10
17
  * A retrievable model entity: a source, one of its views, a field (dimension or
@@ -18,7 +25,23 @@ interface Entity {
18
25
  name: string;
19
26
  source: string | undefined;
20
27
  modelPath: string;
28
+ // Human-facing doc for the response (may fall back to raw annotations).
21
29
  doc: string;
30
+ // #(doc)-only text used as embedding input; never carries predicate
31
+ // annotations (#(authorize) etc.) that must not leave the machine.
32
+ embedDoc: string;
33
+ }
34
+
35
+ /** One tier-4 result. `score` (cosine) rides only on semantic results. */
36
+ interface ResultEntity {
37
+ kind: string;
38
+ name: string;
39
+ source: string | undefined;
40
+ environmentName: string;
41
+ packageName: string;
42
+ modelPath: string;
43
+ doc: string;
44
+ score?: number;
22
45
  }
23
46
 
24
47
  const getContextShape = {
@@ -62,16 +85,38 @@ type GetContextParams = z.infer<z.ZodObject<typeof getContextShape>>;
62
85
  * SourceInfo sources/fields carry Annotation objects ({ value }); named queries
63
86
  * carry raw strings, so accept both.
64
87
  */
65
- export function docText(
88
+ /**
89
+ * Extract ONLY `#(doc)` annotation text, empty when there is none. This is
90
+ * the safe input for embedding: unlike docText it never falls back to the
91
+ * raw annotation lines, so predicate-bearing annotations (`#(authorize)`
92
+ * row-level-security rules, tenant lists, `#(malloy)` internals) are never
93
+ * sent to an external embedding provider.
94
+ */
95
+ export function docOnlyText(
66
96
  annotations?: Array<string | { value: string }>,
67
97
  ): string {
68
98
  if (!annotations || annotations.length === 0) return "";
69
- const lines = annotations.map((a) => (typeof a === "string" ? a : a.value));
70
- const docs = lines
99
+ const docs = annotations
100
+ .map((a) => (typeof a === "string" ? a : a.value))
71
101
  .map((a) => a.match(/#\(doc\)\s*(.*)/)?.[1]?.trim() ?? "")
72
102
  .filter(Boolean);
73
- const chosen = docs.length > 0 ? docs : lines;
74
- return chosen.join(" ").replace(/\s+/g, " ").trim();
103
+ return docs.join(" ").replace(/\s+/g, " ").trim();
104
+ }
105
+
106
+ /**
107
+ * Human-facing doc text for the response `doc` field. Prefers `#(doc)`
108
+ * text and falls back to the raw annotation lines when there is none.
109
+ * Pre-existing lexical behaviour; NOT used as embedding input (see
110
+ * docOnlyText and Entity.embedDoc).
111
+ */
112
+ export function docText(
113
+ annotations?: Array<string | { value: string }>,
114
+ ): string {
115
+ const doc = docOnlyText(annotations);
116
+ if (doc) return doc;
117
+ if (!annotations || annotations.length === 0) return "";
118
+ const lines = annotations.map((a) => (typeof a === "string" ? a : a.value));
119
+ return lines.join(" ").replace(/\s+/g, " ").trim();
75
120
  }
76
121
 
77
122
  export function sanitize(query: string): string {
@@ -109,6 +154,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
109
154
  source: sourceName,
110
155
  modelPath,
111
156
  doc: docText(sourceInfo.annotations),
157
+ embedDoc: docOnlyText(sourceInfo.annotations),
112
158
  });
113
159
  for (const field of sourceInfo.schema.fields ?? []) {
114
160
  // v1 indexes the queryable surface: views and dimension/measure
@@ -127,6 +173,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
127
173
  source: sourceName,
128
174
  modelPath,
129
175
  doc: docText(field.annotations),
176
+ embedDoc: docOnlyText(field.annotations),
130
177
  });
131
178
  }
132
179
  }
@@ -140,6 +187,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
140
187
  source: query.sourceName,
141
188
  modelPath,
142
189
  doc: docText(query.annotations),
190
+ embedDoc: docOnlyText(query.annotations),
143
191
  });
144
192
  }
145
193
  }
@@ -149,7 +197,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
149
197
  // first occurrence per (kind, source, name).
150
198
  const seen = new Set<string>();
151
199
  return entities.filter((e) => {
152
- const key = `${e.kind}|${e.source ?? ""}|${e.name}`;
200
+ const key = entityRowKey(e.kind, e.source ?? "", e.name);
153
201
  if (seen.has(key)) return false;
154
202
  seen.add(key);
155
203
  return true;
@@ -157,6 +205,7 @@ async function collectEntities(pkg: Package): Promise<Entity[]> {
157
205
  }
158
206
 
159
207
  interface PackageIndex {
208
+ pkg: Package;
160
209
  byId: Map<string, Entity>;
161
210
  index: lunr.Index;
162
211
  entityCount: number;
@@ -197,7 +246,12 @@ async function getPackageIndex(
197
246
  });
198
247
  }
199
248
  });
200
- const built: PackageIndex = { byId, index, entityCount: entities.length };
249
+ const built: PackageIndex = {
250
+ pkg,
251
+ byId,
252
+ index,
253
+ entityCount: entities.length,
254
+ };
201
255
  indexCache.set(pkg, built);
202
256
  logger.debug("[MCP Tool getContext] Built and cached entity index", {
203
257
  packageName,
@@ -208,46 +262,45 @@ async function getPackageIndex(
208
262
 
209
263
  const GET_CONTEXT_DESCRIPTION = `Discover what a Publisher deployment exposes and retrieve the model entities most relevant to a plain-English question, so you can ground a query in what the model actually defines instead of guessing. This is the starting point when you do not yet know the environment, package, or model names.
210
264
 
211
- ## Progressive discovery
212
- Call it with as much as you know and omit the rest; it answers at the appropriate level:
213
- - No arguments: lists the available environments, each with its package names.
214
- - environmentName only: lists the packages in that environment, with descriptions.
215
- - environmentName + packageName: lists that package's sources.
216
- - environmentName + packageName + query: returns the sources, views, named queries, and dimension/measure fields most relevant to the question.
265
+ ## Contract rules
266
+ - Use the names it returns verbatim; never invent an environment, package, or entity that is not in the results.
267
+ - Start broad and narrow down: environments, then packages, then sources, then a query.
217
268
 
218
269
  ## Parameters
219
- - environmentName (optional): omit to list environments.
220
- - packageName (optional): omit, with environmentName set, to list packages.
221
- - query (optional): a plain-English description of what you need; omit, with environmentName and packageName set, to list the package's sources.
222
- - sourceName (optional): narrow retrieval to entities within one source (the drill-down phase).
223
- - limit (optional): cap the number of results (max 50). Retrieval defaults to 10; the listing tiers return all unless set.
270
+ All optional. Supply what you know and omit the rest; each combination answers at its own level.
271
+ - none: lists the environments, each with its package names.
272
+ - environmentName: lists that environment's packages, with descriptions.
273
+ - + packageName: lists that package's sources.
274
+ - + query: a plain-English description of what you need, returning the sources, views, named queries, and dimension/measure fields most relevant to it.
275
+ - sourceName: narrows retrieval to one source (the drill-down phase).
276
+ - limit: caps results (max 50). Retrieval defaults to 10; the listing levels return all unless set.
224
277
 
225
278
  ## Response
226
- A JSON object with a results array whose items carry a kind field. For retrieval, each entity has kind (source / view / query / dimension / measure), name, source, modelPath, and doc; environmentName, packageName, modelPath, and source map directly onto malloy_executeQuery parameters, and for a view or named query you pass its name as queryName with sourceName.
227
-
228
- ## Contract rules
229
- - Use the names verbatim; do not invent environments, packages, or entities not in the results.
230
- - Start broad and narrow down: list environments, then packages, then sources, then query.
279
+ A JSON object with a results array whose items carry a kind field. For retrieval, each entity has kind (source / view / query / dimension / measure), name, source, modelPath, and doc; environmentName, packageName, modelPath, and source map directly onto malloy_executeQuery parameters, and for a view or named query you pass its name as queryName with sourceName. When the server is configured with an embedding provider, retrieval is ranked by semantic similarity: the payload then carries a retrieval field ("semantic", or "lexical" when the provider is unavailable) and each semantic entity a score.
231
280
 
232
281
  ## Worked example
233
282
  { "environmentName": "examples", "packageName": "storefront", "query": "revenue by product category" }`;
234
283
 
235
284
  /**
236
- * Wrap a JSON payload in the MCP resource-content shape every tier of this tool
237
- * returns. isError marks a tool-level error (e.g. an unknown environment/package).
285
+ * Every tier of this tool answers with `results`, so an error keeps that key
286
+ * (empty) alongside `error`. Callers can read `results` unconditionally without
287
+ * branching on success first.
288
+ *
289
+ * Routed through classifyToolError for the same reason its three sibling tools
290
+ * are: it homes each error class to real remediation, so an unknown package
291
+ * says so instead of arriving as a bare message with no suggestions. It also
292
+ * replaces a per-site `error instanceof Error ? error.message : "Unknown
293
+ * error"`, which was the one path in this file that could produce exactly the
294
+ * unhelpful string this tool's callers reported.
238
295
  */
239
- function jsonResource(uri: string, payload: unknown, isError = false) {
240
- const content = [
296
+ function contextError(uri: string, identifier: string, error: unknown) {
297
+ return jsonToolError(
298
+ uri,
299
+ classifyToolError("getContext", identifier, error),
241
300
  {
242
- type: "resource" as const,
243
- resource: {
244
- type: "application/json",
245
- uri,
246
- text: JSON.stringify(payload),
247
- },
301
+ results: [],
248
302
  },
249
- ];
250
- return isError ? { isError: true, content } : { content };
303
+ );
251
304
  }
252
305
 
253
306
  /**
@@ -295,16 +348,17 @@ export function registerGetContextTool(
295
348
  results,
296
349
  });
297
350
  } catch (error) {
298
- const message =
299
- error instanceof Error ? error.message : "Unknown error";
300
351
  logger.warn(
301
352
  "[MCP Tool getContext] listing environments failed",
302
- { error: message },
353
+ {
354
+ error:
355
+ error instanceof Error ? error.message : String(error),
356
+ },
303
357
  );
304
- return jsonResource(
358
+ return contextError(
305
359
  buildMalloyUri({}, "get-context"),
306
- { error: message, results: [] },
307
- true,
360
+ "environments",
361
+ error,
308
362
  );
309
363
  }
310
364
  }
@@ -331,19 +385,17 @@ export function registerGetContextTool(
331
385
  { results },
332
386
  );
333
387
  } catch (error) {
334
- const message =
335
- error instanceof Error ? error.message : "Unknown error";
336
388
  logger.warn("[MCP Tool getContext] listing packages failed", {
337
389
  environmentName,
338
- error: message,
390
+ error: error instanceof Error ? error.message : String(error),
339
391
  });
340
- return jsonResource(
392
+ return contextError(
341
393
  buildMalloyUri(
342
394
  { environment: environmentName },
343
395
  "get-context",
344
396
  ),
345
- { error: message, results: [] },
346
- true,
397
+ environmentName,
398
+ error,
347
399
  );
348
400
  }
349
401
  }
@@ -357,21 +409,19 @@ export function registerGetContextTool(
357
409
  packageName,
358
410
  );
359
411
  } catch (error) {
360
- const message =
361
- error instanceof Error ? error.message : "Unknown error";
362
412
  logger.warn("[MCP Tool getContext] index build failed", {
363
413
  environmentName,
364
414
  packageName,
365
415
  sourceName,
366
- error: message,
416
+ error: error instanceof Error ? error.message : String(error),
367
417
  });
368
- return jsonResource(
418
+ return contextError(
369
419
  buildMalloyUri(
370
420
  { environment: environmentName, package: packageName },
371
421
  "get-context",
372
422
  ),
373
- { error: message, results: [] },
374
- true,
423
+ `${environmentName}/${packageName}`,
424
+ error,
375
425
  );
376
426
  }
377
427
 
@@ -404,7 +454,100 @@ export function registerGetContextTool(
404
454
  return jsonResource(uri, { results });
405
455
  }
406
456
 
407
- // Tier 4: retrieval over the package's entities.
457
+ // Tier 4: retrieval over the package's entities. With an
458
+ // embedding provider configured, ranking is semantic (DuckDB
459
+ // cosine over cached entity embeddings); otherwise, or whenever
460
+ // the semantic path is unavailable (index still building,
461
+ // provider down, oversized package), it is lexical lunr. The
462
+ // `retrieval` marker and per-entity `score` appear ONLY when a
463
+ // provider is configured, so the unconfigured payload stays
464
+ // byte-identical to the lexical-only releases.
465
+ const configured = embeddingConfigured();
466
+ let semanticResults: ResultEntity[] | undefined;
467
+ if (configured) {
468
+ let provider: EmbeddingProvider | null = null;
469
+ try {
470
+ provider = getEmbeddingProvider();
471
+ } catch (error) {
472
+ logger.warn(
473
+ "[MCP Tool getContext] Embedding configuration invalid; using lexical ranking",
474
+ {
475
+ error:
476
+ error instanceof Error ? error.message : String(error),
477
+ },
478
+ );
479
+ }
480
+ if (provider) {
481
+ try {
482
+ // The raw query embeds better than the lunr-sanitized
483
+ // one; sanitize() only exists to strip lunr operators.
484
+ const semantic = await trySemanticSearch({
485
+ db: environmentStore.storageManager.getDuckDbConnection(),
486
+ provider,
487
+ pkg: pkgIndex.pkg,
488
+ environmentName,
489
+ packageName,
490
+ entities: Array.from(byId.values()),
491
+ query: query ?? sanitized,
492
+ limit: max,
493
+ // "" means no drill-down, matching the lexical
494
+ // path's truthiness filter.
495
+ sourceName: sourceName || undefined,
496
+ });
497
+ if ("hits" in semantic) {
498
+ const byKey = new Map(
499
+ Array.from(byId.values()).map((e) => [
500
+ entityRowKey(e.kind, e.source ?? "", e.name),
501
+ e,
502
+ ]),
503
+ );
504
+ // Rows are only a vector cache: modelPath and doc
505
+ // come from the live entity, and a hit with no live
506
+ // entity (deleted since the last sync) is dropped.
507
+ semanticResults = semantic.hits.flatMap((hit) => {
508
+ const e = byKey.get(
509
+ entityRowKey(hit.kind, hit.source ?? "", hit.name),
510
+ );
511
+ if (!e) return [];
512
+ return [
513
+ {
514
+ kind: e.kind,
515
+ name: e.name,
516
+ source: e.source,
517
+ environmentName,
518
+ packageName,
519
+ modelPath: e.modelPath,
520
+ doc: e.doc,
521
+ score: Math.round(hit.score * 10_000) / 10_000,
522
+ },
523
+ ];
524
+ });
525
+ }
526
+ } catch (error) {
527
+ // Defensive: trySemanticSearch does not throw, but the
528
+ // storage handle lookup can (e.g. before initialization
529
+ // or under a partial test double). Semantic retrieval
530
+ // must never take tier 4 down with it.
531
+ logger.warn(
532
+ "[MCP Tool getContext] Semantic retrieval unavailable; using lexical ranking",
533
+ {
534
+ error:
535
+ error instanceof Error
536
+ ? error.message
537
+ : String(error),
538
+ },
539
+ );
540
+ }
541
+ }
542
+ }
543
+
544
+ if (semanticResults !== undefined) {
545
+ return jsonResource(uri, {
546
+ retrieval: "semantic",
547
+ results: semanticResults,
548
+ });
549
+ }
550
+
408
551
  let hits: lunr.Index.Result[] = [];
409
552
  try {
410
553
  hits = index.search(sanitized);
@@ -433,7 +576,10 @@ export function registerGetContextTool(
433
576
  doc: e.doc,
434
577
  }));
435
578
 
436
- return jsonResource(uri, { results });
579
+ return jsonResource(
580
+ uri,
581
+ configured ? { retrieval: "lexical", results } : { results },
582
+ );
437
583
  },
438
584
  );
439
585
  }
@@ -5,6 +5,7 @@ import { EnvironmentStore } from "../../service/environment_store";
5
5
  import { PackageController } from "../../controller/package.controller";
6
6
  import { type ErrorDetails } from "../error_messages";
7
7
  import { buildMalloyUri, classifyToolError } from "../handler_utils";
8
+ import { jsonResource, jsonToolError } from "../tool_response";
8
9
 
9
10
  // Zod shape for malloy_reloadPackage. environmentName/packageName mirror the
10
11
  // other tools and point the agent at malloy_getContext for name discovery.
@@ -109,19 +110,7 @@ export function registerReloadPackageTool(
109
110
  }),
110
111
  };
111
112
 
112
- return {
113
- isError: false,
114
- content: [
115
- {
116
- type: "resource" as const,
117
- resource: {
118
- type: "application/json",
119
- uri,
120
- text: JSON.stringify(payload),
121
- },
122
- },
123
- ],
124
- };
113
+ return jsonResource(uri, payload);
125
114
  } catch (error) {
126
115
  // Unknown environment/package, or a compile error in the reloaded
127
116
  // package: surface as a clean isError payload rather than a
@@ -136,22 +125,7 @@ export function registerReloadPackageTool(
136
125
  `${environmentName}/${packageName}`,
137
126
  error,
138
127
  );
139
- return {
140
- isError: true,
141
- content: [
142
- {
143
- type: "resource" as const,
144
- resource: {
145
- type: "application/json",
146
- uri,
147
- text: JSON.stringify({
148
- error: errorDetails.message,
149
- suggestions: errorDetails.suggestions,
150
- }),
151
- },
152
- },
153
- ],
154
- };
128
+ return jsonToolError(uri, errorDetails);
155
129
  }
156
130
  },
157
131
  );
@@ -19,4 +19,205 @@ describe("redactPgSecrets", () => {
19
19
  "user=alice dbname=billing",
20
20
  );
21
21
  });
22
+
23
+ it("redacts the password in a URL-form connection string", () => {
24
+ expect(redactPgSecrets("postgres://user:pass@host/db")).toBe(
25
+ "postgres://user:***@host/db",
26
+ );
27
+ });
28
+
29
+ it("preserves port and query string around a URI password", () => {
30
+ expect(redactPgSecrets("postgresql://u:p@h:5432/d?sslmode=require")).toBe(
31
+ "postgresql://u:***@h:5432/d?sslmode=require",
32
+ );
33
+ });
34
+
35
+ it("redacts a percent-encoded password whole", () => {
36
+ expect(redactPgSecrets("postgres://u:p%40ss@h/d")).toBe(
37
+ "postgres://u:***@h/d",
38
+ );
39
+ });
40
+
41
+ it("redacts a literal @ in the password up to the last @", () => {
42
+ expect(redactPgSecrets("postgres://u:p@ss@h/d")).toBe(
43
+ "postgres://u:***@h/d",
44
+ );
45
+ });
46
+
47
+ it("redacts a password containing colons whole", () => {
48
+ expect(redactPgSecrets("postgres://u:pa:ss@h/d")).toBe(
49
+ "postgres://u:***@h/d",
50
+ );
51
+ });
52
+
53
+ it("redacts a raw slash in a pg password via the mop-up pass", () => {
54
+ expect(redactPgSecrets("postgres://u:pa/ss@h/d")).toBe(
55
+ "postgres://u:***@h/d",
56
+ );
57
+ });
58
+
59
+ it("redacts the password when the username is empty", () => {
60
+ expect(redactPgSecrets("postgres://:secret@h/d")).toBe(
61
+ "postgres://:***@h/d",
62
+ );
63
+ });
64
+
65
+ it("leaves IPv6 hosts intact", () => {
66
+ expect(redactPgSecrets("postgres://u:p@[::1]:5432/d")).toBe(
67
+ "postgres://u:***@[::1]:5432/d",
68
+ );
69
+ });
70
+
71
+ it("does not redact a username-only userinfo", () => {
72
+ expect(redactPgSecrets("postgres://user@host/db")).toBe(
73
+ "postgres://user@host/db",
74
+ );
75
+ });
76
+
77
+ it("leaves URIs without userinfo alone", () => {
78
+ expect(redactPgSecrets("postgres://host/db")).toBe("postgres://host/db");
79
+ expect(redactPgSecrets("postgres://host:5432/db")).toBe(
80
+ "postgres://host:5432/db",
81
+ );
82
+ });
83
+
84
+ it("does not mangle a passwordless URI with :...@ in its query", () => {
85
+ expect(redactPgSecrets("postgres://h?options=endpoint:foo@bar")).toBe(
86
+ "postgres://h?options=endpoint:foo@bar",
87
+ );
88
+ expect(redactPgSecrets("postgres://h/d?x=a@b")).toBe(
89
+ "postgres://h/d?x=a@b",
90
+ );
91
+ });
92
+
93
+ it("leaves email addresses in prose alone", () => {
94
+ expect(redactPgSecrets("contact admin@example.com for help")).toBe(
95
+ "contact admin@example.com for help",
96
+ );
97
+ });
98
+
99
+ it("does not mangle https URLs with @ in the path", () => {
100
+ expect(
101
+ redactPgSecrets("fetched https://github.com:443/@scope/pkg ok"),
102
+ ).toBe("fetched https://github.com:443/@scope/pkg ok");
103
+ });
104
+
105
+ it("redacts every URI in a message independently", () => {
106
+ expect(
107
+ redactPgSecrets(
108
+ "tried postgres://u1:p1@h1/d1 then postgres://u2:p2@h2/d2",
109
+ ),
110
+ ).toBe("tried postgres://u1:***@h1/d1 then postgres://u2:***@h2/d2");
111
+ });
112
+
113
+ it("keeps comma-joined URIs in one token separate", () => {
114
+ expect(
115
+ redactPgSecrets(
116
+ "candidates: postgres://u1:p1@h1/d1,postgres://u2:p2@h2/d2",
117
+ ),
118
+ ).toBe("candidates: postgres://u1:***@h1/d1,postgres://u2:***@h2/d2");
119
+ });
120
+
121
+ it("leaves trailing sentence punctuation alone", () => {
122
+ expect(redactPgSecrets("could not connect to postgres://u:p@h/d.")).toBe(
123
+ "could not connect to postgres://u:***@h/d.",
124
+ );
125
+ });
126
+
127
+ it("handles keyword and URI forms in one message", () => {
128
+ expect(
129
+ redactPgSecrets(
130
+ "attach failed: host=h password=x and fallback postgres://u:p@h2/d",
131
+ ),
132
+ ).toBe(
133
+ "attach failed: host=h password=*** and fallback postgres://u:***@h2/d",
134
+ );
135
+ });
136
+
137
+ it("keeps the URI intact when a password contains 'password='", () => {
138
+ // URI passes run before the keyword pass; keyword-first would eat
139
+ // the @host/db tail via \S+.
140
+ expect(redactPgSecrets("postgres://u:password=abc@h/d")).toBe(
141
+ "postgres://u:***@h/d",
142
+ );
143
+ });
144
+
145
+ it("over-redacts rather than leaks a ?password= query parameter", () => {
146
+ // Pre-existing keyword-pass behavior: \S+ eats through &sslmode=...
147
+ // Over-redaction, never a leak.
148
+ expect(
149
+ redactPgSecrets("postgres://h/d?password=abc&sslmode=require"),
150
+ ).toBe("postgres://h/d?password=***");
151
+ });
152
+
153
+ it("matches uppercase schemes", () => {
154
+ expect(redactPgSecrets("POSTGRES://U:Secret@H/D")).toBe(
155
+ "POSTGRES://U:***@H/D",
156
+ );
157
+ });
158
+
159
+ it("redacts non-pg scheme userinfo passwords too", () => {
160
+ expect(redactPgSecrets("amqp://guest:guest@rabbit:5672/vhost")).toBe(
161
+ "amqp://guest:***@rabbit:5672/vhost",
162
+ );
163
+ });
164
+
165
+ it("redacts an Azure user@servername username form", () => {
166
+ // Azure single-server usernames carry a literal @ (user@servername);
167
+ // userinfo splits at the LAST @, so the password after the first : is
168
+ // redacted while the @-bearing username stays intact.
169
+ expect(
170
+ redactPgSecrets(
171
+ "postgres://myadmin@myserver:Zx7SeCReT9q@myserver.postgres.database.azure.com:5432/db",
172
+ ),
173
+ ).toBe(
174
+ "postgres://myadmin@myserver:***@myserver.postgres.database.azure.com:5432/db",
175
+ );
176
+ });
177
+
178
+ it("redacts an @-in-username form whose password also contains @", () => {
179
+ expect(redactPgSecrets("postgres://myadmin@srv:p@ss@host/db")).toBe(
180
+ "postgres://myadmin@srv:***@host/db",
181
+ );
182
+ });
183
+
184
+ it("redacts an @-in-username form with a raw / in the password", () => {
185
+ expect(redactPgSecrets("postgresql://u@srv:sec/ret@h/d")).toBe(
186
+ "postgresql://u@srv:***@h/d",
187
+ );
188
+ });
189
+
190
+ it("redacts a scheme abutting a word char", () => {
191
+ // No leading anchor on the scheme, so a scheme glued to a preceding
192
+ // word char (an invalid-scheme prefix, or a stray digit) still redacts.
193
+ expect(redactPgSecrets("x_postgres://u:secretpw@h/d")).toBe(
194
+ "x_postgres://u:***@h/d",
195
+ );
196
+ expect(redactPgSecrets("3postgres://u:secretpw@h/d")).toBe(
197
+ "3postgres://u:***@h/d",
198
+ );
199
+ });
200
+
201
+ it("never reveals a secret when re-applied (monotonic)", () => {
202
+ // redactPgSecrets is not strictly idempotent in general (re-applying
203
+ // can redact further, e.g. an unquoted *** left by the keyword pass),
204
+ // but re-application must never turn a redacted secret back into
205
+ // cleartext.
206
+ const input = "postgres://u:p@ss@h/d and host=h password=hunter2";
207
+ const once = redactPgSecrets(input);
208
+ const twice = redactPgSecrets(once);
209
+ expect(once).not.toContain("hunter2");
210
+ expect(twice).not.toContain("hunter2");
211
+ expect(twice).not.toContain("p@ss");
212
+ });
213
+
214
+ it("redacts the DSN inside a real DuckDB attach error message", () => {
215
+ // Shape observed from DuckDB's postgres extension: the full DSN is
216
+ // echoed verbatim inside the IO Error text.
217
+ const msg =
218
+ 'IO Error: Unable to connect to Postgres at "postgres://alice:supersecretpw@127.0.0.1:5432/mydb": connection to server at "127.0.0.1", port 5432 failed: Connection refused';
219
+ const redacted = redactPgSecrets(msg);
220
+ expect(redacted).toContain("postgres://alice:***@127.0.0.1:5432/mydb");
221
+ expect(redacted).not.toContain("supersecretpw");
222
+ });
22
223
  });