@malloy-publisher/server 0.0.207 → 0.0.208
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.
- package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-DDRxo015.js} +1 -1
- package/dist/app/assets/{HomePage-CGedji_w.js → HomePage-BeIoPOVO.js} +1 -1
- package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-DHVFRXPc.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-B9PDlk8c.js → MaterializationsPage-BYnr56IV.js} +1 -1
- package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-B8tF_hYc.js} +1 -1
- package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-LzaaviPn.js} +1 -1
- package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-vAYvRAi3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-CTjs2hXr.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-AOmIKwkc.es-B29cca-e.js} +1 -1
- package/dist/app/assets/index-CVGIZdxd.js +40 -0
- package/dist/app/assets/{index-DtlPzNxc.js → index-Db2wvjL3.js} +3 -3
- package/dist/app/assets/index-Dj4uKosi.js +1760 -0
- package/dist/app/assets/index.umd-BRRXibWA.js +2467 -0
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +7984 -4159
- package/package.json +13 -11
- package/src/health.ts +6 -0
- package/src/mcp/agent_server.protocol.spec.ts +78 -0
- package/src/mcp/agent_server.spec.ts +18 -0
- package/src/mcp/agent_server.ts +144 -0
- package/src/mcp/server.ts +3 -0
- package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
- package/src/mcp/skills/build_skills_bundle.ts +66 -0
- package/src/mcp/skills/skills_bundle.json +1 -0
- package/src/mcp/skills/skills_bundle.spec.ts +33 -0
- package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
- package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
- package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
- package/src/mcp/tools/docs_search_tool.ts +138 -0
- package/src/mcp/tools/get_context_eval.ts +126 -0
- package/src/mcp/tools/get_context_tool.spec.ts +44 -0
- package/src/mcp/tools/get_context_tool.ts +341 -0
- package/src/server.ts +14 -0
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
- package/dist/app/assets/index-DGGe8UpP.js +0 -40
- package/dist/app/assets/index-uu6UpHd2.js +0 -1812
- 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
|
+
}
|