@malloy-publisher/server 0.0.233 → 0.0.235

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 (62) hide show
  1. package/README.docker.md +1 -0
  2. package/dist/app/api-doc.yaml +208 -0
  3. package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
  4. package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
  5. package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
  6. package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
  8. package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
  9. package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
  10. package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
  13. package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
  14. package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
  15. package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
  16. package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
  17. package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
  18. package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/package_load_worker.mjs +53 -3
  21. package/dist/server.mjs +835 -97
  22. package/package.json +12 -12
  23. package/src/config.ts +35 -1
  24. package/src/controller/connection.controller.spec.ts +46 -0
  25. package/src/controller/connection.controller.ts +105 -2
  26. package/src/controller/materialization.controller.spec.ts +25 -0
  27. package/src/controller/materialization.controller.ts +60 -0
  28. package/src/controller/model.controller.ts +24 -0
  29. package/src/controller/query.controller.ts +83 -15
  30. package/src/mcp/handler_utils.ts +10 -2
  31. package/src/mcp/query_envelope.ts +10 -0
  32. package/src/mcp/skills/skills_bundle.json +1 -1
  33. package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
  34. package/src/mcp/tools/execute_query_tool.ts +62 -25
  35. package/src/mcp_config.spec.ts +919 -0
  36. package/src/mcp_config.ts +425 -0
  37. package/src/oom_guards.integration.spec.ts +11 -3
  38. package/src/package_load/package_load_pool.ts +2 -0
  39. package/src/package_load/package_load_worker.ts +17 -5
  40. package/src/package_load/protocol.ts +6 -0
  41. package/src/query_metadata_metrics.ts +49 -0
  42. package/src/server.ts +99 -3
  43. package/src/service/build_plan.spec.ts +125 -0
  44. package/src/service/build_plan.ts +108 -7
  45. package/src/service/connection_config.ts +49 -0
  46. package/src/service/environment.ts +57 -3
  47. package/src/service/materialization_config_validation.spec.ts +99 -0
  48. package/src/service/materialization_config_validation.ts +120 -0
  49. package/src/service/materialization_schedule_surface.spec.ts +124 -0
  50. package/src/service/materialization_service.spec.ts +119 -0
  51. package/src/service/materialization_service.ts +186 -3
  52. package/src/service/materialization_test_fixtures.ts +86 -21
  53. package/src/service/model.spec.ts +45 -1
  54. package/src/service/model.ts +145 -19
  55. package/src/service/package.ts +24 -1
  56. package/src/service/package_manifest.spec.ts +137 -4
  57. package/src/service/package_manifest.ts +140 -5
  58. package/src/service/persist_annotation_validation.spec.ts +12 -0
  59. package/src/service/persist_annotation_validation.ts +9 -4
  60. package/src/service/query_metadata.spec.ts +408 -0
  61. package/src/service/query_metadata.ts +492 -0
  62. package/src/service/query_metadata_identity.spec.ts +149 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Put this server in the host's MCP config, so an agent opened here finds the
3
+ * `malloy_*` tools without being told how.
4
+ *
5
+ * An MCP client only knows a server exists if something registered it: a
6
+ * `.mcp.json` in the directory the session starts in, or a manual
7
+ * `claude mcp add`. A running server is otherwise invisible to it, and starting
8
+ * the server first does not help, because registration and ordering are separate
9
+ * requirements. The repo ships a committed `.mcp.json` and the scaffolder writes
10
+ * one, so `npx @malloy-publisher/server` was the only way in that did not
11
+ * register itself, which is the only reason getting-started had to teach
12
+ * `claude mcp add` before a reader could ask a question.
13
+ *
14
+ * Creates only, never edits, and never reads. The file may hold other servers
15
+ * and their credentials, reading it can block when it is a FIFO, and a boot
16
+ * that rewrites it would churn version-controlled files. The scaffolder can
17
+ * afford to merge because it runs once at the user's request; a server boot
18
+ * cannot.
19
+ *
20
+ * A symlink named `.mcp.json` is never written through. `wx` (`O_EXCL`) refuses
21
+ * one on POSIX whether or not its target exists, but Windows resolves the
22
+ * reparse point first, so a DANGLING link there gets its target created. So the
23
+ * symlink is rejected explicitly, using the same `lstat` that reports a stale
24
+ * config, and `wx` stays as the race-free backstop for everything else.
25
+ */
26
+ import * as fs from "fs";
27
+ import * as os from "os";
28
+ import * as path from "path";
29
+ import { parseBoolEnv } from "./config";
30
+ import { logger } from "./logger";
31
+
32
+ export const MCP_CONFIG_FILENAME = ".mcp.json";
33
+
34
+ /** What happened, so the caller can log it and tests can assert on it. */
35
+ export type McpConfigOutcome =
36
+ | {
37
+ action: "skipped-home";
38
+ dir: string;
39
+ endpoint: string;
40
+ staleConfig: string | undefined;
41
+ }
42
+ | {
43
+ action: "skipped-git";
44
+ dir: string;
45
+ /** The directory holding `.git`, which is often far above `dir`. */
46
+ gitRoot: string;
47
+ /** A `.mcp.json` already up-tree, named so the reader can go look. */
48
+ rootConfig: string | undefined;
49
+ endpoint: string;
50
+ staleConfig: string | undefined;
51
+ }
52
+ | {
53
+ action: "skipped-root";
54
+ dir: string;
55
+ endpoint: string;
56
+ staleConfig: string | undefined;
57
+ }
58
+ | {
59
+ action: "skipped-unstable-port";
60
+ dir: string;
61
+ endpoint: string;
62
+ requestedPort: number;
63
+ boundPort: number;
64
+ staleConfig: string | undefined;
65
+ }
66
+ | { action: "created"; file: string }
67
+ | { action: "exists"; file: string; endpoint: string }
68
+ | { action: "failed"; file: string; problem: string; endpoint: string };
69
+
70
+ /** Pinned rather than `Record<string, string>`, so `type` cannot drift. */
71
+ type McpServerEntry = { type: "http"; url: string };
72
+
73
+ function malloyServer(endpoint: string): McpServerEntry {
74
+ return { type: "http", url: endpoint };
75
+ }
76
+
77
+ /**
78
+ * The port to write, given what was asked for and what the OS handed back.
79
+ *
80
+ * Extracted from the listen callback so it can be tested: this is the piece
81
+ * that makes "no ports to know" true, and a silent fallback to the requested
82
+ * value would write a config naming a port nothing is on.
83
+ */
84
+ export function resolveBoundPort(
85
+ address: ReturnType<import("net").Server["address"]>,
86
+ requestedPort: number,
87
+ ): number {
88
+ return typeof address === "object" && address ? address.port : requestedPort;
89
+ }
90
+
91
+ /**
92
+ * The host an agent on this machine should dial to reach the bound socket. A
93
+ * wildcard becomes the loopback literal of its own family; a specific bind
94
+ * address is written as-is, since when the server is bound only to some address
95
+ * that address is the only one that reaches it.
96
+ *
97
+ * Not `localhost`, which is ambiguous: it resolves to both `127.0.0.1` and
98
+ * `::1`, while the server binds exactly one family (`0.0.0.0` is the IPv4
99
+ * wildcard and does not cover IPv6). With the server on IPv4, `::1` is free, any
100
+ * local process can bind the same port there, and clients using `fetch` prefer
101
+ * the IPv6 answer, so that process receives the traffic. Reproduced 3 times out
102
+ * of 3 before this was changed.
103
+ *
104
+ * This is unambiguous naming, not isolation. On macOS a process can still bind
105
+ * the more specific `127.0.0.1:PORT` while this server holds the wildcard, and
106
+ * any attacker able to do that already runs as this user and could rewrite the
107
+ * file directly. `displayHost` in `service/environment_store.ts` answers the
108
+ * same question with `localhost`, as do the committed root `.mcp.json` and the
109
+ * scaffolder. Reconciling all three is its own change: one feeds a documented
110
+ * line scripts parse, and another ships from a different package.
111
+ */
112
+ export function resolveClientHost(
113
+ address: ReturnType<import("net").Server["address"]>,
114
+ fallbackHost: string,
115
+ ): string {
116
+ // fallbackHost is the one user-controlled input here (everything else is
117
+ // OS-derived), and it is only consulted on a path no runtime reaches today.
118
+ // Anything that is not an IP literal is discarded rather than interpolated:
119
+ // a hostname would reintroduce the ambiguity this function exists to remove,
120
+ // and a quote would break out of the quoting in addCommand, in a line the
121
+ // docs tell people to paste.
122
+ const usableFallback = /^[0-9a-fA-F:.]+$/.test(fallbackHost)
123
+ ? fallbackHost
124
+ : "";
125
+ const fromSocket =
126
+ typeof address === "object" && address ? address.address : undefined;
127
+ // Both runtimes populate `address` for a TCP socket. This runs in a listen
128
+ // callback where a throw kills an already-bound server, so a missing or
129
+ // non-string value falls back rather than throwing, and both no-address
130
+ // shapes fall back the same way.
131
+ const raw =
132
+ typeof fromSocket === "string" && fromSocket
133
+ ? fromSocket
134
+ : usableFallback;
135
+ if (raw === "0.0.0.0" || raw === "") return "127.0.0.1";
136
+ if (raw === "::" || raw === "::0") return "[::1]";
137
+ if (!raw.includes(":")) return raw;
138
+ // Bracket an IPv6 literal so it is a legal URL authority, and drop any zone
139
+ // index first: node keeps `%en0` in address() where bun strips it, and no
140
+ // HTTP client can dial a zone anyway, so keeping it writes a URL that fails
141
+ // to parse rather than one that merely cannot connect.
142
+ return `[${raw.split("%")[0]}]`;
143
+ }
144
+
145
+ /** The URL to write into the config and to print in advice. */
146
+ export function mcpEndpoint(host: string, port: number): string {
147
+ return `http://${host}:${port}/mcp`;
148
+ }
149
+
150
+ /**
151
+ * Is this directory inside a git working tree?
152
+ *
153
+ * A checkout belongs to somebody, and dropping an untracked file into one is both
154
+ * a surprise in `git status` and a thing that gets committed by accident. A clone
155
+ * of this repo is the sharp case: the documented `bun run start` chdirs into
156
+ * `packages/server`, so without this the file landed there, untracked, and not at
157
+ * the root where a session actually reads it.
158
+ *
159
+ * This is deliberately blunt: one `.git` anywhere above the working directory is
160
+ * enough, so it also covers a user's own project, which is a normal place to run
161
+ * the server. That case gets no file, so the caller logs the manual command
162
+ * rather than leaving them with nothing.
163
+ *
164
+ * Walks up rather than shelling out to git, and treats `.git` as present whether
165
+ * it is a directory or the file that worktrees and submodules use.
166
+ */
167
+ function findGitWorkTreeRoot(dir: string): string | undefined {
168
+ let current = path.resolve(dir);
169
+ for (;;) {
170
+ if (fs.existsSync(path.join(current, ".git"))) return current;
171
+ const parent = path.dirname(current);
172
+ if (parent === current) return undefined;
173
+ current = parent;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Is the feature on?
179
+ *
180
+ * `parseBoolEnv`, not a bespoke set: `config.ts` already owns boolean env
181
+ * parsing for this server and throws on an unrecognised value, so a typo in a
182
+ * manifest is loud rather than silently flipping a filesystem write. An earlier
183
+ * version of this function invented its own spellings, including stripping
184
+ * surrounding quotes, which is a problem every flag here would share and so
185
+ * belongs in the shared helper if it belongs anywhere.
186
+ */
187
+ export function mcpConfigEnabled(): boolean {
188
+ return !(parseBoolEnv("PUBLISHER_NO_MCP_CONFIG") ?? false);
189
+ }
190
+
191
+ /**
192
+ * Create `<dir>/.mcp.json` if this directory should have one and does not.
193
+ *
194
+ * Never throws. A convenience file must not be able to stop a server booting, so
195
+ * every failure path returns an outcome instead.
196
+ */
197
+ export function ensureMcpConfig(options: {
198
+ dir: string;
199
+ /** The URL an agent on this machine should dial, from `mcpEndpoint`. */
200
+ endpoint: string;
201
+ /**
202
+ * What was asked for, and what the OS actually gave. Required as a pair: when
203
+ * only one was supplied the guard below silently did nothing, which is the
204
+ * defect it exists to prevent.
205
+ */
206
+ requestedPort: number;
207
+ boundPort: number;
208
+ /**
209
+ * The home directory to stay out of. Defaults to `os.homedir()`; injected by
210
+ * tests so they never depend on the developer's real one, where a regression
211
+ * in guard ordering would write the file instead of going red.
212
+ */
213
+ homeDir?: string;
214
+ }): McpConfigOutcome {
215
+ const { dir, endpoint, requestedPort, boundPort, homeDir } = options;
216
+
217
+ const file = path.join(dir, MCP_CONFIG_FILENAME);
218
+ try {
219
+ // Probed before the guards, not just via EEXIST on the write. Every skip
220
+ // returns before writing, so without this a stale config sitting in this
221
+ // very directory goes unmentioned in exactly the cases where it is the
222
+ // most useful thing to say.
223
+ // lstat, not existsSync: they disagree on a dangling symlink, which is
224
+ // the case this module's header calls out, and the write path (via
225
+ // EEXIST) counts that as present. Reporting and writing must agree.
226
+ const existing = (():
227
+ | { path: string; isSymlink: boolean }
228
+ | undefined => {
229
+ try {
230
+ return {
231
+ path: file,
232
+ isSymlink: fs.lstatSync(file).isSymbolicLink(),
233
+ };
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ })();
238
+ const staleConfig = existing?.path;
239
+
240
+ // Only write a port that will still be this port next boot. Comparing
241
+ // bound against requested catches every way they diverge with one
242
+ // condition: `--mcp_port 0` asks for any free port, and a non-numeric
243
+ // MCP_PORT becomes NaN, which bun binds ephemerally too (k8s injects
244
+ // MCP_PORT=tcp://... into every pod in a namespace with a Service named
245
+ // `mcp`). Create-never-edit means the first boot's file is never
246
+ // corrected, so a file naming a port that moves is permanently wrong.
247
+ if (boundPort !== requestedPort) {
248
+ return {
249
+ action: "skipped-unstable-port",
250
+ dir,
251
+ endpoint,
252
+ requestedPort,
253
+ boundPort,
254
+ staleConfig,
255
+ };
256
+ }
257
+ // Inside the try because os.homedir() throws when HOME is unset and the uid
258
+ // has no passwd entry, which is the ordinary distroless container shape.
259
+ // realpath, not resolve: `resolve` normalises `.` and `..` but not
260
+ // symlinks, while process.cwd() is always fully resolved. With HOME on a
261
+ // symlinked path (an automounted /home is the common one) the two forms
262
+ // never matched and the file landed in the home directory this guard
263
+ // exists to protect. Falls back to the unresolved path when it does not
264
+ // exist, since realpathSync throws on a missing directory.
265
+ const realish = (p: string) => {
266
+ try {
267
+ return fs.realpathSync(p);
268
+ } catch {
269
+ return path.resolve(p);
270
+ }
271
+ };
272
+ if (realish(dir) === realish(homeDir ?? os.homedir())) {
273
+ return { action: "skipped-home", dir, endpoint, staleConfig };
274
+ }
275
+ // The filesystem root is the other directory nobody opens an agent in,
276
+ // and it is where a process manager puts you by default: systemd gives
277
+ // system units a working directory of `/`.
278
+ if (path.resolve(dir) === path.parse(path.resolve(dir)).root) {
279
+ return { action: "skipped-root", dir, endpoint, staleConfig };
280
+ }
281
+ const gitRoot = findGitWorkTreeRoot(dir);
282
+ if (gitRoot !== undefined) {
283
+ // Existence only, never a read. Reported rather than acted on: it tells
284
+ // the reader where to look, and cannot tell us whether that file names
285
+ // this server or some unrelated one, so it must not decide whether to
286
+ // stay quiet.
287
+ const rootCandidate = path.join(gitRoot, MCP_CONFIG_FILENAME);
288
+ return {
289
+ action: "skipped-git",
290
+ dir,
291
+ gitRoot,
292
+ rootConfig: fs.existsSync(rootCandidate)
293
+ ? rootCandidate
294
+ : undefined,
295
+ endpoint,
296
+ staleConfig,
297
+ };
298
+ }
299
+
300
+ // Refuse a symlink before attempting the write, rather than relying on
301
+ // `wx` to refuse it. `O_EXCL` does that on POSIX whether or not the
302
+ // target exists, but Windows resolves the reparse point first, so
303
+ // CREATE_NEW on a DANGLING link creates the target: the write-through
304
+ // this module exists to prevent, on the one platform where `wx` does not
305
+ // prevent it. Caught by the cross-platform CI run, not by reasoning.
306
+ // `wx` stays as the race-free backstop for the non-symlink case.
307
+ if (existing?.isSymlink) return { action: "exists", file, endpoint };
308
+
309
+ const body =
310
+ JSON.stringify(
311
+ { mcpServers: { malloy: malloyServer(endpoint) } },
312
+ null,
313
+ 2,
314
+ ) + "\n";
315
+ fs.writeFileSync(file, body, { encoding: "utf8", flag: "wx" });
316
+ return { action: "created", file };
317
+ } catch (error) {
318
+ const code = (error as NodeJS.ErrnoException)?.code;
319
+ // EEXIST covers both an ordinary existing file and a dangling symlink, and
320
+ // the response to each is the same: leave it alone, unread.
321
+ if (code === "EEXIST") return { action: "exists", file, endpoint };
322
+ return {
323
+ action: "failed",
324
+ file,
325
+ problem: error instanceof Error ? error.message : String(error),
326
+ endpoint,
327
+ };
328
+ }
329
+ }
330
+
331
+ /**
332
+ * The command to register this server by hand, at the DEFAULT (local) scope.
333
+ *
334
+ * Not `-s user`. Claude Code resolves a duplicate name by precedence, local then
335
+ * project then user, and takes the winning entry whole rather than merging. Every
336
+ * branch that prints this either has a `.mcp.json` in the directory or may have
337
+ * one up-tree, and that file is project scope, so a user-scoped registration is
338
+ * silently shadowed by the very file that caused the message. Local outranks it
339
+ * and works. `-s user` still belongs in the README's separate "register once for
340
+ * every directory" instruction, where nothing shadows it.
341
+ */
342
+ export function addCommand(endpoint: string): string {
343
+ // Quoted because an IPv6 endpoint contains brackets, which zsh (the macOS
344
+ // default) treats as a glob and refuses outright: `no matches found`. The
345
+ // docs tell the reader to paste this line as printed.
346
+ return `claude mcp add --transport http malloy '${endpoint}'`;
347
+ }
348
+
349
+ /**
350
+ * Say whether an agent opened here will find this server, and what to do when it
351
+ * will not.
352
+ *
353
+ * Every branch that leaves the user without a file says so at `info`, with the
354
+ * endpoint and the command. Noise is preferred to silence on purpose: a skip
355
+ * that says nothing is indistinguishable from a broken install, and `debug` is
356
+ * not a middle ground, because it disappears entirely at `LOG_LEVEL=info`.
357
+ */
358
+ /** The stale-file warning, appended to whichever skip fired. */
359
+ function staleNote(
360
+ staleConfig: string | undefined,
361
+ alreadyNamed?: string,
362
+ ): string {
363
+ // `alreadyNamed` is the git branch's rootConfig: when cwd is the repo root
364
+ // the two are the same path, and naming it twice reads like two files.
365
+ return staleConfig === undefined || staleConfig === alreadyNamed
366
+ ? ""
367
+ : `. Note that ${staleConfig} is already here and was not written by this run, so an agent started here uses whatever that names.`;
368
+ }
369
+
370
+ export function logMcpConfigOutcome(outcome: McpConfigOutcome): void {
371
+ switch (outcome.action) {
372
+ case "created":
373
+ logger.info(
374
+ `Wrote ${outcome.file} so an agent started in this directory finds this server. Disable with --no-mcp-config.`,
375
+ );
376
+ return;
377
+ case "exists":
378
+ // States the endpoint rather than speculating that the file is wrong.
379
+ // This fires on every boot of a scaffolded package, where the file is
380
+ // almost always correct, so it must not read as a warning.
381
+ logger.info(
382
+ `Left the existing ${outcome.file} alone, unread. This server is at ${outcome.endpoint}. If an agent started here reaches a different Publisher than you expect, ask it to run malloy_getContext, which names the environment and packages it is actually talking to. To point it here: ${addCommand(outcome.endpoint)}`,
383
+ );
384
+ return;
385
+ case "failed":
386
+ // info, not warn: nothing is broken. The server is serving, and a
387
+ // convenience file was not created. On a read-only filesystem this
388
+ // fires on every pod of every rollout, where a warning would be picked
389
+ // up by alerting and read by someone who cannot act on it.
390
+ logger.info(
391
+ `Could not write ${outcome.file} (${outcome.problem}). An agent started here will not find this server on its own. To connect one, run: ${addCommand(outcome.endpoint)}`,
392
+ );
393
+ return;
394
+ case "skipped-git":
395
+ logger.info(
396
+ outcome.rootConfig !== undefined
397
+ ? // Named, not acted on: it may register an unrelated server, or
398
+ // name a port this run is not on, and we never read it to find
399
+ // out. So the endpoint and the command are still given.
400
+ `Did not write ${MCP_CONFIG_FILENAME} into ${outcome.dir} because it is inside the git working tree at ${outcome.gitRoot}, which already has ${outcome.rootConfig}. This server is at ${outcome.endpoint}; if your agent does not list malloy, run this from the directory you start it in: ${addCommand(outcome.endpoint)}${staleNote(outcome.staleConfig, outcome.rootConfig)}`
401
+ : `Did not write ${MCP_CONFIG_FILENAME} into ${outcome.dir} because it is inside the git working tree at ${outcome.gitRoot}. To connect an agent, run this from the directory you start it in: ${addCommand(outcome.endpoint)}${staleNote(outcome.staleConfig)}`,
402
+ );
403
+ return;
404
+ case "skipped-home":
405
+ logger.info(
406
+ `Did not write ${MCP_CONFIG_FILENAME} into your home directory (${outcome.dir}). To connect an agent, run this from the directory you start it in: ${addCommand(outcome.endpoint)}${staleNote(outcome.staleConfig)}`,
407
+ );
408
+ return;
409
+ case "skipped-unstable-port":
410
+ logger.info(
411
+ `Did not write ${MCP_CONFIG_FILENAME}: this server asked for MCP port ${outcome.requestedPort} and got ${outcome.boundPort}, so the port changes from run to run and a saved config would be wrong next boot. To connect an agent to this run: ${addCommand(outcome.endpoint)}. That registration outlives this run, and the port will have moved by the next one, so undo it with claude mcp remove malloy${staleNote(outcome.staleConfig)}`,
412
+ );
413
+ return;
414
+ case "skipped-root":
415
+ logger.info(
416
+ `Did not write ${MCP_CONFIG_FILENAME} into the filesystem root (${outcome.dir}). To connect an agent, run this from the directory you start it in: ${addCommand(outcome.endpoint)}${staleNote(outcome.staleConfig)}`,
417
+ );
418
+ return;
419
+ default: {
420
+ // A new variant must be handled above rather than logging nothing.
421
+ const exhaustive: never = outcome;
422
+ return exhaustive;
423
+ }
424
+ }
425
+ }
@@ -113,11 +113,15 @@ describe("OOM guardrails: end-to-end chain", () => {
113
113
  max: process.env.PUBLISHER_MAX_QUERY_ROWS,
114
114
  timeout: process.env.PUBLISHER_QUERY_TIMEOUT_MS,
115
115
  concurrency: process.env.PUBLISHER_MAX_CONCURRENT_QUERIES,
116
+ metadata: process.env.PUBLISHER_QUERY_METADATA,
116
117
  };
117
118
 
118
119
  beforeEach(() => {
119
120
  resetActiveQueryCountForTesting();
120
121
  resetQueryConcurrencyTelemetryForTesting();
122
+ // Query metadata ships dark; the happy path below asserts the correlation
123
+ // id the response carries, which needs it on.
124
+ process.env.PUBLISHER_QUERY_METADATA = "on";
121
125
  });
122
126
 
123
127
  afterEach(() => {
@@ -132,6 +136,7 @@ describe("OOM guardrails: end-to-end chain", () => {
132
136
  restore("max", "PUBLISHER_MAX_QUERY_ROWS");
133
137
  restore("timeout", "PUBLISHER_QUERY_TIMEOUT_MS");
134
138
  restore("concurrency", "PUBLISHER_MAX_CONCURRENT_QUERIES");
139
+ restore("metadata", "PUBLISHER_QUERY_METADATA");
135
140
  });
136
141
 
137
142
  it("admission gate fires FIRST: 503 before the connector is touched", async () => {
@@ -254,8 +259,11 @@ describe("OOM guardrails: end-to-end chain", () => {
254
259
  .send({ sqlStatement: "SELECT 1" });
255
260
 
256
261
  expect(res.status).toBe(200);
257
- expect(res.body).toEqual({
258
- data: JSON.stringify({ rows, totalRows: rows.length }),
259
- });
262
+ expect(res.body.data).toEqual(
263
+ JSON.stringify({ rows, totalRows: rows.length }),
264
+ );
265
+ // The response carries the id the statement was tagged with, so a caller
266
+ // can find this query in the backend's own history.
267
+ expect(res.body.queryCorrelationId).toMatch(/^[0-9a-f-]{36}$/);
260
268
  });
261
269
  });
@@ -239,6 +239,8 @@ export interface LoadPackageOutcome {
239
239
  manifestLocation?: string | null;
240
240
  materialization?: PackageMaterializationConfig | null;
241
241
  scope?: PackageScope;
242
+ /** See {@link LoadPackageResult.packageMetadata.manifestWarnings}. */
243
+ manifestWarnings?: string[];
242
244
  };
243
245
  models: Array<
244
246
  Omit<SerializedModel, "modelDef" | "sourceInfos"> & {
@@ -86,8 +86,9 @@ import { type FilterDefinition } from "../service/filter";
86
86
  import {
87
87
  PackageMaterializationConfig,
88
88
  PackageScope,
89
+ packageMaterializationWarnings,
89
90
  parsePackageMaterialization,
90
- parsePackageScope,
91
+ resolvePackageScope,
91
92
  } from "../service/package_manifest";
92
93
  import {
93
94
  extractQueriesFromModelDef,
@@ -411,6 +412,7 @@ async function readPackageMetadata(packagePath: string): Promise<{
411
412
  manifestLocation?: string | null;
412
413
  materialization?: PackageMaterializationConfig | null;
413
414
  scope?: PackageScope;
415
+ manifestWarnings?: string[];
414
416
  }> {
415
417
  const manifestPath = path.join(packagePath, PACKAGE_MANIFEST_NAME);
416
418
  const contents = await fs.promises.readFile(manifestPath, "utf8");
@@ -423,6 +425,14 @@ async function readPackageMetadata(packagePath: string): Promise<{
423
425
  materialization?: unknown;
424
426
  scope?: unknown;
425
427
  };
428
+ // Scope has two homes (canonical `materialization.scope`, deprecated root);
429
+ // an invalid value or a conflict between the two throws and fails the load,
430
+ // and the deprecation rides back as a warning.
431
+ const scope = resolvePackageScope(parsed.scope, parsed.materialization);
432
+ const manifestWarnings = [
433
+ ...scope.warnings,
434
+ ...packageMaterializationWarnings(parsed.materialization),
435
+ ];
426
436
  return {
427
437
  name: parsed.name,
428
438
  description: parsed.description,
@@ -439,11 +449,13 @@ async function readPackageMetadata(packagePath: string): Promise<{
439
449
  ? parsed.manifestLocation
440
450
  : null,
441
451
  // Package-level Malloy Persistence policy; surfaced to the control plane,
442
- // which owns scheduling. Only `schedule` is read today.
452
+ // which owns scheduling. `schedule`/`freshness` are for the control plane;
453
+ // `queryMetadata` is the publisher's own package-level layer.
443
454
  materialization: parsePackageMaterialization(parsed.materialization),
444
- // Package-level persist scope mode; defaults to "package". An invalid
445
- // value throws here and fails the load (scope is load-bearing).
446
- scope: parsePackageScope(parsed.scope),
455
+ // Package-level persist scope mode; defaults to "package".
456
+ scope: scope.scope,
457
+ manifestWarnings:
458
+ manifestWarnings.length > 0 ? manifestWarnings : undefined,
447
459
  };
448
460
  }
449
461
 
@@ -185,6 +185,12 @@ export interface LoadPackageResult {
185
185
  manifestLocation?: string | null;
186
186
  materialization?: PackageMaterializationConfig | null;
187
187
  scope?: PackageScope;
188
+ /**
189
+ * Manifest-shape deprecations the load tolerated (e.g. a root-level
190
+ * `scope`). Surfaced with the package's other policy warnings so a
191
+ * still-parsing-but-outdated manifest is visible without failing a load.
192
+ */
193
+ manifestWarnings?: string[];
188
194
  };
189
195
  models: SerializedModel[];
190
196
  /** Wall-clock ms inside the worker for the full package load. */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Telemetry for per-query metadata (see `service/query_metadata.ts`).
3
+ *
4
+ * The resolve path never throws — it clamps a bag that would violate Malloy's
5
+ * contract — so a drop is invisible without a metric: an operator would see
6
+ * correct-looking queries whose warehouse-side attribution is quietly missing a
7
+ * property. The counters make both halves observable: what was attached, and
8
+ * what did not fit.
9
+ *
10
+ * Instruments are created lazily for the same reason as {@link ./query_cap_metrics}:
11
+ * one created before `setGlobalMeterProvider` binds to a NoOp meter
12
+ * (https://github.com/open-telemetry/opentelemetry-js/issues/3505).
13
+ */
14
+
15
+ import { type Counter } from "@opentelemetry/api";
16
+ import { publisherMeter } from "./telemetry";
17
+
18
+ const resetHooks: (() => void)[] = [];
19
+
20
+ function lazyCounter(name: string, description: string): () => Counter {
21
+ let instrument: Counter | null = null;
22
+ resetHooks.push(() => (instrument = null));
23
+ return () =>
24
+ (instrument ??= publisherMeter().createCounter(name, { description }));
25
+ }
26
+
27
+ const appliedCounter = lazyCounter(
28
+ "publisher_query_metadata_applied_total",
29
+ "Queries issued with per-query metadata attached. Label: class ('interactive'|'materialize'|'index'|'ops'|'unknown').",
30
+ );
31
+ const droppedCounter = lazyCounter(
32
+ "publisher_query_metadata_properties_dropped_total",
33
+ "Metadata properties dropped while resolving a bag. Label: reason ('invalid_name'|'invalid_value'|'property_cap'|'serialized_cap').",
34
+ );
35
+
36
+ /** One query's metadata reached the connector. */
37
+ export function recordQueryMetadataApplied(queryClass: string): void {
38
+ appliedCounter().add(1, { class: queryClass });
39
+ }
40
+
41
+ /** One property did not survive resolution. */
42
+ export function recordQueryMetadataDropped(reason: string): void {
43
+ droppedCounter().add(1, { reason });
44
+ }
45
+
46
+ /** Drop memoized instruments so a test can install a fresh MeterProvider. */
47
+ export function resetQueryMetadataMetricsForTest(): void {
48
+ for (const reset of resetHooks) reset();
49
+ }