@malloy-publisher/server 0.0.232 → 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.
- package/dist/app/api-doc.yaml +61 -10
- package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-CmkW1MiE.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-B33zGctF.js → index-CmEVVe-8.js} +4 -4
- package/dist/app/assets/{index-tXJXwdyj.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-BkiWKaAF.js → index-qnhU9CGo.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +19456 -842
- package/package.json +1 -1
- package/src/controller/query.controller.ts +26 -21
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_tool.spec.ts +63 -3
- package/src/mcp/tools/get_context_tool.ts +43 -46
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +77 -14
- package/src/service/connection_config.spec.ts +60 -0
- package/src/service/connection_config.ts +26 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/model.ts +41 -19
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
|
@@ -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
|
|
20
|
-
// getEnvironment to reject
|
|
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)
|
|
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<{
|
|
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
|
-
/**
|
|
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
|
|
127
|
+
const skillDir = path.join(skillsDir, entry.name);
|
|
128
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
60
129
|
if (!fs.existsSync(skillFile)) continue;
|
|
61
|
-
|
|
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(
|
|
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.
|