@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
@@ -0,0 +1,177 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
4
+ import { type ChildProcess, spawn } from "child_process";
5
+ import fs from "fs";
6
+ import net from "net";
7
+ import os from "os";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+
11
+ /**
12
+ * End-to-end coverage for the PUBLISHER_READY line: the contract scripts grep
13
+ * for instead of polling /api/v0/status. It must appear on stderr exactly
14
+ * once, only when the server is actually serving, and carry the environment,
15
+ * package, and load-error counts. That contract spans process boundaries
16
+ * (a real stderr stream from a real boot), so it needs a dedicated server
17
+ * subprocess rather than the in-process REST harness.
18
+ */
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ // tests/integration/first_boot -> packages/server
22
+ const SERVER_DIR = path.resolve(__dirname, "../../..");
23
+
24
+ const ENV_NAME = "ready-env";
25
+ const PKG_NAME = "ready-pkg";
26
+
27
+ /** Allocate an OS-assigned free TCP port (avoids fixed-port collisions). */
28
+ async function getFreePort(): Promise<number> {
29
+ return new Promise<number>((resolve, reject) => {
30
+ const srv = net.createServer();
31
+ srv.on("error", reject);
32
+ srv.listen(0, "127.0.0.1", () => {
33
+ const addr = srv.address();
34
+ const found = typeof addr === "object" && addr ? addr.port : 0;
35
+ srv.close(() =>
36
+ found ? resolve(found) : reject(new Error("no free port")),
37
+ );
38
+ });
39
+ });
40
+ }
41
+
42
+ /** Returns true once `predicate` does, or false if `timeoutMs` elapses first. */
43
+ async function poll(
44
+ predicate: () => Promise<boolean>,
45
+ timeoutMs: number,
46
+ intervalMs = 300,
47
+ ): Promise<boolean> {
48
+ const deadline = Date.now() + timeoutMs;
49
+ while (Date.now() < deadline) {
50
+ if (await predicate()) return true;
51
+ await new Promise((r) => setTimeout(r, intervalMs));
52
+ }
53
+ return false;
54
+ }
55
+
56
+ describe("first-boot readiness line", () => {
57
+ let proc: ChildProcess | undefined;
58
+ let exited = false;
59
+ let stderrLog = "";
60
+ let combinedLog = "";
61
+ let srcDir = "";
62
+ let serverRoot = "";
63
+ let port = 0;
64
+
65
+ const readyLines = () =>
66
+ stderrLog
67
+ .split("\n")
68
+ .filter((line) => line.startsWith("PUBLISHER_READY"));
69
+
70
+ beforeAll(async () => {
71
+ srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "ready-src-"));
72
+ fs.writeFileSync(
73
+ path.join(srcDir, "publisher.json"),
74
+ JSON.stringify({ name: PKG_NAME }),
75
+ );
76
+ fs.writeFileSync(
77
+ path.join(srcDir, "first.malloy"),
78
+ 'source: first_source is duckdb.sql("SELECT 1 as n")\n',
79
+ );
80
+
81
+ serverRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ready-root-"));
82
+ fs.writeFileSync(
83
+ path.join(serverRoot, "publisher.config.json"),
84
+ JSON.stringify({
85
+ frozenConfig: false,
86
+ environments: [
87
+ {
88
+ name: ENV_NAME,
89
+ packages: [{ name: PKG_NAME, location: srcDir }],
90
+ connections: [],
91
+ },
92
+ ],
93
+ }),
94
+ );
95
+
96
+ port = await getFreePort();
97
+ let mcpPort = await getFreePort();
98
+ while (mcpPort === port) mcpPort = await getFreePort();
99
+
100
+ proc = spawn("bun", ["src/server.ts"], {
101
+ cwd: SERVER_DIR,
102
+ env: {
103
+ ...process.env,
104
+ SERVER_ROOT: serverRoot,
105
+ PUBLISHER_HOST: "127.0.0.1",
106
+ PUBLISHER_PORT: String(port),
107
+ MCP_PORT: String(mcpPort),
108
+ },
109
+ stdio: ["ignore", "pipe", "pipe"],
110
+ });
111
+ proc.stdout?.on("data", (d: Buffer) => {
112
+ combinedLog = (combinedLog + d.toString()).slice(-8000);
113
+ });
114
+ proc.stderr?.on("data", (d: Buffer) => {
115
+ stderrLog += d.toString();
116
+ combinedLog = (combinedLog + d.toString()).slice(-8000);
117
+ });
118
+ proc.on("exit", () => {
119
+ exited = true;
120
+ });
121
+
122
+ const serving = await poll(async () => {
123
+ if (exited) throw new Error("server exited before becoming ready");
124
+ try {
125
+ const res = await fetch(`http://127.0.0.1:${port}/api/v0/status`);
126
+ if (!res.ok) return false;
127
+ const body = (await res.json()) as { operationalState?: string };
128
+ return body.operationalState === "serving";
129
+ } catch {
130
+ return false;
131
+ }
132
+ }, 120_000);
133
+ if (!serving) {
134
+ throw new Error(
135
+ `server did not reach serving within 120s\n--- log tail ---\n${combinedLog}`,
136
+ );
137
+ }
138
+ // The line is written in the same tick that flips the status to
139
+ // serving, but the pipe delivery can trail the HTTP response.
140
+ await poll(async () => readyLines().length > 0, 10_000, 100);
141
+ }, 150_000);
142
+
143
+ afterAll(async () => {
144
+ if (proc && !exited) {
145
+ await new Promise<void>((resolve) => {
146
+ // Backstop in case SIGTERM is ignored; cleared once the process exits.
147
+ const backstop = setTimeout(() => proc?.kill("SIGKILL"), 5_000);
148
+ proc?.on("exit", () => {
149
+ clearTimeout(backstop);
150
+ resolve();
151
+ });
152
+ proc?.kill("SIGTERM");
153
+ });
154
+ }
155
+ for (const dir of [srcDir, serverRoot]) {
156
+ try {
157
+ fs.rmSync(dir, { recursive: true, force: true });
158
+ } catch {
159
+ // best-effort cleanup
160
+ }
161
+ }
162
+ });
163
+
164
+ it("prints one PUBLISHER_READY line on stderr once serving", () => {
165
+ const lines = readyLines();
166
+ expect(lines).toHaveLength(1);
167
+ // The URL host/port are the ones the server was actually started with,
168
+ // so a script can dial what the line says.
169
+ expect(lines[0]).toMatch(
170
+ new RegExp(
171
+ `^PUBLISHER_READY url=http://127\\.0\\.0\\.1:${port} ` +
172
+ `mcp=http://127\\.0\\.0\\.1:\\d+ ` +
173
+ `environments=1 packages=1 load_errors=0$`,
174
+ ),
175
+ );
176
+ });
177
+ });
@@ -192,6 +192,37 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
192
192
  return file;
193
193
  }
194
194
 
195
+ /**
196
+ * Write a manifest carrying a `storage=` (cross-connection) entry — one that
197
+ * names a `storageConnectionName` and carries a captured `schema`. Such an
198
+ * entry serves through the virtual-source transform, so it must bind as a
199
+ * serve BINDING, never as a same-connection tableName substitution.
200
+ */
201
+ async function writeStorageManifest(
202
+ sourceEntityId: string,
203
+ ): Promise<string> {
204
+ const file = path.join(tmpDir, `storage-manifest-${Date.now()}.json`);
205
+ await fsp.writeFile(
206
+ file,
207
+ JSON.stringify({
208
+ builtAt: new Date().toISOString(),
209
+ strict: false,
210
+ entries: {
211
+ [sourceEntityId]: {
212
+ sourceEntityId,
213
+ sourceName: "order_summary",
214
+ physicalTableName: "order_summary",
215
+ connectionName: "duckdb",
216
+ storageConnectionName: "lake",
217
+ schema: [{ name: "c", type: "BIGINT" }],
218
+ },
219
+ },
220
+ }),
221
+ "utf8",
222
+ );
223
+ return file;
224
+ }
225
+
195
226
  async function writeManifest(): Promise<string> {
196
227
  const file = path.join(tmpDir, `manifest-${Date.now()}.json`);
197
228
  await fsp.writeFile(
@@ -303,6 +334,79 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
303
334
  { timeout: 120_000 },
304
335
  );
305
336
 
337
+ it(
338
+ "binds a storage= entry as a cross-connection serve binding, not a tableName substitution",
339
+ async () => {
340
+ await getPackage(true); // start from live (unbound)
341
+ const sourceEntityId = await orderSummarySourceEntityId();
342
+ const manifestFile = await writeStorageManifest(sourceEntityId);
343
+
344
+ const patchRes = await patchPackage({
345
+ manifestLocation: manifestFile,
346
+ });
347
+ expect(patchRes.status).toBe(200);
348
+ const patched = (await patchRes.json()) as Record<string, unknown>;
349
+
350
+ // The storage entry never enters the same-connection tableName manifest,
351
+ // so there is nothing to substitute (and no recompile happened).
352
+ expect(patched.manifestEntryCount).toBe(0);
353
+ // It surfaces instead as a cross-connection storage serve binding.
354
+ expect(patched.storageServeBindings).toEqual([
355
+ {
356
+ sourceName: "order_summary",
357
+ storageConnectionName: "lake",
358
+ tablePath: "lake.order_summary",
359
+ },
360
+ ]);
361
+ // The bound URI is recorded even though no tableName manifest bound.
362
+ expect(patched.boundManifestUri).toBe(manifestFile);
363
+
364
+ // Clearing reverts: the storage serve binding is dropped.
365
+ const clearRes = await patchPackage({ manifestLocation: null });
366
+ expect(clearRes.status).toBe(200);
367
+ const cleared = (await clearRes.json()) as Record<string, unknown>;
368
+ expect(cleared.storageServeBindings ?? null).toBeNull();
369
+ await getPackage(true);
370
+ },
371
+ { timeout: 60_000 },
372
+ );
373
+
374
+ it(
375
+ "a rebind to a manifest whose storage entries vanished clears the old bindings",
376
+ async () => {
377
+ // Regression (MED-2): bindManifest must drop storage serve bindings when
378
+ // a NEW manifest (still a real URI, not a clear) no longer carries them —
379
+ // otherwise stale bindings keep routing at a table the host no longer
380
+ // vouches for.
381
+ await getPackage(true);
382
+ const sourceEntityId = await orderSummarySourceEntityId();
383
+ const withStorage = await writeStorageManifest(sourceEntityId);
384
+ const bound = (await (
385
+ await patchPackage({ manifestLocation: withStorage })
386
+ ).json()) as Record<string, unknown>;
387
+ expect(
388
+ (bound.storageServeBindings as unknown[] | undefined)?.length,
389
+ ).toBe(1);
390
+
391
+ // Rebind to a DIFFERENT manifest URI that carries no storage entries.
392
+ const noStorage = path.join(tmpDir, `no-storage-${Date.now()}.json`);
393
+ await fsp.writeFile(
394
+ noStorage,
395
+ JSON.stringify({ builtAt: new Date().toISOString(), entries: {} }),
396
+ "utf8",
397
+ );
398
+ const rebound = (await (
399
+ await patchPackage({ manifestLocation: noStorage })
400
+ ).json()) as Record<string, unknown>;
401
+ // Stale storage binding must be gone, not left routing.
402
+ expect(rebound.storageServeBindings ?? null).toBeNull();
403
+
404
+ await patchPackage({ manifestLocation: null });
405
+ await getPackage(true);
406
+ },
407
+ { timeout: 60_000 },
408
+ );
409
+
306
410
  it("degrades to serving live when the manifest is unreachable", async () => {
307
411
  const missing = path.join(tmpDir, "does-not-exist.json");
308
412
 
@@ -71,7 +71,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
71
71
  for (const block of content) {
72
72
  expect(block.type).toBe("resource");
73
73
  expect(block.resource).toBeDefined();
74
- expect(block.resource.type).toBe("application/json");
74
+ expect(block.resource.mimeType).toBe("application/json");
75
75
  expect(block.resource.text).toBeDefined();
76
76
  expect(typeof block.resource.text).toBe("string");
77
77
  }
@@ -80,12 +80,24 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
80
80
 
81
81
  const queryResultBlock = content[0].resource;
82
82
  expect(queryResultBlock.uri).toContain("#result");
83
- const queryResultData = JSON.parse(queryResultBlock.text);
84
- expect(queryResultData).toBeDefined();
85
- // Check properties directly on the parsed Result object
86
- expect(queryResultData.data).toBeDefined();
87
- expect(Array.isArray(queryResultData.data.array_value)).toBe(true);
88
- // Could add more specific checks on data if needed
83
+ const envelope = JSON.parse(queryResultBlock.text);
84
+
85
+ // Flat rows keyed by column name: the same shape an in-package data
86
+ // app receives, not the type-tagged Malloy cell envelope.
87
+ expect(Array.isArray(envelope.rows)).toBe(true);
88
+ expect(envelope.rows.length).toBeGreaterThan(0);
89
+ expect(typeof envelope.rows[0]).toBe("object");
90
+ expect(envelope.rows[0].data).toBeUndefined();
91
+
92
+ // Credible's field names, so an agent sees one shape whether the
93
+ // app is authored locally against Publisher or served in production.
94
+ expect(typeof envelope._query_row_limit).toBe("number");
95
+ expect(typeof envelope._limit_hit).toBe("boolean");
96
+ // Absent rather than false when nothing was dropped.
97
+ expect("_rows_truncated" in envelope).toBe(false);
98
+ // The metadata flat rows drop.
99
+ expect(envelope._meta.schema).toBeDefined();
100
+ expect(typeof envelope._meta.connection_name).toBe("string");
89
101
  },
90
102
  { timeout: 30000 },
91
103
  );
@@ -120,7 +132,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
120
132
  const queryResultBlock = result.content![0];
121
133
  expect(queryResultBlock.type).toBe("resource");
122
134
  expect(queryResultBlock.resource).toBeDefined();
123
- expect(queryResultBlock.resource.type).toBe("application/json");
135
+ expect(queryResultBlock.resource.mimeType).toBe("application/json");
124
136
  expect(queryResultBlock.resource.uri).toMatch(/result/); // Check URI contains queryResult
125
137
  expect(queryResultBlock.resource.text).toBeDefined();
126
138
 
@@ -159,7 +171,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
159
171
  const errorBlockSyntax = result.content![0];
160
172
  expect(errorBlockSyntax.type).toBe("resource");
161
173
  expect(errorBlockSyntax.resource).toBeDefined();
162
- expect(errorBlockSyntax.resource.type).toBe("application/json");
174
+ expect(errorBlockSyntax.resource.mimeType).toBe("application/json");
163
175
 
164
176
  // Check for Malloy compilation error message from getMalloyErrorDetails
165
177
  const errorJsonTextSyntax = errorBlockSyntax.resource
@@ -169,6 +181,17 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
169
181
  /syntax error|no viable alternative/i,
170
182
  );
171
183
  expect(Array.isArray(errorPayloadSyntax.suggestions)).toBe(true);
184
+
185
+ // The resource block is invisible to a client that renders only
186
+ // text on an isError result, which is how a real diagnostic gets
187
+ // reported as a bare "Unknown error". Pinned here, over the real
188
+ // HTTP transport, because the unit spec calls the handler directly
189
+ // and so cannot catch the block being dropped in serialization.
190
+ const textBlockSyntax = result.content!.find(
191
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
192
+ (b: any) => b.type === "text",
193
+ );
194
+ expect(textBlockSyntax?.text).toContain(errorPayloadSyntax.error);
172
195
  },
173
196
  { timeout: 30000 },
174
197
  );
@@ -277,7 +300,9 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
277
300
  const errorBlockPkgNotFound = result.content![0];
278
301
  expect(errorBlockPkgNotFound.type).toBe("resource");
279
302
  expect(errorBlockPkgNotFound.resource).toBeDefined();
280
- expect(errorBlockPkgNotFound.resource.type).toBe("application/json");
303
+ expect(errorBlockPkgNotFound.resource.mimeType).toBe(
304
+ "application/json",
305
+ );
281
306
 
282
307
  // Parse the JSON string from the resource text content
283
308
  const errorJsonTextPkgNotFound = errorBlockPkgNotFound.resource
@@ -321,7 +346,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
321
346
  const errorBlockModel = result.content![0];
322
347
  expect(errorBlockModel.type).toBe("resource");
323
348
  expect(errorBlockModel.resource).toBeDefined();
324
- expect(errorBlockModel.resource.type).toBe("application/json");
349
+ expect(errorBlockModel.resource.mimeType).toBe("application/json");
325
350
 
326
351
  // Parse the JSON string from the resource text content
327
352
  const errorJsonTextModel = errorBlockModel.resource.text as string;
@@ -398,7 +423,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
398
423
  const errorBlock = result.content![0];
399
424
  expect(errorBlock.type).toBe("resource");
400
425
  expect(errorBlock.resource).toBeDefined();
401
- expect(errorBlock.resource.type).toBe("application/json");
426
+ expect(errorBlock.resource.mimeType).toBe("application/json");
402
427
 
403
428
  // Check for Malloy error indicating the query/view wasn't found at the top level
404
429
  const errorJsonText = errorBlock.resource.text as string;
@@ -0,0 +1,235 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import {
4
+ Notification,
5
+ Request,
6
+ Result,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import http from "http";
9
+ import { AddressInfo } from "net";
10
+ import {
11
+ McpE2ETestEnvironment,
12
+ cleanupE2ETestEnvironment,
13
+ setupE2ETestEnvironment,
14
+ } from "../../harness/mcp_test_setup";
15
+
16
+ /**
17
+ * End-to-end coverage of semantic malloy_getContext retrieval: the real
18
+ * server, the real DuckDB vector cache, and a deterministic in-test
19
+ * embedding endpoint (letter-bigram bag-of-words vectors, so related
20
+ * phrases genuinely land near each other, no network, no key).
21
+ */
22
+
23
+ const ENVIRONMENT_NAME = "examples";
24
+ const PACKAGE_NAME = "storefront";
25
+ const DIMS = 64;
26
+
27
+ /** Deterministic unit vector from letter bigrams. */
28
+ function bigramVector(text: string): number[] {
29
+ const v = new Array<number>(DIMS).fill(0);
30
+ const s = ` ${text.toLowerCase().replace(/[^a-z0-9]+/g, " ")} `;
31
+ for (let i = 0; i < s.length - 1; i++) {
32
+ v[(s.charCodeAt(i) * 31 + s.charCodeAt(i + 1)) % DIMS] += 1;
33
+ }
34
+ const norm = Math.sqrt(v.reduce((acc, x) => acc + x * x, 0)) || 1;
35
+ return v.map((x) => x / norm);
36
+ }
37
+
38
+ let env: McpE2ETestEnvironment | null = null;
39
+ let mcpClient: Client<Request, Notification, Result>;
40
+ let embeddingStub: http.Server;
41
+ let stubFailing = false;
42
+ let stubRequests = 0;
43
+
44
+ const savedEnv: Record<string, string | undefined> = {};
45
+ const ENV_KEYS = [
46
+ "EMBEDDING_API_KEY",
47
+ "EMBEDDING_API_BASE",
48
+ "EMBEDDING_MODEL",
49
+ "EMBEDDING_DIMENSIONS",
50
+ ];
51
+
52
+ interface GetContextPayload {
53
+ retrieval?: string;
54
+ results?: Array<{
55
+ kind: string;
56
+ name: string;
57
+ source?: string;
58
+ score?: number;
59
+ }>;
60
+ error?: string;
61
+ }
62
+
63
+ async function callGetContext(
64
+ args: Record<string, unknown>,
65
+ ): Promise<GetContextPayload> {
66
+ const result = (await mcpClient.callTool({
67
+ name: "malloy_getContext",
68
+ arguments: args,
69
+ })) as {
70
+ content: Array<{ type: string; resource?: { text?: string } }>;
71
+ };
72
+ const text = result.content?.[0]?.resource?.text;
73
+ if (!text) throw new Error("malloy_getContext returned no resource text");
74
+ return JSON.parse(text) as GetContextPayload;
75
+ }
76
+
77
+ describe.serial("MCP getContext semantic retrieval (E2E Integration)", () => {
78
+ beforeAll(async () => {
79
+ // A local OpenAI-compatible /embeddings endpoint with deterministic
80
+ // vectors. Bound to 127.0.0.1:0; the real port comes from the OS.
81
+ embeddingStub = http.createServer((req, res) => {
82
+ let body = "";
83
+ req.on("data", (chunk) => (body += chunk));
84
+ req.on("end", () => {
85
+ stubRequests++;
86
+ if (stubFailing) {
87
+ res.writeHead(500).end("stub down");
88
+ return;
89
+ }
90
+ const { input } = JSON.parse(body) as { input: string[] };
91
+ res.writeHead(200, { "Content-Type": "application/json" }).end(
92
+ JSON.stringify({
93
+ data: input.map((text, index) => ({
94
+ index,
95
+ embedding: bigramVector(text),
96
+ })),
97
+ }),
98
+ );
99
+ });
100
+ });
101
+ await new Promise<void>((resolve) =>
102
+ embeddingStub.listen(0, "127.0.0.1", resolve),
103
+ );
104
+ const port = (embeddingStub.address() as AddressInfo).port;
105
+
106
+ // Set the embedding env vars BEFORE the server harness dynamically
107
+ // imports src/server; config is read per call, but this keeps the
108
+ // whole server lifetime consistently configured.
109
+ for (const key of ENV_KEYS) {
110
+ savedEnv[key] = process.env[key];
111
+ }
112
+ process.env.EMBEDDING_API_KEY = "integration-test-key";
113
+ process.env.EMBEDDING_API_BASE = `http://127.0.0.1:${port}/v1`;
114
+ // A stub-distinct model name: the suite shares publisher.db with
115
+ // local manual runs, and rows cached under the real default model
116
+ // name would poison a later real-provider run (the stub's bigram
117
+ // vectors are not text-embedding-3-small's). Under this name the
118
+ // leftovers are inert; a real run's model diff replaces them.
119
+ process.env.EMBEDDING_MODEL = "integration-test-bigram-stub";
120
+ delete process.env.EMBEDDING_DIMENSIONS;
121
+
122
+ env = await setupE2ETestEnvironment();
123
+ mcpClient = env.mcpClient;
124
+ }, 200000);
125
+
126
+ afterAll(async () => {
127
+ await cleanupE2ETestEnvironment(env);
128
+ env = null;
129
+ // Restore the embedding env so later suites in this process run
130
+ // unconfigured (the provider getter re-reads env per call).
131
+ for (const key of ENV_KEYS) {
132
+ if (savedEnv[key] === undefined) delete process.env[key];
133
+ else process.env[key] = savedEnv[key];
134
+ }
135
+ await new Promise<void>((resolve, reject) =>
136
+ embeddingStub.close((err) => (err ? reject(err) : resolve())),
137
+ );
138
+ });
139
+
140
+ it(
141
+ "answers lexically while indexing, then flips to semantic with scores",
142
+ async () => {
143
+ const first = await callGetContext({
144
+ environmentName: ENVIRONMENT_NAME,
145
+ packageName: PACKAGE_NAME,
146
+ query: "total sales revenue",
147
+ });
148
+ // Configured server: the marker is always present on tier 4.
149
+ expect(["lexical", "semantic"]).toContain(first.retrieval);
150
+
151
+ let payload = first;
152
+ for (let i = 0; i < 60 && payload.retrieval !== "semantic"; i++) {
153
+ await new Promise((resolve) => setTimeout(resolve, 500));
154
+ payload = await callGetContext({
155
+ environmentName: ENVIRONMENT_NAME,
156
+ packageName: PACKAGE_NAME,
157
+ query: "total sales revenue",
158
+ });
159
+ }
160
+ expect(payload.retrieval).toBe("semantic");
161
+ expect(stubRequests).toBeGreaterThan(0);
162
+
163
+ const results = payload.results ?? [];
164
+ expect(results.length).toBeGreaterThan(0);
165
+ for (const r of results) {
166
+ expect(typeof r.score).toBe("number");
167
+ }
168
+ // The bigram embedding puts "total sales revenue" on top of the
169
+ // total_sales measure ("total sales" once humanized).
170
+ expect(results.some((r) => r.name === "total_sales")).toBe(true);
171
+ },
172
+ { timeout: 60000 },
173
+ );
174
+
175
+ it(
176
+ "keeps the semantic index warm across calls (no re-embed per call)",
177
+ async () => {
178
+ const before = stubRequests;
179
+ const payload = await callGetContext({
180
+ environmentName: ENVIRONMENT_NAME,
181
+ packageName: PACKAGE_NAME,
182
+ query: "orders by month",
183
+ });
184
+ expect(payload.retrieval).toBe("semantic");
185
+ // Exactly one stub call: the query embedding. No bulk re-sync.
186
+ expect(stubRequests).toBe(before + 1);
187
+ },
188
+ { timeout: 30000 },
189
+ );
190
+
191
+ it(
192
+ "narrows semantic retrieval with sourceName",
193
+ async () => {
194
+ const payload = await callGetContext({
195
+ environmentName: ENVIRONMENT_NAME,
196
+ packageName: PACKAGE_NAME,
197
+ query: "total sales revenue",
198
+ sourceName: "order_items",
199
+ });
200
+ expect(payload.retrieval).toBe("semantic");
201
+ const results = payload.results ?? [];
202
+ // Non-empty, or the per-result loop below pins nothing.
203
+ expect(results.length).toBeGreaterThan(0);
204
+ for (const r of results) {
205
+ expect(r.source).toBe("order_items");
206
+ }
207
+ },
208
+ { timeout: 30000 },
209
+ );
210
+
211
+ // Keep this test LAST: the induced failure starts the provider
212
+ // cool-down, which short-circuits the semantic path for its window.
213
+ it(
214
+ "falls back to lexical, marked, when the embedding endpoint fails",
215
+ async () => {
216
+ stubFailing = true;
217
+ try {
218
+ const payload = await callGetContext({
219
+ environmentName: ENVIRONMENT_NAME,
220
+ packageName: PACKAGE_NAME,
221
+ query: "top selling products",
222
+ });
223
+ expect(payload.retrieval).toBe("lexical");
224
+ const results = payload.results ?? [];
225
+ expect(results.length).toBeGreaterThan(0);
226
+ for (const r of results) {
227
+ expect(r.score).toBeUndefined();
228
+ }
229
+ } finally {
230
+ stubFailing = false;
231
+ }
232
+ },
233
+ { timeout: 30000 },
234
+ );
235
+ });