@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,230 @@
1
+ import type * as Malloy from "@malloydata/malloy-interfaces";
2
+ import { bigIntReplacer } from "../json_utils";
3
+ import type { QueryRowLimitSource } from "../service/model_limits";
4
+
5
+ /**
6
+ * The agent-facing shape for a query result, matched to what Credible's
7
+ * `execute_query` returns.
8
+ *
9
+ * Matching matters for a specific workflow: a data app is authored locally
10
+ * against Publisher and then served through Credible. An agent that sees one
11
+ * response shape while developing and a different one in production has to
12
+ * learn both, and a shared skill cannot describe either without forking. So the
13
+ * field names here are Credible's, deliberately, including the leading
14
+ * underscores and the mixed casing of `renderLogErrors`.
15
+ *
16
+ * Also following Credible: the truncation fields appear only when truncation
17
+ * happened, rather than always carrying `false`.
18
+ *
19
+ * The two `_limit_...` fields are new to both products. Three separate things
20
+ * can shorten a result and only one of them was ever reported:
21
+ *
22
+ * 1. The query row cap, pushed into the SQL. A query with no `limit:` of its
23
+ * own gets DEFAULT_QUERY_ROW_LIMIT (1000) rows, which is far under
24
+ * PUBLISHER_MAX_QUERY_ROWS, so nothing raises and nothing warns. An agent
25
+ * reports statistics on a silent sample. This is why `_limit_hit` exists,
26
+ * and unlike the others it is not derivable by a client: the cap depends on
27
+ * server config and on the query's own LIMIT.
28
+ * 2. The hard ceiling (maxRows / maxBytes), which throws 413 and is loud.
29
+ * 3. The payload cap below, which degrades to a truncated result plus a
30
+ * warning instead of overflowing the client's per-result limit.
31
+ *
32
+ * `_limit_hit` is a bound, not a total. The server cannot know the true row
33
+ * count, because the database applied the cap. Landing exactly on the limit is
34
+ * the only evidence available that rows were left behind.
35
+ *
36
+ * It is reported only when the cap was the server default, which `_limit_source`
37
+ * names. `resolveModelQueryRowLimit` folds the query's own `limit:`/`top:` into
38
+ * the same number, so equality also holds every time an author limited the query
39
+ * on purpose: three of the eight views in the bundled storefront example use
40
+ * `top:`, and each would otherwise report its complete answer as cut off. The
41
+ * silent sample this exists to catch only ever happens under the default.
42
+ */
43
+ export interface QueryEnvelope {
44
+ rows: unknown;
45
+ /** Malloy metadata the flat rows drop: field types, render tags, timezone. */
46
+ _meta: {
47
+ schema: Malloy.Schema;
48
+ annotations: Malloy.Annotation[];
49
+ connection_name: string;
50
+ model_annotations?: Malloy.Annotation[];
51
+ query_timezone?: string;
52
+ source_annotations?: Malloy.Annotation[];
53
+ };
54
+ /** The cap pushed into the SQL: the query's own LIMIT, else the server default. */
55
+ _query_row_limit: number;
56
+ /** Which of those two the cap came from. */
57
+ _limit_source: QueryRowLimitSource;
58
+ /**
59
+ * Row count equals the cap AND the cap was the server default, so rows were
60
+ * almost certainly left behind. A deliberate `limit:`/`top:` that returns
61
+ * exactly what it asked for is a complete answer and does not set this.
62
+ */
63
+ _limit_hit: boolean;
64
+ /** Present only when the payload cap dropped rows. */
65
+ _rows_truncated?: boolean;
66
+ _total_rows?: number;
67
+ _returned_rows?: number;
68
+ warning?: string;
69
+ renderLogErrors?: string[];
70
+ }
71
+
72
+ /**
73
+ * Cap on the serialized envelope, in characters. Same value Credible uses.
74
+ *
75
+ * Host-loop MCP clients enforce a per-tool-result ceiling of roughly 25k tokens;
76
+ * past it the result is spilled to disk or rejected outright, and the model then
77
+ * struggles to recover it. Chars stand in for tokens at about 4:1, with headroom
78
+ * for the envelope itself.
79
+ */
80
+ export const MAX_RESULT_CHARS = 90_000;
81
+
82
+ function serialize(envelope: QueryEnvelope): string {
83
+ return JSON.stringify(envelope, bigIntReplacer, 2);
84
+ }
85
+
86
+ /**
87
+ * Build the envelope, applying the payload cap.
88
+ *
89
+ * @param rows compactResult: flat row objects, straight from the driver.
90
+ * @param rowLimit the cap pushed into the SQL.
91
+ * @param result the full Malloy result, read only for its metadata.
92
+ */
93
+ export function buildQueryEnvelope(
94
+ rows: unknown,
95
+ rowLimit: number,
96
+ result: Malloy.Result,
97
+ renderLogErrors: string[] = [],
98
+ limit = MAX_RESULT_CHARS,
99
+ rowLimitSource: QueryRowLimitSource = "server_default",
100
+ ): QueryEnvelope {
101
+ const rowCount = Array.isArray(rows) ? rows.length : 0;
102
+ // Equality, not >=: the cap is pushed into the SQL, so the database cannot
103
+ // return more than it. Landing exactly on it is the signal.
104
+ //
105
+ // Restricted to the server default on purpose. resolveModelQueryRowLimit
106
+ // folds the query's own limit:/top: into the same cap, so equality also holds
107
+ // every time an author deliberately limited the query: a modelled `top: 10`
108
+ // view returning its 10 rows would otherwise be reported as cut off, and the
109
+ // Contract rule would tell an agent that a top-N is "not the answer". Only
110
+ // the silently-applied default is evidence that rows were left behind.
111
+ const limitHit =
112
+ rowLimitSource === "server_default" &&
113
+ rowLimit > 0 &&
114
+ rowCount === rowLimit;
115
+
116
+ const envelope: QueryEnvelope = {
117
+ rows,
118
+ _meta: {
119
+ schema: result.schema,
120
+ annotations: result.annotations ?? [],
121
+ connection_name: result.connection_name,
122
+ ...(result.model_annotations !== undefined && {
123
+ model_annotations: result.model_annotations,
124
+ }),
125
+ ...(result.query_timezone !== undefined && {
126
+ query_timezone: result.query_timezone,
127
+ }),
128
+ ...(result.source_annotations !== undefined && {
129
+ source_annotations: result.source_annotations,
130
+ }),
131
+ },
132
+ _query_row_limit: rowLimit,
133
+ _limit_source: rowLimitSource,
134
+ _limit_hit: limitHit,
135
+ ...(renderLogErrors.length > 0 && { renderLogErrors }),
136
+ };
137
+
138
+ if (limitHit) {
139
+ envelope.warning =
140
+ `Returned exactly ${rowLimit} rows, the row limit applied to this query, so there are probably more. ` +
141
+ `This is not a complete result: add an explicit limit, aggregate, or filter rather than reporting these rows as the whole set.`;
142
+ }
143
+
144
+ return fitToBudget(envelope, limit);
145
+ }
146
+
147
+ /**
148
+ * Drop rows until the serialized envelope fits, by binary search on the row
149
+ * count.
150
+ *
151
+ * The marker fields are set BEFORE the search, so their serialized size counts
152
+ * against the limit. Adding them afterwards pushes the payload back over the cap
153
+ * the truncation existed to respect. `_returned_rows` is measured at `total`
154
+ * first, whose digit width is at least that of any value it ends up holding, so
155
+ * the finished payload stays under the limit. Credible's `_truncate_rows` does
156
+ * the same thing for the same reason.
157
+ *
158
+ * The search can land on zero rows, which needs its own wording rather than
159
+ * "Showing 0 of N rows": that reads as an empty result set, and an agent will
160
+ * report "no rows matched" for what is really one row too large to send. It is
161
+ * reachable, because the hard ceiling upstream is maxBytes (50MB by default), so
162
+ * a single row carrying a large text or JSON column passes
163
+ * assertWithinModelResponseLimits and arrives here. Both wordings are measured,
164
+ * and the search uses whichever is longer, so swapping one for the other after
165
+ * the fact cannot push the payload back over the cap.
166
+ *
167
+ * One case cannot be fixed by dropping rows: when the envelope minus its rows
168
+ * already exceeds the limit (a very wide schema in `_meta`). The result is then
169
+ * returned over-limit rather than emptied further, and the warning says so.
170
+ */
171
+ function fitToBudget(envelope: QueryEnvelope, limit: number): QueryEnvelope {
172
+ if (serialize(envelope).length <= limit) return envelope;
173
+
174
+ const rows = envelope.rows;
175
+ if (!Array.isArray(rows) || rows.length === 0) return envelope;
176
+ const total = rows.length;
177
+
178
+ const truncating: QueryEnvelope = {
179
+ ...envelope,
180
+ _rows_truncated: true,
181
+ _total_rows: total,
182
+ _returned_rows: total,
183
+ };
184
+ // The size warning joins any limit warning already present, and is included
185
+ // in the measurement for the same reason as the counts.
186
+ const sizeWarning = (kept: number) =>
187
+ `Showing ${kept} of ${total} rows; the rest were dropped to fit the result size limit. Narrow the query rather than paging through it.`;
188
+ const noneFitWarning =
189
+ `No rows fit the result size limit: the first of ${total} rows is too large to send on its own. ` +
190
+ `This is NOT an empty result and does not mean nothing matched. Select fewer columns, or truncate the oversized field, then run it again.`;
191
+ // Measure with whichever wording is longer, so the one actually returned is
192
+ // never bigger than the one the search sized the payload against.
193
+ const measured =
194
+ noneFitWarning.length > sizeWarning(total).length
195
+ ? noneFitWarning
196
+ : sizeWarning(total);
197
+ truncating.warning = [envelope.warning, measured].filter(Boolean).join(" ");
198
+
199
+ let low = 0;
200
+ let high = total;
201
+ let best = 0;
202
+ while (low <= high) {
203
+ const mid = Math.floor((low + high) / 2);
204
+ if (
205
+ serialize({ ...truncating, rows: rows.slice(0, mid) }).length <= limit
206
+ ) {
207
+ best = mid;
208
+ low = mid + 1;
209
+ } else {
210
+ high = mid - 1;
211
+ }
212
+ }
213
+
214
+ return {
215
+ ...truncating,
216
+ rows: rows.slice(0, best),
217
+ _returned_rows: best,
218
+ warning: [
219
+ envelope.warning,
220
+ best === 0 ? noneFitWarning : sizeWarning(best),
221
+ ]
222
+ .filter(Boolean)
223
+ .join(" "),
224
+ };
225
+ }
226
+
227
+ /** Serialize an envelope for transport, BigInt-safe. */
228
+ export function serializeEnvelope(envelope: QueryEnvelope): string {
229
+ return serialize(envelope);
230
+ }
@@ -16,10 +16,33 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
16
16
  let client: Client;
17
17
 
18
18
  beforeAll(async () => {
19
- // searchDocs does not touch the store; getContext's error path only needs
20
- // getEnvironment to reject, so a throwing stub is sufficient here.
19
+ // searchDocs does not touch the store, and the tools' error paths only
20
+ // need getEnvironment to reject. The one exception is `compiles-badly`,
21
+ // which resolves to a compileSource returning an error diagnostic: that
22
+ // is malloy_compile's own isError path (a bad Malloy snippet rather than
23
+ // a thrown error), and it is only reachable with an environment that
24
+ // exists.
21
25
  const stubStore = {
22
- getEnvironment: async (name: string): Promise<never> => {
26
+ getEnvironment: async (name: string) => {
27
+ if (name === "compiles-badly") {
28
+ return {
29
+ compileSource: async () => ({
30
+ problems: [
31
+ {
32
+ severity: "error",
33
+ message: "'nope' is not defined",
34
+ code: "field-not-found",
35
+ at: {
36
+ range: {
37
+ start: { line: 2, character: 3 },
38
+ end: { line: 2, character: 7 },
39
+ },
40
+ },
41
+ },
42
+ ],
43
+ }),
44
+ };
45
+ }
23
46
  throw new Error(`Environment not found: ${name}`);
24
47
  },
25
48
  } as unknown as EnvironmentStore;
@@ -42,6 +65,43 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
42
65
  expect(names.has("malloy_reloadPackage")).toBe(true);
43
66
  });
44
67
 
68
+ /**
69
+ * Tool descriptions are truncated by some clients. `malloy_getContext`'s was
70
+ * observed arriving cut off mid-sentence at 2271 characters, and what a tail
71
+ * cut removes is whatever the description put last.
72
+ *
73
+ * Two defenses, and the ordering one matters more. No single number is
74
+ * portable, since the cap belongs to the client and is not published; this
75
+ * budget only stops silent regrowth past the one length we have watched fail.
76
+ * The real rule is that contract rules come BEFORE the reference material, so
77
+ * a cut costs an agent the worked example rather than the invariants it
78
+ * cannot self-correct without. See docs/agent-skills/tool-description-template.md.
79
+ */
80
+ it("keeps every tool description under the truncation budget", async () => {
81
+ const BUDGET = 2000;
82
+ const { tools } = await client.listTools();
83
+ const oversized = tools
84
+ .filter((t) => (t.description ?? "").length > BUDGET)
85
+ .map((t) => `${t.name} (${t.description?.length})`);
86
+ expect(oversized).toEqual([]);
87
+ });
88
+
89
+ it("puts contract rules ahead of the reference sections", async () => {
90
+ // The section a tail-truncating client drops must never be the invariants.
91
+ const { tools } = await client.listTools();
92
+ for (const tool of tools) {
93
+ const description = tool.description ?? "";
94
+ const contract = description.indexOf("## Contract rules");
95
+ if (contract === -1) continue;
96
+ for (const later of ["## Response", "## Worked example"]) {
97
+ const index = description.indexOf(later);
98
+ if (index !== -1) {
99
+ expect(contract).toBeLessThan(index);
100
+ }
101
+ }
102
+ }
103
+ });
104
+
45
105
  it("exposes the skill set as dual-channel prompts", async () => {
46
106
  const { prompts } = await client.listPrompts();
47
107
  expect(prompts.length).toBeGreaterThanOrEqual(24);
@@ -74,18 +134,6 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
74
134
  expect(results[0].url.length).toBeGreaterThan(0);
75
135
  });
76
136
 
77
- it("malloy_getContext fails gracefully (isError) for an unknown environment", async () => {
78
- const res = await client.callTool({
79
- name: "malloy_getContext",
80
- arguments: {
81
- environmentName: "nope",
82
- packageName: "nope",
83
- query: "x",
84
- },
85
- });
86
- expect(res.isError).toBe(true);
87
- });
88
-
89
137
  /**
90
138
  * Assert the tool's OWN error payload, not just isError. The SDK sets
91
139
  * isError for any uncaught throw, so `expect(res.isError).toBe(true)` passes
@@ -99,16 +147,48 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
99
147
  } {
100
148
  const res = result as {
101
149
  isError?: boolean;
102
- content?: Array<{ type?: string; resource?: { text?: string } }>;
150
+ content?: Array<{
151
+ type?: string;
152
+ text?: string;
153
+ resource?: { text?: string; mimeType?: string };
154
+ }>;
103
155
  };
104
156
  expect(res.isError).toBe(true);
105
157
  expect(res.content?.[0]?.type).toBe("resource");
158
+ expect(res.content?.[0]?.resource?.mimeType).toBe("application/json");
106
159
  const payload = JSON.parse(res.content?.[0]?.resource?.text ?? "{}");
107
160
  expect(typeof payload.error).toBe("string");
108
161
  expect(Array.isArray(payload.suggestions)).toBe(true);
162
+
163
+ // The structured payload alone is invisible to a client that renders
164
+ // only text blocks on an error, which is how a real diagnostic ends up
165
+ // reported as a bare "Unknown error". Every error must also say it in
166
+ // plain text.
167
+ const textBlock = res.content?.find((b) => b.type === "text");
168
+ expect(textBlock?.text).toContain(payload.error);
169
+
109
170
  return payload;
110
171
  }
111
172
 
173
+ it("malloy_getContext returns its own error payload over the protocol", async () => {
174
+ const res = await client.callTool({
175
+ name: "malloy_getContext",
176
+ arguments: {
177
+ environmentName: "nope",
178
+ packageName: "nope",
179
+ query: "x",
180
+ },
181
+ });
182
+ expect(expectToolErrorPayload(res).error).toContain("nope");
183
+ // getContext answers with `results` at every tier, so an error keeps the
184
+ // key rather than making callers branch on success first.
185
+ const payload = JSON.parse(
186
+ (res.content as Array<{ resource?: { text?: string } }>)[0]?.resource
187
+ ?.text ?? "{}",
188
+ );
189
+ expect(payload.results).toEqual([]);
190
+ });
191
+
112
192
  it("malloy_compile returns its own error payload over the protocol", async () => {
113
193
  const res = await client.callTool({
114
194
  name: "malloy_compile",
@@ -122,6 +202,38 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
122
202
  expect(expectToolErrorPayload(res).error).toContain("nope");
123
203
  });
124
204
 
205
+ it("malloy_compile states a compile diagnostic in text over the protocol", async () => {
206
+ // The diagnostics path keeps its {status, diagnostics} payload rather
207
+ // than the {error, suggestions} of the thrown-error path, so it cannot go
208
+ // through expectToolErrorPayload. What it shares is the invariant that
209
+ // matters: an isError result is never resource-only. Asserted over the
210
+ // real transport because a client that renders only text is exactly the
211
+ // one this was invisible to.
212
+ const res = await client.callTool({
213
+ name: "malloy_compile",
214
+ arguments: {
215
+ environmentName: "compiles-badly",
216
+ packageName: "p",
217
+ modelPath: "m.malloy",
218
+ source: "run: nope -> { aggregate: c is count() }",
219
+ },
220
+ });
221
+ const content = res.content as Array<{
222
+ type?: string;
223
+ text?: string;
224
+ resource?: { text?: string; mimeType?: string };
225
+ }>;
226
+ expect(res.isError).toBe(true);
227
+ expect(content[0]?.type).toBe("resource");
228
+ expect(content[0]?.resource?.mimeType).toBe("application/json");
229
+ expect(JSON.parse(content[0]?.resource?.text ?? "{}").status).toBe(
230
+ "error",
231
+ );
232
+ const textBlock = content.find((b) => b.type === "text");
233
+ expect(textBlock?.text).toContain("'nope' is not defined");
234
+ expect(textBlock?.text).toContain("field-not-found");
235
+ });
236
+
125
237
  it("malloy_reloadPackage returns its own error payload over the protocol", async () => {
126
238
  const res = await client.callTool({
127
239
  name: "malloy_reloadPackage",
@@ -51,14 +51,102 @@ export function isCredible(dirName: string): boolean {
51
51
  return dirName.toLowerCase().startsWith("credible-");
52
52
  }
53
53
 
54
- /** Read every skill under a skills directory, in the bundle's own order. */
54
+ /** The subdirectory a skill keeps its on-demand detail in. */
55
+ const REFERENCE_DIR = "reference";
56
+
57
+ /** Prompt name for one reference file: `<skill>/<file stem>`. */
58
+ export function referenceName(skillName: string, file: string): string {
59
+ return `${skillName}/${path.basename(file, ".md")}`;
60
+ }
61
+
62
+ /**
63
+ * Turn a reference file into an entry. These carry no frontmatter, so the
64
+ * description comes from the first H1, which every reference file in the tree
65
+ * has ("# Rubric: Correctness", "# Severity, Confidence, Categorization").
66
+ */
67
+ export function parseReference(
68
+ md: string,
69
+ skillName: string,
70
+ file: string,
71
+ ): SkillEntry {
72
+ const body = md.replace(/\r\n/g, "\n").trim();
73
+ const heading = body.match(/^#\s+(.+)$/m)?.[1]?.trim();
74
+ return {
75
+ name: referenceName(skillName, file),
76
+ description: heading
77
+ ? `${heading}. Reference detail for the ${skillName} skill.`
78
+ : `Reference detail for the ${skillName} skill.`,
79
+ body,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Tell an agent that reached this body over MCP where the reference files went.
85
+ *
86
+ * The skill text points at them by relative path ("see `reference/rubric-*.md`"),
87
+ * which is correct for a host reading the skill off disk and unresolvable for
88
+ * one that received this as a prompt: nothing in the prompt says which directory
89
+ * to stand in. Rewriting the paths in the source file is not an option, since
90
+ * that would break the filesystem channel, so the mapping is appended to the
91
+ * bundled copy only. One source file, one correct rendering per channel.
92
+ */
93
+ export function referencePointer(skillName: string, files: string[]): string {
94
+ const stems = files.map((f) => path.basename(f, ".md"));
95
+ return [
96
+ `## Reference files over MCP`,
97
+ ``,
98
+ `This skill's \`${REFERENCE_DIR}/\` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read \`${REFERENCE_DIR}/<name>.md\`, get the prompt named \`${skillName}/<name>\` instead.`,
99
+ ``,
100
+ `Available: ${stems.join(", ")}.`,
101
+ ].join("\n");
102
+ }
103
+
104
+ /** Reference files for one skill, sorted, or [] when it has none. */
105
+ function referenceFiles(skillDir: string): string[] {
106
+ const dir = path.join(skillDir, REFERENCE_DIR);
107
+ if (!fs.existsSync(dir)) return [];
108
+ return fs
109
+ .readdirSync(dir)
110
+ .filter((f) => f.endsWith(".md"))
111
+ .sort();
112
+ }
113
+
114
+ /**
115
+ * Read every skill under a skills directory, in the bundle's own order.
116
+ *
117
+ * A skill's `reference/` files become entries of their own rather than being
118
+ * folded into the parent body: they are the skill's on-demand detail (a review
119
+ * loads the rubrics its scope needs, not all ten), and one prompt per file
120
+ * keeps that property over MCP. Folding them in would put 83k characters of
121
+ * malloy-review rubrics in front of an agent that wanted the workflow.
122
+ */
55
123
  export function buildSkills(skillsDir: string): SkillEntry[] {
56
124
  const skills: SkillEntry[] = [];
57
125
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
58
126
  if (!entry.isDirectory() || isCredible(entry.name)) continue;
59
- const skillFile = path.join(skillsDir, entry.name, "SKILL.md");
127
+ const skillDir = path.join(skillsDir, entry.name);
128
+ const skillFile = path.join(skillDir, "SKILL.md");
60
129
  if (!fs.existsSync(skillFile)) continue;
61
- skills.push(parseSkill(fs.readFileSync(skillFile, "utf8"), entry.name));
130
+
131
+ const skill = parseSkill(fs.readFileSync(skillFile, "utf8"), entry.name);
132
+ const files = referenceFiles(skillDir);
133
+ if (files.length > 0) {
134
+ skill.body = `${skill.body}\n\n${referencePointer(skill.name, files)}`;
135
+ }
136
+ skills.push(skill);
137
+
138
+ for (const file of files) {
139
+ skills.push(
140
+ parseReference(
141
+ fs.readFileSync(
142
+ path.join(skillDir, REFERENCE_DIR, file),
143
+ "utf8",
144
+ ),
145
+ skill.name,
146
+ file,
147
+ ),
148
+ );
149
+ }
62
150
  }
63
151
  return skills.sort((a, b) => a.name.localeCompare(b.name));
64
152
  }
@@ -77,7 +165,9 @@ function main(): void {
77
165
  const skills = buildSkills(skillsDir);
78
166
 
79
167
  fs.writeFileSync(outFile, JSON.stringify({ skills }));
80
- console.log(`Wrote ${skills.length} skills to ${outFile}`);
168
+ console.log(
169
+ `Wrote ${skills.length} entries (${skills.filter((s) => !s.name.includes("/")).length} skills plus ${skills.filter((s) => s.name.includes("/")).length} reference files) to ${outFile}`,
170
+ );
81
171
  }
82
172
 
83
173
  // Only run when invoked directly (bun run ...), not when imported by a test.