@malloy-publisher/server 0.0.207 → 0.0.209

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 (59) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/index-CCcHdeew.js +1760 -0
  12. package/dist/app/assets/index-CNFX-CGL.js +40 -0
  13. package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +26182 -16839
  17. package/package.json +14 -11
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/health.ts +6 -0
  21. package/src/materialization_metrics.ts +117 -34
  22. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  23. package/src/mcp/agent_server.spec.ts +18 -0
  24. package/src/mcp/agent_server.ts +144 -0
  25. package/src/mcp/server.ts +3 -0
  26. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  27. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  28. package/src/mcp/skills/skills_bundle.json +1 -0
  29. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  30. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  31. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  32. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  33. package/src/mcp/tools/docs_search_tool.ts +138 -0
  34. package/src/mcp/tools/get_context_eval.ts +126 -0
  35. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  36. package/src/mcp/tools/get_context_tool.ts +341 -0
  37. package/src/server-old.ts +2 -11
  38. package/src/server.ts +16 -10
  39. package/src/service/build_plan.spec.ts +116 -0
  40. package/src/service/build_plan.ts +238 -0
  41. package/src/service/connection.ts +4 -0
  42. package/src/service/connection_config.spec.ts +182 -1
  43. package/src/service/connection_config.ts +70 -0
  44. package/src/service/db_utils.spec.ts +159 -1
  45. package/src/service/db_utils.ts +131 -0
  46. package/src/service/materialization_service.spec.ts +388 -184
  47. package/src/service/materialization_service.ts +156 -442
  48. package/src/service/materialization_test_fixtures.ts +119 -0
  49. package/src/service/package.ts +41 -1
  50. package/src/storage/DatabaseInterface.ts +5 -13
  51. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  52. package/src/storage/duckdb/schema.ts +4 -5
  53. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  54. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  55. package/dist/app/assets/HomePage-CGedji_w.js +0 -1
  56. package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
  57. package/dist/app/assets/index-DGGe8UpP.js +0 -40
  58. package/dist/app/assets/index-uu6UpHd2.js +0 -1812
  59. package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.207",
4
+ "version": "0.0.209",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -36,16 +36,17 @@
36
36
  "@azure/storage-blob": "^12.26.0",
37
37
  "@duckdb/node-api": "1.5.3-r.2",
38
38
  "@google-cloud/storage": "^7.16.0",
39
- "@malloydata/db-bigquery": "^0.0.405",
40
- "@malloydata/db-databricks": "^0.0.405",
41
- "@malloydata/db-duckdb": "^0.0.405",
42
- "@malloydata/db-mysql": "^0.0.405",
43
- "@malloydata/db-postgres": "^0.0.405",
44
- "@malloydata/db-snowflake": "^0.0.405",
45
- "@malloydata/db-trino": "^0.0.405",
46
- "@malloydata/malloy": "^0.0.405",
47
- "@malloydata/malloy-sql": "^0.0.405",
48
- "@malloydata/render-validator": "^0.0.405",
39
+ "@malloydata/db-bigquery": "^0.0.415",
40
+ "@malloydata/db-databricks": "^0.0.415",
41
+ "@malloydata/db-duckdb": "^0.0.415",
42
+ "@malloydata/db-mysql": "^0.0.415",
43
+ "@malloydata/db-postgres": "^0.0.415",
44
+ "@malloydata/db-publisher": "^0.0.415",
45
+ "@malloydata/db-snowflake": "^0.0.415",
46
+ "@malloydata/db-trino": "^0.0.415",
47
+ "@malloydata/malloy": "^0.0.415",
48
+ "@malloydata/malloy-sql": "^0.0.415",
49
+ "@malloydata/render-validator": "^0.0.415",
49
50
  "@modelcontextprotocol/sdk": "^1.13.2",
50
51
  "@opentelemetry/api": "^1.9.0",
51
52
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
@@ -65,6 +66,7 @@
65
66
  "globals": "^15.9.0",
66
67
  "handlebars": "^4.7.8",
67
68
  "http-proxy-middleware": "^3.0.5",
69
+ "lunr": "^2.3.6",
68
70
  "morgan": "^1.10.0",
69
71
  "node-cron": "^3.0.3",
70
72
  "recursive-readdir": "^2.2.3",
@@ -80,6 +82,7 @@
80
82
  "@types/bun": "^1.2.20",
81
83
  "@types/cors": "^2.8.12",
82
84
  "@types/express": "^4.17.14",
85
+ "@types/lunr": "^2.3.7",
83
86
  "@types/morgan": "^1.9.9",
84
87
  "@types/node": "^24.10.1",
85
88
  "@types/node-cron": "^3.0.11",
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as sinon from "sinon";
3
+ import { BadRequestError } from "../errors";
4
+ import type { MaterializationService } from "../service/materialization_service";
5
+ import { MaterializationController } from "./materialization.controller";
6
+
7
+ /**
8
+ * Unit tests for {@link MaterializationController.createMaterialization}'s body
9
+ * validation. The service is stubbed so each test asserts the parsed options
10
+ * the controller forwards (or the rejection for a malformed body).
11
+ */
12
+ function build() {
13
+ const createMaterialization = sinon.stub().resolves({ id: "m1" });
14
+ const service = {
15
+ createMaterialization,
16
+ } as unknown as MaterializationService;
17
+ const controller = new MaterializationController(service);
18
+ return { controller, createMaterialization };
19
+ }
20
+
21
+ /** Parsed options the controller forwards for a given request body. */
22
+ async function parse(body: Record<string, unknown>) {
23
+ const { controller, createMaterialization } = build();
24
+ await controller.createMaterialization("env", "pkg", body);
25
+ return createMaterialization.firstCall.args[2];
26
+ }
27
+
28
+ describe("MaterializationController.createMaterialization validation", () => {
29
+ it("forwards an empty body as empty options", async () => {
30
+ expect(await parse({})).toEqual({});
31
+ });
32
+
33
+ it("passes through forceRefresh and sourceNames", async () => {
34
+ expect(
35
+ await parse({ forceRefresh: true, sourceNames: ["a", "b"] }),
36
+ ).toEqual({ forceRefresh: true, sourceNames: ["a", "b"] });
37
+ });
38
+
39
+ it("rejects a non-boolean forceRefresh", async () => {
40
+ const { controller } = build();
41
+ await expect(
42
+ controller.createMaterialization("env", "pkg", {
43
+ forceRefresh: "yes",
44
+ }),
45
+ ).rejects.toThrow(BadRequestError);
46
+ });
47
+
48
+ it("rejects sourceNames that is not an array of strings", async () => {
49
+ const { controller } = build();
50
+ await expect(
51
+ controller.createMaterialization("env", "pkg", {
52
+ sourceNames: [1, 2],
53
+ }),
54
+ ).rejects.toThrow(BadRequestError);
55
+ });
56
+
57
+ it("flattens buildInstructions.sources into the instruction list", async () => {
58
+ const parsed = await parse({
59
+ buildInstructions: {
60
+ sources: [
61
+ {
62
+ buildId: "b1",
63
+ sourceID: "orders@m",
64
+ materializedTableId: "mt-1",
65
+ physicalTableName: "orders_v1",
66
+ realization: "COPY",
67
+ },
68
+ ],
69
+ },
70
+ });
71
+ expect(parsed).toEqual({
72
+ buildInstructions: [
73
+ {
74
+ buildId: "b1",
75
+ sourceID: "orders@m",
76
+ materializedTableId: "mt-1",
77
+ physicalTableName: "orders_v1",
78
+ realization: "COPY",
79
+ },
80
+ ],
81
+ });
82
+ });
83
+
84
+ it("treats null buildInstructions as absent (auto-run)", async () => {
85
+ expect(await parse({ buildInstructions: null })).toEqual({});
86
+ });
87
+
88
+ it("rejects buildInstructions without a non-empty sources array", async () => {
89
+ const { controller } = build();
90
+ await expect(
91
+ controller.createMaterialization("env", "pkg", {
92
+ buildInstructions: { sources: [] },
93
+ }),
94
+ ).rejects.toThrow(BadRequestError);
95
+ });
96
+
97
+ it("rejects an instruction missing a required field", async () => {
98
+ const { controller } = build();
99
+ await expect(
100
+ controller.createMaterialization("env", "pkg", {
101
+ buildInstructions: {
102
+ sources: [{ buildId: "b1", materializedTableId: "mt-1" }],
103
+ },
104
+ }),
105
+ ).rejects.toThrow(BadRequestError);
106
+ });
107
+
108
+ it("rejects an unrecognized realization", async () => {
109
+ const { controller } = build();
110
+ await expect(
111
+ controller.createMaterialization("env", "pkg", {
112
+ buildInstructions: {
113
+ sources: [
114
+ {
115
+ buildId: "b1",
116
+ materializedTableId: "mt-1",
117
+ physicalTableName: "orders_v1",
118
+ realization: "MERGE",
119
+ },
120
+ ],
121
+ },
122
+ }),
123
+ ).rejects.toThrow(BadRequestError);
124
+ });
125
+ });
@@ -20,18 +20,20 @@ export class MaterializationController {
20
20
  private validateCreateBody(body: Record<string, unknown>): {
21
21
  forceRefresh?: boolean;
22
22
  sourceNames?: string[];
23
- pauseBetweenPhases?: boolean;
23
+ buildInstructions?: BuildInstruction[];
24
24
  } {
25
25
  const result: {
26
26
  forceRefresh?: boolean;
27
27
  sourceNames?: string[];
28
- pauseBetweenPhases?: boolean;
28
+ buildInstructions?: BuildInstruction[];
29
29
  } = {};
30
- if (body.pauseBetweenPhases !== undefined) {
31
- if (typeof body.pauseBetweenPhases !== "boolean") {
32
- throw new BadRequestError("pauseBetweenPhases must be a boolean");
33
- }
34
- result.pauseBetweenPhases = body.pauseBetweenPhases;
30
+ if (
31
+ body.buildInstructions !== undefined &&
32
+ body.buildInstructions !== null
33
+ ) {
34
+ result.buildInstructions = this.validateBuildInstructions(
35
+ body.buildInstructions,
36
+ );
35
37
  }
36
38
  if (body.forceRefresh !== undefined) {
37
39
  if (typeof body.forceRefresh !== "boolean") {
@@ -53,30 +55,24 @@ export class MaterializationController {
53
55
  return result;
54
56
  }
55
57
 
56
- async buildMaterialization(
57
- environmentName: string,
58
- packageName: string,
59
- materializationId: string,
60
- body: Record<string, unknown>,
61
- ) {
62
- return this.materializationService.buildMaterialization(
63
- environmentName,
64
- packageName,
65
- materializationId,
66
- this.validateBuildBody(body),
67
- );
68
- }
69
-
70
- private validateBuildBody(
71
- body: Record<string, unknown>,
72
- ): BuildInstruction[] {
73
- const sources = body.sources;
58
+ /**
59
+ * Validate the orchestrated `buildInstructions` payload (BuildInstructions:
60
+ * `{ sources: BuildInstruction[] }`) and flatten it to the instruction list
61
+ * the service consumes.
62
+ */
63
+ private validateBuildInstructions(raw: unknown): BuildInstruction[] {
64
+ if (typeof raw !== "object" || raw === null) {
65
+ throw new BadRequestError("buildInstructions must be an object");
66
+ }
67
+ const sources = (raw as Record<string, unknown>).sources;
74
68
  if (!Array.isArray(sources) || sources.length === 0) {
75
69
  throw new BadRequestError(
76
- "build requires a non-empty 'sources' array of BuildInstruction",
70
+ "buildInstructions requires a non-empty 'sources' array of BuildInstruction",
77
71
  );
78
72
  }
79
- return sources.map((raw) => this.validateInstruction(raw));
73
+ return sources.map((instruction) =>
74
+ this.validateInstruction(instruction),
75
+ );
80
76
  }
81
77
 
82
78
  private validateInstruction(raw: unknown): BuildInstruction {
package/src/health.ts CHANGED
@@ -74,6 +74,7 @@ export function registerSignalHandlers(
74
74
  mcpServer: Server,
75
75
  shutdownDrainDurationSeconds: number = 0,
76
76
  shutdownGracefulCloseTimeoutSeconds: number = 0,
77
+ agentMcpServer?: Server,
77
78
  ): void {
78
79
  // Keep the process alive on SIGTERM — do not close the server.
79
80
  // K8s will SIGKILL after terminationGracePeriodSeconds (which cannot be caught).
@@ -96,6 +97,7 @@ export function registerSignalHandlers(
96
97
  server,
97
98
  mcpServer,
98
99
  shutdownGracefulCloseTimeoutSeconds,
100
+ agentMcpServer,
99
101
  );
100
102
  });
101
103
  }
@@ -114,6 +116,7 @@ export async function performGracefulShutdownAfterDrain(
114
116
  server: Server,
115
117
  mcpServer: Server,
116
118
  shutdownGracefulCloseTimeoutSeconds: number,
119
+ agentMcpServer?: Server,
117
120
  ): Promise<void> {
118
121
  const closeServer = (server: Server, name: string) =>
119
122
  new Promise<void>((resolve) => {
@@ -134,6 +137,9 @@ export async function performGracefulShutdownAfterDrain(
134
137
  await Promise.all([
135
138
  closeServer(server, "Main server"),
136
139
  closeServer(mcpServer, "MCP server"),
140
+ ...(agentMcpServer
141
+ ? [closeServer(agentMcpServer, "Agent MCP server")]
142
+ : []),
137
143
  ]);
138
144
 
139
145
  try {
@@ -1,12 +1,11 @@
1
1
  /**
2
- * Centralized telemetry for the two-round materialization protocol.
2
+ * Centralized telemetry for materialization builds.
3
3
  *
4
- * Operators need to answer "are builds completing, how long do the two
5
- * rounds take, and where are they failing?" without grepping logs. The
6
- * counter carries `round` (`round1`|`round2`) and `outcome`
7
- * (`success`|`failed`|`cancelled`); the histogram carries `round` so a
8
- * dashboard can render Round 1 (compile+plan) vs Round 2 (build) latency
9
- * side by side.
4
+ * Operators need to answer "are builds completing, how long do they take, and
5
+ * where are they failing?" without grepping logs. The run counter carries
6
+ * `mode` (`auto`|`orchestrated`) and `outcome` (`success`|`failed`|`cancelled`);
7
+ * the run-duration histogram carries `mode` so a dashboard can render auto-run
8
+ * vs orchestrated build latency side by side.
10
9
  *
11
10
  * Lazy init for the same reason as {@link ./query_cap_metrics}: instruments
12
11
  * created before `setGlobalMeterProvider` bind to a NoOp meter
@@ -15,58 +14,138 @@
15
14
 
16
15
  import { metrics, type Counter, type Histogram } from "@opentelemetry/api";
17
16
 
18
- export type MaterializationRound = "round1" | "round2" | "auto";
17
+ export type MaterializationMode = "auto" | "orchestrated";
19
18
  export type MaterializationOutcome = "success" | "failed" | "cancelled";
20
19
  /** Manifest bind outcome: timeout is split out from generic failure on purpose. */
21
20
  export type ManifestBindOutcome = "success" | "failure" | "timeout";
22
21
 
23
- let roundCounter: Counter | null = null;
24
- let roundDuration: Histogram | null = null;
22
+ let runCounter: Counter | null = null;
23
+ let runDuration: Histogram | null = null;
24
+ let sourcesCounter: Counter | null = null;
25
+ let buildPlanComputeDuration: Histogram | null = null;
26
+ let autoLoadCounter: Counter | null = null;
27
+ let connectionDigestSkipCounter: Counter | null = null;
25
28
  let manifestBindCounter: Counter | null = null;
26
29
  let sourceBuildDuration: Histogram | null = null;
27
30
  let dropTablesCounter: Counter | null = null;
28
31
 
29
32
  function ensureTelemetry(): { counter: Counter; duration: Histogram } {
30
- if (roundCounter && roundDuration) {
31
- return { counter: roundCounter, duration: roundDuration };
33
+ if (runCounter && runDuration) {
34
+ return { counter: runCounter, duration: runDuration };
32
35
  }
33
36
  const meter = metrics.getMeter("publisher");
34
- if (!roundCounter) {
35
- roundCounter = meter.createCounter(
36
- "publisher_materialization_rounds_total",
37
- {
38
- description:
39
- "Materialization rounds completed. Labels: round ('round1'|'round2'|'auto'), outcome ('success'|'failed'|'cancelled').",
40
- },
41
- );
37
+ if (!runCounter) {
38
+ runCounter = meter.createCounter("publisher_materialization_runs_total", {
39
+ description:
40
+ "Materialization builds completed. Labels: mode ('auto'|'orchestrated'), outcome ('success'|'failed'|'cancelled').",
41
+ });
42
42
  }
43
- if (!roundDuration) {
44
- roundDuration = meter.createHistogram(
45
- "publisher_materialization_round_duration_ms",
43
+ if (!runDuration) {
44
+ runDuration = meter.createHistogram(
45
+ "publisher_materialization_run_duration_ms",
46
46
  {
47
47
  description:
48
- "Wall-clock duration of a materialization round. Label: round ('round1'|'round2'|'auto').",
48
+ "Wall-clock duration of a materialization build. Label: mode ('auto'|'orchestrated').",
49
49
  unit: "ms",
50
50
  },
51
51
  );
52
52
  }
53
- return { counter: roundCounter, duration: roundDuration };
53
+ return { counter: runCounter, duration: runDuration };
54
54
  }
55
55
 
56
- /** Record the outcome and duration of a materialization round. */
57
- export function recordMaterializationRound(
58
- round: MaterializationRound,
56
+ /** Record the outcome and duration of a materialization build. */
57
+ export function recordMaterializationRun(
58
+ mode: MaterializationMode,
59
59
  outcome: MaterializationOutcome,
60
60
  durationMs: number,
61
61
  ): void {
62
62
  const { counter, duration } = ensureTelemetry();
63
- counter.add(1, { round, outcome });
64
- duration.record(durationMs, { round });
63
+ counter.add(1, { mode, outcome });
64
+ duration.record(durationMs, { mode });
65
+ }
66
+
67
+ /**
68
+ * Record how many persist sources a run built vs. reused (carried forward
69
+ * unchanged via skip-if-unchanged). Lets a dashboard show the reuse ratio,
70
+ * the main lever on materialization cost.
71
+ */
72
+ export function recordSourcesOutcome(
73
+ outcome: "built" | "reused",
74
+ count: number,
75
+ ): void {
76
+ if (count <= 0) return;
77
+ if (!sourcesCounter) {
78
+ sourcesCounter = metrics
79
+ .getMeter("publisher")
80
+ .createCounter("publisher_materialization_sources_total", {
81
+ description:
82
+ "Persist sources processed by a materialization run. Label: outcome ('built'|'reused').",
83
+ });
84
+ }
85
+ sourcesCounter.add(count, { outcome });
86
+ }
87
+
88
+ /**
89
+ * Record the wall-clock cost of compiling a package's build plan
90
+ * (`Package.buildPlan`). This recompiles models at load, so it is worth
91
+ * tracking as a discrete cost separate from the build itself.
92
+ */
93
+ export function recordBuildPlanComputeDuration(durationMs: number): void {
94
+ if (!buildPlanComputeDuration) {
95
+ buildPlanComputeDuration = metrics
96
+ .getMeter("publisher")
97
+ .createHistogram(
98
+ "publisher_materialization_build_plan_compute_duration_ms",
99
+ {
100
+ description:
101
+ "Wall-clock duration of compiling a package's build plan (Package.buildPlan).",
102
+ unit: "ms",
103
+ },
104
+ );
105
+ }
106
+ buildPlanComputeDuration.record(durationMs);
107
+ }
108
+
109
+ /**
110
+ * Record an auto-run manifest auto-load attempt. Auto-load is best-effort (a
111
+ * failure does not fail the run), so a failure here is otherwise invisible:
112
+ * the run is MANIFEST_FILE_READY but queries still resolve to the old tables.
113
+ */
114
+ export function recordAutoLoadOutcome(outcome: "success" | "failure"): void {
115
+ if (!autoLoadCounter) {
116
+ autoLoadCounter = metrics
117
+ .getMeter("publisher")
118
+ .createCounter("publisher_materialization_auto_load_total", {
119
+ description:
120
+ "Auto-run manifest auto-load attempts. Label: outcome ('success'|'failure').",
121
+ });
122
+ }
123
+ autoLoadCounter.add(1, { outcome });
124
+ }
125
+
126
+ /**
127
+ * Record that a connection digest could not be computed during build-plan
128
+ * compilation because the connection failed to resolve. The resulting buildIds
129
+ * are computed without a digest, so this is a correctness signal, not just noise.
130
+ */
131
+ export function recordConnectionDigestSkipped(): void {
132
+ if (!connectionDigestSkipCounter) {
133
+ connectionDigestSkipCounter = metrics
134
+ .getMeter("publisher")
135
+ .createCounter(
136
+ "publisher_materialization_connection_digest_skipped_total",
137
+ {
138
+ description:
139
+ "Connection digests skipped during build-plan compile because the connection did not resolve.",
140
+ },
141
+ );
142
+ }
143
+ connectionDigestSkipCounter.add(1);
65
144
  }
66
145
 
67
146
  /**
68
- * Record a manifest-bind attempt (publisher binding a control-plane manifest to
69
- * a package at load). `timeout` is distinguished from `failure` so operators can
147
+ * Record a manifest-bind attempt (publisher binding a configured manifest to a
148
+ * package at load). `timeout` is distinguished from `failure` so operators can
70
149
  * tell an unreachable/slow manifest store from a malformed manifest.
71
150
  */
72
151
  export function recordManifestBind(outcome: ManifestBindOutcome): void {
@@ -113,8 +192,12 @@ export function recordDropTables(outcome: "success" | "failure"): void {
113
192
 
114
193
  /** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
115
194
  export function resetMaterializationTelemetryForTesting(): void {
116
- roundCounter = null;
117
- roundDuration = null;
195
+ runCounter = null;
196
+ runDuration = null;
197
+ sourcesCounter = null;
198
+ buildPlanComputeDuration = null;
199
+ autoLoadCounter = null;
200
+ connectionDigestSkipCounter = null;
118
201
  manifestBindCounter = null;
119
202
  sourceBuildDuration = null;
120
203
  dropTablesCounter = null;
@@ -0,0 +1,78 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
+ import { initializeAgentMcpServer } from "./agent_server";
5
+ import type { EnvironmentStore } from "../service/environment_store";
6
+
7
+ /**
8
+ * End-to-end coverage of the agent MCP server over the real MCP protocol, using
9
+ * the SDK's in-memory transport (no HTTP, no network, no DuckDB). Exercises tool
10
+ * registration, the dual-channel prompts, a real tool call (searchDocs over the
11
+ * bundled index), and the getContext error path.
12
+ */
13
+ describe("agent MCP server over the MCP protocol (in-memory)", () => {
14
+ let client: Client;
15
+
16
+ beforeAll(async () => {
17
+ // searchDocs does not touch the store; getContext's error path only needs
18
+ // getEnvironment to reject, so a throwing stub is sufficient here.
19
+ const stubStore = {
20
+ getEnvironment: async (name: string): Promise<never> => {
21
+ throw new Error(`Environment not found: ${name}`);
22
+ },
23
+ } as unknown as EnvironmentStore;
24
+
25
+ const server = initializeAgentMcpServer(stubStore);
26
+ const [clientTransport, serverTransport] =
27
+ InMemoryTransport.createLinkedPair();
28
+ await server.connect(serverTransport);
29
+ client = new Client({ name: "agent-protocol-test", version: "0.0.0" });
30
+ await client.connect(clientTransport);
31
+ });
32
+
33
+ it("exposes exactly the two agent tools", async () => {
34
+ const { tools } = await client.listTools();
35
+ expect(new Set(tools.map((t) => t.name))).toEqual(
36
+ new Set(["malloy_getContext", "malloy_searchDocs"]),
37
+ );
38
+ });
39
+
40
+ it("exposes the skill set as dual-channel prompts", async () => {
41
+ const { prompts } = await client.listPrompts();
42
+ expect(prompts.length).toBeGreaterThanOrEqual(24);
43
+ expect(prompts.some((p) => p.name === "malloy-analysis")).toBe(true);
44
+ });
45
+
46
+ it("malloy_searchDocs returns relevant docs over the protocol", async () => {
47
+ const res = await client.callTool({
48
+ name: "malloy_searchDocs",
49
+ arguments: { query: "window functions" },
50
+ });
51
+ const content = res.content as Array<{ resource?: { text?: string } }>;
52
+ const results = JSON.parse(
53
+ content?.[0]?.resource?.text ?? "[]",
54
+ ) as Array<{
55
+ url: string;
56
+ }>;
57
+ expect(results.length).toBeGreaterThan(0);
58
+ expect(results[0].url.length).toBeGreaterThan(0);
59
+ });
60
+
61
+ it("malloy_getContext fails gracefully (isError) for an unknown environment", async () => {
62
+ const res = await client.callTool({
63
+ name: "malloy_getContext",
64
+ arguments: {
65
+ environmentName: "nope",
66
+ packageName: "nope",
67
+ query: "x",
68
+ },
69
+ });
70
+ expect(res.isError).toBe(true);
71
+ });
72
+
73
+ it("a skill prompt returns its body", async () => {
74
+ const res = await client.getPrompt({ name: "malloy-analysis" });
75
+ const content = res.messages[0].content as { text?: string };
76
+ expect((content.text ?? "").length).toBeGreaterThan(200);
77
+ });
78
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { agentServerInfo, initializeAgentMcpServer } from "./agent_server";
3
+ import type { EnvironmentStore } from "../service/environment_store";
4
+
5
+ describe("agent MCP server", () => {
6
+ it("advertises a distinct server name from the core MCP server", () => {
7
+ expect(agentServerInfo.name).toBe("malloy-publisher-agent-mcp-server");
8
+ });
9
+
10
+ it("initializes and registers its tools and skill prompts without throwing", () => {
11
+ // The store is captured for later handler calls, not used during
12
+ // registration, so a placeholder is sufficient for this smoke test.
13
+ const server = initializeAgentMcpServer(
14
+ {} as unknown as EnvironmentStore,
15
+ );
16
+ expect(server).toBeTruthy();
17
+ });
18
+ });