@malloy-publisher/server 0.0.226 → 0.0.228
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/README.docker.md +8 -8
- package/README.md +2 -11
- package/dist/app/assets/{EnvironmentPage-DvOJ7L_b.js → EnvironmentPage-EW2lbGvb.js} +1 -1
- package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-Bkwc9Woc.js} +1 -1
- package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-Bum_KBpN.js} +1 -1
- package/dist/app/assets/{MainPage-BIe0VwBa.js → MainPage-oiEy7TNM.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-BuZ6UJVx.js → MaterializationsPage-C_VJsTgU.js} +1 -1
- package/dist/app/assets/{ModelPage-DsPf-s8B.js → ModelPage-z8REqAmk.js} +1 -1
- package/dist/app/assets/{PackagePage-CEVNAKZa.js → PackagePage-C2Vtt1Ln.js} +1 -1
- package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-DmJLpLXm.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DWC_FdNU.js → ThemeEditorPage-BywFjC7A.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CGrsFz8p.js → WorkbookPage-DCMizDMR.js} +1 -1
- package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-CEDZMHV1.es-_yGzNgNH.js} +1 -1
- package/dist/app/assets/{index-D6YtyiJ0.js → index-CE9xhdra.js} +1 -1
- package/dist/app/assets/{index-BioohWQj.js → index-CdmFub34.js} +1 -1
- package/dist/app/assets/{index-gEWxu09x.js → index-DDMrjIT3.js} +1 -1
- package/dist/app/assets/{index-DNUZpnaa.js → index-EqslXZ44.js} +4 -4
- package/dist/app/index.html +1 -1
- package/dist/default-publisher.config.json +7 -7
- package/dist/package_load_worker.mjs +86 -24
- package/dist/runtime/publisher.js +5 -0
- package/dist/server.mjs +1415 -7876
- package/package.json +1 -4
- package/publisher.config.example.bigquery.json +7 -7
- package/publisher.config.example.duckdb.json +7 -7
- package/publisher.config.json +7 -11
- package/src/config.spec.ts +2 -2
- package/src/controller/package.controller.ts +62 -31
- package/src/default-publisher.config.json +7 -7
- package/src/health.ts +3 -8
- package/src/mcp/error_messages.ts +8 -37
- package/src/mcp/handler_utils.spec.ts +108 -0
- package/src/mcp/handler_utils.ts +99 -124
- package/src/mcp/mcp_constants.ts +0 -16
- package/src/mcp/server.protocol.spec.ts +138 -0
- package/src/mcp/server.ts +57 -37
- package/src/mcp/skills/build_skills_bundle.ts +1 -1
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/compile_tool.spec.ts +207 -0
- package/src/mcp/tools/compile_tool.ts +177 -0
- package/src/mcp/tools/docs_search_tool.ts +1 -1
- package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
- package/src/mcp/tools/execute_query_tool.ts +39 -6
- package/src/mcp/tools/get_context_eval.ts +3 -3
- package/src/mcp/tools/get_context_tool.spec.ts +196 -1
- package/src/mcp/tools/get_context_tool.ts +165 -67
- package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
- package/src/mcp/tools/reload_package_tool.ts +158 -0
- package/src/package_load/package_load_pool.ts +3 -0
- package/src/package_load/package_load_worker.ts +68 -24
- package/src/package_load/protocol.ts +16 -0
- package/src/package_load/rpc_wait_accountant.spec.ts +109 -0
- package/src/package_load/rpc_wait_accountant.ts +76 -0
- package/src/package_load_metrics.spec.ts +114 -0
- package/src/package_load_metrics.ts +127 -0
- package/src/pg_helpers.spec.ts +2 -206
- package/src/pg_helpers.ts +4 -120
- package/src/runtime/publisher.js +5 -0
- package/src/server.ts +7 -21
- package/src/service/environment.ts +71 -7
- package/src/service/model.spec.ts +92 -0
- package/src/service/model.ts +58 -7
- package/src/service/package.ts +113 -55
- package/src/service/package_reload_safety.spec.ts +193 -0
- package/src/test_helpers/metrics_harness.ts +40 -0
- package/tests/fixtures/query-givens/data/orders.csv +7 -0
- package/tests/fixtures/query-givens/model.malloy +34 -0
- package/tests/fixtures/query-givens/publisher.json +5 -0
- package/tests/harness/mcp_test_setup.ts +1 -1
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +7 -31
- package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -6
- package/dxt/malloy_bridge.py +0 -354
- package/dxt/manifest.json +0 -22
- package/src/dto/connection.dto.spec.ts +0 -186
- package/src/dto/connection.dto.ts +0 -308
- package/src/dto/index.ts +0 -2
- package/src/dto/package.dto.spec.ts +0 -42
- package/src/dto/package.dto.ts +0 -27
- package/src/dto/validate.spec.ts +0 -76
- package/src/dto/validate.ts +0 -31
- package/src/ducklake_version.spec.ts +0 -43
- package/src/ducklake_version.ts +0 -26
- package/src/mcp/agent_server.protocol.spec.ts +0 -78
- package/src/mcp/agent_server.spec.ts +0 -18
- package/src/mcp/agent_server.ts +0 -144
- package/src/mcp/prompts/handlers.ts +0 -84
- package/src/mcp/prompts/index.ts +0 -11
- package/src/mcp/prompts/prompt_definitions.ts +0 -160
- package/src/mcp/prompts/prompt_service.ts +0 -67
- package/src/mcp/prompts/utils.ts +0 -62
- package/src/mcp/resource_metadata.ts +0 -47
- package/src/mcp/resources/environment_resource.ts +0 -187
- package/src/mcp/resources/model_resource.ts +0 -155
- package/src/mcp/resources/notebook_resource.ts +0 -137
- package/src/mcp/resources/package_resource.ts +0 -373
- package/src/mcp/resources/query_resource.ts +0 -122
- package/src/mcp/resources/source_resource.ts +0 -141
- package/src/mcp/resources/view_resource.ts +0 -136
- package/src/mcp/tools/discovery_tools.ts +0 -280
- package/src/storage/BaseRepository.ts +0 -31
- package/src/storage/StorageManager.mock.ts +0 -50
- package/tests/harness/e2e.ts +0 -96
- package/tests/harness/mocks.ts +0 -39
- package/tests/harness/uris.ts +0 -31
- package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -655
- package/tests/integration/mcp/setup.spec.ts +0 -5
- package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
- package/tests/unit/mcp/prompt_happy.test.ts +0 -51
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
import { Environment, PackageStatus } from "./environment";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression tests for RELOAD data loss.
|
|
10
|
+
*
|
|
11
|
+
* `Package.create`'s failure cleanup was written for a fresh install, where the
|
|
12
|
+
* half-built tree is Publisher's to remove. A reload runs the same code against
|
|
13
|
+
* a directory that already exists and is already serving, so a model that
|
|
14
|
+
* failed to compile deleted the user's package directory and evicted the
|
|
15
|
+
* last-good compiled model, taking the package offline until it was
|
|
16
|
+
* re-provisioned. That is reachable from the reload endpoint and the
|
|
17
|
+
* malloy_reloadPackage MCP tool by any caller that saves a broken model and
|
|
18
|
+
* reloads, which is the ordinary authoring mistake.
|
|
19
|
+
*
|
|
20
|
+
* Directory cleanup is now opt-in and only `installPackage` asks for it, so
|
|
21
|
+
* these two guard the reload path. Run against a real `Environment` and a real
|
|
22
|
+
* `Package.create` over temp dirs, the same way package_rollback.spec.ts does.
|
|
23
|
+
*/
|
|
24
|
+
describe("failed reload does not destroy a serving package", () => {
|
|
25
|
+
let rootDir: string;
|
|
26
|
+
let envPath: string;
|
|
27
|
+
|
|
28
|
+
const GOOD_MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
|
|
29
|
+
const BROKEN_MODEL = `source: broken is @@@ not valid malloy !!!\n`;
|
|
30
|
+
|
|
31
|
+
async function writePackageDir(dir: string, model: string): Promise<void> {
|
|
32
|
+
await fs.mkdir(dir, { recursive: true });
|
|
33
|
+
await fs.writeFile(
|
|
34
|
+
path.join(dir, "publisher.json"),
|
|
35
|
+
JSON.stringify({ name: "pkg", description: "reload fixture" }),
|
|
36
|
+
);
|
|
37
|
+
await fs.writeFile(path.join(dir, "model.malloy"), model);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function copyDir(src: string, dst: string): Promise<void> {
|
|
41
|
+
await fs.mkdir(dst, { recursive: true });
|
|
42
|
+
await fs.cp(src, dst, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-reload-"));
|
|
47
|
+
envPath = path.join(rootDir, "env");
|
|
48
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("keeps the package directory on disk when the reloaded model does not compile", async () => {
|
|
56
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
57
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
58
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
59
|
+
await env.addPackage("pkg");
|
|
60
|
+
|
|
61
|
+
// The ordinary authoring mistake: save something that does not compile,
|
|
62
|
+
// then reload.
|
|
63
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
|
|
64
|
+
await expect(env.getPackage("pkg", true)).rejects.toThrow();
|
|
65
|
+
|
|
66
|
+
// The directory and the file the user was editing both survive.
|
|
67
|
+
const modelText = await fs.readFile(
|
|
68
|
+
path.join(pkgDir, "model.malloy"),
|
|
69
|
+
"utf-8",
|
|
70
|
+
);
|
|
71
|
+
expect(modelText).toBe(BROKEN_MODEL);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps serving the last good model after a failed reload", async () => {
|
|
75
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
76
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
77
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
78
|
+
await env.addPackage("pkg");
|
|
79
|
+
|
|
80
|
+
const servingBefore = await env.getPackage("pkg", false);
|
|
81
|
+
|
|
82
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
|
|
83
|
+
await expect(env.getPackage("pkg", true)).rejects.toThrow();
|
|
84
|
+
|
|
85
|
+
// The package is still loaded and answerable: a failed reload reports the
|
|
86
|
+
// compile error, it does not take the package down. Assert the exact
|
|
87
|
+
// state, not just that something is there: a status stranded at LOADING
|
|
88
|
+
// would satisfy toBeDefined() while listPackages skips it, so the package
|
|
89
|
+
// would answer getPackage and be invisible to listings and discovery.
|
|
90
|
+
const stillServing = await env.getPackage("pkg", false);
|
|
91
|
+
expect(stillServing).toBe(servingBefore);
|
|
92
|
+
expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
|
|
93
|
+
expect((await env.listPackages()).map((p) => p.name)).toContain("pkg");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps a rolled-back reinstall listed and serving", async () => {
|
|
97
|
+
// The other reload path. A package with an install location reloads via
|
|
98
|
+
// installPackage, whose rollback restores the previous tree but used to
|
|
99
|
+
// drop the status while `packages` kept the old package: it answered
|
|
100
|
+
// getPackage but vanished from listPackages and discovery until a
|
|
101
|
+
// restart. Both maps must agree that it is still serving.
|
|
102
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
103
|
+
const goodFixture = path.join(rootDir, "good");
|
|
104
|
+
const brokenFixture = path.join(rootDir, "broken");
|
|
105
|
+
await writePackageDir(goodFixture, GOOD_MODEL);
|
|
106
|
+
await writePackageDir(brokenFixture, BROKEN_MODEL);
|
|
107
|
+
|
|
108
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
109
|
+
copyDir(goodFixture, stagingPath),
|
|
110
|
+
);
|
|
111
|
+
const servingBefore = await env.getPackage("pkg", false);
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
env.installPackage("pkg", (stagingPath) =>
|
|
115
|
+
copyDir(brokenFixture, stagingPath),
|
|
116
|
+
),
|
|
117
|
+
).rejects.toThrow();
|
|
118
|
+
|
|
119
|
+
expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
|
|
120
|
+
expect((await env.listPackages()).map((p) => p.name)).toContain("pkg");
|
|
121
|
+
expect(await env.getPackage("pkg", false)).toBe(servingBefore);
|
|
122
|
+
// The on-disk half of the same claim: the rejected tree is gone and the
|
|
123
|
+
// user's is back. Without this, nothing pins "your files are left alone".
|
|
124
|
+
expect(
|
|
125
|
+
await fs.readFile(path.join(envPath, "pkg", "model.malloy"), "utf-8"),
|
|
126
|
+
).toBe(GOOD_MODEL);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not advertise a package as serving when the rollback could not restore it", async () => {
|
|
130
|
+
// Keeping the last-good package served is only honest when the rollback
|
|
131
|
+
// actually put its tree back. If nothing was restored, the cached package
|
|
132
|
+
// no longer matches disk, and claiming SERVING would advertise a package
|
|
133
|
+
// over a canonical path that is missing or still holds rejected content.
|
|
134
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
135
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
136
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
137
|
+
await env.addPackage("pkg");
|
|
138
|
+
expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
|
|
139
|
+
|
|
140
|
+
// Record retirements rather than scheduling the real drain, which is a
|
|
141
|
+
// 30s timer this test has no reason to wait on.
|
|
142
|
+
const retiredLabels: string[] = [];
|
|
143
|
+
(
|
|
144
|
+
env as unknown as {
|
|
145
|
+
retireConnectionGeneration: (label: string) => void;
|
|
146
|
+
}
|
|
147
|
+
).retireConnectionGeneration = (label: string) => {
|
|
148
|
+
retiredLabels.push(label);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// The tree disappears from under Publisher, so the reinstall has nothing
|
|
152
|
+
// to retire and the rollback has nothing to restore.
|
|
153
|
+
await fs.rm(pkgDir, { recursive: true, force: true });
|
|
154
|
+
|
|
155
|
+
const brokenFixture = path.join(rootDir, "broken-nofallback");
|
|
156
|
+
await writePackageDir(brokenFixture, BROKEN_MODEL);
|
|
157
|
+
await expect(
|
|
158
|
+
env.installPackage("pkg", (stagingPath) =>
|
|
159
|
+
copyDir(brokenFixture, stagingPath),
|
|
160
|
+
),
|
|
161
|
+
).rejects.toThrow();
|
|
162
|
+
|
|
163
|
+
// Eviction is the honest outcome here, and both maps must agree on it.
|
|
164
|
+
expect(env.getPackageStatus("pkg")).toBeUndefined();
|
|
165
|
+
expect((await env.listPackages()).map((p) => p.name)).not.toContain(
|
|
166
|
+
"pkg",
|
|
167
|
+
);
|
|
168
|
+
// Assert the packages map too, not just the status: asserting only the
|
|
169
|
+
// status leaves the half that actually strands a package untested.
|
|
170
|
+
await expect(env.getPackage("pkg", false)).rejects.toThrow();
|
|
171
|
+
|
|
172
|
+
// Evicting must retire the old package's connections. Once it leaves
|
|
173
|
+
// this.packages, closeAllConnections cannot reach its MalloyConfig, so
|
|
174
|
+
// skipping the retire orphans its native handles for the process's life.
|
|
175
|
+
// The drain is timer-based, so assert it was scheduled, not that it ran.
|
|
176
|
+
expect(retiredLabels).toContain("package pkg");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("recovers on the next reload once the model compiles again", async () => {
|
|
180
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
181
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
182
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
183
|
+
await env.addPackage("pkg");
|
|
184
|
+
|
|
185
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
|
|
186
|
+
await expect(env.getPackage("pkg", true)).rejects.toThrow();
|
|
187
|
+
|
|
188
|
+
// Because nothing was deleted, fixing the model and reloading is enough.
|
|
189
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), GOOD_MODEL);
|
|
190
|
+
const reloaded = await env.getPackage("pkg", true);
|
|
191
|
+
expect(reloaded).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -62,6 +62,15 @@ export interface MetricsHarness {
|
|
|
62
62
|
* fired for this gauge yet.
|
|
63
63
|
*/
|
|
64
64
|
collectGauge(name: string): Promise<number | undefined>;
|
|
65
|
+
/**
|
|
66
|
+
* Force a collection cycle and return the aggregate `count`/`sum` across all
|
|
67
|
+
* data points for the named histogram (optionally scoped to one label set).
|
|
68
|
+
* `count` is 0 when the histogram has not been emitted yet.
|
|
69
|
+
*/
|
|
70
|
+
collectHistogram(
|
|
71
|
+
name: string,
|
|
72
|
+
attributeFilter?: Record<string, string | number | boolean>,
|
|
73
|
+
): Promise<{ count: number; sum: number; boundaries: number[] }>;
|
|
65
74
|
shutdown(): Promise<void>;
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -100,6 +109,37 @@ export async function startMetricsHarness(): Promise<MetricsHarness> {
|
|
|
100
109
|
}
|
|
101
110
|
return total;
|
|
102
111
|
},
|
|
112
|
+
async collectHistogram(
|
|
113
|
+
name: string,
|
|
114
|
+
attributeFilter?: Record<string, string | number | boolean>,
|
|
115
|
+
): Promise<{ count: number; sum: number; boundaries: number[] }> {
|
|
116
|
+
const result = await reader.collect();
|
|
117
|
+
let count = 0;
|
|
118
|
+
let sum = 0;
|
|
119
|
+
let boundaries: number[] = [];
|
|
120
|
+
for (const rm of result.resourceMetrics.scopeMetrics) {
|
|
121
|
+
for (const metric of rm.metrics) {
|
|
122
|
+
if (metric.descriptor.name !== name) continue;
|
|
123
|
+
for (const dp of metric.dataPoints) {
|
|
124
|
+
if (attributeFilter) {
|
|
125
|
+
const allMatch = Object.entries(attributeFilter).every(
|
|
126
|
+
([k, v]) => dp.attributes?.[k] === v,
|
|
127
|
+
);
|
|
128
|
+
if (!allMatch) continue;
|
|
129
|
+
}
|
|
130
|
+
const point = dp.value as {
|
|
131
|
+
count: number;
|
|
132
|
+
sum?: number;
|
|
133
|
+
buckets?: { boundaries: number[] };
|
|
134
|
+
};
|
|
135
|
+
count += point.count;
|
|
136
|
+
sum += point.sum ?? 0;
|
|
137
|
+
if (point.buckets) boundaries = point.buckets.boundaries;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { count, sum, boundaries };
|
|
142
|
+
},
|
|
103
143
|
async collectGauge(name: string): Promise<number | undefined> {
|
|
104
144
|
const result = await reader.collect();
|
|
105
145
|
for (const rm of result.resourceMetrics.scopeMetrics) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
##! experimental.givens
|
|
2
|
+
|
|
3
|
+
given: target_region :: string is 'US'
|
|
4
|
+
given: min_amount :: number is 0
|
|
5
|
+
given: role :: string is 'guest'
|
|
6
|
+
|
|
7
|
+
#(filter) dimension=status type=equal
|
|
8
|
+
source: orders is duckdb.table('data/orders.csv') extend {
|
|
9
|
+
primary_key: id
|
|
10
|
+
|
|
11
|
+
measure:
|
|
12
|
+
order_count is count()
|
|
13
|
+
|
|
14
|
+
view: by_given_region is {
|
|
15
|
+
where: region = $target_region and amount >= $min_amount
|
|
16
|
+
aggregate: order_count
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Gated variant for authorize-interaction coverage: only $role = 'admin' may
|
|
21
|
+
// read it. Shares the givens above so we can prove the name-vs-value-vs-authorize
|
|
22
|
+
// status matrix on a gated source.
|
|
23
|
+
#(authorize) "$role = 'admin'"
|
|
24
|
+
source: gated_orders is duckdb.table('data/orders.csv') extend {
|
|
25
|
+
primary_key: id
|
|
26
|
+
|
|
27
|
+
measure:
|
|
28
|
+
order_count is count()
|
|
29
|
+
|
|
30
|
+
view: by_given_region is {
|
|
31
|
+
where: region = $target_region and amount >= $min_amount
|
|
32
|
+
aggregate: order_count
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -30,7 +30,7 @@ let portCounter = 0;
|
|
|
30
30
|
|
|
31
31
|
// True mutex: the previous Promise-based lock had a TOCTOU race where two beforeAll hooks
|
|
32
32
|
// could both pass the `if (initializationLock)` check and run setup concurrently, sharing
|
|
33
|
-
// one publisher.db / server singleton and causing flaky
|
|
33
|
+
// one publisher.db / server singleton and causing flaky tool calls and port collisions.
|
|
34
34
|
const e2eSetupMutex = new Mutex();
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -23,8 +23,8 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
23
23
|
let env: McpE2ETestEnvironment | null = null;
|
|
24
24
|
let mcpClient: Client;
|
|
25
25
|
|
|
26
|
-
const ENVIRONMENT_NAME = "
|
|
27
|
-
const PACKAGE_NAME = "
|
|
26
|
+
const ENVIRONMENT_NAME = "examples";
|
|
27
|
+
const PACKAGE_NAME = "storefront";
|
|
28
28
|
|
|
29
29
|
beforeAll(async () => {
|
|
30
30
|
// Setup the E2E environment (starts server, connects client)
|
|
@@ -49,8 +49,8 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
49
49
|
arguments: {
|
|
50
50
|
environmentName: ENVIRONMENT_NAME,
|
|
51
51
|
packageName: PACKAGE_NAME,
|
|
52
|
-
modelPath: "
|
|
53
|
-
query: "run:
|
|
52
|
+
modelPath: "storefront.malloy",
|
|
53
|
+
query: "run: order_items->{ aggregate: c is count() }",
|
|
54
54
|
},
|
|
55
55
|
});
|
|
56
56
|
|
|
@@ -97,9 +97,9 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
97
97
|
const params = {
|
|
98
98
|
environmentName: ENVIRONMENT_NAME,
|
|
99
99
|
packageName: PACKAGE_NAME,
|
|
100
|
-
modelPath: "
|
|
101
|
-
sourceName: "
|
|
102
|
-
queryName: "
|
|
100
|
+
modelPath: "storefront.malloy",
|
|
101
|
+
sourceName: "order_items", // Added sourceName
|
|
102
|
+
queryName: "top_products",
|
|
103
103
|
};
|
|
104
104
|
|
|
105
105
|
// Expect RESOLUTION with success
|
|
@@ -142,8 +142,8 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
142
142
|
const params = {
|
|
143
143
|
environmentName: ENVIRONMENT_NAME,
|
|
144
144
|
packageName: PACKAGE_NAME,
|
|
145
|
-
modelPath: "
|
|
146
|
-
query: "run:
|
|
145
|
+
modelPath: "storefront.malloy",
|
|
146
|
+
query: "run: order_items->{BAD SYNTAX aggregate: order_count is count()}",
|
|
147
147
|
};
|
|
148
148
|
|
|
149
149
|
// Application Error (Malloy Compilation): Expect RESOLUTION with isError: true
|
|
@@ -181,9 +181,9 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
181
181
|
const params = {
|
|
182
182
|
environmentName: ENVIRONMENT_NAME,
|
|
183
183
|
packageName: PACKAGE_NAME,
|
|
184
|
-
modelPath: "
|
|
185
|
-
query: "run:
|
|
186
|
-
queryName: "
|
|
184
|
+
modelPath: "storefront.malloy",
|
|
185
|
+
query: "run: order_items->{aggregate: c is count()}",
|
|
186
|
+
queryName: "top_products",
|
|
187
187
|
};
|
|
188
188
|
|
|
189
189
|
// Expect RESOLUTION because the error is thrown *inside* the handler
|
|
@@ -209,7 +209,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
209
209
|
const params = {
|
|
210
210
|
environmentName: ENVIRONMENT_NAME,
|
|
211
211
|
packageName: PACKAGE_NAME,
|
|
212
|
-
modelPath: "
|
|
212
|
+
modelPath: "storefront.malloy",
|
|
213
213
|
// Missing query AND queryName
|
|
214
214
|
};
|
|
215
215
|
|
|
@@ -237,7 +237,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
237
237
|
// Missing modelPath
|
|
238
238
|
environmentName: ENVIRONMENT_NAME,
|
|
239
239
|
packageName: PACKAGE_NAME,
|
|
240
|
-
query: "run:
|
|
240
|
+
query: "run: order_items->{aggregate: c is count()}",
|
|
241
241
|
};
|
|
242
242
|
|
|
243
243
|
// Protocol Error (Caught by Zod/MCP): Expect REJECTION
|
|
@@ -259,8 +259,8 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
259
259
|
const params = {
|
|
260
260
|
environmentName: ENVIRONMENT_NAME,
|
|
261
261
|
packageName: "nonexistent_package", // Use a package that doesn't exist
|
|
262
|
-
modelPath: "
|
|
263
|
-
query: "run:
|
|
262
|
+
modelPath: "storefront.malloy",
|
|
263
|
+
query: "run: order_items->{aggregate: c is count()}",
|
|
264
264
|
};
|
|
265
265
|
|
|
266
266
|
// Application Error (Service Layer): Expect RESOLUTION with isError: true
|
|
@@ -304,7 +304,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
304
304
|
environmentName: ENVIRONMENT_NAME,
|
|
305
305
|
packageName: PACKAGE_NAME,
|
|
306
306
|
modelPath: "nonexistent_model.malloy", // Use a model that doesn't exist
|
|
307
|
-
query: "run:
|
|
307
|
+
query: "run: order_items->{aggregate: c is count()}",
|
|
308
308
|
};
|
|
309
309
|
|
|
310
310
|
// Application Error (Service Layer): Expect RESOLUTION with isError: true
|
|
@@ -364,8 +364,8 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
364
364
|
arguments: {
|
|
365
365
|
environmentName: ENVIRONMENT_NAME,
|
|
366
366
|
packageName: PACKAGE_NAME,
|
|
367
|
-
modelPath: "
|
|
368
|
-
query: "run:
|
|
367
|
+
modelPath: "storefront.malloy",
|
|
368
|
+
query: "run: order_items->{aggregate: c is count()}",
|
|
369
369
|
},
|
|
370
370
|
}),
|
|
371
371
|
).rejects.toThrow();
|
|
@@ -377,8 +377,8 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
377
377
|
const params = {
|
|
378
378
|
environmentName: ENVIRONMENT_NAME,
|
|
379
379
|
packageName: PACKAGE_NAME,
|
|
380
|
-
modelPath: "
|
|
381
|
-
queryName: "
|
|
380
|
+
modelPath: "storefront.malloy",
|
|
381
|
+
queryName: "top_products", // Nested view, but sourceName is missing
|
|
382
382
|
};
|
|
383
383
|
|
|
384
384
|
// Expect RESOLUTION with error because it's invalid usage processed by the handler
|
|
@@ -405,7 +405,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
|
405
405
|
const errorPayload = JSON.parse(errorJsonText);
|
|
406
406
|
// Expect error about the query/view itself not found
|
|
407
407
|
expect(errorPayload.error).toMatch(
|
|
408
|
-
/Query '
|
|
408
|
+
/Query 'top_products' not found|Reference to undefined object 'top_products'/i,
|
|
409
409
|
);
|
|
410
410
|
expect(Array.isArray(errorPayload.suggestions)).toBe(true);
|
|
411
411
|
});
|
|
@@ -34,32 +34,20 @@ describe.serial("MCP Transport Tests (E2E Integration)", () => {
|
|
|
34
34
|
|
|
35
35
|
describe("Basic Protocol", () => {
|
|
36
36
|
it(
|
|
37
|
-
"should handle a simple
|
|
37
|
+
"should handle a simple listTools request",
|
|
38
38
|
async () => {
|
|
39
39
|
if (!env) throw new Error("Test environment not initialized");
|
|
40
|
-
const result = await mcpClient.
|
|
40
|
+
const result = await mcpClient.listTools();
|
|
41
41
|
// Assert based on actual successful response structure
|
|
42
|
-
expect(result).toHaveProperty("
|
|
43
|
-
expect(Array.isArray(result.
|
|
42
|
+
expect(result).toHaveProperty("tools");
|
|
43
|
+
expect(Array.isArray(result.tools)).toBe(true);
|
|
44
|
+
expect(
|
|
45
|
+
result.tools.some((t) => t.name === "malloy_executeQuery"),
|
|
46
|
+
).toBe(true);
|
|
44
47
|
},
|
|
45
48
|
{ timeout: 30000 },
|
|
46
49
|
);
|
|
47
50
|
|
|
48
|
-
it("should receive InvalidParams error when calling a known method with invalid params", async () => {
|
|
49
|
-
if (!env) throw new Error("Test environment not initialized");
|
|
50
|
-
expect.assertions(2);
|
|
51
|
-
try {
|
|
52
|
-
// Use the client from the test environment
|
|
53
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
-
await mcpClient.readResource({} as any); // Force invalid params
|
|
55
|
-
} catch (error) {
|
|
56
|
-
expect(error).toBeInstanceOf(Error);
|
|
57
|
-
// Check error code and message
|
|
58
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
-
expect((error as any).code).toBe(ErrorCode.InternalError);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
51
|
it("should receive MethodNotFound error for unknown methods", async () => {
|
|
64
52
|
if (!env) throw new Error("Test environment not initialized");
|
|
65
53
|
expect.assertions(2); // Expecting error instance and specific code
|
|
@@ -84,18 +72,6 @@ describe.serial("MCP Transport Tests (E2E Integration)", () => {
|
|
|
84
72
|
});
|
|
85
73
|
|
|
86
74
|
describe("Connection Management & Bridge Logic", () => {
|
|
87
|
-
// DELETED - Test irrelevant in stateless mode
|
|
88
|
-
// it("should maintain session across multiple sequential requests", async () => {
|
|
89
|
-
// ...
|
|
90
|
-
// });
|
|
91
|
-
|
|
92
|
-
// DELETED - Test irrelevant in stateless mode
|
|
93
|
-
// it("should handle reconnection after close", async () => {
|
|
94
|
-
// ...
|
|
95
|
-
// });
|
|
96
|
-
|
|
97
|
-
// --- Bridge Error Handling ---
|
|
98
|
-
|
|
99
75
|
it("should return 405 Method Not Allowed for GET requests", async () => {
|
|
100
76
|
if (!env) throw new Error("Test environment not initialized");
|
|
101
77
|
const getUrl = new URL(`${env.serverUrl}/mcp`); // No session ID needed
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP E2E coverage for `given:` runtime parameters on POST .../query:
|
|
5
|
+
* givens retarget query results, unknown/mistyped givens surface as 400 (not
|
|
6
|
+
* 500), givens compose with filterParams, a `null` given value is an
|
|
7
|
+
* explicit SQL-NULL override, and a numeric-string value is accepted for a
|
|
8
|
+
* `number`-typed given (Malloy's own coercion). Malloy is the single validator:
|
|
9
|
+
* an unknown name or bad value throws a `runtime-given-*` error that
|
|
10
|
+
* model.ts maps to a 400 (see getQueryResults).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
import { type RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const ENV_NAME = "query-givens-env";
|
|
20
|
+
const PKG = "query-givens";
|
|
21
|
+
const MODEL = "model.malloy";
|
|
22
|
+
|
|
23
|
+
type Row = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
describe("givens forwarding on /query (HTTP E2E)", () => {
|
|
26
|
+
let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
|
|
27
|
+
let baseUrl: string;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
env = await startRestE2E();
|
|
31
|
+
baseUrl = env.baseUrl;
|
|
32
|
+
const fixtureDir = path.resolve(__dirname, "../../fixtures/query-givens");
|
|
33
|
+
const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
name: ENV_NAME,
|
|
38
|
+
packages: [{ name: PKG, location: fixtureDir }],
|
|
39
|
+
connections: [],
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
if (!createRes.ok) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Failed to create test environment (${createRes.status}): ${await createRes.text()}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const deadline = Date.now() + 30_000;
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
const res = await fetch(
|
|
50
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PKG}`,
|
|
51
|
+
);
|
|
52
|
+
if (res.ok) break;
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
|
|
59
|
+
method: "DELETE",
|
|
60
|
+
}).catch(() => {});
|
|
61
|
+
await env?.stop();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const query = (body: Record<string, unknown>) =>
|
|
65
|
+
fetch(
|
|
66
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PKG}/models/${MODEL}/query`,
|
|
67
|
+
{
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
sourceName: "orders",
|
|
72
|
+
queryName: "by_given_region",
|
|
73
|
+
compactJson: true,
|
|
74
|
+
...body,
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
async function rows(res: Response): Promise<Row[]> {
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
const body = (await res.json()) as { result: string };
|
|
82
|
+
return JSON.parse(body.result) as Row[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it("retargets rows via a given override (EU vs default US)", async () => {
|
|
86
|
+
const res = await query({ givens: { target_region: "EU" } });
|
|
87
|
+
const r = await rows(res);
|
|
88
|
+
expect(r.length).toBe(1);
|
|
89
|
+
expect(Number(r[0].order_count)).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns 400 for an unknown given name, naming it in the message", async () => {
|
|
93
|
+
// Malloy rejects the unknown name (`runtime-given-unknown`, with a
|
|
94
|
+
// did-you-mean hint) at prepare time; model.ts maps that to a 400 and
|
|
95
|
+
// forwards Malloy's message, which names the given.
|
|
96
|
+
const res = await query({ givens: { NOtaGiven: 1 } });
|
|
97
|
+
expect(res.status).toBe(400);
|
|
98
|
+
const body = (await res.json()) as { message: string };
|
|
99
|
+
expect(body.message).toContain("NOtaGiven");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns 400 for a value-type mismatch on a number-typed given", async () => {
|
|
103
|
+
// "min_amount" is declared `:: number`; a non-numeric string can't be
|
|
104
|
+
// coerced, so Malloy rejects it at prepare time (`runtime-given-*`),
|
|
105
|
+
// surfaced as a 400 via the run try/catch in model.ts, not a 500.
|
|
106
|
+
const res = await query({ givens: { min_amount: "not-a-number" } });
|
|
107
|
+
expect(res.status).toBe(400);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("composes givens with filterParams", async () => {
|
|
111
|
+
// target_region=EU narrows to ids 4,5,6; filterParams status=active
|
|
112
|
+
// narrows further to ids 4,6 -> order_count=2.
|
|
113
|
+
const res = await query({
|
|
114
|
+
givens: { target_region: "EU" },
|
|
115
|
+
filterParams: { status: "active" },
|
|
116
|
+
});
|
|
117
|
+
const r = await rows(res);
|
|
118
|
+
expect(r.length).toBe(1);
|
|
119
|
+
expect(Number(r[0].order_count)).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("omitting givens entirely uses the declared defaults (US baseline)", async () => {
|
|
123
|
+
const res = await query({});
|
|
124
|
+
const r = await rows(res);
|
|
125
|
+
expect(r.length).toBe(1);
|
|
126
|
+
expect(Number(r[0].order_count)).toBe(3);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("a null given value is an explicit SQL-NULL override", async () => {
|
|
130
|
+
// `region = NULL` is never true in SQL, so this should return zero
|
|
131
|
+
// matching rows rather than falling back to the 'US' default.
|
|
132
|
+
const res = await query({ givens: { target_region: null } });
|
|
133
|
+
const r = await rows(res);
|
|
134
|
+
expect(r.length).toBe(1);
|
|
135
|
+
expect(Number(r[0].order_count)).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("accepts a numeric string for a number-typed given", async () => {
|
|
139
|
+
// Guards against Malloy's numeric-string coercion for `number` givens
|
|
140
|
+
// regressing; "5" should be accepted just like the literal 5.
|
|
141
|
+
const res = await query({ givens: { min_amount: "5" } });
|
|
142
|
+
const r = await rows(res);
|
|
143
|
+
expect(r.length).toBe(1);
|
|
144
|
+
expect(Number(r[0].order_count)).toBe(3);
|
|
145
|
+
});
|
|
146
|
+
});
|