@malloy-publisher/server 0.0.227 → 0.0.229

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 (106) hide show
  1. package/README.docker.md +8 -8
  2. package/dist/app/api-doc.yaml +84 -1
  3. package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
  4. package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
  5. package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
  6. package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
  7. package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
  8. package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
  9. package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
  10. package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
  11. package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
  12. package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
  13. package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
  14. package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
  15. package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
  16. package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
  17. package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
  18. package/dist/app/assets/index-DxArlgRD.js +529 -0
  19. package/dist/app/index.html +1 -1
  20. package/dist/default-publisher.config.json +7 -7
  21. package/dist/instrumentation.mjs +1 -1
  22. package/dist/package_load_worker.mjs +1 -1
  23. package/dist/runtime/publisher.js +5 -0
  24. package/dist/server.mjs +7675 -899
  25. package/package.json +4 -2
  26. package/publisher.config.example.bigquery.json +7 -7
  27. package/publisher.config.example.duckdb.json +7 -7
  28. package/publisher.config.json +7 -11
  29. package/src/config.spec.ts +89 -3
  30. package/src/config.ts +102 -1
  31. package/src/controller/materialization.controller.spec.ts +9 -0
  32. package/src/controller/materialization.controller.ts +15 -0
  33. package/src/controller/package.controller.ts +62 -31
  34. package/src/default-publisher.config.json +7 -7
  35. package/src/materialization_metrics.ts +33 -0
  36. package/src/mcp/handler_utils.spec.ts +108 -0
  37. package/src/mcp/handler_utils.ts +98 -4
  38. package/src/mcp/server.protocol.spec.ts +58 -0
  39. package/src/mcp/server.ts +29 -1
  40. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  41. package/src/mcp/skills/skills_bundle.json +1 -1
  42. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  43. package/src/mcp/tools/compile_tool.spec.ts +207 -0
  44. package/src/mcp/tools/compile_tool.ts +177 -0
  45. package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
  46. package/src/mcp/tools/execute_query_tool.ts +37 -4
  47. package/src/mcp/tools/get_context_tool.ts +1 -1
  48. package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
  49. package/src/mcp/tools/reload_package_tool.ts +158 -0
  50. package/src/query_param_utils.ts +11 -0
  51. package/src/runtime/publisher.js +5 -0
  52. package/src/server.ts +74 -14
  53. package/src/service/connection.spec.ts +8 -7
  54. package/src/service/cron_evaluator.spec.ts +79 -0
  55. package/src/service/cron_evaluator.ts +105 -0
  56. package/src/service/environment.ts +188 -19
  57. package/src/service/environment_store.spec.ts +372 -2
  58. package/src/service/environment_store.ts +149 -17
  59. package/src/service/environment_store_anchoring.spec.ts +107 -0
  60. package/src/service/manifest_loader.spec.ts +3 -1
  61. package/src/service/manifest_loader.ts +9 -5
  62. package/src/service/materialization_scheduler.spec.ts +260 -0
  63. package/src/service/materialization_scheduler.ts +304 -0
  64. package/src/service/materialization_service.spec.ts +167 -4
  65. package/src/service/materialization_service.ts +121 -8
  66. package/src/service/materialization_test_fixtures.ts +10 -1
  67. package/src/service/model.spec.ts +92 -0
  68. package/src/service/model.ts +58 -7
  69. package/src/service/package.ts +121 -14
  70. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  71. package/src/service/package_reload_safety.spec.ts +193 -0
  72. package/src/service/persistence_policy.spec.ts +31 -0
  73. package/src/service/quoting.spec.ts +57 -1
  74. package/src/service/quoting.ts +33 -0
  75. package/src/storage/DatabaseInterface.ts +15 -0
  76. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  77. package/src/storage/duckdb/MaterializationRepository.spec.ts +154 -0
  78. package/src/storage/duckdb/MaterializationRepository.ts +56 -0
  79. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  80. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  81. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  82. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  83. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  84. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  85. package/tests/fixtures/query-givens/data/orders.csv +7 -0
  86. package/tests/fixtures/query-givens/model.malloy +34 -0
  87. package/tests/fixtures/query-givens/publisher.json +5 -0
  88. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  89. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  90. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  91. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  92. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  93. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  94. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  95. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
  96. package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
  97. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
  98. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
  99. package/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
  100. package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
  101. package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
  102. package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
  103. package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
  104. package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
  105. package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
  106. package/dist/app/assets/index-DNUZpnaa.js +0 -527
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { buildSkills, type SkillEntry } from "./build_skills_bundle";
2
4
  import bundle from "./skills_bundle.json";
3
5
 
4
6
  const skills = (
@@ -7,9 +9,36 @@ const skills = (
7
9
  }
8
10
  ).skills;
9
11
 
12
+ /** The repo's top-level skills/, which this bundle is generated from. */
13
+ const sourceDir = path.join(
14
+ import.meta.dir,
15
+ "..",
16
+ "..",
17
+ "..",
18
+ "..",
19
+ "..",
20
+ "skills",
21
+ );
22
+
23
+ /**
24
+ * Codepoint order. The committed bundle is sorted with localeCompare, which
25
+ * depends on the runtime's locale, and this suite runs on three platforms.
26
+ * Membership and content are what matter here; file order is cosmetic.
27
+ */
28
+ const byName = (a: SkillEntry, b: SkillEntry) =>
29
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
30
+
10
31
  describe("skills_bundle.json (generated dual-channel asset)", () => {
11
- it("contains the full ported skill set", () => {
12
- expect(skills.length).toBeGreaterThanOrEqual(24);
32
+ it("is in sync with skills/", () => {
33
+ // Regenerate with:
34
+ // bun run src/mcp/skills/build_skills_bundle.ts ../../skills
35
+ expect([...skills].sort(byName)).toEqual(
36
+ [...buildSkills(sourceDir)].sort(byName),
37
+ );
38
+ });
39
+
40
+ it("is not empty", () => {
41
+ expect(skills.length).toBeGreaterThan(0);
13
42
  });
14
43
 
15
44
  it("every skill has a nonempty name, description, and body", () => {
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { registerCompileTool } from "./compile_tool";
3
+ import type { EnvironmentStore } from "../../service/environment_store";
4
+ import { PackageNotFoundError, ServiceUnavailableError } from "../../errors";
5
+
6
+ // Capture the handler registerCompileTool passes to McpServer.tool, so it can
7
+ // be exercised against a mocked EnvironmentStore. The tool builds a
8
+ // CompileController internally, which calls environment.compileSource, so the
9
+ // mock only needs getEnvironment -> { compileSource }.
10
+ type Handler = (params: Record<string, unknown>) => Promise<{
11
+ isError?: boolean;
12
+ content: Array<{ resource: { text: string } }>;
13
+ }>;
14
+
15
+ function captureHandler(store: Partial<EnvironmentStore>): Handler {
16
+ let handler: Handler | undefined;
17
+ const fakeServer = {
18
+ tool: (_name: string, _desc: string, _shape: unknown, h: Handler) => {
19
+ handler = h;
20
+ },
21
+ };
22
+ registerCompileTool(fakeServer as never, store as EnvironmentStore);
23
+ if (!handler) throw new Error("handler was not registered");
24
+ return handler;
25
+ }
26
+
27
+ function parse(result: { content: Array<{ resource: { text: string } }> }) {
28
+ return JSON.parse(result.content[0].resource.text);
29
+ }
30
+
31
+ function storeReturning(
32
+ problems: Array<Record<string, unknown>>,
33
+ sql?: string,
34
+ ): Partial<EnvironmentStore> {
35
+ return {
36
+ getEnvironment: async () =>
37
+ ({
38
+ compileSource: async () => ({ problems, sql }),
39
+ }) as never,
40
+ };
41
+ }
42
+
43
+ const args = {
44
+ environmentName: "malloy-samples",
45
+ packageName: "ecommerce",
46
+ modelPath: "ecommerce.malloy",
47
+ source: "run: order_items -> { aggregate: c is count() }",
48
+ };
49
+
50
+ describe("malloy_compile tool", () => {
51
+ it("returns status success and empty diagnostics for a clean compile", async () => {
52
+ const handler = captureHandler(storeReturning([]));
53
+ const result = await handler(args);
54
+ expect(result.isError).toBe(false);
55
+ expect(parse(result)).toEqual({ status: "success", diagnostics: [] });
56
+ });
57
+
58
+ it("flattens an error diagnostic and sets isError", async () => {
59
+ const handler = captureHandler(
60
+ storeReturning([
61
+ {
62
+ severity: "error",
63
+ message: "'nope' is not defined",
64
+ code: "field-not-found",
65
+ at: {
66
+ range: {
67
+ start: { line: 2, character: 3 },
68
+ end: { line: 2, character: 7 },
69
+ },
70
+ },
71
+ replacement: undefined,
72
+ },
73
+ ]),
74
+ );
75
+ const result = await handler(args);
76
+ expect(result.isError).toBe(true);
77
+ const parsed = parse(result);
78
+ expect(parsed.status).toBe("error");
79
+ expect(parsed.diagnostics).toEqual([
80
+ {
81
+ severity: "error",
82
+ message: "'nope' is not defined",
83
+ code: "field-not-found",
84
+ line: 2,
85
+ character: 3,
86
+ endLine: 2,
87
+ endCharacter: 7,
88
+ },
89
+ ]);
90
+ });
91
+
92
+ it("treats a warning as a clean compile (status success, isError false)", async () => {
93
+ const handler = captureHandler(
94
+ storeReturning([
95
+ {
96
+ severity: "warn",
97
+ message: "deprecated syntax",
98
+ code: "deprecated",
99
+ at: {
100
+ range: {
101
+ start: { line: 0, character: 0 },
102
+ end: { line: 0, character: 4 },
103
+ },
104
+ },
105
+ },
106
+ ]),
107
+ );
108
+ const result = await handler(args);
109
+ expect(result.isError).toBe(false);
110
+ const parsed = parse(result);
111
+ expect(parsed.status).toBe("success");
112
+ expect(parsed.diagnostics[0].severity).toBe("warn");
113
+ });
114
+
115
+ it("includes sql when the controller returns it", async () => {
116
+ const handler = captureHandler(storeReturning([], "SELECT 1"));
117
+ const parsed = parse(await handler({ ...args, includeSql: true }));
118
+ expect(parsed.sql).toBe("SELECT 1");
119
+ });
120
+
121
+ it("surfaces a thrown error as a tool error, not a transport fault", async () => {
122
+ const handler = captureHandler({
123
+ getEnvironment: async () => {
124
+ throw new Error("Environment 'nope' could not be resolved");
125
+ },
126
+ });
127
+ const result = await handler({ ...args, environmentName: "nope" });
128
+ expect(result.isError).toBe(true);
129
+ const parsed = parse(result);
130
+ expect(parsed.error).toBeDefined();
131
+ });
132
+
133
+ it("reports a missing package as not-found, not as a Malloy syntax problem", async () => {
134
+ // Pin the remediation text, not just that an error came back. Asserting
135
+ // only that `error` is defined passes even when every class funnels
136
+ // through the Malloy helper, which is the bug classifyToolError exists to
137
+ // fix: a typo'd package name told the caller to check its Malloy syntax.
138
+ const handler = captureHandler({
139
+ getEnvironment: async () => {
140
+ throw new PackageNotFoundError("Package 'nope' not found");
141
+ },
142
+ });
143
+ const parsed = parse(await handler({ ...args, packageName: "nope" }));
144
+ expect(parsed.error).toContain("Resource not found");
145
+ expect(JSON.stringify(parsed.suggestions)).not.toContain("Malloy file");
146
+ });
147
+
148
+ it("reports back-pressure as retryable, not as a Malloy syntax problem", async () => {
149
+ const handler = captureHandler({
150
+ getEnvironment: async () => {
151
+ throw new ServiceUnavailableError("Memory limit reached");
152
+ },
153
+ });
154
+ const parsed = parse(await handler(args));
155
+ expect(parsed.error).toContain("Memory limit reached");
156
+ expect(JSON.stringify(parsed.suggestions)).toContain("Retry");
157
+ expect(JSON.stringify(parsed.suggestions)).not.toContain("Malloy file");
158
+ });
159
+
160
+ it("reports an unexpected internal error without blaming the Malloy", async () => {
161
+ // The catch-all used to hand a TypeError to the Malloy helper, so a bug
162
+ // in our own code told the agent to go edit a model that was fine.
163
+ const handler = captureHandler({
164
+ getEnvironment: async () => {
165
+ throw new TypeError("cannot read properties of undefined");
166
+ },
167
+ });
168
+ const parsed = parse(await handler(args));
169
+ expect(parsed.error).toContain("unexpected internal error");
170
+ expect(JSON.stringify(parsed.suggestions)).not.toContain("Malloy file");
171
+ });
172
+
173
+ it("omits position fields when a diagnostic has no location", async () => {
174
+ const handler = captureHandler(
175
+ storeReturning([
176
+ { severity: "error", message: "no location", code: "syntax" },
177
+ ]),
178
+ );
179
+ const parsed = parse(await handler(args));
180
+ expect(parsed.diagnostics[0]).toEqual({
181
+ severity: "error",
182
+ message: "no location",
183
+ code: "syntax",
184
+ });
185
+ });
186
+
187
+ it("forwards givens to compileSource", async () => {
188
+ let captured: unknown;
189
+ const handler = captureHandler({
190
+ getEnvironment: async () =>
191
+ ({
192
+ compileSource: async (
193
+ _pkg: string,
194
+ _model: string,
195
+ _source: string,
196
+ _includeSql: boolean,
197
+ givens: unknown,
198
+ ) => {
199
+ captured = givens;
200
+ return { problems: [] };
201
+ },
202
+ }) as never,
203
+ });
204
+ await handler({ ...args, givens: { region: "west" } });
205
+ expect(captured).toEqual({ region: "west" });
206
+ });
207
+ });
@@ -0,0 +1,177 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import type { GivenValue } from "@malloydata/malloy";
4
+ import { logger } from "../../logger";
5
+ import { EnvironmentStore } from "../../service/environment_store";
6
+ import { CompileController } from "../../controller/compile.controller";
7
+ import { type ErrorDetails } from "../error_messages";
8
+ import { buildMalloyUri, classifyToolError } from "../handler_utils";
9
+
10
+ // Zod shape for malloy_compile. environmentName/packageName mirror the other
11
+ // tools and point the agent at malloy_getContext for name discovery.
12
+ const compileShape = {
13
+ environmentName: z
14
+ .string()
15
+ .describe(
16
+ "Environment name. Call malloy_getContext with no arguments to list the available environments.",
17
+ ),
18
+ packageName: z
19
+ .string()
20
+ .describe(
21
+ "Package containing the model. Call malloy_getContext with just environmentName to list its packages.",
22
+ ),
23
+ modelPath: z
24
+ .string()
25
+ .describe(
26
+ "Path to the .malloy model whose namespace the source compiles against. The source is appended to this model, so its imports, sources, and queries are in scope.",
27
+ ),
28
+ source: z
29
+ .string()
30
+ .describe(
31
+ "The Malloy source to validate. Compiled in the context of modelPath and not executed.",
32
+ ),
33
+ includeSql: z
34
+ .boolean()
35
+ .optional()
36
+ .describe(
37
+ "When true and the source ends in a runnable query, also return the generated SQL for inspection. The query is still not run.",
38
+ ),
39
+ givens: z
40
+ .record(z.unknown())
41
+ .optional()
42
+ .describe(
43
+ "Given values for the model's given: block. Also required to satisfy any #(authorize) gate on the target model, whether or not includeSql is set.",
44
+ ),
45
+ };
46
+
47
+ const COMPILE_DESCRIPTION = `Compile-check Malloy source against a model and return structured diagnostics WITHOUT running a query. Use this to validate a model or a change while authoring, instead of firing a throwaway malloy_executeQuery just to see whether it parses.
48
+
49
+ ## Parameters
50
+ - environmentName, packageName, modelPath (required): the model whose namespace the source compiles against. The source is appended to that model, so its imports, sources, and queries are in scope, and modelPath is real context, not a label.
51
+ - source (required): the Malloy text to validate.
52
+ - includeSql (optional): also return the generated SQL when the source ends in a runnable query. The query is still not executed and no data is scanned.
53
+
54
+ ## Response
55
+ A JSON object with status ("success" or "error") and diagnostics: an array of { severity ("error" / "warn" / "debug"), message, code, line, character, endLine, endCharacter, replacement }. Positions are 0-based (line and character start at 0) and relative to the model file with your source appended to it, so a diagnostic in your submitted source lands after the model's own line count, and a diagnostic may point at pre-existing content in the model rather than at your source. A clean compile can still return warnings; status is "error" only when at least one diagnostic has error severity.`;
56
+
57
+ /**
58
+ * Registers the malloy_compile MCP tool: validates Malloy source against a model
59
+ * and returns structured diagnostics without executing a query. Wraps the same
60
+ * in-process compile path the REST /compile endpoint uses (CompileController).
61
+ */
62
+ export function registerCompileTool(
63
+ mcpServer: McpServer,
64
+ environmentStore: EnvironmentStore,
65
+ ): void {
66
+ const compileController = new CompileController(environmentStore);
67
+
68
+ mcpServer.tool(
69
+ "malloy_compile",
70
+ COMPILE_DESCRIPTION,
71
+ compileShape,
72
+ async (params) => {
73
+ const {
74
+ environmentName,
75
+ packageName,
76
+ modelPath,
77
+ source,
78
+ includeSql,
79
+ givens,
80
+ } = params;
81
+
82
+ logger.info("[MCP Tool compile] Compiling source", {
83
+ environmentName,
84
+ packageName,
85
+ modelPath,
86
+ includeSql: !!includeSql,
87
+ });
88
+
89
+ const uri = buildMalloyUri(
90
+ {
91
+ environment: environmentName,
92
+ package: packageName,
93
+ resourceType: "models",
94
+ resourceName: modelPath,
95
+ },
96
+ "compile",
97
+ );
98
+
99
+ try {
100
+ const result = await compileController.compile(
101
+ environmentName,
102
+ packageName,
103
+ modelPath,
104
+ source,
105
+ includeSql ?? false,
106
+ givens as Record<string, GivenValue> | undefined,
107
+ );
108
+
109
+ // Flatten each LogMessage's nested at.range into line/character so
110
+ // agents do not have to walk it. Positions are 0-based (LSP-style).
111
+ const diagnostics = result.problems.map((p) => ({
112
+ severity: p.severity,
113
+ message: p.message,
114
+ code: p.code,
115
+ line: p.at?.range.start.line,
116
+ character: p.at?.range.start.character,
117
+ endLine: p.at?.range.end.line,
118
+ endCharacter: p.at?.range.end.character,
119
+ replacement: p.replacement,
120
+ }));
121
+
122
+ const payload = {
123
+ status: result.status,
124
+ diagnostics,
125
+ ...(result.sql !== undefined && { sql: result.sql }),
126
+ };
127
+
128
+ return {
129
+ isError: result.status === "error",
130
+ content: [
131
+ {
132
+ type: "resource" as const,
133
+ resource: {
134
+ type: "application/json",
135
+ uri,
136
+ text: JSON.stringify(payload),
137
+ },
138
+ },
139
+ ],
140
+ };
141
+ } catch (error) {
142
+ // Unknown environment/package, a notebook (.malloynb) rejected up
143
+ // front, an authorize denial, or a system error: surface as a clean
144
+ // isError payload rather than a transport fault. A missing modelPath
145
+ // does NOT error here; compileSource compiles the source against an
146
+ // empty namespace, so a typo in modelPath yields a normal result.
147
+ logger.warn("[MCP Tool compile] compile failed", {
148
+ environmentName,
149
+ packageName,
150
+ modelPath,
151
+ error: error instanceof Error ? error.message : String(error),
152
+ });
153
+ const errorDetails: ErrorDetails = classifyToolError(
154
+ "compile",
155
+ `${environmentName}/${packageName}/${modelPath}`,
156
+ error,
157
+ );
158
+ return {
159
+ isError: true,
160
+ content: [
161
+ {
162
+ type: "resource" as const,
163
+ resource: {
164
+ type: "application/json",
165
+ uri,
166
+ text: JSON.stringify({
167
+ error: errorDetails.message,
168
+ suggestions: errorDetails.suggestions,
169
+ }),
170
+ },
171
+ },
172
+ ],
173
+ };
174
+ }
175
+ },
176
+ );
177
+ }
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { MalloyError } from "@malloydata/malloy";
3
+ import { registerExecuteQueryTool } from "./execute_query_tool";
4
+ import type { EnvironmentStore } from "../../service/environment_store";
5
+ import {
6
+ NotQueryableError,
7
+ QueryTimeoutError,
8
+ ServiceUnavailableError,
9
+ } from "../../errors";
10
+
11
+ // Capture the handler registerExecuteQueryTool passes to McpServer.tool, so it
12
+ // can be exercised against a mocked EnvironmentStore. Mirrors the pattern in
13
+ // compile_tool.spec.ts and reload_package_tool.spec.ts.
14
+ type Handler = (params: Record<string, unknown>) => Promise<{
15
+ isError?: boolean;
16
+ content: Array<{ resource: { text: string } }>;
17
+ }>;
18
+
19
+ function captureHandler(store: Partial<EnvironmentStore>): Handler {
20
+ let handler: Handler | undefined;
21
+ const fakeServer = {
22
+ tool: (_name: string, _desc: string, _shape: unknown, h: Handler) => {
23
+ handler = h;
24
+ },
25
+ };
26
+ registerExecuteQueryTool(fakeServer as never, store as EnvironmentStore);
27
+ if (!handler) throw new Error("handler was not registered");
28
+ return handler;
29
+ }
30
+
31
+ function parse(result: { content: Array<{ resource: { text: string } }> }) {
32
+ return JSON.parse(result.content[0].resource.text);
33
+ }
34
+
35
+ /**
36
+ * A store whose model loads cleanly and whose QUERY throws.
37
+ *
38
+ * The throw has to originate inside the tool's own try block to pin anything.
39
+ * getModelForQuery already homes a back-pressure error from
40
+ * `environment.assertCanAdmitQuery()`, which runs outside it, so a test that
41
+ * trips that path passes even with the catch's classifyToolError call reverted.
42
+ * Failing at getQueryResults puts the error where tryAcquireQuerySlot's
43
+ * ServiceUnavailableError lands, which is the case that was reported.
44
+ */
45
+ function storeWhoseQueryThrows(error: unknown): Partial<EnvironmentStore> {
46
+ return {
47
+ getEnvironment: async () =>
48
+ ({
49
+ assertCanAdmitQuery: () => undefined,
50
+ getPackage: async () => ({
51
+ getModel: () => ({
52
+ getModelType: () => "model",
53
+ getModel: async () => ({}),
54
+ getQueryResults: async () => {
55
+ throw error;
56
+ },
57
+ }),
58
+ }),
59
+ }) as never,
60
+ };
61
+ }
62
+
63
+ const args = {
64
+ environmentName: "env",
65
+ packageName: "pkg",
66
+ modelPath: "m.malloy",
67
+ query: "run: a -> { aggregate: c is count() }",
68
+ };
69
+
70
+ describe("malloy_executeQuery error classification", () => {
71
+ it("tells an at-capacity caller to retry, not to check its Malloy", async () => {
72
+ // The reported bug. tryAcquireQuerySlot runs inside the tool's try, so at
73
+ // the concurrency cap its ServiceUnavailableError landed in a catch that
74
+ // funnelled everything through the Malloy helper: a 503 told the agent to
75
+ // go verify its syntax. This is the assertion that fails if the catch
76
+ // stops routing through classifyToolError.
77
+ const handler = captureHandler(
78
+ storeWhoseQueryThrows(
79
+ new ServiceUnavailableError("Memory limit reached"),
80
+ ),
81
+ );
82
+ const parsed = parse(await handler(args));
83
+ expect(parsed.error).toContain("Memory limit reached");
84
+ expect(JSON.stringify(parsed.suggestions)).toContain("Retry");
85
+ expect(JSON.stringify(parsed.suggestions)).not.toContain("Malloy file");
86
+ });
87
+
88
+ it("keeps Malloy advice for a real query error", async () => {
89
+ // The other half: a bad query throws a raw MalloyError, so homing by
90
+ // class must not send it to the internal branch.
91
+ const handler = captureHandler(
92
+ storeWhoseQueryThrows(new MalloyError("unexpected '@'", [])),
93
+ );
94
+ const parsed = parse(await handler(args));
95
+ expect(parsed.error).not.toContain("unexpected internal error");
96
+ expect(JSON.stringify(parsed.suggestions)).toContain("Malloy");
97
+ });
98
+
99
+ it("keeps the reload hint on an undefined name", async () => {
100
+ const handler = captureHandler(
101
+ storeWhoseQueryThrows(
102
+ new MalloyError("Reference to undefined object 'orders'", []),
103
+ ),
104
+ );
105
+ const parsed = parse(await handler(args));
106
+ expect(JSON.stringify(parsed.suggestions)).toContain(
107
+ "malloy_reloadPackage",
108
+ );
109
+ });
110
+
111
+ it("does not tell a timed-out query to try again later", async () => {
112
+ // Retrying an identical too-slow query fails the same way. The class
113
+ // exists to be distinguishable from the retryable 503, so the one thing
114
+ // it must not say is the internal branch's "try the request again later".
115
+ const handler = captureHandler(
116
+ storeWhoseQueryThrows(
117
+ new QueryTimeoutError("Query exceeded PUBLISHER_QUERY_TIMEOUT_MS"),
118
+ ),
119
+ );
120
+ const parsed = parse(await handler(args));
121
+ expect(JSON.stringify(parsed.suggestions)).toContain("not transient");
122
+ expect(JSON.stringify(parsed.suggestions)).not.toContain(
123
+ "Try the request again later",
124
+ );
125
+ });
126
+
127
+ it("reports a query-boundary denial as not-found, not as a server fault", async () => {
128
+ // NotQueryableError is a deliberate 404: a hidden source should look like
129
+ // a missing one. Reporting it as an internal error tells the caller to
130
+ // contact support about a boundary that is working correctly.
131
+ const handler = captureHandler(
132
+ storeWhoseQueryThrows(
133
+ new NotQueryableError('No queryable source "salaries".'),
134
+ ),
135
+ );
136
+ const parsed = parse(await handler(args));
137
+ expect(parsed.error).toContain("Resource not found");
138
+ expect(parsed.error).not.toContain("unexpected internal error");
139
+ // The class exists so a hidden target is indistinguishable from a missing
140
+ // one; echoing the name back would undo that.
141
+ expect(parsed.error).not.toContain("salaries");
142
+ });
143
+ });
@@ -10,10 +10,26 @@ import {
10
10
  } from "../../query_concurrency";
11
11
  import { runWithQueryTimeout } from "../../query_timeout";
12
12
  import { EnvironmentStore } from "../../service/environment_store";
13
- import { getMalloyErrorDetails, type ErrorDetails } from "../error_messages";
14
- import { buildMalloyUri, getModelForQuery } from "../handler_utils";
13
+ import { type ErrorDetails } from "../error_messages";
14
+ import {
15
+ buildMalloyUri,
16
+ classifyToolError,
17
+ getModelForQuery,
18
+ } from "../handler_utils";
15
19
  import { MCP_ERROR_MESSAGES } from "../mcp_constants";
16
20
 
21
+ /**
22
+ * Malloy's two ways of saying a name is not in the model's namespace: a bare
23
+ * reference ("Reference to undefined object 'x'") and a name used where the
24
+ * compiler expected a definition ("'x' is not defined").
25
+ */
26
+ function isUndefinedNameError(message: string): boolean {
27
+ return (
28
+ message.includes("is not defined") ||
29
+ message.includes("Reference to undefined object")
30
+ );
31
+ }
32
+
17
33
  // Zod shape defining required/optional params for executeQuery
18
34
  const executeQueryShape = {
19
35
  // environmentName is required; other fields mirror SDK expectations
@@ -267,17 +283,34 @@ export function registerExecuteQueryTool(
267
283
  `[MCP Server Error] Error executing query in ${environmentName}/${packageName}/${modelPath}:`,
268
284
  { error: queryError },
269
285
  );
270
- const errorDetails: ErrorDetails = getMalloyErrorDetails(
286
+ // Home the error by class first. tryAcquireQuerySlot runs inside
287
+ // this try, so at the concurrency cap a ServiceUnavailableError
288
+ // lands here; funnelling that through the Malloy helper told the
289
+ // agent to check its syntax when the answer was to retry.
290
+ const errorDetails: ErrorDetails = classifyToolError(
271
291
  "executeQuery",
272
292
  `${environmentName}/${packageName}/${modelPath}`, // Include environment
273
293
  queryError,
274
294
  );
275
295
 
296
+ // A name the model does not define reads as a typo, and the
297
+ // suggestions say so. But the same error is what an author gets
298
+ // after saving a new source or view: the served model is the one
299
+ // compiled at boot, so the name exists on disk and not in memory.
300
+ // Point at the reload rather than let them hunt for a typo that
301
+ // isn't there.
302
+ const suggestions = [...errorDetails.suggestions];
303
+ if (isUndefinedNameError(errorDetails.message)) {
304
+ suggestions.push(
305
+ "If you added or renamed this source or view on disk after the server loaded the package, the running model is still the one compiled at boot. Call malloy_reloadPackage for this package, then retry.",
306
+ );
307
+ }
308
+
276
309
  // Format error details as structured JSON
277
310
  const errorJson = JSON.stringify(
278
311
  {
279
312
  error: errorDetails.message,
280
- suggestions: errorDetails.suggestions,
313
+ suggestions,
281
314
  },
282
315
  null,
283
316
  2,
@@ -230,7 +230,7 @@ A JSON object with a results array whose items carry a kind field. For retrieval
230
230
  - Start broad and narrow down: list environments, then packages, then sources, then query.
231
231
 
232
232
  ## Worked example
233
- { "environmentName": "malloy-samples", "packageName": "ecommerce", "query": "revenue by product category" }`;
233
+ { "environmentName": "examples", "packageName": "storefront", "query": "revenue by product category" }`;
234
234
 
235
235
  /**
236
236
  * Wrap a JSON payload in the MCP resource-content shape every tier of this tool