@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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +135 -13
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
- package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +22627 -1260
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/controller/query.controller.ts +26 -21
- package/src/errors.ts +19 -0
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +358 -5
- package/src/mcp/tools/get_context_tool.ts +202 -56
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +339 -20
- package/src/service/connection_config.spec.ts +108 -0
- package/src/service/connection_config.ts +47 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +569 -59
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
- 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 {
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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 =
|
|
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 = {
|
|
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
|
-
##
|
|
212
|
-
|
|
213
|
-
-
|
|
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
|
-
|
|
220
|
-
-
|
|
221
|
-
-
|
|
222
|
-
-
|
|
223
|
-
-
|
|
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
|
-
*
|
|
237
|
-
*
|
|
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
|
|
240
|
-
|
|
296
|
+
function contextError(uri: string, identifier: string, error: unknown) {
|
|
297
|
+
return jsonToolError(
|
|
298
|
+
uri,
|
|
299
|
+
classifyToolError("getContext", identifier, error),
|
|
241
300
|
{
|
|
242
|
-
|
|
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
|
-
{
|
|
353
|
+
{
|
|
354
|
+
error:
|
|
355
|
+
error instanceof Error ? error.message : String(error),
|
|
356
|
+
},
|
|
303
357
|
);
|
|
304
|
-
return
|
|
358
|
+
return contextError(
|
|
305
359
|
buildMalloyUri({}, "get-context"),
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
392
|
+
return contextError(
|
|
341
393
|
buildMalloyUri(
|
|
342
394
|
{ environment: environmentName },
|
|
343
395
|
"get-context",
|
|
344
396
|
),
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
418
|
+
return contextError(
|
|
369
419
|
buildMalloyUri(
|
|
370
420
|
{ environment: environmentName, package: packageName },
|
|
371
421
|
"get-context",
|
|
372
422
|
),
|
|
373
|
-
{
|
|
374
|
-
|
|
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(
|
|
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
|
);
|
package/src/pg_helpers.spec.ts
CHANGED
|
@@ -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
|
});
|