@malloy-publisher/server 0.0.207 → 0.0.209

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/index-CCcHdeew.js +1760 -0
  12. package/dist/app/assets/index-CNFX-CGL.js +40 -0
  13. package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +26182 -16839
  17. package/package.json +14 -11
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/health.ts +6 -0
  21. package/src/materialization_metrics.ts +117 -34
  22. package/src/mcp/agent_server.protocol.spec.ts +78 -0
  23. package/src/mcp/agent_server.spec.ts +18 -0
  24. package/src/mcp/agent_server.ts +144 -0
  25. package/src/mcp/server.ts +3 -0
  26. package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
  27. package/src/mcp/skills/build_skills_bundle.ts +66 -0
  28. package/src/mcp/skills/skills_bundle.json +1 -0
  29. package/src/mcp/skills/skills_bundle.spec.ts +33 -0
  30. package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
  31. package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
  32. package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
  33. package/src/mcp/tools/docs_search_tool.ts +138 -0
  34. package/src/mcp/tools/get_context_eval.ts +126 -0
  35. package/src/mcp/tools/get_context_tool.spec.ts +44 -0
  36. package/src/mcp/tools/get_context_tool.ts +341 -0
  37. package/src/server-old.ts +2 -11
  38. package/src/server.ts +16 -10
  39. package/src/service/build_plan.spec.ts +116 -0
  40. package/src/service/build_plan.ts +238 -0
  41. package/src/service/connection.ts +4 -0
  42. package/src/service/connection_config.spec.ts +182 -1
  43. package/src/service/connection_config.ts +70 -0
  44. package/src/service/db_utils.spec.ts +159 -1
  45. package/src/service/db_utils.ts +131 -0
  46. package/src/service/materialization_service.spec.ts +388 -184
  47. package/src/service/materialization_service.ts +156 -442
  48. package/src/service/materialization_test_fixtures.ts +119 -0
  49. package/src/service/package.ts +41 -1
  50. package/src/storage/DatabaseInterface.ts +5 -13
  51. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  52. package/src/storage/duckdb/schema.ts +4 -5
  53. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  54. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
  55. package/dist/app/assets/HomePage-CGedji_w.js +0 -1
  56. package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
  57. package/dist/app/assets/index-DGGe8UpP.js +0 -40
  58. package/dist/app/assets/index-uu6UpHd2.js +0 -1812
  59. package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { sanitize, searchDocsIndex } from "./docs_search_tool";
3
+
4
+ describe("docs_search sanitize", () => {
5
+ it("strips lunr operator characters", () => {
6
+ expect(sanitize('window:functions +lag -"exact"')).not.toMatch(
7
+ /[~^:*+\-"]/,
8
+ );
9
+ });
10
+ });
11
+
12
+ describe("docs_search searchDocsIndex (bundled index)", () => {
13
+ it("returns relevant matches for a common Malloy term", () => {
14
+ const results = searchDocsIndex("source", 8);
15
+ expect(results.length).toBeGreaterThan(0);
16
+ expect(results.length).toBeLessThanOrEqual(8);
17
+ for (const r of results) {
18
+ expect(typeof r.title).toBe("string");
19
+ expect(r.url.length).toBeGreaterThan(0);
20
+ expect(typeof r.excerpt).toBe("string");
21
+ }
22
+ });
23
+
24
+ it("respects the limit", () => {
25
+ expect(searchDocsIndex("query", 3).length).toBeLessThanOrEqual(3);
26
+ });
27
+
28
+ it("returns [] for an empty or operator-only query without throwing", () => {
29
+ expect(searchDocsIndex("", 8)).toEqual([]);
30
+ expect(searchDocsIndex('~ ^ : * + - "', 8)).toEqual([]);
31
+ });
32
+ });
@@ -0,0 +1,138 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import lunr from "lunr";
4
+ import { EnvironmentStore } from "../../service/environment_store";
5
+ import { buildMalloyUri } from "../handler_utils";
6
+ import { logger } from "../../logger";
7
+ import rawIndex from "./docs_search/malloy_docs_index.json";
8
+
9
+ interface DocEntry {
10
+ title: string;
11
+ path: string;
12
+ url: string;
13
+ excerpt: string;
14
+ }
15
+
16
+ const asset = rawIndex as unknown as {
17
+ docCount: number;
18
+ index: object;
19
+ store: Record<string, DocEntry>;
20
+ };
21
+
22
+ // Load the prebuilt lunr index once at module init. The index is a build-time asset
23
+ // (see docs_search/build_docs_index.ts), so there is no runtime network dependency.
24
+ const docsIndex = lunr.Index.load(asset.index);
25
+
26
+ const searchDocsShape = {
27
+ query: z
28
+ .string()
29
+ .max(500)
30
+ .describe(
31
+ 'Keywords describing what you need, e.g. "window functions", "bar chart", "pivot".',
32
+ ),
33
+ limit: z
34
+ .number()
35
+ .int()
36
+ .positive()
37
+ .max(50)
38
+ .optional()
39
+ .describe("Maximum number of results to return. Default 8, max 50."),
40
+ };
41
+ type SearchDocsParams = z.infer<z.ZodObject<typeof searchDocsShape>>;
42
+
43
+ // lunr treats several characters as query operators; strip them so a plain-English
44
+ // query never throws and is matched as an OR of its terms.
45
+ export function sanitize(query: string): string {
46
+ return query.replace(/[~^:*+\-"]/g, " ").trim();
47
+ }
48
+
49
+ /**
50
+ * Search the bundled docs index and return up to `max` matches. Pure over the
51
+ * module-level index asset, so it is unit-testable without a server. An empty or
52
+ * operator-only query returns []; a lunr parse failure is logged and returns [].
53
+ */
54
+ export function searchDocsIndex(
55
+ query: string,
56
+ max: number,
57
+ ): Array<{ title: string; url: string; excerpt: string }> {
58
+ const sanitized = sanitize(query);
59
+ if (!sanitized) return [];
60
+
61
+ let hits: lunr.Index.Result[] = [];
62
+ try {
63
+ hits = docsIndex.search(sanitized);
64
+ } catch (error) {
65
+ logger.warn("[MCP Tool searchDocs] lunr search failed", {
66
+ query,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ return [];
70
+ }
71
+
72
+ // Guard against an index/store mismatch (a stale or corrupt asset): skip any
73
+ // hit whose ref is missing from the store rather than throwing.
74
+ return hits
75
+ .map((hit) => asset.store[hit.ref])
76
+ .filter((entry): entry is DocEntry => entry !== undefined)
77
+ .slice(0, max)
78
+ .map((entry) => ({
79
+ title: entry.title,
80
+ url: entry.url,
81
+ excerpt: entry.excerpt,
82
+ }));
83
+ }
84
+
85
+ const SEARCH_DOCS_DESCRIPTION = `Search the Malloy documentation by keyword and return the most relevant doc pages, each with a short excerpt and a link. Use this to look up Malloy language or rendering syntax instead of guessing.
86
+
87
+ ## When to use
88
+ - Before writing unfamiliar Malloy syntax (window functions, autobin, dialect-specific functions, rendering tags) or when a query fails with a syntax error you do not recognize.
89
+ - Do NOT use it to look up field or source names in a model; use malloy_getContext or malloy_modelGetText for that.
90
+
91
+ ## Parameters
92
+ - query (required): keywords describing what you need.
93
+ - limit (optional): maximum results to return; default 8.
94
+
95
+ ## Response
96
+ A JSON array of matches, each with title, url (a docs.malloydata.dev link), and a short excerpt, ordered by relevance. Empty array if nothing matches; broaden the keywords and retry.
97
+
98
+ ## Contract rules
99
+ - These are documentation pages, not model entities. Do not treat a doc title as a field or source name.
100
+ - The excerpt is only a hint; open the url for the full detail.
101
+
102
+ ## Worked example
103
+ { "query": "window functions lag" }`;
104
+
105
+ /**
106
+ * Registers the malloy_searchDocs MCP tool: lexical (lunr/BM25) search over a bundled
107
+ * index of the Malloy documentation.
108
+ */
109
+ export function registerDocsSearchTool(
110
+ mcpServer: McpServer,
111
+ _environmentStore: EnvironmentStore,
112
+ ): void {
113
+ mcpServer.tool(
114
+ "malloy_searchDocs",
115
+ SEARCH_DOCS_DESCRIPTION,
116
+ searchDocsShape,
117
+ async (params: SearchDocsParams) => {
118
+ const { query, limit } = params;
119
+ const max = limit ?? 8;
120
+ logger.info("[MCP Tool searchDocs] Searching docs", { query, limit });
121
+
122
+ const results = searchDocsIndex(query, max);
123
+
124
+ return {
125
+ content: [
126
+ {
127
+ type: "resource" as const,
128
+ resource: {
129
+ type: "application/json",
130
+ uri: buildMalloyUri({}, "docs-search"),
131
+ text: JSON.stringify(results),
132
+ },
133
+ },
134
+ ],
135
+ };
136
+ },
137
+ );
138
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Lexical-baseline eval for malloy_getContext.
3
+ *
4
+ * Runs a labeled set of plain-English queries against the live agent MCP endpoint
5
+ * and reports recall@K: whether the expected entity appears in the top-K results.
6
+ * This establishes the lexical (lunr/BM25) baseline so retrieval quality is
7
+ * measurable; an embeddings comparison is deferred to if/when an embedding
8
+ * provider is added (see the EPC eval risk).
9
+ *
10
+ * bun run packages/server/src/mcp/tools/get_context_eval.ts [K]
11
+ *
12
+ * Targets AGENT_MCP_URL (default http://localhost:4041/mcp) over the demo
13
+ * environment (EVAL_ENV, default "samples"); start the server first.
14
+ */
15
+
16
+ interface EvalCase {
17
+ pkg: string;
18
+ query: string;
19
+ expect: string; // substring expected in a top-K result name (case-insensitive)
20
+ }
21
+
22
+ interface ResultEntity {
23
+ kind: string;
24
+ name: string;
25
+ source?: string | null;
26
+ }
27
+
28
+ // Ground truth verified against the demo packages (ecommerce, faa).
29
+ const CASES: EvalCase[] = [
30
+ {
31
+ pkg: "ecommerce",
32
+ query: "revenue by product category",
33
+ expect: "category",
34
+ },
35
+ { pkg: "ecommerce", query: "total sales revenue", expect: "total_sales" },
36
+ { pkg: "ecommerce", query: "customer state location", expect: "state" },
37
+ { pkg: "ecommerce", query: "order count", expect: "order_count" },
38
+ { pkg: "ecommerce", query: "product brand", expect: "brand" },
39
+ { pkg: "faa", query: "flights by carrier", expect: "carrier" },
40
+ { pkg: "faa", query: "airport", expect: "airport" },
41
+ { pkg: "faa", query: "aircraft model", expect: "aircraft" },
42
+ // Known lexical gap: the field is "dep_delay", which does not share tokens with
43
+ // "departure delay", so a token-based index misses it. Kept to surface the gap.
44
+ { pkg: "faa", query: "departure delay", expect: "delay" },
45
+ ];
46
+
47
+ const ENDPOINT = process.env.AGENT_MCP_URL || "http://localhost:4041/mcp";
48
+ const EVAL_ENV = process.env.EVAL_ENV || "samples";
49
+ const K = Number(process.argv[2] || 5);
50
+
51
+ async function getContext(
52
+ pkg: string,
53
+ query: string,
54
+ limit: number,
55
+ ): Promise<ResultEntity[]> {
56
+ const res = await fetch(ENDPOINT, {
57
+ method: "POST",
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ Accept: "application/json, text/event-stream",
61
+ },
62
+ body: JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ id: 1,
65
+ method: "tools/call",
66
+ params: {
67
+ name: "malloy_getContext",
68
+ arguments: {
69
+ environmentName: EVAL_ENV,
70
+ packageName: pkg,
71
+ query,
72
+ limit,
73
+ },
74
+ },
75
+ }),
76
+ });
77
+ const text = await res.text();
78
+ const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
79
+ if (!dataLine) return [];
80
+ const msg = JSON.parse(dataLine.slice(6)) as {
81
+ result?: { content?: { resource?: { text?: string } }[] };
82
+ };
83
+ const payloadText = msg.result?.content?.[0]?.resource?.text;
84
+ if (!payloadText) return [];
85
+ const payload = JSON.parse(payloadText) as { results?: ResultEntity[] };
86
+ return payload.results ?? [];
87
+ }
88
+
89
+ async function main(): Promise<void> {
90
+ console.log(
91
+ `get_context lexical baseline, recall@${K} (endpoint ${ENDPOINT})\n`,
92
+ );
93
+ let hits = 0;
94
+ for (const c of CASES) {
95
+ const results = await getContext(c.pkg, c.query, K);
96
+ const rank = results.findIndex((r) =>
97
+ r.name.toLowerCase().includes(c.expect.toLowerCase()),
98
+ );
99
+ const hit = rank >= 0;
100
+ if (hit) hits++;
101
+ const top =
102
+ results
103
+ .slice(0, 3)
104
+ .map((r) => `${r.kind}:${r.name}`)
105
+ .join(", ") || "(none)";
106
+ const tag = hit ? `HIT@${rank + 1}` : "MISS ";
107
+ console.log(` [${tag}] ${c.pkg} / "${c.query}" (want ~${c.expect})`);
108
+ console.log(` top: ${top}`);
109
+ }
110
+ const pct = ((hits / CASES.length) * 100).toFixed(0);
111
+ console.log(`\nrecall@${K}: ${hits}/${CASES.length} (${pct}%)`);
112
+ console.log(
113
+ "Misses occur where the field name does not share tokens with the query",
114
+ );
115
+ console.log(
116
+ "(e.g. 'departure delay' vs a 'dep_delay' field). Closing that gap is the",
117
+ );
118
+ console.log(
119
+ "motivation for an optional embeddings provider; lexical ships as the v1 baseline.",
120
+ );
121
+ }
122
+
123
+ main().catch((err) => {
124
+ console.error(err);
125
+ process.exit(1);
126
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { docText, sanitize } from "./get_context_tool";
3
+
4
+ describe("get_context docText", () => {
5
+ it("extracts #(doc) text from annotation lines", () => {
6
+ expect(docText(["#(doc) Total revenue in USD."])).toBe(
7
+ "Total revenue in USD.",
8
+ );
9
+ });
10
+
11
+ it("accepts Annotation objects ({ value }) as well as raw strings", () => {
12
+ expect(docText([{ value: "#(doc) Customer state." }])).toBe(
13
+ "Customer state.",
14
+ );
15
+ });
16
+
17
+ it("joins multiple #(doc) lines and collapses whitespace", () => {
18
+ expect(docText(["#(doc) line one", "#(doc) line two"])).toBe(
19
+ "line one line two",
20
+ );
21
+ });
22
+
23
+ it("falls back to the raw lines when no #(doc) annotation is present", () => {
24
+ expect(docText(["# bar_chart"])).toBe("# bar_chart");
25
+ });
26
+
27
+ it("returns an empty string for missing or empty annotations", () => {
28
+ expect(docText()).toBe("");
29
+ expect(docText([])).toBe("");
30
+ });
31
+ });
32
+
33
+ describe("get_context sanitize", () => {
34
+ it("strips lunr operator characters so a plain-English query never throws", () => {
35
+ const cleaned = sanitize('revenue: +top ~10 "exact" -minus ^boost *wild');
36
+ expect(cleaned).not.toMatch(/[~^:*+\-"]/);
37
+ });
38
+
39
+ it("keeps an ordinary query intact", () => {
40
+ expect(sanitize("revenue by product category")).toBe(
41
+ "revenue by product category",
42
+ );
43
+ });
44
+ });
@@ -0,0 +1,341 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import lunr from "lunr";
4
+ import { EnvironmentStore } from "../../service/environment_store";
5
+ import { Package } from "../../service/package";
6
+ import { buildMalloyUri } from "../handler_utils";
7
+ import { logger } from "../../logger";
8
+
9
+ /**
10
+ * A retrievable model entity: a source, one of its views, a field (dimension or
11
+ * measure) defined on a source, or a named query. Sources, views, and fields come
12
+ * from the compiled SourceInfo (Model.getSourceInfos()); named queries from
13
+ * Model.getQueries().
14
+ */
15
+ interface Entity {
16
+ id: string;
17
+ kind: "source" | "view" | "query" | "dimension" | "measure";
18
+ name: string;
19
+ source: string | undefined;
20
+ modelPath: string;
21
+ doc: string;
22
+ }
23
+
24
+ const getContextShape = {
25
+ environmentName: z
26
+ .string()
27
+ .describe(
28
+ "Environment name. Names are listed in the malloy_environmentList tool.",
29
+ ),
30
+ packageName: z
31
+ .string()
32
+ .describe(
33
+ "Package name. Package names are listed in malloy_packageList.",
34
+ ),
35
+ query: z
36
+ .string()
37
+ .max(500)
38
+ .describe(
39
+ 'Plain-English description of what you need, e.g. "revenue by product category" or "customer churn".',
40
+ ),
41
+ sourceName: z
42
+ .string()
43
+ .optional()
44
+ .describe(
45
+ "Optional. Narrow results to entities within this source (the drill-down phase).",
46
+ ),
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .positive()
51
+ .max(50)
52
+ .optional()
53
+ .describe("Maximum number of entities to return. Default 10, max 50."),
54
+ };
55
+ type GetContextParams = z.infer<z.ZodObject<typeof getContextShape>>;
56
+
57
+ /**
58
+ * Pull #(doc) text from annotation lines, falling back to the raw lines.
59
+ * SourceInfo sources/fields carry Annotation objects ({ value }); named queries
60
+ * carry raw strings, so accept both.
61
+ */
62
+ export function docText(
63
+ annotations?: Array<string | { value: string }>,
64
+ ): string {
65
+ if (!annotations || annotations.length === 0) return "";
66
+ const lines = annotations.map((a) => (typeof a === "string" ? a : a.value));
67
+ const docs = lines
68
+ .map((a) => a.match(/#\(doc\)\s*(.*)/)?.[1]?.trim() ?? "")
69
+ .filter(Boolean);
70
+ const chosen = docs.length > 0 ? docs : lines;
71
+ return chosen.join(" ").replace(/\s+/g, " ").trim();
72
+ }
73
+
74
+ export function sanitize(query: string): string {
75
+ return query.replace(/[~^:*+\-"]/g, " ").trim();
76
+ }
77
+
78
+ /**
79
+ * Walk every model in the package and collect sources, their views and
80
+ * dimension/measure fields, and named queries. Returns the full set; the
81
+ * optional source-level drill-down is applied by the caller after retrieval.
82
+ */
83
+ async function collectEntities(pkg: Package): Promise<Entity[]> {
84
+ // listModels() already returns only .malloy model files (notebooks are listed separately).
85
+ const models = await pkg.listModels();
86
+
87
+ const entities: Entity[] = [];
88
+ let n = 0;
89
+ for (const apiModel of models) {
90
+ // path is optional in the generated API types; skip models without one.
91
+ const modelPath = apiModel.path;
92
+ if (!modelPath) continue;
93
+ const model = pkg.getModel(modelPath);
94
+ if (!model) continue;
95
+ // SourceInfo carries the full schema (views plus dimension/measure fields);
96
+ // named queries come from getQueries().
97
+ const sourceInfos = model.getSourceInfos() ?? [];
98
+ const queries = model.getQueries() ?? [];
99
+
100
+ for (const sourceInfo of sourceInfos) {
101
+ const sourceName = sourceInfo.name;
102
+ entities.push({
103
+ id: String(n++),
104
+ kind: "source",
105
+ name: sourceName,
106
+ source: sourceName,
107
+ modelPath,
108
+ doc: docText(sourceInfo.annotations),
109
+ });
110
+ for (const field of sourceInfo.schema.fields ?? []) {
111
+ // v1 indexes the queryable surface: views and dimension/measure
112
+ // fields. Joins (structural) and calculate (window) are skipped.
113
+ if (
114
+ field.kind !== "view" &&
115
+ field.kind !== "dimension" &&
116
+ field.kind !== "measure"
117
+ ) {
118
+ continue;
119
+ }
120
+ entities.push({
121
+ id: String(n++),
122
+ kind: field.kind,
123
+ name: field.name,
124
+ source: sourceName,
125
+ modelPath,
126
+ doc: docText(field.annotations),
127
+ });
128
+ }
129
+ }
130
+
131
+ for (const query of queries) {
132
+ if (!query.name) continue;
133
+ entities.push({
134
+ id: String(n++),
135
+ kind: "query",
136
+ name: query.name,
137
+ source: query.sourceName,
138
+ modelPath,
139
+ doc: docText(query.annotations),
140
+ });
141
+ }
142
+ }
143
+
144
+ // A package can re-export the same source from more than one model (e.g. a
145
+ // model that extends another), which surfaces the same entity twice. Keep the
146
+ // first occurrence per (kind, source, name).
147
+ const seen = new Set<string>();
148
+ return entities.filter((e) => {
149
+ const key = `${e.kind}|${e.source ?? ""}|${e.name}`;
150
+ if (seen.has(key)) return false;
151
+ seen.add(key);
152
+ return true;
153
+ });
154
+ }
155
+
156
+ interface PackageIndex {
157
+ byId: Map<string, Entity>;
158
+ index: lunr.Index;
159
+ entityCount: number;
160
+ }
161
+
162
+ // Cache the built entity index per Package instance. environment.getPackage()
163
+ // serves a cached Package, and a reload swaps in a new instance, so a stale entry
164
+ // is dropped automatically (WeakMap) and the next call rebuilds.
165
+ const indexCache = new WeakMap<Package, PackageIndex>();
166
+
167
+ /** Get, or lazily build and cache, the lunr entity index for a package. */
168
+ async function getPackageIndex(
169
+ environmentStore: EnvironmentStore,
170
+ environmentName: string,
171
+ packageName: string,
172
+ ): Promise<PackageIndex> {
173
+ const environment = await environmentStore.getEnvironment(
174
+ environmentName,
175
+ false,
176
+ );
177
+ const pkg = await environment.getPackage(packageName, false);
178
+ const cached = indexCache.get(pkg);
179
+ if (cached) return cached;
180
+
181
+ const entities = await collectEntities(pkg);
182
+ const byId = new Map(entities.map((e) => [e.id, e]));
183
+ const index = lunr(function () {
184
+ this.ref("id");
185
+ this.field("name", { boost: 4 });
186
+ this.field("source");
187
+ this.field("doc");
188
+ for (const e of entities) {
189
+ this.add({
190
+ id: e.id,
191
+ name: e.name,
192
+ source: e.source ?? "",
193
+ doc: e.doc,
194
+ });
195
+ }
196
+ });
197
+ const built: PackageIndex = { byId, index, entityCount: entities.length };
198
+ indexCache.set(pkg, built);
199
+ logger.debug("[MCP Tool getContext] Built and cached entity index", {
200
+ packageName,
201
+ entityCount: entities.length,
202
+ });
203
+ return built;
204
+ }
205
+
206
+ const GET_CONTEXT_DESCRIPTION = `Retrieve the model entities (sources, views, named queries, and dimension/measure fields) most relevant to a plain-English question, so you can ground a query in what the model actually defines instead of guessing.
207
+
208
+ ## When to use
209
+ - Before writing a query, to find which source and which prebuilt views fit the question.
210
+ - Two-phase: call once with just a query to discover the most relevant sources and views (discovery), then optionally call again with sourceName set to focus on one source (drill-down).
211
+
212
+ ## Parameters
213
+ - environmentName, packageName (required): where to look.
214
+ - query (required): a plain-English description of what you need.
215
+ - sourceName (optional): narrow to entities within one source.
216
+ - limit (optional): maximum entities to return; default 10.
217
+
218
+ ## Response
219
+ A JSON object with results: a ranked list of entities, each with kind (source / view / query / dimension / measure), name, source, modelPath, and doc. The environmentName, packageName, modelPath, and source map directly onto malloy_executeQuery parameters; for a view or named query, pass its name as queryName with sourceName.
220
+
221
+ ## Contract rules
222
+ - Use the names verbatim; do not invent entities not in the results.
223
+ - Results include field-level dimensions and measures. If you need the full field list of a source or want to confirm exact spelling, read the model with malloy_modelGetText on the modelPath this returns.
224
+
225
+ ## Worked example
226
+ { "environmentName": "samples", "packageName": "ecommerce", "query": "revenue by product category" }`;
227
+
228
+ /**
229
+ * Registers the malloy_getContext MCP tool: lexical (lunr/BM25) retrieval over a
230
+ * package's model entities (sources, views, dimension/measure fields, named
231
+ * queries). The entity index is built once per Package and cached (see
232
+ * getPackageIndex), rebuilding automatically when the package reloads.
233
+ */
234
+ export function registerGetContextTool(
235
+ mcpServer: McpServer,
236
+ environmentStore: EnvironmentStore,
237
+ ): void {
238
+ mcpServer.tool(
239
+ "malloy_getContext",
240
+ GET_CONTEXT_DESCRIPTION,
241
+ getContextShape,
242
+ async (params: GetContextParams) => {
243
+ const { environmentName, packageName, query, sourceName, limit } =
244
+ params;
245
+ const max = limit ?? 10;
246
+ logger.info("[MCP Tool getContext] Retrieving context", {
247
+ environmentName,
248
+ packageName,
249
+ query,
250
+ sourceName,
251
+ limit,
252
+ });
253
+
254
+ let pkgIndex: PackageIndex;
255
+ try {
256
+ pkgIndex = await getPackageIndex(
257
+ environmentStore,
258
+ environmentName,
259
+ packageName,
260
+ );
261
+ } catch (error) {
262
+ const message =
263
+ error instanceof Error ? error.message : "Unknown error";
264
+ logger.warn("[MCP Tool getContext] index build failed", {
265
+ environmentName,
266
+ packageName,
267
+ sourceName,
268
+ error: message,
269
+ });
270
+ return {
271
+ isError: true,
272
+ content: [
273
+ {
274
+ type: "resource" as const,
275
+ resource: {
276
+ type: "application/json",
277
+ uri: buildMalloyUri(
278
+ {
279
+ environment: environmentName,
280
+ package: packageName,
281
+ },
282
+ "get-context",
283
+ ),
284
+ text: JSON.stringify({ error: message, results: [] }),
285
+ },
286
+ },
287
+ ],
288
+ };
289
+ }
290
+
291
+ const { byId, index } = pkgIndex;
292
+
293
+ const sanitized = sanitize(query);
294
+ let hits: lunr.Index.Result[] = [];
295
+ if (sanitized) {
296
+ try {
297
+ hits = index.search(sanitized);
298
+ } catch (error) {
299
+ logger.warn("[MCP Tool getContext] lunr search failed", {
300
+ query,
301
+ error: error instanceof Error ? error.message : String(error),
302
+ });
303
+ hits = [];
304
+ }
305
+ }
306
+
307
+ // Defensive: skip any hit whose ref is missing from the entity map.
308
+ const results = hits
309
+ .map((hit) => byId.get(hit.ref))
310
+ .filter((e): e is Entity => e !== undefined)
311
+ // Drill-down: narrow to one source when sourceName is set.
312
+ .filter((e) => !sourceName || e.source === sourceName)
313
+ .slice(0, max)
314
+ .map((e) => ({
315
+ kind: e.kind,
316
+ name: e.name,
317
+ source: e.source,
318
+ environmentName,
319
+ packageName,
320
+ modelPath: e.modelPath,
321
+ doc: e.doc,
322
+ }));
323
+
324
+ return {
325
+ content: [
326
+ {
327
+ type: "resource" as const,
328
+ resource: {
329
+ type: "application/json",
330
+ uri: buildMalloyUri(
331
+ { environment: environmentName, package: packageName },
332
+ "get-context",
333
+ ),
334
+ text: JSON.stringify({ results }),
335
+ },
336
+ },
337
+ ],
338
+ };
339
+ },
340
+ );
341
+ }